Skip to content

Privacy & Data-Classification — cross-artefact reference

Type: Reference Normative status: Supporting companion. Defers to 009-Instrumentation, 010-Schemas, 014-HTTPRequests, 015-Data-Classification, Tool-Pair, Conventions, and Security for every contract surface named here. This doc is the discoverability index — one place to land for "where do privacy primitives live across re-frame2's artefacts, what is the composition order, and what do I declare to keep a value out of off-box egress?"

re-frame2's privacy surface is the leak-prevention overlay on observability. Real data flows through events / cofx / handlers / fx / app-db / subs / views unchanged; sentinel substitution happens only at the observation/egress boundary. The contract spans five artefacts (re-frame.core, re-frame.http, re-frame.schemas, re-frame.epoch, tools/mcp-base) and the owner-classification declaration sources the graduated EP-0015 model fixes — frame config (durable frame-wide facts), per-slot schema props (owner-local schema'd data), and registration metadata (transient payloads) — this doc gathers them into one inventory and pins the composition order.

Graduated to the EP-0015 model. This doc was rewritten to the EP-0015 (final) owner-classification + project-egress + :rf.egress/*-profile model that Spec 015 graduates, and reflects the EP-0017 (final) recordable-coeffect secrets-exclusion rule (see §Recordable coeffects must exclude secrets). The pre-EP-0015 framing — the "seven first-class marking sites", the public add-marks / set-marks app-db path-mark API, schema-attached app-db classification, and the positional redact-interceptor — is superseded; those surfaces are removed (EP-0025 also removed the re-frame.marks namespace itself — the marks projection substrate migrated into the marks-free re-frame.classification / re-frame.elision engine), and where this index links to a sibling spec that still carries the old framing, the link is kept but its description reconciled here. The normative contract lives in Spec 015; this doc is the cross-artefact index.

Posture (per Spec 015 §Posture). Privacy here is observability hygiene, not authorisation. Apps still own auth, authorisation, encryption-at-rest, and transport security. The classification machinery exists so that the framework's own dev-time observability surfaces (and their downstream consumers — log sinks, AI agents, dashboards) cannot accidentally exfiltrate user secrets or stuff log lines with multi-megabyte blobs. See Security.md §Privacy / secret handling for the pattern-level threat model.


Table of contents


The six observation boundaries

Privacy declarations exist to stop leaks at every framework-mediated observation/egress boundary. Per 015 §Scope the complete set, each with the egress profile Spec 015 projects it under:

# Boundary What sees it Profile / production posture
1 Trace-bus emit — every :rf/trace-event built by emit! / emit-error! Trace listeners, Xray panel, error monitors, log sinks Dev stream gated on re-frame.interop/debug-enabled? (the CLJS mirror of goog.DEBUG); the always-on error-emit substrate (009 §Error-emit substrate) survives production
2 Xray / Story panels — Event Detail, App-DB Diff, Subscriptions, Trace, Causality Graph, Machine Inspector, Flow Panel, Story scenarios The on-box dev tool; CLJS-only :rf.egress/local-redacted by default; dev-only (production-elided)
3 MCP / tool wire transporttools/re-frame2-pair-mcp, tools/story-mcp, any future MCP server Off-box LLM / tool consumers :rf.egress/off-box-tool; N/A (tooling, not in the production bundle)
4 AI / LLM context lifted by tools — any code path that lifts trace events / app-db / sub outputs / machine :data into an LLM prompt The hosted LLM endpoint :rf.egress/off-box-tool; N/A
5 Hosted log sinks — Datadog, Sentry, LogRocket, Honeybadger, custom fan-outs; routed by frame :observability Off-box ops/monitoring :rf.egress/off-box-observability; the always-on error-emit substrate is the live path here — it survives goog.DEBUG=false, so sensitive-projection MUST work in production builds
6 Epoch export, SSR / hydration, public error responses, HTTP diagnostics, schema-validation failure records Off-box recorders, the browser, hosted dashboards each its own profile — :rf.egress/ssr-hydration (after the §SSR allowlist), :rf.egress/public-error, :rf.egress/off-box-observability

The contract for the off-box boundaries (3 / 4 / 5 / 6) is project before egress: the runtime projects every record under the owning frame's classification and the boundary's :rf.egress/* profile via project-egress before any sink, tool, or wire sees it; a sink never receives a raw record. On the always-on trace/error substrate, a record carrying :sensitive? true at the top level is dropped by the off-box forwarder. Apps opt back in by passing the off-box-wire opt — the MCP tool argument is the unqualified :include-sensitive (the Anthropic tool-input-schema regex rejects a trailing ?; see tools/mcp-base/spec/sensitive.md §Cross-server arg-vocabulary), which resolves to the :rf.egress/local-raw profile — or, for an on-box panel, the {:show-sensitive? true} UI toggle. The off-box-wire verb family is named include-sensitive? (the ? rides the config-knob verb, not the MCP wire key). See Conventions §Privacy config-knob naming.


What the production bundle itself discloses

Every boundary above is runtime egress — a record leaving the process, projected on its way out. A production JavaScript bundle is a different kind of surface. It is a build artefact, served to and readable by anyone who can fetch the app's JS, and no classification declaration reaches it: project-egress projects records under a frame's classification, and a bundle is neither. Two things ride into it that an author would not necessarily predict. Both are deliberate, both are stated here because nothing else states them, and neither is user data — what they disclose is the builder's environment and the code's own structure.

1. Absolute build-machine source paths, one per macro-driven registration. Per 001 §Production elision contract, source-coord capture has two sinks and only the first is stripped in production. Sink 2 — the always-on error-coord registry — is retained under :advanced + goog.DEBUG=false on purpose, so that Sentry / Honeybadger / Rollbar reports keep source-line information in the builds that actually break. The :file it stores is the absolute on-disk path resolved at macro-expansion time: it is absolutised deliberately (rf2-wvsxg) so the editor-URI builder resolves regardless of the host's :project-root. The consequence, which follows from the contract rather than contradicting it, is that a production bundle carries the compiling machine's directory layout — /home/<user>/…, /Users/<user>/…, C:/Users/<user>/… — as ordinary string literals, once per registration.

A production error record shipped off-box carries one only when the failing id resolves in that registry — that is, a macro-driven registration, looked up under the registry kind the record's error category pivots to. Three shapes resolve nothing and are the common ones: a programmatic registration that bypassed the macro path, an id that was never registered, and a :rf.error/frame-destroyed raised by a (frame) read that found a dead incarnation before any operation ran, which has no component source to name. In each of those the emitter conditionally omits the slot, so the record arrives with no :source-coord key at all rather than one holding nil (the same partial-shape caveat Spec-Schemas §:rf/source-coord-meta records for programmatic registrations). The bundle-level disclosure is unconditional regardless: the paths are string literals in the shipped artefact whether or not any record ever quotes one.

There is no knob and no projection profile for this; the retention is the contract. An organisation for which its own directory layout is sensitive should build in a neutral working directory — a CI checkout under /home/runner/work/… usually already is — rather than expect the framework to relativise a path it promised to keep absolute. Note also that the repository's own scripts/check-no-hardcoded-paths.sh says nothing about this and correctly should not: it gates tracked source files against a personal-name token, so it stays green while a locally-built bundle carries the builder's path. The bundle is generated, and its paths are the builder's rather than the repository's.

2. A compiled declaration's static manifest. A {:compiled true} declaration carries its manifest as inert quoted data on the registry entry, and that data is in the :advanced bundle — the roster keys, the site vocabulary, the grammar tag, and each site's own :source-coord (so a compiled view contributes build-machine paths per site, not only per declaration). This is what makes v/manifest answerable in a production build, and a published door that answers has to carry what it answers with. Docstrings are the boundary: a view's docstring is not in the bundle, so the payload is structure and coordinates rather than prose. See 004D §Static manifests and capability elision — specifically its The manifest ships into production, and that is the door's cost subsection — for the measurement, the sentinels that discriminate, and the owner.

Neither item is a leak of anything a classification declaration could have covered, so neither has a remedy in this document's vocabulary. They are recorded here because "what does a shipped bundle disclose" is a privacy question an author will land on this page to ask, and the honest answer was previously unwritten.


Inventory by artefact

The complete imperative + declarative surface, grouped by owning namespace. Every entry's normative owner lives in the cited Spec section; this table is the index, not the contract.

re-frame.core (production-survivable subset re-exported from artefacts below)

Surface Kind Purpose Owner
:sensitive reg-meta key Vector of paths into the registration's primary data shape (event arg-map, fx-input map, cofx value, sub output, flow output, machine transition payload) 015 §Registration-owned transient classification
:large reg-meta key Symmetric to :sensitive — paths to slots elided with :rf.size/large-elided 015 §Registration-owned transient classification
:sensitive / :large / :clear-sensitive / :clear-large commit-plane effects (reg-event return) The owner of durable app-db classification (EP-0025) — each a vector of :rf/path vectors, applied with the :db write at the commit point into the per-frame elision registry. A handler classifies a durable app-db path by returning these alongside :db. Replaces the removed frame-config :sensitive {:app-db …} annotation and the add-marks / set-marks / declare-sensitive-*! surfaces. 015 §Durable app-db — the four commit-plane effects
subsystem :sensitive / :large subsystem registration (reg-machine, reg-resource, reg-mutation, reg-route) Projection-relative durable classification declared on a subsystem definition (e.g. a machine's :data-rooted :rf/path vectors), re-rooted to the instance's absolute runtime path and unioned into the same per-frame registry the commit-plane effects write. 015 §Subsystem projection-relative classification
:rf.http/managed :carriers reg-fx :rf.http/managed meta App-specific HTTP carrier names (a union onto the immutable built-in defaults) — the EP-0025 transient-payload case. (rf/reg-fx :rf.http/managed {:carriers {:headers […] :query-params […]}} h). 015 §HTTP carriers
frame :observability frame config (make-frame) Observability sink policy. The frame :sensitive key is gone entirely — its :app-db durable block moved to the four commit-plane effects above, and its :http carrier block moved to :rf.http/managed; a frame config :sensitive is rejected fail-loud. 015 §Frame-owned observability sink policy
project-egress record-level boundary primitive (rf/project-egress record opts) → projected record. The public record-level projection primitive (knows app-db-/event-/exception-/HTTP-/summary-shaped slots); the required step before any off-box sink. Delegates per tree-shaped slot to elide-wire-value. New façade export (subject to the facade-export classification rule). 015 §project-egress
register-observability-sink! / unregister-observability-sink! façade fns Register the concrete sink fn against the id a frame's :observability policy names; the sink consumes the already-projected record. New façade exports (subject to the facade-export classification rule). 015 §Frame-owned observability sink policy
sensitive? predicate (rf/sensitive? trace-event) → bool. True iff the event carries :sensitive? true at the top level. The framework-published predicate every forwarder composes against. 009 §Privacy
elide-wire-value walker (rf/elide-wire-value v opts) → walked v. The low-level value walker for tree-shaped values; project-egress delegates to it per tree-shaped slot. Sinks and tools should rarely call it directly. API.md §wire-elision walker, 009 §Size elision
(configure! {:elision ...}) runtime config {:rf.size/threshold-bytes N} — wire-elision size cap. Default 16384. API.md §Configure keys

Removed surfaces (not live surfaces; listed so a reader following an older cross-reference lands on the removal):

  • :rf.egress/output-sensitivity reg-meta key — the derived-output declassification claim + its :rf.egress/inherit / :sensitive / :public value set; classification does not propagate, so there is nothing to declassify (per 015 §No propagation, no taint).
  • the frame-config :sensitive / :large {:app-db …} durable annotation — durable app-db classification is the four commit-plane effects above; a frame config :sensitive {:app-db …} is rejected fail-loud.
  • redact-derived-slots — the value-match / taint dual of elide-wire-value; removed from the façade and from re-frame.elision (value-match is propagation by another name). The only derived-tree egress boundary is now project-egress over a :rf.observe/derived-tree record, path-walked against the frame's classification (per 015 §project-egress).
  • populate-elision-from-schemas! / populate-sensitive-from-schemas! — the schema→registry migration bridge; removed (schemas describe shape, not durable app-db egress policy — per 015 §Schemas describe shape).

re-frame.http

HTTP carrier policy. The HTTP fx maps headers + query-strings + the decoded response body into :rf.http/* trace events; their classification surfaces:

Surface Kind Purpose Owner
Built-in header / query-param denylists framework default (immutable) Closed sets of always-sensitive header / query-param names redacted in every :rf.http/* trace event regardless of the request :sensitive? flag — the name is the signal. No frame can remove a built-in name. 014 §1–2
Managed-HTTP carriers declarative (reg-fx :rf.http/managed meta) (rf/reg-fx :rf.http/managed {:carriers {:headers [..] :query-params [..]}} h) — app-specific carrier names that union onto the immutable built-in defaults (the transient-payload case). There is no frame :sensitive {:http …} block and no process-global declare-sensitive-*! mutator. 014 §HTTP carriers
Response-body classification declarative (:decode schema) Per-slot :sensitive? / :large? props on the request's :decode Malli schema classify the decoded body. Whole-body root prop redacts everything; an unschematized body fails closed off-box. 014 §Response-body classification
:sensitive? (per-call) request arg {:rf.http/managed {:sensitive? true}} — opts a specific request in. When true, the request body is redacted to the sentinel and all query params are scrubbed (broader than the denylist). Sugar form: {:request {:sensitive? true}}. 014 §Privacy

Built-in denylists ship populated with the obvious cross-app names (authorization, cookie, x-api-key, set-cookie, ...; api_key, access_token, auth, token, ...). App-specific carrier names (X-MyApp-Auth, shop_token) are declared on the :rf.http/managed reg-fx registration via the :carriers block — there is no frame :sensitive {:http …} block and no process-global declare-sensitive-header! / declare-sensitive-query-param! mutator.

re-frame.schemas (declarative — no imperative surface)

Schema-attached slot props. These are the one and only classification route for owner-local schema'd data — machine :data-schema, resource :data-schema / :params-schema, an HTTP request's :decode schema (one owner, one route) — and they drive schema-validation error-trace redaction. They are not a route for durable app-db classification (that rides the four commit-plane effects per 015 §Schemas describe shape); the schema→registry hydrators (populate-elision-from-schemas! / populate-sensitive-from-schemas!) are removed (see the removal note above).

Surface Kind Purpose Owner
:sensitive? true schema slot prop Per-slot Malli property {:sensitive? true} on a :data-schema / :params-schema / :decode schema slot (or, for migration import only, an app-schema slot). The canonical fine-grained surface for schema-owned data; schema-validation error traces consult the prop (:value / :received / :explain / :rf.fx/args / :rf.sub/query-v redaction). 010 §:sensitive?, 015 §Machine-owned
:large? true schema slot prop Symmetric to :sensitive? for the size axis — per-slot Malli property on a :data-schema / :params-schema / :decode schema slot. Schema-validation error traces consult the prop and substitute :rf.size/large-elided for the matching slot value. (Not a durable app-db route — that rides the four commit-plane effects.) 010 §:large?

re-frame.epoch

Per EP-0015 issue 6 (graduated), epoch records are causal replay material (post-EP-0010) and storage-side mutation is removed — the raw record stays in the ring and every register-epoch-listener! listener receives it unmutated; off-box egress MUST project through projected-record / projected-history (which run project-egress under an off-box profile). The surviving :redact-fn hook is projection-side only (export/egress), not a storage-side record transform; it is an advanced escape for slots the frame's classification cannot prove.

Surface Kind Purpose Owner
(configure! {:epoch-history {:redact-fn fn}}) runtime config Projection-side advanced override. Invoked 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 + every listener therefore deliver the raw record (mutating replay material at rest corrupts the EP-0010 replay contract). Failures emit :rf.warning/epoch-redact-fn-exception and fall back to the projected record for that egress only. Production elides the record supply — the capture / assembly / ring / listener machinery rides debug-enabled?, so normal dispatch assembles no record for the hook to run on — but the projected-record helper it runs inside is itself ungated and stays callable. 015 §Epoch projection, Tool-Pair §Redaction hook, API.md §Configure keys
:rf.epoch/sensitive? record-level rollup Top-level boolean on the assembled :rf/epoch-record — true iff any captured trace event / declared-sensitive leaf in the record was sensitive. Computed at build-time from the raw record's schema-declared sensitive leaves, so it stays an accurate off-box-branch signal on the raw ring record. Tool-Pair §Time-travel
projected-record projection fn (rf/projected-record record) — off-box-safe projection of a :rf/epoch-record. Routes each tree slot through project-egress (over elide-wire-value), strips raw :db-before / :db-after, keeps the structured fields (:trigger-event, :fx, :halt-reason, :schema-digest, :rf.epoch/sensitive?, :rf.epoch/redacted-modified-paths-count). The single projection site when shipping epoch data off-box; then applies the :redact-fn advanced override. Idempotent. Tool-Pair §Direct-read privacy
projected-history projection fn (rf/projected-history frame-id)(mapv projected-record (epoch-history frame-id)). Off-box-safe equivalent of epoch-history. Tool-Pair §Time-travel

tools/mcp-base (cross-MCP wire egress)

The framework-published privacy filter every MCP forwarder composes. Apps don't author against this directly — MCP server implementations do, conforming to the cross-server vocabulary.

Surface Kind Purpose Owner
sensitive-event? predicate Conservative predicate over a trace-event map — true iff (:sensitive? ev) is literal true. Mirror of re-frame.privacy/sensitive?. tools/mcp-base/spec/sensitive.md
strip-sensitive walker (strip-sensitive coll)[kept dropped-count]. The dropped-count becomes the :dropped-sensitive envelope counter on the MCP response. tools/mcp-base/spec/sensitive.md
scrub-snapshot walker Snapshot-tree walker — descends into nested registration handles and removes :sensitive?-stamped sub-trees (stricter than top-level filtering). tools/mcp-base/spec/sensitive.md
:include-sensitive cross-MCP wire arg Per-call opt-in on every MCP tool surfacing trace-like data. Defaults to false. The wire-key spelling is now uniform across every server — story-mcp and re-frame2-pair-mcp both ship the unqualified :include-sensitive (no trailing ? — the Anthropic tool-input-schema regex ^[a-zA-Z0-9_.-]{1,64}$ rejects ?). The ? is retained only on the internal walker option (:rf.size/include-sensitive?) and the config-knob verb (include-sensitive?), never the MCP wire key. tools/mcp-base/spec/sensitive.md §Cross-server arg-vocabulary, Conventions §Privacy config-knob
:rf.size/large-elided (elision marker) + :include-large? (wire arg) cross-MCP wire vocabulary Size-elision peer of :sensitive?. The walker substitutes :rf.size/large-elided {:bytes N :head "..." :handle ...} at over-threshold or :large?-declared slots; off-box callers opt in with {:include-large? true}. tools/mcp-base/spec/elision.md, 009 §Size elision

Inventory by declaration source

Same surfaces, regrouped by the owner that declares the classification (015 §The ownership split): handler commit-plane effects classify durable app-db state; machine / resource / mutation definitions classify owner-local schema'd data (projection-relative); registration metadata classifies transient payloads (including the :rf.http/managed :carriers block — HTTP carrier names); frame config carries frame-local egress facts (observability sink policy, SSR allowlist).

Handler commit-plane effects (durable app-db, EP-0025)

Durable app-db classification is declared by the four commit-plane effects a handler returns alongside :db; the classification commits with the db write into the per-frame elision registry, value-independently:

  • {:db … :sensitive [[:auth :token] …]} — classify durable app-db sensitive paths (:rf/path values)
  • {:db … :large [[:docs :csv-upload] …]} — classify durable app-db large paths
  • {:db … :clear-sensitive [[:auth :token]]} / {:db … :clear-large [[:docs :csv-upload]]} — un-classify

This replaces the removed frame-config :sensitive / :large {:app-db …} annotation and the add-marks / set-marks app-db path-mark API (the marks namespace is gone; its underlying projection substrate migrated into the marks-free elision engine — see §Removed surfaces). Per 015 §Durable app-db — the four commit-plane effects.

Frame config (frame-local egress facts)

The frame carries observability sink policy and the SSR hydration allowlist (the :sensitive key is gone entirely — its :app-db durable block moved to the commit-plane effects and its :http carrier block moved to :rf.http/managed; a frame config :sensitive is rejected fail-loud):

  • (rf/make-frame {:id :app :observability {:handled-events […] :errors […]}}) — production sink policy (§Frame-owned observability sink policy)
  • (rf/make-frame {:id :app :ssr {:hydrate {:include-app-db […]}}}) — allowlist-first SSR/hydration boundary

Frame config installs atomically at frame creation (before the :initial-events setup runs); re-registering replaces it wholesale. HTTP carrier names are NO LONGER frame config — they ride the :rf.http/managed reg-fx registration's :carriers block (see the next section); there is no frame :sensitive {:http …} block and no process-global declare-sensitive-header! / declare-sensitive-query-param! mutator (their underlying fns survive as internal/test helpers only — see §Removed surfaces).

Per-slot schema props (owner-local schema'd data)

{:sensitive? true} / {:large? true} Malli props on the owner's own schema are the one-and-only route for owner-local schema'd data — machine :data-schema, resource :data-schema / :params-schema, an HTTP request's :decode schema (the one shared mechanism; no sibling path-map vocabulary). Whole-shape claims are the degenerate root-prop case; an unschematized HTTP body is whole-sensitive (fail-closed). Per 015 §Machine-owned, §Resource and mutation, §HTTP response bodies. (Schema props on an app-db schema are not a route to durable app-db classification — that rides the four commit-plane effects; the schema→registry hydrators are removed, per 015 §Schemas describe shape.)

Registration metadata (transient payloads)

reg-event / reg-sub / reg-fx / reg-cofx / reg-flow accept :sensitive / :large (vectors of paths) into the registration's primary data shape. Empty path [[]] marks the whole shape. (There is no :rf.egress/output-sensitivity declassification key — classification does not propagate, so there is nothing to declassify; to expose a derived secret safely, classify only the paths you mean to redact.)

Reg kind Path root Owner
reg-event the event arg-map (second element of [:event-id {arg-map}]) 015 §Registration-owned transient classification
reg-sub the sub's output value (classify the output paths directly — no propagation, no declassification claim) 015 §Registration-owned transient classification
reg-fx the fx-input map 015 §Registration-owned transient classification
reg-cofx the coeffect value ([[]] = the whole value) — see also §Recordable coeffects must exclude secrets 015 §Registration-owned transient classification
reg-flow the flow's :output value (classify the output paths directly — no propagation, no declassification claim) 015 §Registration-owned transient classification

Machine transition payloads are transient payloads classified by the transition/registration metadata that introduces them — not by the machine's durable :data policy. Durable runtime-subsystem state (resource/mutation) is owned by its definition (per-slot schema props), not classified as a transient payload merely because it is declared by reg-resource / reg-mutation.

HTTP carriers (:rf.http/managed registration) — quick reference

  • (rf/reg-fx :rf.http/managed {:carriers {:headers ["X-MyApp-Auth"]}} h) — header carrier; unions onto the immutable built-in header denylist
  • (rf/reg-fx :rf.http/managed {:carriers {:query-params ["my_token"]}} h) — query-param carrier; unions onto the built-in query-param denylist
  • {:rf.http/managed {:decode <malli-schema-with-:sensitive?-props>}} — per-slot response-body classification
  • {:rf.http/managed {:sensitive? true ...}} — per-call opt-in (body redaction + ALL params scrubbed)

Runtime config — epoch redact hook

  • (rf/configure! {:epoch-history {:redact-fn (fn [record] ...)}}) — single-pass record-in / record-out hook at the epoch boundary.

The composition order (data-flow)

The single most-asked question this doc answers: what runs when, in what order, between handler exit and off-box wire? The order is fixed and documented in pieces across 009 / 014 / 015 / Tool-Pair — this section pins it in one place.

┌─────────────────────────────────────────────────────────────────────────────┐
│  1. HANDLER BODY runs with REAL VALUES                                      │
│     - Event handler sees the raw event arg-map (via :event coeffect)        │
│     - Cofx values, app-db reads, fx args — all unredacted                   │
│     - This is by design — the handler MUST see real values to do its job   │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│  2. EVENT-PAYLOAD SCRUB during trace-event build                            │
│     - The router's internal redaction interceptor stashes a scrubbed copy   │
│       at :rf/redacted-event for every handler whose registration-owned      │
│       :sensitive paths (or a frame-sensitive app-db path the slice          │
│       overlaps) match the event payload.                                    │
│     - (The public positional `redact-interceptor` is REMOVED per EP-0015    │
│       §7; the underlying re-frame.privacy/redact-interceptor survives as    │
│       internal router plumbing only — registration-owned :sensitive is the  │
│       public route.)                                                        │
│     - Trace assembly reads :rf/redacted-event (not :event) when building    │
│       :rf.event/* and :rf.event/db-changed tag shapes.                      │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│  3. SPEC 015 CLASSIFICATION PROJECTION (classification/project-trace-event) │
│     - The trace bus chokepoint walks :tags for per-registration marks       │
│       declared at reg-time (`:sensitive [paths]` on the registration meta). │
│     - Substitutes :rf/redacted at sensitive paths, :rf.size/large-elided    │
│       at large paths inside the per-tag shape (events under :event, fxs    │
│       under :fx-args, cofx under :coeffects, subs under :value, machines   │
│       under :before / :after / :snapshot).                                  │
│     - Sub-output propagation table consulted: a sub reading any sensitive   │
│       app-db path yields a sensitive output (footgun prevention).           │
│     - Stamps :sensitive? true at the top level of the trace event.          │
│     - Gated on `re-frame.interop/debug-enabled?` — production CLJS bundles  │
│       DCE this away.                                                        │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│  4. HTTP-SPECIFIC REDACTION (re-frame.http.privacy/prepare-emit-tags)       │
│     - For :rf.http/* trace events only.                                     │
│     - `redact-headers` walks the :headers map, replaces values whose name   │
│       is in the header denylist with :rf/redacted (unconditional — denyy-  │
│       listed names are the signal).                                         │
│     - `redact-url-query-string` walks the :url string, replaces query-      │
│       param values whose name is in the query-param denylist (unconditional).│
│     - When `:sensitive? true` is the per-call flag: also scrubs :body and   │
│       ALL params (broader than the denylist).                               │
│     - `:sensitive? true` stamped on the trace event when ANY scrub fired    │
│       (denylist hit OR per-call opt-in OR upstream from path-mark).         │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│  5. TRACE-BUS EMIT — every listener receives the redacted, stamped event    │
│     - Dev-only listeners (Xray, story recorder, dev panels): consult       │
│       :sensitive? at top level; on-box dev panels render an opaque indicator│
│       and require `:trace/show-sensitive? true` to reveal.                  │
│     - Always-on error-emit substrate listeners (production-survivable):     │
│       consult :sensitive? and drop the whole event by default at off-box    │
│       egress (Sentry/Honeybadger/Datadog forwarders).                       │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│  6. EPOCH ASSEMBLY (re-frame.epoch/build-record)                            │
│     - Per-frame, once per dequeued event.                                   │
│     - sensitive-rollup computes :rf.epoch/sensitive? from the raw record's  │
│       schema-declared sensitive leaves at build-time.                       │
│     - The RAW record is appended to the ring and delivered to every         │
│       register-epoch-listener! listener UNMUTATED — storage-side mutation   │
│       is REMOVED (EP-0015 issue 6): epoch records are EP-0010 causal replay │
│       material, mutating them at rest corrupts the replay contract.         │
│     - The :redact-fn runs at step 7 (off-box egress), not here.             │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│  7. OFF-BOX PROJECTION (rf/project-egress → rf/projected-record →           │
│     rf/elide-wire-value)                                                    │
│     - `project-egress` is the record-level boundary primitive; it           │
│       delegates per tree-shaped slot to `elide-wire-value` (the value       │
│       walker, single emission site for :rf/redacted + :rf.size/large-       │
│       elided) under the frame's classification + the boundary's             │
│       :rf.egress/* profile.                                                 │
│     - `projected-record` (epoch) strips raw :db-before / :db-after; THEN    │
│       applies the projection-side :redact-fn advanced override (after the   │
│       frame/profile projection, never at storage time).                     │
│     - The structured :effects rows' :args (raw fx-handler payload, not      │
│       app-db-rooted so the walker cannot prove it safe) FAIL CLOSED to      │
│       :rf/redacted off-box, lifted only by :include-fx-args? true.          │
│     - `elide-wire-value` walks tree-typed payloads; consults the per-frame  │
│       [:rf.runtime/elision :declarations] +                                 │
│       [:rf.runtime/elision :sensitive-declarations] (frame-sourced, plus    │
│       schema-sourced migration-import entries — union at lookup time).      │
│     - Composition rule: sensitive drop WINS over large elision when both    │
│       apply at the same path (the size marker would otherwise leak :path /  │
│       :bytes / :digest).                                                    │
│     - Default `{:include-sensitive? false :include-large? false}` —         │
│       maximum elision unless the caller explicitly opts in.                 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│  8. MCP TOOL EGRESS (tools/mcp-base/sensitive + elision)                    │
│     - Cross-MCP filter that runs on every tool response payload.            │
│     - `strip-sensitive` returns [kept dropped-count]; populates the         │
│       `:dropped-sensitive` envelope counter (omitted when zero).            │
│     - `:elided-large` envelope counter sums the `:rf.size/large-elided`     │
│       substitutions.                                                        │
│     - The counters ride alongside the payload as unqualified keys so the    │
│       calling agent recognises filtering without re-inferring from absence. │
└─────────────────────────────────────────────────────────────────────────────┘

Rule summary

  • Composition is additive at every site. A commit-plane :sensitive classification effect (durable app-db) and an owner-local schema :sensitive? prop that resolve to the same path both redact at the same observation surface — they union.
  • Sensitive wins over large at the same path. 015 §:rf/redacted {:bytes N} and 009 §Size elision in traces. The sensitive drop suppresses the size marker because the marker carries :path / :bytes / :digest which would themselves leak.
  • HTTP denylists are upstream of the trace stream. They run inside prepare-emit-tags / prepare-emit-failure before trace/emit! fires — they shape the trace event itself, not its downstream consumers. Per Spec 014 §Privacy.
  • Real values are never redacted mid-handler. The router stashes a scrubbed copy at :rf/redacted-event; the handler body continues to read the unredacted :event coeffect. Projection happens at the observation/egress boundary after the handler returns, never before.
  • Production has one live path: the always-on error-emit substrate. Everything else (dev trace bus, epoch ring, schema-validation traces, Xray) elides via goog.DEBUG. The error substrate honours :sensitive? in production — that's the load-bearing case for substrate-level enforcement.
  • runtime-db is redacted/omitted off-box by default (EP-0001). Off-box egress of frame-state — the epoch projected-record / projected-history pair, Xray-MCP, and pair recorders — redacts or omits the runtime-db side of frame-state by default; the off-box default fails closed. Only the app-db partition (subject to its own :sensitive? / :large? elision) and explicitly allowlisted serializable runtime-db facts cross the wire. The SSR hydration payload likewise ships only the serializable runtime-db facts the client needs to reconstitute (machine snapshots, route slice, elision declarations, SSR metadata — per 011 §The :rf/hydrate event), never transient runtime side-channel state. A trusted-local tool may request richer runtime-db diagnostics explicitly (the same opt-in shape as :include-sensitive? for app-db); off-box / AI / log egress fails closed. This is the runtime-db peer of the app-db :sensitive? default: app-db redacts at marked paths, runtime-db redacts/omits wholesale unless explicitly opted in. The normative statement (including the elision-declarations-live-in-runtime-db corollary) is 009 §Privacy / sensitive data in traces.

Recordable coeffects must exclude secrets

Recordable coeffects fold host facts into durable frame-state — values written into the :rf.cofx envelope map and re-presented verbatim by replay (the discipline: durable state folds facts, never reads; see 002 §The recordable-coeffect rule, EP-0017). A recordable coeffect is durable — it lands in every epoch record, replay fixture, and exported trace. 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.

The rule is a normative review discipline, not a structural guarantee: app-owned reg-cofx suppliers mint whatever value they return, so the framework cannot prove a value is not a secret. The enforcement surface is this guide's secrets material plus a recommended lint (a handler that writes durable state declaring an ambient-grade id — "durable state folds facts, never reads", mechanically checkable). See EP-0017 §Security Considerations.

Projection composes the same way for cofx values as for event payloads. Each :rf.cofx leaf follows the same projection / redaction rules as event-arg values — classify the cofx's value shape per-slot (reg-cofx :sensitive / :large, or the cofx's registered :schema props), and the off-box projection redacts it like any other transient payload. The framework's one built-in recordable fact, :rf/time-ms, is classified always-safe and never redacted. So the cofx surface has two complementary obligations:

  1. Off-box egress redaction — a recordable coeffect that is legitimately sensitive (e.g. a non-secret-but-private fact a fold needs) is classified and projected like any transient payload; it is never shipped raw off-box.
  2. The secrets exclusion (above) — a secret must not be a recordable coeffect at all, because redaction does not undo durability: the raw value still lives in the on-box ring, the local epoch, and any trusted-local raw read. The two rules are not substitutes — exclusion is the load-bearing rule for credentials; projection is the safety net for the merely-private.

The analogous obligation already holds for resource scopes / params (classified, projected — but not a place to put a secret) and for HTTP response bodies (fail-closed when unschematized). Recordable coeffects extend that posture to the input-fold side.


Display sentinels

Per 015 §Display contract and API.md §wire-elision walker:

Sentinel When Drillable?
:rf/redacted (opaque keyword) Sensitive content. Carries no information about the underlying value — not its type, not its size, not a hash, not a prefix. No. A tool that offers a "show original" affordance against :rf/redacted is non-conformant.
:rf.size/large-elided {:path [...] :bytes N :type :map :hint "..." :handle [:rf.elision/at path] :digest "sha256:..." (when:include-digests?true)} Large content; size diagnostic without leaking content. The :hint rides from the schema's :hint prop. Yes for on-box panels with size-confirmation modal; no for off-box egress by default.
:rf/redacted at a path also marked large Sensitive + large composed — sensitive wins. The size marker is suppressed entirely (the marker payload would leak :path / :bytes). No. (Per 015 §:rf/redacted {:bytes N} — the Spec contemplates a :rf/redacted {:bytes N} composed form preserving the size diagnostic; the CLJS reference currently suppresses the marker entirely. Both are conformant — readers should not depend on :bytes being present alongside :rf/redacted.)

All three sentinel keywords are framework-reserved per Conventions §Reserved namespaces — apps MUST NOT use them as legitimate payload values.


Config knobs

The two verb families that decide whether a sensitive value passes through a consumer. The verb encodes the trust boundary. Per Conventions §Privacy config-knob naming:

Verb Where Default Trust boundary
:rf.privacy/show-sensitive? On-box devtools panels (Xray, Story trace panel) — set via each tool's configure!, e.g. (xray-config/configure! {:rf.privacy/show-sensitive? true}). Reads back via (re-frame.privacy/get-show-sensitive). Per — the :rf.privacy/* namespace is the cross-tool reservation (every re-frame2 tool that consumes the trace bus reads the same atom; one config flip covers every tool). false (suppress) The panel is for the operator running this process; toggle controls UI visibility, not egress.
:include-sensitive? / :rf.size/include-sensitive? Off-box wire egress (MCP servers, hosted-LLM preload, error monitors, Datadog/Sentry forwarders) false (suppress) The toggle controls whether sensitive values cross the process trust boundary.

Both default to suppress per Spec 009's default-private posture. A sixth consumer adding a knob picks the verb by trust-boundary class — on-box panel → show-sensitive?; off-box wire → include-sensitive?.

Configure-keys that touch privacy

Per API.md §Configure keys and 015:

(rf/configure! {<key> {...}}) Privacy-relevant opt Default Purpose
:elision :rf.size/threshold-bytes N 16384 Wire-elision size threshold — advisory, not a cap. An over-threshold value at an undeclared path fires :rf.warning/large-value-unschema'd but ships unchanged; only declared / schema-marked entries elide. Non-negative integer; 0 disables the auto-detect warning.
:epoch-history :redact-fn fn nil Projection-side advanced override — runs at off-box egress (inside projected-record, after the frame/profile projection), never at storage (EP-0015 issue 6). See Tool-Pair §Redaction hook.
:epoch-history :depth N / :trace-events-keep N depth 50, trace-events-keep nil Bounds the ring (doesn't redact; bounds the surface).

Indicator slots

Counters that ride alongside MCP tool responses so the calling agent knows the payload was filtered, without re-inferring from absence. Per Conventions §Reserved indicator slots:

Slot Meaning Where Owner
:dropped-sensitive Integer count of leaves the walker dropped because they matched :sensitive? true. Omit when zero. MCP response envelope (unqualified key) Cross-MCP convention
:elided-large Integer count of leaves replaced with the :rf.size/large-elided marker. Omit when zero. MCP response envelope (unqualified key) Cross-MCP convention
[● REDACTED N] / [● ELIDED N] Panel-chrome mirror of the MCP slots for on-box surfaces (Xray, story panel) Panel chrome (not JSON) Conventions §Reserved panel-chrome surface

The walker also emits a top-level :rf.epoch/redacted-modified-paths-count on :rf/epoch-record values when the :redact-fn substituted at non-schema-declared paths — apps can detect "the redact-fn touched these many slots" without re-walking.


Worked example — password in app-db + token header on HTTP

Finding #8's canonical question: "I have a :password field in app-db and a :token header on an HTTP request — what do I declare where to keep both out of off-box egress?"

;; 1. Declare the durable app-db classification via COMMIT-PLANE EFFECTS
;;    (EP-0025) — a handler returns `:sensitive` (a vector of `:rf/path`
;;    vectors) alongside `:db`; the classification commits WITH the db write
;;    into the per-frame elision registry. Classify a path before any value
;;    lands there (value-independent); an init event is the natural home.
;;    This replaces the removed frame `:sensitive {:app-db …}` annotation and
;;    the add-marks / set-marks API. (App-specific HTTP carriers ride the
;;    :rf.http/managed registration — see step 3; the observability sink
;;    policy still lives on the frame map.)
(rf/reg-event :app/init-classification
  (fn [{:keys [db]} _]
    {:db db
     :sensitive [[:auth :password]
                 [:auth :token]
                 [:auth :refresh-token]
                 [:user :ssn]]}))

;; App-specific HTTP carrier names ride the :rf.http/managed registration's
;; :carriers block (EP-0025 §HTTP carriers). The frame map carries only the
;; concerns it still owns (e.g. observability sink policy).
(rf/reg-fx :rf.http/managed
  {:carriers {:headers ["X-MyApp-Session"]}}
  re-frame.http.managed/managed-handler)

;; 2. Declare the event-arg-side mark on the login handler — the password
;;    arrives in the event arg-map before it lands in app-db.
(rf/reg-event :auth/log-in
  {:sensitive [[:password] [:totp-code]]}
  (fn [{:keys [db]} [_ {:keys [email password totp-code]}]]
    ;; The handler sees real password / totp-code values.
    ;; The trace event sees [:auth/log-in {:email "..."
    ;;                                     :password :rf/redacted
    ;;                                     :totp-code :rf/redacted}].
    {:fx [[:rf.http/managed
           {:method     :post
            :url        "/api/login"
            :body       {:email email :password password}
            :sensitive? true     ;; per-call opt-in — body + ALL params scrubbed
            :on-success [:auth/log-in-success]
            :on-failure [:auth/log-in-failure]}]]}))

;; 3. The app-specific auth-token header carrier `X-MyApp-Session` was
;;    declared on the :rf.http/managed registration's :carriers block above.
;;    The built-in defaults already cover `authorization` / `x-api-key` /
;;    `cookie` / `set-cookie`; the carrier unions onto those immutable
;;    defaults (EP-0025 §HTTP carriers).

;; 4. The on-success event receives the JWT in the response payload. Mark
;;    its event arg so the trace surface sees :rf/redacted there too.
(rf/reg-event :auth/log-in-success
  {:sensitive [[:jwt] [:refresh-token]]}
  (fn [{:keys [db]} [_ {:keys [jwt refresh-token user]}]]
    ;; Writing the JWT into app-db [:auth :token] — the commit-plane
    ;; `:sensitive` classification from step 1 (standing in the per-frame
    ;; registry) means downstream Xray renders the path as :rf/redacted.
    ;; Classification does NOT propagate: a sub/flow reading this path does
    ;; not auto-inherit — classify the derived output path too (see step 5).
    {:db (-> db
             (assoc-in [:auth :token] jwt)
             (assoc-in [:auth :refresh-token] refresh-token)
             (assoc-in [:user :id] (:id user)))}))

;; 5. (Optional) — a subscription reading from a sensitive path does NOT
;;    inherit its input's classification (EP-0025: no propagation, no taint).
;;    A derived prefix is a NEW value at a new output path — it ships raw
;;    unless YOU classify the output. There is nothing to "declassify": if a
;;    sanitised derived value is safe, simply do not classify it; if a derived
;;    value is itself sensitive, declare the sub's own output path `:sensitive`:
(rf/reg-sub :auth/token-prefix
  :<- [:db/auth]
  (fn [auth _] (str (subs (:token auth) 0 8) "...")))   ;; sanitised — ships raw

;; 6. (Optional) — install a PROJECTION-SIDE epoch redact-fn for
;;    defence-in-depth redaction of slots no classification covered (raw
;;    exception messages, custom :trace-events slots). EP-0015 issue 6: the
;;    hook runs at off-box EGRESS (inside projected-record, after the
;;    frame/profile projection), NEVER at storage — the in-process ring
;;    stays raw (causal replay material).
(rf/configure! {:epoch-history
  {:redact-fn (fn [record]
                ;; Scrub :exception-message on any captured trace event.
                (update record :trace-events
                        #(mapv (fn [ev]
                                 (cond-> ev
                                   (= :error (:op-type ev))
                                   (update :tags dissoc :exception-message)))
                               %)))}})

What every observation surface sees after the drain settles:

Surface Observation
Handler body (:auth/log-in) Real password value in :event coeffect (via the regular handler arg)
Trace bus :rf.event/dispatched [:auth/log-in {:email "..." :password :rf/redacted :totp-code :rf/redacted}], top-level :sensitive? true
Trace bus :rf.fx/handled for :rf.http/managed :rf.fx/args body and params scrubbed (per-call :sensitive? true); :headers X-MyApp-Session value :rf/redacted (denylist hit)
Trace bus :rf.event/db-changed [:auth :token] slot renders :rf/redacted (the commit-plane :sensitive classification effect that classified [:auth :token])
Xray App-DB Diff panel Same as above (Xray projects via project-egress under :rf.egress/local-redacted, consulting the same frame classification)
MCP get-app-db tool response :rf/redacted at the marked slots (projected under :rf.egress/off-box-tool); :dropped-sensitive N envelope counter set to the count of dropped leaves
Off-box log shipper (Datadog/Sentry) Routed by frame :observability under :rf.egress/off-box-observability; drops the whole :rf.event/dispatched and :rf.fx/handled events (top-level :sensitive? true); ships the structural skeleton only
Always-on error-emit substrate (production survives) The error record carries :sensitive? true and the listener-side projection honours it before egress to Sentry
Epoch projected-record All of the above redactions plus the projection-side :redact-fn's extra scrub (applied at egress, never at storage); the structured :effects rows' :args fail closed to :rf/redacted off-box (lifted only by :include-fx-args? true); the in-process ring + listener fan-out see the RAW record

What's NOT covered by this declaration set:

  • An ex-info message that interpolates the password into the string ((throw (ex-info (str "User " email " failed login") {...}))) — the path walker can't resolve into a string substring. See §Author guidance — the exception-path residual below.
  • An ex-data map whose author-chosen key name ({:user/email "..."}) has no relationship to the path-marked declarations. Substitute :rf/redacted at the assembly site, or omit the key.

Author guidance — the exception-path residual

Classification declarations are projected at the six observation boundaries named above. Projection walks known data shapes; it does NOT walk exception messages or ex-data map keys. The residual surface — the handler read a sensitive value AND threw with that value in ex-message or ex-data — is author responsibility. Per 015 §Author guidance for the exception-path residual and Security §Author guidance for exceptions under path-level :sensitive?:

Anti-pattern Preferred
(throw (ex-info (str "User " email " failed login") {:user/email email :reason :invalid-credentials})) — leaks email into :exception-message and :exception-data (throw (ex-info "Invalid credentials" {:reason :invalid-credentials})) — name the category in the message; correlate via :dispatch-id against the (correctly redacted) :db-before snapshot
Author-named ex-data keys carrying the sensitive value Substitute :rf/redacted at the assembly site, or omit the key entirely

The framework does NOT ship a safe-throw helper — which ex-data keys correspond to sensitive paths in this specific app is app-level knowledge. A twelve-line per-app safe-throw helper is the recommended shape; worked example at docs/core §24.08 — Exceptions under :sensitive?.


Removed surfaces

Surfaces removed from this matrix. Listed here so readers don't search for them in v1.

Surface Replacement
add-marks / set-marks (public app-db path-mark API) Durable app-db egress policy is declared by the four commit-plane effects (:sensitive / :large / :clear-sensitive / :clear-large, returned by a handler alongside :db), not a post-creation imperative mutation. The marks API and marks.cljc namespace are gone; the projection substrate lives in the marks-free elision engine.
declare-sensitive-header! / declare-sensitive-query-param! (and clear-*!) App-specific HTTP carrier names belong on the :rf.http/managed reg-fx registration (:carriers {:headers […] :query-params […]}), union onto the immutable built-in defaults — not process-global mutation and not a frame annotation.
frame :sensitive {:http …} carrier block A frame config carries no :sensitive {:http …} carrier block (the whole :sensitive frame key is gone). App-specific HTTP carrier names ride the :rf.http/managed reg-fx registration's :carriers block (the transient-payload case). A frame config :sensitive is rejected fail-loud.
redact-interceptor (public positional interceptor) Registration-owned :sensitive classifies event payload paths; centralized project-egress projects at egress. re-frame.privacy/redact-interceptor survives as internal router plumbing only (not façade-published).
Schema-attached :sensitive? / :large? as the public app-db classification route Schemas describe shape; durable app-db egress policy rides the four commit-plane effects. Per-slot props remain the one route for owner-local schema'd data (machine / resource / HTTP-body), not a second route for durable app-db. The schema→registry hydrators (populate-elision-from-schemas! / populate-sensitive-from-schemas!) are removed.
inject-cofx (public cofx-injection interceptor) Coeffect dependencies are declared with :rf.cofx/requires registration metadata; reg-cofx is value-returning + graded. inject-cofx is removed (calling it is the hard error :rf.error/inject-cofx-removed). Named here because cofx values are a classification surface.
Handler-meta :sensitive? registration flag Use Spec 015 per-path declarations. A handler that is the unit of sensitivity (the rare "this whole run is sensitive" case) re-expresses by declaring the path-marks that the handler reads / writes.
:rf.fx/sensitive-mode configure key Use per-call {:sensitive? true} on :rf.http/managed args. The name set-trace-redaction-policy is not in re-frame.core.
rf/safe-throw framework helper Author-level concern; per-app helpers fit the local convention better than a framework default. Worked-example shape lives in the docs/core.

Cross-references

Primary contract owners

Cross-cutting conventions

Implementation cross-references

  • tools/mcp-base/spec/sensitive.md — cross-MCP sensitive-event? / strip-sensitive / scrub-snapshot walkers and the :include-sensitive arg vocabulary (the unqualified MCP wire key; the ? is retained only on the internal :rf.size/include-sensitive? walker option and the config-knob verb).
  • tools/mcp-base/spec/elision.md — cross-MCP elision walker + the :include-large? arg vocabulary.

API.md projection

Author-side guide