Platform architecture (summary)

Audience: Platform WebService owners, service authors adopting the cross-cutting contracts, and operators wiring Console configuration. This is the orientation summary; defer to the linked module dossiers for contract-level detail.

This module covers two things: (1) the cross-cutting contracts and guardrails that every Stella Ops service must follow, and (2) the Platform WebService (StellaOps.Platform.WebService) — the runtime that backs the Console UI, serves frontend configuration, owns installation identity/settings and the cross-service tenant directory, and exposes federated-telemetry/NIS2 surfaces plus rebuildable read projections. It does not host ReleaseOrchestrator operational endpoints or migration authority.

Installation versus deployment ownership: Platform owns the Stella Ops installation control plane (platform.*, setup state, installation settings, tenant directory, and read projections). ReleaseOrchestrator owns deployment topology and image placement (release.environments, release.targets, inventory/digests, promotions, and deployment execution). A shared physical PostgreSQL installation does not make Platform a second writer or migration owner for ReleaseOrchestrator tables. Environment, script, and regional-federation operations are hosted by ReleaseOrchestrator and routed there directly by Gateway. Platform also no longer compiles or hosts the dormant RO EvidenceThread implementation; its former Platform surface is absent rather than proxied.

Source of truth for the runtime described below: src/Platform/StellaOps.Platform.WebService/Program.cs (composition root + endpoint map), src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs + PlatformPolicies.cs (authorization), and src/Platform/__Libraries/StellaOps.Platform.Persistence/Migrations/Platform/ (the embedded Platform-owned migration stream). The former Migrations/Release/ stream was never embedded or selected by Platform startup and was archived on 2026-09-14 (SPRINT_20260914_005 PEF-2) under Migrations/_archived/pre_1.0/mig061/Release/v1/. CM-6 boundary statements are verified against d3358c84319b0da835e944d70f360f7df526d9c5; re-verify with rg -n "ProjectReference Include=.*ReleaseOrchestrator" src/Platform -g '*.csproj' and rg -n "Map(EvidenceThread|ReleaseOrchestratorEnvironment|Script)|FederationController|AddReleaseOrchestratorFederation" src/Platform/StellaOps.Platform.WebService -g '*.cs'.

Anchors

Scope

Coordination

Platform docs are the starting point for new contributors; keep this summary in sync with module-specific dossiers and sprint references.

Notify build seam ruled (Q-19 + Q-24, 2026-08-27): contracts plus enqueue client. Platform’s backup and NIS2 emitters (BackupRunEventPublisher, Nis2EffectivenessNotifyThresholdEmitter) stop compiling StellaOps.Notify.Queue/Notify.Models implementation and repoint onto two verified-closed Notify-owned projects: the BCL-only wire-contract project shared with Q-11 and the separate enqueue-only StellaOps.Notify.EventClient. Publishing through domain-neutral StellaOps.Eventing was rejected for these emitters — NIS2/backup notification ordering and delivery semantics must not change as a side effect of a build-boundary fix. The client preserves the existing Redis/NATS bytes, stream/subject, idempotency, partition, attributes and failure behavior without exposing lease/claim, delivery queues, health checks, persistence, hosts or migrations. Executed by SPRINT_20260722_026 CM-7 — see the delivery note below.

Notify seam delivered (2026-09-05, CM-7). StellaOps.Platform.WebService no longer references StellaOps.Notify.Queue. It compiles StellaOps.Notify.Contracts (BCL-only) and StellaOps.Notify.EventClient (enqueue-only), both classified cross-service-client-sdk/notify, and composes AddNotifyEventClient against the same notify:queue section with the same binding and the same fail-closed behavior. The three production consumers — BackupAlertService, BackupRunEventPublisher, Nis2EffectivenessNotifyThresholdEmitter — depend on INotifyEventPublisher; no using directive changed, because the moved types kept their StellaOps.Notify.Models / StellaOps.Notify.Queue namespaces and both predecessor assemblies forward every moved public type. Parity is structural rather than two implementations agreeing: StellaOps.Notify.Queue composes the same producer, and both encode through one NotifyEventWireEncoder.

