ZIFFER home

Running a sandbox tenant

Who this is for: you, if ZIFFER has provisioned you a sandbox tenant, or if you are deciding whether to ask for one.

Who this is for: you, if ZIFFER has provisioned you a sandbox tenant, or if you are deciding whether to ask for one. It says what the word "sandbox" is promising, what it is not promising, what you sign, and what you may read from a sandbox decision.

What you end up with: a tenant whose decisions go through the real Policy Engine, the real quorum and the real signing path, and are approved by a robot instead of a person. Nothing is mocked, nothing is bypassed, and no code path is different from production's, which is the only reason a sandbox is worth having at all.

Read section 6 before you plan around any of this. A sandbox is a separation, not a safety net: it guarantees exactly one thing, it is worth having for that one thing, and every other comfortable reading of the word is wrong.


1. The idea in one paragraph

A ZIFFER sandbox is a separate tenant. It has its own signed policy bundle, its own receipt signing identity, and its own API key. Its quorum enrols a robot approver: a real service, holding a real hybrid key, producing a real attestation over the real canonical bytes, which the Policy Engine verifies exactly as it verifies a human approver's. So a sandbox decision exercises the whole path, end to end, with no human in it.

The consequence worth stating first: because the receipt signing identity differs, a sandbox receipt fails production verification. Your production verifier is anchored to your production key (receipt_identity.json, PB-12); a sandbox receipt is signed under a different key; the signature check refuses it under clause 9.3-1. That is a property of the keys, not of a flag anyone can forget to set.

2. What the marker is, exactly

A tenant is a sandbox when both of these hold:

  1. the tenant id its signed bundle governs ends in -sandbox; and
  2. the fingerprint of that bundle's receipt signing identity is registered for that tenant id in an allowlist the robot approver reads inside the deployment.

That is a naming convention plus an allowlist, and it is deliberately not a field in the bundle. The obvious design, a boolean in manifest.json, is a change to the normative bundle schema, which is frozen at the version this deployment pins. So the marker is made of two values the bundle already carries, and the cost of that choice is written down here rather than hidden.

Both halves are required, and neither is the other's backstop. Drop the name check and any production tenant whose receipt key somebody listed becomes a sandbox. Drop the allowlist and any tenant becomes one by being renamed. They fail closed on disjoint inputs, so the robot refuses under two different names, TenantNameNotSandbox and SandboxIdentityNotAllowlisted, rather than one: they are different incidents, and folding them into a single refusal tells the operator the wrong thing.

What it guarantees

Production verification refuses a sandbox receipt. Different tenants have different receipt signing identities, §9.3 step 1 verifies under the key the verifier's own bundle names, and the allowlist is what makes that a checked fact rather than an assumption: a bundle whose receipt identity is the production one cannot be marked a sandbox by renaming its tenant.

What it does not guarantee

It does not guarantee that a mis-enrolled robot key can never approve a production action. Nothing in the marker is consulted on a production tenant's path. If somebody enrolled the robot's public key in a production bundle's attester registry, that bundle would be signed policy saying the robot may attest, and the robot's own refusals would never be reached: they run against the sandbox bundle it was configured with, not against yours.

That is an enrolment-time rule and it belongs to the signing ceremony (RK-5, two-person control): the robot's key material goes into sandbox bundles and nowhere else. Both ends now refuse it. A sandbox bundle whose registry names another tenant's robot is refused when the members are emitted (SandboxRobotEnrolledForAnotherTenant, since 2026-09-05), and a production bundle whose attesters/registry.json enrols a known robot approver is refused at the publish endpoint (SandboxRobotInProductionBundle) before it is ever served. This section said the second half was still to come, until 2026-09-09. It had stopped being true: the publish service will not even start without the robot list it checks against.

What that check can see is a robot on the list the deployment holds, and what keeps the list honest is that the tooling refuses to enrol a robot the list does not name. It is a real control and not a complete one, which is why the checklist in section 8 still asks you to look.

3. What a sandbox costs, and why it is a separate tenant

The quorum is one. attesters/registry.json is written with quorum_k: 1 and the robot as the only attester, because auto-approval means one robot signing alone. The engine's own schema permits k=1 and says what it costs: it "collapses the invariant to single-compromise, so a deployment choosing it should say so in its residual risk statement rather than discover it later". This is that statement. INV-1-HIGH does not hold in a sandbox tenant.

That is precisely why the sandbox is a separate tenant with its own signing identity rather than a mode on your production one. A k=1 quorum inside your production tenant would be a single-compromise path to production receipts. A k=1 quorum in a tenant whose receipts your production verifier refuses is a test fixture.

