Operator decision-signing enrolled-key contract (ADR-025)
Status: Stable (frozen for the operator-signed-decisions program) Owner: Authority / StellaOps.IssuerDirectory Sprint: SPRINT_20260608_012_Authority_operator_signing_key_enrollment.md (AUTH-OPSIGN-006) ADR: ADR-025 — Operator-signed governance decisions
This is the single, stable shape of an enrolled operator decision-signing key. It is the contract that the rest of the operator-signed-decisions program consumes:
| Workstream | Sprint | Consumes the enrolled key for |
|---|---|---|
| WS1 (Cryptography) | SPRINT_20260608_011 | IOperatorDecisionVerifier resolves the provider by providerHint/algorithmId and verifies against publicKey. |
WS3 (operator-decision@v1) | SPRINT_20260608_013 | The predicate binds keyId, subjectId, algorithmId, and the key fingerprint; the envelope keyid resolves to this key. |
| WS4 (Release/Approval) | SPRINT_20260608_014 | Verifies a promote/approve/deploy signature against the approver’s enrolled key. |
| WS5 (Policy/Findings) | SPRINT_20260608_015 | The policy.trusted_keys verification view + the IOperatorDecisionVerifier read path. |
| WS6 (CLI) / WS7 (UI) | sprints 009 / others | The operator signs the server-returned PAE with the private key whose public half is enrolled here. |
Non-custodial (ADR-025 §1, hard rule). Only the public key (or qualified certificate) is ever enrolled. The operator’s private signing key never leaves the operator; the server cannot reconstruct or hold it. The enrollment endpoint rejects any private-key material (
IssuerKeyValidator.RejectPrivateKeyMaterial→400). This is the property that makes an operator-decision signature non-repudiable.The single ring-fenced exception is the custodial server-mint fallback for explicitly named low-assurance tenants (see the lifecycle extension below, binding decision 1). It does not satisfy this non-custodial guarantee and must never be counted as if it did.
Lifecycle-managed enforcement extension (binding target, 2026-07-18)
This section extends the same stable enrolled-key shape for the Authority operator-signing-key lifecycle program (SPRINT_20260718_009_Authority_operator_signing_key_lifecycle.md). It is a binding target for P1-P7; it does not claim those implementation tasks have shipped. The existing public-key-only contract remains the normal assurance path.
Binding owner decisions
- Provisioning: force a client-side enrolment ceremony by default. A custodial server-mint fallback is permitted only for an explicitly named low-assurance tenant, must be presented and audited as a non-repudiation downgrade, is never a default, and is forbidden for regulated tenants. A key created by this fallback is not allowed to masquerade as satisfying the non-custodial guarantee above.
- Accountable act: sign the grant (approve, auto-approve, or gate-override). An ordinary request is key-gated rather than signature-gated. If auto-approve collapses request and grant into one act, that act must carry the grant signature.
- Enforcement: use a tenant-owned
signing-requiredsetting. It defaultsfalse; enable it one tenant at a time only after enrolment readiness and explicit owner authorization. There is no global flip. - Assurance tier: AdES is the v1 requirement. QES is the P7 follow-on and remains gated by legal counsel and an offline LOTL/qualified-provider pack.
- Provider change: keep the grace window on and notify users in exception-granting roles.
- Key-to-person binding: allow multiple device/holder keys for one
subjectId. The key in use, rather than an arbitrary other device key owned by that person, determines whether a signing flow can proceed. - Enrolment destination: use
/administration/profile, pre-filled with issuer namespace and active provider, for onboarding and blocked-flow enrolment links.
Auto-bind, current-key, and provider-change rules
- Auto-bind: enrolment derives the provider and allowed algorithm from the tenant’s active compliance profile. The resolved active provider must report
Supports(CryptoCapability.Verification, algorithmId)(the lifecycle contract’sSupports(activeProvider, alg)check); otherwise enrolment is rejected. An operator cannot override the active provider with a free-form region choice. Browser enrolment remains NIST-only; other providers route to CLI/smartcard/QSCD holders. - Current key: for a signing attempt, the current device/holder must have an
Activekey whose purpose isDecisionSigningand whose algorithm is supported by the active provider. Another active key for the same person on a different device/holder does not make the current holder usable. - Provider change: affected active keys remain resolvable for historical verification and enter the configured grace window for new signatures. Rotation caused by the change records the stable reason
superseded_by_provider_change; after grace, an old-provider key is not current for new grants. Users in exception-granting roles are notified to re-enrol. The supersede request carries a stable GUIDoperationId, initiatingoriginalActorSubject, Authority-resolved subject ids, and supported algorithm ids. IssuerDirectory atomically commits key retirement, audit, and an append-only receipt. A lost-response retry with the same canonical tenant/issuer/subject/algorithm identity returns the original byte-equivalent receipt and emits no second audit; reusing the operation id for a different identity fails closed with409. Actor/grace drift on replay cannot rewrite the original receipt. The receipt contains no private key material.
The user-facing enrolment projection has exactly these states:
| State | Meaning |
|---|---|
pending | The user has no usable active-provider key yet, or a newly enrolled key is awaiting required approval. |
enrolled | The current device/holder has an Active DecisionSigning key supported by the active provider. |
re_enroll_required | A provider change, expiry, revocation, or holder mismatch leaves the current device/holder without a usable key. |
The projection is not a substitute for the append-only key records in section 2. A provider change never deletes or overwrites an old public key, and historical verification continues to resolve the envelope’s exact keyId.
Implementation status (2026-07-18): the OSK-P2 backend boundary is present. Platform exposes a tenant-bound read projection at GET /api/v1/admin/crypto-providers/compliance-profile/operator-signing-enrollment, and IssuerDirectory consumes it before enrolment or rotation persists a public key. The projection supplies the exact active provider identity, and IssuerDirectory rejects a caller override or a registry entry that does not support verification for the projected algorithm. World and FIPS profiles select ES256 and their active verification provider. GOST and SM profiles reject a browser ES256 downgrade and require a configured matching regional verification provider; an unavailable profile projection fails closed. The Console picker/routing work and live browser/CLI forcing function remain OSK-P2 work and are not claimed complete by this backend slice.
OSK-P3 backend status (2026-07-19): the standard Authority credential store marks every newly provisioned user with persisted metadata pendingKeyEnrollment=true and emits the non-secret stellaops:pending_key_enrollment=true descriptor claim as a first-use UX hint. The provisioning contract accepts no signing/private-key field and mints no signing key. This marker is not authorization evidence: Policy queries IssuerDirectory and projects pending, enrolled, or re_enroll_required from the subject’s append-only DecisionSigning key history. The signing-required request guard fails closed until that projection reports an enrolled key. Wiring the tenant’s exact Platform provider into the Policy projection, the forced ceremony, and the end-to-end onboarding journey remain open.
Web authorization boundary (2026-07-25): the first-use hint does not grant access to signing-key APIs. The global Web auth guard forces the enrollment ceremony only when the session has both pendingKeyEnrollment=true and the canonical self-service authority:signing-keys.enroll scope. A read-only professional role may carry the persisted hint while correctly lacking enrollment authority; it continues to its read-only destination instead of being redirected into a profile workflow whose APIs must return 403. Signing-capable roles still fail closed on the authoritative Policy/Platform enrollment projection.
OSK-P3 provisioning ring-fence + invite (2026-07-19): two further P3 controls landed on the Authority backend.
- Custodial server-mint ring-fence (Owner Q1). The standard plugin exposes
custodialKeyMint.enabled(defaultfalse) +custodialKeyMint.tenants(default empty) — the explicit low-assurance allow-list. The decisionCustodialKeyMintOptions.AllowsCustodialMint(tenant)is default-deny: an empty allow-list denies every tenant, and enabling the switch with no named tenant fails validation closed (so it can never become a global opt-in by accident). For a named tenant only, provisioning additionally records the audit/authorisation markercustodialKeyMint=trueand logs the non-repudiation downgrade. Even on this path no operator private key is minted or stored — the marker is the sole hook a (separately gated, not shipped) custodial keystore would act on, so the hard invariant “the server never holds an operator private key” holds on every path today. Defence in depth: a custodial key could only ever be a software NIST key, which the OSK-P2 auto-bind guard refuses for a regulated (SM/GOST/eIDAS) tenant, so a mislisted regulated tenant still cannot obtain a usable custodial key. A key created by this fallback never satisfies the non-custodial guarantee in the intro hard-rule box. - Admin enrol-invite deep-link.
GET /issuer-directory/operator-signing-keys/{issuerId}/enroll-invite(scopeauthority:signing-keys.admin) returnsOperatorEnrollInviteResponse— the tenant’s active profile/algorithm/provider plus adeepLink(QR payload) to the enrolment ceremony pre-filled with the issuer namespace + active provider (/administration/profile?issuer=…&provider=…, matching the console panel’s query keys). It mutates nothing and mints nothing; regional (CLI/smartcard) profiles return the documentation route verbatim with no browser prefill so a regulated user is never dead-ended.
Still open on P3: the live end-to-end onboarding ceremony (browser non-extractable key → pending→ enrolled) and the regional out-of-band-enrol walkthrough are Tier-2/live-stack proofs, not covered by the in-process unit/HTTP suites above.
OSK-P4 key-management surface + append-only delete-guard (2026-07-19): the operator-facing key management and an admin oversight read model landed, plus a regression fence for the never-delete invariant.
- Key history / rotate / re-enroll (console
DecisionSigningEnrollmentPanelComponent). The panel renders the operator’s full append-only history —Active/Pending/Retired/Revokedwith thereplacesKeyIdchain and enrolment timestamps — across multiple device keys per person (Owner Q6), marking the current browser device’s key. It fed byGET /issuer-directory/operator-signing-keys/{issuerId}/keys(current operator, all statuses). It adds a rotate action (client generates a fresh non-extractable device keypair →POST …/keys/{keyId}/rotate, which retires the old key append-only) and are_enroll_requiredbanner + CTA derived from the same current-key rule the request gate uses (anActivekey on the active provider algorithm; anActivekey on a superseded algorithm reads asre_enroll_required). Regional (non-NIST) profiles route rotate/enroll to the CLI/smartcard path — no browser dead-end. - Admin coverage read model.
GET /issuer-directory/operator-signing-keys/{issuerId}/coverage(scopeauthority:signing-keys.admin) projects the tenant’s append-only decision-signing history grouped by subject and derives each operator’spending | enrolled | re_enroll_requiredstate, plus per-operator lifecycle counts. It resolves the active provider algorithm from the enrolment policy so a stale-algorithm Active key is reported asre_enroll_required; when the tenant enforces compliance but the policy is unavailable it fails closed (503) rather than under-report stale operators. It is an oversight surface — it returns no key material. The console surface isOperatorSigningCoverageComponentalongside the crypto/compliance admin views. - Append-only delete-guard (req 5 fence).
IIssuerKeyRepositoryintentionally exposes no delete operation; issuer deletion preflights decision-signing history and refuses; and startup migration003_operator_signing_key_append_only_guard.sqlinstalls a PostgreSQLBEFORE DELETEtrigger onissuer_keysthat blocks direct and cascading deletion ofDecisionSigningrows. A Testcontainer integration test proves repository/direct/cascade deletion is rejected and the key history is retained, and a reflection test asserts no repository path can delete a key row.
Still open on P4: the live browser Tier-2 walkthrough (history renders retired/revoked keys and a past operator-decision@v1 envelope still verifies against a retired key) is a live-stack proof, not covered by the in-process unit/HTTP suites above.
OSK-P5R IssuerDirectory durability status (2026-07-19): the provider-change supersede boundary now uses a stable operation id and a PostgreSQL transaction repository. Startup migration 004_operator_provider_change_receipts.sql embeds the append-only receipt store. The first execution locks the operation id, selects affected active DecisionSigning keys deterministically, retires them, writes the correlated per-key audit rows, inserts the final ordered receipt, and commits once. Exact replay returns that stored receipt; cross-tenant or changed canonical-request reuse is rejected. The existing admin-scope set is unchanged. Platform orchestration/reconciliation and live deployment proof remain separate owning work.
1. System of record
Operator decision-signing keys are Authority-resident in StellaOps.IssuerDirectory. That service already does public-key enrollment (add / rotate / revoke + validator + audit sink), so the operator path extends it rather than standing up a second key-enrollment subsystem. The enrollment surface, scopes, fresh-auth gate, audit, and optional dual-control are specified in docs/modules/issuer-directory/architecture.md §“Operator decision-signing keys” and were delivered by AUTH-OPSIGN-001/002/003.
policy.trusted_keys is not a second source of record — it is a downstream verification view that Policy/Attestor read (see §4). Authority does not take a production reference on StellaOps.Policy.Persistence(ADR-025 §2); the dependency only flows downstream.
Read surfaces are now explicit and non-stubbed:
GET /issuer-directory/operator-signing-keys/{issuerId}/keyslists the authenticated operator’sDecisionSigningkeys for that issuer. It is bound to the tokensuband requiresauthority:signing-keys.enrollplus the fresh-auth gate.GET /issuer-directory/operator-signing-keys/{issuerId}/keys/{keyId}returns oneDecisionSigningkey for service verification. It requires the IssuerDirectory read policy and returns404when the key is missing or belongs to a non-decision-signing purpose.
The canonical domain type is src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core/Domain/IssuerKeyRecord.cs (SubjectId / ProviderHint / AlgorithmId / Purpose added by AUTH-OPSIGN-001). The API projection is OperatorSigningKeyResponse (src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Contracts/IssuerKeyDtos.cs).
2. The enrolled-key contract (stable shape)
A single enrolled operator decision-signing key:
| Field | Type | Source field | Notes |
|---|---|---|---|
keyId | string | IssuerKeyRecord.Id / OperatorSigningKeyResponse.KeyId | Stable key identifier. The operator-decision@v1 DSSE envelope keyid resolves to this. |
subjectId | string | IssuerKeyRecord.SubjectId | The enrolling operator’s sub. Always the token sub, never a blindly-trusted client value. |
tenant | string (slug) | IssuerKeyRecord.TenantId | Tenant slug (e.g. default, partner-dev-lab). String, not a Guid — see §3 and the WS5 reconciliation (§5). |
issuerId | string | IssuerKeyRecord.IssuerId | The IssuerDirectory issuer the key is nested under (the account-scoped operator namespace). |
algorithmId | string | IssuerKeyRecord.AlgorithmId | A canonical SignatureAlgorithms constant (§2.1). Distinct from the material type. |
providerHint | string? | IssuerKeyRecord.ProviderHint | Exact public-key verification-provider identity. Verification resolves it through ICryptoProviderRegistry.TryResolve(providerHint) before the tenant default. Regional values are ru.bouncycastle.gost for GOST and cn.sm.soft for SM; both are verification-only adapters and do not claim that the private signing holder is software. CLI/smartcard/HSM signing may use a distinct holder such as PKCS#11, CryptoPro, or SmRemote. |
publicKey | string (SPKI/PEM or cert) | IssuerKeyRecord.Material | Public key material only. type/format describe the encoding (§2.2). |
fingerprint | string | IssuerKeyRecord.Fingerprint | SHA-256 fingerprint of the DER-encoded public key; the second unique lookup key. |
purpose | enum | IssuerKeyRecord.Purpose | DecisionSigning for operator keys (vs Issuer for publisher/VEX keys). A verify path for an operator decision MUST require purpose = DecisionSigning. |
type | enum | IssuerKeyRecord.Type | Material format: Ed25519PublicKey / X509Certificate / DssePublicKey. |
status | enum | IssuerKeyRecord.Status | Active / Retired / Revoked / Pending (§2.3). |
validFrom | timestamp | IssuerKeyRecord.CreatedAtUtc | Start of the key’s validity window. |
validUntil | timestamp? | IssuerKeyRecord.ExpiresAtUtc | End of the validity window (null = no expiry). |
retiredAt | timestamp? | IssuerKeyRecord.RetiredAtUtc | Retirement cutoff. Normally the rotation instant; for provider-change retirement it is the configured grace deadline. The key stays resolvable for historical verification. |
revokedAt | timestamp? | IssuerKeyRecord.RevokedAtUtc | Set on revoke; key stays resolvable but MUST never be treated as active. |
replacesKeyId | string? | IssuerKeyRecord.ReplacesKeyId | The prior key this one rotated in for (lifecycle chaining). |
The wire projection (OperatorSigningKeyResponse) flattens the lifecycle into status + pendingApproval (true iff status == Pending) and returns the public verification material as publicKey plus materialFormat. It also surfaces createdAtUtc/expiresAtUtc for compatibility and the explicit verification window fields validFromUtc/validUntilUtc plus retiredAtUtc/revokedAtUtc/replacesKeyId.
2.1 algorithmId — the canonical algorithm vocabulary
algorithmId is one of the SignatureAlgorithms constants (src/__Libraries/StellaOps.Cryptography/SignatureAlgorithms.cs): ES256 / ES384 / ES512, RS256 / RS384 / RS512, PS256 / PS384 / PS512, ED25519 (and EdDSA), GOST12-256 / GOST12-512, SM2, DILITHIUM3, FALCON512. It is distinct from type: type is the material format, algorithmId is the signature algorithm used for region routing. IssuerKeyValidator rejects material whose format/parameters contradict the declared algorithmId (e.g. algorithmId=ES256 with Ed25519 material → reject).
Browser limitation (ADR-025 §6). WebCrypto in the operator’s browser can produce NIST algorithms only (
ES*/RS*/PS*).SM2,GOST12-*, and eIDAS-QES are CLI/smartcard-only on the operator side; the UI directs those regions to the CLI. This is a permanent constraint of the browser, not a gap.
2.2 type / format — the material encoding
type (IssuerKeyType): Ed25519PublicKey, X509Certificate, DssePublicKey. format is the byte encoding of the public material (base64 or pem). Together they form the IssuerKeyMaterial. For verification, the SPKI is recovered from this material and handed to provider.CreateEphemeralVerifier(algorithmId, spki) (ADR-025 §3).
2.3 status / keyset lifecycle (historical resolvability)
status (IssuerKeyStatus): Active, Retired, Revoked, Pending.
Active— usable for verification now.Retired— rotated out (a newer key signs now), but still resolvable so a decision signed while it was active stays verifiable.Revoked— compromise/off-boarding; still resolvable for historical verification but MUST never be treated as active.Pending— enrolled under dual-control and awaiting a second approver (IssuerDirectory:OperatorSigningKeys:RequireDualControl=true). A pending key is public-key-only but MUST NOT verify a signature until promoted toActive.
Contract invariant (historical verification): a verifier resolving the key that signed a past decision MUST be able to fetch a Retired/Revoked key by keyId/fingerprint and decide validity from the decision’s timestamp against the key’s validFrom/validUntil/retiredAt/revokedAt window — never treat “not currently active” as “not found”. (AUTH-OPSIGN-001 asserts this with a test: a revoked key is returned by lookup but reported non-active.)
2.4 Provider-change retirement and grace
OSK-P5 uses one append-only transition owned by IssuerDirectory:
POST /issuer-directory/operator-signing-keys/{issuerId}/provider-change-supersederequiresauthority:signing-keys.admin. ItssubjectIdsare resolved upstream by Authority and MUST be limited to enabled users whose effective roles grant exception approval; IssuerDirectory never accepts a role name as key-lifecycle authority.- Only
Active+DecisionSigningkeys for those subjects whosealgorithmIdis absent from the new provider’s supported-algorithm set are changed. Replays see no active affected key and are no-ops. - The key becomes
Retiredimmediately, the audit reason is the stablesuperseded_by_provider_change, and enrollment projections consequently returnre_enroll_requiredwhen the subject has no other compatible active key. IssuerDirectory:OperatorSigningKeys:ProviderChangeGraceDaysconfigures the new-signature grace period (default 7, allowed 0–90 days).UpdatedAtUtcrecords the provider-change instant;RetiredAtUtcrecords the grace cutoff. Attestor already evaluates a retired key against the decision’s own timestamp and rejects at/after that cutoff, while older decisions keep verifying.
Platform composes the owning services after a real resolved-provider transition. Both compliance-profile and provider-preference mutation paths compare the effective provider before and after the durable write. Only a changed provider calls Authority’s effective-permission recipient projection, then this IssuerDirectory endpoint. Platform publishes platform.crypto-provider-changed only when the response contains retired keys, and narrows the event recipients to the subjects of those keys. The event id, payload ordering, and Idempotency-Key are deterministic for the transition timestamp and retired-key set, while Notifier owns durable replay suppression and delivery. Platform never writes Authority, IssuerDirectory, or Notifier persistence directly.
Policy treats a provider-change-retired key as usable for a new exception only while now < retiredAt. The request-readiness gate deliberately resolves verification through the key’s enrolled providerHint during that interval rather than rejecting it against the tenant’s new provider family; it still projects re_enroll_required, so grace never looks like completed re-enrolment. At the cutoff the request gate fails closed. The operator-decision resolver also refreshes a stale policy.trusted_keys lifecycle row from IssuerDirectory after the configured trusted-key cache TTL (default five minutes), persists the new Retired + cutoff projection, and invalidates its in-memory verify-only entry. Historical attestation verification continues to use the signed decision timestamp, so a decision made before the cutoff remains verifiable after it. Before any new exception approval, risk override, or gate-bypass grant is persisted, Policy separately authorizes that exact verified key against current server time; at or after the cutoff it returns key_retired even if the envelope was signed earlier. IssuerDirectory/issuer configuration remains required for lifecycle refresh; a due refresh that cannot run or cannot reach Authority fails closed rather than using stale Active state.
3. Tenant typing — string slug is authoritative for the operator path
The enrolled-key tenant is a string slug end-to-end on the operator path (IssuerKeyRecord.TenantId is string; TrustedKeyEntity.TenantId is string; the live TrustedKeyRepository is string-tenant). The Policy domain abstraction ITrustedKeyRegistry/TrustedKey.TenantId (src/Policy/__Libraries/StellaOps.Policy/Gates/Attestation/ITrustedKeyRegistry.cs:199) is a Guid— this is a pre-existing split in the Policy module, not something this contract introduces. The operator verification path uses the string store (TrustedKeyEntity/TrustedKeyRepository). WS5 reconciles the two on the Policy side; the reconciliation rule is fixed in §5 so WS5 implements against a frozen decision.
4. Authority → Policy sync mechanism (pull-by-keyId; no Authority→Policy.Persistence reference)
policy.trusted_keys is a read-side verification cache, not an independent source of record. The operator key is resolved on the verify path from the Authority system of record, so Authority never references StellaOps.Policy.Persistence(the dependency-graph fix, ADR-025 §2; the audit’s critical layering finding). The chosen mechanism is pull-by-keyId:
- A decision arrives carrying an
operator-decision@v1DSSE envelope whosekeyidis the enrolledkeyId. - The Policy/Findings verify path (
IOperatorDecisionVerifier, WS3) resolves the enrolled key:- Cache hit: read the row from
policy.trusted_keysby(tenant, keyId)(string-scoped). - Cache miss / stale: pull the key from the Authority IssuerDirectory read API (the existing
GET …/keyslist/read surface on the WebService —IssuerKeyEndpoints.ListKeys) bykeyId, project it onto the §2 shape, and upsert thepolicy.trusted_keysrow as a read-through cache.
- Cache hit: read the row from
- Verification then runs through
ICryptoProviderRegistryagainstpublicKey/algorithmId/providerHint(ADR-025 §3). Lifecycle (status/validUntil/revokedAt) is evaluated against the decision timestamp per §2.3.
Implementation note (2026-06-12): Policy Engine implements this cache-miss path in PolicyTrustedKeyOperatorDecisionResolver: it checks policy.trusted_keys first, then, when IssuerDirectory:Client:BaseAddress, Policy:ExceptionApprovalSigning:IssuerId, and a resolved tenant are present, pulls the enrolled operator key from IssuerDirectory, normalizes browser raw ES256 DssePublicKey material to SPKI, and upserts policy.trusted_keys. Missing client/issuer/tenant or IssuerDirectory transport failure remains fail-closed.
Direction of dependency: Policy → Authority (downstream → upstream read API). Authority publishes the enrolled key; Policy pulls it. No reverse reference.
Why pull, not push/event: StellaOps.IssuerDirectory has no event/outbox/message-bus infrastructure today (verified — no IEventPublisher/outbox/bus in the IssuerDirectory tree). Introducing one would be a new cross-cutting subsystem (and likely a new managed dependency, which ADR-025 §6 forbids). Pull-by-keyId needs no new infrastructure: it reuses the existing IssuerDirectory read API and the existing policy.trusted_keys cache. If a future sprint adds a platform event bus, a decision-signing-key.enrolled /.rotated/.revoked event MAY be layered on as a cache-invalidation optimisation — but it is not required for correctness and is not in scope for this program. Until then, freshness is bounded by the cache TTL (PostgresTrustedKeyRegistryOptions) and a forced refresh on a cache miss.
Revocation propagation: because the cache is read-through with a TTL, a revoked key is re-pulled (and re-evaluated as non-active) within the TTL window. A deployment that needs immediate revocation propagation sets a short TTL or calls the refresh path on revoke; this is an operational tuning knob, not a contract change.
5. policy.trusted_keys verification-view delta — SPECIFICATION handed to WS5
This section is a specification, not an implementation. Per the single-working-directory rule (CLAUDE.md §2.3), this sprint (WS2, working dir
src/Authority) does not editStellaOps.Policy.Persistence. The delta below is owned by WS5 (SPRINT_20260608_015_Policy_signed_governance_decisions.md, task POLICY-OPSIGN-008, added by AUTH-OPSIGN-006) and is frozen here so WS5 implements against a stable shape.
5.1 What policy.trusted_keys already has (reusable as-is)
The baseline table (src/Policy/__Libraries/StellaOps.Policy.Persistence/Migrations/001_v1_policy_baseline.sql, policy.trusted_keys) already carries: id, tenant_id TEXT, key_id TEXT, fingerprint TEXT, algorithm TEXT, public_key_pem TEXT, owner TEXT, issuer_pattern TEXT, purposes JSONB, valid_from, valid_until, is_active, revoked_at, revoked_reason, metadata JSONB, created_at, updated_at, created_by, with UNIQUE(tenant_id, key_id) and UNIQUE(tenant_id, fingerprint).
Most of the §2 shape maps onto existing columns with no schema change:
| Enrolled-key field | Existing trusted_keys column | Mapping |
|---|---|---|
keyId | key_id | direct |
tenant | tenant_id (TEXT) | direct (string, §3) |
fingerprint | fingerprint | direct |
algorithmId | algorithm | direct (store the SignatureAlgorithms constant) |
publicKey | public_key_pem | direct (SPKI/PEM) |
subjectId | owner | owner already documents “OIDC subject”; store sub here |
purpose | purposes (JSONB array) | add the string token "decision-signing" to the array |
validFrom/validUntil | valid_from/valid_until | direct |
status=Active | is_active + revoked_at IS NULL | the existing active predicate |
status=Revoked/revokedAt | revoked_at/revoked_reason | direct |
5.2 Columns to ADD (the delta)
The fields with no existing home are the operator-routing/lifecycle bits that distinguish an operator decision-signing key and let the verify path route + resolve history precisely. Add as nullable columns (backward compatible; existing issuer/sbom/vex keys leave them null):
| New column | Type | Maps to | Why |
|---|---|---|---|
subject_id | TEXT NULL | subjectId | First-class operator sub. (Could overload owner, but a dedicated column is cleaner for the operator query path and avoids conflating issuer-owner semantics.) |
provider_hint | TEXT NULL | providerHint | Region/provider routing for ICryptoProviderRegistry.TryResolve. No existing column. |
key_purpose | TEXT NULL | purpose | A scalar decision-signing discriminator for cheap indexed filtering (the JSONB purposes array stays the general-purpose list; this is the fast operator predicate). |
retired_at | TIMESTAMPTZ NULL | retiredAt | The baseline has revoked_at but no retired_at; rotation (retire) must be distinguishable from revoke for historical resolvability (§2.3). |
status | TEXT NULL | status | Optional explicit lifecycle string (active/retired/revoked/pending) if WS5 wants parity with the Authority status machine; otherwise derive from is_active/revoked_at/retired_at. Recommended for pending (dual-control) which has no is_active/revoked_at representation. |
replaces_key_id | TEXT NULL | replacesKeyId | Lifecycle chaining (which key this rotated in for). |
Indexes (idempotent, CREATE INDEX IF NOT EXISTS — see feedback_create_index_must_be_idempotent):
idx_trusted_keys_subject ON policy.trusted_keys(tenant_id, subject_id) WHERE subject_id IS NOT NULL— resolve an operator’s enrolled keys.idx_trusted_keys_decision_signing ON policy.trusted_keys(tenant_id, key_purpose) WHERE key_purpose = 'decision-signing'— fast operator-key filter.
5.3 Forward-only migration location
Forward-only migrations appended after 001_v1_policy_baseline.sql under src/Policy/__Libraries/StellaOps.Policy.Persistence/Migrations/ (ADR-004 forward-only; migration-recovery runbook). Shipped as 002_operator_decision_signing_columns.sql (signature columns) and 003_operator_decision_signing_view.sql (the policy.trusted_keys verification-view delta described in §5.2). Rules:
ALTER TABLE policy.trusted_keys ADD COLUMN IF NOT EXISTS …for each column in §5.2 (idempotent).- Do not edit the baseline; do not place anything under
Migrations/_archived/**(feedback_archived_migrations_glob— archived leaf-name collisions can silently win and lock the schema). - Confirm it is picked up by the existing
<EmbeddedResource Include="Migrations\**\*.sql" />glob and applied byAddStartupMigrationson a fresh DB with no manualpsql(CLAUDE.md §2.7).
Coordination with POLICY-OPSIGN-001: that task already adds a new forward-only migration for the decision tables (
exception_approval_requests/gate_bypass_audit/risk-override). WS5 MAY fold thistrusted_keysdelta into the same migration file or ship it as a separate00N_…sql— either is fine as long as the baseline stays untouched and the file numbering is monotonic.
5.4 EF compiled-model touch point
TrustedKeyEntity is mapped via the EF compiled model (src/Policy/__Libraries/StellaOps.Policy.Persistence/EfCore/CompiledModels/TrustedKeyEntityEntityType.cs, auto-generated). The compiled model is used at runtime, so it must be updated in lockstep with the entity (this is the exact lockstep POLICY-OPSIGN-001 already calls out for the decision entities):
- Add the new properties to
src/Policy/__Libraries/StellaOps.Policy.Persistence/Postgres/Models/TrustedKeyEntity.cs(SubjectId,ProviderHint,KeyPurpose,RetiredAt,Status,ReplacesKeyId— all nullable). - Update the compiled model: each new property needs an
AddProperty(...)block with itsRelational:ColumnNameannotation, andpropertyCountmust be bumped from18to the new total (it is a hard-coded constructor argument inTrustedKeyEntityEntityType.Create). Add the two new indexes to theAddIndex(...)calls (and adjustnamedIndexCount/unnamedIndexCountaccordingly). Prefer regenerating the compiled model with the EF tooling over hand-editing if the WS5 project has the generation target wired; otherwise hand-edit and verify a round-trip test (a Testcontainers PG insert + read-back of the new columns) passes.
5.5 Multi-tenant registry variant (cross-tenant verification)
PostgresTrustedKeyRegistry (src/Policy/__Libraries/StellaOps.Policy.Persistence/Postgres/PostgresTrustedKeyRegistry.cs:35-46) is constructed with a single _tenantIdand every read goes through TrustedKeyRepository.GetByKeyIdAsync(tenantId, keyId, …), which opens a tenant-scoped, RLS-enforced connection (OpenConnectionAsync(tenantId, "reader")). That is correct for the common verify-within-my-tenant case, but operator-decision verification can need a cross-tenant resolve (e.g. a global admin verifying a decision recorded under another tenant, or a verify path that only has the keyId/fingerprint from the envelope, not the tenant).
WS5 adds a multi-tenant lookup variant (the explicit hand-off): either
- a registry constructed without a pinned tenant that resolves
(tenant, keyId)where the tenant comes from the decision/envelope context, opening the reader connection for that tenant; or - a
GetByKeyIdAcrossTenantsAsync(keyId)/GetByFingerprintAcrossTenantsAsync(fingerprint)repository method that runs under an elevated (RLS-bypass) reader for the verify-only path, returning the enrolled row regardless of the caller’s tenant.
Either way the result still carries tenant (string), and the verify path enforces that the resolved key’s tenant/subjectId/purpose match the decision’s claimed binding. Keep the existing per-tenant ctor for the common path; the cross-tenant variant is additive.
6. Constraints honoured
- Non-custodial — public key only; enrollment rejects private-key material (§intro, ADR-025 §1).
- No new managed dependency — verification routes through the already-present
ICryptoProviderRegistrybacked by BouncyCastle 2.6.2 (MIT) + .NET BCL (ADR-025 §6). The sync mechanism (§4) deliberately avoids a message-bus dependency. - Layering — Authority is the system of record; Policy pulls. No Authority →
Policy.Persistencereference (§1, §4; ADR-025 §2). - On-prem / air-gap — public-key verification needs no connectivity; the pull is service-to-service inside the cluster; no cloud-managed services.
7. Cross-references
- ADR-025 — Operator-signed governance decisions
- Issuer Directory dossier — operator decision-signing keys (enrollment surface, scopes, fresh-auth gate, audit, dual-control)
- Sprint WS2 —
docs/implplan/SPRINT_20260608_012_Authority_operator_signing_key_enrollment.md(AUTH-OPSIGN-001…006) - Sprint WS5 —
docs/implplan/SPRINT_20260608_015_Policy_signed_governance_decisions.md(POLICY-OPSIGN-001 decision-table columns; POLICY-OPSIGN-008trusted_keysverification-view delta) - Canonical scopes —
src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs(authority:signing-keys.enroll,authority:signing-keys.admin,decision-signing) - Canonical algorithm ids —
src/__Libraries/StellaOps.Cryptography/SignatureAlgorithms.cs
