Most logging pipelines capture everything and promise to redact later. Bounded's observe path records the shape of an action, not its contents. Raw payloads never leave the process. Here is how, and where the line is.
The standard way to build observability is to capture everything and promise to redact it later. Ship the full request and response to a logging service, run a scrubber, hope the scrubber's patterns were complete. Every "we redact PII" pipeline I have seen still has the raw payload sitting in a buffer for the moments before the scrubber runs, and in a breach those moments are enough. Bounded's observe path is built the other way around. It records the shape of an action, never its contents. The raw payload never leaves the process. Here is how, and exactly where the line is.
Shape, not contents
When an app on Bounded takes an external action, an agent spending on a model, a call to Stripe, a tool invocation, the event that gets recorded is metadata only:
- the destination host;
- the templated path (UUIDs, numeric ids, and hashes become
{id} before anything leaves the process);
- the method, status, duration, and byte counts;
- the actor context, meaning who did it.
Each event is also classed: an action on success, an error on a failed or declined call. For an unrecognized route, that is the entire event. Not the body. Not the query-string values. Not the headers. "agent:support-1 called POST stripe.com/v1/refunds, 200, 240ms" tells you what happened without telling you anything about whom it happened to.
The one exception, and it is an allow-list
There is exactly one case where a value, not just a shape, is recorded. For a small set of recognized routes, Stripe refunds, charges, and payment intents, OpenAI and Anthropic spend, the event carries a few safe fields: amounts in cents, opaque ids like ch_…, model names, token counts. Those are what turn a log line into a readable action story:
event.json
{
"class": "action",
"actor": { "id": "agent:refunds-bot", "kind": "agent", "grade": "attested" },
"dest": { "host": "mcp://payments", "pathTemplate": "/issue_refund", "method": "POST" },
"rec": { "rail": "mcp", "action": "issue_refund", "fields": { "amount_cents": 4900 } },
"status": 200, "dur_ms": 240, "bytes": 512
}
→ "agent:refunds-bot refunded $49.00 on payments"
The set of capturable fields is a compiled-in allow-list of non-PII scalar keys: amount, currency, quantity, reason, status. To capture anything beyond it you change the configuration explicitly, with a flag. There is no code path that widens capture on its own, and no heuristic that decides a field looks safe enough. The default is deny. You opt specific fields in.
What never leaves the process
Recorded
- Destination host and templated path
- Method, status, duration, byte counts
- Actor: who took the action
- Allow-listed safe fields (amount, currency, status)
Never captured
- Request and response bodies
- Query-string and header values
- Prompts, completions, and messages
- Any PII-named field (email, card, password, address, token)
That PII denylist is not advisory. It is compiled into the sensor, it overrides the allow-list, and it is not runtime-configurable. The ingest then re-applies the same key denylist independently on the server, plus a value scrubber for anything shaped like a card number, an email, or a JWT. Two independent layers would both have to fail for a payload to leak, and one of them cannot be reconfigured at all. That is the design: defense in depth, not a single pattern you have to trust.
The actor is the key
Every event is attributed. In the MCP wrapper, the sensor key is the actor: a minted key is an attested credential, so the event is attributed to an attested agent, and you can override the id per server (agent:refunds-bot). In the Node shim, you map a request to an actor for the length of its async chain, or scope an explicit actor around a block for bots and jobs. Actor ids are meant to be opaque internal ids, never emails, and an email-shaped actor value gets redacted as suspected PII like anything else. Calls you do not attribute are still captured; they roll up under an unattributed bucket the dashboard makes visible, so you improve attribution over time instead of losing the event.
Visible is not safe
One thing I want to be plain about: watching is reporting, not protection. An observed action is visible, not safe. The observe path never blocks, alters, or delays a call, and it is fail-open by design. If Bounded is unreachable, the wrapped app behaves exactly as it did before. So the honest thing to tell a user about a watched action is that you can see it, not that it is guarded.
Enforcement is a separate, deliberate step: you promote a baseline into an actual invariant, which is a different trust level with a different guarantee. On the reporting side, if events are ever dropped under backpressure, the drop count is reported on the next send, so a quiet feed does not get mistaken for a safe one.
Why do it this way
Capture-everything-redact-later optimizes for the debugging session you might have someday, at the cost of holding data you never wanted. Record-shape-only optimizes for the breach you do not want to have, at the cost of not being able to reconstruct a payload after the fact, which for most operational questions, who did what, to which host, how much, how often, you never needed anyway.
The dashboard lives at app-<appId>.bounded.sh: a Feed of recognized actions with their stories, an Actors view of who did what, and a Coverage tab showing which sensors are live so you know whether to trust a quiet feed. The MCP wrapper is one line in front of your server; the shim is a single --require in front of your Node process. Neither can capture what it never reads. It is built on Bounded, and so is poof.new.