The vault
How much we hold, who can open it, and how you find out. The design is that nobody here can open your content alone at any tier, and nobody can open it quietly at any tier. Quietly is already impossible — every unseal lands in your own chain. Alone is what the second and third key holders end, and the box directly below names which of them the running software has.
The seal runs. Started with --vault-key, the
router seals request content at stream close, the receipt for that request reads
vault: { mode: "managed", written: true }, and /health
publishes a vault.sealed counter that rises with it. The
cryptography is AES-256-GCM per record under a fresh data key, the key wrapped
per holder with X25519 and HKDF, and a genuine Shamir 2-of-3 split whose
share-independence is proved exhaustively over the field rather than asserted.
The rest of the tier table arrives with the holders and the store.
Today there is no KMS integration, so the only holder is our own key — the
start-up banner says so in as many words, “our_key seals only (no customer or
escrow holder configured)”. There is no customer bucket. There is no escrow
agent relationship, so zdr_escrow is a mechanism we can show you the
proof for, not a service you can buy. The store is on the local filesystem of one
process, not a durable multi-region one.
The tier table is the product as designed. The banner your own router prints at start-up is what it does today. What is actually running.
We do not offer unconditional zero retention, and the reason is contractual. Reselling model access carries a duty to be able to investigate an abuse report — identify the customer, act, and cooperate with the vendor. A gateway that can never see anything cannot discharge that duty, and some obligations, CSAM reporting among them, have no privacy-architecture exemption at all.
So retention is an account-level contracted tier over a minimum-retention floor, and the strongest tiers are things you qualify for rather than toggle. That is also how the model vendors run their own ZDR programmes.
The four tiers
| Tier | What we hold | Who can open it | How you know | Gating |
|---|---|---|---|---|
| open default | 30 days, encrypted under our key | us, break-glass, two-person | unseal receipt in your chain | self-serve |
| mdr | 7 days, encrypted under our key | us, break-glass, two-person | unseal receipt in your chain | verified business |
| zdr_escrow | 72 hours, sealed 2-of-3 | nobody alone — we hold one share of three | unseal receipt, and the absence of one is provable | contract + KYC |
| zdr_absolute | nothing — your bucket only | only you | there is nothing to open | contract + indemnity + minimum commitment |
The 2-of-3 seal, in detail
On zdr_escrow the data key is not wrapped to one party. It is split
into three shares under a 2-of-3 threshold scheme:
- You hold one share.
- We hold one share.
- An independent escrow agent holds the third — named in your contract, and never a party we control.
Four consequences, each a property rather than a promise:
- We cannot decrypt unilaterally. Not "we have a policy against it" — we hold one share of three. An insider with full production access still cannot open your content.
- An investigation requires two parties to cooperate, which is a real procedural bar with a paper trail, and it satisfies the vendor obligation to be able to investigate.
- Every unseal — attempted, refused or completed — is a record in your own receipt chain, carrying the reason, the outcome and how many shares were presented. You cannot be investigated silently.
- The absence of an unseal is provable. Because the chain is complete and signed, "nobody opened anything last quarter" is something you can verify offline rather than something we assert.
The window is 72 hours by default and the ciphertext is destroyed on expiry, so even the worst case is bounded.
Where the content itself goes
How a request is sealed
- The stream closes. We assemble the request, the response and any reasoning text the provider actually emitted.
- We mint a fresh 256-bit data key and seal the payload with AES-256-GCM.
- We wrap that data key to your KMS key — AWS KMS, GCP KMS, Azure Key Vault or an HSM you control. We never hold anything that unwraps it.
- We write the ciphertext, and zero the plaintext before the request arena is released.
customer_bucket mode arrives with the object-store integration:
step 4 writes into your own bucket via a role you grant, and we hold nothing at all
— not the key, not the ciphertext. It is the mode we expect enterprises to pick and
the one we will recommend. The router has no object-store integration today, in any
mode, and step 3 wraps to our key because there is no other holder configured.
What we are honest about
Lose the key and the data is gone
Permanently. That is the guarantee working, not failing. Use your own cloud KMS so your existing key-management and recovery tooling apply, or enable escrow — a second wrapped copy under a key held by a party you nominate, never us, off by default, and disclosed on every receipt if enabled. A privacy posture with a silent exception is worse than none.
There is no server-side search, and never will be
It is not on a roadmap. Search runs in prism export, on your
machine, against the copy you decrypted. Anyone offering semantic search over
“your private logs” is holding a readable copy of them — that is not a
criticism, it is arithmetic.
We do see plaintext while proxying
Unavoidable for any gateway, load balancer or TLS-terminating proxy. What is avoidable is writing it anywhere, and no code path does — including the error paths, which is where this normally leaks. A CI guard traces body-typed values and fails on any reaching a writer.
A vault write can fail, and never silently
It cannot fail your request: the seal runs after the answer has been
produced, and a copy we could not keep never becomes an answer you do not get.
It increments vault_write_failures, publishes on the status page,
and marks the receipt vault_written: false, so a gap in your
export is visible from the chain rather than discovered a quarter later.
Retention postures
Four values. Each one names the class of document and the verification date that would back it, and the rate card records, per endpoint, whether that document has been read — the “Evidence we hold” column below is the shape of the evidence, not a claim that we hold it. Your policy sets a floor; endpoints below it are removed from the candidate set rather than ranked lower. A fallback may never lower your posture.
| Posture | Means | Evidence that would back it | Re-checked |
|---|---|---|---|
| zdr_contractual | The vendor contractually retains nothing. | DPA clause reference and date | on contract renewal |
| zdr_configured | The vendor supports zero retention and it is enabled on our account. | org setting plus API verification | every 30 days, automatically |
| transient | Retained N days for abuse monitoring only. | published policy and the window | every 30 days |
| retaining | May be retained indefinitely or used for training. | published policy | every 30 days |
One posture field, read by the eligibility filter and published by the
catalogue. That is the design, and drift between the two is the usual way a
retention promise quietly becomes false. It is now one field in one
file. Every one of the 25 cards in router/data/rate-card.json
and every one of the 10 unpriced endpoints carries the schema's
retention block, the loader installs those postures into the routing
filter at startup, and a bundle in which any card, any unpriced declaration or any
catalogue endpoint has no block fails the load and the router exits rather than
starts. The compiled table that used to answer this question survives only as a
named fallback, and the boot banner prints which of the two answered.
The evidence still has to arrive. All 35 of those blocks read
verification_state: unverified, with evidence_url and
verified_at null and a written reason in
unverified_reason, because we hold no DPA clause reference, no
zero-retention addendum and no verified organisation setting for any endpoint —
and there is no vendor contract for one to live in. The loader refuses a block
that carries an evidence URL while claiming nobody read it, and refuses one that
claims verification and shows nothing, so the two states cannot be blurred. What
changed is that the posture became accountable, not that it became evidenced,
which is why the catalogue still publishes no posture per endpoint.
“ZDR on all tiers where possible” is the honest phrasing, because it is not always possible: some models are served only on retaining substrates. We list those endpoints in the catalogue rather than hide them, and a policy with a ZDR floor simply cannot see them. A smaller catalogue that tells the truth is the entire pitch.
Security architecture
| Area | What we do |
|---|---|
| Authentication | Enforced. Started with --tenants <register> the router authenticates every request against a credential register; started with neither that flag nor an explicit --no-auth it refuses to boot rather than serve an open port. Keys are sk_prism_<kid>_<secret>, the plaintext is shown once at mint and never stored, the register keeps a SHA-256 verifier, and an unrecognised or malformed key is refused 401 before any table is consulted. Receipts carry org_id and key_ref, both inside the signature. Credentials have a lifecycle over the wire, on a separate admin credential type: /admin/orgs, /admin/keys and /admin/revoke, the last of which withdraws a leaked key on a running router — measured, the next request with it is refused 401 without a restart. Two things this row must not overclaim: the key hash is SHA-256, not argon2id — an earlier version of this row said argon2id and no such code has ever existed here — and there is no request-signature scheme, so a bearer token does cross the wire. Per-organisation rate limiting is also not enforced. |
| Isolation | Per-request arena memory, freed at close. No cross-request shared mutable state on the hot path. |
| Transport | TLS 1.3 to clients and to every upstream. HSTS. No plaintext hop anywhere, including internally. |
| Secrets | Upstream vendor credentials are read from the process environment and never written to disk or to a log; started with neither credentials nor an explicit mock flag the router refuses to boot rather than pretend. There is no KMS integration — that is a design we have written down, not a system we run — and no BYOK arrangement, so there are no per-organisation provider credentials to seal. A pre-push hook blocks any commit containing a credential pattern, installed before the first commit rather than after the first leak. |
| Receipt log | Append-only files with the kernel append-only flag set, one write per record, rotated daily. A stray redirect or a careless cleanup script cannot destroy history. Rotation clears the flag deliberately. |
| Conversation grouping | A keyed hash under a per-install secret — never a raw session id and never a hash of your text. A short hash of real user text is brute-forceable, and an audit log written to scope a privacy incident must not become one. |
| Access | Least privilege, MFA required, and production access is audited. Support cannot read your content because there is no readable copy to read. |
Compliance position
Prismux is pre-launch and has no customers. We hold no third-party attestations, and we do not display badges for audits that have not happened. Everything below is marked with what is actually true today.
| Item | Status | Detail |
|---|---|---|
| GDPR / UK GDPR processor terms | draft | DPA published as a draft pending counsel review. |
| Subprocessor list | published | It is the catalogue. One document, so it cannot drift from the routing filter. |
| Records of processing | by design | Generated from the receipt log rather than maintained by hand. |
| Data residency enforcement | by design | A routing filter that fails closed, not a preference. |
| Deletion / erasure requests | by design | Trivial in off mode (nothing exists) and in customer_bucket mode (your lifecycle policy). A ciphertext delete in managed mode. |
| SOC 2 Type I | not started | Planned before the first regulated-enterprise engagement. |
| SOC 2 Type II | not started | Requires a 6–12 month observation window. We will publish the window start date when the clock begins. |
| ISO 27001 | not started | — |
| Penetration test | not started | Summary will be published; full report under NDA. |
| HIPAA / BAA | not offered | We will not sign a BAA we cannot honour end to end across every upstream. |
When any row changes we will date the change here rather than quietly swap the chip. A trust page whose history you cannot see is a marketing page.
Subprocessors
Under GDPR Article 28 you are entitled to know every party that processes your data, and to be told when the list changes. For a model gateway the subprocessor list is the set of endpoints traffic can reach — so we publish one document, not two that can drift.
The catalogue is the subprocessor list. Every row is a model vendor and a hosting substrate, with the jurisdiction that serves it and the retention terms we hold them to.
Infrastructure subprocessors — the parties that touch metadata rather than content — are separate and short:
| Party | Purpose | Data | Region |
|---|---|---|---|
| Cloud compute and object storage | data plane, receipt log, managed vault ciphertext | metadata; ciphertext we cannot read | your chosen residency |
| Managed Postgres | accounts, keys, journal, receipt index | metadata and money, never content | your chosen residency |
| Payment processor | billing and fraud signals | billing details; never request data | global |
| Email delivery | alerts, invoices, notices | contact details | global |
Notice of any change: 30 days before it takes effect, with a right to object. A policy restricting posture or residency also restricts your subprocessor set — and every receipt names the endpoint that actually served you.
Resale standing
A question most gateways do not invite, so here it is directly. Reselling model API access is not automatically permitted: vendor terms vary on onward resale, some paths are explicitly designed for it, and others require a named agreement.
| Path | Standing | What it means for you |
|---|---|---|
| Cloud marketplace substrates (Bedrock, Vertex, Azure) | designed for it | These paths exist to be resold and are contracted accordingly. |
| Direct vendor endpoints, managed billing | per vendor | We will state our standing per vendor here, with the date, once agreements are in place. We hold none today, which is why there is no launched managed-billing product to buy — and why no request this router has ever served went to a real vendor. |
There is no bring-your-own-key option on this platform. It does not exist and it is not planned: BYOK means the customer holds the vendor relationship, which is the opposite of a business whose whole basis is that we hold the agreements.
So our resale standing, stated per vendor above, is the question this business turns on, and we are not hedging it with a second product.
Service levels — two numbers, never one
Our uptime cannot exceed the union of our upstreams' for a model you have pinned. Publishing one blended figure would hide exactly that, so we publish two.
Both take their values from traffic, and there is none yet. Nothing has been measured because nothing is deployed — the 99.99% and 99.9% this page once printed were measurements of nothing. Neither is a contractual target either: we name an SLO once we can show the history behind it, and not before. The shape of the measurement is the part that is settled.
A policy allowing rung-1 fallback has a materially higher effective availability than one pinned to a single endpoint. You should be able to see that number for your own configuration before choosing, so the dashboard shows it — an honest SLA that doubles as the argument for a better policy.
Live figures and history: status.
Enforcement without reading your content
Metadata alone is not sufficient to enforce an acceptable use policy, and it is worth being exact about where it works and where it does not.
What metadata catches well: fraud, credential abuse, stolen-card credit purchases, and industrialised abuse operations — which have a traffic shape nothing else does. What it catches poorly: a low-volume, well-funded customer generating prohibited content. That is the case that ends companies, and no amount of concurrency-curve analysis finds it.
So enforcement runs on four content-blind signals plus a retention floor:
| Signal | What it sees | Works at tier |
|---|---|---|
| Blind matching | A perceptual hash of content checked against known-bad fingerprint sets. Learns matched: true, never the content — the principle image platforms have used for over a decade. | all, including zdr_absolute |
| Vendor filter signals | The model vendor's own filters still run. A key accumulating content_filter refusals is actionable without our seeing anything. | all |
| Traffic shape | Request distributions, token ratios, refusal rates, model mix, concurrency curves, key-creation velocity. | all |
| Payment and identity | Prepaid first, spend velocity earned rather than granted, business verification above a threshold, KYC for the contracted tiers. | all |
| Sealed content, on cause | The minimum-retention window, or the 2-of-3 unseal. The path that exists precisely because the four above are not sufficient on their own. | open, mdr, zdr_escrow |
At zdr_absolute the last row does not exist, which is why that tier
requires you to accept the enforcement duty contractually: AUP flow-down to your end
users, a named responsible officer, and indemnity. The duty does not evaporate
because the ciphertext did — it moves.
What we will not do at any tier: read prompts to moderate them as a matter of course, or retry a vendor's content-filter refusal on a different vendor to get around it. Routing around a filter would make us a laundering service for the thing the filter exists to prevent. Full policy: acceptable use.
An operator running their own router can add a content classifier on top of all of this. It is off unless they switch it on, and what it is allowed to write into your receipt is deliberately almost nothing — the classifier layer.
The classifier layer, and what a receipt may say about it
Everything above is content-blind. This is the one control that is not, and it is switched off unless an operator switches it on. A classifier inspects the request before it is routed, or the answer before it is returned, and the router obeys what it says — allow, flag, transform, or block. It is pluggable: the built-ins exist for testing and for the one policy that needs no model, and the real one is a process you supply, spoken to in one JSON line each way. Nothing is recompiled to change a policy.
A classifier that cannot run — a timeout, a crash, an unparseable answer, an
oversize payload — leaves exactly one question: does the request go through
unclassified, or is it refused? Both answers are defensible, which is precisely why
this software will not choose. fail_closed spends availability to keep
the policy true. fail_open lets the request proceed and the receipt
records that it was never inspected. A router told to classify and not told which
does not start.
Nor will it start without knowing where the prompt goes once the classifier has
it. A classifier reads every byte the model reads, and a hook is free to be one line
of curl to somebody else's host — so a retention floor enforced against
the model and not against the inspector is an overclaim, and the receipt would
attest it. The declaration must name a posture and a jurisdiction, and either an
evidence URL for a document somebody has read, or forty characters of written
reason why nobody has. An unverified declaration may claim only the weakest posture:
evidence gates the claim rather than accompanying it.
Measured on 2026-09-04 — the shipped router, told to classify, given a stance, and given no retention declaration:
$ prismux-router --classifier length --classifier-id demo.len \
--classify-inbound --classifier-stance fail_closed
FATAL: the classifier configuration is incoherent: PostureUndeclared
Exit 78, before a socket is opened. A safety control whose configuration is incoherent must not serve one request before anybody finds out.
What the receipt records, and what it must never record
A classifier's whole job is to look at content and say something about it. Recording what it said is therefore recording a function of your content — into a chain that is signed, durable and published. So every candidate field had to survive one question: does this tell a receipt-holder something about the content that the caller did not already observe from their own request and response? The caller has the content. A third party reading the chain does not, and that asymmetry disqualifies almost everything.
Two things are recorded. The classifier's id, which is configuration — identical on every request under one deployment, so it carries no bits about any particular one. And a mark per point, from a closed 9-variant list, every variant named after what the router did rather than what the classifier found:
absent off allowed flagged transformed blocked error_open error_closed skipped
This is what the block looks like on a real leg — measured on 2026-09-04, from a router running the built-in length classifier at the inbound point:
"classification": {"classifier_id": "demo.length",
"classify_inbound": "allowed",
"classify_outbound": "off",
"coverage": "complete"}
blocked is in that list. blocked_for_pii is not, and that
distinction is the entire design. Also refused, each for its own reason:
| Rejected field | Why it is not there |
|---|---|
| Category labels | A statement about the subject matter of your prompt, made durable and published. Over a chain it builds a topic profile of an organisation out of receipts that were sold as content-free. The most tempting field and the most damaging one. |
| Scores | A per-request real number derived from content is a covert channel — and worse, a gradient. Anyone who can submit and read watches the score move as they edit, which is a tuning signal against the operator's own policy. |
| A hash of the matched span | The field that looks safest and is not. Hashing is not privacy over a low-entropy domain, and a matched span is low-entropy by construction — a name, an address, a card number. Anyone holding the receipt can guess a value, hash it, and confirm. Salting does not help: whoever reads the chain usually reads the salt. |
| Match counts, offsets, lengths | Content-derived integers with far more range than a verdict needs. |
| Per-request classifier duration | A timing side channel that correlates with input size and sometimes with what was found. The receipt already leaks input size more strongly through its timestamps, so the marginal cost is small — and the product value of this field on a customer's receipt is zero, because an operator measures classifier latency from their own metrics. A field with no value and a nonzero leak should not exist. |
Two limits worth knowing before you rely on this
An outbound classifier cannot run on a streaming response. The
client has already rendered bytes before the last one arrives from upstream, so there
is no moment at which the whole answer exists and nothing has been delivered, and
nothing can un-send what was already sent. Under fail_open the receipt
says skipped rather than implying a check that did not happen. Under
fail_closed the request is refused outright, before anything is
forwarded — measured on 2026-09-04, that refusal reads
classification_impossible and tells the caller to drop
stream or ask the operator to run the classifier fail-open. This
mattered: the mark alone used to be the answer regardless of stance, which meant a
fail-closed deployment served uninspected answers to any client that asked for
streaming — fail-open behaviour wearing a fail-closed configuration, recorded
honestly and therefore easy to miss.
A request blocked at the inbound point has no leg, so its receipt does not
name the classifier. The classification block lives on a routing attempt, and
a request refused before routing made none. You still get a signed receipt — measured
on 2026-09-04 it read delivery: none, posture: refused and
charged: 0.000000, and the error names classifier as the
binding constraint — but the chain line for that refusal carries no
classifier_id. It is a real gap and it is stated here rather than
discovered.
A caller may send X-Prism-Classify: on to switch
classification on for their own request. off does nothing: a policy the
caller can disable from their own request is not a policy, it is a suggestion. An
organisation can carry a standing requirement that forces both points on and forces
fail_closed, and there is no expressible value of it that switches a
point off or relaxes a stance — the same direction every other axis here travels.
Signing keys
Receipts are signed with Ed25519, over the receipt's own content hash. Started
with --signing-key, the router prints one line at boot and tells you to
pin it:
signing 7d6d9778b3d96b3f ed25519pub:wD3QWek9ka1XmFWRPl2AmGu+oYAFMPAN0+BlP0chNxw=
^ pin this line; `prism verify <chain> --keys <file>` reads it verbatim
That is the whole key distribution story today, and it is deliberately the
smallest one that works: the id is the same value every receipt carries as
signing_key_id, and the file format the verifier reads is that line,
unedited. The line you pin is the line the verifier consumes, with nothing to
convert in between.
The signature covers the receipt's content hash, and the content hash covers
44 canonical fields — including org_id, key_ref, the
whole promises block and the whole charge breakdown. So an edited receipt still
carries a signature that verifies against the hash it was signed with, and what
catches the edit is that the hash no longer matches the record. The verifier
reports the two independently and says so on the line between them, rather than
letting a green signature imply an intact record.
Chaining tells you a record was not altered relative to the chain. The signature tells you the chain is ours. You need both, and you now have both.
Key publication and rotation land with the first production deployment:
/.well-known/prismux-signing-keys.json, and a rotation history beside
it. Today there is one key, no deployment to serve it from, and
no published rotation history because nothing has rotated. A rotated key still
checks old receipts. If a signing key is ever compromised we publish the fact, the
affected window, and a re-attestation of every receipt in it — the chain makes the
scope of such an event computable rather than a matter of assurance.
Incident policy
- Notification within 72 hours of becoming aware of a personal data breach, to affected customers, with what we know and what we do not yet.
- Scope from the receipt log, not from guesswork. Records carry an interval, not a timestamp, precisely so “which requests were in flight concurrently on endpoint X between T1 and T2” is a query. A point-in-time log cannot express concurrency, and that is what makes an incident unscopeable.
- A public post-mortem for anything affecting integrity of the receipt chain, retention guarantees, or billing correctness — including what we got wrong and what we changed.
- Counters, not adjectives. Degradations already appear as published numbers before anyone writes a status update.
Reporting a vulnerability
Write to security@prismux.com. We will acknowledge within one
business day and give you a substantive response within five.
We do not require an NDA to report, we will not threaten legal action against good-faith research, and we will credit you unless you prefer otherwise. If you find a way to make the receipt log contain content, or to make a fallback lower a retention posture without it being counted, we especially want to hear from you — those are the two claims this whole system rests on.