Export Center API Contract

Contract ID: CONTRACT-EXPORT-BUNDLE-009 Version: 1.0 Status: Published Last Updated: 2026-05-30

Overview

This contract is the integration reference for the Stella Ops Export Center API. It is written for engineers building clients against Export Center and for operators configuring exports. It defines export profiles (scope and configuration), export runs (executions), produced artifacts, and run verification (manifest, signature, and content-hash checks). It also covers the supported output formats, persistence schema, and telemetry.

Model note. The implemented API is organized around profiles → runs → artifacts, not around standalone “jobs” and “bundles”. A profile holds the reusable scope/format/signing/schedule configuration; a run is a single execution of a profile; artifacts are the files a run produces. There is no separate /jobs resource — scheduling is a property of a profile (kind = Scheduled plus a cron schedule).

Implementation References

Authorization Scopes

All Export Center endpoints require one of the following scopes (StellaOpsScopes):

ScopeConstantGranted operations
export.viewerStellaOpsScopes.ExportViewerRead profiles, runs, artifacts; download artifacts; verify; read manifest/attestation; SSE streams
export.operatorStellaOpsScopes.ExportOperatorCreate/update profiles; start runs; cancel runs
export.adminStellaOpsScopes.ExportAdminArchive (soft-delete) profiles; retention/encryption/scheduling policy

The endpoint group is registered with RequireAuthorization(ExportViewer); write operations additionally require ExportOperator, and archive requires ExportAdmin (see ExportApiEndpoints.MapExportApiEndpoints).

Tenant is resolved from the tenant_id (or tid) claim on the caller’s principal; requests without a resolvable tenant claim return 400 Bad Request.

Data Models

ExportProfile

Reusable definition of an export’s scope, format, signing, and (optional) schedule. Source: Core/Domain/ExportProfile.cs, response DTO ExportProfileResponse in Api/ExportApiModels.cs.

{
  "profileId": "8d2c0b6e-0000-0000-0000-000000000001",
  "tenantId": "0a1b2c3d-0000-0000-0000-000000000001",
  "name": "daily-vex-export",
  "description": "Daily VEX advisory export",
  "kind": "Scheduled",
  "status": "Active",
  "scope": {
    "targetKinds": ["vex"],
    "tags": ["critical", "high"],
    "namespaces": ["github", "redhat"],
    "maxItems": 5000
  },
  "format": {
    "format": "Ndjson",
    "compression": "Gzip",
    "includeMetadata": true,
    "prettyPrint": false,
    "normalizeTimestamps": true,
    "sortKeys": true
  },
  "signing": {
    "enabled": true,
    "algorithm": "ES256",
    "keyId": "signing-key-001",
    "includeProvenance": true
  },
  "schedule": "0 0 * * *",
  "createdAt": "2026-05-30T10:00:00Z",
  "updatedAt": "2026-05-30T10:00:00Z",
  "archivedAt": null
}

Field notes:

ExportProfileKind enum

Core/Domain/ExportProfile.cs. Serialized by name.

ValueNumericDescription
AdHoc1Manually triggered, one-off (default on create)
Scheduled2Runs on a cron schedule
EventDriven3Triggered by webhooks/events
Continuous4Near-real-time mirror updates

ExportProfileStatus enum

ValueNumericDescription
Draft1Being set up (default on create)
Active2Can start runs
Paused3Will not run scheduled exports
Archived4Read-only (soft-deleted)

ExportRun

A single execution of a profile. Source: Core/Domain/ExportRun.cs, response DTO ExportRunResponse.

