Chio/Docs
LOGIN · JOIN

Tenancy & Read Scoping

Give each tenant a read credential for its own tool receipts on a shared trust control plane, and diagnose empty or refused reads.

Concept chio@5f63e6e

On this page

Each tenant of a shared trust control plane reads its own receipts with its own credential. The service resolves the bearer token to a tenant, and the receipt store returns only rows whose stored tenant_id matches; the operator’s credential reads across tenants.

Your pricing service serves two customer tenants, harbor-goods and ostrander. A Harbor Goods auditor can audit every quote Harbor's agents requested without seeing a single Ostrander price. A tenant can inspect its agents’ decisions and export scoped evidence while holding no operator authority. The scope is a read boundary inside one store; separate databases, process isolation, encryption domains, and per-tenant write authority are deployment design. Each node answers from its local view, so replication progress decides which records a node has.

The bearer credential decides which tenant’s rows a read returns

Take one node holding four pricing.quote receipts: two tagged harbor-goods, one tagged ostrander, and one with no tenant. Under the default strict isolation, each credential reads a different slice.

Tenant read scopeChoose a credential.
operatorharbor-goods tokenostrander tokenno tokennresolves the tokenreceipt storeharbor-goodsrcpt-01a8…9c04ostranderrcpt-01b3…41e7untaggedrcpt-01c5…88a0harbor-goodsrcpt-01d9…2f36operatorharbor-goodsostrandernonenresolves tokenreceipt storeharbor-goodsrcpt-01a8…9c04ostranderrcpt-01b3…41e7untaggedrcpt-01c5…88a0harbor-goodsrcpt-01d9…2f36

A tenant token reads its own tagged rows

The service resolves the bearer token to harbor-goods, and the store’s predicate requires a matching tenant_id. The untagged row has no tenant, so it stays out of every tenant’s view. Receipt Query API.

Your pricing node, its receipt store read through four credentials under the default strict isolation. Receipt ids are shortened; child receipts, which carry no tenant attribution, and the compatibility mode that admits untagged rows are left out.

Receipt list and query parameters carry no tenant selector; the handlers derive the scope from the principal. An operator can still narrow a result with supported filters such as tool, outcome, or agent, and a tenant’s view comes from that tenant’s credential. An untagged row matches no tenant, so it appears only to the operator.

Each tenant gets a distinct read token when the service starts

Add one --tenant-read-token tenant=token option per tenant to the operator’s chio trust serve command, with tenant ids that match the attribution on the receipts you intend to expose:

chio trust serve · additional argumentsbash
--tenant-read-token "harbor-goods=$HARBOR_GOODS_READ_TOKEN" \
--tenant-read-token "ostrander=$OSTRANDER_READ_TOKEN"

Each token must differ from the operator’s service token and from every other tenant’s token. Startup accepts two tenant ids that share a token value and resolves that token to the first id in sorted order, so assign unique values yourself. The map is static configuration: changing it means restarting the service.

How the CLI parses the flag

The CLI refuses missing separators, duplicate tenant ids, empty or padded ids and tokens, control characters, and a tenant token equal to the admin token. It splits on the first =, so the character may appear inside a token, and service configuration validation checks the assembled map again before the listener binds. The admin flag reads CHIO_TRUST_SERVICE_TOKEN; the tenant flag has no environment fallback, so a shell variable expanded into it lands in the launched command’s arguments.

crates/platform/chio-control-plane/src/trust_control/service_types/config.rs112-135rust
for (tenant_id, token) in &self.tenant_read_tokens {
    if tenant_id.trim().is_empty() {
        return Err(CliError::cli_other_error(
            "tenant read token id must be non-empty".to_string(),
        ));
    }
    if tenant_id.trim() != tenant_id {
        return Err(CliError::cli_other_error(
            "tenant read token id must not contain surrounding whitespace".to_string(),
        ));
    }
    if tenant_id.chars().any(char::is_control) {
        return Err(CliError::cli_other_error(
            "tenant read token id must not contain control characters".to_string(),
        ));
    }
    let token_label = format!("tenant read token for `{tenant_id}`");
    validate_control_secret(token, &token_label)?;
    if token == &self.service_token {
        return Err(CliError::cli_other_error(
            "control tenant read token must not equal service token".to_string(),
        ));
    }
}

A remote read takes its scope from the control token

With CONTROL_URL set to https://kernel.larkfield.example, the Harbor Goods auditor passes the tenant’s read token as the control token and lists its receipts:

Terminalbash
chio --control-url "$CONTROL_URL" \
  --control-token "$HARBOR_GOODS_READ_TOKEN" \
  receipt list --limit 20

