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 it must survive into production (a hosted-monitor forwarder MUST; a dev-only console panel must not), 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 — frame:observabilitysinks (register-observability-sink!, the default for any forwarder that must keep firing in production), orregister-event-listener!/register-error-listener!for an intentionally corpus-wide hook, or the dev-onlyregister-listener!/register-epoch-listener!for an observer that is meant to elide in production, or per-frame:interceptorsfor behaviour-modifiers — with the framework's sensitive / large defense composed by default.The production-survivability fork comes first (rf2-ntv9i9.6). Before privacy or size, classify each forwarder by whether it must run in a production build, because that decides the surface, and the surface decides everything below it. The default for a hosted-monitor forwarder (Sentry / Datadog / Rollbar / custom telemetry endpoint) is a frame
:observabilitysink — the runtime hands the sink an already-projected record (the owning frame's classification composed with the entry's egress profile), so the redaction + size-cap this rule's §2–§3 build by hand are framework-managed for you. Drop to the rawregister-event-listener!/register-error-listener!substrates only for an intentionally corpus-wide hook (one fan-out across every frame rather than per-frame policy).register-listener!(Shape A below) andregister-epoch-listener!(Shape B) ride the dev-only trace surface — they DCE out under:advanced+goog.DEBUG=false(and-Dre-frame.debug=falseJVM-side), so a forwarder ported there ships nothing in production. Use those two only when the observer is genuinely dev-loop-only. The entry-point hierarchy is pinned in 009 §Use case × surface routing (which forwards to the guide §16 consumer-facing walkthrough); the §2–§4 redaction/size machinery below applies to whichever surface a site lands on (frame sinks compose it for you; raw-listener ports apply it by hand).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-listener!." 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-listener! 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-listener! 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-listener!. 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-listener!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 owner-classification model (015-Data-Classification.md, EP-0025) — durable app-db paths declared via the four commit-plane classification effects (a reg-event returns :sensitive / :large alongside :db), owner-local schema'd data (machine :data, resource params/data, HTTP :decode bodies) via per-slot {:sensitive? true} Malli props, and transient payloads (event args, sub/flow outputs) via :sensitive path-collections in registration metadata. The framework redacts at emit-site (always-on substrates) and at off-box egress (project-egress / the 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 classification declarations on the right owner for the schemas / handlers / frames 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-listener! 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. Two mechanisms cooperate, and the migration must not conflate them:
- The wire-elision walker elides declared paths — schema/effect-declared
:sensitive→:rf/redacted, declared:large→ a:rf.size/large-elidedmarker. Its:rf.size/threshold-bytesis advisory only: for an undeclared large string over the threshold the walker emits the dev-only:rf.warning/large-value-unschema'dnudge and returns the value unchanged (per 009 §Size elision in traces: "The walker does not auto-elide unschema'd values"). It never bounds an undeclared value. - A genuine per-payload byte budget — the hard cap. Because an undeclared oversize value survives the walk, the helper measures the serialised size of the elided result and, when it still exceeds the destination budget, drops the payload to a compact over-budget marker rather than letting it ride off-box. The cap is per-payload bytes, applied after sensitive redaction:
(defn cap-or-elide
"Returns [payload dropped-count over-budget?].
1. Elides DECLARED paths through the wire-elision walker (declared :sensitive →
:rf/redacted, declared :large → a :rf.size/large-elided marker — independent
of any byte threshold).
2. Enforces a GENUINE serialised-byte budget on the elided result: an undeclared
oversize value is NOT bounded by the walker (its threshold is advisory — it
warns via :rf.warning/large-value-unschema'd and returns the value unchanged),
so this backstop drops the whole payload to an over-budget marker.
dropped-count is the count of DECLARED elisions; over-budget? is whether the byte
budget fired. The advisory warning, the declared-elision count, and the genuine
budget stay three distinct signals — none is conflated with another."
[v {:keys [budget-bytes frame]
:or {budget-bytes 16384
frame :rf/default}}]
(let [opts {:rf.size/threshold-bytes budget-bytes ;; advisory nudge for undeclared-large
: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? %) (contains? % :rf.size/large-elided))))
count)
;; Genuine backstop: the walker leaves an undeclared oversize value in place,
;; so cap the SERIALISED result (pr-str length) against the destination budget.
over? (> (count (pr-str elided)) budget-bytes)]
(if over?
[{:audit/over-budget {:budget-bytes budget-bytes
:bytes (count (pr-str elided))}}
dropped true]
[elided dropped false])))
16384 is the framework's default advisory threshold per API.md §wire-elision walker configure key — the size above which the walker nudges an undeclared large string. It is not a wire cap; only paths you declared :large (via the commit-plane :large effect, EP-0025) elide to a marker, independent of the threshold. The genuine per-listener bound is cap-or-elide's :budget-bytes backstop — the operator sets it to 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). Passing the same number as the advisory threshold is deliberate: an undeclared value over budget both nudges you to declare it :large and trips the backstop that keeps it off the wire.
Surfacing the dropped count via register-listener!¶
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-listener! :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
over?] (cap-or-elide event-v
{:budget-bytes 32768
:frame (:frame trace-event)})]
(when (or (pos? dropped) over?)
;; Per-batch counter: operator sees how often the cap fires
(rf/dispatch [:audit/dropped-counter-inc {:dropped dropped
:over-budget over?
:operation (:operation trace-event)}]))
(sentry/capture-message
{:message "audit event"
:extra {:event/operation (:operation trace-event)
:event/payload bounded
:event/dropped dropped
:event/over-budget over?}})))))
Two diagnostic signals:
- The
:event/dropped+:event/over-budgetslots on every forwarded payload — the operator opening the destination dashboard sees per-event how many declared slots were filtered and whether the byte-budget backstop dropped the payload. Reporting them separately keeps a declared-elision distinct from a genuine over-budget drop — the two must not be conflated. - 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 backstop fires twice a day" from "the backstop fires 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 shapes, picked first by the production-survivability fork (§Why this is its own rule) and then by what the v1 site did:
- Shape 0 — frame
:observabilitysink. The default for a hosted-monitor forwarder that must keep firing in production. The framework projects the record for you; you write noredact-sensitive/cap-or-elideby hand. - Shape A —
register-listener!(dev-only trace surface) for a cross-frame observer that is meant to elide in production (a dev panel, a local audit log shared between operators). DCEs out of a production bundle — never use it for a hosted monitor. - Shape B —
register-epoch-listener!(dev-only) for assembled-per-cascade observers. - Shape C — per-frame
:interceptorsfor behaviour-modifiers (not observers).
For an intentionally corpus-wide production forwarder (one fan-out across every frame, not per-frame policy), use register-event-listener! / register-error-listener! instead of Shape 0 — same projection contract, no frame-scoping. Shapes A and B's hand-rolled redact-sensitive / cap-or-elide helpers are not needed there: those substrates apply off-box elision before fan-out, exactly like the frame sink.
Shape 0 — frame :observability sink (the default for a production-survivable off-box forwarder)¶
A forwarder classified "observer (off-box egress)" that must run in production lands here, not on Shape A. Declare a sink under the frame's :observability policy and register its fn; the runtime delivers an already-projected record (the owning frame's :sensitive / :large classification composed with the entry's egress profile), so the §2 sensitive redaction and the §3 size-cap are framework-managed — you do not re-walk:
(ns my-app.observability
(:require [re-frame.core :as rf]
[sentry.core :as sentry]
[datadog.core :as datadog]))
;; 1. Declare the sink policy on the frame. (Durable app-db classification
;; is NOT a frame key — a handler returns the :sensitive / :large
;; commit-plane effects alongside its :db write, EP-0025.)
(rf/make-frame
{:id :app/main
:observability {:handled-events [{:sink :my-app.sinks/datadog
:rf.egress/profile :rf.egress/off-box-observability}]
:errors [{:sink :my-app.sinks/sentry
:rf.egress/profile :rf.egress/off-box-observability}]}})
;; 2. Register the concrete sink fns. The record is ALREADY projected —
;; no redact-sensitive, no cap-or-elide; sensitive paths arrive :rf/redacted.
(rf/register-observability-sink! :my-app.sinks/datadog
(fn [record] (datadog/track-event! record)))
(rf/register-observability-sink! :my-app.sinks/sentry
(fn [record] (sentry/capture! record)))
This is the v2-canonical target for the majority of "observer (off-box egress)" hits — anything that worked in v1 by hooking the dispatch envelope to ship telemetry to a hosted backend. The privacy + oversize machinery §2–§3 build by hand exists for the cases that can't use Shape 0 (Shapes A/B's dev surface, and bespoke pre-projection transforms); the frame sink composes that machinery for you. Where the v1 site's payload walked sensitive keys, propose the commit-plane classification-effect (:sensitive / :large returned by a reg-event alongside :db) / registration :sensitive declarations (per the §"Schema-declaration follow-on") so the projection covers them — the declaration is strictly better than a per-site explicit drop, exactly as §2 already notes for the schema path.
Shape A — register-listener! for dev-only cross-frame observers (the M-13 / M-17 cross-frame-observer replacement)¶
Dev-only surface — elides in production. Port a hit here only when the observer is genuinely dev-loop-only (a dev panel, a console audit log). A hosted-monitor forwarder ported to Shape A ships nothing under
:advanced+goog.DEBUG=false— that site belongs on Shape 0 (or the corpus-wideregister-event-listener!/register-error-listener!) above.
(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 [payload dropped-count over-budget?] — elides DECLARED sensitive/large
paths (schema-aware walker) plus floor redaction for undeclared sensitive keys,
then enforces a genuine serialised-byte budget as a backstop. The walker's
threshold is ADVISORY (it warns on an undeclared large value and returns it
unchanged, it does not bound it); the :budget-bytes backstop is the hard cap
that keeps an oversize undeclared value off the wire."
[v {:keys [budget-bytes frame] :or {budget-bytes 32768 frame :rf/default}}]
(let [floor-redacted (redact-sensitive-floor v)
opts {:rf.size/threshold-bytes budget-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? %) (contains? % :rf.size/large-elided))))
count)
over? (> (count (pr-str elided)) budget-bytes)]
(if over?
[{:audit/over-budget {:budget-bytes budget-bytes :bytes (count (pr-str elided))}}
dropped true]
[elided dropped false])))
;; --- The trace listener registration (the M-13 / M-17 replacement) ---
(rf/register-listener! :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
over?] (cap-or-elide event-v {:budget-bytes 32768
:frame frame})]
(when (or (pos? dropped) over?)
(rf/dispatch [:audit/dropped-counter-inc
{:operation (:operation trace-event)
:dropped dropped
:over-budget over?}]))
(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/over-budget over?
: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 over-budget]}]]
(-> db
(update-in [:audit/counters :total-dropped] (fnil + 0) (or dropped 0))
(update-in [:audit/counters :per-operation operation] (fnil + 0) (or dropped 0))
(cond-> over-budget
(update-in [:audit/counters :over-budget] (fnil inc 0))))))
(rf/reg-sub :audit/dropped-counter
(fn [db _] (:audit/counters db)))
This is the structural rewrite target for every dev-only "observer-shaped reg-event-error-handler / reg-global-interceptor / add-post-event-callback / handler-body telemetry" hit from §1 — the ones meant to elide in production. (Production-survivable off-box forwarders land on Shape 0's frame sink, or the corpus-wide register-event-listener! / register-error-listener!, where the framework composes this same redaction + size-cap for you.) On the dev surface 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 dev-surface 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-listener! 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-listener! rather than register-listener! — the framework hands the listener one assembled :rf/epoch-record per dequeued event's run-to-completion — not per drain-settle, so a parent event plus the :fx [[:dispatch …]] child it queues yields two records — with the structured :sub-runs / :renders / :effects projections (per 009 §register-epoch-listener! — assembled-epoch listener). The mediation body is structurally similar to Shape A but operates on the epoch record:
(rf/register-listener! :epoch :my-app/post-mortem-shipper
(fn epoch-shipper [epoch-record]
(when-not (:rf.epoch/sensitive? epoch-record) ;; honour epoch-level rollup
;; Project at the egress boundary FIRST — the listener holds the RAW record.
;; projected-record is the single off-box projection (frame/profile redaction,
;; raw :db-before / :db-after stripped, plus any installed :redact-fn). It elides
;; DECLARED sensitive/large paths but does NOT bound an undeclared oversize slot,
;; so cap-or-elide's :budget-bytes backstop below is what actually bounds the
;; already-projected payload against the destination budget.
(let [projected (rf/projected-record epoch-record)
[bounded dropped over?] (cap-or-elide projected
{:budget-bytes 65536
:frame (:frame epoch-record)})]
(when (or (pos? dropped) over?)
(rf/dispatch [:audit/dropped-counter-inc
{:operation :epoch/settled
:dropped dropped
:over-budget over?}]))
(if over?
;; Over budget even after projection — forward the bounded over-budget
;; marker, never the oversize record.
(post-mortem-svc/forward {:over-budget (:audit/over-budget bounded)
: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.- Every off-box epoch forwarder MUST project at the egress boundary. The listener receives the raw record, so a forwarder that ships
epoch-recordstraight off-box leaks whatever the raw record holds. Route it through(rf/projected-record record)first (or(rf/projected-history frame-id)for the whole ring) — the single off-box egress projection: it applies the frame/profile classification and strips the raw:db-before/:db-after. Do not assume the record was scrubbed at storage; it was not. - The
(rf/configure! {:epoch-history {:redact-fn ...}})hook is a projection-side advanced override, not a storage-time scrub and not a substitute forprojected-record. When installed it runs once per projection call, insideprojected-record, after the built-in frame/profile projection — an escape hatch for sensitive slots the frame's classification can't prove. It never mutates stored records: the in-process ring buffer and everyregister-epoch-listener!listener still deliver the raw record (epoch records are causal replay material — mutating them at rest would corruptrestore-epoch!fidelity), so installing a:redact-fndoes not let a forwarder skipprojected-record. Reach for it when the payload carries sensitive material the schema-driven projection can't reach — declared once at boot, it composes into every forwarder'sprojected-recordrather than being hand-rolled per site; where the schema already classifies those slots,projected-recordalone suffices.
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 production-survivability classification (must-survive vs dev-only), the v2 target shape (0 / A / B / C), the framework defaults composed (sensitive guard, walker defaults — or the frame-sink projection for Shape 0), 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
:budget-bytesbackstop and the rationale (Sentry budget, log-file size, dashboard payload limit) — operator confirmation per listener. (The walker's:rf.size/threshold-bytesadvisory is separate: it only nudges undeclared-large paths toward a:largedeclaration and does not bound the payload.) - 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 production-survivable observability surfaces are the frame
:observabilitysinks (register-observability-sink!— the default) and the corpus-wide always-on event-emit / error-emit listeners beneath them (per 009 §What IS available in production and the entry-point hierarchy in 009 §Use case × surface routing). Observability sites that need a production-survivable path go through those surfaces, not throughregister-listener!/register-epoch-listener!(which elide on:advanced + goog.DEBUG=false). (There is no app-steering:on-errorrecovery policy — that earlier draft surface was removed; recovery is framework-owned. See M-13.)