{
  "runId": "1f7e9a40-0000-0000-0000-000000000001",
  "profileId": "8d2c0b6e-0000-0000-0000-000000000001",
  "tenantId": "0a1b2c3d-0000-0000-0000-000000000001",
  "status": "Running",
  "trigger": "Api",
  "correlationId": "corr-001",
  "initiatedBy": "user-123",
  "progress": {
    "totalItems": 150,
    "processedItems": 42,
    "failedItems": 0,
    "totalSizeBytes": 1048576,
    "percentComplete": 28.0
  },
  "error": null,
  "createdAt": "2026-05-30T00:00:00Z",
  "startedAt": "2026-05-30T00:00:00Z",
  "completedAt": null,
  "expiresAt": "2026-06-06T00:00:00Z",
  "artifacts": [
    {
      "artifactId": "2a3b4c5d-0000-0000-0000-000000000001",
      "name": "vex-export.ndjson.gz",
      "kind": "vex",
      "sizeBytes": 1048576,
      "contentType": "application/x-ndjson",
      "checksum": "sha256:abc123...",
      "downloadUrl": "/v1/exports/runs/1f7e9a40-.../artifacts/2a3b4c5d-.../download"
    }
  ]
}

Field notes:

ExportRunStatus enum

Core/Domain/ExportRun.cs. DB column status smallint CHECK (status BETWEEN 1 AND 6).

ValueNumericDescription
Queued1Waiting to start (concurrency-limited)
Running2Actively processing
Completed3Finished successfully
PartiallyCompleted4Completed with some item failures
Failed5Failed
Cancelled6Cancelled

ExportRunTrigger enum

DB column trigger smallint CHECK (trigger BETWEEN 1 AND 4).

ValueNumericDescription
Manual1Manually triggered by a user
Scheduled2Triggered by a cron schedule
Event3Triggered by an external event
Api4Triggered via API (StartRunFromProfile uses this)

ExportArtifact

A file produced by a run. Source: Core/Persistence/IExportRunRepository.cs (ExportArtifact record), response DTO ExportArtifactResponse.

{
  "artifactId": "2a3b4c5d-0000-0000-0000-000000000001",
  "runId": "1f7e9a40-0000-0000-0000-000000000001",
  "tenantId": "0a1b2c3d-0000-0000-0000-000000000001",
  "name": "vex-export.ndjson.gz",
  "kind": "vex",
  "path": "/var/lib/exports/.../vex-export.ndjson.gz",
  "sizeBytes": 1048576,
  "contentType": "application/x-ndjson",
  "checksum": "sha256:abc123...",
  "checksumAlgorithm": "SHA-256",
  "metadata": { "format": "ndjson" },
  "createdAt": "2026-05-30T00:00:05Z",
  "expiresAt": "2026-06-06T00:00:05Z"
}

kind is a free-form string (e.g. sbom, vex, attestation, policy, evidence, manifest); checksumAlgorithm defaults to SHA-256.

Export Formats

ExportFormatOptions.Format (Core/Planner/ExportPlanModels.cs). Serialized by name.

FormatNumericDescription
JsonRaw1Raw JSON, one object per file (default)
JsonPolicy2JSON with policy metadata included
Ndjson3Newline-delimited JSON (streaming)
Csv4CSV
Mirror5Full mirror layout with indexes
TrivyDb6Trivy vulnerability DB format (schema v2)
TrivyJavaDb7Trivy Java DB format (Maven/Gradle/SBT supplement)

Not implemented as ExportFormat values. OpenVEX, CSAF, CycloneDX, and SPDX are content schemas produced by their owning modules (Excititor / Scanner), not Export Center output-format options. Export Center carries those payloads as items within JsonRaw/Ndjson/Mirror outputs.

ExportFormatOptions

FieldTypeDefaultDescription
formatExportFormatJsonRawOutput format
compressionCompressionFormatNoneNone (0), Gzip (1), Zstd (2), Brotli (3)
includeMetadatabooltrueInclude item metadata
prettyPrintboolfalsePretty-print JSON
redactFieldsstring[][]Field paths to redact
normalizeTimestampsbooltrueNormalize timestamps for determinism
sortKeysbooltrueSort object keys for determinism

ExportScope

Core/Planner/ExportScopeModels.cs. Stored as scope_json.

