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

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 (OperatorDecisionTypeOperatorDecisionTypeWireFormat):

Wire tokenEnumGovernance surface
release-approveReleaseApproveRelease promote / approve / deploy (WS4)
exception-approveExceptionApprovePolicy exception / waiver approval, M-of-N (WS5)
exception-rejectExceptionRejectPolicy exception / waiver rejection (WS5)
risk-overrideRiskOverrideRisk-override approval (WS5)
gate-bypassGateBypassPromotion-gate bypass (WS5)
vex-overrideVexOverrideVEX override / annotation (WS5b)
ceremony-quorumCeremonyQuorumDual-control ceremony quorum, multi-signed (WS3)

Schema

Required fields

FieldTypeDescription
decisionTypestring (enum)The kind of decision — one of the wire tokens above.
subjectobjectThe object the decision was made about (see below).
subject.namestringHuman/tool-readable subject name (image ref, release id, exception id).
subject.digestobjectDigest map (e.g. { "sha256": "…" }). At least one non-empty entry is required — the cryptographic anchor tying the decision to a concrete object.
verdictstringThe decision outcome (e.g. approved, rejected, bypassed, not_affected).
operatorobjectThe operator and their enrolled key (see below).
operator.substringThe operator’s OIDC subject (sub) — always the token subject, never a client-supplied value.
operator.keyIdstringThe enrolled decision-signing key id (the IssuerDirectory keyId); the DSSE envelope keyid resolves to this.
operator.fingerprintstringSHA-256 fingerprint of the enrolled public key.
freshAuthobjectFresh-auth claims echoed from the OIDC step-up that gated the decision.
freshAuth.authTimestring (ISO-8601 O)The OIDC auth_time (UTC) of the authorizing authentication.
policyContextDigeststringSHA-256 digest of the canonicalized policy context the decision was made against.
decidedAtstring (ISO-8601 O)The instant the decision was made (UTC, round-trip "O" format).

Optional fields

FieldTypeDescription
reasonstringHuman-readable rationale for the decision. Omitted when not provided.
freshAuth.acrstringAuthentication Context Class Reference (acr). Omitted when absent.
freshAuth.amrstring[]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”).

  1. The server canonicalizes; clients do not. The canonical payload is produced by StellaOps.Canonical.Json.CanonJson.Canonicalize — a Utf8JsonWriter with JavaScriptEncoder.UnsafeRelaxedJsonEscaping, camelCase property naming, and a recursive Ordinal key sort, minified. This is NOT RFC-8785 JCS and is NOT reproducible by a browser JSON.stringify + manual sort or a naive CLI canonicalizer (number formatting and string escaping differ). Any independent client canonicalization would diverge non-deterministically.
  2. 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.
  3. The seam (consumed by WS6/WS7): the server computes { payloadType, canonicalPayloadBytes, paeBytes, payloadSha256 } and hands paeBytes to the CLI (WS6) or browser (WS7). The client signs those paeBytes verbatim 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. (The IOperatorDecisionEnvelopeBuilder that exposes this step is ATTEST-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>" }
  ]
}

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:

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:

  1. Parses the DSSE envelope and recomputes the PAE from the parsed payload (server-side; it does not trust client-provided PAE).
  2. Resolves the enrolled key for the envelope keyid via 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 for purpose = decision-signing, validity window, and lifecycle status against the decision timestamp (§2.3 of the enrolled-key contract).
  3. Calls IOperatorDecisionVerifier.VerifyAsync({ algorithmId, spki, providerHint, tenant }, paeBytes, signature). The verifier honours the enrolled providerHint (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 wiringAssembleEnvelope, persistence via IOperatorDecisionEnvelopeStore, optional transparency anchor — is ATTEST-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.