Skip to content

Security — pattern-level posture and threat model

Type: Reference The framework's consolidated security-posture document at the pattern level: threat model + behavioural MUSTs + pragmatic stance + decisions log. Other-language ports (TypeScript, Fable, PureScript, Scala.js, Kotlin/JS, Squint, Melange / ReScript / Reason) read this doc as contract. The CLJS reference's concrete names, numbers, and stub semantics live in implementation/SECURITY.md — the impl-side companion (per Ownership §implementation/SECURITY.md and the external-canonical-home rule allowing external canonical homes for impl-level concerns, recorded at README §Canonical homes outside /spec).

How to read this doc

This doc is a pattern-level coordination layer. Each category below names a class of concern, names the behavioural defense the framework owns, and cross-references the owning Spec section + the bead where the decision was recorded. The behavioural detail lives in the owner; this doc names what is defended, where the defense lives, and why the call was made — in language-agnostic terms.

Language-agnostic versus CLJS-reference. A pattern-level statement reads: "sensitive values must default-redact at trace, MCP, and log boundaries." The CLJS reference's binding of that obligation reads: "re-frame.core/elide-wire-value walks the tree and substitutes :rf/redacted for declared-sensitive leaves." This doc carries the first form; implementation/SECURITY.md carries the second. A TypeScript port re-binds the second to elideWireValue and uses this doc as the contract for whether the binding is correct.