4. What you receive, and what you sign

ZIFFER provisions the sandbox tenant. You receive three things at onboarding:

  • The tenant id. It ends in -sandbox. It is a different tenant from your production one, and everything on this page follows from that.
  • An API key. Handed over once, out of band, and stored nowhere: what the deployment keeps is its hash, so a mislaid key is replaced rather than recovered. ZIFFER_API_KEY is what selects the tenant, so pointing a service at the sandbox is a credential change, never a flag in a request body.
  • A trust anchor file. The public half of the sandbox tenant's own receipt signing identity, handed over out of band, and given to your verifier as ZIFFER_TRUST_ANCHOR. It is not your production anchor, and section 1 says what that costs you.

Three bundle members come from us, and you sign them with the rest. They are the members only we can author, because they name keys we hold:

memberwhat it says
receipt_identity.jsonthe identity that signs this tenant's receipts. It is the sandbox tenant's own, which is what makes a sandbox receipt fail a production verify
attesters/registry.jsonquorum_k: 1, with the robot approver as the only attester: one robot signing alone. Section 3 states what that costs
door_identities.jsonthe robot as this tenant's presentation door (PB-11), so the signed policy names the service that will actually present decisions to it

Put those three in your policy directory beside your own manifest, floors, risk functions, reversibility, notice targets and alert targets, with tenant_id naming the sandbox tenant, and sign the directory with your own policy signing key. That key is yours: we never see it. The Publishing policy from your own CI guide is the whole of how a bundle is authored, signed and published, and none of it changes for a sandbox.

Two refusals are worth knowing before you sign, because both are named rather than silent:

  • a bundle whose tenant_id is not the sandbox tenant we provisioned is refused, so a sandbox bundle cannot be aimed at production by editing one string. The spelling matters on its own: a tenant id that does not end in -sandbox fails the first half of section 2's marker, and the robot will not approve for it;
  • a bundle that changed or dropped one of the three members above is refused by name (BundleReceiptIdentityMismatch, BundleProvisionedMemberMismatch), because a bundle naming a different receipt key would be signed policy pointing at a signer that does not exist.

You never hold the sandbox tenant's receipt signing key. It is ours, and the trust anchor is its public half. That is the same arrangement as production; the only thing that differs is which key.

4a. Which tier signs these receipts, and where the private half is

A sandbox tenant provisioned by the deployed installation signs its receipts at custody tier T2: a real KMS key pair, one key per primitive, created inside AWS KMS by the tenant-key tooling and non-exportable. No private half exists on any operator machine, ours or yours, at any point -- there is nothing to copy, because the key was never outside the module. The dated evidence is tools/kms-tenant-demo-sandbox-2026-09-02.json: one sandbox tenant's two receipt keys, alias/ziffer/<tenant>/receipt-ed25519 and alias/ziffer/<tenant>/receipt-mldsa65, with the key policy proven by refusing the operator kms:Sign before the service role was shown signing. It is evidence about 2026-09-02 and about nothing later; a key policy is state.

Being the same tier as production is the point of saying it: a sandbox is a different key, not a weaker arrangement around one. What a sandbox receipt does not do is verify against your production anchor, and section 1 says why.

The one place a file-backed key exists is the local rehearsal on a laptop, for a sandbox tenant composed locally for that stack. That tenant is file-sandbox, and it exists so the loop a customer runs can be driven to the end on a machine with no AWS account at all: its receipt signing identity is a seed file read at start-up -- custody tier T0 -- because a laptop has no KMS, and a rehearsal that needed one would not be a rehearsal anybody could run. The rehearsal's other tenant, demo-sandbox, stays at T2; with no session its receipt leg is reported NOT CHECKED by name rather than skipped, and with a session bound it is file-sandbox's that is.

A closed loop at T0 proves everything after the signature and nothing about the signer, and no run is allowed to print otherwise: the transcript says custody NOT CHECKED beside every T0 receipt it reports.

The two are not arrangeable side by side: the tiers are two mutually exclusive variable families, and the service refuses to start when both or neither is set, naming them (services/kms/src/config.rs). The deployed installation sets T2's; the compose stack sets T0's. And T0 cannot be pointed at production even by hand -- the same file refuses at start-up when the declared environment is production, in CustodyTier::permits's own words, because the alternative is a process that binds its port and then denies every signing request. deploy/bin/mint-pki.sh states that rule for the issuing key under the name FileSignerInProduction, and services/issuer/src/signer.rs is where the predicate is applied to a leaf signer.

