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:

WorkstreamSprintConsumes the enrolled key for
WS1 (Cryptography)SPRINT_20260608_011IOperatorDecisionVerifier resolves the provider by providerHint/algorithmId and verifies against publicKey.
WS3 (operator-decision@v1)SPRINT_20260608_013The predicate binds keyId, subjectId, algorithmId, and the key fingerprint; the envelope keyid resolves to this key.
WS4 (Release/Approval)SPRINT_20260608_014Verifies a promote/approve/deploy signature against the approver’s enrolled key.
WS5 (Policy/Findings)SPRINT_20260608_015The policy.trusted_keys verification view + the IOperatorDecisionVerifier read path.
WS6 (CLI) / WS7 (UI)sprints 009 / othersThe 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.RejectPrivateKeyMaterial400). 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

  1. 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.
  2. 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.
  3. Enforcement: use a tenant-owned signing-required setting. It defaults false; enable it one tenant at a time only after enrolment readiness and explicit owner authorization. There is no global flip.
  4. 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.
  5. Provider change: keep the grace window on and notify users in exception-granting roles.
  6. 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.
  7. 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

The user-facing enrolment projection has exactly these states:

StateMeaning
pendingThe user has no usable active-provider key yet, or a newly enrolled key is awaiting required approval.
enrolledThe current device/holder has an Active DecisionSigning key supported by the active provider.
re_enroll_requiredA 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.

  1. Custodial server-mint ring-fence (Owner Q1). The standard plugin exposes custodialKeyMint.enabled (default false) + custodialKeyMint.tenants (default empty) — the explicit low-assurance allow-list. The decision CustodialKeyMintOptions.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 marker custodialKeyMint=true and 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.
  2. Admin enrol-invite deep-link. GET /issuer-directory/operator-signing-keys/{issuerId}/enroll-invite (scope authority:signing-keys.admin) returns OperatorEnrollInviteResponse — the tenant’s active profile/algorithm/provider plus a deepLink (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 → pendingenrolled) 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.

  1. Key history / rotate / re-enroll (console DecisionSigningEnrollmentPanelComponent). The panel renders the operator’s full append-only historyActive/Pending/Retired/Revoked with the replacesKeyId chain and enrolment timestamps — across multiple device keys per person (Owner Q6), marking the current browser device’s key. It fed by GET /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 a re_enroll_required banner + CTA derived from the same current-key rule the request gate uses (an Active key on the active provider algorithm; an Active key on a superseded algorithm reads as re_enroll_required). Regional (non-NIST) profiles route rotate/enroll to the CLI/smartcard path — no browser dead-end.
  2. Admin coverage read model. GET /issuer-directory/operator-signing-keys/{issuerId}/coverage (scope authority:signing-keys.admin) projects the tenant’s append-only decision-signing history grouped by subject and derives each operator’s pending | enrolled | re_enroll_required state, plus per-operator lifecycle counts. It resolves the active provider algorithm from the enrolment policy so a stale-algorithm Active key is reported as re_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 is OperatorSigningCoverageComponent alongside the crypto/compliance admin views.
  3. Append-only delete-guard (req 5 fence). IIssuerKeyRepository intentionally exposes no delete operation; issuer deletion preflights decision-signing history and refuses; and startup migration 003_operator_signing_key_append_only_guard.sql installs a PostgreSQL BEFORE DELETE trigger on issuer_keys that blocks direct and cascading deletion of DecisionSigning rows. 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:

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:

FieldTypeSource fieldNotes
keyIdstringIssuerKeyRecord.Id / OperatorSigningKeyResponse.KeyIdStable key identifier. The operator-decision@v1 DSSE envelope keyid resolves to this.
subjectIdstringIssuerKeyRecord.SubjectIdThe enrolling operator’s sub. Always the token sub, never a blindly-trusted client value.
tenantstring (slug)IssuerKeyRecord.TenantIdTenant slug (e.g. default, partner-dev-lab). String, not a Guid — see §3 and the WS5 reconciliation (§5).
issuerIdstringIssuerKeyRecord.IssuerIdThe IssuerDirectory issuer the key is nested under (the account-scoped operator namespace).
algorithmIdstringIssuerKeyRecord.AlgorithmIdA canonical SignatureAlgorithms constant (§2.1). Distinct from the material type.
providerHintstring?IssuerKeyRecord.ProviderHintExact 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.
publicKeystring (SPKI/PEM or cert)IssuerKeyRecord.MaterialPublic key material only. type/format describe the encoding (§2.2).
fingerprintstringIssuerKeyRecord.FingerprintSHA-256 fingerprint of the DER-encoded public key; the second unique lookup key.
purposeenumIssuerKeyRecord.PurposeDecisionSigning for operator keys (vs Issuer for publisher/VEX keys). A verify path for an operator decision MUST require purpose = DecisionSigning.
typeenumIssuerKeyRecord.TypeMaterial format: Ed25519PublicKey / X509Certificate / DssePublicKey.
statusenumIssuerKeyRecord.StatusActive / Retired / Revoked / Pending (§2.3).
validFromtimestampIssuerKeyRecord.CreatedAtUtcStart of the key’s validity window.
validUntiltimestamp?IssuerKeyRecord.ExpiresAtUtcEnd of the validity window (null = no expiry).
retiredAttimestamp?IssuerKeyRecord.RetiredAtUtcRetirement cutoff. Normally the rotation instant; for provider-change retirement it is the configured grace deadline. The key stays resolvable for historical verification.
revokedAttimestamp?IssuerKeyRecord.RevokedAtUtcSet on revoke; key stays resolvable but MUST never be treated as active.
replacesKeyIdstring?IssuerKeyRecord.ReplacesKeyIdThe 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.

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:

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:

  1. A decision arrives carrying an operator-decision@v1 DSSE envelope whose keyid is the enrolled keyId.
  2. The Policy/Findings verify path (IOperatorDecisionVerifier, WS3) resolves the enrolled key:
    • Cache hit: read the row from policy.trusted_keys by (tenant, keyId) (string-scoped).
    • Cache miss / stale: pull the key from the Authority IssuerDirectory read API (the existing GET …/keys list/read surface on the WebService — IssuerKeyEndpoints.ListKeys) by keyId, project it onto the §2 shape, and upsert the policy.trusted_keys row as a read-through cache.
  3. Verification then runs through ICryptoProviderRegistry against publicKey/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 edit StellaOps.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 fieldExisting trusted_keys columnMapping
keyIdkey_iddirect
tenanttenant_id (TEXT)direct (string, §3)
fingerprintfingerprintdirect
algorithmIdalgorithmdirect (store the SignatureAlgorithms constant)
publicKeypublic_key_pemdirect (SPKI/PEM)
subjectIdownerowner already documents “OIDC subject”; store sub here
purposepurposes (JSONB array)add the string token "decision-signing" to the array
validFrom/validUntilvalid_from/valid_untildirect
status=Activeis_active + revoked_at IS NULLthe existing active predicate
status=Revoked/revokedAtrevoked_at/revoked_reasondirect

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 columnTypeMaps toWhy
subject_idTEXT NULLsubjectIdFirst-class operator sub. (Could overload owner, but a dedicated column is cleaner for the operator query path and avoids conflating issuer-owner semantics.)
provider_hintTEXT NULLproviderHintRegion/provider routing for ICryptoProviderRegistry.TryResolve. No existing column.
key_purposeTEXT NULLpurposeA scalar decision-signing discriminator for cheap indexed filtering (the JSONB purposes array stays the general-purpose list; this is the fast operator predicate).
retired_atTIMESTAMPTZ NULLretiredAtThe baseline has revoked_at but no retired_at; rotation (retire) must be distinguishable from revoke for historical resolvability (§2.3).
statusTEXT NULLstatusOptional 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_idTEXT NULLreplacesKeyIdLifecycle chaining (which key this rotated in for).

Indexes (idempotent, CREATE INDEX IF NOT EXISTS — see feedback_create_index_must_be_idempotent):

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:

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 this trusted_keys delta into the same migration file or ship it as a separate 00N_…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):

  1. Add the new properties to src/Policy/__Libraries/StellaOps.Policy.Persistence/Postgres/Models/TrustedKeyEntity.cs (SubjectId, ProviderHint, KeyPurpose, RetiredAt, Status, ReplacesKeyId — all nullable).
  2. Update the compiled model: each new property needs an AddProperty(...) block with its Relational:ColumnName annotation, and propertyCount must be bumped from 18to the new total (it is a hard-coded constructor argument in TrustedKeyEntityEntityType.Create). Add the two new indexes to the AddIndex(...) calls (and adjust namedIndexCount/unnamedIndexCount accordingly). 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

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


7. Cross-references