O-18. Security + operational logging sweep on the observability interceptor surface¶
Type B (semantic flag — every hit needs operator judgement). The agent sweeps the codebase for hand-rolled observability interceptors (audit logging, telemetry forwarders, error projectors, post-event recorders) registered against the v1 surfaces (M-13
reg-event-error-handler, M-17reg-global-interceptor, M-19add-post-event-callback, bespokereg-event-fxwrappers that emit telemetry from inside handler bodies, ajax-cljs response-side:interceptors), classifies each by whether the payload it ships off-box may carry sensitive data and whether the slot it walks may carry oversize values, and produces a per-site rewrite proposal that lands the interceptor on the canonical v2 surface (register-trace-cb!,register-epoch-cb!, or per-frame:interceptors) with the framework's sensitive / large defense composed by default.Cross-references. Required-rule M-13 drops
reg-event-error-handler; this rule covers the broader sweep that catches observers M-13 misses. Required-rule M-17 dropsreg-global-interceptor; this rule sweeps the audit-shaped subset of M-17 hits to the trace surface rather than the per-frame:interceptorsvector. The API.md §wire-elision walker is the framework primitive every off-box forwarder this rule produces routes through; Security.md §Privacy / secret handling is the threat-model context.
Why this is its own rule¶
M-13 and M-17 hand the operator a per-call-site decision — "this reg-event-error-handler was an observer; convert to register-trace-cb!." That's the right shape for the mechanical part. What M-13 / M-17 leave on the floor is the security and operational consequence of the conversion: an audit-logger that worked in v1 by hooking the dispatch envelope sees the whole event vector, which may carry passwords / tokens / PII; the v2-canonical register-trace-cb! listener receives the same event under :tags :event-v and ships it to wherever the listener's body forwards (Sentry, an external SIEM, a local log file). Per Security.md §Privacy / secret handling, the framework defends with :sensitive? declarations + the wire-elision walker, but the defense is declarative — if the v1 site never declared its observability surface, the v2 port silently leaks the same payloads to a wider audience.
This rule is the dedicated sweep that turns the post-M-13 / post-M-17 observer set into a v2-canonical set with privacy + oversize defenses composed at every egress. It has four sections:
- §Discovery — how to find every observability site that needs review.
- §Sensitive-key checklist — the closed set of payload-key substrings that signal sensitive content, plus the recursive-walk discipline.
- §Size-cap pattern + register-trace-cb! for dropped count — how to bound listener egress and surface a dropped-count signal so the operator sees what was filtered.
- §Reference mediation interceptor — the canonical "redact + size-cap + forward" interceptor body that every site is rewritten to.
1. Discovery¶
The agent sweeps the codebase for the following patterns. Each hit is one observability site; classify the site by what it does with the event (logs locally / forwards off-box / mutates app-db / something else) and what payload it walks (full event vector / specific keys / app-db slice / failure response).
# 1. Direct v1 observer surfaces (M-13 / M-17 / older add-post-event-callback)
rg -n 'reg-event-error-handler|reg-global-interceptor|add-post-event-callback|remove-post-event-callback' .
# 2. Bespoke per-handler telemetry that emits from inside reg-event-fx bodies
rg -n '\(reg-event-fx[^)]*\)' . -A 20 | rg -n '(log|console|track|telemetry|sentry|honeybadger|rollbar|datadog|analytics|posthog|mixpanel|segment)' -B 5
# 3. Manually-constructed interceptors via ->interceptor that fire side-effects in :before/:after
rg -n '->interceptor' . -A 10 | rg -n '(:before|:after).*\b(log|console|track|telemetry|sentry|fetch|XMLHttpRequest|XhrIo|js/fetch)' -B 5
# 4. ajax-cljs / cljs-ajax response-side :interceptors (often telemetry-shaped)
rg -n ':interceptors\b' . -A 15 | rg -n '(:response|:on-response)' -B 5
# 5. Post-handler dispatch from inside fx for the purpose of recording (recorder pattern)
rg -n 'fn \[\{:keys \[.*event.*\]\}.*\] \{:fx.*\[:.*record' .
The agent presents every hit to the operator with a one-line classification:
- observer (off-box egress) — body forwards a payload over an HTTP / SDK boundary (Sentry, Honeybadger, Rollbar, Datadog, custom telemetry endpoint). High-risk for sensitive data. Apply the full pattern: sensitive redaction + size cap + dropped-count signal.
- observer (local log) — body writes to console / local log file / dev panel. Lower-risk (local trust boundary) but still benefits from the size cap to avoid log bloat; sensitive redaction recommended for dev environments shared with other operators.
- behaviour-modifying interceptor — body mutates
app-db/ dispatches an event / changes the effect map. Not an observability site — port to per-frame:interceptorsper M-17, not toregister-trace-cb!. The two patterns are structurally different: observers must not change runtime behaviour; behaviour-modifying interceptors must. - misclassified telemetry-from-handler-body — the v1 author inlined telemetry inside a
reg-event-fxbody because the v1 surface didn't have a cross-cutting trace listener. Lift to the trace surface. The handler body returns the domain effect map; aregister-trace-cb!listener picks up the trace event and forwards from there. Removing the inline telemetry shrinks every handler that has it.
The classification drives the rewrite path. Sites flagged "observer (off-box egress)" or "observer (local log)" are the substantive payload of this rule.
2. Sensitive-key checklist¶
re-frame2's framework defense for sensitive data is the :sensitive? declaration (per 009 §Privacy / sensitive data in traces) — schema-driven via {:sensitive? true} on Malli slots and handler-scoped via :sensitive? true in registration metadata. The framework redacts at emit-site (always-on substrates) and at off-box egress (wire-elision walker). But the framework only redacts what's declared. v1 observability sites usually pre-date the declaration discipline, so the rewrite must inspect each site's payload and either (a) propose new :sensitive? declarations against the schemas / handlers the site walks, or (b) compose an explicit drop in the listener body for the keys it must filter even when undeclared.
Closed set of sensitive-key substring matches¶
The agent applies this closed set against every payload key the observability site walks. A key whose lower-cased name contains any of these substrings is treated as sensitive by default — propose :sensitive? true on the schema slot if one exists, and compose an explicit drop in the listener body regardless:
| Substring | Why |
|---|---|
password |
Account credential. Variants: :password, :user/password, :new-password, :current-password, :password-confirmation. |
token |
Auth token / session token / refresh token / API token. Variants: :token, :auth-token, :access-token, :refresh-token, :csrf-token, :bearer-token, :reset-token. |
secret |
API secret / signing secret / webhook secret. Variants: :secret, :client-secret, :api-secret, :webhook-secret, :signing-secret. |
jwt |
JSON Web Token (auth credential carrying claims). Variants: :jwt, :jwt-token, :auth.jwt/value. |
sudo |
Elevated-privilege session / sudo-mode credential. Variants: :sudo, :sudo-token, :auth.sudo/expires. |
auth-uri |
OAuth redirect URI / auth-flow URI carrying a code / state parameter (the URI itself is sensitive when it contains an ?code=...&state=... query). Variants: :auth-uri, :auth-flow/uri, :oauth/redirect-uri. |
user-id |
User identifier — sensitive in privacy-regulated contexts (GDPR, HIPAA, SOC2) where PII linkage is restricted. Variants: :user-id, :user/id, :account-id. |
email |
Personal email address — PII. Variants: :email, :user/email, :contact-email. |
phone |
Personal phone number — PII. Variants: :phone, :mobile, :phone-number. |
ssn |
Social-security / national-id — PII / sensitive-PII. Variants: :ssn, :national-id, :tax-id. |
cc / card |
Credit-card number — PCI-regulated. Variants: :cc-number, :card-number, :card-cvv, :card-exp. |
The list is the floor, not the ceiling. App-specific sensitive keys (HIPAA-regulated medical fields, partner-API secrets, internal session ids) require operator review per codebase. The agent surfaces the floor list and asks: "what app-specific keys also signal sensitive?" — every additional key joins the closed set for this rewrite pass.
The match is case-insensitive substring ((re-find #"(?i)password|token|...|cc|card" (name k))) — the agent walks every keyword in the observed payload and drops every key whose name matches. Namespace prefix is ignored (:user/password and :auth.sudo/expires both match).
Recursive walk discipline¶
Observability payloads are typically nested: an event vector carries a payload map carrying a :user sub-map carrying :credentials carrying :password. The agent's rewrite MUST walk the payload recursively — top-level redaction misses nested credentials. The framework's wire-elision walker (API.md §wire-elision walker) does this correctly when given :sensitive? declarations from the registry; the explicit-drop fallback (for undeclared sensitive keys) MUST also recurse. The canonical body:
(defn redact-sensitive
"Walk v recursively, replacing every map-entry value whose key matches the
sensitive-key floor with :rf/redacted. Returns the redacted value."
[v]
(let [sensitive? (fn [k]
(and (or (keyword? k) (string? k))
(re-find #"(?i)password|token|secret|jwt|sudo|auth-uri|user-id|email|phone|ssn|cc|card"
(name k))))]
(clojure.walk/postwalk
(fn [node]
(if (map? node)
(reduce-kv (fn [m k vv] (assoc m k (if (sensitive? k) :rf/redacted vv))) {} node)
node))
v)))
postwalk ensures the walker visits leaves before parents — every nested map is rebuilt with redaction applied at its own level. :rf/redacted is the canonical sensitive-substitution sentinel (per Conventions §Reserved namespaces); listener bodies that produce a different sentinel ("REDACTED", nil, the empty string) defeat downstream consumers that filter on :rf/redacted and MUST be normalised in this pass.
The agent SHOULD prefer the framework wire-elision walker over a hand-rolled redact-sensitive whenever the payload walks a value that has registered :sensitive? schema declarations — the walker reads the registry, applies sensitive drop, AND composes the size-cap from §3 in one pass. Use the hand-rolled redact-sensitive only as a fallback for sites whose payload is entirely undeclared (transient telemetry that never lands in app-db, headers from an external SDK, raw failure responses with no schema). The reference mediation interceptor in §4 shows both paths composed.
Schema-declaration proposal as a follow-on¶
For every sensitive key the agent finds in an observability payload that does appear in a registered schema, the agent proposes a follow-on {:sensitive? true} schema annotation:
;; Before
[:map
[:email :string]
[:auth/token :string]]
;; After (follow-on, per O-3 modernisation)
[:map
[:email {:sensitive? true} :string]
[:auth/token {:sensitive? true} :string]]
The schema-declaration path is strictly better than per-listener explicit drops: the declaration covers every consumer (trace listeners, error monitors, MCP servers, hosted dashboards) uniformly, and the framework's always-on error / event substrates honour it before fan-out (per 009 §Privacy / sensitive data in traces). The agent flags every schema-eligible key in the report and surfaces "consider adding {:sensitive? true} on <schema-id> slot <path>" as a separate operator decision per slot — the rewrite of the observability site is independent of the schema annotation, but the schema annotation eliminates the need for the explicit drop on every future consumer.
3. Size-cap pattern + register-trace-cb! for dropped count¶
Observability payloads can be unboundedly large — a :db/state-loaded event carrying the full app-db slice, an HTTP failure carrying a 5 MB response body, a :render/completed event carrying every rendered view's props. Listener bodies that forward such payloads to off-box destinations (Sentry / log shippers / hosted dashboards) cause memory pressure, network bloat, and rate-limited destinations rejecting batches. The framework defends with the :large? schema declaration + the wire-elision walker (per 009 §Size elision in traces); this rule's rewrite composes the defense at every listener body produced.
The cap pattern¶
Every listener body that walks a payload bounded by user input or by app-db size MUST apply a size cap. The cap is per-payload bytes, applied after sensitive redaction:
(defn cap-or-elide
"Returns [bounded-value dropped-count]. Walks v through the wire-elision walker
with a size threshold; returns the elided value plus a count of slots the walker
dropped (sensitive + large combined)."
[v {:keys [threshold-bytes frame]
:or {threshold-bytes 16384
frame :rf/default}}]
(let [opts {:rf.size/threshold-bytes threshold-bytes
:rf.size/include-sensitive? false
:rf.size/include-large? false
:frame frame}
elided (rf/elide-wire-value v opts)
;; Count :rf.size/large-elided markers and :rf/redacted sentinels in elided
dropped (->> (tree-seq coll? seq elided)
(filter #(or (= :rf/redacted %)
(and (map? %) (= :rf.size/large-elided (:rf.size/marker %)))))
count)]
[elided dropped]))
16384 bytes is the framework default per API.md §wire-elision walker configure key; the operator picks a per-listener cap that matches the destination's payload budget (Sentry's 100KB event-payload soft cap suggests ~32-64KB per listener; a self-hosted log file can be larger). The default is the right floor for production telemetry; specialised listeners (a dev-only console.log panel reading the trace buffer) can opt for a higher cap.
Surfacing the dropped count via register-trace-cb!¶
The cap silently elides — but silent elision is the wrong default for operational observability. Operators need to see that the listener filtered SOMETHING — otherwise a misconfigured schema (forgot {:sensitive? true} on a new field) leads to "the dashboard shows nothing" with no diagnostic signal. The pattern is to emit a counter trace event every time the listener drops slots:
(rf/register-trace-cb! :my-app/audit-forwarder
(fn [trace-event]
(when (and (#{:event/dispatched :event/handler-completed} (:operation trace-event))
(not (:sensitive? trace-event))) ;; default-drop sensitive cascades
(let [event-v (-> trace-event :tags :event-v)
[bounded
dropped] (cap-or-elide event-v
{:threshold-bytes 32768
:frame (:frame trace-event)})]
(when (pos? dropped)
;; Per-batch counter: operator sees how often the cap fires
(rf/dispatch [:audit/dropped-counter-inc {:dropped dropped
:operation (:operation trace-event)}]))
(sentry/capture-message
{:message "audit event"
:extra {:event/operation (:operation trace-event)
:event/payload bounded
:event/dropped dropped}})))))
Two diagnostic signals:
- The
:event/droppedslot on every forwarded payload — the operator opening the destination dashboard sees per-event how many slots were filtered. - The
[:audit/dropped-counter-inc ...]dispatch into the runtime — accumulates a counter the operator can query via(rf/subscribe [:audit/dropped-counter])for a continuous "how often is the cap firing" view.
The two together let the operator distinguish "the cap is a healthy backstop firing twice a day" from "the cap is firing on every event because a misconfigured schema is leaking the whole app-db." Both signals MUST land on the rewrite — silent elision is the failure mode this rule defends against.
Composition with the framework default¶
The framework already drops :sensitive? true events on the off-box-forwarder default (per 009 §Privacy / sensitive data in traces — "Framework-published listener integrations MUST default to suppressing :sensitive? true events"). The (when-not (:sensitive? trace-event) ...) guard in the listener body composes the default — the listener body never even sees a sensitive-scope cascade. The explicit cap-or-elide walk catches the rest of the payload — fields whose own schema didn't carry :sensitive? but match the floor checklist from §2, plus the size-cap.
4. Reference mediation interceptor¶
The canonical "redact + size-cap + forward + drop-count" body — the agent ports every classified-as-observer hit to one of these two shapes depending on what the v1 site did:
Shape A — register-trace-cb! for cross-frame observers (the M-13 / M-17 cross-frame-observer replacement)¶
(ns my-app.observability
(:require [re-frame.core :as rf]
[clojure.walk]
[sentry.core :as sentry]))
;; --- Local helpers (lift to a shared utility if used across multiple listeners) ---
(def ^:private sensitive-key-re
#"(?i)password|token|secret|jwt|sudo|auth-uri|user-id|email|phone|ssn|cc|card")
(defn- sensitive-key? [k]
(and (or (keyword? k) (string? k))
(re-find sensitive-key-re (name k))))
(defn- redact-sensitive-floor
"Recursive postwalk redaction for the closed sensitive-key floor.
Fallback for payloads whose values aren't covered by registered :sensitive? schema."
[v]
(clojure.walk/postwalk
(fn [node]
(if (map? node)
(reduce-kv (fn [m k vv] (assoc m k (if (sensitive-key? k) :rf/redacted vv))) {} node)
node))
v))
(defn- cap-or-elide
"Returns [bounded-value dropped-count] — applies schema-aware wire-elision walker
first, then floor redaction as a fallback for undeclared keys."
[v {:keys [threshold-bytes frame] :or {threshold-bytes 32768 frame :rf/default}}]
(let [floor-redacted (redact-sensitive-floor v)
opts {:rf.size/threshold-bytes threshold-bytes
:rf.size/include-sensitive? false
:rf.size/include-large? false
:frame frame}
elided (rf/elide-wire-value floor-redacted opts)
dropped (->> (tree-seq coll? seq elided)
(filter #(or (= :rf/redacted %)
(and (map? %) (= :rf.size/large-elided (:rf.size/marker %)))))
count)]
[elided dropped]))
;; --- The trace listener registration (the M-13 / M-17 replacement) ---
(rf/register-trace-cb! :my-app/audit-forwarder
(fn audit-forwarder [trace-event]
(when (and (= :event/dispatched (:operation trace-event)) ;; one event per dispatch
(not (:sensitive? trace-event))) ;; honour framework default-drop
(let [event-v (-> trace-event :tags :event-v)
frame (:frame trace-event)
[bounded
dropped] (cap-or-elide event-v {:threshold-bytes 32768
:frame frame})]
(when (pos? dropped)
(rf/dispatch [:audit/dropped-counter-inc
{:operation (:operation trace-event)
:dropped dropped}]))
(sentry/capture-message
{:message "audit event"
:extra {:event/operation (:operation trace-event)
:event/payload bounded
:event/dispatch-id (:dispatch-id trace-event)
:event/dropped dropped
:event/frame frame}})))))
;; --- The dropped-counter event + sub (operator-visible signal #2) ---
(rf/reg-event-db :audit/dropped-counter-inc
(fn [db [_ {:keys [operation dropped]}]]
(-> db
(update-in [:audit/counters :total-dropped] (fnil + 0) dropped)
(update-in [:audit/counters :per-operation operation] (fnil + 0) dropped))))
(rf/reg-sub :audit/dropped-counter
(fn [db _] (:audit/counters db)))
This is the structural rewrite target for every "observer-shaped reg-event-error-handler / reg-global-interceptor / add-post-event-callback / handler-body telemetry" hit from §1. The framework defaults compose (the :sensitive? guard, the off-box-include defaults on the walker); the floor checklist composes (redact-sensitive-floor); the size cap composes (cap-or-elide); the dropped-count signal composes (the [:audit/dropped-counter-inc ...] dispatch). The body is the minimum baseline — every observability site lands here or better; the agent surfaces the diff between the v1 site's body and this shape and asks the operator to confirm any deviations (a destination-specific SDK call, a custom batching layer, an alternative redaction policy).
Shape B — register-epoch-cb! for assembled-epoch observers¶
When the v1 observer assembled a per-cascade summary (an audit-log entry per drain, an error-projection per failed cascade, a post-mortem record per top-level event), the v2-canonical surface is register-epoch-cb! rather than register-trace-cb! — the framework hands the listener one assembled :rf/epoch-record per drain-settle with the structured :sub-runs / :renders / :effects projections (per 009 §register-epoch-cb! — assembled-epoch listener). The mediation body is structurally similar to Shape A but operates on the epoch record:
(rf/register-epoch-cb! :my-app/post-mortem-shipper
(fn epoch-shipper [epoch-record]
(when-not (:rf.epoch/sensitive? epoch-record) ;; honour epoch-level rollup
(let [[bounded dropped] (cap-or-elide epoch-record
{:threshold-bytes 65536
:frame (:frame epoch-record)})]
(when (pos? dropped)
(rf/dispatch [:audit/dropped-counter-inc
{:operation :epoch/settled
:dropped dropped}]))
(post-mortem-svc/forward
{:trigger-event (:trigger-event bounded)
:outcome (:outcome bounded)
:sub-runs (:sub-runs bounded)
:renders (:renders bounded)
:effects (:effects bounded)
:dropped dropped})))))
Two epoch-specific notes:
(:rf.epoch/sensitive? epoch-record)is the framework-computed rollup over the schema-declared sensitive leaves of:db-before/:db-after/:trigger-event/:trace-events(per Security.md §Sensitive rollup at the record level). The shipper MUST default-drop sensitive epochs; the rollup is exactly the signal to gate on.- The
(rf/configure :epoch-history {:redact-fn ...})build-time hook is a stronger alternative — instead of every off-box forwarder applying its owncap-or-elide, the operator installs one redact-fn at boot that erases sensitive material from the record once per drain; every downstream consumer (ring buffer, listener fan-out, off-box egress) sees the redacted shape. The agent SHOULD recommend the build-time hook when the codebase has multiple forwarders against the same epoch surface; for single-forwarder codebases the per-listenercap-or-elideis sufficient.
Shape C — per-frame :interceptors for behaviour-modifying interceptors¶
For hits classified as "behaviour-modifying interceptor" (§1's third category), the rewrite is not to the trace surface — those interceptors are not observers, and lifting them off the dispatch path would change runtime behaviour. Port them per M-17 to the frame-level :interceptors vector. This rule does not cover that path — surface the hit and point the operator at M-17.
Schema-declaration follow-on per site¶
For every observability site the agent rewrites, the report lists the sensitive keys the site walked and proposes {:sensitive? true} schema annotations for each that has a registered schema slot. The operator picks per slot — the schema annotation eliminates the per-listener explicit-drop on every future consumer and is the canonical privacy declaration (per Security.md §Privacy / secret handling). The follow-on lands as a separate diff per schema, surfaced in the report's "schema-annotation follow-ons" section.
Reporting¶
When the agent applies this rule:
- The migration report lists every observability site found, classified per §1 (observer-off-box / observer-local / behaviour-modifying / misclassified-handler-body) with file/line.
- Each rewrite shows: the v1 site, the v2 target shape (A / B / C), the framework defaults composed (sensitive guard, walker defaults), the floor-checklist drops applied, the dropped-count signal added.
- The "schema-annotation follow-ons" section lists every sensitive-key + schema-slot pair the agent found, with a per-slot proposal of
{:sensitive? true}and the rationale (which observability site walked it). - The "size-cap configuration" section lists each listener's chosen
threshold-bytesand the rationale (Sentry budget, log-file size, dashboard payload limit) — operator confirmation per listener. - Any hit the agent could not classify ("the body does too much / does both observer and behaviour-modifying work") is listed as an escalation, with the recommended path (split the body, port halves to different surfaces).
- The "framework defaults" section reminds the operator that the always-on substrate (
:on-error, event-emit listener, error-emit listener — per 009 §What IS available in production) survives production builds; observability sites that need a production-survivable path go through those substrates, not throughregister-trace-cb!(which elides on:advanced + goog.DEBUG=false).