FieldTypeDescription
targetKindsstring[]Kind filter (e.g. sbom, vex, attestation)
sourceRefsstring[]Specific source references to include
tagsstring[]Items must have all specified tags
namespacesstring[]Namespace/project filter
dateRangeobject{ from, to, field }; fieldCreatedAt/ModifiedAt/ProcessedAt
maxItemsint?Maximum number of items
samplingobject{ strategy, size, seed, stratifyBy }; strategy ∈ None/Random/First/Last/Stratified/Systematic
runIdsGUID[]Include items from specific runs
excludePatternsstring[]Exclude items matching patterns

Schedule Format

Cron expressions stored in ExportProfile.schedule (used by Scheduled profiles). Format:

┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sunday=0)
│ │ │ │ │
* * * * *

Examples:

ScheduleDescription
0 0 * * *Daily at midnight
0 */6 * * *Every 6 hours
0 0 * * 0Weekly on Sunday
0 0 1 * *Monthly on the 1st

Distribution Targets

Distribution targets are part of the planner/run pipeline, identified by ExportDistributionKind (Core/Domain/ExportDistribution.cs). They are specified on the export plan (ExportDistributionTargetSpec), not on the profile create/update DTOs above.

KindNumericDescription
FileSystem1Local file system
AmazonS32Amazon S3
Mirror3Mirror server
OfflineKit4Air-gap offline kit
Webhook5Webhook notification (metadata only)
OciRegistry6OCI registry (artifact push)

The DB export_distributions.kind check constraint is BETWEEN 1 AND 5; the OciRegistry (6) kind is defined in the domain enum and is exercised via the OCI distribution publishers (Distribution/Oci/).

API Endpoints

