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 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 robot is also the service that shows the decision and signs what was shown, which is what your sandbox's door_identities.json names. That is why a sandbox registry enrols the robot and nobody else: add a person to it and their approval would be signed by a different service from the one your policy names, so the Executor would refuse the release after the person had been told the quorum was complete. We refuse that bundle when you publish it rather than letting you find it that way. A tenant with people in its registry is a normal tenant, not a sandbox — it gets no robot and its door_identities.json names the approval service.

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 as SignatureInvalid (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 marker is made of two values the bundle already carries.

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 keeps a robot out of production

The marker is consulted on a sandbox tenant's path only. What keeps the robot's key out of a production bundle 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 refuse it. A sandbox bundle whose registry names another tenant's robot is refused when the members are emitted (SandboxRobotEnrolledForAnotherTenant), 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. The publish service will not start without the robot list it checks against, and the tooling refuses to enrol a robot the list does not name. The checklist in section 8 asks you to look as well.

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.

We also ask you for one address: whose mailbox the robot's notices go to. The robot is an attester, and a held floor-HIGH action is announced to every attester plus the operator (DR-3). The robot has no inbox, so the notices about what it approved go to the human who owns the sandbox — you tell us who that is at provisioning, and there is no default. It is not part of your signed bundle: your policy names IDENTITIES and the addresses are ours to place, so changing it later is a message to support and not a re-publish. Every other identity your policy names needs one too. A setup where one does not is refused by name before anything is placed (RecipientsIncomplete), because the alternative is the failure this rule was written after: the notification door reports the identity it has no address for as undelivered, the Executor refuses the action rather than running it unwatched (DR-8), no receipt is released — and your GET reads decided/ATTEST with nothing else in it, indefinitely.

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

A sandbox tenant we provision signs its receipts at custody tier T2: a real KMS key pair, one key per primitive, created inside AWS KMS 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 key policy grants kms:Sign to the signing service's role alone.

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.

A file-backed signing key cannot be pointed at production: the signing service refuses at start-up when the declared environment is production, because the alternative is a process that binds its port and then denies every signing request.

Every signing key signs once and is verified before the process listens -- 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.

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

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 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.

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 result means

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 7 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 works while the two anchors stay apart, and that is one path in your configuration: keep them apart.

  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 an AI assistant, the ZIFFER MCP server is a development-time tool and nothing more: its tools are listed in the Integrating the SDK guide section 10. Four of them run commands on your machine: check_policy_repo, explain_policy and simulate_decision run the ziffer tool, and scan starts the MCP servers your AI agent clients are configured to start, only once you confirm. The files it writes are temporary ones and scan's draft policy folder. 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 published on npm.

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.

A receipt means the Executor's part is done. The receipt attaches to your GET only once the Executor has finished its part: written the decision into your audit chain, sent whatever notice was owed, and recorded the action in its effect log. Your handler performs the action itself, after verify, exactly as in production. Before that your GET reads decided with no receipt, exactly as it does while the quorum is still forming. If the Executor cannot deliver a notice the action owes, it refuses the action and no receipt attaches: bound your polling loop, and treat a decision that never gains a receipt as an action that did not happen.

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.

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 covers.
  • 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.

On this page