Six sections:

  1. Threat model + scope — what the framework defends and what is explicitly out of scope.
  2. Categories — input validation, XSS at output boundaries, CRLF injection, privacy / secrets, DoS by input, MCP tool authority, editor URI allowlist, file-path boundaries, production gates.
  3. Catalogue references — pointers (not duplication) into Conventions.md and Spec 009 for the :rf.error/* / :rf.warning/* rows, reserved config slots, and :sensitive? / :large? meta keys.
  4. Trust-boundary matrix — surface-keyed index: each row pins the owner doc, default, opt-in escape, implementation hook, conformance coverage, and downstream tool consumers for one trust boundary. The matrix is a projection of §Categories above; it does not invent normative claims.
  5. Pragmatic stance — the working principle that governs every call below.
  6. Decisions log — bead IDs + one-line behavioural rationales for every concrete call. The full implementation-side audit trail (38 beads with named functions, numeric defaults, stub semantics) lives in implementation/SECURITY.md §Decisions log.

The mirror to this doc is Ownership.md — Security here names what is defended; Ownership names where the defense's contract lives. Where the two overlap (the :sensitive? slot, the wire-elision walker, the keyword-interning cap), Ownership wins on the "who owns the surface" question; Security names the threat the surface defends against.

Threat model + scope

re-frame2 is a framework. The trust boundaries it owns are the surfaces the framework emits, parses, renders, or mediates. The trust boundaries applications own — authentication, authorisation, deployment hardening, network egress policy, user-code input validation — are out of scope by design. Framework defenses compose with application defenses; the framework does not pretend to substitute for them.

What the framework defends

  • Framework-emitted traces, error records, and MCP wire surfaces. The trace bus (009), the always-on error-emit substrate (009 §What IS available in production), the always-on event-emit substrate (009 §Privacy / sensitive data in traces), and the MCP servers' wire pipelines (re-frame2-pair-mcp, story-mcp per Tool-Pair.md) all carry framework-shaped payloads to consumers that may forward off-box. Sensitive values must not default-leak; oversize values must elide deterministically; the wire vocabulary must be stable across servers.
  • Framework-parsed input. Managed-HTTP response-body decoders (014 §Decoding) parse JSON / EDN / custom-decoded bodies into host values. The hydration boundary (011 §Payload scope) parses serialised app-db on the client. Schema-driven boundary validation (010 §Boundary-validation seam) gates structured-input surfaces. Each parse site is an attacker-controllable input under hostile-upstream / supply-chain compromise threat models.
  • Framework-rendered output. Server-side rendering (011) emits HTML strings — render-tree → DOM, with attribute values, text content, and JSON-LD payloads inlined into a script context. Each emission position has different escaping rules; mixing them is the XSS vector. Response-shape fx (:rf.server/set-header, :rf.server/redirect, :rf.server/set-cookie) produce header strings on the wire boundary.
  • Framework-mediated dispatch. The router accepts events, drains them through registered handlers, runs the effect-resolution dominoes, and commits app-db. Cascading dispatch, depth-exceeded recovery, and the :on-error substrate are runtime concerns the framework owns.
  • Framework tooling authority. The MCP servers ship tools that read and mutate live runtime state (get-app-db, dispatch, restore-epoch!, replace-app-db, and the qualitatively-different eval-cljs). The authority model — which tools require explicit gates, which assume "I invoked the tool" as consent — is a framework decision.

What is explicitly out of scope

  • Application authentication and authorisation. The framework does not ship a login flow, a session store, an authz policy engine, or a permission model. Apps build these on top, typically as a feature with its own slice of app-db and its own events. The framework's only auth-adjacent surface is :rf/server-init cofx-injection for SSR (011 §Cofx injection at boot), which is a plumbing hook, not an auth surface.
  • Deployment configuration. TLS termination, reverse-proxy hardening, CDN edge rules, web application firewalls, network segmentation. These are operations concerns; the framework does not configure them and cannot enforce them.
  • User-code input validation beyond what the framework offers. Schemas (010) provide a declarative validator the framework runs at registered boundaries; the framework does not pretend that every user surface is automatically validated. Apps that read untrusted input through paths the framework does not gate are responsible for their own validation.
  • Network-level concerns. Same-origin policy, CORS preflight, certificate pinning, mTLS — these ride the host platform's networking stack (browser fetch, JDK HttpClient on the CLJS reference; the host's equivalent on other ports). The framework classifies CORS rejections as a failure category (014 §Classification order) but does not configure CORS.
  • Supply-chain integrity of host dependencies. Package signatures on the host's package manager are outside framework scope.
  • Third-party egress in dev tooling. Documented; not gated. Story's QR generator hits a third-party endpoint; axe-core loads from a public CDN. These are dev-tool conveniences with documented egress; apps that want to bundle locally do so on the user side.

The split mirrors 000 §Goals — the framework optimises for AI-implementable from the spec alone, which means the security surface is the surface the spec normatively pins. Concerns outside that surface are real but live in adjacent domains.

Categories

Each category names the concern, the behavioural defenses re-frame2 ships, and cross-references the owning spec section + the bead where the decision was recorded. The CLJS-reference binding of each behaviour — named function, numeric default, exact error keyword — lives in implementation/SECURITY.md.

Input validation / boundary parsing

The framework parses attacker-controllable bytes at three boundaries: managed-HTTP response bodies (014 §Decoding), the SSR hydration payload's deserialisation on the client (011 §Payload scope), and the schema-driven boundary-validation seam (010 §Boundary-validation seam). Each is a point where a compromised upstream / hostile partner / corrupted edge could submit shaped bytes intended to crash the parse, mint state, or amplify load.

  • Bounded keyword (or symbol) interning on structured-data decode. Any decoder that converts incoming object keys into a host-language symbol type that is interned into a process-global table (Clojure keywords on the JVM; symbols in TS/F#/PureScript when the host caches them) must cap the number of unique keys interned per request. Overflow surfaces a structured decode-failure category, not an opaque host error. The default cap is documented per host; the per-request override slot allows opt-up where legitimate payloads exceed the default. Per 014 §Keyword-interning cap.
  • Decoder bounds-checks on hand-rolled paths. Any port that ships a hand-rolled JSON / EDN reader (rather than depending on a hardened third-party parser) bounds-checks unicode-escape sequences and surfaces structured :rf.error/malformed-json :reason slots — truncated / invalid escapes do not become opaque host errors. The CLJS reference removed its hand-rolled fallback in favour of a hardened third-party dep; ports that ship a hand-rolled reader own the bounds-check contract. Per 014 §Decoding.
  • Boundary-validation seam. Schema-driven validation interceptors run at event / sub / fx boundaries and emit :rf.error/schema-validation-failure on mismatch. Apps that want shape-correctness on every wire-edge ingress install the validator at the boundary they care about. Per 010 §Boundary-validation seam.

XSS at output boundaries

The SSR HTML emitter renders the host render-tree to a string that crosses the trust boundary into a browser. Three emission positions have different escaping rules — text nodes, attribute values, and raw-script bodies (<script> for JSON-LD, <style>) — and the emitter must apply the position-appropriate escape at every leaf. The host's client-side adapter has the parallel surface: render-tree attrs land on React createElement props (or equivalent for the host's React binding).

  • JSON-LD <script> body escape. String values inlined into a <script type="application/ld+json"> body have every < re-encoded as &lt; so an attacker-supplied substring cannot close the script context. Per 011 §XSS at output boundaries.
  • Attribute-key escape (not just value). Attribute keys (not just values) are escaped at the SSR emitter so an attacker-controlled key (a registered view receiving keyed data) cannot break out of the attribute namespace. Per 011 §XSS at output boundaries.
  • Event-handler-prop filter + reserved-prop-keys gate at static-markup emission. SSR static-markup emission strips on* event-handler props and function-valued props at attribute-emit time, matching react-dom/server behaviour. Reserved prototype keys (__proto__, constructor, prototype) are dropped before they reach the underlying host's createElement-equivalent. Closes both the event-handler-injection vector and the prototype-pollution path on the client. Per 011 §XSS at output boundaries.

CRLF injection at HTTP-response boundaries

SSR response-shape fx produce header strings on the wire. A CRLF (\r\n) embedded in a header value would split the header into adjacent header lines, allowing a response-splitting attack (injection of a new header or even a second response body). The fail-fast policy is "reject at fx-handler time, no strip-and-warn" — silent normalisation would mask bugs and let through downstream-encoded attacks.

  • :rf.server/set-header / :rf.server/append-header fail-fast. Header values containing \r or \n throw the fx with :rf.error/header-invalid-value and the rejecting fx-id in :tags. No strip-and-warn semantic; the caller's bug surfaces immediately. Per 011 §Standard fx.
  • :rf.server/redirect Location-header fail-fast. The Location: header is a header value subject to the same CR/LF/NUL check; a CR, LF, or NUL in the redirect target surfaces :rf.error/redirect-invalid-location. That header-splitting gate (RFC 7230 §3.2.4) is the only gate on the caller-trusted path — no structural URL-shape check is applied. The caller-trusted :rf.server/redirect fx accepts arbitrary URL strings without allowlist, relative-only, or URL-shape gating (a raw space or other RFC 3986 shape quirk every browser accepts in a Location header passes through); caller-untrusted strings (a ?next= query param) use the open-redirect-mitigating :rf.server/safe-redirect (below), which owns URL-shape and origin validation. Per 011 §Standard fx.
  • :rf.server/set-cookie per-attribute CRLF check. Set-Cookie's attribute fields (:domain, :path, :name, :value, :max-age, :same-site) are individually checked for CRLF before the host adapter serialises the cookie line. Apps build cookies as host-data values; the per-attribute check protects against attacker-supplied attribute values flowing into a user-id field, a domain string, or a path that re-enters the header line as CRLF-bearing payload. Per 011 §Cookie shape.

Open-redirect mitigation

The caller-trusted :rf.server/redirect fx accepts any URL string — appropriate for internal-only redirects the app composes from trusted data. Caller-untrusted redirect strings — typically a ?next= URL parameter — need stronger gating to prevent the open-redirect class of attack (an attacker-controlled URL parameter that redirects the user off-origin to a phishing page).

  • :rf.server/safe-redirect ships alongside :rf.server/redirect. Validation order: (1) URL must parse — fails surface :rf.error/safe-redirect-invalid-url; (2) reject javascript: / data: / vbscript: schemes — surfaces :rf.error/safe-redirect-scheme-rejected; (3) :relative-only? true and the URL has a host — surfaces :rf.error/safe-redirect-host-disallowed (:reason :relative-only-violation); (4) :allow [...] allowlist mismatch — surfaces :rf.error/safe-redirect-host-disallowed (:reason :not-in-allowlist); (5) on pass, populates :redirect (same shape as :rf.server/redirect). Per 011 §Standard fx.

Trusted shell hook contract

The SSR host-adapter's default HTML envelope (CLJS reference: re-frame.ssr.ring/default-html-shell for non-streaming, default-streaming-prefix + default-streaming-suffix for streaming SSR) exposes four caller-trusted convenience opts that split by injection position. The two content-position opts are injected RAW into the rendered HTML response: :head (verbatim HTML inside <head>...</head>) and :body-end (verbatim HTML before </body>) — free-form HTML content has no single-correct escape. The two attribute-value-position opts are escape-attr-escaped at the shell: :script-src (the bootstrap-script URL written into <script src="...">) and :app-element-id (the id of the <div> wrapping the rendered body) — an attribute-value position HAS a single correct escape, so escaping (&/" only, lossless) is position-correct and stops a stray quote in a trusted-but-quote-bearing value from breaking out of the attribute. These are all TRUSTED STRINGS in the same sense :rf.server/redirect's :location is caller-trusted — the framework names the boundary, validates the structural shape, encodes the attribute-value opts for their position, and points at structured alternatives; the content trust itself is the caller's. Apps that wire any of the four from untrusted input (a CMS field, a tenant-admin form, a query-string parameter) accept an arbitrary-script-injection XSS vector via the RAW content opts — the attribute-value escaping is structural-correctness, not a content sandbox.

  • The four opts are NAMED as trusted-string surfaces at the spec level. Per 011 §Trusted shell hook contract. The naming closes the documented-vs-undocumented gap surfaced by (parent finding): the four had always been trusted strings in practice, but apps wiring any from untrusted input had no spec-level signal that they were opting into an XSS vector. The trust call itself remains the caller's.
  • Construction-time structural-shape validation. The framework MUST validate at handler-construction time that each of the four, if supplied, is a string (or nil). Non-string non-nil values (a map, a vector, a symbol, a number) surface :rf.error/ssr-trusted-shell-opt-invalid at boot — the structural mistake is caught before the first request rather than as a ClassCastException deep in the rendering path. Per 011 §Trusted shell hook contract.
  • Structured alternatives for untrusted-customization use cases. Apps offering admin- or tenant-configurable shell customization MUST NOT wire the raw input through these four opts. The structured alternatives are reg-head for head fragments (011 §Head/meta contract — head models are derived from app-db through registered fns; the SSR emitter applies position-appropriate escaping at every leaf), reg-view* + :rf.server/* fx for body content (the standard SSR emitter path applies attribute / text-node / raw-script escaping per §XSS at output boundaries), and :rf.server/set-header for header-shaped customization (CRLF-checked, schema-validated). Per 011 §Trusted shell hook contract.

Privacy / secret handling

Cross-reference: Privacy.md — the cross-artefact inventory of every privacy + data-classification surface (frame-owned + per-slot-schema + registration-owned classification, HTTP denylists, the projection-side epoch :redact-fn, the cross-MCP filters) and the composition order from handler exit to off-box wire. This section pins the pattern-level posture; Privacy.md pins where each primitive lives + what runs when.

Sensitive values — credentials, session tokens, PII, partner secrets — flow through the framework on three paths: the trace surface (:rf.error/* events carry failing values verbatim by default), direct reads against app-db from MCP tools (get-app-db, get-path), and the always-on error-emit / event-emit substrates that forward to hosted observability back-ends. Each path must default-project and offer explicit opt-in. Per the graduated EP-0015 (final) model, classification is owner-declared — frame config owns durable app-db classification (reg-frame :sensitive {:app-db …} / :large, replacing the removed add-marks / set-marks / declare-sensitive-*! surfaces); per-slot :sensitive? / :large? schema props own owner-local schema'd data (machine :data-schema, resource :data-schema / :params-schema, HTTP :decode); registration metadata :sensitive / :large owns transient payloads. Projection is centralized at trust boundaries via project-egress (the record-level primitive) over elide-wire-value (the low-level walker, the canonical emission site for the :rf/redacted / :rf.size/large-elided sentinels — API.md §wire-elision walker), under a named :rf.egress/* profile per boundary; sinks consume already-projected records only. The cross-artefact inventory and composition order live in Privacy.md.

Direct-read privacy posture for sub-cache and get-path

Direct-read tools — get-app-db, get-app-db-diff, get-machine-state, get-path, sub-cache — bypass the trace surface where classification stamping operates. The framework's MUST is that direct-read wire egress routes the returned value through project-egress / the wire-elision walker at the off-box boundary, with sensitivity opt-in defaulting to OFF (:rf.egress/off-box-tool redacts; :rf.egress/local-raw is the explicit trusted-local opt-in). The trace-surface scrub (the internal redaction interceptor + :sensitive? stamping) shapes trace :db-before / :db-after slots; it does not protect a live-value read. Per Tool-Pair §Direct-read privacy.

Epoch privacy posture — raw in-process records vs projected egress

The epoch artefact (per Tool-Pair §Time-travel and Spec-Schemas §:rf/epoch-record) records, per drain-settle, an :rf/epoch-record carrying :db-before, :db-after, the raw :trace-events vector, and the :sub-runs / :renders / :effects projections. Three distinct surfaces consume these records: the per-frame ring buffer (epoch-history), the register-epoch-listener! listener fan-out, and any tool that egresses a record off-box (Xray-MCP watch-epochs, Story / pair recorders, hosted post-mortem dashboards). Each surface has a different trust posture and a different default.

The pattern-level MUSTs:

  • The in-process ring buffer carries the raw record. (rf/epoch-history frame-id) returns records with raw :db-before / :db-after / :trace-events so on-box devtools (Xray panel, REPL, in-process restore via restore-epoch!) can reason about exact state. Restore semantics depend on the raw :db-after; redacting the ring would break time-travel. The ring is a dev-only in-process surface — it is gated on the production-elision flag (re-frame.interop/debug-enabled?) and elides entirely in optimised production builds. The "raw" posture is bounded by the gate.
  • Listener fan-out delivers the raw record by default; off-box forwarders project at egress. register-epoch-listener! callbacks fire in the same process the ring lives in — many on-box consumers (a same-process Xray panel reading the live tree, an in-process REPL forwarder) need the raw record to do useful work. The framework MUST therefore deliver the raw record to listeners by default, and MUST NOT silently project it (a silent projection would break Xray's diff visualiser and the on-box restore-epoch! driver). Listener authors that forward off-box (an MCP server pushing to a remote IDE, a hosted observability shipper) MUST project at the egress boundary using the projected-record helper named below — the framework publishes the helper as the single normative projection emission site, parallel to elide-wire-value for direct reads.
  • Off-box egress MUST be projected. Any tool that egresses an epoch record over an MCP wire, an HTTP forwarder, a log shipper, or any process boundary MUST route the record through the projected-record helper before egress, with the off-box defaults (:include-sensitive? false, :include-large? false). The projected-record helper is built on top of the wire-elision walker (the single normative emission site for :rf/redacted and :rf.size/large-elided, per API.md §wire-elision walker); per-tool reimplementation of the projection is prohibited. The trusted-local opt-ins route THROUGH the projection, never around it. A tool that honours an operator's :include-sensitive opt-in (the --allow-sensitive-reads two-key gate) MUST thread it as the :include-sensitive? true egress opt INTO the projected-record helper — it MUST NOT disable the projection and ship the raw record. :include-sensitive? lifts ONLY the app-db sensitive axis; the orthogonal :include-fx-args? / :include-runtime-db? / :include-large? axes and the app :redact-fn advanced override all stay at their fail-closed defaults regardless of :include-sensitive. A full raw epoch escape (every axis) is the explicit per-axis combination of those opts on the projected-record helper, NOT an :include-sensitive-implied bypass — there is no axis-conflating raw escape hatch. The structured :effects rows' :args slot is payload-bearing — it carries the raw fx-handler argument (an HTTP request body, a dispatched event vector, a payment map) captured verbatim from the :rf.fx/args trace tag; it is NOT routed through the marks-projection chokepoint at emit time and is NOT rooted at the frame's app-db, so the schema-path-keyed walker cannot prove any value safe. Off-box egress therefore fails closed: the projection MUST redact each :effects row's :args to :rf/redacted by default (every outcome — :ok, :skipped-on-platform, :error — including the no-such-fx / handler-exception rows whose args are never pre-redacted), preserving the value-free :fx-id / :outcome / :error-trace metadata. A trusted-local caller MAY opt the raw args back in with :include-fx-args? true (orthogonal to the app-db :include-sensitive? / :include-large? opt-ins — fx args are a different keyspace). The projection is uniformly idempotent under both substitution kinds — re-projecting an already-projected record returns a structurally-equal value at every substituted slot. The sensitive case holds because :rf/redacted is a non-matchable scalar; the large case holds because the wire-elision walker is marker-aware and passes a pre-existing :rf.size/large-elided marker through unchanged at the still-declared path. A forwarder pipeline that accidentally double-projects (middleware composition, tool-then-watcher fan-out) MUST NOT drift the wire shape. Per Tool-Pair §Time-travel.
  • Trace retention bounds the in-process surface. The :trace-events slot can be large (raw cascade traces accumulate per drain). The framework MUST offer a finite retention cap — the most-recent N records keep :trace-events; older records keep the cheap structured projections (:sub-runs / :renders / :effects) but drop the raw stream. The cap is configurable via (rf/configure! {:epoch-history {:trace-events-keep N}}); the runtime SHOULD ship a sensible finite default so a long-running dev session does not accumulate unbounded raw traces in heap. Production builds elide the entire ring; the retention cap is a dev-side memory-bound, not a security boundary against an attacker.
  • App-supplied redact-fn (projection-side advanced override). Apps that record material the schema-driven projection cannot prove (a non-schema-declared sensitive slot) MAY install a :redact-fn on the epoch-history configure key — (rf/configure! {:epoch-history {:redact-fn (fn [record] …)}}). Per EP-0015 §15 (Epoch Redaction) + open-issue 6 disposition (RULED, hardened) the hook is projection-side only: the framework MUST invoke the fn once per record at the off-box egress boundary — inside the projected-record helper, after the frame/profile project-egress projection — and MUST NOT mutate the record at storage time. The in-process ring buffer and every register-epoch-listener! listener therefore deliver the raw record: post-EP-0010 epoch records are causal replay material, and mutating them at rest would corrupt the replay contract (not merely restore fidelity). Ordinary redaction needs only the frame's :sensitive? / :large? classification, which the projection already applies; the override is the rare advanced escape. The :rf.epoch/sensitive? rollup is computed from the raw record's schema-declared sensitive leaves at build-time, so it remains an accurate off-box-branch signal on the raw ring record. A throwing :redact-fn emits :rf.warning/epoch-redact-fn-exception at projection time and falls back to the projected (frame/profile-redacted) record — neither the drain nor the egress is broken, and the raw ring record is untouched. The override is composable with the projected-record helper (idempotent under :rf/redacted sentinels) and shares the universal re-frame.interop/debug-enabled? gate. Because it runs only on the projected egress copy, it cannot affect restore-epoch! fidelity — the ring stays raw, so the former build-time caveat is gone by construction. Per Tool-Pair §Time-travel.
  • Sensitive rollup at the record level. When any path the record carries (in :db-before, :db-after, :trigger-event, or :trace-events) overlaps a schema-declared sensitive slot ({:sensitive? true}) for the record's frame, the record carries a top-level boolean rollup (:rf.epoch/sensitive? true). Listener fan-out, off-box egress, and recorder consumers branch on the rollup the same way they branch on :sensitive? at the trace-event surface (per §Behavioural MUSTs across the privacy surface). The rollup is computed from the same schema-declared [:rf.runtime/elision :sensitive-declarations] registry the wire-elision walker consults — one reader, one declaration source. (That registry lives in the frame's durable runtime-db partition per EP-0001, not the retired app-db :rf/runtime root.)

The split is the same shape as §Direct-read privacy posture: on-box raw is fine inside the dev gate; off-box egress defaults projected.

Behavioural MUSTs across the privacy surface

  • Per-path elision on the always-on error path. The always-on error-emit substrate (per 009 §What IS available in production) survives production builds. The substrate projects each error record (via project-egress under :rf.egress/public-error / :rf.egress/off-box-observability, populated from the frame's classification and per-slot schema :sensitive? props) before fanning out — sensitive failing values at marked paths land at hosted error monitors as the redaction sentinel, not as verbatim secrets. (The legacy handler-meta :sensitive? annotation has been removed; the owner-classification mechanism is the graduated Spec 015 / EP-0015 model.)
  • Recordable coeffects MUST NOT carry secrets (EP-0017). A recordable coeffect (EP-0017, final) is a host fact folded into durable frame-state — written into the :rf.cofx envelope map, recorded with the causal token, and re-presented verbatim by replay (the discipline: durable state folds facts, never reads002 §The recordable-coeffect rule). That durability is the threat model for credentials: crypto-grade randomness, tokens, nonces, session ids, and key material MUST NOT be minted or carried as recordable coeffects. Recording a secret does not make it safe; it makes it durable — copied into every recording, fixture, and exported trace. Secrets are generated at the edge and handled by guarded runtime mechanisms, off the ledger (EP-0010's exclusion, restated at this surface). Because app-owned reg-cofx suppliers mint arbitrary values, this is a normative review discipline + lint, not a structural guarantee — the enforcement surfaces are the guide's secrets material and EP-0017's "durable state folds facts, never reads" lint (a durable-writing handler declaring an ambient-grade id). Complementarily, every other :rf.cofx leaf follows the same EP-0015 projection / redaction rules as event-arg values (:rf/time-ms alone is classified always-safe): exclusion is the load-bearing rule for secrets; projection is the safety net for the merely private. Per EP-0017 §Security Considerations; see Privacy §Recordable coeffects must exclude secrets.
  • Schema-validation-failure redaction. When a :rf.error/schema-validation-failure carries a failing slot whose schema props declare :sensitive? true, the emit-site replaces :value, :received, :explain, :fx-args (on :where :fx-args emissions), and :query-v (on :where :sub-return emissions) with the redaction sentinel before the trace event ships. Per 010 §:sensitive? — privacy in schema-validation error traces.
  • Recorder redact-but-record. Story / pair recorders honour :sensitive? by dropping the event payload (or marking it redacted) rather than refusing to record. The slot survives so the dev can correlate the cascade; the payload is scrubbed. Per 009 §Privacy / sensitive data in traces.
  • Registration-owned :sensitive + per-slot schema meta — the two classification composition sites. (a) registration metadata :sensitive [paths] on reg-event classifies event-payload paths; the router's internal redaction interceptor scrubs them on the trace surface before emit, while the handler body still sees the unredacted value via the regular :event coeffect (the public positional redact-interceptor is removed per EP-0015 §7 — its underlying fn survives as internal router plumbing only); (b) per-slot schema :sensitive? true props (on :data-schema / :params-schema / :decode) redact at the validation emit-site and drive the per-path elision walker on the always-on substrates. Per API.md §Privacy and 009 §Privacy / sensitive data in traces.
  • include-sensitive? vs show-sensitive? verb split. Knobs that govern wire egress out of the process use the include-sensitive? verb; knobs that govern on-box devtools UI visibility use show-sensitive?. Both default to suppress. Per Conventions §Privacy config-knob naming.
  • :dropped-sensitive indicator on MCP returns. Every MCP tool response that walked a tree-typed payload carries an integer :dropped-sensitive count when the walker dropped at least one leaf. The agent sees "the payload was filtered" without re-inferring from absence. Per Conventions §Reserved indicator slots.

Author guidance for exceptions under path-level :sensitive?

The owner-classification mechanism in 015 §Scope projects at six observation surfaces — trace-bus emit, Xray / Story panels, MCP / tool wire transport, AI/LLM context, hosted log sinks, and the epoch-export / SSR-hydration / public-error / HTTP-diagnostic / schema-validation boundary — by walking known data shapes (event arg-maps, app-db, sub outputs, fx inputs, cofx values, machine :data, flow outputs) at the egress boundary. The framework does NOT walk exception messages or ex-data maps automatically to redact values that originated from sensitive paths. Per 015 §Out of scope, the contract is a leak-prevention overlay on observability, not a taint-tracking system; once a sensitive value has been concatenated into a string or assigned to an author-keyed slot in an arbitrary ex-data map, no path the walker can resolve survives.

The author MUSTs:

  • Do NOT interpolate sensitive-path values into exception messages. A (throw (ex-info (str "User " email " failed login") {...})) over a sensitive [:user :email] slot lands the raw email in :exception-message on the resulting :rf.error/handler-exception trace event. The trace event's top-level :sensitive? rollup fires (because some leaf in the record overlapped a marked path) and off-box shippers drop the whole event — but the on-box dev surfaces (Xray Event Detail, the re-frame2-pair-mcp surface under :show-sensitive? true, story scenarios saved for replay) render the message verbatim.
  • Do NOT assign sensitive-path values to ex-data map keys. ex-data keys are author-chosen ({:user/email "..."}) and have no relationship to the path-marked declarations the walker consults. The walker cannot infer that :user/email in ex-data corresponds to [:user :email] in app-db. The author SHOULD either substitute :rf/redacted at sensitive ex-data keys at assembly time, or omit the keys entirely.
  • Prefer naming the category of failure in the exception message. The dev consuming the trace needs to know what went wrong; the whose data was involved answer lives on the (correctly redacted) :db-before / :db-after slots, not the message string. A category-only message ("Invalid credentials") plus :dispatch-id correlation closes the gap with no author-side helper required.

Common-case safety. A handler that reads a sensitive-path value, computes derived state, writes back to app-db, and emits a downstream trace event will see :db-after redacted by the wire-elision walker at emit time. The exception path is the gap: the residual leak surface is the intersection of the handler read a sensitive path AND the handler threw with that value in the message or ex-data. Author guidance and a worked example (the safe-throw helper convention) lives in docs/guide §24.08 — Exceptions under :sensitive?.

DoS by input

Attacker-controllable inputs that the framework will process unconditionally — JSON bodies, HTTP request reads, dispatch cascades — must have bounded resource consumption. "Bounded" means an explicit upper limit (size, time, depth) the framework enforces, with a structured failure category on overflow.

  • Keyword-interning cap (DoS variant). The same cap that defends against the integrity threat above (§Input validation) also defends against the DoS threat: a compromised partner submitting N-unique-key payload-per-response would permanently burn N slots in the host's interned-symbol table on long-running processes (SSR JVMs are the worst case). The managed-HTTP JSON decode surface carries an explicit :rf.http/max-decoded-keys cap (default 10000 unique keys per parse; overflow raises :rf.error/malformed-json :reason :too-many-keys) because it genuinely keywordizes partner / webhook JSON object keys against a schema — a real attacker-controlled interning vector (and 014 §Keyword-interning cap). The routing query-string surface needs no cap: match-url keywordizes only keys named by the route's declared :query / :query-defaults / :query-retain vocabulary and passes every undeclared key through as a string, so a hostile URL of N-unique undeclared keys interns zero keywords (and 012 §Keyword-interning cap on query keys + values). The two surfaces are not symmetric — selective keywording already provides on the routing side what the HTTP cap provides — and routing has no route-too-many-keys error.
  • Per-attempt wall-clock timeout default. Managed-HTTP requests apply a per-attempt wall-clock timeout when :timeout-ms is absent. A slow-loris upstream that never finishes the body would otherwise pin a host-promise / future indefinitely; in a long-running process the in-flight registry fills until the connection pool is exhausted. Two explicit opt-outs (:timeout-ms nil / :timeout-ms 0) carry deliberate caller intent — the caller signals "I genuinely need unbounded." Per 014 §:timeout-ms security defaults.
  • Drain-depth-exceeded atomic rollback. The run-to-completion drain has a depth limit; on overflow the runtime emits :rf.error/drain-depth-exceeded with :tags {:depth :queue-size :last-event}, halts the cascade atomically (no partial app-db commit), and surfaces the failure to :on-error. A handler that recursively dispatches itself cannot DoS the dispatch loop; the failure surfaces as a bounded error rather than a stack overflow. Per 009 §Error event catalogue :rf.error/drain-depth-exceeded.

MCP tool authority and isolation

The MCP servers (re-frame2-pair-mcp, story-mcp) ship tools that read and mutate live runtime state. Two qualitatively-different authority classes: named mutations (dispatch, restore-epoch!, replace-app-db, get-app-db, get-path) — known, addressable, framework-shaped operations the user invoked the tool to perform; and arbitrary code execution (eval-cljs, or the host's equivalent eval surface) — the operator can run anything the host process can. The two classes get different gates.

  • Named-mutation tools — no extra approval gate. Invoking a re-frame2-pair-mcp tool is itself the explicit consent. Dispatching an event through dispatch, rewinding via restore-epoch!, injecting via replace-app-db does not require a per-call confirmation prompt. The programmer must be able to use these tools with low friction; an approval gate per mutation would make the tools unusable as a debugging surface. Per Tool-Pair §MCP tool authority classes.
  • Arbitrary-eval ships ENABLED; --no-eval launch-flag opt-OUT. Arbitrary-eval is qualitatively different — the operator can run anything the host process can. But a default-OFF eval gate adds no protection separable from the localhost bind and --allow-writes: eval expresses every write those gates already cover, so disabling it by default would only add friction to the REPL primitive of a pair-debug session without closing a separable attack surface. The published re-frame2-pair-mcp therefore ships with the eval surface ENABLED; the load-bearing remote-attack protection is the localhost bind below. The operator opts OUT at server launch via an explicit --no-eval launch flag for the rare paranoid case (CI runs, shared dev environments). Per Tool-Pair §MCP tool authority classes.
  • MCP servers default to localhost-bind. Published MCP servers bind to a loopback address by default; remote access is an explicit launch-flag opt-in. Per Tool-Pair §MCP servers default localhost-bind.
  • Wire payloads run through the wire-elision walker. Every tool response that walks tree-typed payloads runs the framework's wire-elision walker with off-box defaults (:include-sensitive? false, :include-large? false) before egress. Per Tool-Pair §Direct-read privacy and Conventions §Reserved indicator slots.
  • Per-session cache keyed on app-db root identity. MCP-server per-session caches that summarise app-db across calls are keyed on the actual app-db identity (a root hash or equivalent). Cache invalidation rests on identity equality — cache poisoning by mismatched session is structurally impossible. Per Tool-Pair §Per-session app-db cache.

Editor URI scheme allowlist

Pair-tool source-map surfaces produce clickable links in dev tooling — IDE protocol URIs that open a file at a line. Custom editor templates ({:custom "vim://%s:%d"} and similar) let devs route to JetBrains, Sublime, Emacs, org-tooling, etc. An attacker-controllable scheme — javascript:, data:, vbscript: — that landed in the editor template would be clicked, opening an attack surface in the dev's browser. The minimum gate is a scheme-rejection list, not an allowlist; everything other than the three known-bad schemes passes through.

  • Reject javascript: / data: / vbscript: schemes. The editor-template surface rejects URIs whose scheme matches the three known-bad schemes. Everything else (vim:, idea:, subl:, org:, vscode:, cursor:, …) passes, no dev burden. Per Tool-Pair §Editor URI scheme allowlist.

File-path boundaries

Implementation tooling that writes to the filesystem (test fixtures, scaffolding, conformance-corpus emit sites) accepts env-var overrides for path roots. A misconfigured env var pointing at the home directory or / would let a tool's "rm temporary scratch" step delete the wrong tree. The defense is a path-policy check that constrains every writing tool to implementation/ and examples/; an escape attempt fails fast with a clear error.

  • Path-policy env var constrained to implementation/ + examples/. Every writing tool that accepts an env-var path override checks the resolved path against the allowed prefix list; an escape (../, an absolute path outside the allowed roots, a symlink that resolves out) surfaces a clear error. Documented as a CI-internal knob, not a stable public interface.

Production gates

The trace surface and dev-only instrumentation must elide in production builds — both for performance (no trace allocation in the hot path) and for security (dev-side enrichment slots — :dispatch-id, source-coord, retain-N ring buffer — would otherwise leak to production-bound consumers). The framework owns two parallel gates: one for the browser host (a compile-time constant the optimising compiler folds away — e.g. goog.DEBUG on CLJS / Closure) and one for the long-running JVM/server host (a runtime-read environment variable / system property read once at startup). Other ports realise the equivalent on their host's build system.

  • Production-elision contract on the optimising-compiler host. Every dev-only emit site sits inside a compile-time boolean gate. With the gate folded false in optimised builds, the optimising compiler DCEs the dependent allocation: trace maps, listener iteration, schema-validation calls, error-reason string assembly, the Performance API bridge. Verified by a production-elision conformance gate. Per 009 §Production builds.
  • Runtime production gate (long-running JVM / server posture). The runtime gate reads its dev-flag once at process startup from a system property / env var. SSR / webhook receivers / long-running JVMs that face untrusted input set the flag false explicitly, eliminating the dev-side enrichment surface in production. Per 009 §JVM builds.
  • Always-on substrate is the production-survivable surface. Three always-on substrates survive both production gates: the per-frame :on-error slot, the event-emit listener surface, the error-emit listener surface (per 009 §What IS available in production). These are the only paths registered listeners may rely on at production runtime; the trace surface is dev-only by construction. Listener registration sites SHOULD use the compile-time gate as belt-and-braces alongside the user's explicit config flag.

Catalogue references

This section pins the cross-references into Conventions.md and Spec 009 — the catalogue of security-relevant reserved namespaces, error categories, warning categories, and meta keys lives in those docs; this doc names the security relevance and points there.

Security-relevant :rf.error/* rows (catalogue: 009 §Error event catalogue)

Error keyword Owning spec Security relevance
:rf.error/header-invalid-value 011 CRLF injection in :rf.server/set-header / :rf.server/append-header / :rf.server/set-cookie
:rf.error/redirect-invalid-location 011 CRLF / malformed-URL injection in :rf.server/redirect
:rf.error/safe-redirect-invalid-url 011 Caller-untrusted redirect — URL fails to parse
:rf.error/safe-redirect-scheme-rejected 011 Caller-untrusted redirect — javascript: / data: / vbscript: scheme
:rf.error/safe-redirect-host-disallowed 011 Caller-untrusted redirect — :relative-only? violation or :allow allowlist mismatch
:rf.error/ssr-trusted-shell-opt-invalid 011 Structural-shape gate on the four trusted-shell-hook opts (:head, :body-end, :script-src, :app-element-id); non-string non-nil value at handler-construction time
:rf.error/schema-validation-failure 010 Boundary input validation; sensitive-slot redaction at emit-site
:rf.error/drain-depth-exceeded 009 Dispatch-depth DoS via runaway recursion
:rf.error/handler-exception 009 Production-survivable error path; :sensitive? redaction at the always-on substrate
:rf.error/no-such-handler 009 Surfaces tampered dispatch / route / frame lookups
:rf.error/malformed-json 014 Hostile JSON input — truncated / invalid unicode escapes, structurally invalid bodies
:rf.http/decode-failure 014 Closes the keyword-interning-cap overflow path (:reason :too-many-keys) and the decode failure surface
:rf.http/cors 014 Cross-origin rejection classified (Option a — see decisions log); retry policy decides per category
:rf.http/timeout 014 Slow-loris defense — the per-attempt 30s default surfaces as this category

Security-relevant :rf.warning/* rows (catalogue: 009 §Error event catalogue)

Warning keyword Owning spec Security relevance
:rf.warning/epoch-redact-fn-exception 009 A user-supplied projection-side epoch :redact-fn threw while projecting an epoch record at off-box egress; the runtime isolates the failure and falls back to the (frame/profile-projected) record — the raw ring record is never mutated (EP-0015 issue 6). Fails closed: a throwing override never leaks a raw record. (The pre-EP-0015 :rf.warning/sensitive-without-redaction advisory, which keyed off the removed redact-interceptor, no longer exists — registration-owned :sensitive + frame-owned classification are the v2 mechanism.)
:rf.error/no-frame-context 002 / 009 A frame-scoped op carried no frame stamp and ran under no scope — surface for lost/tampered frame context (EP-0002; replaces the retired :rf.warning/dispatch-from-async-callback-fell-through-to-default, which described a now-impossible silent fall-through to :rf/default). The structured error fails closed and carries capture-site ancestry.

Security-relevant reserved namespaces (catalogue: Conventions §Reserved namespaces)

Namespace Owning spec Security relevance
:rf.error/* 009 Structured error vocabulary; every security-relevant error rides here
:rf.warning/* 009 Structured warning vocabulary
:rf.http/* 014 Failure-taxonomy keys, decode-keys cap, classification categories
:rf.server/* 011 Response-shape fx (the CRLF-check site)
:rf.size/* 009 Size-elision wire markers, low-level walker policy flags (:rf.size/include-sensitive?, :rf.size/include-large?, :rf.size/include-digests?)
:rf.egress/* 015 EP-0015 egress-policy namespace: :rf.egress/profile (the per-boundary projection profile) and its closed value enum (:rf.egress/off-box-observability / off-box-tool / local-redacted / local-raw / ssr-hydration / public-error); plus :rf.egress/output-sensitivity derived-output declassification
:rf.observe/* 015 EP-0015 observation-record-kind namespace: :rf.observe/handled-event, :rf.observe/error — the production observation records frame :observability routes
:rf.elision/* 009 Wire-egress sentinel-handle namespace ([:rf.elision/at <path>] re-fetch handle inside the :rf.size/large-elided marker). The elision declaration registry itself lives in the durable runtime-db partition at [:rf.runtime/elision] (declarations, sensitive-declarations) per Conventions §Reserved runtime-db keys.
:rf.cofx / :rf.cofx/* 002 / EP-0017 Recordable-coeffect envelope map + the :rf.cofx/requires declaration key. Security-relevant: leaves project per EP-0015, and secrets are excluded from the recordable grade entirely
:rf/redacted 009 The single sentinel keyword for sensitive-value substitution; reserved at the keyword level

Security-relevant reserved config slots (catalogue: Conventions §Reserved fx-ids and surface tables)

Slot Owning spec Security relevance
:rf.http/max-decoded-keys 014 Per-request keyword-interning cap; host default in implementation/SECURITY.md §Numeric defaults
:timeout-ms (managed-HTTP) 014 Slow-loris defense; nil / 0 deliberately opt out
:rf.size/threshold-bytes API §wire-elision walker Wire-elision size cap
:rf.size/include-sensitive? API §wire-elision walker Off-box wire-egress sensitivity opt-in
:rf.size/include-large? API §wire-elision walker Off-box wire-egress oversize opt-in
Runtime dev-flag (host-specific env/property) 009 §JVM builds Long-running-host production-elision gate; set false in SSR / webhook deployments
Compile-time dev-flag (host-specific constant) 009 §The mechanism Optimising-compiler DCE gate for the trace surface

Security-relevant meta keys

Meta key Position Owning spec Security relevance
:sensitive / :large (path map) reg-frame meta ({:app-db [...]} / {:http {...}}) 015 §Frame-owned durable classification Frame-owned durable app-db egress policy + frame-local HTTP carrier names — the owner of durable app-db classification (EP-0015). Replaces the removed add-marks / set-marks / declare-sensitive-*! surfaces.
:sensitive / :large (path vector) registration meta (reg-event / reg-sub / reg-fx / reg-cofx / reg-flow) 015 §Registration-owned transient classification Transient-payload classification — paths into the registration's primary data shape, projected at trust boundaries. Empty path [[]] marks the whole shape. (:rf.cofx leaves follow the same rules; secrets are excluded entirely — see §Behavioural MUSTs.)
:sensitive? / :large? per-slot schema props 010 §:sensitive? — privacy in schema-validation error traces, 015 §Machine-owned Path-targeted privacy declaration on owner-local schema'd data (:data-schema / :params-schema / :decode) — sensitivity is a property of the data value at a path, not of the handler that touched it. Drives the per-path elision walker on the always-on substrates and schema-validation error-trace redaction. (Schema props on an app-db schema are not a public route — the frame owns app-db; the legacy handler-meta :sensitive? annotation has been removed — see 009 §:sensitive? registration metadata key.)
:rf.egress/output-sensitivity reg-sub / reg-flow meta 015 §Declassifying a derived output Derived-output declassification: :rf.egress/inherit (default) | :rf.egress/sensitive | :rf.egress/public. Not :sensitive false. A :public claim is a standing Xray audit surface.
:large? per-slot schema props 009 §Size elision in traces Size-elision nomination (schema metadata only); composes with :sensitive? (sensitive drop wins)
:rf.cofx/requires registration meta (reg-event, machine named entries) EP-0017 / 002 §Recordable coeffects Declares a handler's recordable/ambient coeffect dependencies. Security-relevant because recordable coeffects are durable replay material — secrets MUST NOT be carried as recordable coeffects (EP-0017 §Security Considerations).
:rf.trace/no-emit? registration-meta 009 §Trace-emission opt-out Handler-scoped trace suppression; honoured by the always-on event-emit substrate

Trust-boundary matrix

The matrix is a surface-keyed projection of §Categories above. Each row pins one trust boundary the framework owns; the columns trace the boundary to its owning spec, ship-default, opt-in escape, implementation hook, conformance / test coverage, and downstream tool consumers. The matrix does not introduce normative obligations — every cell sources from the owners cited; this section is the single auditable index so a reader following one thread ("what defends the editor template?") does not have to walk five docs.

Rows are roughly ordered by the §Categories sequence; surfaces that span two categories appear once at the surface that owns the defence.

Surface Owner spec Ship-default Opt-in escape Implementation hook Conformance / test coverage Downstream tool consumers
Trace egress (always-on error / event substrates) 009 §Privacy / sensitive data in traces + 009 §What IS available in production Frame-owned :sensitive {:app-db …} + per-slot schema :sensitive? + registration-owned :sensitive [paths] project at emit (via project-egress over the elision walker); the always-on substrates project before fan-out; :rf.trace/no-emit? handler-scoped trace suppression :include-sensitive? true on hosted-observability forwarder (resolves to :rf.egress/local-raw); remove the classification declaration re-frame.error-emit/* + re-frame.event-emit/*; re-frame.projection/project-egress over re-frame.core/elide-wire-value (internal re-frame.privacy/redact-interceptor plumbing) implementation/core/test/re_frame/error_emit_*_test.cljc; implementation/core/test/re_frame/event_emit_*_test.cljc; npm run test:elision (production-elision gate) Xray trace panel; re-frame-10x; re-frame2-pair-mcp trace-window / watch-epochs; any hosted error monitor (Sentry / Rollbar shim)
Direct app-db read (get-app-db, get-path, snapshot) Tool-Pair §Direct-read privacy + Security §Direct-read privacy Off-box egress MUST route through rf/project-egress / rf/elide-wire-value; defaults :rf.size/include-sensitive? false + :rf.size/include-large? false (the :rf.egress/off-box-tool profile); sensitive drop wins over size marker MCP-call opts :include-sensitive? true / :include-large? true (resolving to :rf.egress/local-raw); declare the writer's path frame-sensitive for depth-in defence re-frame.core/elide-wire-value (single normative emission site); reads the runtime-db [:rf.runtime/elision :declarations] + [:rf.runtime/elision :sensitive-declarations] registries app-side implementation/core/test/re_frame/elision_test.clj (walker behaviour); tools/re-frame2-pair-mcp/test/.../snapshot_test.cljs (get-path + :app-db wrap); cross-MCP :dropped-sensitive indicator re-frame2-pair-mcp get-path-tool (lands); re-frame2-pair-mcp snapshot :app-db slice (lands); story-mcp surfaces inheriting the same posture; any third-party MCP get-path
Sub-cache read ((rf/sub-cache frame-id), MCP snapshot :sub-cache slice) Tool-Pair §Direct-read privacy Same as direct app-db read — wire-elision walker, off-box defaults Same MCP-call opt escape as direct app-db read Same re-frame.core/elide-wire-value hook; sub-cache wrapper lives at the MCP-server emit boundary tools/re-frame2-pair-mcp/test/re_frame2_pair_mcp/cache_test.cljs (cache identity-keying); the :sub-cache slice of re-frame2-pair-mcp snapshot MUST be elide-wrapped per the contract pinned at Tool-Pair L607 re-frame2-pair-mcp snapshot :sub-cache slice; any third-party MCP sub-cache tool MUST land the wrap from day one
Epoch egress (register-epoch-listener! off-box forwarders) Security §Epoch privacy posture + Tool-Pair §Time-travel — Redaction hook + EP-0015 §15 In-process ring carries raw record (causal replay material); listener fan-out delivers raw; off-box egress MUST project via projected-record helper, which routes each tree slot through project-egress under the :rf.egress/off-box-observability profile (:include-sensitive? false + :include-large? false) then applies the :redact-fn advanced override; :rf.epoch/sensitive? rollup computed at build-time from schema-declared leaves on the raw record; finite :trace-events-keep retention cap (rf/configure! {:epoch-history {:redact-fn …}}) (projection-side advanced override — open-issue 6, RULED); cross-MCP :include-sensitive? true opt Projected-record helper built on re-frame.projection/project-egress (over elide-wire-value); re-frame.epoch.assembly/apply-redact-fn (projection-side); re-frame.interop/debug-enabled? production gate implementation/epoch/test/re_frame/epoch_privacy_test.clj; implementation/epoch/test/re_frame/epoch_redact_fn_test.clj; implementation/epoch/test/re_frame/epoch_redact_fn_projection_test.clj; implementation/epoch/test/re_frame/epoch_jvm_prod_gate_test.clj re-frame2-pair-mcp epoch streamers; Story recorders; hosted post-mortem dashboards
MCP named-mutation tools (dispatch, restore-epoch!, replace-app-db) Tool-Pair §MCP tool authority classes + Security §MCP tool authority and isolation No per-call approval gate (invoking the tool IS the consent — see §Pragmatic stance proposition 1); per-session cache keyed on app-db root identity; reserved indicator :dropped-sensitive on tree-walking responses None — gating accidents not theoretical attacks (per Security §Pragmatic stance) tools/re-frame2-pair-mcp/src/re_frame2_pair_mcp/tools/dispatch.cljs; per-session cache in tools/re-frame2-pair-mcp/src/.../session_cache.cljs tools/re-frame2-pair-mcp/test/.../dispatch_test.cljs; tools/re-frame2-pair-mcp/test/.../cache_test.cljs (per-session identity-keying); cross-MCP :rf.mcp/cache-hit wire-marker conformance re-frame2-pair-mcp (canonical); story-mcp; any third-party pair-shaped server
MCP arbitrary-eval tools (eval-cljs) Tool-Pair §MCP tool authority classes + Security §MCP tool authority and isolation Enabled by default (eval is the REPL primitive of a pair-debug session; a default-OFF eval gate does not add a protection separable from --allow-writes because eval expresses every write the writes-gate blocks). The load-bearing remote-attack protection is the localhost-bind below. Launch-flag opt-OUT (--no-eval or per-server equivalent) for the rare paranoid case (CI runs, shared dev environments) tools/re-frame2-pair-mcp/src/re_frame2_pair_mcp/tools/eval_cljs.cljs (default-ON atom; gate flipped OFF by --no-eval at boot) Per-server eval-default-ON unit + conformance test (:eval-cljs/disabled-via-no-eval); live wire-shape gate in tools/mcp-conformance/test/live-re-frame2-pair-subscribe.cjs (boots WITH --no-eval) re-frame2-pair-mcp eval surface; future pair-shaped servers (MAY default ON; SHOULD expose an opt-out flag)
MCP server network bind Tool-Pair §MCP servers default localhost-bind + Security §MCP tool authority and isolation Loopback bind (127.0.0.1 / [::1]); remote access is explicit launch-flag opt-in Launch-flag (per-server --bind / --host equivalent); the load-bearing remote-attack protection given eval-cljs defaults ON tools/re-frame2-pair-mcp/src/re_frame2_pair_mcp/server.cljs bind-address resolution; nREPL middleware localhost note Stdio transport tests in tools/re-frame2-pair-mcp/test/stdio-roundtrip.js; published-skill baseline allowed-tools policy All MCP servers (re-frame2-pair-mcp / story-mcp); published Claude Code skills inheriting the localhost note
Editor URI template (source-mapping clicks) Tool-Pair §Editor URI scheme allowlist + Security §Editor URI scheme allowlist Reject javascript: / data: / vbscript: schemes (case-insensitive) at registration AND at click-resolution; structured warning on rejection; no silent fallthrough to default editor None — the rejection list is the minimum floor (per §Pragmatic stance proposition 2); legitimate IDE schemes pass through without allowlist burden re-frame.dev.editor-template/* (CLJS-reference); equivalent host hook in each port implementation/core/test/re_frame/dev/editor_template_test.cljc (registration + click-resolution checks); editor-scheme-reject fixture in conformance corpus re-frame-pair source-mapping; Xray "open in editor" affordance; re-frame2-pair-mcp source-coord tool; re-frame-10x stack-frame links
File-path boundary (env-var path overrides) Security §File-path boundaries Writing tools constrain resolved path to implementation/ + examples/ prefix list; escape attempts (../, absolute outside roots, symlink escape) fail-fast with clear error None — documented as a CI-internal knob, not a stable public interface (per §Pragmatic stance proposition 2 — accident-mode defence, not adversary defence) Per-tool path-policy check (no shared framework helper — tools each own their writing boundary); re-frame.dev.path-policy/* companion in CLJS reference Per-tool path-policy tests; conformance harness's writing-tool isolation check Story scaffolding emitters; conformance-corpus EDN fixture writers; template tool; any CI-internal writing tool inheriting
SSR trusted shell hooks (:head, :body-end, :script-src, :app-element-id) 011 §Trusted shell hook contract + Security §Trusted shell hook contract Construction-time structural-shape validation — non-string non-nil values surface :rf.error/ssr-trusted-shell-opt-invalid at boot, NOT as a deep ClassCastException; content trust itself is caller-owned For untrusted-customisation use cases (CMS field, tenant-admin form), use the structured alternatives: reg-head for head fragments (position-appropriate escaping at every leaf); reg-view* + :rf.server/* fx for body (standard SSR emitter escapes per §XSS at output boundaries); :rf.server/set-header for headers (CRLF-checked) implementation/ssr-ring/src/re_frame/ssr/ring/shell.clj (default-html-shell); implementation/ssr-ring/src/re_frame/ssr/ring/trust.clj (construction-time gate); implementation/ssr-ring/src/re_frame/ssr/ring/streaming.clj implementation/ssr-ring/test/re_frame/ssr/ring_test.clj; implementation/ssr/test/re_frame/ssr_end_to_end_test.clj; SSR conformance fixture for :rf.error/ssr-trusted-shell-opt-invalid App SSR ring adapter; SSR-loaders pattern docs; downstream SSR ports (other-language hosts)
SSR response-shape fx (set-header, redirect, safe-redirect, set-cookie) 011 §Standard fx + Security §CRLF injection + §Open-redirect mitigation Header values fail-fast on \r / \n; :rf.server/redirect is caller-trusted (internal-only composition); cookie attrs individually CRLF-checked :rf.server/safe-redirect for caller-untrusted URLs — validates URL parse, rejects javascript: / data: / vbscript: schemes, optional :relative-only? + :allow [...] allowlist re-frame.ssr.response/* fx handlers in implementation/ssr/src/re_frame/ssr/response.cljc implementation/ssr-ring/test/re_frame/ssr/ring_test.clj (CRLF cases); :rf.error/safe-redirect-* conformance fixtures; :rf.error/header-invalid-value / :rf.error/redirect-invalid-location fixtures App SSR controllers; auth/redirect plumbing; cookie-setting interceptors
HTTP response-body decode (managed-HTTP, hand-rolled paths) 014 §Decoding + 014 §Keyword-interning cap + Security §Input validation + Security §DoS by input Bounded keyword-interning per request (default 10000 unique keys); structured :rf.error/malformed-json :reason rather than opaque host error; per-attempt wall-clock timeout (default per host, slow-loris defence); CLJS reference uses hardened third-party JSON dep :rf.http/max-decoded-keys per-request override slot; selective keywording against declared schema vocabulary; :timeout-ms nil / :timeout-ms 0 to opt out of timeout (explicit caller intent) implementation/http/src/re_frame/http_decode.cljc; implementation/http/src/re_frame/util_json.cljc; implementation/http/src/re_frame/http_transport.cljc; implementation/http/src/re_frame/http_handlers.cljc implementation/http/test/re_frame/util_json_test.clj; HTTP decode conformance fixtures (:reason :too-many-keys, :reason :truncated-escape, :reason :invalid-escape) App managed-HTTP fx callers; partner / webhook receivers; SSR JVM long-running hosts
URL query parsing (router match-url) 012 §Keyword-interning cap on query keys + values + Security §DoS by input Keyword-interning closed by selective keywording — only keys named by the route's :query / :query-defaults / :query-retain vocabulary are promoted to keywords; every undeclared URL key passes through as a string, so N-unique undeclared keys intern zero keywords (no cap needed). [:enum ...] allowlist gate for :keyword-typed values None — selective keywording is unconditional; authors widen the keyword universe by declaring :query / :query-defaults / :query-retain vocabulary implementation/routing/src/re_frame/routing/registry.cljc (coerce-query) implementation/routing/test/re_frame/routing_registry_test.clj (selective-keywording + string-passthrough tests); routing keyword-discipline conformance fixtures Deep-link consumers; partner referral handlers; share-link processors
Schema-driven boundary validation 010 §Boundary-validation seam + 010 §:sensitive? — privacy in schema-validation error traces :rf.error/schema-validation-failure emit at boundary; :value / :received / :explain / :fx-args / :query-v replaced with :rf/redacted sentinel when failing slot is :sensitive? true None on the redaction (privacy MUST); apps install the validator at the boundary they care about (event / sub / fx) re-frame.schemas/* validator interceptor; emit-site redact in re-frame.error-emit/* implementation/schemas/test/re_frame/schemas/*_test.cljc; sensitive-redaction conformance fixtures Boundary-validator users; SSR hydration validators; HTTP response validators
Production gates (trace / dev-only enrichment elision) 009 §Production builds + 009 §JVM builds + Security §Production gates Compile-time gate (re-frame.interop/debug-enabled?, alias of goog.DEBUG on CLJS) folds false in optimised builds — Closure DCE strips trace allocation, listener iteration, schema-validation calls, source-coord enrichment, retain-N ring; JVM def read once at startup from -Dre-frame.debug / RE_FRAME_DEBUG Three always-on substrates survive both gates: per-frame :on-error, event-emit listeners, error-emit listeners implementation/core/src/re_frame/interop.cljc; the gated branches across re-frame.core / re-frame.epoch / re-frame.schemas npm run test:elision (production-elision gate); implementation/epoch/test/re_frame/epoch_jvm_prod_gate_test.clj; implementation/core/test/.../interop_debug_gate_test.clj Every CLJS build target; every long-running JVM artefact (SSR / webhook / conformance JVMs); hosted observability shippers (consume the three always-on substrates only)

Reading the matrix. Each row's Ship-default column says what the framework gives you for free; the Opt-in escape names the explicit knob a caller turns when the default doesn't fit. The Implementation hook points at the single CLJS-reference helper / fn that realises the default — the place a port re-binds when shipping another language. Conformance / test coverage names the test or fixture that asserts the default holds (so a port has a known-good harness target). Downstream tool consumers names who builds on the surface, so a change to the default has a known blast radius.

What the matrix is not. Not a normative invention — every row's claim already sits in the owning spec named in column 2. Not a replacement for the per-category prose above — the prose carries the why and the threat model; the matrix carries the one-place index. Not implementation-bound — the CLJS-reference column names the binding; other-language ports re-bind without re-deciding.

Forward-looking obligations. The matrix's "Sub-cache read" row carries the single ambiguous future-work item: the :sub-cache slice of re-frame2-pair-mcp's snapshot tool is not yet elide-wrapped (the contract is pinned at Tool-Pair §Direct-read privacy L607). The obligation itself is already normative — a MUST pinned by the Tool-Pair contract; what remains is the CLJS-reference implementation landing the wrap, which is an implementation task tracked against the re-frame2-pair-mcp snapshot surface, not a deferred spec decision. Every other forward-looking obligation in this matrix either lands as a concrete bead or surfaces as an explicit third-party / port obligation (the Implementation hook column names "no shared framework helper — tools each own their writing boundary" for the file-path case; ports re-binding ride the per-language equivalent).

Pragmatic stance

The working principle that governs every call in the decisions log below:

Trust the explicit invoker; gate accidents not theoretical attacks; programmer ergonomics matter; secure by default but not at the cost of friction.

This stance is normative for re-frame2 security decisions and applies whenever a security call has a friction cost. Four propositions:

  1. Trust the explicit invoker. A programmer who launches a pair-tool and calls dispatch is the principal; the act of invoking the tool is the consent. Inserting an "are you sure?" gate per mutation does not improve security — the attacker model where someone else invokes the tool is the wrong threat model for a debugging surface bound to localhost. The right threat model is "the programmer made an accident"; gate accidents not theoretical attacks.
  2. Gate accidents, not theoretical attacks. Concrete examples: the editor URI allowlist rejects javascript: / data: / vbscript: because those are known XSS vectors; it does not gate vim: / idea: / subl: because gating a long-tail of editor URIs would force a list-maintenance burden onto every dev workflow with no actual attack surface mitigated. The path-policy env-var check constrains writes to implementation/ + examples/ because the accident-mode is "env var unset, tool writes to $HOME"; the check is a safety net, not a security boundary against a hostile attacker.
  3. Programmer ergonomics matter. Friction is a tax with a real cost — devs route around painful gates, sometimes by disabling them, often by working around them. A gate that ships disabled because it is friction-heavy provides no security; a gate that is low-friction and enabled by default is worth N theoretically-stricter gates that ship disabled. Arbitrary-eval ships enabled by default precisely on this principle (a default-OFF eval gate adds no protection separable from the localhost bind and --allow-writes, because eval expresses every write those gates already cover); the --no-eval launch-flag opt-OUT is the one-time, transparent, documented escape for the rare paranoid case. The MCP-named-mutation tools shipping ungated is a friction call — frequent use, low harm per call, fast feedback if something goes wrong.
  4. Secure by default but not at the cost of friction. The defaults the framework ships matter — :include-sensitive? defaults false, per-attempt HTTP timeout has a sensible default, keyword-interning cap defaults to a sensible cap, the MCP server defaults to a localhost bind (the load-bearing remote-attack protection given arbitrary-eval defaults ON, opt-out via --no-eval), the optimising-compiler dev-flag defaults false in optimised builds. Each default trades a small friction (the conscious opt-out, the bumped cap) for a real-world default-safe posture. Where a default is friction-free and default-safe, ship it (the SSR app-db side-channel accumulator for response state — invisible to apps, makes the privacy boundary self-enforcing). Where a default would impose burden without proportionate risk reduction, document and move on (third-party egress in story tooling, nested package-manager installs).

This stance was codified across nine policy-review beads in May 2026. Each landed a concrete call against the four propositions; together they established the framework's settled posture, and new security calls are graded against them.

The stance composes with Principles §Regularity over cleverness and Principles §Public query surfaces. Regularity says "one obvious way to do a thing" — there is one wire-elision walker; the canonical privacy marker is :sensitive?; the canonical error vocabulary is :rf.error/*. Public query surfaces says "an agent can ask the runtime, without private-API spelunking" — the :dropped-sensitive / :elided-large indicator slots make filter-events programmatically observable rather than implicit. Security is the application of the principles to attack surfaces; it does not invent new disciplines.

Decisions log

Every concrete security call recorded as a bead, with one-line behavioural rationale. Ordered roughly by category. The bead's bd show <id> carries the full context; this log is the audit trail of "this is the call we made, here is the why." The CLJS reference's binding of each call (named functions, numeric defaults, exact event keywords) lives in implementation/SECURITY.md §Decisions log.

Input validation / DoS

Bead Call Rationale
Bounded keyword-interning on structured-data decode Compromised upstream returning N-unique-key payload-per-response would permanently burn N slots in the host's interned-symbol table; long-running processes (SSR JVMs, webhooks) are the worst case. Cap is the second line of defence — first line is "decode to plain strings" for endpoints that don't need keywordization.
Keyword-interning closed by selective keywording on URL query parse (no cap) Caller-controlled URLs (deep links, partner referrals, malformed share links) flow into match-url's query parser. The keyword-interning DoS is closed at the source: match-url keywordizes only keys named by the route's declared :query / :query-defaults / :query-retain vocabulary and passes every undeclared URL key through as a string, so a long-running SSR JVM hit by N-unique-key URL streams interns zero keywords. An [:enum ...] allowlist gate bounds :keyword-typed values. Unlike the HTTP JSON-decode surface (which genuinely keywordizes object keys against a schema and so carries a cap), the routing surface needs no key cap.
Decoder bounds-check on hand-rolled paths Truncated / invalid hex escapes surface :rf.error/malformed-json with structured :reason instead of an opaque host error. Matters for any port that ships a hand-rolled reader.
CLJS reference: hand-rolled JSON fallback deleted; depend on a hardened third-party JSON dep Removing the fallback eliminates a parser the framework owns and would have to keep hardened. The bounds-check / cap contracts moved to the hardened-dep paths.
Per-attempt wall-clock timeout default; nil / 0 opt out Slow-loris defense against partner / webhook / agent-controlled fetches. Two opt-out values are explicit caller intent (not idiomatic); the call-site author signals "I genuinely need unbounded."
CORS classification (Option a — heuristic emission) Spec-vs-impl drift fix: the :rf.http/cors category was specced but never emitted. Heuristic emission landed on the browser host (TypeError + cross-origin URL); 3 classifier tests + retry-set membership test added.

XSS at output boundaries

Bead Call Rationale
JSON-LD <script> body: escape < as &lt; Standard XSS posture for inline application/ld+json. Attacker-supplied substring cannot close the script context.
Attribute key escape (not just value) Attribute keys are attacker-reachable through registered views receiving keyed data; escape prevents breakout from the attribute namespace.
Strip on* and fn-valued props at static-markup emission; drop reserved prototype-pollution keys before they reach createElement Matches react-dom/server behaviour. Closes both the event-handler-injection vector and the __proto__ / constructor / prototype prototype-pollution path on the client.

CRLF injection

Bead Call Rationale
Headers / redirects fail-fast on CRLF No strip-and-warn — silent normalisation masks bugs and lets through downstream-encoded attacks. :rf.error/header-invalid-value / :rf.error/redirect-invalid-location surface immediately.
Set-Cookie per-attribute CRLF check Attacker-supplied attribute values (user-id flowing into :name, partner-supplied :domain) get the same fail-fast treatment as the top-level header value.
:rf.server/safe-redirect open-redirect mitigation Caller-untrusted redirect strings (?next=… URL parameter) get URL parse + scheme reject + :relative-only? / :allow [...] allowlist gating. Five :rf.error/safe-redirect-* categories surface the rejection path. :rf.server/redirect keeps the caller-trusted contract for internal use.
Trusted shell hook contract — four host-adapter shell opts NAMED as trusted strings + structural-shape validation + structured-alternative recommendation The host adapter's default-shell convenience opts (:head, :body-end, :script-src, :app-element-id) were always trusted strings in practice — injected RAW into the rendered HTML envelope — but the trust semantic was not normatively named, so apps wiring any of them from untrusted input (CMS field, tenant-admin form) had no spec-level signal of the XSS vector. The call (a) NAMES the four as trusted-string surfaces at 011 §Trusted shell hook contract, (b) adds construction-time structural-shape validation (:rf.error/ssr-trusted-shell-opt-invalid rejects maps / vectors / symbols / numbers), and (c) documents the structured alternatives (reg-head, reg-view* + :rf.server/* fx, :rf.server/set-header) for untrusted-customization use cases. Closes the documented-vs-undocumented gap from (parent finding, closed).

Privacy / secret handling

Bead Call Rationale
:sensitive? enforcement on always-on error path (not warning-only) Always-on error-emit substrate survives production builds; a sensitive failing value would land at hosted error monitors as a verbatim secret without substrate-level enforcement.
Schema-validation-failure redacts :value / :received / :explain / :fx-args / :query-v when slot is :sensitive? Schema-validation surfaces typically carry the failing value verbatim — :query-v on the sub-return surface re-leaks the caller-supplied lookup key (typically the same secret material the schema is gating). Without redaction the trace event re-leaks the secret to every registered listener.
Recorder redacts payload but records the slot Drop-the-payload semantics, not refuse-to-record — devs lose useful correlation otherwise. Matches the always-on error-emit substrate's posture.
Direct-read tools MUST run the wire-elision walker; named mutations need no extra gate Direct reads (get-app-db, get-path) bypass the trace surface where the redaction interceptor and :sensitive? stamping operate. The MCP egress is the right boundary for live-value redaction. Named mutations get no extra gate — invoking the tool is the consent.
spec/004-Wire-Pipeline.md aligned to spec/Tool-Pair MUST on direct-read privacy Spec-vs-spec drift resolution: the trace-surface redaction interceptor (the public redact-interceptor is since removed per EP-0015 §7; the internal plumbing remains) shapes trace :db-before / :db-after; it does NOT protect a live-value direct read. Tool-Pair MUST wins.
:sensitive? hoisted from :tags to trace-event top-level Consumers route on top-level :sensitive? rather than (get-in trace-event [:tags :sensitive?]) — flatter access path, cheaper conformance gate.
Same :sensitive? boolean exposed for non-trace consumers One reader, two surfaces — the trace surface and the always-on substrates honour the same predicate.
Epoch ring carries raw records; listener fan-out delivers raw; off-box egress MUST project via the projected-record helper; finite :trace-events-keep retention cap Restore-epoch and on-box devtools depend on the raw record (silent projection would break Xray's diff visualiser and restore-epoch! itself). Off-box egress is the right boundary for the projection — the MCP wire and log shippers route records over a process boundary that MUST default-redact. The retention cap bounds dev-session heap growth without compromising structured-projection coverage. The record-level :rf.epoch/sensitive? rollup mirrors the trace-event :sensitive? stamp so listener consumers branch on one slot.

MCP tool authority

Bead Call Rationale
(part 1) Named-mutation tools ungated; arbitrary-eval is a separate authority class Programmer-friction matters; named mutations are the debugging primitive. Arbitrary-eval is qualitatively different.
re-frame2-pair-mcp arbitrary-eval ships ENABLED; --no-eval launch-flag opt-OUT A default-OFF eval gate adds no protection separable from the localhost bind + --allow-writes (eval expresses every write those gates cover), so default-ON is the friction-free pair-debug primitive; the localhost bind is the load-bearing remote-attack protection. --no-eval is the one-time, transparent, documented opt-out for the rare paranoid case (CI / shared dev environments).
MCP servers default localhost-bind Remote access is explicit opt-in; rules out the casual cross-network reach.
Per-session MCP cache keyed on app-db root identity Cache invalidation is keyed on the actual app-db identity — cache poisoning by mismatched session is structurally impossible.

Editor URI allowlist + file-path boundaries

Bead Call Rationale
Reject javascript: / data: / vbscript: schemes; everything else passes Minimum gate against known XSS vectors; no dev burden for the long-tail of legitimate IDE schemes.
Env-var path-policy constrains writes to implementation/ + examples/ Safety net against env-var-unset accidents (rm -rf $UNSET_VAR/...). Documented as a CI-internal knob, not a stable public interface.

Production gates

Bead Call Rationale
Runtime dev-flag (host-specific env/property) for the long-running-host production gate SSR / long-running posture: explicit dev-flag opt-out, read once at startup. Eliminates the dev-side trace surface in production.
Always-on error-emit substrate (not gated by the dev-flag) The handler-exception path is the primary production-monitoring case; gating it on the dev-flag would eliminate the production observability surface. Dev-side enrichments (:dispatch-id, source-coord) elide with the rest of the trace surface.
Always-on event-emit substrate Sibling to the error-emit substrate; per-event records for hosted observability.
Always-on error-emit listener surface Corpus-wide fan-out per :rf.error/* event to observability shippers; per-listener exceptions isolated. (The per-frame :on-error recovery policy was removed — recovery is framework-owned.)
SSR response accumulator moved to side-channel store (not in app-db) Hydration payload defaults to shipping the whole app-db. Response state (auth cookies, internal X-* headers) in app-db default-leaks; side-channel store makes the privacy boundary self-enforcing.

Pragmatic stance (the nine policy beads)

Bead Call Rationale
Migration skill warn-before-mass-rewrite gate Accident protection — mass rewrite is high-risk; the gate is one warning, not a per-file confirmation.
Retrospective skill: GH-issue routing + shell-safety here-doc pattern Pattern, not a hard gate. Documents the safe shell idiom so future skill authors copy from a vetted example.
Implementor cross-repo announce gate + GH-issue routing Per-repo announce on cross-cutting changes; mirrors the migration-skill posture for implementor-side changes.
Published-skill baseline allowed-tools policy + nREPL localhost note Default-safe published skills; nREPL is documented localhost-only.
Recorder redact-but-record on :sensitive? Pragmatic privacy: scrub the payload, keep the correlation slot.
Keep third-party egress in story tooling (QR endpoint, axe-core CDN); document the egress Bundling axe-core balloons the story bundle for the a11y minority; QR is explicitly user-triggered. Dev-tool conveniences with documented egress, not a security gate worth its friction.
Nested package-manager install during test runs is fine; skip the bootstrap-script restructure Nested-package-manager install is how nested projects work; not a security concern in a dev tool.
Reject only the three known-bad URI schemes in editor templates Minimum gate, maximum dev compatibility.
Env-var path-policy check constrains to implementation/ + examples/ Accident-mode defense, not adversary defense.

Tooling and infrastructure

Bead Call Rationale
Wire-vocab schema + grep conformance gate Cross-MCP namespace pin: every server emits byte-identical :rf.mcp/* / :rf.size/* markers. Drift detector.
:rf.mcp/summary lazy-summary slot Wire-vocabulary pin so the agent sees the summary boundary the same way across servers.
:rf.mcp/dedup-table + :rf.mcp/ref structural dedup Reserved cross-server so the agent pattern-matches the dedup shape uniformly.
Schema walker populates the sensitive-declarations registry at boot Boot-time additive population so the schema-validation emit-site can consult the registry without re-walking.
Original top-level catalogue + threat model + decisions log Same shape pattern as Conventions.md + Principles.md: top-level coordination doc that points at the detail without duplicating it.
Decision: split into pattern-level (this doc) + CLJS-impl (implementation/SECURITY.md) Each doc serves one audience cleanly — a TS implementer reads spec/Security.md; a CLJS contributor reads implementation/SECURITY.md. Per the external-canonical-home rule.
The split executed Keystone bead — unblocks the Security cross-ref cluster + clears spec/Ownership.md for spec-coherence cluster.

Cross-references