diff --git a/.agents/skills/add-middleware/SKILL.md b/.agents/skills/add-middleware/SKILL.md index c4bd69f0a..13dd674b6 100644 --- a/.agents/skills/add-middleware/SKILL.md +++ b/.agents/skills/add-middleware/SKILL.md @@ -24,7 +24,7 @@ middleware behavior to a new pipeline stage. Decide these before editing code: -- Is this for tools, LLMs, or both? +- Is this for tools, LLMs, marks, scope events, or a combination? - Is it a conditional guardrail, sanitize guardrail, request intercept, or execution intercept? - Does it run on request input, inner callable execution, stream chunks, or @@ -33,6 +33,8 @@ Decide these before editing code: - Does it need both global and scope-local registration? - What should subscribers observe in `event.input` and `event.output` after this middleware runs? +- If this is an event sanitizer, which of `data`, `category_profile`, and + `metadata` may change, and is the event used only as immutable context? ## Pipeline Order @@ -44,6 +46,9 @@ See `docs/about/concepts/middleware.md` for the full diagrams. - **LLM execute**: conditional guardrails -> request intercepts -> sanitize request (for events) | execution intercept chain(callable) -> sanitize response +- **Mark and scope events**: + specialized tool/LLM sanitizer (when applicable) -> mark or scope event + sanitizer -> subscriber dispatch ## Core Steps @@ -88,6 +93,7 @@ Follow the `add-binding-feature` skill for the cross-binding implementation chec - [ ] Callback error propagation - [ ] Scope-local registration, inheritance, and cleanup on pop - [ ] Event input/output semantics after middleware mutation +- [ ] Mark and scope event field semantics, including immutable identity fields - [ ] Parity coverage in every affected binding ## Key References diff --git a/crates/node/README.md b/crates/node/README.md index 8a131f83f..3ff1cca3d 100644 --- a/crates/node/README.md +++ b/crates/node/README.md @@ -41,7 +41,9 @@ should install it from npm rather than depend on the Rust crate directly. napi-rs native extension. - **Managed tool and LLM execution**: Helpers that emit lifecycle events and run middleware in a consistent order. -- **Middleware APIs**: Guardrails and intercepts for tool and LLM boundaries. +- **Middleware APIs**: Guardrails and intercepts for tool and LLM boundaries, + plus mark and scope event sanitizers for `data`, `categoryProfile`, and + `metadata`. - **Observability exporters**: Subscriber and exporter support for common runtime telemetry flows. - **Additional entry points**: `nemo-relay-node/typed`, diff --git a/docs/about-nemo-relay/concepts/events.mdx b/docs/about-nemo-relay/concepts/events.mdx index dddf27cd0..15d9706ac 100644 --- a/docs/about-nemo-relay/concepts/events.mdx +++ b/docs/about-nemo-relay/concepts/events.mdx @@ -103,6 +103,12 @@ exporters. Consumers should prefer typed event fields, binding helpers, and annotated request or response data before falling back to raw payload re-reading. +Before that handoff, event sanitizers can replace `data`, `category_profile`, +and `metadata`. The runtime keeps identity, lifecycle, category, timestamp, +and parent fields immutable. Refer to +[Event Sanitizers](/reference/event-sanitizers) for registration levels and +ordering. + Events preserve runtime facts. They do not decide which scope owns a call, how replay or cost policy is applied, how redaction policy is configured, how streams are blocked, or how exporter-specific semantic projection works. Those diff --git a/docs/about-nemo-relay/concepts/middleware.mdx b/docs/about-nemo-relay/concepts/middleware.mdx index 427782afe..08cc96d38 100644 --- a/docs/about-nemo-relay/concepts/middleware.mdx +++ b/docs/about-nemo-relay/concepts/middleware.mdx @@ -8,7 +8,8 @@ import { MermaidStyles } from "@/components/MermaidStyles"; {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} -This page explains the runtime behavior that runs around managed tool and LLM calls. +This page explains the runtime behavior that runs around managed tool and LLM +calls and sanitizes emitted mark and scope events. ## What Middleware Is @@ -106,6 +107,22 @@ Sanitize-response guardrails rewrite the payload recorded on emitted end events. Use them when the event stream should hide or reduce sensitive response data. +### Sanitize Mark and Scope Events + +Event sanitizers cover observability fields that are outside the specialized +tool and LLM payload APIs. Separate registries apply to marks, scope starts, +and scope ends. They can rewrite `data`, `category_profile`, and `metadata` +while receiving the complete event as immutable context. + +Scope event sanitizers run for every category. On tool and LLM scope events, +they run after the specialized request or response sanitizer. Mark sanitizers +cover explicit marks and marks materialized by middleware, plugins, and +streaming lifecycle helpers. + +Register event sanitizers globally, on an owning scope, or through a plugin +context. For the callback contract and binding APIs, refer to +[Event Sanitizers](/reference/event-sanitizers). + Sanitize guardrails are observability-oriented. They do not rewrite the real arguments passed to the callback or the real value returned to the caller. @@ -261,6 +278,9 @@ Use these comparisons to pick the middleware surface that matches the behavior y boundary. - Use a **sanitize guardrail** when only subscribers and exporters should see rewritten data. +- Use a **mark or scope event sanitizer** when the sensitive fields are in + `data`, `category_profile`, or `metadata` rather than the managed tool or LLM + request/response payload. - Use a **stream execution intercept** when you need streaming-specific behavior applied across the lifecycle of a long-lived or chunked response, such as per-chunk transformation, incremental authorization, logging or diff --git a/docs/build-plugins/language-binding/about.mdx b/docs/build-plugins/language-binding/about.mdx index 6e8e1a306..73359fd44 100644 --- a/docs/build-plugins/language-binding/about.mdx +++ b/docs/build-plugins/language-binding/about.mdx @@ -64,6 +64,8 @@ references during registration. A plugin can install one or more of these runtime surfaces: - **Subscribers:** Event subscribers. +- **Event sanitizers:** Mark, scope-start, and scope-end observability-field + sanitizers. - **Tool middleware:** Sanitize-request and sanitize-response guardrails, conditional-execution guardrails, request intercepts, and execution intercepts. diff --git a/docs/build-plugins/language-binding/advanced-configuration.mdx b/docs/build-plugins/language-binding/advanced-configuration.mdx index a1b75d68d..a9bec92cb 100644 --- a/docs/build-plugins/language-binding/advanced-configuration.mdx +++ b/docs/build-plugins/language-binding/advanced-configuration.mdx @@ -158,6 +158,8 @@ Prefer observe-only defaults for new policies and execution-affecting intercepts Use `PluginContext` to register: - Subscribers +- Mark event sanitizers +- Scope-start and scope-end event sanitizers - Tool guardrails - Tool request and execution intercepts - LLM guardrails diff --git a/docs/build-plugins/language-binding/register-behavior.mdx b/docs/build-plugins/language-binding/register-behavior.mdx index 1bf911723..f84967af8 100644 --- a/docs/build-plugins/language-binding/register-behavior.mdx +++ b/docs/build-plugins/language-binding/register-behavior.mdx @@ -34,6 +34,14 @@ Use the context only after validation succeeds. Keep validation deterministic an side-effect free. Inspect configuration and return diagnostics. Create runtime objects and attach them to the context during registration. +The context includes mark, scope-start, and scope-end event sanitizer +registrations in addition to the tool and LLM middleware surfaces. Event +sanitizer callbacks receive the immutable event plus `data`, +`category_profile`, and `metadata`, and return only those observability fields. +Use the context methods so component name qualification and rollback also apply +to these registries. Refer to [Event Sanitizers](/reference/event-sanitizers) +for the binding-specific method names. + ## Header Plugin Example The same model applies in every binding: validate component-local config, then install middleware through the component-scoped registration context. diff --git a/docs/configure-plugins/pii-redaction/about.mdx b/docs/configure-plugins/pii-redaction/about.mdx index 73711e4e8..6c06ceccc 100644 --- a/docs/configure-plugins/pii-redaction/about.mdx +++ b/docs/configure-plugins/pii-redaction/about.mdx @@ -8,8 +8,8 @@ position: 1 SPDX-License-Identifier: Apache-2.0 */} Use the PII redaction plugin when you want first-party privacy controls for -managed NeMo Relay LLM and tool observability surfaces through the shared -plugin system. +managed NeMo Relay LLM, tool, mark, and generic scope observability surfaces +through the shared plugin system. The built-in plugin component uses `kind = "pii_redaction"` and is available as a first-party NeMo Relay plugin. @@ -30,7 +30,8 @@ The plugin supports these backend modes: Start here when you need to: -- Remove sensitive fields from emitted tool or LLM payloads. +- Remove sensitive fields from emitted tool, LLM, mark, or generic scope + payloads. - Replace sensitive text with a deterministic marker such as `[REDACTED]`. - Hash matching values before observability exporters or subscribers receive them. @@ -66,10 +67,11 @@ The difference between middleware and `pii_redaction` is that middleware is the ## Current Scope -The built-in plugin currently exposes four managed sanitize surfaces: +The built-in plugin exposes five managed sanitize surfaces: - `input` - `output` +- `mark` - `tool_input` - `tool_output` @@ -88,6 +90,11 @@ The current backend boundary is intentional: - Managed LLM surfaces use the selected built-in codec so redaction can target normalized Relay request and response shapes such as `/messages/0/content` and `/message`. +- `mark` sanitizes `data`, `category_profile`, and `metadata` independently on + every mark event. It defaults to `true`; set `mark = false` to opt out. +- `input` and `output` also sanitize those three fields on non-tool, non-LLM + scope starts and ends. Tool and LLM scope envelopes remain on their + specialized sanitizer paths to avoid applying the same mask or hash twice. ## Observability Boundary diff --git a/docs/configure-plugins/pii-redaction/configuration.mdx b/docs/configure-plugins/pii-redaction/configuration.mdx index 1d2c16af6..fca02822f 100644 --- a/docs/configure-plugins/pii-redaction/configuration.mdx +++ b/docs/configure-plugins/pii-redaction/configuration.mdx @@ -66,8 +66,9 @@ The top-level PII redaction object contains: |---|---| | `version` | PII redaction config schema version. Defaults to `1`. | | `mode` | Backend mode. Current values are `builtin` and `local_model`. | -| `input` | Enables managed LLM request sanitization. | -| `output` | Enables managed LLM response sanitization. | +| `input` | Enables managed LLM request sanitization and non-tool/LLM scope-start field sanitization. | +| `output` | Enables managed LLM response sanitization and non-tool/LLM scope-end field sanitization. | +| `mark` | Enables mark `data`, `category_profile`, and `metadata` sanitization. Defaults to `true`. | | `tool_input` | Enables sanitization of emitted tool-request observability payloads. | | `tool_output` | Enables sanitization of emitted tool-response observability payloads. | | `priority` | Guardrail priority. Lower values run earlier. | @@ -87,6 +88,7 @@ The following table compares the available PII redaction backends: | Built-in component kind and config validation | Supported | Supported | | Managed LLM `input` | Supported | Not implemented | | Managed LLM `output` | Supported | Not implemented | +| Mark and generic scope event fields | Supported | Not implemented | | Managed `tool_input` | Supported | Not implemented | | Managed `tool_output` | Supported | Not implemented | | Built-in actions | `remove`, `redact`, `regex_replace`, `hash`, `mask` | N/A | @@ -131,6 +133,7 @@ mode = "builtin" codec = "openai_chat" input = true output = true +mark = true tool_input = true tool_output = true @@ -158,6 +161,12 @@ This example configures the built-in backend for: - LLM response redaction from the normalized response path `/message` - tool argument redaction at `/api_key` - tool result redaction at `/result/secret` +- mark and non-tool/LLM scope event redaction across `data`, + `category_profile`, and `metadata` + +`mark` is enabled when omitted. Set `mark = false` only when marks are known to +contain no sensitive observability fields or another sanitizer owns that +boundary. ### CLI Editor Support @@ -168,6 +177,7 @@ Use the editor when you want to: - Toggle the component on or off - Choose `builtin` or `local_model` +- Enable or opt out of mark sanitization - Set the LLM `codec` - Edit `builtin` action settings such as `action`, `target_paths`, `pattern`, `detector`, `replacement`, and masking fields @@ -277,6 +287,10 @@ The plugin uses different payload boundaries for tools and LLMs: - LLMs use the selected built-in codec. Prefer normalized Relay paths such as: - `/messages/0/content` for request message content - `/message` for the normalized assistant response text +- Marks and non-tool/LLM scopes sanitize `data`, `category_profile`, and + `metadata` as separate JSON values. A target path is evaluated independently + inside each value; it does not start with `/data`, `/category_profile`, or + `/metadata`. The current implementation also preserves provider-shaped response-path compatibility for the supported codecs, but normalized LLM paths are the @@ -327,14 +341,23 @@ string leaf in the selected payload boundary. ## Observability Semantics -The built-in plugin uses sanitize guardrails. +The built-in plugin uses sanitize guardrails and event sanitizers. That means: -- The real provider response value remains unchanged. -- The plugin sanitizes emitted NeMo Relay start and end event payloads. -- The plugin populates `annotated_response` from the sanitized end-event payload - when a response codec is available. +- the real provider response value is unchanged +- the emitted NeMo Relay start, end, or mark event observability fields are + sanitized +- `annotated_response` is populated from the sanitized end-event payload when a + response codec is provided +- subscribers, ATOF, ATIF, OpenTelemetry, and OpenInference receive the + sanitized event; raw values removed by the plugin do not bypass through an + exporter + +For tool and LLM scope events, the generic scope sanitizer leaves the shared +fields unchanged because the specialized tool or LLM sanitizer already owns +that lifecycle boundary. This prevents repeated hashing or masking. Generic +scope categories use `input` for starts and `output` for ends. ## Local Model Mode diff --git a/docs/instrument-applications/adding-scopes-and-marks.mdx b/docs/instrument-applications/adding-scopes-and-marks.mdx index ce7ede293..d40270f0f 100644 --- a/docs/instrument-applications/adding-scopes-and-marks.mdx +++ b/docs/instrument-applications/adding-scopes-and-marks.mdx @@ -200,7 +200,9 @@ Use this checklist before running the pattern in production traffic. - Keep scope names stable enough for filtering and dashboards. - Use marks for business checkpoints, not verbose debug logging. - Include only JSON-compatible `data`, `metadata`, `input`, and `output` payloads. -- Do not attach sensitive payloads unless sanitize guardrails or exporter filters will remove them. +- Do not attach sensitive payloads unless a mark or scope + [event sanitizer](/reference/event-sanitizers) removes them before subscriber + and exporter delivery. - Propagate the active scope stack when work crosses thread or worker boundaries. ## Next Steps diff --git a/docs/reference/event-sanitizers.mdx b/docs/reference/event-sanitizers.mdx new file mode 100644 index 000000000..bfc7ed786 --- /dev/null +++ b/docs/reference/event-sanitizers.mdx @@ -0,0 +1,181 @@ +--- +title: "Event Sanitizers" +description: "Sanitize mark and scope event fields before subscribers and exporters receive them." +position: 3 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Event sanitizers rewrite observability fields on mark and scope events before +the events reach subscribers or exporters. Use them for event data that is not +covered by the tool and LLM request or response sanitizer APIs. + +## Sanitizer Surfaces + +NeMo Relay provides three registries: + +| Registry | Events | +|---|---| +| Mark sanitizer | Every mark, including explicit, pending, plugin-runtime, and streaming marks | +| Scope-start sanitizer | Every scope category with `scope_category = "start"` | +| Scope-end sanitizer | Every scope category with `scope_category = "end"` | + +Scope sanitizers run for all categories, including `tool` and `llm`. For tool +and LLM events, they run after the specialized request or response sanitizers. +This ordering lets a general event policy inspect the already-sanitized event +fields without changing the real request or response. + +## Callback Contract + +The callback receives: + +- The complete event as immutable context. +- An `EventSanitizeFields` value containing `data`, `category_profile`, and + `metadata`. + +Return the three observability fields that should be applied. Event identity, +parentage, timestamps, kind, scope category, semantic category, attributes, +input/output lifecycle meaning, and schemas cannot be changed through this +API. Set a field to `null` or `None` to remove it. Preserve fields that your +policy does not change. + +Registries are priority ordered. Lower priorities run first, and each callback +receives the fields returned by the preceding callback. Invalid callback +results fail open and preserve the current fields. Binding-specific callback +error inspection remains available where the binding exposes it. + +## Registration Lifetimes + +Event sanitizers use the same ownership model as other middleware: + +- Global registrations remain active until explicitly deregistered. +- Scope-local registrations are inherited by descendants and are removed when + the owning scope closes. +- Plugin-context registrations are qualified to the component instance and + roll back if activation fails or the plugin configuration is cleared. + +## Register a Global Mark Sanitizer + +The following examples replace mark data and remove mark metadata while +preserving the category profile. + + + +```python +import nemo_relay +from nemo_relay import guardrails + +def sanitize_mark(event, fields: nemo_relay.EventSanitizeFields): + return { + "data": {"checkpoint": event.name}, + "category_profile": fields["category_profile"], + "metadata": None, + } + +guardrails.register_mark_sanitize("safe-marks", 100, sanitize_mark) +try: + nemo_relay.scope.event("planning", data={"secret": "value"}) +finally: + guardrails.deregister_mark_sanitize("safe-marks") +``` + + + +```js +const relay = require("nemo-relay-node"); + +relay.registerMarkSanitizeGuardrail("safe-marks", 100, (event, fields) => ({ + data: { checkpoint: event.name }, + categoryProfile: fields.categoryProfile, + metadata: null, +})); + +try { + relay.event("planning", null, { secret: "value" }); +} finally { + relay.deregisterMarkSanitizeGuardrail("safe-marks"); +} +``` + + + +```rust +use nemo_relay::api::registry::{ + deregister_mark_sanitize_guardrail, register_mark_sanitize_guardrail, +}; +use serde_json::json; +use std::sync::Arc; + +register_mark_sanitize_guardrail( + "safe-marks", + 100, + Arc::new(|event, mut fields| { + fields.data = Some(json!({"checkpoint": event.name()})); + fields.metadata = None; + fields + }), +)?; + +// Emit marks while the registration is active. + +deregister_mark_sanitize_guardrail("safe-marks")?; +``` + + + +## API Names + +Use the matching start and end APIs when the policy applies to duration-bearing +scope events. + +| Level | Python | Node.js | Rust | +|---|---|---|---| +| Global mark | `guardrails.register_mark_sanitize` | `registerMarkSanitizeGuardrail` | `register_mark_sanitize_guardrail` | +| Global start | `guardrails.register_scope_sanitize_start` | `registerScopeSanitizeStartGuardrail` | `register_scope_sanitize_start_guardrail` | +| Global end | `guardrails.register_scope_sanitize_end` | `registerScopeSanitizeEndGuardrail` | `register_scope_sanitize_end_guardrail` | +| Scope-local mark | `scope_local.register_mark_sanitize` | `scopeRegisterMarkSanitizeGuardrail` | `scope_register_mark_sanitize_guardrail` | +| Scope-local start | `scope_local.register_scope_sanitize_start` | `scopeRegisterScopeSanitizeStartGuardrail` | `scope_register_scope_sanitize_start_guardrail` | +| Scope-local end | `scope_local.register_scope_sanitize_end` | `scopeRegisterScopeSanitizeEndGuardrail` | `scope_register_scope_sanitize_end_guardrail` | + +Each registration API has a corresponding `deregister` API. Python +scope-local helpers accept a scope handle. Node.js and Rust scope-local helpers +accept the owning scope UUID. + +Plugin contexts expose the same three surfaces as +`register_mark_sanitize_guardrail`, +`register_scope_sanitize_start_guardrail`, and +`register_scope_sanitize_end_guardrail`, using each binding's naming +convention. + +## Dynamic Plugins + +Rust native plugins receive the three methods on their typed plugin context. +Python `grpc-v1` workers receive the same methods on `PluginContext`; callbacks +may be synchronous or asynchronous. The worker protocol remains `grpc-v1` and +adds these stable registration surface values: + +- `MARK_SANITIZE_GUARDRAIL = 30` +- `SCOPE_SANITIZE_START_GUARDRAIL = 31` +- `SCOPE_SANITIZE_END_GUARDRAIL = 32` + +Dynamic plugins should declare only the surfaces they install and should use +the plugin context so failed activation rolls back every registration. + +## Experimental C and Go Bindings + +The source-first C API uses `NemoRelayEventSanitizeCb` and exports global, +scope-local, and plugin-context registration functions for all three surfaces. +Global names begin with `nemo_relay_register_`; scope-local names begin with +`nemo_relay_scope_register_`. + +The Go binding exposes `EventSanitizeFields`, `EventSanitizeFunc`, global +`Register*SanitizeGuardrail` helpers, scope-local +`ScopeRegister*SanitizeGuardrail` helpers, and the same methods on +`PluginContext`. The `guardrails` package provides shorter aliases. + +## Related Topics + +- [Middleware](/about-nemo-relay/concepts/middleware) +- [Events](/about-nemo-relay/concepts/events) +- [Register Plugin Behavior](/build-plugins/language-binding/register-behavior) +- [PII Redaction Configuration](/configure-plugins/pii-redaction/configuration) diff --git a/docs/reference/tool-execution-intercept-outcomes.mdx b/docs/reference/tool-execution-intercept-outcomes.mdx index ca177fe83..7567f6a23 100644 --- a/docs/reference/tool-execution-intercept-outcomes.mdx +++ b/docs/reference/tool-execution-intercept-outcomes.mdx @@ -29,10 +29,12 @@ does not expose downstream pending marks. Relay retains those marks, combines them in effective middleware order, and materializes them only after the final outcome succeeds. -There is no mark-specific registration path. Use the existing global, -scope-local, or plugin-context tool execution registration APIs and return the -canonical outcome from every registered callback. Relay rejects legacy raw -intercept returns at public and dynamic-plugin boundaries. +Use the existing global, scope-local, or plugin-context tool execution +registration APIs to produce pending marks, and return the canonical outcome +from every registered callback. Use a +[mark event sanitizer](/reference/event-sanitizers) when the emitted pending +mark's `data`, `category_profile`, or `metadata` must be sanitized. Legacy raw +intercept returns are rejected at public and dynamic-plugin boundaries. ## Managed Lifecycle @@ -44,7 +46,8 @@ mark a timestamp after the tool end event. If execution or an intercept fails, Relay emits the error end event and discards accumulated pending marks. Pending marks are never included in the -application-visible tool result or passed to sanitize-response guardrails. +application-visible tool result or passed to tool sanitize-response +guardrails. They pass through the mark sanitizer registry when materialized. ## Binding Contract diff --git a/go/nemo_relay/README.md b/go/nemo_relay/README.md index 81baaba83..39985b134 100644 --- a/go/nemo_relay/README.md +++ b/go/nemo_relay/README.md @@ -40,7 +40,8 @@ primary supported surfaces. - **Scope, tool, and LLM helpers**: Managed lifecycle APIs backed by the shared Rust runtime. - **Middleware APIs**: Guardrails and intercepts for request rewriting, - blocking, sanitization, and execution wrapping. + blocking, sanitization, and execution wrapping, including mark and scope + event sanitizers at global, scope-local, and plugin-context levels. - **Event subscribers**: Runtime lifecycle callbacks for observability and diagnostics. - **Convenience subpackages**: Short imports for scopes, tools, LLM calls, diff --git a/python/nemo_relay/README.md b/python/nemo_relay/README.md index 6d6e2fac9..c377f6e7d 100644 --- a/python/nemo_relay/README.md +++ b/python/nemo_relay/README.md @@ -41,7 +41,8 @@ runtime semantics as the Rust and Node.js surfaces. - **Scope, tool, and LLM helpers**: Managed boundaries that emit lifecycle events and run middleware in a consistent order. - **Middleware APIs**: Guardrails and intercepts for tool and LLM requests, - responses, and execution. + responses, and execution, plus mark and scope event sanitizers for `data`, + `category_profile`, and `metadata`. - **Subscribers and exporters**: Event consumers for observability and diagnostics. - **Plugin and typed helpers**: Public modules for plugins, codecs, typed diff --git a/skills/nemo-relay-export-atif-trajectories/SKILL.md b/skills/nemo-relay-export-atif-trajectories/SKILL.md index 73cceb6f8..d890af256 100644 --- a/skills/nemo-relay-export-atif-trajectories/SKILL.md +++ b/skills/nemo-relay-export-atif-trajectories/SKILL.md @@ -50,8 +50,8 @@ live OTLP spans. - ATIF exports the full event buffer collected so far. - Consecutive tool observations can be merged into one system observation step. -- Trajectories reflect sanitized event payloads, not raw secrets that - sanitize guardrails removed before event emission. +- Trajectories reflect sanitized event payloads, not raw secrets that tool, + LLM, mark, or scope event sanitizers removed before event emission. - Response codecs can improve LLM end annotations, but they do not change the caller-visible LLM response.