Base group: /v1/exports (registered via app.MapExportApiEndpoints(); no api path-base prefix on the service itself). Legacy /exports, POST /exports, DELETE /exports/{id} endpoints return 410 Gone and point callers at /v1/exports/*.

Profile Management

List profiles

GET /v1/exports/profiles?status=Active&kind=Scheduled&search=vex&offset=0&limit=50
Scope: export.viewer

Response: 200 OK
{
  "items": [ { ...ExportProfileResponse } ],
  "totalCount": 5,
  "offset": 0,
  "limit": 50
}

limit defaults to 50 and is clamped to 1..100; offset is clamped to >= 0.

Get profile

GET /v1/exports/profiles/{profileId}        (profileId: GUID)
Scope: export.viewer

Response: 200 OK | 404 Not Found

Create profile

POST /v1/exports/profiles
Scope: export.operator
Content-Type: application/json

{
  "name": "daily-vex-export",
  "description": "Daily VEX advisory export",
  "kind": "Scheduled",
  "scope": { ...ExportScope },
  "format": { ...ExportFormatOptions },
  "signing": { ...ExportSigningOptions },
  "schedule": "0 0 * * *"
}

Response: 201 Created  (Location: /v1/exports/profiles/{profileId})
         | 409 Conflict  (profile name not unique within tenant)
         | 400 Bad Request

Created profiles start in status = Draft.

Update profile

PUT /v1/exports/profiles/{profileId}
Scope: export.operator

{ "name?": "...", "description?": "...", "status?": "Active",
  "scope?": {...}, "format?": {...}, "signing?": {...}, "schedule?": "..." }

Response: 200 OK | 404 Not Found | 409 Conflict | 400 Bad Request

Updating an Archived profile returns 400. Setting status to Active / Paused emits the corresponding audit operation.

Archive profile

DELETE /v1/exports/profiles/{profileId}
Scope: export.admin

Response: 204 No Content | 404 Not Found

This is a soft delete (sets archivedAt / status = Archived).

Start run from profile

POST /v1/exports/profiles/{profileId}/runs
Scope: export.operator

{ "scopeOverride?": {...}, "formatOverride?": {...},
  "correlationId?": "corr-001", "dryRun?": false }

Response: 202 Accepted  (Location: /v1/exports/runs/{runId})
         | 404 Not Found  (profile missing)
         | 400 Bad Request (profile not Active)
         | 429 Too Many Requests (concurrency limit; see below)

The created run uses trigger = Api. If concurrency limits are reached and queueing is enabled, the run is created in status = Queued; otherwise it starts Running.

Run Management

List runs

GET /v1/exports/runs?profileId=&status=&trigger=&createdAfter=&createdBefore=&correlationId=&offset=0&limit=50
Scope: export.viewer

Response: 200 OK
{ "items": [ { ...ExportRunResponse } ], "totalCount": 1, "offset": 0, "limit": 50 }

Get run

GET /v1/exports/runs/{runId}
Scope: export.viewer

Response: 200 OK  (includes "artifacts" list) | 404 Not Found

Cancel run

POST /v1/exports/runs/{runId}/cancel
Scope: export.operator

Response: 200 OK  (updated ExportRunResponse)
         | 404 Not Found
         | 400 Bad Request (run not in a cancellable state)

Artifact Retrieval

List artifacts for a run

GET /v1/exports/runs/{runId}/artifacts
Scope: export.viewer

Response: 200 OK
{ "items": [ { ...ExportArtifactResponse } ], "totalCount": 1 }
| 404 Not Found

Get artifact metadata

GET /v1/exports/runs/{runId}/artifacts/{artifactId}
Scope: export.viewer

Response: 200 OK  (ExportArtifactResponse) | 404 Not Found

Download artifact

GET /v1/exports/runs/{runId}/artifacts/{artifactId}/download
Scope: export.viewer

Response: 200 OK
Content-Type: <artifact.contentType, default application/octet-stream>
(file stream; filename = artifact.name)
| 404 Not Found  (run, artifact, or backing file missing)

Downloads are audited and increment export_artifact_downloads_total.

Run Events (Server-Sent Events)

GET /v1/exports/runs/{runId}/events
Scope: export.viewer
Content-Type: text/event-stream

event: connected
data: { ...ExportRunResponse }

event: run.progress
data: { totalItems, processedItems, failedItems, totalSizeBytes }

event: run.completed
data: { ...ExportRunResponse }

The stream emits connected, progress updates (run.progress), status-change events, then a final disconnected event. SSE event-type constants (ExportRunSseEventTypes):

Event typeConstant
run.startedRunStarted
run.progressRunProgress
run.phase.startedRunPhaseStarted
run.phase.completedRunPhaseCompleted
run.artifact.createdRunArtifactCreated
run.completedRunCompleted
run.failedRunFailed
run.cancelledRunCancelled

Verification

Verify a run

POST /v1/exports/runs/{runId}/verify
Scope: export.viewer

{ "verifyHashes": true, "verifySignatures": true, "verifyManifest": true,
  "verifyEncryption": true, "checkRekor": false, "trustedKeys": [] }

Response: 200 OK
{
  "runId": "...",
  "status": "Valid",
  "isValid": true,
  "verifiedAt": "2026-05-30T00:00:10Z",
  "manifest": { "isValid": true, "entryCount": 3, "digest": "sha256:...", "errors": [] },
  "signature": { "isValid": true, "algorithm": "ES256", "keyId": "...",
                 "signer": "...", "rekorVerified": null, "rekorLogIndex": null, "errors": [] },
  "fileHashes": [ { "path": "...", "isValid": true, "expectedHash": "...",
                    "computedHash": "...", "algorithm": "SHA-256", "error": null } ],
  "errors": [],
  "warnings": []
}
| 404 Not Found

status is the VerificationStatus enum serialized to string: Valid, Invalid, Partial, Error, Pending. isValid is true only when status == Valid.

Get run manifest

GET /v1/exports/runs/{runId}/verify/manifest
Scope: export.viewer

Response: 200 OK
{ "runId": "...", "manifestContent": "<raw manifest>", "digest": "sha256:..." }
| 404 Not Found

Get attestation status

GET /v1/exports/runs/{runId}/verify/attestation
Scope: export.viewer

Response: 200 OK
{ "runId": "...", "hasAttestation": true, "attestationType": "DSSE",
  "manifestDigest": "sha256:...", "verifiedAt": null }
| 404 Not Found

Stream verification progress (SSE)

POST /v1/exports/runs/{runId}/verify/stream
Scope: export.viewer
Content-Type: text/event-stream

event: progress
data: { ...verification progress }

Signing Configuration

ExportSigningOptions (Api/ExportApiModels.cs). Stored as signing_json.

{
  "enabled": true,
  "algorithm": "ES256",
  "keyId": "signing-key-001",
  "providerHint": "vault",
  "includeProvenance": true
}
FieldTypeDefaultDescription
enabledboolfalseEnable signing
algorithmstringES256Signature algorithm
keyIdstring?Signing key identifier
providerHintstring?Free-form hint selecting the signing provider/backend (e.g. vault for the HashiCorp Vault KEK backend).
includeProvenancebooltrueEmbed provenance in the attestation

Attestations are emitted as DSSE envelopes (see GET .../verify/attestation, which reports attestationType: "DSSE" when a signature is present). Rekor inclusion is optional and only checked when checkRekor is requested at verify time.

Concurrency Control

ExportConcurrencyOptions (Api/ExportApiModels.cs) governs run admission in StartRunFromProfile:

OptionDefaultDescription
MaxConcurrentRunsPerTenant4Max active runs per tenant
MaxConcurrentRunsPerProfile2Max active runs per profile
QueueExcessRunstrueQueue runs that exceed limits instead of rejecting
MaxQueueSizePerTenant10Max queued runs per tenant

When limits are exceeded and queueing is disabled (or the queue is full), the start request returns 429 Too Many Requests and increments export_concurrency_limit_exceeded_total.

Persistence

Schema export_center (plus export_center_app for tenant helpers). Created and migrated from 001_initial_schema.sql. All tenant-scoped tables enable PostgreSQL Row-Level Security keyed on app.current_tenant.

TableKeyNotes
export_center.export_profilesprofile_idkind/status smallints; unique (tenant_id, name) where archived_at IS NULL
export_center.export_runsrun_idFK → export_profiles; status 1–6, trigger 1–4
export_center.export_inputsinput_idItems per run; FK → export_runs (cascade); content_hash is ^[0-9a-f]{64}$
export_center.export_distributionsdistribution_idDistribution to targets; FK → export_runs (cascade); kind 1–5

Telemetry

Meter StellaOps.ExportCenter (Telemetry/ExportTelemetry.cs). Selected metrics:

MetricTypeNotes
export_runs_totalcounterRuns initiated; tags: profile/tenant/type
export_runs_success_totalcounterSuccessful runs
export_runs_failed_totalcounterFailed runs; tag error_code
export_artifacts_totalcounterArtifacts exported; tag artifact_type
export_bytes_totalcounterBytes exported
export_artifact_downloads_totalcounterArtifact downloads; tags tenant_id, artifact_kind
export_sse_connections_totalcounterSSE connections
export_concurrency_limit_exceeded_totalcounterConcurrency rejections; tag limit_type
export_audit_events_totalcounterAudit events; tags operation, resource_type
export_run_duration_secondshistogramRun duration
export_bundle_size_byteshistogramBundle size
export_runs_in_progressup/down counterActive runs gauge

Error Behavior

The API uses HTTP status codes rather than a custom error-code catalog:

ConditionStatus
Missing/invalid tenant claim400 Bad Request
Profile/run/artifact not found404 Not Found
Duplicate profile name within tenant409 Conflict
Start run on non-Active profile400 Bad Request
Update on Archived profile400 Bad Request
Cancel run in non-cancellable state400 Bad Request
Concurrency / queue limit exceeded429 Too Many Requests
Legacy /exports endpoints410 Gone

Validation/error message strings are produced by the localization layer (StellaOps.Localization), e.g. exportcenter.error.profile_name_conflict.