Verified against source commit 257f05bbd3 and re-verified at the landing merge dede3cfe8ac9b45632a8087617f90eb19a4d51bd: the Release publish’s deps.json compiles no StellaOps.Notify.Queue; the platform|notify register pin shrank 3 → 2. CM-2’s 2026-09-09 source increment removes the remaining migration-plane reference and NotifyMigrationModulePlugin, retiring that pair. The host build also exposed the NIS2 threshold payload still arriving through Notify.Models; that payload and its two routing records now live unchanged in Notify.Contracts, with the existing Models assembly forwarding their identities for prebuilt consumers. Platform no longer compiles Notify.Persistence or Notify.Models. Re-verify with pwsh tools/scripts/build-boundary/generate-build-boundary-report.ps1 -Check and pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/StellaOps.Architecture.Contracts.Tests.csproj -Class "*BuildBoundaryConformanceTests*".

ReleaseOrchestrator build seam delivered (Q-10, 2026-08-25): contract-seam. Platform and ReleaseOrchestrator remain distinct services — one-family was rejected to preserve the distinct RO/agent-core trust boundary. Platform compiles no RO implementation: its sole RO project edge is the producer-owned, zero-reference StellaOps.ReleaseOrchestrator.Topology.Contracts wire project. Environment/script/federation endpoints are absent from Platform and owned by RO; the former Platform EvidenceThread surface is absent and no dormant EvidenceThread implementation is hosted. The ruled ownership direction remains: RO owns environment/script/deployment operations and drives deployment agents (pull or push); RO reads Platform’s non-release settings by Platform’s contract/API only. Execution: SPRINT_20260722_026 CM-6 (settlement receipt there).

Platform WebService runtime

StellaOps.Platform.WebService is the Console backend. It is a router-microservice (AddRouterMicroservice(serviceName: "platform")) that reaches the gateway over the Valkey/Redis transport and is authenticated with Authority-issued bearer tokens verified by AddStellaOpsResourceServerAuthentication. All bound options live under the Platform configuration section (PlatformServiceOptions).

Storage and startup posture

Release topology projection owner seam

Platform rebuilds its topology inventory without opening the ReleaseOrchestrator database. In the PostgreSQL runtime it reads only active tenant UUIDs from its own shared.tenants, then calls the RO owner directly at GET /internal/v1/release-orchestrator/topology-snapshot once per tenant. The internal call does not traverse Gateway and has no Router route or SQL fallback.

The call uses the producer-owned schema-v1 contract project and a signed Router identity envelope whose tenant is fixed to the requested UUID, whose only scope is release:read, and whose lifetime is two minutes. STELLAOPS_RELEASE_ORCHESTRATOR_URL is the only accepted owner-base configuration; missing or invalid HTTP(S) configuration fails startup. A non-success response, malformed or unsupported payload, duplicate/empty identifiers, tenant mismatch, or cross-tenant row from the owner fails closed rather than returning a partial projection.

The owner snapshot returns regions, environments, targets, and agents in deterministic order. Agent capabilities are the ordinal-sorted distinct union of the built-in capability enum and extended capability strings; the owner maps canonical Stale status to wire status offline. Platform maps the validated wire response into its own rebuildable release.topology_*_inventory and context projections. It never reads RO’s release.regions, release.environments, release.targets, or release.agents tables.

Subject-access erasure owner seam

POST /api/v1/operator/sar/{actorRef}/erase remains a Platform-owned, tenant-scoped platform:sar:erase workflow. Platform first erases the mutable shared.actor_identity projection, then asks the Evidence owner whether immutable legacy capsules must be retained under Article 17(3). Platform does not query evidence_locker.* tables and does not treat an unavailable owner as an empty retention result.

The owner call uses the producer-owned StellaOps.EvidenceLocker.Contracts wire records and a short-lived signed Router identity envelope containing the original authenticated tenant claim, requester subject, correlation ID and only evidence:create. Before sending, the same Platform tenant resolver used by the erasure API must resolve that claim to the requested canonical UUID. The UUID remains the actor-data identity; replacing the claim would break the Evidence owner’s configured tenant allowlist. Missing authentication, an unknown tenant or a different resolved UUID refuses the owner call before transport. Configuration is selected once at composition:

This is configuration precedence, not runtime failover. Missing envelope-signing material, transport failure, any non-success status, or an empty/malformed/internally inconsistent response fails the request; it never retries a second owner host, opens a shared database, or fabricates completed. The identity update and owner evaluation remain two ordered service operations, not a distributed transaction. See SPRINT_20260722_021 PLT-1A and the EVD-9 runbook.

Environment-state custodian (platform.environment_state)

Platform is the generic custodian of environment posture — never its interpreter (owner ruling A2; docs-archive/modules/export-center/consolidation-design.md §6.3a). platform.environment_state stores one versioned state document per (class, scope) — {class, scope, state_json, version, declared_at, declared_by} — and the same row shape serves airgap-seal, time-anchor, and every future class.