At either tier, every signing key signs once and is verified before the process listens (services/kms/src/boot.rs) -- a real signature, not a permission lookup, because a task granted GetPublicKey and denied Sign passes every other start-up check and then refuses every request.

Everything below describes the deployed installation, which is the one you are given a tenant on.

5. What runs it, and what re-checks it

This section is about the deployed installation; the local rehearsal above runs the same processes from a compose stack and is not what you are handed.

Many sandbox tenants share one Policy Engine, the way many production tenants do. The engine holds a root whose children are the customers and selects your bundle per request, refusing by name a customer nobody placed (BundleRootTenantAbsent) and a placed bundle whose manifest governs somebody else.

What makes a sandbox its own lane is the attesters your bundle enrols, and since 2026-09-14 it is nothing else. Your registry enrols a machine -- a key pair the robot approver holds -- where a production customer enrols people, and the Policy Engine presents each of its approvers at the door that kind is reached through: a machine polls a door, a person opens a page. That is a value in your signed policy, not a setting on the process, so one installation serves sandbox customers and production customers at the same time.

This paragraph said the opposite until that date, and the correction matters if you read the earlier version: one engine pinned one approval door, the pin was per process rather than per customer, and a sandbox therefore ran against a separate installation. Everything else --- ingress, executor, publish and audit --- was already the shape a production tenant runs, and now the approval path is too.

The robot re-runs every check on every presentation, not only at start-up, because a bundle rotated under a running process must not keep approving on the old policy. It refuses, by name, a tenant it was not configured for, a bundle that is not marked, a key the registry does not enrol, a key that is not the bundle's presentation door, and a consent count that is not the bundle's quorum_k. Those refusals go to its own log and answer 403 with an empty body: an endpoint that explained which enrolment failed would be an oracle over signed policy for the party being verified.

6. What a sandbox does not give you

Read this before you decide what to test in one.

  1. It is not a dry run. ZIFFER decides; your handler acts. Nothing about a sandbox tenant reaches into your code and stops the line that performs the action: that is verify and your if, exactly as the Integrating the SDK guide section 6 describes. A sandbox ALLOW handed to a handler that transfers money transfers money.

  2. An ALLOW here means the path worked, not that anyone agreed. The approver is a robot with a signed summary that says, in words, that no human reviewed the action. Do not read a sandbox decision as evidence that a policy would have been approved by people.

  3. It approves everything the policy admits. The robot has no judgement: it attests to whatever the Policy Engine presents to it, for the tenants it holds keys for. Testing that your policy denies something is a real test in a sandbox; testing that an approver would refuse is not, because there is nobody there to refuse.

  4. A sandbox receipt is a real receipt of a different tenant. It verifies perfectly under the sandbox anchor. If you point your production service at the sandbox anchor to "make the tests pass", you have anchored production to a key a robot approves under. The separation only works while the two anchors stay apart, and nothing enforces that from our side: it is one path in your configuration.

  5. The marker is not a permission system. It decides which bundles the robot may approve for. It does not decide what a sandbox tenant may propose, and it has no opinion about production: see section 2's second half.

7. The developer's view

You are given two things: an API key and a trust anchor. Use them exactly as the Integrating the SDK guide describes, because the integration is not different in a sandbox, which is the point. ZIFFER_API_KEY determines the tenant, so pointing your service at a sandbox is a credential change and never a flag in a request body; a proposal naming a tenant_id that is not your key's is refused with TenantMismatch.

If you work through a coding agent, the ZIFFER MCP server is a development-time tool and nothing more: it registers five tools (propose, check_decision, get_integration_guide, explain_receipt, sandbox_status), writes no files and runs no commands. sandbox_status reports whether the tenant id you are sending is a sandbox by name and, given a decision_id, whether that decision is decided or still waiting on the robot. It approves nothing and verifies nothing: it reads a name and asks the API about one decision. The allowlist half of the marker is checked inside the deployment and no client can observe it. The package @ziffer-io/mcp is not published on npm today.

ATTEST is what the robot's own gate looks like from your side

The first thing a floor-HIGH action does in a sandbox is come back without a receipt:

status: "decided"   outcome: "ATTEST"   receipt: absent

That is section 3's quorum of one seen from the client, and it is not a refusal. Your action was graded at or above the bundle's HIGH floor, so it needs a quorum before anything is signed; the Policy Engine presents it to the robot approver, the robot forms the quorum, and the receipt attaches to the same decision on a later GET /v1/decisions/{id}. Poll that route until receipt is present. Do not wait on the outcome field, which stays ATTEST: the receipt appearing is the change. Do not re-propose, which submits a second action.

