Serve two customers with tenant-scoped receipts
Give Harbor Goods and Ostrander their own quotes, activity views, and receipt exports from one pricing service, separated by verified identity.
On this page
One pricing service and one kernel serve two of Larkfield's customers, and each customer's verified identity decides which account a quote lands in and which receipts they can read.
Priya Raman buys for Harbor Goods (tenant harbor-goods) and Tomás Ibarra for Ostrander (ostrander). The host validates their credentials, binds the verified tenant to the Chio session, and applies the same mapping when either of them lists, looks up, or exports receipts. A tenant value in a request body or query is never the source of authority.
Priya's identity selects Harbor Goods' account
Priya Raman's verified membership selects her session and capability, and the host adds harbor-goods to the call. The quote lands in quotes/harbor-goods, and the kernel signs tenant_id: harbor-goods into the receipt. Tenancy and read scoping.
Both quotes take the same endpoint and kernel and end in different receipts, because identity picks the tenant before the call. The third request names a tenant in its body and never becomes a call.
1. Start the customer service
Starts from chio new pricing-desk --example pricing-desk, or the same project as a download. The desk is Larkfield's pricing functions behind small hosts that embed the kernel, so it needs no separate Chio process. Cargo selects Rust 1.94.1 from the project's toolchain file and fetches the Chio crates its manifest pins; the first build takes a few minutes.
chio new pricing-desk --example pricing-desk && cd pricing-desk
cargo run --locked --bin customer-serviceThe host keeps its state in state/customers and signs receipts into state/customers/receipts.db. It serves its interface at 127.0.0.1:4311; keep the terminal running.
Before you begin
Use a Unix-compatible system with durable file locking, Rust through rustup, and curl and jq for the client commands. Keep the selected port free. The project README lists every endpoint, credential file, and supported persistence setting.
You should see priya-token.txt, tomas-token.txt, and operator-token.txt under the state directory: one-hour JWT credentials from a local issuer whose key stays on the host. No endpoint lets a customer mint a tenant identity.
2. Request one quote as each customer
Call the same endpoint with Priya's credential, then Tomás's. In the browser, open two tabs with one credential each.
PRIYA_TOKEN="$(cat state/customers/priya-token.txt)"
TOMAS_TOKEN="$(cat state/customers/tomas-token.txt)"
quote() {
curl -sS http://127.0.0.1:4311/quotes -H "Authorization: Bearer $1" \
-H 'Content-Type: application/json' -d "$2"
}
quote "$PRIYA_TOKEN" '{"sku":"sku-118","percent":10}' > priya.json
quote "$TOMAS_TOKEN" '{"sku":"sku-204","percent":25}' > tomas.json
jq '{verdict, output: (.output | {sku, price}), tenant: .receipt.tenant_id, receipt_id}' priya.json tomas.json{
"verdict": "Allow",
"output": { "sku": "sku-118", "price": "21.60" },
"tenant": "harbor-goods",
"receipt_id": "34f8a4487e5b31498166ae612138abf35e66a5c9cc06516757a3306c9120ed26"
}
{
"verdict": "Allow",
"output": { "sku": "sku-204", "price": "43.50" },
"tenant": "ostrander",
"receipt_id": "4489b06d387ff5231e04db5381688bb74bf596e487e4ef2aa900acf5487e2550"
}You should see two allowed calls with their own receipt IDs and tenants. Priya's membership selected her session and capability, the host added harbor-goods to the call, the quote tool wrote the quote into Harbor Goods' directory, and the kernel signed tenant_id: harbor-goods into the receipt. The body accepts only sku and percent, so it cannot supply a tenant or a capability.
How the host binds the tenant to execution
The host verifies the JWT signature, algorithm, issuer, audience, expiry, and account membership before it creates a session. It sets federated_claims.tenant_id in the authenticated session and executes a ToolCallOperation; the host checks the signed attribution before returning the result.
kernel.set_session_auth_context(&session, authenticated_context)?;
kernel.activate_session(&session)?;
// The host adds the tenant from verified claims; the body cannot supply it.
let response = kernel.evaluate_session_operation(&context, &operation)?;3. Try to widen Priya's authority
Ask for Ostrander's receipts with Priya's credential, then request a quote that names Ostrander in its body.
curl -sS -w ' HTTP %{http_code}\n' 'http://127.0.0.1:4311/receipts?tenant=ostrander' \
-H "Authorization: Bearer $PRIYA_TOKEN"
curl -sS -w ' HTTP %{http_code}\n' http://127.0.0.1:4311/quotes \
-H "Authorization: Bearer $PRIYA_TOKEN" -H 'Content-Type: application/json' \
-d '{"sku":"sku-118","percent":10,"tenant":"ostrander"}'{"error":"receipt read boundary error: receipt query tenant filter cannot widen authenticated tenant scope"} HTTP 409
Failed to deserialize the JSON body into the target type: tenant: unknown field `tenant`, expected `sku` or `percent` at line 1 column 38 HTTP 422You should see HTTP 409 for the widened receipt filter and HTTP 422 for the extra field. Both are application boundary checks, so no tool runs and no receipt is signed. The operator credential has the mirror-image limits:
| Operation | Priya or Tomás | Operator |
|---|---|---|
| Request or list quotes | Own tenant only | Refused |
| List or export receipts | Own tenant’s signed records | Both tenants, administrative view |
| Exact receipt lookup | Own receipt; others return 404 | Both tenants |
| Tenant in a quote body | Rejected as unknown field | No invocation route |
Administrative evidence access grants no tool authority: the operator reads both tenants’ receipts through an explicit administrative read context and has no route that requests a quote for a customer.
4. Look up Tomás's receipt as Priya
Put Tomás's receipt ID from step 2 into an exact lookup with Priya's credential.
curl -sS -w ' HTTP %{http_code}\n' "http://127.0.0.1:4311/receipts/$(jq -r .receipt_id tomas.json)" \
-H "Authorization: Bearer $PRIYA_TOKEN"{"error":"Receipt not found in your authorized view"} HTTP 404You should see 404: the receipt is outside Priya's authorized view. Tomás's credential returns his complete signed receipt. In Priya's tab, Load my activity shows only Harbor Goods' receipts.
The receipt read boundary
let context = ReceiptReadContext::authenticated_tenant(tenant);
let rows = store.query_receipts(&ReceiptQuery {
read_context: Some(context),
limit: 50,
..Default::default()
})?;5. Export and verify each customer’s receipts
Export Priya's receipts and count the tenants they carry.
curl -sS http://127.0.0.1:4311/export -H "Authorization: Bearer $PRIYA_TOKEN" \
-o harbor-goods-receipts.ndjson
jq -r .tenant_id harbor-goods-receipts.ndjson | sort | uniq -c1 harbor-goods
You should see one tenant. The export walks every scoped page and writes complete signed records as NDJSON; Tomás's export carries only ostrander. Download my receipts in each tab produces the same files. The captured run holds both attributed quotes and every refusal above.
Complete source: src/customers.rs, customers.html, src/lib.rs, all in the project download.
The resource boundary matches the receipt boundary
Receipt scoping protects evidence; the quotes need their own boundary. The quote tool files each quote under the tenant the host added from verified claims, state/customers/quotes/harbor-goods or ostrander. Neither the request body nor a URL parameter chooses it. Request distinguishable quotes and compare the files in both directories; the application CI exercises both boundaries.
In your service, validate the identity provider you trust, map verified organization membership on every request, and apply that mapping to lists, exact lookups, pagination, exports, and resource reads. A fresh login never renews tool authority: restarting the example issues new one-hour logins while the saved one-day tool capabilities keep their expiry. Renew tool authority through an explicit, trusted provisioning path.