Operator Decision Predicate (operator-decision@v1)
Overview
The operator-decision@v1 predicate is a cryptographically signed, in-toto record of a human governance decision — a release approval, a policy-exception/waiver approval, a risk-override, a promotion-gate bypass, a VEX override, or a dual-control ceremony quorum. Unlike the platform’s machine-produced attestations (SBOMs, scan verdicts, policy decisions), this predicate is signed by the operator’s own enrolled key so the decision is non-repudiable: the server never holds the operator’s private key and therefore cannot forge the signature (ADR-025 §1).
- Status: Draft (program WS3,
SPRINT_20260608_013) - Owner: Attestor Guild
- ADR: ADR-025 — Operator-signed governance decisions (§4 the predicate; §3 verify-through-registry)
- Model:
src/Attestor/StellaOps.Attestation/OperatorDecision/OperatorDecisionPredicate.cs
Predicate Type URI
https://stellaops.io/attestations/operator-decision/v1
This URI is a single shared constant — OperatorDecisionPredicateTypes.PredicateTypeV1 (also exposed as OperatorDecisionPredicate.PredicateType). Do not hardcode it elsewhere; WS4 (Release/Approval), WS5 (Policy/Findings), WS6 (CLI) and WS7 (browser) all reference it.
The predicate is wrapped in an in-toto Statement: _type = https://in-toto.io/Statement/v1, subject = the decision subject, predicateType = the URI above, predicate = the field map below.
Decision types
decisionType is one of the following stable lowercase-hyphen wire tokens (OperatorDecisionType ↔ OperatorDecisionTypeWireFormat):
| Wire token | Enum | Governance surface |
|---|---|---|
release-approve | ReleaseApprove | Release promote / approve / deploy (WS4) |
exception-approve | ExceptionApprove | Policy exception / waiver approval, M-of-N (WS5) |
exception-reject | ExceptionReject | Policy exception / waiver rejection (WS5) |
risk-override | RiskOverride | Risk-override approval (WS5) |
gate-bypass | GateBypass | Promotion-gate bypass (WS5) |
vex-override | VexOverride | VEX override / annotation (WS5b) |
ceremony-quorum | CeremonyQuorum | Dual-control ceremony quorum, multi-signed (WS3) |
Schema
Required fields
| Field | Type | Description |
|---|---|---|
decisionType | string (enum) | The kind of decision — one of the wire tokens above. |
subject | object | The object the decision was made about (see below). |
subject.name | string | Human/tool-readable subject name (image ref, release id, exception id). |
subject.digest | object | Digest map (e.g. { "sha256": "…" }). At least one non-empty entry is required — the cryptographic anchor tying the decision to a concrete object. |
verdict | string | The decision outcome (e.g. approved, rejected, bypassed, not_affected). |
operator | object | The operator and their enrolled key (see below). |
operator.sub | string | The operator’s OIDC subject (sub) — always the token subject, never a client-supplied value. |
operator.keyId | string | The enrolled decision-signing key id (the IssuerDirectory keyId); the DSSE envelope keyid resolves to this. |
operator.fingerprint | string | SHA-256 fingerprint of the enrolled public key. |
freshAuth | object | Fresh-auth claims echoed from the OIDC step-up that gated the decision. |
freshAuth.authTime | string (ISO-8601 O) | The OIDC auth_time (UTC) of the authorizing authentication. |
policyContextDigest | string | SHA-256 digest of the canonicalized policy context the decision was made against. |
decidedAt | string (ISO-8601 O) | The instant the decision was made (UTC, round-trip "O" format). |
Optional fields
| Field | Type | Description |
|---|---|---|
reason | string | Human-readable rationale for the decision. Omitted when not provided. |
freshAuth.acr | string | Authentication Context Class Reference (acr). Omitted when absent. |
freshAuth.amr | string[] | Authentication Methods References (amr), order-preserving. Omitted when empty. |
Validation (construction rejects empties)
OperatorDecisionPredicate.Create(...) is the only constructor and it rejects an attempt to build a predicate with any empty required field — specifically subject.digest (no non-blank entry), operator.sub, operator.keyId, verdict, policyContextDigest, decidedAt (default), and freshAuth.authTime (default) each throw ArgumentException. Optional fields containing only whitespace are normalized to omitted.
The operator.keyId and operator.fingerprint are resolved against the enrolled-key contract — see the operator decision-signing enrolled-key contract (keyId, subjectId, algorithmId, fingerprint, providerHint, publicKey, status, validity windows). The envelope keyid MUST resolve to an enrolled key whose purpose = decision-signing.
Worked example
Predicate (the predicate object, before canonicalization — pretty-printed for reading)
{
"decisionType": "release-approve",
"subject": {
"name": "registry.example.com/app:1.4.2",
"digest": { "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" }
},
"verdict": "approved",
"reason": "Approved after dual-control review; reachability clean.",
"operator": {
"sub": "3ab8bd98-1c4a-4f2a-9b21-0e5a6f7c8d90",
"keyId": "op-key-7c1f",
"fingerprint": "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca7"
},
"freshAuth": {
"authTime": "2026-06-08T12:00:00.0000000+00:00",
"acr": "urn:stellaops:acr:mfa",
"amr": ["pwd", "otp"]
},
"policyContextDigest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
"decidedAt": "2026-06-08T12:05:30.0000000+00:00"
}
Canonical in-toto Statement (the exact bytes the server signs over — minified, keys sorted)
{"_type":"https://in-toto.io/Statement/v1","predicate":{"decidedAt":"2026-06-08T12:05:30.0000000+00:00","decisionType":"release-approve","freshAuth":{"acr":"urn:stellaops:acr:mfa","amr":["pwd","otp"],"authTime":"2026-06-08T12:00:00.0000000+00:00"},"operator":{"fingerprint":"9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca7","keyId":"op-key-7c1f","sub":"3ab8bd98-1c4a-4f2a-9b21-0e5a6f7c8d90"},"policyContextDigest":"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae","reason":"Approved after dual-control review; reachability clean.","subject":{"digest":{"sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},"name":"registry.example.com/app:1.4.2"},"verdict":"approved"},"predicateType":"https://stellaops.io/attestations/operator-decision/v1","subject":[{"digest":{"sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},"name":"registry.example.com/app:1.4.2"}]}
For this example (payload length 947 bytes):
payloadSha256 = 06f4a8431c6ece6ae339cb39156be89048cbf9e342a99a7a2a102d04b47515a2
PAE prefix = "DSSEv1 31 https://in-toto.io/Statement/v1 947 {…}"
These exact bytes are frozen as the cross-language conformance fixture in
ATTEST-OPSIGN-002. WS6/WS7 reproduce them as a regression guard — but the contract is “sign the server-provided bytes” (next section), so the fixture is a guard, not a client-side canonicalizer spec.
Canonical JSON + PAE contract (server is the canonicalization authority)
This is the load-bearing cross-runtime contract (ADR-025 Consequences “Determinism”).
- The server canonicalizes; clients do not. The canonical payload is produced by
StellaOps.Canonical.Json.CanonJson.Canonicalize— aUtf8JsonWriterwithJavaScriptEncoder.UnsafeRelaxedJsonEscaping, camelCase property naming, and a recursive Ordinal key sort, minified. This is NOT RFC-8785 JCS and is NOT reproducible by a browserJSON.stringify+ manual sort or a naive CLI canonicalizer (number formatting and string escaping differ). Any independent client canonicalization would diverge non-deterministically. - The to-be-signed bytes are the DSSE Pre-Authentication Encoding (PAE).
DsseHelper.PreAuthenticationEncoding(payloadType, canonicalPayload)is the length-prefixed DSSEv1 PAE (DSSEv1 <len(type)> <type> <len(payload)> <payload>) — this construction is language-portable, but it is fed the server’s canonical payload bytes. - The seam (consumed by WS6/WS7): the server computes
{ payloadType, canonicalPayloadBytes, paeBytes, payloadSha256 }and handspaeBytesto the CLI (WS6) or browser (WS7). The client signs thosepaeBytesverbatim with the private key whose public half is enrolled. The server re-derives the PAE on verify and rejects if the client re-serialized the predicate (the signed bytes would not match). Clients NEVER canonicalize the predicate themselves. (TheIOperatorDecisionEnvelopeBuilderthat exposes this step isATTEST-OPSIGN-002; this doc fixes the contract it must honour.)
DSSE envelope structure
{
"payloadType": "https://in-toto.io/Statement/v1",
"payload": "<base64-encoded-canonical-in-toto-statement>",
"signatures": [
{ "keyid": "op-key-7c1f", "sig": "<base64-operator-signature-over-PAE>" }
]
}
- The envelope
payloadTypeis the in-toto Statement type (https://in-toto.io/Statement/v1), not the predicate type — the predicate type lives inside the payload. - Each
keyidis the operator’s enrolledkeyId. Aceremony-quorumdecision carries one signature per approver (multi-signed on quorum,ATTEST-OPSIGN-005);DsseEnvelopeorders signatures deterministically (keyidascending, then signature) so the multi-sig envelope is canonical. - The envelope digest is
sha256:+ hex(SHA-256(compact-DSSE-JSON)).
Envelope persistence (durable)
IOperatorDecisionEnvelopeStore (src/Attestor/StellaOps.Attestation/OperatorDecision/OperatorDecisionEnvelopeStore.cs) persists the assembled envelope idempotently on (tenant, envelopeDigest) and reads it back (GetAsync). An envelope that cannot be re-read after a restart is not evidence, so the store is durable in every live host:
- Production (Signer):
PostgresOperatorDecisionEnvelopeStore(src/Attestor/StellaOps.Signer/StellaOps.Signer.Infrastructure/Ceremonies/) writes tosigner.operator_decision_envelopes— columns(tenant_id, envelope_digest)PK,decision_type,subject_digest,key_id(the lead/primary enrolled key; the full multi-sig set lives in the envelope JSON),predicate_type,payload_type,envelope_json(BYTEA),created_at. Created by the embedded, forward-only migration002_operator_decision_envelopes.sql(StellaOps.Signer.KeyManagement/Migrations/), applied on startup by the existingAddStartupMigrations(schemaName: "signer", moduleName: "Signer.KeyManagement", …). Registered as a singleton (its consumer,ICeremonyQuorumAttestor, is a singleton) with its own pooledNpgsqlDataSource. Writes areON CONFLICT … DO NOTHING— a re-store of a content-addressed envelope is a no-op, never a duplicate. InMemoryOperatorDecisionEnvelopeStoreis test-host only. It is registered solely when noConnectionStrings:KeyManagementis configured (i.e. the test harness), andSignerRuntimeGuards.EnsureProductionRuntimeBindingsrefuses to start a Development/Testing- external host that resolves it. Before SPRINT_20260712_003 (EVI-2) it was the production default, so a ceremony’s multi-signed quorum envelope was lost on every container restart whilesigner.ceremonies/signer.ceremony_audit_logkept the ceremony — the evidence chain silently lost its cryptographic layer.
Verification (through the crypto registry — never bare BCL)
Operator decisions verify through IOperatorDecisionVerifier(src/__Libraries/StellaOps.Cryptography/IOperatorDecisionVerifier.cs, WS1), routed by ICryptoProviderRegistry (ADR-025 §3). The verify path:
- Parses the DSSE envelope and recomputes the PAE from the parsed payload (server-side; it does not trust client-provided PAE).
- Resolves the enrolled key for the envelope
keyidvia an injected resolver delegate (Func<keyId, …, EnrolledKey?>) so the Attestation library takes no hard dependency on Authority / IssuerDirectory /StellaOps.Policy.Persistence(avoids the layering inversion; ADR-025 §2). The resolved key supplies{ algorithmId, SPKI, providerHint, tenant }and is checked forpurpose = decision-signing, validity window, and lifecycle status against the decision timestamp (§2.3 of the enrolled-key contract). - Calls
IOperatorDecisionVerifier.VerifyAsync({ algorithmId, spki, providerHint, tenant }, paeBytes, signature). The verifier honours the enrolledproviderHint(TryResolve(providerHint)+Supports(Verification, algorithmId)) first, then falls back to the tenant-compliance-profile default — routing a GOST signature to the GOST provider, an SM2 signature to the SM provider, etc. Regional compliance is structural.
The existing
DsseVerifier(src/Attestor/StellaOps.Attestation/DsseVerifier.cs) is bare BCL (ECDsa.Create/RSA.Create/BouncyCastle directly) with no SM2/GOST/regional routing and MUST NOT be used for operator decisions — that would violate ADR-025 §3. It is untouched for its existing machine-attestation callers. (The DSSE envelope wiring —AssembleEnvelope, persistence viaIOperatorDecisionEnvelopeStore, optional transparency anchor — isATTEST-OPSIGN-003.)
Regional signing matrix (where an operator can produce the signature)
The server verifies every region from the enrolled public key (BouncyCastle 2.6.2 MIT + .NET BCL — no new dependency). The constraint is on the operator’s signing side:
Algorithm class (algorithmId) | In-browser (WebCrypto, WS7) | CLI / smartcard (WS6) |
|---|---|---|
NIST — ES256/384/512, RS256/384/512, PS256/384/512 | ✅ supported | ✅ supported |
ED25519 / EdDSA | ⚠️ not via WebCrypto subtle today → CLI | ✅ supported |
SM2 | ❌ never (WebCrypto cannot) | ✅ CLI / smartcard only |
GOST12-256 / GOST12-512 | ❌ never | ✅ CLI / smartcard only |
| eIDAS QES (qualified cert) | ❌ never | ✅ CLI / smartcard / QSCD only |
The browser limitation is permanent (WebCrypto has no SM2/GOST/QES); regulated regions sign via the CLI or a smartcard/QSCD and the UI directs them there (ADR-025 §6). eIDAS-QES is a follow-on tier (ADR-025 Consequences).
Air-gap / offline
Public-key verification needs no connectivity; the enrolled key is resolved from the in-cluster Authority read API / policy.trusted_keys verification view, and the optional transparency anchor is the Stella-owned, air-gap-safe local log (no cloud, no Rekor dependency). All crypto is local.
Related documents
- ADR-025 — Operator-signed governance decisions
- Operator decision-signing enrolled-key contract (the
keyId/fingerprint/algorithmId/providerHintresolution) - Predicate Schema Registry (registered as
operator-decision@v1) - VEX Override Predicate (the DSSE pipeline template this mirrors)
- Attestor Architecture
- Sprint —
docs/implplan/SPRINT_20260608_013_Attestor_operator_decision_attestation.md(ATTEST-OPSIGN-001…005)
