PlatformMembership & Identity
Cluster
Runtime Assurance Tiers
The authority appraises a node's attestation, resolves a tier, and narrows the scope it signs. A node never sets its own level.
The enclave, and the appraisal of it
A tier is an appraisal, not a claim
RuntimeAssuranceTier is a four-value ordered enum in crates/core/chio-core-types/src/capability/runtime_attestation.rs: none < basic < attested < verified, defaulting to none. It derives Ord, and every comparison on this page is that ordering.
A piece of RuntimeAttestationEvidence carries a tier field. That field is a statement by whoever produced the evidence. The issuing authority does not read it as the answer. It runs an appraisal, and the number that governs issuance is policy_outcome.effective_tier on the resulting VerifiedRuntimeAttestationRecord. With no configured trust rules, that value is none no matter what the evidence declared.
This is Cluster content because the appraisal happens at the one authority that signs for the fleet, not on the node presenting the evidence. Enforcement is shipped, and it is reached only through a HushSpec policy. On the plain Chio YAML path it does not exist at all, which is the failure mode worth reading twice.
Objects
| Type | Holds | Written by |
|---|---|---|
RuntimeAttestationEvidence | Schema id, verifier, declared tier, issued/expires, evidence digest, optional runtime identity, optional normalized workload identity, optional vendor claims. | The requester. Arrives as JSON on the issue request. |
AttestationTrustRule | A named binding of schema plus verifier to an effective_tier, with optional verifier family, max evidence age, allowed attestation types, and required normalized assertions. | Operator, under extensions.runtime_assurance.trusted_verifiers. |
RuntimeAssuranceTierPolicy | A tier name, the minimum_attestation_tier that unlocks it, and a TierScopeCeiling. | Operator, under extensions.runtime_assurance.tiers. |
TierScopeCeiling | Operations, max invocations, per-invocation and total cost caps, max delegation depth, TTL seconds, and a constraints_required flag. | Same block. Shared verbatim with reputation tiers. |
VerifiedRuntimeAttestationRecord | The evidence, its appraisal, provenance (verifier family, adapter, canonical verifier, matched rule), the subject identity, and the policy outcome carrying accepted and effective_tier. | chio_appraisal::verify_runtime_attestation_record, per issuance. |
Two attestation stacks that never meet
chio-attest-verify verifies raw TEE quotes: Intel TDX, AMD SEV-SNP, and AWS Nitro, all behind the tee-quotes feature. Every backend must byte-compare the full 64-byte report_data slot against expect_report_data(kernel_pk, receipt_root), whose first 32 bytes are SHA256(kernel_pk.to_hex().into_bytes() || receipt_root) and whose remaining 32 are zero padding. The key contributes its algorithm-prefixed hex rendering, not its raw bytes. The module doc names the revocation oracle and chio-tee custody envelopes as its consumers; the crate graph does not agree. Neither chio-revocation-oracle nor chio-tee declares a chio-attest-verify dependency, and the only in-tree caller of QuoteVerifier::verify_quote or expect_report_data is the chio attest dispatch in chio-cli, itself behind that binary’s own default-off tee-quotes feature.
The kernel reaches the same shapes at one remove. chio-kernel deliberately does not depend on chio-attest-verify and instead declares a narrow KernelSelfQuoteVerifier port an operator binary wires to a real backend. Read what that gate actually gates: it is not a general boot gate but the post-quantum signing key load. The kernel comes up classical-only, and only a verified self-quote committing to expect_report_data(kernel_classical_pk, RECEIPT_ROOT_GENESIS) releases the ML-DSA derivation. The receipt root in that binding is the all-zero genesis sentinel, fixed for the life of the node, not a live receipt root.
The tier machinery on this page lives in chio-control-plane and chio-appraisal, consumes already-normalized RuntimeAttestationEvidence, and never sees a quote. Nothing in the tree converts a VerifiedQuote into evidence. Treat them as two independent trust boundaries that happen to name some of the same silicon.
What happens on an issue request
crates/platform/chio-control-plane/src/issuance/authority.rs:99-173at fe56570Arrival
handle_issue_capability authenticates, forwards to the leader through forward_authority_post_to_leader, and applies the persisted term fence before it touches the authority. That path is documented in Leader & Failover; note that this route returns a plain issuance response and carries none of the leader-visibility metadata. Before any appraisal runs, the handler calls validate_workload_identity_binding on the supplied evidence and answers 400 on a conflict. That check catches an explicit workloadIdentity that disagrees with a SPIFFE runtimeIdentity on scheme, trust domain, or path, and an explicit identity paired with an opaque non-SPIFFE runtime identity.
Appraisal
verify_runtime_attestation_record does three things in order. It derives an appraisal, which dispatches on the schema string and returns UnsupportedSchema for anything outside the four families below, so an unknown schema is a denial rather than a low tier. It normalizes the subject identity. Then it computes the policy outcome, and this is where a declared tier stops mattering:
fn verify_runtime_attestation_policy_outcome(
evidence: &RuntimeAttestationEvidence,
trust_policy: Option<&AttestationTrustPolicy>,
now: u64,
) -> Result<VerifiedRuntimeAttestationPolicyVerification, RuntimeAttestationVerificationError> {
let trust_policy_configured = trust_policy.is_some_and(|policy| !policy.rules.is_empty());
if trust_policy_configured {
let resolved = evidence
.resolve_effective_runtime_assurance(trust_policy, now)
.map_err(RuntimeAttestationVerificationError::TrustPolicy)?;
let matched_trust_rule = resolved.matched_rule.clone();
return Ok(VerifiedRuntimeAttestationPolicyVerification {
outcome: RuntimeAttestationPolicyOutcome {
trust_policy_configured: true,
accepted: true,
effective_tier: resolved.effective_tier,
reason: matched_trust_rule
.as_ref()
.map(|rule| format!("matched attestation trust rule `{rule}`")),
},
matched_trust_rule,
});
}
evidence.validate_workload_identity_binding()?;
if !evidence.is_valid_at(now) {
return Err(RuntimeAttestationVerificationError::StaleEvidence {
now,
issued_at: evidence.issued_at,
expires_at: evidence.expires_at,
});
}
Ok(VerifiedRuntimeAttestationPolicyVerification {
outcome: RuntimeAttestationPolicyOutcome {
trust_policy_configured: false,
accepted: false,
effective_tier: RuntimeAssuranceTier::None,
reason: Some(
"runtime attestation evidence did not cross a local verified trust boundary"
.to_string(),
),
},
matched_trust_rule: None,
})
}Evidence that declares verified and arrives with no matching trust rule is appraised at none and marked not accepted. The test issuance_verification_returns_verified_record_without_runtime_policy asserts exactly that against evidence declaring attested. Do not infer the behaviour from the core type alone: called directly with no policy or an empty rule list, RuntimeAttestationEvidence::resolve_effective_runtime_assurance returns the raw declared tier unchanged. It is the chio-appraisal wrapper above that forces none, and issuance goes through the wrapper.
When rules do exist, resolve_effective_runtime_assurance matches on schema, canonicalized verifier URL, and optional verifier family, then applies the rule’s max evidence age, attestation-type allowlist, and required normalized assertions. Three properties of that loop matter. Rules live in a BTreeMap keyed by rule name, so evaluation order is lexicographic by name and not the order you wrote them. The first rule whose schema, verifier, and family match wins, and if that rule’s age, type, or assertion checks then fail the whole resolution errors out rather than falling through to a later rule. Falling off the end of the list is UntrustedEvidence, not a fallback to the raw tier. HushSpec validation refuses two rules that canonicalize to the same schema and verifier pair, so the ambiguity cannot be written down in the first place. Note also that accepted is set to true whenever a trust policy is configured and resolution succeeds, independent of the tier that came back, so a rule binding effective_tier: none produces an accepted record at tier none. Accepted is not a synonym for trusted at a useful level.
Nothing on the issuance path checks a signature
derive_runtime_attestation_appraisal switches on the schema string and, for any of the four recognized values, returns an appraisal stamped EvidenceVerified without inspecting anything else. derive_runtime_attestation_trust_material builds every normalized assertion by reading evidence.claims. So a trust rule that pins verifier: https://maa.contoso.test and required_assertions.secureBoot: enabled is an allowlist over strings the requester supplied, matched against a digest field (evidence_sha256) that nothing recomputes. The only thing standing between an arbitrary caller and a self-declared verified tier is the admin bearer token on /v1/capabilities/issue. The adapters in chio-control-plane/src/attestation/ that do verify JWT signatures and COSE chains are not on this path; see the limits table.Tier resolution and the ceiling
Tiers are materialized sorted ascending by minimum_attestation_tier, and HushSpec validation rejects two tiers that share one minimum. Resolution is a reverse find, so the highest tier whose minimum is satisfied wins. If no tier has a minimum of none, an unattested request matches nothing and is denied with does not satisfy any configured assurance tier.
The ceiling check compares the requested TTL against ttl_seconds, then builds a synthetic parent grant per requested grant and asks is_subset_of. The synthetic parent copies the requested server_id and tool_name, so a tier ceiling never restricts which tool is reachable, only the operations, the invocation cap, and the cost caps. Resource and prompt grants get the same treatment against operations alone. Two consequences follow from is_subset_of directly: a capped ceiling refuses an uncapped request rather than clamping it, and cost comparison requires an exact currency match, so a EUR request against a USD ceiling is a denial and not a conversion.
Two ceiling fields do less than their names suggest. max_delegation_depth is never compared as a depth: the check fires only on the exact value Some(0), and then only to refuse a grant requesting the delegate operation. Any other value, including Some(3), is ignored. constraints_required only requires each tool grant to carry at least one constraint; the ceiling it then compares against is a copy of the request’s own constraints, so it does not require any particular one. The synthetic parent also sets dpop_required: None, so a tier can never demand DPoP.
Cumulative-approval grants fail an unconstrained ceiling
is_subset_of carries a second constraint rule beyond "every parent constraint is preserved by some child constraint": every child RequireCumulativeApprovalAbove must also be preserved by some parent constraint. When constraints_required is false the synthetic parent’s constraint list is empty, so no parent constraint exists to preserve it and the check fails. A request carrying a cumulative-approval constraint is therefore denied by any tier ceiling that requires no constraints at all. Set constraints_required: true on tiers that should admit those grants.The narrowing
Everything above denies. This is the only step that rewrites:
let mut constrained = scope.clone();
if tier.minimum_attestation_tier > RuntimeAssuranceTier::None {
for grant in &mut constrained.grants {
if grant_is_economically_sensitive(grant)
&& !grant
.constraints
.contains(&Constraint::MinimumRuntimeAssurance(
tier.minimum_attestation_tier,
))
{
grant.constraints.push(Constraint::MinimumRuntimeAssurance(
tier.minimum_attestation_tier,
));
}
}
}A grant is economically sensitive when it carries a per-invocation or total cost cap, or one of GovernedIntentRequired, RequireApprovalAbove, SellerExact, or MinimumAutonomyTier. Resource and prompt grants are never rewritten. The baseline tier is exempt by construction: a tier whose minimum is none adds nothing.
The narrowed scope, not the requested one, is what the inner authority signs and what validate_issued_capability_response re-checks by canonical-JSON equality. So a caller can receive a capability whose scope is strictly tighter than the one it asked for, with no field in the response saying so. The control-plane architecture record states the property directly:
- `issuance::enforce_runtime_assurance_policy` is the only place that narrows
a granted capability scope: it denies requests above the resolved tier's
ceiling and appends `Constraint::MinimumRuntimeAssurance` to economically
sensitive grants. Reputation-tier gating (`issuance::enforce_tier_scope`) is
deny-only; it never rewrites a grant.Both gates run in the same wrapper and in a fixed order: attestation verification first, then reputation, then runtime assurance. Reputation resolves a tier from a local scorecard and calls the same ceiling function. It is deny-only, but not uniformly a 403: a ceiling breach is CapabilityIssuanceDenied and answers 403, while a scorecard that resolves no tier at all is CapabilityIssuanceFailed, and handle_issue_capability maps every error that is not CapabilityIssuanceDenied to 500. The same is true of a validate_issued_capability_response mismatch. Read a 500 on that route as a policy or authority fault, not only as an infrastructure one.
At call time the appended constraint is what governed_validation enforces: a governed request whose attestation is missing, not locally accepted, or below the required tier is denied with GovernedTransactionDenied. Two things follow that the issuance response does not tell you.
- The constraint pulls the grant onto the governed-transaction path.
governed_requirementsreports a non-empty requirement, which skips the early return for ungoverned calls, so every subsequent call on that grant must carry agoverned_intentor it is denied withgoverned transaction intent required by grant or request. A caller that was invoking the tool plainly before the tier applied now has to change its request shape. - The attestation must be re-presented inside
governed_intent.runtime_attestationand is re-appraised against the enforcing kernel’s ownattestation_trust_policy, which is separate state from the control plane’s issuance policy. Both are set from the same loaded HushSpec on a single node (set_attestation_trust_policyinchio-control-plane/src/lib.rs), but a kernel that never loaded atrusted_verifiersblock appraises every attestation as not accepted and denies every governed call on a constrained grant. A capability narrowed by one node is not usable at another node that lacks the matching trust rules.
The four verifier families
Each family is a schema constant, an adapter name, and a verification policy with its own checks. These are the adapters that do real cryptography, and per the callout above they sit beside the issuance path rather than on it. Nothing outside chio-control-plane/src/attestation/ and its tests calls them.
All four policies share one refusal in validate(): a configured tier above attested is rejected, with the message that verifier adapters must not widen runtime assurance above attested before trust-policy rebinding. The AWS Nitro policy is the odd one out and still says before policy v2 rebinding; the behaviour is identical. An adapter can therefore never mint verified. Only a trusted_verifiers rule’s effective_tier reaches that value, which is what the rebinding tests exercise.
| Adapter | Schema | What the adapter checks |
|---|---|---|
azure_maa | chio.runtime-attestation.azure-maa.jwt.v1 | RS256 or PS256 only, JWT signature against an RSA JWK resolved by kid and algorithm, canonicalized issuer equality, nbf/exp window, non-empty x-ms-ver and x-ms-attestation-type, an optional attestation-type allowlist, and an optional workload claim path projected into a SPIFFE identity. |
aws_nitro | chio.runtime-attestation.aws-nitro-attestation.v1 | COSE_Sign1 with ES384 only, P-384 signature over the reconstructed Signature1 structure, certificate chain walked to a configured trusted Nitro root with validity checked at each hop, SHA384 digest, every PCR exactly 48 bytes, expected-PCR comparison, an optional nonce, and a document-age bound with future timestamps refused. |
google_confidential_vm | chio.runtime-attestation.google-confidential-vm.jwt.v1 | RS256 only, canonicalized issuer equality, nbf/exp window, non-empty sub, optional audience allowlist, a required hwmodel claim with an optional model allowlist, optional secure-boot requirement, and an optional service-account allowlist. |
enterprise_verifier | chio.runtime-attestation.enterprise-verifier.json.v1 | A signed export envelope whose Ed25519 signer must appear in trusted_signer_keys, schema and verifier equality against the policy, validity window, an age bound, and a TierTooHigh refusal when the enclosed evidence declares a tier above the policy tier. |
An all-zero PCR set is debug evidence
DebugModeEvidence unless the policy sets allow_debug_mode. That flag defaults to false and should stay false outside a lab: a debug enclave is measured, it is just not measuring anything.Both gates are HushSpec-only
load_policy reads the file, then branches on chio_policy::is_hushspec_format, which scans for any line starting at column zero with hushspec (bare, or quoted with " or ') followed by optional whitespace and a colon. The HushSpec branch calls materialize_reputation_issuance_policy and materialize_runtime_assurance_policy. The plain Chio YAML branch does not:
Ok(LoadedPolicy {
format: PolicyFormat::ChioYaml,
identity: PolicyIdentity { source_hash, runtime_hash },
kernel: policy.kernel.clone(),
default_capabilities,
guard_pipeline: build_guard_pipeline(&policy.guards)?,
post_invocation_pipeline: build_post_invocation_pipeline(&policy.guards)?,
issuance_policy: None,
runtime_assurance_policy: None,
threshold_approval: None,
})Those two literals are the whole story. Downstream, the wrapper only appraises against a trust policy when runtime_assurance_policy is Some, and only enforces a ceiling or narrows a scope in the same condition. There is no separate CLI flag: --policy <path> takes both formats, and the format decides whether tier gating exists. The Chio YAML schema cannot express the blocks either, because ChioPolicy is deny_unknown_fields over exactly kernel, guards, and capabilities, so pasting an extensions: block into one fails the parse rather than being quietly ignored.
On the plain path there is no tier gating
runtimeAttestation on the issue request and still runs verify_runtime_attestation_for_issuance with a None trust policy, so an unrecognized schema, a conflicting workload-identity binding, or expired evidence each still deny with 403. What is discarded is the tier. The record is verified and then dropped: no ceiling is applied, no MinimumRuntimeAssurance is appended, and well-formed evidence buys the caller nothing that omitting it would not. Nothing logs a warning. Check it from outside: GET /health reports federation.runtimeAssurancePolicyConfigured and federation.issuancePolicyConfigured, and both are false on that path.extensions:
runtime_assurance:
tiers:
baseline:
minimum_attestation_tier: none
max_scope:
operations: ["invoke"]
ttl_seconds: 60
verified:
minimum_attestation_tier: verified
max_scope:
operations: ["invoke"]
ttl_seconds: 300
trusted_verifiers:
google_cvm_prod:
schema: chio.runtime-attestation.google-confidential-vm.jwt.v1
verifier: https://confidentialcomputing.googleapis.com
verifier_family: google_attestation
effective_tier: verified
max_evidence_age_seconds: 120
allowed_attestation_types: [confidential_vm]
required_assertions:
hardwareModel: GCP_AMD_SEV
secureBoot: enabledGuarantees and limits
| Status | Claim | Evidence |
|---|---|---|
| Shipped | Tier enforcement is the only issuance step that rewrites a granted scope. Reputation-tier gating shares the ceiling function and is deny-only. | issuance/scope.rs; the invariant stated in chio-control-plane/ARCHITECTURE.md |
| Shipped | Evidence with no matching trust rule appraises at none and is marked not accepted, whatever tier it declares. An unrecognized schema is a denial, not a downgrade. | verify_runtime_attestation_policy_outcome, derive_runtime_attestation_appraisal |
| Proved by test | A trusted Azure MAA or Google Confidential VM appraisal rebinds to verified and the issued capability carries MinimumRuntimeAssurance(Verified). | runtime_assurance_policy_rebinds_trusted_attestation_to_verified_tier, runtime_assurance_policy_rebinds_google_attestation_to_verified_tier |
| Proved by test | The same evidence with the verifier host changed fails closed under a trust policy, and with no trust policy stays on the baseline ceiling instead of unlocking the attested one. | runtime_assurance_policy_denies_untrusted_attestation_when_verifier_rules_exist, runtime_assurance_policy_denies_raw_attestation_without_local_trust_boundary |
| Limit | No verifier adapter can produce verified; all four cap at attested in validate() and all four default to attested. Reaching verified is always an operator decision written into a trust rule. | attestation/model.rs, the four default_*_runtime_tier functions |
| Unsupported | Cryptographic verification of evidence at issuance. The adapters that turn a raw JWT or COSE document into evidence have no non-test caller. The control plane accepts RuntimeAttestationEvidence already normalized on the wire and checks no signature over it, so schema, verifier, claims, and evidence_sha256 are all requester-asserted. A trusted_verifiers rule is an allowlist over those strings, and the admin bearer token is the real boundary. | No verify_and_appraise or verify_*_attestation_* call outside attestation/ and its tests; derive_runtime_attestation_appraisal and derive_runtime_attestation_trust_material switch on the schema string only |
| Limit | A narrowed scope is invisible in the response and fails a strict client-side re-check. RemoteCapabilityAuthority compares the returned scope against the scope it requested by canonical-JSON equality, so an appended constraint reads as a mismatch. Reachable only when the client itself carries no issuance or runtime-assurance policy: pairing --control-url with a policy that has one is refused at startup with policy-gated issuance must be enforced by the trust-control service itself. No in-tree test pairs that client with a narrowing server; this is read off the code, not observed. | remote_authority::verify_issuance_response into validate_issued_capability_response_at; the --control-url guard in chio-control-plane/src/lib.rs |
| Limit | The appended constraint changes the calling contract, not just the ceiling. It forces every later call on that grant onto the governed-transaction path, and it is re-appraised against the enforcing kernel’s own trust policy rather than the issuer’s. | governed_requirements and validate_runtime_assurance in chio-kernel/src/kernel/governed_validation.rs |
| Limit | A tier ceiling cannot restrict which tool is reachable, cannot bound delegation depth beyond refusing delegate at Some(0), cannot require DPoP, and cannot require a specific constraint. With constraints_required false it also refuses any grant carrying RequireCumulativeApprovalAbove. | enforce_tool_grant and required_constraints in issuance/scope.rs; ToolGrant::is_subset_of |
| Limit | Format detection is a column-zero prefix scan for hushspec:, not a parse. A file that is valid HushSpec but indents its top-level keys is routed to the Chio YAML branch and fails there. | is_hushspec_format in chio-policy/src/lib.rs |
| Not wired | Cross-organization appraisal import is report-only. /v1/reports/runtime-attestation-appraisal/import takes the min of two tiers that both travel inside the signed exporter result, rejects to none on any reason code, and attenuates to the local maximum_effective_tier. The local policy contributes reason codes and that ceiling, not a tier of its own. No issuance path consumes the outcome. It is also the one runtime-attestation path in the tree that verifies a signature (InvalidSignature reason code). | evaluate_imported_runtime_attestation_appraisal behind build_runtime_attestation_appraisal_import_report; its only callers are the report handler and the CLI |
| Not proved end to end | The one HTTP-level test that drives the issue route under a runtime-assurance policy is #[ignore]-gated as a slow scenario. Its evidence fixture uses schema chio.runtime-attestation.v1, which derive_runtime_attestation_appraisal does not recognize, and its policy declares no trusted_verifiers, so even a recognized schema would appraise at none. Do not read it as a live regression gate; the enforced behavior is covered by the in-crate tests above. | trust_cluster_runtime_assurance_policy_gates_capability_issuance in chio-cli/tests/trust_cluster.rs |
| Unsupported | Anything reaching basic on its own. No adapter default and no appraisal path emits it; it exists as a value an operator can write into a rule or an import ceiling. | Every RuntimeAssuranceTier::Basic in crates/ is a test fixture, a normalization arm, or a policy classification |
Next Steps
- Confidential Node · the enclave this page appraises, its image, and the mode lattice
- Authority & Rotation · the keypair that signs every capability a tier decision shapes
- Leader & Failover · how the issue request reaches the node that answers it, and the term fence in front of it
- Swarm Authority · what happens to a constrained grant once it descends to an actor you do not run
- Node Overview · the process presenting the evidence, and what it can answer about itself