The remote CLI uses the paged receipt-query endpoint: rows go to stdout, and a continuation cursor goes to stderr for the next call’s --cursor. The local boundary flags --tenant and --admin-all belong to a local database, and the CLI refuses them on a remote read:

Remote scope comes from the control tokentranscript
$ chio --control-url "$CONTROL_URL" --control-token "$CONTROL_TOKEN" \
  receipt list --tenant acme
error[CHIO-CLI-FLAG-CONFLICT]: --tenant and --admin-all apply to a local --receipt-db; a remote read takes its scope from the control token
  help: drop --tenant, or use a control token scoped to that tenant
  code: urn:chio:error:cli:flag-conflict
  docs: https://chio.computer/docs/reference/errors#error-cli-flag-conflict
exit 1

Over HTTP, send Authorization: Bearer <token>. The resolver matches the literal Bearer prefix and a configured token; a missing or unmatched credential gets 401 with WWW-Authenticate: Bearer before the receipt handler opens its store. Later route or export authorization can still refuse an authenticated request.

Read more in Receipt Query API, which covers filters and cursor semantics.

A tenant token reads its own tool receipts and nothing operator-wide

Point-loads, child receipts, and reports need the operator’s receipt read authority; the paged and bounded lists serve both credentials.

RouteTenant read tokenOperator credential
GET /v1/receipts/queryOwn tagged rows, pagedAll rows, paged
GET /v1/agents/{subject_key}/receiptsOwn rows for that subjectThat subject’s rows
GET /v1/receipts/toolsOne bounded list of own rowsOne bounded list, no cursor
GET /v1/receipts/tools?receiptId=…403The named receipt
GET /v1/receipts/children403, no tenant attributionChild receipts
GET /v1/receipts/analytics, reports403Report over local data
POST /v1/evidence/exportOwn tenant boundaryAn explicit boundary

Export authorizes the request, merges any signed federation policy, and authorizes the merged query again, so a policy cannot widen the tenant’s scope. With the credit module enabled, a tenant token can also request a credit-limit decision for its own tenant id.

Read more in Export boundary and Credit-limit decisions.

Attribution is fixed when the receipt is written

A read token selects a stored tenant_id and adds nothing to existing records, so an empty tenant view usually traces back to the writer:

  • Kernel evaluation with an OAuth session. The bearer session’s enterprise identity supplies the tenant id, falling back to the federated tenant claim. The tool-call request has no say in it.
  • Evaluation without a tenant-bearing session. Other session auth methods and sessionless evaluation supply no tenant, and the row stays untagged.
  • SCIM deprovisioning. The stored enterprise identity supplies the tenant, which SCIM provisioning copied from the Chio extension’s tenantId.
  • Operator receipt append or peer import. The signed receipt carries its attribution; the receiver verifies and stores it as written.

Before issuing a tenant token, read a sample receipt with operator authority and confirm its tenant. Receipt verification and trust in an imported signer stay separate from row selection.

Boundaries, writers, and symptoms

Local database boundaries

An operator who can open the receipt database selects exactly one read boundary:

Terminalbash
# Only Harbor Goods' tagged tool receipts
chio --receipt-db ./.chio/receipts.db receipt list --tenant harbor-goods

# All tenants, untagged tool receipts included
chio --receipt-db ./.chio/receipts.db receipt list --admin-all

The file must exist, and omitting both flags or combining them is refused. These flags record the local operator’s chosen scope; anyone who can read the database file reads every row.

Export boundary

A tenant principal cannot request AdminAll, another tenant in read_boundary, or another tenant in the export query’s tenant field; when those fields are omitted, authorization fills in the credential’s own tenant. Export authorizes the original query, prepares it with any signed federation policy, then authorizes the result again before collecting evidence. A signed federation policy binds an explicit receipt read boundary of its own, so a boundary conflict can be refused during policy preparation as well as during principal authorization.

The operator principal passes its requested query into export validation, which still applies the policy’s boundary and proof requirements. Evidence Export covers proof coverage, child scope, and the bundle.

Store read context

Library callers supply a ReceiptReadContext; a tenant_filter alone is refused. Under an admin context the filter may narrow to a tenant, and under a tenant context it must match the authorized tenant or be absent. The context is a caller-supplied contract, so an embedder establishes the caller’s authority before building it.

crates/kernel/chio-kernel/src/receipt_query.rs188-210rust
ReceiptReadBoundary::TenantScoped { tenant } => {
    let tenant = tenant.trim();
    if tenant.is_empty() {
        return Err(
            "tenant-scoped receipt query requires a non-empty tenant".to_string()
        );
    }
    if self
        .tenant_filter
        .as_deref()
        .is_some_and(|filter| filter != tenant)
    {
        return Err(
            "receipt query tenant filter cannot widen authenticated tenant scope"
                .to_string(),
        );
    }
    Ok(EffectiveReceiptReadScope {
        tenant: Some(tenant.to_string()),
        include_null_tenant: context.include_null_tenant,
        is_admin_all: false,
    })
}