Read the next subsection before you conclude anything from that receipt, because on a deployment whose notification account runs no notifier the receipt is most of what you get. The Executor summons the notifier before it holds a deferred action, so where no notifier answers, the F3.3 hand-off from the Policy Engine to the Executor refuses and the engine's own answer is DENY at DP-90. Your GET does not show you that: the receipt was signed and stored for your read before the hand-off was attempted, so it attaches as described above and ziffer.verify accepts it, while the deferred door behind it never ran. A floor-HIGH sandbox receipt is therefore evidence that a decision was signed, and not that the approval path completed. The local compose stack is gaining a notifier under ticket ACP-326, which is in progress: nothing here claims it has landed, and this paragraph describes what is true until it does.

This is where section 6's second warning bites hardest: the quorum that lifts the gate is a robot with no judgement, so a sandbox receipt for a floor-HIGH action means the attestation path ran, not that anyone weighed the action. In production the same ATTEST waits on people, and it waits for as long as people take.

Measured, not described, and measured on an operator's own machine rather than on a deployed control plane: on 2026-09-03 a rehearsal stood the whole control plane up locally from a real sandbox tenant's deployed configuration, got decided/ATTEST/no receipt from the POST and the receipt on the next GET within a second, with the robot approver printing its approval for that proposal's hash. That is evidence about that stack on that day and nothing later. This paragraph said "a live sandbox deployment" until 2026-09-05. It was one run with two incompatible descriptions in three of our own files, and this is the correction.

What that receipt does not prove yet, and it is a floor-HIGH action's whole point

Today no deployment has a reachable notifier, so the deferred door does not run. The Executor summons the notifier before it holds a deferred action. The notifier runs in an account that has never been deployed: a fix that lets its tasks start landed on 2026-09-07 and is unproven live until the next deployment, and no local stack has an equivalent of the crossing that would reach it. So the summon cannot answer, the F3.3 hand-off from the Policy Engine to the Executor refuses, and the engine's answer is DENY at DP-90 (F3.3 is the leg; DP-90 is the rule its outage falls under): a receipt was signed and this engine could not hand it on. The 2026-09-03 rehearsal declares that refusal in advance for the quorum path and asserts it exactly, because "it always denies" is how a real refusal gets waved through.

One thing does not follow from a notifier becoming reachable, so it is worth saying here rather than later: it delivers to the recipients the deployment holds for you, and those are placed configuration rather than part of your signed bundle. Without them, every recipient of every summons is reported undelivered.

What you see from the client does not change, and that is the point of writing it here:

  • your GET stays decided / ATTEST, and it never carries the clause: the refusal is visible to the operator, in the approver's log, and not to you;
  • a receipt does attach, within a second, and ziffer.verify accepts it. The Policy Engine signs and stores the receipt for your read before it hands the decision on, so the receipt is a signed, bound ALLOW for a decision the engine then refused to deliver.

So on a deployment with no notification account, a floor-HIGH receipt is evidence that a decision was signed, not that the deferred door ran: nobody was notified, the hold never opened, and the Executor's own legs behind that hand-off, the audit append and the ledger claim, are not known to have run. Whether the receipt or the completed hand-off is "the decision" is an open question, with the two orderings written down side by side in the engine and not yet answered; until it is answered and the notification account is deployed with that fix, treat a sandbox floor-HIGH receipt as a demonstration of the signing path and not of the approval path. Dated 2026-09-05; the notifier sentence amended 2026-09-07.

The Integrating the SDK guide section 4 states the same contract, including that Client.wait returns at the decision and the receipt is a further poll.

8. Checklist

  • The sandbox tenant id ends in -sandbox, and it is a different tenant from production with a different API key.
  • The bundle you signed names that tenant in manifest.json, and carries the three members we emitted for it unchanged.
  • The sandbox tenant's receipt identity appears in no production bundle, and the robot approver appears in no production bundle's attester registry. The publish endpoint refuses the second, and section 2 says what that check can and cannot see.
  • Your production service's ZIFFER_TRUST_ANCHOR still points at the production anchor, and your sandbox one at the sandbox anchor.
  • The sandbox API key is held where you would hold a production credential. It is one, for a tenant that signs real receipts.
  • You have read section 6 and know that a sandbox ALLOW still performs the action in your own code.

Synced from ziffer docs/onboarding/sandbox.md at 32bc4db; edit the source, never this page.

On this page