Export Center Gateway Contract (draft v0.9)
This contract describes how the Stella Ops Web gateway proxies the Export Center service: tenant-scoped exports, deterministic responses, sealed-mode readiness, and offline-friendly signed-URL handling. It is the integration surface for Console and SDK clients that create export profiles, start runs, and download or verify the resulting artifacts.
Status: reconciled against implementation (2026-05-30). The Export Center service (
src/ExportCenter) exposes a typed REST surface under/v1/exports/*(ExportApiEndpoints.MapExportApiEndpoints). The Web gateway proxies it under the public prefix/api/export-center(Angularapp.config.ts,resolveApiBaseUrl(gatewayBase, '/api/export-center')). Identifiers are GUIDs, not theexport-run::tenant::…composite strings used in earlier drafts. Scopes have been corrected to the canonical catalog (StellaOps.Auth.Abstractions/StellaOpsScopes.cs): theexport:read/export:writescopes in earlier drafts do not exist. Sections still marked (forward design — not implemented) describe a planned distribution/OCI surface that the service does not yet expose as HTTP endpoints.
Security / headers
Authorization: Bearer <jwt>(resource-server audienceapi://export-center). DPoP proof is verified by the gateway only when aDPoPheader is present (see tenant-auth); it is not required by the Export Center service itself.X-StellaOps-TenantId: <tenantId>(tenant context; required — see tenant-auth). The service resolves the tenant from thetenant_id/tidJWT claim and rejects requests without it.X-StellaOps-Project: <projectId>(optional).Idempotency-Key: <uuid>(the Web client forwards it for run starts; the service does not currently enforce idempotency keys).Accept: application/json(ortext/event-streamfor SSE).Scopes (canonical, enforced via
StellaOpsResourceServerPolicies):export.viewer— read endpoints (list/get profiles, runs, artifacts, downloads, verify/manifest/attestation, SSE).export.operator— create/update profiles, start runs, cancel runs.export.admin— archive (soft-delete) profiles.
The whole
/v1/exportsgroup also requiresexport.vieweras a baseline, with the strongerexport.operator/export.adminpolicies applied per route. There is no singleexport:read/export:writescope.
Endpoints
All paths are relative to the service base (/v1/exports), proxied at the gateway under /api/export-center. Route IDs ({profileId}, {runId}, {artifactId}) are GUIDs.
Profiles:
GET /v1/exports/profiles— list export profiles (tenant-scoped; query:status,kind,search,offset,limit;limitclamped 1–100, default 50). Scope:export.viewer.GET /v1/exports/profiles/{profileId}— get a profile. Scope:export.viewer.POST /v1/exports/profiles— create a profile. Scope:export.operator.PUT /v1/exports/profiles/{profileId}— update a profile. Scope:export.operator.DELETE /v1/exports/profiles/{profileId}— archive (soft delete) a profile. Scope:export.admin.POST /v1/exports/profiles/{profileId}/runs— start an export run from a profile. Scope:export.operator.
Runs:
GET /v1/exports/runs— list runs (query:profileId,status,trigger,createdAfter,createdBefore,correlationId,offset,limit). Scope:export.viewer.GET /v1/exports/runs/{runId}— run status + artifact summaries. Scope:export.viewer.POST /v1/exports/runs/{runId}/cancel— cancel a queued/running run. Scope:export.operator.GET /v1/exports/runs/{runId}/events— SSE progress stream. Scope:export.viewer.
Artifacts:
GET /v1/exports/runs/{runId}/artifacts— list artifacts produced by a run. Scope:export.viewer.GET /v1/exports/runs/{runId}/artifacts/{artifactId}— artifact metadata. Scope:export.viewer.GET /v1/exports/runs/{runId}/artifacts/{artifactId}/download— stream the artifact file. Scope:export.viewer.
Verification:
POST /v1/exports/runs/{runId}/verify— verify manifest, signatures, and content hashes. Scope:export.viewer.GET /v1/exports/runs/{runId}/verify/manifest— get the run manifest + digest. Scope:export.viewer.GET /v1/exports/runs/{runId}/verify/attestation— attestation status (DSSE presence + manifest digest). Scope:export.viewer.POST /v1/exports/runs/{runId}/verify/stream— SSE verification progress. Scope:export.viewer.
Specialised export families (mapped alongside the core API in Program.cs): /v1/exports/assurance/*, /v1/exports/nis2/soa/*, plus attestation, promotion-attestation, incident, risk-bundle, simulation, audit-bundle, exception-report, and lineage endpoints. These are out of scope for this contract; see the Export Center module dossier.
Deprecated (return 410 Gone): GET /exports, POST /exports, DELETE /exports/{id} — use the /v1/exports/* routes above.
(forward design — not implemented)
GET /export-center/distributions/{id}for signed OCI/object-storage distribution URLs is not an HTTP endpoint in the current service. Distribution is handled internally (Distribution/IExportDistributionLifecycle,Distribution/Oci/*) and artifacts are retrieved via theruns/{runId}/artifacts/{artifactId}/downloadendpoint.
POST /v1/exports/profiles/{profileId}/runs (request)
Body shape: StartExportRunRequest (Api/ExportApiModels.cs). Run inputs (targets, formats, signing, retention, encryption, distribution) are defined on the profile, not the run-start request; the run may override scope/format only.
{
"scopeOverride": {
"targetKinds": ["sbom", "vex", "attestation"],
"namespaces": ["tenant-default"],
"maxItems": 1000
},
"formatOverride": {
"format": "Ndjson",
"compression": "None",
"sortKeys": true,
"normalizeTimestamps": true
},
"correlationId": "01HXYZ...",
"dryRun": false
}
All fields are optional. format values: JsonRaw, JsonPolicy, Ndjson, Csv, Mirror, TrivyDb, TrivyJavaDb (and further mirror/standards formats). A 429 is returned when tenant/profile concurrency limits are exceeded and queueing is disabled.
202 Accepted
Body: ExportRunResponse. The Location header is /v1/exports/runs/{runId}.
{
"runId": "8f3c1d2e-4b5a-6c7d-8e9f-0a1b2c3d4e5f",
"profileId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"tenantId": "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f",
"status": "Queued",
"trigger": "Api",
"correlationId": "01HXYZ...",
"initiatedBy": "user-123",
"progress": {
"totalItems": 0,
"processedItems": 0,
"failedItems": 0,
"totalSizeBytes": 0,
"percentComplete": 0
},
"createdAt": "2026-05-30T10:00:00Z",
"startedAt": null,
"expiresAt": "2026-06-06T10:00:00Z"
}
status is an enum string: Queued, Running, Completed, PartiallyCompleted, Failed, Cancelled. trigger is Manual, Scheduled, Event, or Api (API-initiated runs are Api). Runs are created with a default 7-day expiresAt.
GET /v1/exports/runs/{runId}
Body: ExportRunResponse with artifacts (an array of ExportArtifactSummary).
{
"runId": "8f3c1d2e-4b5a-6c7d-8e9f-0a1b2c3d4e5f",
"profileId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"tenantId": "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f",
"status": "Running",
"trigger": "Api",
"correlationId": "01HXYZ...",
"initiatedBy": "user-123",
"progress": {
"totalItems": 200,
"processedItems": 70,
"failedItems": 0,
"totalSizeBytes": 1048576,
"percentComplete": 35.0
},
"error": null,
"createdAt": "2026-05-30T10:00:00Z",
"startedAt": "2026-05-30T10:00:01Z",
"completedAt": null,
"expiresAt": "2026-06-06T10:00:00Z",
"artifacts": [
{
"artifactId": "c0ffee00-1111-2222-3333-444455556666",
"name": "manifest.json",
"kind": "manifest",
"sizeBytes": 4096,
"contentType": "application/json",
"checksum": "sha256:c0ffee...",
"downloadUrl": "/v1/exports/runs/8f3c1d2e-4b5a-6c7d-8e9f-0a1b2c3d4e5f/artifacts/c0ffee00-1111-2222-3333-444455556666/download"
}
]
}
Full artifact metadata (path, checksumAlgorithm default SHA-256, metadata, expiresAt) is available from GET …/artifacts/{artifactId} (ExportArtifactResponse).
SSE events (GET /v1/exports/runs/{runId}/events)
Each event is emitted as event: <type>\ndata: <ExportRunSseEvent JSON>\n\n. The envelope is { eventType, runId, timestamp, data }; data is an ExportRunResponse for lifecycle events and an ExportRunProgress for progress events. The stream polls run state every ~2s while Queued/Running. Event types (ExportRunSseEventTypes):
connected— initial state on connect;data= current run.run.progress—data={ totalItems, processedItems, failedItems, totalSizeBytes }.run.started— status changed toRunning.run.completed— status changed toCompleted/PartiallyCompleted.run.failed— status changed toFailed.run.cancelled— status changed toCancelled.disconnected— final state when the stream closes.
Additional event-type constants run.phase.started, run.phase.completed, and run.artifact.created are defined but not yet emitted by the API streamer.
Determinism & limits
- Concurrency (
ExportConcurrencyOptions, configurable):MaxConcurrentRunsPerTenant= 4,MaxConcurrentRunsPerProfile= 2,QueueExcessRuns= true,MaxQueueSizePerTenant= 10. Exceeding limits with queueing disabled returns429. - Pagination:
limitdefaults to 50 and is clamped to 1–100 for both profiles and runs. - Run artifacts expire 7 days after creation by default (
expiresAt). - Format options support deterministic output:
sortKeysandnormalizeTimestampsdefault totrue;prettyPrintdefaults tofalse. - Checksums use SHA-256 (
ExportArtifactResponse.checksumAlgorithmdefaultSHA-256); timestamps are ISO-8601 UTC (DateTimeOffset).
(forward design — not implemented) The fixed request-body, target-count, bundle-size, and SSE idle-timeout caps in earlier drafts (256 KiB body, 50 targets, 500 MiB bundle, 60-minute job timeout, 60s SSE idle,
1s,2s,4s,8sclient backoff) are not enforced as documented constants in the current service. Concurrency and pagination are the enforced limits above.
Error handling
The service maps failures to RFC 7807 problem responses (UseExportCenterProblemExceptionMapping). Observed responses:
400 Bad Request— tenant not found in claims; profile not active; profile archived; invalid run state for cancel.404 Not Found— unknown profile, run, or artifact (or artifact file missing on disk).409 Conflict— profile name conflict within the tenant.429 Too Many Requests— tenant/profile concurrency or queue limit exceeded.410 Gone— deprecated/exportslegacy endpoints.
(forward design — not implemented) The
ERR_EXPORT_*codes in earlier drafts (ERR_EXPORT_PROFILE_NOT_FOUND,ERR_EXPORT_REQUEST_INVALID,ERR_EXPORT_TOO_LARGE,ERR_EXPORT_RATE_LIMIT,ERR_EXPORT_DISTRIBUTION_FAILED,ERR_EXPORT_EXPIRED) are not emitted by the current service; errors are returned as ProblemDetails with localized messages (resource keys such asexportcenter.error.profile_name_conflict).
Persistence (reference)
PostgreSQL schema export_center (Infrastructure/Db/Migrations/001_initial_schema.sql), tenant-isolated via row-level security keyed on app.current_tenant:
export_center.export_profiles(profile_id,tenant_id,name,kind,status,scope_json,format_json,signing_json,schedule, …; unique(tenant_id, name)while not archived).export_center.export_runs(run_id,profile_id,tenant_id,status,trigger,correlation_id, item/byte counters,error_json, timestamps, FK toexport_profiles).- Artifact rows + supporting tables in the same migration.
A local-only EXPORTCENTER_ALLOW_INMEMORY_REPOSITORIES=true switch boots in-memory repositories for first-run/dev; it is rejected in Production by the runtime posture validator.
Telemetry
Meter StellaOps.ExportCenter (ExportTelemetry). Selected instruments relevant to this surface:
export_runs_total,export_runs_success_total,export_runs_failed_total,export_runs_in_progress(UpDownCounter).export_artifacts_total,export_bytes_total,export_artifact_downloads_total.export_sse_connections_total,export_concurrency_limit_exceeded_total.- Histograms
export_run_duration_seconds,export_plan_duration_seconds,export_bundle_size_bytes.
Common tags: tenant / tenant_id, profile / profile_id, type, artifact_kind.
Samples
- Run request/response and SSE shapes: see the blocks above (grounded in
Api/ExportApiModels.cs). - Console manifest sample:
../console/samples/console-export-manifest.json(Console export surface; field names differ from the Export Center DTOs above).
Outstanding for sign-off
- Whether to expose the distribution/OCI surface as a public HTTP endpoint (
GET …/distributions/{id}) or keep it internal to the run/artifact lifecycle. - Whether DSSE attestation is mandatory for sealed tenants (currently optional;
verify/attestationreports DSSE presence). - Confirm gateway-side request/bundle-size caps and SSE idle-timeout policy (not enforced in the service today).