Both SQLite open paths enable strict tenant isolation by default, and a tenant scope adds a predicate requiring a matching tenant id. The public store API can disable strict isolation; combined with a context that includes null tenants, it permits a tenant-or-untagged compatibility query. Authenticated tenant contexts set include_null_tenant to false, and the local CLI uses that strict context for --tenant. An admin context with no tenant restriction includes untagged rows.

Admin-only store reports and replication listings check their own read boundaries, so a tenant context gains nothing by bypassing the HTTP handler. Cluster peers import receipt streams with admin read authority over their own credential; keep the peer list aligned with the records those nodes may hold, as Cluster Peer Auth describes.

SCIM lifecycle

The Chio SCIM user extension, urn:chio:params:scim:schemas:extension:chio:2.0:User, carries providerId and an optional tenantId, which SCIM identity derivation copies into the enterprise identity context. Provisioning is an operator operation; a tenant read token provisions nothing.

Deprovisioning revokes the identity’s tracked capabilities, appends a deprovision receipt, then deactivates and saves the registry record. The receipt needs an enterprise tenant id: without one, the handler returns a SCIM-shaped 409 after the revocation loop, so revocations already made may persist while the registry record stays active. Include the tenant at provisioning, and inspect partial state when a deprovision fails.

Workload identity

A workload identity describes the workload presenting authority to the kernel, and the receipt tenant resolver derives no tenant from a SPIFFE trust domain. Configure the enterprise tenant mapping explicitly, and use Workload Identity and Bind Workload Identity for admission and capability binding.

Agent Passport

An Agent Passport carries credential and reputation evidence for a receiving party to assess. A tenant read token selects a local receipt view and serves as neither a passport nor an issuance credential. Reputation & Passports covers portable trust, and Agent Passport covers issuance and presentation.

Credit-limit decisions

POST /v1/fiscal/marketplace/credit-limit belongs to the optional credit module, which default builds leave off. A tenant token requests a decision for its own tenant id, and another id returns 403; the operator credential names any tenant. The route needs its fiscal runtime and receipt and revocation stores, derives the reputation tier from local inspection under the resolved read context, and grants the tenant no operator write credential.

Symptoms and checks

For an empty tenant result, check receipt attribution before changing tokens; untagged receipts stay outside every tenant view.

  • 401, missing or invalid control bearer token. Check the configured token, the exact Bearer header format, and which node received the request. No tenant principal resolved.
  • 403 on a valid tenant token. Check the route against the permissions grid: point-loads, child receipts, and reports are operator routes, and export and credit-limit requests refuse another tenant’s scope.
  • A successful query with zero receipts. An empty tenant result proves nothing about the store. Check tenant_id with admin authority, then filters, pagination, and the responding node’s replication progress.
  • The operator sees rows and the tenant sees none. Look for untagged rows or a different tenant id; a read credential cannot add attribution after the write.
  • A token resolves to an unexpected tenant. Check whether two tenant ids share one token value; the first matching id in sorted order wins.
  • The CLI refuses a remote --tenant. Drop the local boundary flags and use the tenant’s control token; for a local file, choose --tenant or --admin-all.
  • SCIM delete returns 409 for a missing tenant_id. Inspect persisted revocations and the registry’s active state; the revocation loop runs before the receipt, so the operation may be partly applied.

Handover checks and sources

Confirm that representative receipts carry the expected tenant, that the token returns those rows, and that an operator-only route refuses it, against the node the tenant will use. An empty result confirms neither attribution nor replication.

  • crates/platform/chio-control-plane/src/trust_control/report_validation.rs: principal resolution and export authorization.
  • crates/kernel/chio-kernel/src/receipt_query.rs (ReceiptQuery::effective_read_scope) and crates/platform/chio-store-sqlite/tests/tenant_isolation.rs: the read-boundary contract, cross-tenant exclusion, untagged compatibility, and explicit-context refusal.
  • crates/kernel/chio-kernel/src/kernel/kernel_scopes.rs and kernel/tests/multi_tenant_receipt.rs: session attribution.
  • crates/platform/chio-control-plane/src/scim_lifecycle.rs and trust_control/authority_handlers.rs: the SCIM tenant origin and deprovision ordering.

Next: page through a tenant’s receipts

Tenancy & Read Scoping · Chio Docs