Composer pipeline and /platform/envsettings.json

The anonymous GET /platform/envsettings.json endpoint (alias GET /envsettings.json for direct service access) returns the Angular frontend AppConfig (EnvironmentSettingsResponse: Authority/OIDC settings, ApiBaseUrls, optional telemetry/welcome/doctor blocks, and a Setup state). The payload is built by EnvironmentSettingsComposer.ComposeAsync from three layers, lowest-to-highest priority:

  1. Environment variables — STELLAOPS_*_URL values folded into ApiBaseUrls by StellaOpsEnvVarPostConfigure (an IPostConfigureOptions<PlatformServiceOptions>).
  2. YAML/JSON config — standard IOptions binding of Platform:EnvironmentSettings (and ../etc/platform.yaml / platform.yaml).
  3. Database overrides — platform.environment_settings (key/value), overlaid last via IEnvironmentSettingsStore. Keys follow ApiBaseUrls:{service} for per-service base URLs, or scalar names (ClientId, TokenEndpoint, OtlpEndpoint, WelcomeTitle, DoctorFixEnabled, PlatformVersion, …).

PlatformVersion — the installation’s product version

PlatformVersion is the single source of the product version the console shell displays (the string under the brand mark in the sidebar). It is an ordinary environment setting: composed through the three layers above, served in the platformVersion field of /platform/envsettings.json, and changeable at runtime by an operator via PUT /platform/envsettings/db/PlatformVersion without a rebuild.

Two properties are deliberate:

This replaced a hardcoded string in app-sidebar.component.ts, which had drifted from the 1.0.0-alpha1 in the service .csproj files and from the 1.0.0 the CLI and GET /platform/metadata report. Note that PlatformVersion governs only what the console displays; assembly/InformationalVersion values remain per-project and are not derived from it.

DB-layer overrides are managed through the authenticated admin API GET/PUT/DELETE /platform/envsettings/db[/{key}] (platform.setup.read to list, platform.setup.admin to mutate; mutations are audited). EnvironmentSettingsRefreshService (hosted) re-reads the DB layer on the Platform:Cache:EnvironmentSettingsRefreshSeconds cadence (default 60 s) and reacts to Valkey pub/sub dirty signals when ConnectionStrings:Redis is configured.

Region-scoped signature-verification material (Verification:*)

Operator-entered, region-scoped signature-verification keys/trust-roots are stored as ordinary platform.environment_settings rows (no schema change) under reserved verification namespaces, grouped by the active regional crypto profile (world/fips/gost/sm/kcmvp/eidas, see ComplianceProfiles):

The platform STORES + SERVES these admin-entered values; it never generates keypairs. Pack lifecycle rows are written through the verification-key lifecycle API, not arbitrary environment-setting mutation. The existing generic admin PUT retains only a strict first-install bootstrap for an exact known-profile scoped Pack scalar while no Pack ledger exists; exact retry is idempotent, while replacement, metadata/progress mutation, and generic deletion are rejected. Generic PUT/DELETE attempts against lifecycle-owned keys return HTTP 409 with stable code verification_setting_lifecycle_owned; a strict-bootstrap conditional race returns 409 verification_root_state_conflict. A pre-existing single-region legacy install may omit the <profile> segment (Verification:PacksRegistry:PublicKeyPem); resolution still falls back to that read-only legacy form and the next lifecycle introduce captures it before rotation.

Because the /platform/envsettings.json composer is frontend-only and drops unknown keys, backend services do not receive this material through that payload. Instead, VerificationSettingsResolver maps the stored rows into flat, service-local configuration values and serves them at the existing GET /platform/verification-settings/{service}?profile=<active> route (VerificationSettingsEndpoints). For PacksRegistry the response contains the compatible active scalar (PacksRegistry:Verification:PublicKeyPem), a canonical JSON live trust set (PacksRegistry:Verification:TrustSetJson), and its deterministic epoch (PacksRegistry:Verification:TrustEpoch). No new route is involved.

