Privacy & non-retention
ZeroSignal promises payers that the content of their prompts and the model's responses is not retained anywhere on the part of the request path you control.
Encryption gets that content to your node and back without anyone in between reading it, and needs nothing from you. In standard mode, your node holds plaintext after decryption. Do not log or store it.
Logging or storing payer prompts or responses violates the network's promise to its users, and an operator found doing it is subject to eviction and slashing of its staked USDC.
If you cannot meet this — a regulatory obligation, an internal audit mandate, a logging policy you don't control — do not run a node.
What you must not do
The node software does not log decrypted prompts or response bodies. Keeping it that way is your responsibility, and it is easy to undo from outside the process:
- Don't wrap the node with anything that captures request or response
bodies: nginx
$request_bodylogging, an Envoy access-log body field, mitmproxy, a debugging sidecar, any "log everything" shim. A TLS-terminating reverse proxy in front of the node is fine; one that records what flows through it is not. - Don't persist decrypted prompts, completions, tool-call arguments, or tool-call outputs to disk, a database, an observability platform, or any third-party service.
- Don't add prompt or response content to log statements in a local patch of the node. To inspect a specific request, use synthetic prompts in development, never a production node serving real payers. To see what your upstream rejected, use the fenced switch in Debugging without breaking it.
- Don't enable a backend's own prompt logging. Kronk's
KRONK_INSECURE_LOGGING=trueis the one you are most likely to meet; it logs prompts on your box.
What is safe to log
Everything settlement and observability need:
| Safe | Not safe |
|---|---|
| Token counts, images produced | Prompts, completions, deltas |
| Ticket IDs, operator and node IDs | Tool-call arguments and outputs |
| Model names, latency, microUSDC amounts | Request or response bodies |
| Error reasons and upstream error codes | The payer's Algorand address |
The payer's Algorand address is not safe to log, even though it isn't prompt content. It is a stable pseudonym, so a third-party log sink that collects it over time can join everything else it logs to that one payer. The reference node keeps it out of its logs; keep it out of yours.
Use the ticket ID as your correlation key instead. It joins to the on-chain ticket box, which is all any legitimate dispute or reconciliation needs.
Your algod provider sees the payer's address. Before serving, admission probes the payer's account (a funds check, and on the free tier an allowance simulate), and both carry the payer's address to whatever algod you point at. A shared public RPC therefore learns which payers reserve on your node. There is no protocol-level fix today; if that matters to you or your payers, run your own algod.
Retention at your upstream
Non-retention on your node is unconditional. Retention at the LLM provider behind it is caller-controlled, with a privacy-preserving default.
Both the proxy and your node inject store: false on every admitted request
that did not set the field, so a client that omits it (curl, a quick script)
doesn't inherit the provider's retention default. A client that needs
server-side retention may send store: true, and that value is preserved end to
end. The usual reason is Responses API previous_response_id continuation,
which strict providers refuse alongside store: false.
So if you front ZeroSignal traffic with an OpenAI key, that key's storage and dashboard contain only the requests where the caller explicitly opted in.
store: false is not zero data retention on every vendor. xAI retains API
traffic for 30 days by default regardless of store, and only the org-level
Zero Data Retention setting disables it. The node
requires that setting automatically and
unconditionally
for any api.x.ai backend, on both the text and image paths.
On OpenRouter, your dashboard toggles can't loosen what the node sets. By
default every request the node sends pins provider.zdr: true and
provider.data_collection: "deny", and OpenRouter's tighten-only rules mean your
account settings cannot weaken them. That also closes the Data Training block,
which is separate from ZDR and by default permits free endpoints that train on
requests. The trade-off: a model with no ZDR endpoint
can't be served at all.
If you need such a model, you can release
that pin, losing the training protection with it.
Where the upstream offers no such mechanism — a first-party OpenAI or Azure key, a runtime on hardware you own — the node has nothing to pin or check, so it advertises nothing by default. If you hold a real zero-retention agreement there, declare it:
llm:
upstream_zdr_declared: true # and image_llm.upstream_zdr_declared for images
This advertises operator_declared, the weakest tier: an unverified assertion.
It is ignored on any upstream where the node already derives a stronger tier, so
it can neither upgrade nor downgrade a checked node. zs-node doctor warns if
you leave it set where it has no effect, which usually means it outlived the
base_url it was written for.
What your node advertises about retention
Each model on /v1/zs/details carries a retention value saying whether the
prompt is retained anywhere once it leaves the payer, and on what evidence.
Strongest first:
| Value | What it means | How good the evidence is |
|---|---|---|
tee_attested | Local weights inside a TEE. Nothing leaves the node to answer the request, and your node's own non-retention is hardware-attested. | Attested |
upstream_confirmed | The upstream affirms zero retention on every response and the node refuses any that doesn't. xAI. | Checked per request |
upstream_enforced | The node pins a constraint the upstream can only tighten, so a retaining endpoint is unroutable. OpenRouter. Scoped to the endpoint that answers, and never confirmed back. | Checked indirectly |
no_upstream | Local weights or a loopback runtime, no TEE. The prompt never leaves the box to be answered. | Your commitment |
operator_declared | You asserted an arrangement the node can't check. | Your word |
| (absent) | No signal. This is the case for most hosted upstreams, and for an OpenRouter route whose pin you released. | — |
Absent means unknown, not "retains". The reference chat app shows a neutral "not stated" chip for it, so a payer can tell "this operator made no claim" from "we haven't finished asking".
Every tier describes the route your node takes to answer the request. It
does not cover tools the caller switches on: zs_web_search and zs_web_read
run only when the caller lists them in that request, and their outbound traffic
is covered under
Outbound requests your node makes below.
Tiers are ordered by strength of evidence, not by privacy.
upstream_confirmed outranks no_upstream even though it is less private in
the sense most people mean. It describes only the destination: the upstream
won't keep the prompt. It says nothing about whether your node logged it on the
way past. That part is covered by the commitment at the top of this page, and
only a TEE makes it verifiable.
"Destination" means the endpoint that answered, which matters if you run an
aggregator. upstream_enforced is a constraint your node sends out. Nothing
comes back confirming it was honoured, and it governs only which endpoint may
serve the request. If that upstream is a broker (OpenRouter is the one this node
pins), the broker reads the prompt on the way through, and its own retention is
a separate account setting the pin does not touch. The tier means only: no
retaining endpoint answered, as far as the node can tell. That is also why a
confidential node can't be an
attested_passthrough to a broker:
that posture has to name the one party that read the prompt.
The value never affects routing.
If you need unconditional upstream non-retention — a compliance obligation,
a contractual commitment to your payers, a jurisdictional constraint the
caller-controlled default doesn't satisfy — you can force store: false at your
edge with a small middleware that overwrites store before the request is
forwarded. This also disables previous_response_id continuation for your
clients, so tell them.
Debugging without breaking it
Two switches exist so you never have to hand-patch a log line.
llm.openai.debug_dump_errors — dev only. When an OpenAI-compatible
upstream rejects a request (a vLLM validation error, a strict gateway's opaque
400, a context_length_exceeded), the client sees a sealed error and you
can't tell what bytes the node sent. With this set, the node prints the request
body it sent and the upstream's response body to stderr, both
pretty-printed, on every non-2xx.
llm:
openai:
debug_dump_errors: true # or NODE_LLM_OPENAI_DEBUG_DUMP_ERRORS=true
It writes decrypted content to stderr, outside the guarantee above. So:
- It is off by default.
- It logs a loud startup WARN whenever it is on.
- It is a hard startup error under
tee.mode != none. - The dump goes only to stderr, never through the structured logger, so it cannot leak into a log shipper. It will land in your journal if that is where stderr goes.
- Large image-tool turns produce large dumps (base64 data URLs in full).
Never enable it on a production node serving real payers.
llm.openai.log_raw_usage — safe, just noisy. To see the exact token usage
object your upstream returns, including cached-token fields the node may not yet
interpret, set it and the node emits a raw upstream usage INFO line carrying
the usage object verbatim for every response. The usage object is counts
only, with no prompt or response content, so this is privacy-safe and is not
gated by tee.mode. It is off by default only to avoid log noise. Turn it on for
a capture run when checking whether a provider reports cached tokens, then turn
it back off.
Outbound requests your node makes
Each of these is documented where it's configured:
zs_web_readfetches pages in-process. The site sees your node's IP and azs-node/…user agent; no intermediary learns what was read. The model may only read a URL the user supplied or a prior search returned, which stops a hostile page from steering it into sending the conversation to an attacker's URL. Web search goes to DuckDuckGo. See Built-in tools.- Source favicons are fetched by your node, not the client, after a search
round on a streaming request, so the user's IP never reaches the result sites.
The bytes ride the sealed response, so a relay can't read them. Turn them off
with
zs.builtin_tools.favicons.enabled: falseif your node has metered or locked-down egress. See Source favicons. - HuggingFace model discovery fetches each served model's repo once near
startup, which tells HuggingFace your served set (already public on
/v1/zs/details). Disable it withzs.coordinates.discover_huggingface: false. See Model integrity.
What you can and can't see
In standard mode you see the decrypted prompt but not who sent it, and as a relay you can't read what you forward; see Encryption & keys. For a guarantee that you cannot read the prompt at all, see Confidential compute.