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 rf2-0hs5t.3 (a) rule allowing external canonical homes for impl-level concerns).

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, reset-frame-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 (rf2-wu1n5).
  • 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 rf2-263km / rf2-dgsu1.
  • 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 rf2-m5u23 and 011 §HTML emission.
  • 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 rf2-vl8ir.
  • 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 rf2-dwds9.

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 rf2-hbty2 and 011 §Standard fx.
  • :rf.server/redirect Location-header fail-fast. The Location: header is a header value subject to the same CRLF check, plus a structural URL-shape check; a CRLF in the redirect target surfaces :rf.error/redirect-invalid-location. The caller-trusted :rf.server/redirect fx accepts arbitrary URL strings without allowlist or relative-only gating; caller-untrusted strings (a ?next= query param) use the open-redirect-mitigating :rf.server/safe-redirect (below). Per rf2-hbty2.
  • :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 rf2-rpedl and 011 §Cookies.

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 rf2-zfm8v and 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 convenience opts that the framework injects RAW into the rendered HTML response: :head (verbatim HTML inside <head>...</head>), :body-end (verbatim HTML before </body>), :script-src (the bootstrap-script URL written into <script src="...">), and :app-element-id (the id of the <div> wrapping the rendered body). These are TRUSTED STRINGS in the same sense :rf.server/redirect's :location is caller-trusted — the framework names the boundary, validates the structural shape, 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.

  • 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 rf2-briq0 (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 rf2-o6ndb.
  • 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 rf2-o6ndb.

Privacy / secret handling

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-redact and offer explicit opt-in. The :sensitive? declaration is the single normative privacy marker; the canonical emission site for the :rf/redacted sentinel is the framework's wire-elision walker (API.md §wire-elision walker).

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 with-redacted and :sensitive? stamping operate. The framework's MUST is that direct-read wire egress routes the returned value through the wire-elision walker at the off-box boundary, with sensitivity opt-in defaulting to OFF. With-redacted shapes trace :db-before / :db-after slots; it does not protect a live-value read. Per Tool-Pair §Direct-read privacy (rf2-czv3p / rf2-b2hip).

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-cb! listener fan-out, and any tool that egresses a record off-box (Causa-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 (Causa 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-cb! callbacks fire in the same process the ring lives in — many on-box consumers (a same-process Causa 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 Causa'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 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 rf2-fq8ep.
  • 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 (build-time). Apps that record sensitive material into app-db MAY install a :redact-fn on the epoch-history configure key — (rf/configure :epoch-history {:redact-fn (fn [record] …)}). The framework MUST invoke the fn once per assembled record, at build-time (between build-record and ring-append / listener fan-out), so the per-frame ring buffer, every register-epoch-cb! listener, and the projected-record helper all see the same redacted shape — no later listener-fan-out re-derives slots the fn erased. The :rf.epoch/sensitive? rollup is computed from the raw record's schema-declared sensitive leaves before the fn runs, so the rollup remains an accurate signal even when the fn erases the leaves it keyed on. A throwing :redact-fn emits :rf.warning/epoch-redact-fn-exception and falls back to the raw record for that drain only — the drain itself is not broken. The redact-fn is composable with the projected-record helper (idempotent under :rf/redacted sentinels), shares the universal re-frame.interop/debug-enabled? gate (no new production-elision gate), and has one documented caveat: restore-epoch against a redacted :db-after rewinds app-db to the redacted shape — apps wanting restore fidelity should leave :db-before / :db-after alone in their fn and target only :trace-events / :trigger-event. Per Tool-Pair §Time-travel. Per rf2-wp70d.
  • 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/elision :sensitive-declarations] registry the wire-elision walker consults — one reader, one declaration source.

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. Per rf2-j1m7x / rf2-mrsck.

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 runs the per-path elision wire-walker (populated from 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 per rf2-hjs2d; path-marked classification is the v2 mechanism — separate spec doc; in progress.)
  • 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 (rf2-kj51z + rf2-adtp2).
  • 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 rf2-hdadz.
  • with-redacted interceptor + per-slot meta — two composition sites for :sensitive?. (a) the positional with-redacted interceptor scrubs named payload keys before the handler body runs (the handler sees the unredacted value via the regular :event coeffect; the trace surface sees the redacted version); (b) per-slot schema :sensitive? true props redact at the validation emit-site and drive the per-path elision wire-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 path-marked :sensitive? mechanism in 015 §Scope redacts at five observation surfaces — trace bus emit, Causa, MCP wire transport, AI/LLM context, and third-party log sinks — by walking known data shapes (event arg-maps, app-db, sub outputs, fx inputs, cofx injections, machine :data, flow outputs) at emit time. 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 (Causa 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) :app-db-before / :app-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 :app-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?. Per rf2-dv79m.

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 cap applies symmetrically to two attacker-influenceable parse surfaces: managed-HTTP JSON decode (per rf2-wu1n5 and 014 §Keyword-interning cap) and URL query-string parsing in match-url (per rf2-3k3o7 and 012 §Keyword-interning cap on query keys + values). Both default to 10000 unique keys per parse; both raise structured failures (:rf.error/malformed-json :reason :too-many-keys for HTTP; :rf.error/route-too-many-keys for routing); both compose with a selective-keywording policy that limits the keyword universe to the schema's declared vocabulary.
  • 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 rf2-it1cd and 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, reset-frame-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, resetting via reset-frame-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 rf2-czv3p.
  • Arbitrary-eval gated by launch flag, default OFF. Arbitrary-eval is qualitatively different — the operator can run anything the host process can. The published re-frame2-pair-mcp ships with the eval surface DISABLED. The operator opts in at server launch via an explicit launch flag; the default-OFF posture means a stock install cannot execute arbitrary code over MCP. Per rf2-czv3p / rf2-cxx5s.
  • 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 rf2-hpkkx.
  • 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 rf2-3rt1f.

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 rf2-vwcsq.

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. Per rf2-21rfv.

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 and rf2-0la4f / rf2-vnjfg.
  • 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 per rf2-o6ndb
: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/sensitive-without-redaction 009 Registration declares :sensitive? true but the interceptor chain has no with-redacted — emit-time advisory
:rf.warning/dispatch-from-async-callback-fell-through-to-default 009 An async-callback dispatch landed on :rf/default because frame-context binding did not survive — surface for tampered async resolution

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, policy keys (:include-sensitive?, :include-large?)
:rf/elision, :rf.elision/* 009 App-db elision registry (declared-sensitive, declared-large), wire-egress sentinel-handle
: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 (rf2-wu1n5); host default in implementation/SECURITY.md §Numeric defaults
re-frame.routing/default-max-decoded-keys (host const) 012 Per-URL cap on the number of unique query keys match-url will parse (rf2-3k3o7 — symmetric with rf2-wu1n5). Default 10000; overflow throws :rf.error/route-too-many-keys.
: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? registration-meta (reg-event-*, reg-sub, reg-cofx) 009 §Privacy / sensitive data in traces Stamps every trace event in the handler's scope; substrate-level enforcement on always-on paths
:sensitive? per-slot schema props 010 §:sensitive? — privacy in schema-validation error traces Schema-driven privacy declaration; populates [:rf/elision :sensitive-declarations] at boot
:large? registration-meta + per-slot schema props 009 §Size elision in traces Size-elision nomination; composes with :sensitive? (sensitive drop wins)
: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 :sensitive? registration-meta + per-slot schema :sensitive? redact at emit; :rf.trace/no-emit? handler-scoped trace suppression :include-sensitive? true on hosted-observability forwarder; remove :sensitive? declaration re-frame.error-emit/* + re-frame.event-emit/*; re-frame.core/with-redacted interceptor 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) Causa 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/elide-wire-value; defaults :rf.size/include-sensitive? false + :rf.size/include-large? false; sensitive drop wins over size marker MCP-call opts :include-sensitive? true / :include-large? true; per-frame redact via with-redacted on writer (depth-in defence) re-frame.core/elide-wire-value (single normative emission site); reads [:rf/elision :declarations] + [:rf/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); forward-looking sub-cache wrap obligation tracked at rf2-0hert:sub-cache slice of re-frame2-pair-mcp snapshot not yet elide-wrapped, contract pinned at Tool-Pair L607 re-frame2-pair-mcp snapshot :sub-cache slice (pending rf2-0hert); any third-party MCP sub-cache tool MUST land the wrap from day one
Epoch egress (register-epoch-cb! off-box forwarders) Security §Epoch privacy posture + Tool-Pair §Time-travel — Redaction hook In-process ring carries raw record; listener fan-out delivers raw; off-box egress MUST project via projected-record helper with :include-sensitive? false + :include-large? false; :rf.epoch/sensitive? rollup computed pre-:redact-fn from schema-declared leaves; finite :trace-events-keep retention cap (rf/configure :epoch-history {:redact-fn …}) (build-time, one pass per record); cross-MCP :include-sensitive? true opt Projected-record helper built on re-frame.core/elide-wire-value; re-frame.epoch/build-record + :redact-fn invocation; 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_jvm_prod_gate_test.clj re-frame2-pair-mcp epoch streamers; Story recorders; hosted post-mortem dashboards
MCP named-mutation tools (dispatch, restore-epoch, reset-frame-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 (rf2-3rt1f); 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 (MUST follow rf2-czv3p)
MCP arbitrary-eval tools (eval-cljs) Tool-Pair §MCP tool authority classes + Security §MCP tool authority and isolation Disabled by default; launch-flag opt-in (per rf2-cxx5s); compose with localhost-bind for full "stock install can't run remote eval" posture Launch-flag (--allow-eval or per-server equivalent — one-time per launch, transparent) tools/re-frame2-pair-mcp/src/re_frame2_pair_mcp/nrepl.cljs (eval surface, gated on launch flag) Per-server eval-OFF default test re-frame2-pair-mcp eval surface; future pair-shaped servers (MUST default OFF)
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); composes with --allow-eval for two-opt-in remote eval posture 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 (rf2-hpkkx) 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; Causa "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 rf2-21rfv
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 (rf2-dgsu1) :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 Bounded keyword-interning per URL parse (default 10000); structured :rf.error/route-too-many-keys on overflow; selective keywording against route's :query schema; [:enum ...] allowlist gate for :keyword-typed values re-frame.routing/default-max-decoded-keys host const override implementation/routing/src/re_frame/routing.cljc implementation/routing/test/re_frame/routing_test.clj; routing decode 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 (rf2-kj51z + rf2-adtp2) 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). Tracked as rf2-0hert. 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. (rf2-czv3p)
  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. (rf2-vwcsq, rf2-21rfv)
  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. The arbitrary-eval launch-flag-opt-in is friction (you must add a launch flag), but it is one-time, transparent, and a documented opt-in for a qualitatively-different authority class. The MCP-named-mutation tools shipping ungated is a friction call — frequent use, low harm per call, fast feedback if something goes wrong. (rf2-czv3p, rf2-zyoj2, rf2-cxx5s)
  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, arbitrary-eval ships disabled, 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 per rf2-jbcmt). Where a default would impose burden without proportionate risk reduction, document and move on (third-party egress in story tooling per rf2-su313, nested package-manager installs per rf2-o0tpo).

This stance was codified across nine policy-review beads in May 2026 (rf2-cktdt, rf2-80grk, rf2-s6k4i, rf2-hpkkx, rf2-hdadz, rf2-su313, rf2-o0tpo, rf2-vwcsq, rf2-21rfv). 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
rf2-wu1n5 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.
rf2-3k3o7 Bounded keyword-interning on URL query parse Symmetric routing-side companion to rf2-wu1n5: caller-controlled URLs (deep links, partner referrals, malformed share links) flow into match-url's query parser. A long-running SSR JVM hit by N-unique-key URL streams would permanently extend its keyword table; the cap (default 10000) + selective keywording against the route's declared :query schema + an [:enum ...] allowlist gate for :keyword-typed values together bound the surface.
rf2-263km 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.
rf2-dgsu1 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.
rf2-it1cd 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."
rf2-r40km 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
rf2-m5u23 JSON-LD <script> body: escape < as &lt; Standard XSS posture for inline application/ld+json. Attacker-supplied substring cannot close the script context.
rf2-vl8ir Attribute key escape (not just value) Attribute keys are attacker-reachable through registered views receiving keyed data; escape prevents breakout from the attribute namespace.
rf2-dwds9 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
rf2-hbty2 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.
rf2-rpedl 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.
rf2-zfm8v :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.
rf2-o6ndb 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 rf2-briq0 (parent finding, closed).

Privacy / secret handling

Bead Call Rationale
rf2-vnjfg :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.
rf2-kj51z / rf2-adtp2 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.
rf2-hdadz 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.
rf2-czv3p 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 with-redacted 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.
rf2-b2hip spec/004-Wire-Pipeline.md aligned to spec/Tool-Pair MUST on direct-read privacy Spec-vs-spec drift resolution: with-redacted shapes trace :db-before / :db-after; it does NOT protect a live-value direct read. Tool-Pair MUST wins.
rf2-isdwf :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.
rf2-iwqu9 Same :sensitive? boolean exposed for non-trace consumers One reader, two surfaces — the trace surface and the always-on substrates honour the same predicate.
rf2-j1m7x / rf2-mrsck 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 Causa'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
rf2-czv3p (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.
rf2-cxx5s re-frame2-pair-mcp arbitrary-eval ships disabled; launch-flag opt-in Published servers default-safe. One-time per server launch, transparent, documented.
rf2-hpkkx MCP servers default localhost-bind Remote access is explicit opt-in; rules out the casual cross-network reach.
rf2-3rt1f 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
rf2-vwcsq 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.
rf2-21rfv 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
rf2-0la4f 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.
rf2-hqbeh 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.
rf2-rirbq Always-on event-emit substrate Sibling to the error-emit substrate; per-event records for hosted observability.
rf2-bacs4 Always-on error-emit listener surface Corpus-wide fan-out path parallel to per-frame :on-error. Mutually isolated from the per-frame policy fn.
rf2-jbcmt 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
rf2-cktdt Migration skill warn-before-mass-rewrite gate Accident protection — mass rewrite is high-risk; the gate is one warning, not a per-file confirmation.
rf2-80grk 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.
rf2-s6k4i Implementor cross-repo announce gate + GH-issue routing Per-repo announce on cross-cutting changes; mirrors the migration-skill posture for implementor-side changes.
rf2-hpkkx Published-skill baseline allowed-tools policy + nREPL localhost note Default-safe published skills; nREPL is documented localhost-only.
rf2-hdadz Recorder redact-but-record on :sensitive? Pragmatic privacy: scrub the payload, keep the correlation slot.
rf2-su313 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.
rf2-o0tpo 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.
rf2-vwcsq Reject only the three known-bad URI schemes in editor templates Minimum gate, maximum dev compatibility.
rf2-21rfv Env-var path-policy check constrains to implementation/ + examples/ Accident-mode defense, not adversary defense.

Tooling and infrastructure

Bead Call Rationale
rf2-rrnnf Wire-vocab schema + grep conformance gate Cross-MCP namespace pin: every server emits byte-identical :rf.mcp/* / :rf.size/* markers. Drift detector.
rf2-tygdv :rf.mcp/summary lazy-summary slot Wire-vocabulary pin so the agent sees the summary boundary the same way across servers.
rf2-obpa9 :rf.mcp/dedup-table + :rf.mcp/ref structural dedup Reserved cross-server so the agent pattern-matches the dedup shape uniformly.
rf2-c1l4d 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.
rf2-edfhh 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.
rf2-1g6cj 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 rf2-0hs5t.3 (a).
rf2-ao8a2 The split executed Keystone bead — unblocks the rf2-wpo8k Security cross-ref cluster + clears spec/Ownership.md for rf2-exdfg spec-coherence cluster.

Cross-references