The Pack trust set is schema stellaops.packsregistry.verification-trust-set/v1. Its roots have fixed keyId, lowercase status (active/retiring), and exact publicKeyPem fields; they are ordinal-sorted by key id then status. A Pack key id is stored in exact trimmed form, contains no control characters, and is at most 256 UTF-8 bytes. Introduce rejects a violating id before any write, while publication fails closed on a violating retained row and requires exact operator recovery rather than truncation or relabeling. The epoch is sha256: plus lowercase SHA-256 of UTF-8 without BOM over profile + "\n" + canonicalJson. A real Pack ledger must contain exactly one active root and at most one retiring root to publish. A distinct introduce is rejected atomically while that retiring root exists; exact retry remains idempotent, and operators must finish/invalidate rather than stack rotations. Exactly one RSA PUBLIC KEY / RSA PUBLIC KEY PEM block plus surrounding whitespace is accepted; private-key or mixed content is rejected before introduce writes so secret material cannot enter the non-secret ledger or response. Platform normalizes every live RSA root by importing the PEM, exporting canonical DER SubjectPublicKeyInfo, and hashing it with SHA-256; duplicate normalized keys fail publication even when their key ids or PEM encodings differ. Archived Pack roots are audit-only because packs are mutable and re-enveloped; revoked roots are excluded. Missing/malformed metadata, missing or invalid included PEM, duplicate key ids or normalized RSA keys, case-insensitive key-id collisions or lifecycle-key variants, multiple retiring roots, or an ordinal active-ledger/scalar mismatch fails closed rather than silently shrinking trust.

The direct Pack lifecycle snapshot is bounded before values are materialized: one profile may retain at most 256 root rows, each lifecycle value may occupy at most 64 KiB UTF-8, and the full snapshot may occupy at most 4 MiB UTF-8. PostgreSQL reads byte lengths first with sequential access and limits output to one row beyond the ceiling. Both PostgreSQL and in-memory mutation stores validate the complete projected state against the same bounds before their first write. Separately, the complete outer PacksRegistry verification-settings JSON response must fit the consumer’s 64 KiB startup transport ceiling: introduce/bootstrap serialize the exact projected dictionary with the endpoint options before write, and retained over-bound publication fails closed. A corrupt or historically over-bound snapshot fails publication and mutation closed for owner-approved recovery; terminal audit history is never silently omitted, truncated, or reclassified as live trust.

For scalar-only legacy installations, Platform synthesizes the honest identity legacy-pem-sha256-<sha256-of-exact-UTF8-PEM-text>. The first lifecycle introduce persists that scalar as a retiring record before overwriting it with the new active PEM. A legacy active ledger record that lacks publicMaterial is similarly backfilled from the scalar before demotion; an unrecoverable value aborts before any write and requires operator repair.

Every retained Pack ledger row must therefore carry parseable public material before a later introduce. A pre-repair archived/revoked row that lacks publicMaterial cannot be compared cryptographically and blocks rotation before any write; it is never silently ignored or deleted. The generic settings API is not a repair seam. The operator must recover the exact original public PEM from custody/audit evidence and obtain an owner-approved, audited ledger repair; if that material is unavailable, rotation remains fail-closed and must be escalated rather than relabeling the historical key.

Pack lifecycle writes cross the flat settings store through a narrow conditional-mutation seam. Its expected-state token covers the scoped and unscoped compatible scalars, every Pack ledger row, and Pack re-envelope progress for the profile. PostgreSQL takes a deterministic profile advisory transaction lock, bypasses the general settings cache for an in-transaction re-read/token comparison, and commits the retiring row, active row, scoped scalar, and re-envelope seed as one atomic batch. In-memory mode performs the same compare-and-batch operation under one gate. A concurrent mutation receives a typed conflict before any write; this prevents two active rows and last-writer scalar selection across replicas. An exact retry of the already-active key id and ordinal-equal PEM is idempotent (including commit-then-response-loss), while reuse of that key id with different material is rejected. A new key id whose normalized RSA SPKI fingerprint matches any Pack ledger member is also rejected, including an alternate PKCS#1/SPKI PEM encoding and archived/revoked history; invalidation cannot make the same cryptographic key eligible under a new label. A distinct key is also rejected while any root remains retiring, so the live trust set cannot grow through stacked rotations. Pack invalidate and progress ingestion use the same mutation boundary, and both generic store implementations reject lifecycle-owned Pack keys from ordinary set/batch/delete calls. Thus the admin route and internal fixed-key callers cannot bypass profile serialization. Under the locked unique snapshot, an update or delete resolves a case-insensitive logical match to the exact pre-existing physical key spelling; canonical spelling is created only when no row exists. This keeps PostgreSQL and in-memory behavior aligned without silently duplicating a mixed-case legacy row. Pack key ids are case-insensitively unique, while exact ordinal active-key/PEM retries retain their idempotent behavior.

