Issuer Directory Architecture
Owned by the Authority domain (Sprint 216, 2026-03-04). The IssuerDirectory source tree moved under
src/Authority/. Schema and DbContext ownership are documented canonically in the Authority architecture dossier (sections 21.1–21.4); this document describes the IssuerDirectory service’s own runtime, API, persistence, and observability surface as implemented.
Source of truth
- Service host:
src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/ - Domain + application services:
src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core/ - Infrastructure (seed loader, persistence wiring):
src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Infrastructure/ - Persistence library:
src/Authority/__Libraries/StellaOps.IssuerDirectory.Persistence/ - Client library:
src/Authority/__Libraries/StellaOps.IssuerDirectory.Client/(shared with Excititor, DeltaVerdict) - Schema/DbContext ownership rationale: Authority architecture dossier § 21.1–21.4
- Canonical scope catalog:
src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs
1. Purpose
Issuer Directory centralises trusted VEX/CSAF publisher metadata so downstream Stella Ops services (VEX Lens, Excititor, Policy Engine) can resolve issuer identity, active signing keys, and trust weights. It provides tenant-scoped CRUD APIs with an audit trail, key lifecycle management (add/rotate/revoke), tenant trust-weight overrides, and a bootstrap import for CSAF publishers.
This dossier is for engineers and operators integrating with the Issuer Directory service; it documents the runtime, API surface, persistence schema, and observability as implemented.
2. Runtime topology
- Service name (Router):
issuerdirectory(registered viaAddRouterMicroservice). Container imagestellaops/issuer-directory-web:dev, container namestellaops-issuer-directory(devops/compose/docker-compose.stella-services.yml). (Historicallystellaops/issuer-directory— an operator grepping compose for that bare name finds nothing.) - Framework: ASP.NET Core minimal APIs (
net10.0),WebApplicationhost. - Persistence: PostgreSQL is mandatory outside
Testing. The default schema isissuer(IssuerDirectoryDataSource.DefaultSchemaName). In-memory persistence is registered only whenPersistence:Provider=InMemoryand the environment isTesting; otherwise startup throws. - AuthZ: StellaOps resource-server scopes (
issuer-directory:read,issuer-directory:write,issuer-directory:admin), enforced via three named authorization policies (see § 4). - Audit: Every create/update/delete/seed/key/trust mutation writes an issuer-domain audit row through
IIssuerAuditSink; the service also wires unified audit emission viaAddAuditEmission(posts events to the Timeline service). - CSAF bootstrap: When
SeedCsafPublishers=true(default), the service loadsCsafSeedPath(defaultcsaf-publishers.json) on startup and seeds publishers into the directory, recording aseededaudit per publisher. - Health: anonymous
GET /healthz(liveness, predicate always false → 200 when the process responds) andGET /readyz(readiness, default predicate). A build-info endpoint is exposed for the operator verify aggregator (fallbackModuleName=issuerdirectory).
Clients ──> Authority (JWT) ──> IssuerDirectory WebService ──> PostgreSQL (schema: issuer)
│
├─> IIssuerAuditSink (PostgreSQL audit table)
└─> Unified audit emission ──> Timeline service
3. Configuration
Configuration binds to IssuerDirectoryWebServiceOptions (section name IssuerDirectory, env prefix ISSUERDIRECTORY_). Verified keys:
| Key | Default | Notes |
|---|---|---|
IssuerDirectory:Telemetry:MinimumLogLevel | Information | Serilog minimum level. |
IssuerDirectory:Authority:Enabled | true | When false, anonymous mode (Development/Testing only — startup throws otherwise, see § 5). |
IssuerDirectory:Authority:Issuer | "" | Required when enabled. |
IssuerDirectory:Authority:Audiences | [] | Required (≥1) when enabled. |
IssuerDirectory:Authority:RequireHttpsMetadata | true | |
IssuerDirectory:Authority:ReadScope | issuer-directory:read | Scope bound to the Reader policy. |
IssuerDirectory:Authority:WriteScope | issuer-directory:write | Scope bound to the Writer policy. |
IssuerDirectory:Authority:AdminScope | issuer-directory:admin | Scope bound to the Admin policy. |
IssuerDirectory:Authority:Scopes | [read, write, admin] | Advertised scope set. |
IssuerDirectory:Authority:ClientScopes | [] | |
IssuerDirectory:Authority:BypassNetworks | [] | CIDR networks that receive a synthetic principal carrying the required scope. Populated from ISSUERDIRECTORY__AUTHORITY__BYPASSNETWORKS__N; explicitly copied into StellaOpsResourceServerOptions.BypassNetworks (SPRINT_20260512_010 FU-1). |
IssuerDirectory:TenantHeader | X-StellaOps-TenantId | Wire tenant header. Since TEN-3 it is not the isolation key when the caller is authenticated — it is conflict-checked against the stellaops:tenant claim, and is only a fallback on the read path (see §4 “Tenancy contract”). |
IssuerDirectory:SeedCsafPublishers | true | Enables CSAF bootstrap seeding. |
IssuerDirectory:CsafSeedPath | csaf-publishers.json | Relative paths resolved against the content root. |
IssuerDirectory:Persistence:Provider | Postgres | Postgres or InMemory (InMemory only valid in Testing). |
IssuerDirectory:Persistence:PostgresConnectionString | "" | Required when provider is Postgres. |
IssuerDirectory:Persistence:SchemaName | issuer | Effective schema; falls back to IssuerDirectory:Postgres:Schema, then issuer. |
IssuerDirectory:Postgres:ConnectionString | "" | Legacy connection-string fallback. |
IssuerDirectory:Postgres:Schema | issuer | Legacy schema fallback. |
IssuerDirectory:Postgres:CommandTimeoutSeconds | 30 | Legacy. |
Auto-migration is wired: IssuerDirectoryPersistenceRuntime calls AddStartupMigrations<IssuerDirectoryWebServiceOptions> (module IssuerDirectory.Persistence, migrations assembly = IssuerDirectoryDataSource.Assembly) so a fresh database converges to the configured schema on startup.
4. API surface
All endpoints live under the /issuer-directory/issuers route group. Write/delete operations accept an optional audit reason via the X-StellaOps-Reason header (IssuerDirectoryHeaders.AuditReason); trust PUT also accepts a reason in the body.
Tenancy contract (claim-bound)
Sprint SPRINT_20260712_001 (TEN-3) moved tenant resolution off the wire header and onto the authenticated identity. TenantResolver (WebService/Services/TenantResolver.cs) now applies:
| Caller shape | Resolution |
|---|---|
Token carries stellaops:tenant | The claim is the isolation key. A X-StellaOps-TenantId header that disagrees with it is rejected 400 error_code=tenant_conflict — it is never an override. (Global-admin console tenant switching is unaffected: the shared StellaOpsTenantResolver already folds the canonical header into the claim for stellaops:admin principals, so the two agree by construction.) |
No claim + mutation (POST/PUT/DELETE, key rotate/revoke, operator-key enroll/rotate/revoke/approve) | Rejected 400 error_code=tenant_missing. |
No claim + read (GET) | Falls back to the X-StellaOps-TenantId header. |
Authority:Enabled=false (anonymous local harness only — IssuerDirectoryAnonymousModeGuard hard-blocks it outside Development/Testing) | Header remains the tenancy source for every access mode. |
Why reads still honour the header: StellaOps.IssuerDirectory.Client sends the tenant header and no bearer token, and Excititor / Policy Engine / ReleaseOrchestrator resolve issuer keys and trust weights through it over the backend network (Authority:BypassNetworks). Reads are non-destructive; the 2026-07-11 audit finding was that a mutation from that same tokenless bypass shape could rewrite another tenant’s VEX-consensus trust weights. Mutations are therefore claim-bound and a bypass-network caller — which carries no principal, hence no tenant claim — can no longer write into a tenant it merely names.
The failure vocabulary (error_code problem extension: tenant_missing, tenant_conflict, tenant_unknown) mirrors the platform’s StellaOpsTenantEndpointFilter. All tenant failures are 400, matching the platform mapping (not Attestor’s 403 tenant_claim_mismatch).
Authorization policies (IssuerDirectoryPolicies): IssuerDirectory.Reader → read scope, IssuerDirectory.Writer → write scope, IssuerDirectory.Admin → admin scope. Each maps to the configured scope via AddStellaOpsScopePolicy (honors BypassNetworks). When Authority:Enabled=false, all three policies allow any request (anonymous mode).
Issuers
| Method | Route | Policy / Scope | Notes |
|---|---|---|---|
GET | /issuer-directory/issuers | Reader / issuer-directory:read | Query includeGlobal (default true). Returns IssuerResponse[]. |
GET | /issuer-directory/issuers/{id} | Reader / issuer-directory:read | Query includeGlobal (default true); 404 if not found. |
POST | /issuer-directory/issuers | Writer / issuer-directory:write | Body IssuerUpsertRequest; returns 201 Created with Location: /issuer-directory/issuers/{id}. |
PUT | /issuer-directory/issuers/{id} | Writer / issuer-directory:write | Body IssuerUpsertRequest; route id must equal body Id (400 on mismatch). Returns 200. |
DELETE | /issuer-directory/issuers/{id} | Admin / issuer-directory:admin | Returns 204 No Content only when the issuer has no append-only operator decision-signing history; persistence rejects a delete that could cascade into those key rows. |
Issuer keys (nested under {issuerId}/keys)
| Method | Route | Policy / Scope | Notes |
|---|---|---|---|
GET | /issuer-directory/issuers/{issuerId}/keys | Reader / issuer-directory:read | Query includeGlobal. Returns IssuerKeyResponse[]. |
POST | /issuer-directory/issuers/{issuerId}/keys | Writer / issuer-directory:write | Body IssuerKeyCreateRequest; returns 201 Created (Location includes generated key id). Validation failures → 400. |
POST | /issuer-directory/issuers/{issuerId}/keys/{keyId}/rotate | Writer / issuer-directory:write | Body IssuerKeyRotateRequest; retires the active key and creates a replacement atomically. Returns 200. |
DELETE | /issuer-directory/issuers/{issuerId}/keys/{keyId} | Admin / issuer-directory:admin | Revokes the key (status → Revoked). Returns 204. |
Operator decision-signing keys (ADR-025 / AUTH-OPSIGN-002)
The stable enrolled-key contract consumed by the rest of the operator-signed-decisions program (WS3/WS4/WS5/WS6/WS7) — the frozen field shape, the
policy.trusted_keysverification-view delta, and the Authority→Policy pull-by-keyId sync — lives indocs/modules/authority/operator-signing-key-contract.md(AUTH-OPSIGN-006). This section documents the enrolment surface; that doc is the contract.
A separate, account-scoped enrolment surface for operator decision-signing public keys (the operator-decision@v1 predicate). Distinct from the issuer-scoped key endpoints above:
- The key is always bound to the enrolling operator’s
subfrom the verified token — a client-suppliedsubjectIdis honoured only when it matches and is otherwise rejected (400); it is never trusted on its own. - Non-custodial: only the public key (or qualified certificate) is accepted. Private-key material is rejected (
400,IssuerKeyValidator.RejectPrivateKeyMaterial). - Every operation is fresh-auth-gated: a token carrying
authority:signing-keys.enrollorauthority:signing-keys.adminis rejected byStellaOpsScopeAuthorizationHandlerunless itsauth_timeis within the freshness window (403); a missing scope is403, an unauthenticated request401. - Every operation audits the operator
sub,algorithmId,providerHint, and the fresh-auth claims (auth_time/acr/amr) viaIIssuerAuditSink; approval also recordsapprover_sub. - Optional dual-control (
IssuerDirectory:OperatorSigningKeys:RequireDualControl=true): the enrolled key startsPendingand stays inactive until a second approver (distinctsub, holdingauthority:signing-keys.admin) promotes it. Self-approval is rejected (400). - Compliance-profile auto-bind (
IssuerDirectory:OperatorSigningKeys:EnforceComplianceProfile=true, the default): before enrollment, IssuerDirectory calls Platform’s narrow tenant-bound/api/v1/admin/crypto-providers/compliance-profile/operator-signing-enrollmentprojection with the validated operator bearer and tenant. The projection determines the canonicalalgorithmIdand exact activeproviderName; the localICryptoProviderRegistrymust resolve that provider and it must reportSupports(Verification, algorithmId). A client algorithm/provider mismatch is400; an unavailable profile projection or missing active provider is503. The same guard runs before both enrolment and rotation, so rotation cannot replace a compliant key with a caller-selected algorithm/provider. No key row is written or retired on failure. World/FIPS project ES256 browser enrollment; GOST/SM project their regional algorithm and a CLI/smartcard route, never an ES256 downgrade. - The IssuerDirectory host registers
default,fips.ecdsa.soft, andeu.eidas.softverification identities. The FIPS/eIDAS software providers keep their existing explicit environment gates; selecting one without enabling/configuring that provider returns the fail-closed503rather than falling back todefault. Regional verification providers remain operator/plugin configuration. - Append-only verification history:
IIssuerKeyRepositoryexposes no delete/remove operation. Revocation and rotation update lifecycle state while retaining the public key. Migration003_operator_signing_key_append_only_guard.sqladds a PostgreSQLBEFORE DELETEtrigger forDecisionSigningrows, so direct deletes and the historical issuerON DELETE CASCADEpath fail closed.PostgresIssuerRepository.DeleteAsyncalso rejects an issuer delete before mutation when that issuer owns operator decision-signing history. - Crash-safe provider-change retirement: the provider-change mutation requires a stable non-empty
operationId. IssuerDirectory canonicalizes the subject and supported-algorithm lists, then commits every affected key retirement, its per-key audit row, and one immutable receipt in a single PostgreSQL transaction. A retry with the same tenant/issuer/canonical selection returns that original receipt without retiring or auditing again, even if the configured grace period or executing service identity changed after the first attempt. Reusing an operation id for a different tenant, issuer, subject set, or algorithm set fails closed with409. The initiating operator is persisted asoriginalActorSubject; the audit also records the current executing subject, while neither request nor receipt carries private key material.
| Method | Route | Policy / Scope | Notes |
|---|---|---|---|
GET | /issuer-directory/operator-signing-keys/{issuerId}/keys | authority:signing-keys.enroll (+ fresh auth) | Lists the current operator’s DecisionSigning keys for the issuer, bound to the token sub. Returns public verification material and lifecycle status. |
GET | /issuer-directory/operator-signing-keys/{issuerId}/keys/subjects/{subjectId} | Reader / issuer-directory:read | Tenant-scoped service/admin lookup for all public DecisionSigning keys belonging to one subject. This is deliberately reader-only: Policy uses it to answer key-presence checks for the authenticated operator without impersonating that operator’s fresh-auth enrolment token. The tenant header remains mandatory and cross-tenant rows are never returned. |
GET | /issuer-directory/operator-signing-keys/{issuerId}/keys/{keyId} | Reader / issuer-directory:read | Service/admin verification read for one DecisionSigning key. Returns 404 when the key is missing or is not a decision-signing key. |
POST | /issuer-directory/operator-signing-keys/{issuerId}/keys | authority:signing-keys.enroll (+ fresh auth) | Body OperatorSigningKeyEnrollRequest; binds the key to the token sub with purpose DecisionSigning and, by default, auto-binds the active compliance-profile algorithm/provider. Returns 201 (Active, or Pending if dual-control is on), 400 for a client/profile mismatch, or 503 when the profile/provider cannot be resolved safely. |
POST | /issuer-directory/operator-signing-keys/{issuerId}/keys/{keyId}/rotate | authority:signing-keys.enroll (+ fresh auth) | Body OperatorSigningKeyEnrollRequest; applies the same active-profile algorithm/provider guard as enrolment, then retires the prior key and binds the replacement to the same operator. Returns 200; a rejected replacement leaves the active key unchanged. |
DELETE | /issuer-directory/operator-signing-keys/{issuerId}/keys/{keyId} | authority:signing-keys.enroll (+ fresh auth) | Revokes the key (stays resolvable for historical verification). Returns 204. |
POST | /issuer-directory/operator-signing-keys/{issuerId}/keys/{keyId}/approve | authority:signing-keys.admin (+ fresh auth) | Dual-control: a second approver promotes a Pending key to Active. Returns 200. |
POST | /issuer-directory/operator-signing-keys/{issuerId}/provider-change-supersede | Any of authority:signing-keys.admin, crypto:admin, crypto:profile:admin, or ops.admin | Body OperatorProviderChangeSupersedeRequest contains the stable operationId, initiating originalActorSubject, Authority-resolved exception-granting subjectIds, and active-provider supportedAlgorithmIds. Returns the immutable OperatorProviderChangeSupersedeResponse; exact-request replay returns the original response, conflicting operation-id reuse returns 409. |
Scope gotcha (CLAUDE.md §1.5):
authority:signing-keys.enroll/authority:signing-keys.admin/decision-signingare JWT scope claim values that double as the ASP.NET policy names (the endpoints registerRequireAuthorization(<scope>)with the same literal). The freshness gate is applied byStellaOpsScopeAuthorizationHandlerfor any token presenting these scopes, so applying the policy is sufficient to require a recent step-up.
Issuer trust (nested under {issuerId}/trust)
| Method | Route | Policy / Scope | Notes |
|---|---|---|---|
GET | /issuer-directory/issuers/{issuerId}/trust | Reader / issuer-directory:read | Query includeGlobal. Returns IssuerTrustResponse (tenant + global overrides + effective weight). |
PUT | /issuer-directory/issuers/{issuerId}/trust | Writer / issuer-directory:write | Body IssuerTrustSetRequest (Weight, optional Reason). Weight must be in [-10, 10] (else 400). Returns 200 with the recomputed view. |
DELETE | /issuer-directory/issuers/{issuerId}/trust | Admin / issuer-directory:admin | Removes the tenant override (falls back to global/default weight). Returns 204. |
Request/response contracts
IssuerUpsertRequest:Id,DisplayName,Slug(required),Description?,Contact(IssuerContactRequest:Email,Phone,Website,Timezone),Metadata(IssuerMetadataRequest:CveOrgId,CsafPublisherId,SecurityAdvisoriesUrl,CatalogUrl,Languages[],Attributes{}),Endpoints[](IssuerEndpointRequest:Kind(defaultcsaf),Url,Format?,RequiresAuth),Tags[]. (Slugis required on create/update but is not separately re-validated against the existing record on update.)IssuerResponse:Id,TenantId,DisplayName,Slug,Description?,Contact,Metadata,Endpoints[],Tags[],CreatedAtUtc,CreatedBy,UpdatedAtUtc,UpdatedBy,IsSystemSeed.IssuerKeyCreateRequest/IssuerKeyRotateRequest:Type(parsed case-insensitively toIssuerKeyType),Format,Value,ExpiresAtUtc?. (Format+Valueform theIssuerKeyMaterial.)IssuerKeyResponse:Id,IssuerId,TenantId,Type,Status,MaterialFormat,Fingerprint,CreatedAtUtc,CreatedBy,UpdatedAtUtc,UpdatedBy,ExpiresAtUtc?,RetiredAtUtc?,RevokedAtUtc?,ReplacesKeyId?.OperatorSigningKeyEnrollRequest(ADR-025):Type,Format,Value(PUBLIC key only),AlgorithmId?(aSignatureAlgorithmsconstant),ProviderHint?,SubjectId?(must match the tokensubwhen present),ExpiresAtUtc?.OperatorSigningKeyResponse(ADR-025):KeyId,IssuerId,TenantId,SubjectId,Type,Status,MaterialFormat,PublicKey(the stored publicIssuerKeyRecord.Material.Value),AlgorithmId?,ProviderHint?,Purpose,Fingerprint,PendingApproval,CreatedAtUtc,ExpiresAtUtc?,ValidFromUtc,ValidUntilUtc?,RetiredAtUtc?,RevokedAtUtc?,ReplacesKeyId?. The response never contains private material; enrollment rejects it before a key can be stored.IssuerTrustResponse:TenantOverride?,GlobalOverride?(each aTrustOverrideSummary:Weight,Reason?,UpdatedAtUtc,UpdatedBy,CreatedAtUtc,CreatedBy),EffectiveWeight.
Domain enums
IssuerKeyType:Ed25519PublicKey,X509Certificate,DssePublicKey.IssuerKeyStatus:Active,Retired,Revoked,Pending. (Pendingis the dual-control hold for operator decision-signing keys — ADR-025 / AUTH-OPSIGN-002; not used by classic issuer keys.)IssuerKeyPurpose:Issuer(default, publisher/VEX signing),DecisionSigning(operator decision-signing key — ADR-025).
Note: the startup SQL migration’s
key_typeCHECK constraint lists the legacy lower-case token set (ed25519,x509,dsse,kms,hsm,fido2) and theissuers.statusCHECK lists (active,revoked,deprecated). Theissuer_keys.statusCHECK was relaxed by migration002_operator_signing_key_dual_control.sqlto admitpendingalongsideactive/retired/revoked. These string vocabularies are not the same surface as the C# enums above, which are the authoritative API contract.
5. Authentication and anonymous mode
- When
Authority:Enabled=true, the service registers StellaOps resource-server authentication (AddStellaOpsResourceServerAuthentication) with the configured issuer, audiences,RequireHttpsMetadata, andBypassNetworks. - When
Authority:Enabled=false, anAllowAnonymousauthentication handler is used and all three policies pass.IssuerDirectoryAnonymousModeGuard.Enforcepermits this only inDevelopmentorTesting; any other environment throws at startup (“anonymous mode is restricted to Development/Testing local harnesses”).
6. Persistence schema (default schema issuer)
Startup migration Migrations/001_initial_schema.sql (embedded; applied by the shared migration runner under the configured search_path) creates:
issuers—id UUIDPK,tenant_id UUID(global seeds use the@globalGUID),name(slug),display_name,description,endpoints/contact/metadataJSONB,tags TEXT[],status(active|revoked|deprecated),is_system_seed, created/updated audit columns. Unique(tenant_id, name).issuer_keys—id UUIDPK,issuer_id(FK →issuers,ON DELETE CASCADE),tenant_id,key_id,key_type,public_key,fingerprint,not_before/not_after,status(active|retired|revoked),replaces_key_id, lifecycle timestamps,revoke_reason,metadataJSONB. Unique(issuer_id, key_id)and uniquefingerprint.trust_overrides—id UUIDPK,issuer_id(FK),tenant_id,weight NUMERIC(5,2)(CHECK-10..10),rationale,expires_at, audit columns. Unique(issuer_id, tenant_id).audit—id BIGSERIALPK,tenant_id,actor,action,issuer_id,key_id,trust_override_id,reason,detailsJSONB,correlation_id,occurred_at.operator_provider_change_receipts— append-only operation receipt keyed globally byoperation_id, with tenant/issuer identity, canonical subject and algorithm arrays, initiating and executing actor subjects, original reason/grace/time, and the ordered retired-key result as JSONB. Migration004_operator_provider_change_receipts.sqlcreates the table, tenant/time index, and aBEFORE UPDATE OR DELETEtrigger. The receipt contains lifecycle/public identifiers only, never private key material.schema_migrations— migration tracking table.
Indexes cover tenant/status/slug lookups, key issuer/status/tenant, trust tenant, and audit (tenant_id, occurred_at DESC)/issuer. BEFORE UPDATE triggers maintain updated_at on issuers, issuer_keys, and trust_overrides. Startup migration 003_operator_signing_key_append_only_guard.sql adds the issuer_keys delete guard that preserves operator decision-signing public keys even when an issuer delete would otherwise cascade. Startup migration 004_operator_provider_change_receipts.sql makes provider-change retry receipts durable and append-only; PostgresOperatorProviderChangeRepository commits each receipt in the same transaction as the corresponding key lifecycle and audit mutations.
The schema isolation from
AuthorityDbContextis a deliberate security feature (blast-radius containment; least privilege). See Authority architecture § 21.3.
7. Observability
OpenTelemetry meter StellaOps.IssuerDirectory (IssuerDirectoryMetrics):
issuer_directory_changes_total— tagstenant,issuer,action. Counts issuer create/update/delete events.issuer_directory_key_operations_total— tagstenant,issuer,operation,key_type. Counts key create/rotate/revoke operations.issuer_directory_key_validation_failures_total— tagstenant,issuer,reason. Counts key validation/verification failures.
The host registers ASP.NET Core + runtime metrics instrumentation and ASP.NET Core + HttpClient tracing.
Audit actions
Issuer-domain audit rows are written with these action values (from the Core services): created, updated, deleted, seeded, key_created, key_enrollment_pending, key_retired, key_rotated, key_revoked, trust_override_set, trust_override_deleted. key_enrollment_pending is written instead of key_created when RequireDualControl is on and the key is held for a second-operator approval (IssuerKeyService.Add.cs:91). Provider-change retirement writes key_retired once per affected key with correlation id operationId; details retain both original_actor_subject and executing_actor_subject. A receipt replay writes no additional audit row.
8. Dependencies and reuse
StellaOps.IssuerDirectory.Core— domain model (IssuerRecord,IssuerKeyRecord,IssuerTrustOverrideRecord,IssuerAuditEntry) + application services (IssuerDirectoryService,IssuerKeyService,IssuerTrustService) + key validators (Ed25519/X.509/DSSE).StellaOps.IssuerDirectory.Infrastructure— CSAF seed loader, persistence wiring.StellaOps.IssuerDirectory.Persistence— PostgreSQL repositories, audit sink, EF CoreIssuerDirectoryDbContextwith compiled models, startup migration.- Shared libraries:
StellaOps.Configuration,StellaOps.Cryptography,StellaOps.Auth.ServerIntegration(+ tenancy),StellaOps.Audit.Emission,StellaOps.Router.AspNet,StellaOps.Infrastructure.Postgres.Migrations,StellaOps.Localization.
9. Archived / superseded documentation
- Archived original dossier:
docs-archive/modules/issuer-directory/architecture.md(andoperations/*). The archived YAML sample and table-name configuration (schema: issuer_directory,issuersTable/auditTablekeys) describe the pre-Sprint-216 design and do not match the current implementation; treat this document and the Authority dossier § 21 as authoritative.
