Issuer Directory Architecture — HOST RETIRED 2026-09-11

The standalone issuer-directory service no longer runs. The issuer surface does. SPRINT_20260722_016 AUTH-9 folded the issuer directory into Authority on 2026-09-08 and AUTH-10 retired the deployable on 2026-09-11. The host project is frozen under src/__Obsoleted/Authority/StellaOps.IssuerDirectory.WebService/; the compose key, the publish key and the issuerdirectory.stella-ops.local alias are gone.

What serves the surface now. stellaops-authority maps the same endpoint groups from the library src/Authority/__Libraries/StellaOps.IssuerDirectory.Api/ through IssuerDirectoryFold, and the gateway routes /issuer-directory to authority by a static ReverseProxy rule. The issuer schema moved into stellaops_authority at the fold window; the schema NAME is unchanged (D-AUTH3-1).

Paths, scopes and payloads are UNCHANGED, which is why this dossier is still useful rather than merely historical: api-reference.mdand openapi/v1.json remain accurate about the API. What is out of date anywhere below is the deployment framing — a separate container, its own base URL, its own host project and, since AUTH-10, the StellaOps.IssuerDirectory.WebService.* namespace, which is now StellaOps.IssuerDirectory.Api.*.

Canonical current dossier: ../authority/architecture.md(issuer sections 21.2 and 21.4).

Read the runtime sections below as the folded host’s behaviour, not a separate process: there is no longer an IssuerDirectory Program.cs, Kestrel listener, health endpoint or container of its own. Composition, authentication, tenancy middleware and the migration chain are Authority’s. Four divergences from the retired host’s composition are recorded in SPRINT_20260722_016 as D-AUTH5B-14…17, and the fold’s own wiring defects as AUTH-19/AUTH-21.

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

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

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:

KeyDefaultNotes
IssuerDirectory:Telemetry:MinimumLogLevelInformationSerilog minimum level.
IssuerDirectory:Authority:EnabledtrueWhen 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:RequireHttpsMetadatatrue
IssuerDirectory:Authority:ReadScopeissuer-directory:readScope bound to the Reader policy.
IssuerDirectory:Authority:WriteScopeissuer-directory:writeScope bound to the Writer policy.
IssuerDirectory:Authority:AdminScopeissuer-directory:adminScope 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:TenantHeaderX-StellaOps-TenantIdWire 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:SeedCsafPublisherstrueEnables CSAF bootstrap seeding.
IssuerDirectory:CsafSeedPathcsaf-publishers.jsonRelative paths resolved against the content root.
IssuerDirectory:Persistence:ProviderPostgresPostgres or InMemory (InMemory only valid in Testing).
IssuerDirectory:Persistence:PostgresConnectionString""Required when provider is Postgres.
IssuerDirectory:Persistence:SchemaNameissuerEffective schema; falls back to IssuerDirectory:Postgres:Schema, then issuer.
IssuerDirectory:Postgres:ConnectionString""Legacy connection-string fallback.
IssuerDirectory:Postgres:SchemaissuerLegacy schema fallback.
IssuerDirectory:Postgres:CommandTimeoutSeconds30Legacy.

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 shapeResolution
Token carries stellaops:tenantThe 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

MethodRoutePolicy / ScopeNotes
GET/issuer-directory/issuersReader / issuer-directory:readQuery includeGlobal (default true). Returns IssuerResponse[].
GET/issuer-directory/issuers/{id}Reader / issuer-directory:readQuery includeGlobal (default true); 404 if not found.
POST/issuer-directory/issuersWriter / issuer-directory:writeBody IssuerUpsertRequest; returns 201 Created with Location: /issuer-directory/issuers/{id}.
PUT/issuer-directory/issuers/{id}Writer / issuer-directory:writeBody IssuerUpsertRequest; route id must equal body Id (400 on mismatch). Returns 200.
DELETE/issuer-directory/issuers/{id}Admin / issuer-directory:adminReturns 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)

MethodRoutePolicy / ScopeNotes
GET/issuer-directory/issuers/{issuerId}/keysReader / issuer-directory:readQuery includeGlobal. Returns IssuerKeyResponse[].
POST/issuer-directory/issuers/{issuerId}/keysWriter / issuer-directory:writeBody IssuerKeyCreateRequest; returns 201 Created (Location includes generated key id). Validation failures → 400.
POST/issuer-directory/issuers/{issuerId}/keys/{keyId}/rotateWriter / issuer-directory:writeBody IssuerKeyRotateRequest; retires the active key and creates a replacement atomically. Returns 200.
DELETE/issuer-directory/issuers/{issuerId}/keys/{keyId}Admin / issuer-directory:adminRevokes 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_keys verification-view delta, and the Authority→Policy pull-by-keyId sync — lives in docs/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:

MethodRoutePolicy / ScopeNotes
GET/issuer-directory/operator-signing-keys/{issuerId}/keysauthority: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:readTenant-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:readService/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}/keysauthority: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}/rotateauthority: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}/approveauthority: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-supersedeAny of authority:signing-keys.admin, crypto:admin, crypto:profile:admin, or ops.adminBody 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-signing are JWT scope claim values that double as the ASP.NET policy names (the endpoints register RequireAuthorization(<scope>) with the same literal). The freshness gate is applied by StellaOpsScopeAuthorizationHandler for any token presenting these scopes, so applying the policy is sufficient to require a recent step-up.

Issuer trust (nested under {issuerId}/trust)

MethodRoutePolicy / ScopeNotes
GET/issuer-directory/issuers/{issuerId}/trustReader / issuer-directory:readQuery includeGlobal. Returns IssuerTrustResponse (tenant + global overrides + effective weight).
PUT/issuer-directory/issuers/{issuerId}/trustWriter / issuer-directory:writeBody IssuerTrustSetRequest (Weight, optional Reason). Weight must be in [-10, 10] (else 400). Returns 200 with the recomputed view.
DELETE/issuer-directory/issuers/{issuerId}/trustAdmin / issuer-directory:adminRemoves the tenant override (falls back to global/default weight). Returns 204.

Request/response contracts

Domain enums

Note: the startup SQL migration’s key_type CHECK constraint lists the legacy lower-case token set (ed25519, x509, dsse, kms, hsm, fido2) and the issuers.status CHECK lists (active, revoked, deprecated). The issuer_keys.status CHECK was relaxed by migration 002_operator_signing_key_dual_control.sql to admit pending alongside active/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

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:

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 AuthorityDbContext is a deliberate security feature (blast-radius containment; least privilege). See Authority architecture § 21.3.

7. Observability

OpenTelemetry meter StellaOps.IssuerDirectory (IssuerDirectoryMetrics):

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

9. Archived / superseded documentation