Typed conditional-state races from introduce, invalidate, or Pack progress ingest are returned as HTTP 409 with stable code verification_root_state_conflict; validation errors remain 400. Invalidating a sole active Pack root clears its scalar and leaves publication fail-closed. A later consequences-gated introduce may establish one cryptographically distinct active root only when the ledger has zero active members, no retiring member, and no active scalar row; its normalized RSA SPKI fingerprint must not match any retained archived or revoked row. Those audit rows are preserved unchanged and never auto-promoted.

The verification-settings endpoint uses application-level authentication: a service pulling at boot presents the shared internal HMAC secret (Router:IdentityEnvelopeSigningKey) in X-Stella-Verification-Token, with authenticated platform.setup.read as the fallback. Validator-wave services consume the flat response via PlatformVerificationSettingsConfigurationSource (in StellaOps.Hosting.RuntimeConfiguration), added to builder.Configuration before their verification options bind — one HTTP GET at startup, fail-soft at the source (if Platform is unreachable it contributes nothing) followed by the service’s fail-loud startup guard. Pack publication bypasses the general settings cache and reads a direct coherent Pack snapshot on every request, so a fresh consumer cannot receive pre-revoke trust from a warm Platform replica. VexHub retains the general cached read. Key changes still require a consuming service restart because configuration is read once at boot.

Tenant directory (shared.tenants)

Platform owns the shared.tenants directory table (migration 000_shared_tenants_bootstrap.sql):

ColumnNotes
id UUIDCanonical tenant UUID (slug claims resolve to this via IPlatformTenantResolver).
tenant_id TEXT UNIQUETenant slug (e.g. default).
is_default BOOLEANOptional installation-default marker. A partial unique index allows at most one marked row; existing and clean installations may legitimately have none. Request tenant resolution does not use this marker.
default_region VARCHAR(16)NOT NULL DEFAULT 'unspecified', constrained to ^[a-z0-9][a-z0-9_-]{0,15}$. Platform-owned data-residency fallback consumed by EvidenceLocker when a request carries no explicit region (migration 078_SharedTenantDefaultRegion.sql).
status, name, display_name, settings, metadataLifecycle/state owned by Authority and propagated via ISharedTenantsPropagator; see ../authority/tenant-model.md.

IPlatformTenantResolver resolves request claims by an active, case-insensitive tenant_id slug (or accepts an already-canonical UUID); it does not infer the request tenant from is_default. The optional local/demo well-known actor seed follows the same invariant. Historical seed S078_SeedWellKnownActorIdentity.sql looked for is_default=true and can therefore be recorded as a successful no-op on older directories. Forward-only S079_RecoverWellKnownActorIdentity.sql recovers those histories by targeting the active canonical default slug without creating a tenant, changing is_default, overwriting a self-upserted row, or restoring erased PII. See the well-known actor seed recovery runbook.

Federation and NIS2 telemetry endpoints

Database migration module and host

StellaOps.Platform.Persistence declares Platform’s own migration module statically: PlatformMigrationModule (name Platform, migration bookkeeping schema platform, resource prefix StellaOps.Platform.Persistence.Migrations.Platform). It is consumed by platform-web’s AddStartupMigrations call and by the guided-setup wizard (PlatformSetupService / PlatformSetupMigrations), which admits only that module and fails closed on any other name. The MigrationModuleInfo / MigrationModuleSourceInfo record types and MigrationModuleConsolidation remain for those two consumers.

The central migration-plugin mechanism is deleted (SPRINT_20260722_021 PLT-4, DC-26, 2026-09-14). IMigrationModulePlugin.cs, MigrationModulePluginDiscovery.cs (assembly scan, plugins/migrations directory and STELLAOPS_MIGRATION_PLUGIN* env-var probing), MigrationModulePlugins.cs, ReleaseMigrationRunner.cs, ServiceCollectionExtensions.cs and the static MigrationModuleRegistry (GetModules / FindModule) no longer exist, and the CLI no longer references StellaOps.Platform.Persistence. Nothing discovers modules any more, so nothing can converge a schema it does not own. Each deployable service owns and auto-migrates its own database from its own embedded Startup-band SQL; one service = one database + own role (ADR-039). The per-plugin retirement notes below are the dated history of how the registry shrank to zero.

The registry was no longer a Platform runtime surface before that. SPRINT_20260722_026 CM-2 retired every central face: /api/v1/admin/migrations/* and /api/v1/admin/seed-demo were deleted along with their gateway route (2026-08-02), PlatformMigrationAdminService was folded into the assembly-internal Services/PlatformSetupMigrations.cs used only by guided setup and guarded so it can apply only Platform’s own module (2026-08-04), and stella admin seed-demo — a third face that ran startup migrations across every registered module — was deleted (2026-08-18). Platform therefore offers no way to migrate, verify, or seed another service’s database. Three conformance pins in DatabaseOwnershipConformanceTests keep it that way: CentralMigrationAdminApi_StaysRetired, CentralMigrationAdminService_StaysFolded, and CentralMigrator_HasExactlyOneSurvivingCliFace.

What replaced it:

The remaining in-process path:

Authorization scopes

Platform maps OAuth scopes to named authorization policies in Program.cs via PlatformPolicies → PlatformScopes (Constants/). ops.admin is a global escape hatch for the crypto/KEK policies. Selected mappings:

PolicyRequired scope(s)Surface
HealthRead / HealthAdminops.health / ops.admin/api/v1/platform/health/*
QuotaRead / QuotaAdminquota.read|orch:quota / quota.admin|orch:quotaquotas + legacy quota compatibility
OnboardingRead / OnboardingWriteonboarding.read / onboarding.write/onboarding/*
PreferencesRead / PreferencesWriteui.preferences.read / ui.preferences.write/preferences/*, dashboard profiles
ContextRead / ContextWriteplatform.context.read / platform.context.write/api/v2/context/*
SearchRead / MetadataReadsearch.read / platform.metadata.readglobal search, metadata
AnalyticsReadanalytics.read/api/analytics/*, NIS2 telemetry
SetupRead / SetupWrite / SetupAdminplatform.setup.read / .write / .adminsetup wizard, env-settings DB layer, migration admin, seed
FederationRead / FederationManageplatform:federation:read / platform:federation:writePlatform federated telemetry only; RO applies the same canonical scopes to its separately hosted regional-federation controller
SubjectAccessRead / SubjectAccessEraseplatform:sar:read / platform:sar:eraseGDPR subject-access
ActorIdentityRead (any-of)ui.read or platform:sar:readconsole actor-identity badge resolver (GET /api/v1/platform/actor-identity/{ref}) — any console user resolves the redaction-aware projection (i5 #17)
EnvironmentStateRead (any-of)envstate:read or airgap:status:readread any environment-state document (GET /api/v1/platform/environment-state/{class}/{scope})
EnvironmentStateAirgapSealWrite (any-of)envstate:airgap-seal:write or airgap:sealdeclare the airgap-seal state document; the legacy grant is aliased so existing sealing authority survives the re-homing
EnvironmentStateTimeAnchorWriteenvstate:time-anchor:writedeclare the time-anchor state document (separate authority: declaring trusted time is not sealing)
CryptoProviderRead / CryptoProviderAdmin / CryptoProfileAdmincrypto:read / crypto:admin / crypto:profile:admin (or ops.admin)crypto provider + compliance profile admin
OperatorSigningEnrollmentReadauthority:signing-keys.enroll (or crypto:read / ops.admin)narrow tenant compliance-profile projection for operator public-key enrollment
CryptoKekRead / CryptoKekRotatecrypto:kek:read / crypto:kek:rotate (or ops.admin)KEK control plane
TrustRead/Write/Admin, Script*, ReleaseControl*see PlatformScopes.cstrust signing, scripts, release-control bundles

(The frontend OIDC scope superset requested by the SPA lives in PlatformEnvironmentSettingsOptions.Scope.)

Operator signing provider-change composition

PUT /api/v1/admin/crypto-providers/compliance-profile/ and PUT /api/v1/admin/crypto-providers/preferences compare the resolved decision-signing provider before and after the durable Platform write. An unchanged provider creates no new transition, but the request still claims and resumes any existing transition for that tenant. A changed provider is composed through the owning services: Authority resolves enabled users whose effective permissions include exception approval, IssuerDirectory retires only incompatible active DecisionSigning keys, and Notifier accepts platform.crypto-provider-changed only when keys were actually retired. The event targets only retired-key subjects and deep-links to /administration/profile; its event id, payload ordering, and idempotency key are deterministic for replay. Platform:OperatorProviderChange:IssuerId selects the operator key namespace and defaults to operator-signing.

OSK-P5R makes the composition restart-safe. PostgreSQL migration 088 owns platform.operator_provider_change_outbox; the profile/preference mutation and transition intent commit in one transaction. The row freezes tenant, previous/current provider, ordered algorithms, original actor, stable operation id, stage, attempt/backoff, Authority recipients, the IssuerDirectory receipt, and the exact Notify idempotency key/body. It never stores a bearer token or Router envelope. The first Notify send uses the body returned by the outbox checkpoint write (not the pre-write serialization), so PostgreSQL jsonb normalization and replay use identical bytes. Completion is recorded only after Notify accepts. Development/testing uses the same mutation/outbox contract in memory; an async mutation gate makes the setting write and transition enqueue one atomic critical section without blocking an async call under a monitor lock.

The mutation request tries reconciliation immediately and a same-value retry ignores scheduled backoff to resume the existing operation. A production hosted worker, registered after Platform startup migrations, claims due rows across tenants after restart. Ambient authenticated bearer/verified Router-envelope auth is used on the request path; the worker mints a fresh two-minute, tenant-bound service identity envelope from the configured Router:IdentityEnvelopeSigningKey for each downstream request. The persisted original actor remains audit/event metadata while the reconciler service identity is the executing actor. IssuerDirectory keys requests by the stable operation id and replays the original immutable receipt after a lost response. There is no destructive dead-letter transition: a failure releases the lease and keeps the row pending with attempt_count, bounded exponential next_attempt_at (maximum five minutes), and truncated last_error for operator observability; recovery always resumes the same operation. Platform:OperatorProviderChange:ReconciliationIntervalSeconds controls the idle poll interval (default 5, allowed 1-300 seconds).

The IssuerDirectory grace cutoff preserves lifecycle/verification context; Platform does not interpret it as Policy authorization for new exceptions. That authorization rule remains open in OSK-P5.

Scope-catalog gap closed (verified 2026-07-19). Authority migration S041_platform_console_scope_catalog.sql, the canonical StellaOpsScopes catalog, and seed-parity tests now cover platform.setup.read/write/admin, platform.metadata.read, onboarding.read/write, search.read, and ops.admin. These Platform policies no longer depend on bypass-network scope satisfaction.

Endpoint surface (route prefixes)

Mapped in Program.cs. Highlights beyond the core /api/v1/platform group:

PrefixEndpoint classNotes
/platform/envsettings.json, /platform/envsettings/dbEnvironmentSettingsEndpoints, EnvironmentSettingsAdminEndpointsFrontend config (anonymous) + DB-layer admin
/platform/verification-settings/{service}VerificationSettingsEndpointsRegion-scoped signature-verification keys for a backend service’s startup config source (shared-token authenticated)
/api/v1/platform/*PlatformEndpointshealth, quotas, onboarding, preferences, search, metadata
/api/v1/search, /api/v1/platform/searchPlatformEndpointsglobal search (legacy search path sends Deprecation/Sunset headers)
/api/v2/context, /api/v2/releases, /api/v2/topology, /api/v2/integrations, /api/v2/evidenceContext + read-model endpointsPlatform-owned aggregation-only read-model projections
/api/v1/telemetry/federation, /api/telemetry/nis2FederationTelemetryEndpoints, Nis2TelemetryEndpointsfederation + NIS2 telemetry
/api/v1/admin/migrations(deleted)RETIRED 2026-08-02 (SPRINT_20260722_026 CM-2) — status is each service’s own db.migration-status doctor check; convergence is startup migrations
/api/v1/admin/crypto/kek, /api/v1/admin/crypto-providers, /api/v1/admin/crypto/profilecrypto admin endpointsKEK control plane, provider catalog, profile validate
/api/v1/platform/connector-credentials, /api/v1/platform/connectorsConnectorCredentialsEndpoints, ConnectorsCatalogEndpointsCredential-at-rest store (see Connector credential store below); connector:credentials:read/:write
MapVerificationKeyLifecycleEndpoints, MapPackAdapterEndpoints, MapActorIdentityEndpointsverification-key lifecycle, pack adapters, actor identitymapped in Program.cs; see source for exact prefixes
/api/v1/setupSetupEndpointsfirst-run setup wizard; the central demo-seed face is retired
/api/v1/platform/localizationLocalizationEndpointstenant-scoped localization
/api/v1/release-control/bundlesrelease-control endpointsPlatform-owned read-model projection
/api/v1/administration/trust-signing, /api/v1/stella-assistantmisctrust signing, assistant
/healthz, /readyz, /health, /buildinfo.jsoninlineanonymous liveness/readiness + image-staleness self-check

Relocated stable paths keep their public URLs but are no longer Platform surfaces: RO owns /api/v1/release-orchestrator/environments*, /api/v2/scripts*, and /api/v1/federation/*; /api/v1/score/* is owned by Signals, /api/v1/function-maps/* by Scanner, /api/v1/policy/interop/* by Policy Engine, and /api/v2/security/* plus /api/risk/aggregated-status by Findings.Security. Router configuration is the external ownership source of truth.

PostgreSQL-backed topology reads retain a process-local last-known snapshot and its synchronization watermarks for brief connection-exhaustion recovery. The cache is isolated by tenant, bounded to 2,048 tenant entries, and expires each value after five minutes. It is populated only by a successful inventory read or upsert, preserves an authoritative null snapshot sentinel, and is consulted only when PostgreSQL reports SQLSTATE 53300 (too_many_connections). Missing or expired last-known values and every other storage failure continue to propagate, so the recovery cannot hide schema, authorization, cancellation, or unknown infrastructure failures.

Identity provider ownership boundary

Identity-provider configuration is Authority-owned and process-global. The canonical operator surface is /console/admin/identity-providers, protected by authority:idp.read / authority:idp.write. Platform no longer maps /api/v1/platform/identity-providers, registers its former read/write policy, proxies Authority status, performs LDAP/OIDC/SAML probes, or carries an identity-provider persistence service. The console and CLI must call Authority directly so there is one mutation and runtime-status contract.

Migration 087_v1_platform_identity_provider_configs.sql is intentionally retained byte-for-byte as forward-only history (normalized SHA-256 2a76024dca7c92436b2b6498673059c3e13360957ea1e51191b87685f8251bf2) and as the input to Authority’s one-time, cross-database global-provider importer. Its retirement comment is applied by the additive 089_v1_platform_identity_provider_configs_retirement_comment.sql migration; an already-applied migration is never edited to describe later lifecycle state. Platform has no EF entity, DbSet, store, or endpoint that can write platform.identity_provider_configs; therefore the table is empty on a fresh installation and vestigial after an upgrade/import. Nothing reads it any more: SPRINT_20260722_016 AUTH-4 (X7) deleted Authority’s cross-database importer and the Authority:IdentityProviderImport:* settings, so the table is now frozen and unread on both sides. Physical removal is still deferred until a PostgreSQL snapshot is operator-approved under ADR-004; that drop is its own destructive window.

Connector credential store (ConnectorCredentials/)

The Console backend hosts a credential-at-rest subsystem under StellaOps.Platform.WebService/ConnectorCredentials/, mapped by MapConnectorCredentialsEndpoints() / MapConnectorsCatalogEndpoints() (Program.cs). It stores integration credentials (SCM/CI/registry/secrets connectors) encrypted at rest and is the reason the crypto-provider + KEK control plane exist on Platform. Key components:

Authorization: every route checks connector:credentials:read or connector:credentials:write in-handler (ConnectorCredentialsEndpoints.cs); both scopes are in the canonical catalog. This is a security-relevant surface — treat credential read/rotate as privileged and audited.

Shared Storage Driver Contract (Sprint 312)

This contract is the default for all stateful StellaOps webservices unless a module ADR explicitly overrides it.

Fail-fast policy:

Current implementation status (2026-03-05):

Platform Runtime Read-Model Boundary Policy (Point 4 / Sprint 20260305-005)

Platform runtime read-model APIs are aggregation-only and must stay behind explicit query contracts. Runtime read handlers must not take direct dependencies on foreign module persistence internals.

Approved runtime query contracts:

Prohibited in runtime read-model services:

Non-runtime migration allowlist (explicit boundary exception):

Enforcement:

Runtime Dependency Inventory (2026-03-05)

ComponentDependency categoryClassificationNotes
ReleaseReadModelServiceIReleaseControlBundleStoreAllowed runtime read-model dependencyRelease projection reads only via Platform-owned bundle-store contract.
TopologyReadModelServiceIReleaseControlBundleStore, IPlatformContextQueryAllowed runtime read-model dependencyTopology projection composes release bundles with context inventory through explicit query contracts.
SecurityReadModelServiceIReleaseControlBundleStore, IPlatformContextQueryAllowed runtime read-model dependencySecurity projection remains synthetic/read-only and does not call VEX/exception write stores directly.
IntegrationsReadModelServiceIReleaseControlBundleStore, IPlatformContextQueryAllowed runtime read-model dependencyIntegration freshness projection uses release run metadata and context inventory only.
PlatformContextServiceIPlatformContextStore (InMemory/Postgres)Allowed runtime dependency (module-local persistence)Exposes read-only IPlatformContextQuery plus preference write APIs; no foreign module coupling.
MigrationModulePluginsForeign module migration assembliesDeleted 2026-09-14 (PLT-4, DC-26)The former non-runtime exception for the CLI recovery path no longer exists; PlatformMigrationModule declares only Platform’s own module.

Advisory Commitments (2026-02-26 Batch)