OfflineKit Architecture

Verified against source commit 5a216be61150edfac39a64765b5c081e91767c28, 2026-09-14. Re-verify with dotnet test src/OfflineKit/__Tests/StellaOps.OfflineKit.WebService.Tests/StellaOps.OfflineKit.WebService.Tests.csproj. src/ is the source of truth; where this page and the code disagree, the code wins.

OfflineKit is the consolidated offline-exchange service of Stella Ops: one deployable family that produces signed bundles out of an estate (the export plane) and takes signed carriers in (the custody/import plane). It absorbed the Export Center service, the Mirror creator and the two AirGap posture hosts. The predecessor dossier is frozen at docs-archive/modules/export-center/architecture.md and describes hosts that no longer run.

1. Purpose and boundaries

2. Runtime topology

RoleProjectImageServes
offlinekit-websrc/OfflineKit/StellaOps.OfflineKit.WebServicestellaops/offlinekit-webthe export plane, the custody/import plane, the doctor surface, /health/ready
offlinekit-workersrc/OfflineKit/StellaOps.OfflineKit.Workerstellaops/offlinekit-workerthe export-run executor, the risk-bundle and DevPortal-offline loops; only /health/liveness and /health/readiness over HTTP

Both roles converge the same database on startup; the migration runner serialises through an advisory lock.

VolumeMountHolds
compose_offlinekit-data/var/lib/stellaimport custody: OfflineKit:Import:StorageRoot = /var/lib/stella/offlinekit-import with quarantine/, cas/, multipart-buffer/
compose_exportcenter-object-store-data/var/lib/stella/exportcenterruns/ (export runs), nis2-soa/, assurance/, audit-bundles/, risk-bundles/, devportal-offline/
bind devops/etc/exportcenter/app/etc/exportcenter (read-only)operator-mounted export signing material; devops/etc/exportcenter/keys/ is git-ignored
bind OFFLINEKIT_IMPORT_TRUST_PUBLIC_KEY_PATH/app/etc/offlinekit/import-trust.pem (read-only)the operator public key import verifies against

3. Planes

The web host serves two planes that look alike and are not (src/OfflineKit/StellaOps.OfflineKit.WebService/Program.cs). The generated route reference is api-reference.md, rendered from openapi/v1.json — the document the host serves at /openapi/v1.json and the gateway aggregate lists as offlinekit (tools/scripts/docs/regen-from-openapi.ps1 -Service offlinekit, SPRINT_20260914_002 EA-6).

3.1 Export plane

The lifted Export Center application (src/OfflineKit/__Libraries/StellaOps.ExportCenter.Application) is mounted as a group at /api/offlinekit; every endpoint extension carries its own /v1/... prefix, so the served paths are:

GroupPrefix under /api/offlinekitSource
Profiles, runs, artifacts, events, verify/v1/exports/profiles, /v1/exports/runs, /v1/exports/runs/{runId}/artifacts, /v1/exports/runs/{runId}/events, /v1/exports/runs/{runId}/verifyApi/ExportApiEndpoints.cs
Part re-activation/v1/imports/{importId}/parts/{domain}/activate (custody plane, airgap:import) — re-offers one admitted part to its owner from its CAS bytes (EA-3)Endpoints/OfflineKitEndpoints.cs
Run carrier descriptor/v1/exports/runs/{runId}/carrier — the five carrier files a completed run wrote, with download routes; 404 when the run wrote noneApi/ExportApiEndpoints.cs, Api/ExportRunCarrierDescriptor.cs
Export attestations/v1/exports/...Attestation/AttestationEndpoints.cs
Promotion attestations/v1/promotionsAttestation/PromotionAttestationEndpoints.cs
Incidents/v1/incidentsIncident/IncidentEndpoints.cs
Risk bundles/v1/risk-bundlesRiskBundle/RiskBundleEndpoints.cs
Simulation exports/v1/exports/simulationsSimulationExport/SimulationExportEndpoints.cs
Audit bundles/v1/audit-bundlesAuditBundle/AuditBundleEndpoints.cs
Exception reports/v1/exports/exceptionsExceptionReport/ExceptionReportEndpoints.cs
Lineage evidence packs/v1/lineage-evidence-packsLineage/LineageExportEndpoints.cs
Assurance exports/v1/exports/assuranceAssuranceExports/AssuranceExportEndpoints.cs
NIS2 SoA/v1/exports/nis2/soaNis2Soa/Nis2SoaExportEndpoints.cs

Authorization is per endpoint: export.viewer for reads, export.operator for profile writes and run start/cancel, export.admin for profile archive (StellaOpsResourceServerPolicies.ExportViewer/ExportOperator/ExportAdmin). There is no global fallback policy; the predecessor’s fallback would have blocked the custody plane and the readiness probe. The tenant middleware is applied to /api/offlinekit except the four custody routes, and tenants resolve from the local catalog replica (Catalog:Replication:Tenants, replica-only resolver: a disabled replica fails the resolve loudly instead of answering “no such tenant”).

Two anonymous internal routes report export-adapter plugin status: GET /internal/plugins/status and POST /internal/plugins/probe. The /internal/* prefix must stay gateway-blocked.

3.2 Custody/import plane

src/OfflineKit/StellaOps.OfflineKit.WebService/Endpoints/OfflineKitEndpoints.cs maps the group /api/offlinekit/v1:

RouteScopePurpose
POST /api/offlinekit/v1/importairgap:importverify a carrier, take custody, dispatch parts to owners; answers 202 pointing at /status
GET /api/offlinekit/v1/statusairgap:status:readlatest verified carrier and per-part activation state for the tenant
GET /api/offlinekit/v1/manifestairgap:status:readthe manifest of the latest verified carrier
POST /api/offlinekit/v1/validateairgap:importvalidate a manifest and its envelope without taking custody

This plane resolves its tenant from the authenticated identity envelope, not from the tenants catalogue, which is why the tenant middleware excludes it: applied globally it turned a 422 digest-mismatch answer into a 403 before the verifier could say what was wrong.

3.3 Posture read

Catalog:Replication:EnvironmentState (default off) replicates Platform’s environment_state catalog into this host’s own catalog_replica schema. AirGapPostureReplicaReader answers sealed-posture and time-anchor questions from that replica, so reads keep working when Platform is down. The doctor check doctor.offlinekit.posture.not-stored-here measures that the retired airgap.state table is absent and that the custodian is platform.environment_state, class airgap-seal. The egress side of the same policy is bound from AirGap:Egress.

4. Export run engine

Source: src/OfflineKit/__Libraries/StellaOps.ExportCenter.Application/RunExecution/, src/OfflineKit/__Libraries/StellaOps.ExportCenter.WorkerRuntime/, and the Postgres repositories under src/OfflineKit/__Libraries/StellaOps.ExportCenter.Infrastructure/Postgres/Repositories/.

5. Import pipeline

Source: src/OfflineKit/StellaOps.OfflineKit.WebService/Imports/. Envelope model: OfflineKitCarrierManifest in src/OfflineKit/__Libraries/StellaOps.AirGap.Bundle/Models/OfflineKitCarrierManifest.cs (bundle{version,bundleId,channel,kind,capturedAt,isDelta,baseBundleId,releaseManifestSha256,producer} plus artifacts[]{name,sha256,size,domain,mediaType,generation}).

  1. Carrier. POST /import is multipart: the bundle archive, the manifest JSON, a DSSE envelope over the bundle and a DSSE envelope over the manifest. Size caps come from OfflineKit:Import:Max{Bundle,Manifest,Signature,DecodedSignature}Bytes; concurrency from OfflineKit:Import:MaxConcurrentImports (1 to 8). Multipart buffering is pinned inside the custody volume (multipart-buffer/), never the process temp path.
  2. Verification. Parts land in a private quarantine directory. Both envelopes are verified by OfflineKitDsseVerifier against the operator public key at OfflineKit:Import:TrustedPublicKeyPath (ES256 or PS256, SHA-256 digests, optional TrustedKeyId pin). The key must exist at startup (OfflineKitImportTrustStartupValidator); a key carried by the upload is never trusted. Manifest shape and every artifact’s (domain, mediaType, generation) triple are validated against the owner contracts before anything is promoted.
  3. Posture gate (OfflineKitImportPostureGate). Read from the posture replica: posture not declared or unsealed admits; posture unreadable admits with a logged warning (a replica outage must not strand a sealed estate’s only supply path); posture sealed runs the freshness gate and fails closed with 409 when no trusted time anchor is declared, the anchor cannot be read, or the anchor has breached the seal document’s staleness budget. Import is the sanctioned ingress of a sealed estate; it is freshness-gated, never blocked outright.
  4. Custody. The verified carrier is promoted from quarantine into the content-addressed store under OfflineKit:Import:StorageRoot/cas/ and recorded verified in offlinekit.bundle_imports (tenant-scoped idempotency key, both envelope bytes, the trust anchor’s SPKI digest, the CAS object key). Rejected bytes are deleted from quarantine and never enter the ledger or CAS. Carrier identity is semantic: the first canonical DSSE evidence in CAS wins and a re-signed retry reuses it. The CAS write pause (OfflineKitImportCasWritePause) refuses promotion once the CAS budget is exhausted (operations/import-cas-capacity-guard.md).
  5. Parts and activators. OfflineKitPartExtractor walks the archive once for every declared part a registered IOfflineKitPartActivator claims; OfflineKitPartActivatorRegistry refuses to compose when two activators claim one triple. Each part is recorded quarantined, then staged, and ends activated, failed or superseded (owner considered it and declined) in offlinekit.bundle_import_parts, with the trail in bundle_import_part_events. A part no activator claims is failed with PART_ACTIVATOR_NOT_REGISTERED while the carrier stays verified: custody and activation are separate verdicts. Registered owners:
ActivatorTripleOwner API
ScannerArtifactBomPartActivatorscanner / application/vnd.stellaops.scanner.artifact-boms.v1+json / 1POST /api/internal/v1/offlinekit/artifact-boms/activate on Scanner, scope scanner:offlinekit:artifact-boms
VulnerabilityMirrorSeedPartActivatorvulnerabilities / application/vnd.stellaops.mirror-seed+ndjson / 1POST /api/vulnerabilities/v1/mirror/seeds/import on Vulnerabilities, audience api://vulnerabilities, scopes vulnerabilities:advisory:ingest and vulnerabilities:vex:ingest; 30 min client timeout

Activators authenticate with client credentials from OfflineKit:ScannerActivation and OfflineKit:VulnerabilitiesActivation ({Scanner,Vulnerabilities}BaseAddress, Authority, TokenEndpoint) plus the secret in OFFLINEKIT_ACTIVATOR_AUTHORITY_CLIENT_SECRET. 6. Ledgers and status. /status reports the latest verified carrier and parts[]; contentActivated is derived from parts and never from the carrier verdict. bundle_versions holds the active bundle version per (tenant, bundle_type) and is what the importer’s monotonicity gate reads to refuse a stale bundle; bundle_version_history records every activation, including forced ones. CAS promotion and the ledger write are not one transaction: on a ledger failure after promotion the CAS object is retained and status reports only ledger-backed custody.

Per-part content addressing (SPRINT_20260914_002 EA-3). After the carrier is promoted, every part a registered activator claims is stored ONCE by its own digest under <OfflineKit:Import:StorageRoot>/cas/parts/sha256/<xx>/<digest> (OfflineKitImportStorage.StorePartAsync: re-hashed on write, staged through the quarantine so the capacity pause measures it, moved into place, a concurrent writer of the same address loses harmlessly). Two carriers declaring the same bytes share one file; the carrier object under cas/sha256/<carrierDigest>/ stays as custody evidence. The part’s ledger row records the address in bundle_import_parts.cas_part_key (migration 010, nullable: rows written before EA-3 and never-claimed parts carry none), and POST /api/offlinekit/v1/imports/{importId}/parts/{domain}/activate re-reads the bytes, re-hashes them against the row’s signed declaration and re-offers them to the owner through the same staged → activated / superseded / failed lifecycle — the part is verifiable and re-activatable without the carrier that delivered it. Only a failed or superseded part is re-offered; an activated part is terminal (409 PART_ALREADY_ACTIVATED), as the ledger’s forward-only rule requires. The doctor measurer (volume:offlinekit-import-cas) walks cas/ whole, so its byte count reflects one copy per part.

6. Data model

All tables below are created by the embedded migrations. Row-level security keys off app.current_tenant through export_center_app.require_current_tenant() (uuid tenants) and offlinekit.require_current_tenant() (text tenants); both fail loudly when the session set no tenant. Retention classes are recorded as table comments and measured by the doctor checks.

export_center (baseline 001, cursors 007):

TablePurpose
export_profilesoperator-authored export configuration; kind 1 to 5, status Draft/Active/Paused/Archived, scope_json, format_json, signing_json
export_runsone export execution and its outcome; status 1 to 6, trigger 1 to 4, item counters, error_json, expires_at
export_inputsresolved input items per run, written before the adapter runs
export_distributionsproduced artifacts and their distribution state; backs the artifact API; idempotency key, retention expiry, soft-delete markers
export_attestationsDSSE export attestations for readback and verification
promotion_attestation_assembliespromotion attestation assemblies
export_verification_runs, export_verification_artifactsverification readback
export_incidents, export_incident_updatesincident lifecycle (updates partitioned by 006)
export_timeline_notificationsdurable timeline notification sink
audit_bundle_jobsaudit-bundle jobs with lease owner/expiry and payload storage keys
exception_report_jobsexception report jobs
risk_bundle_jobs, risk_bundle_job_eventsrisk-bundle jobs and their event trail (events partitioned by 006)
mirror_run_cursors (007)resume position per (tenant, profile, source), replaced only by a succeeded mirror run; cascades with the profile

export_center_app: no tables; require_current_tenant(), update_updated_at() and tenants_with_pending_runs(status).

offlinekit (baseline 001, custody 002, parts 003, states 008):

TablePurpose
bundle_versionsthe active imported bundle version per (tenant, bundle_type); the monotonicity gate reads it
bundle_version_historyappend-audit trail of activations (partitioned by 006)
bundle_importstenant-scoped custody ledger of verified carriers; status is always verified
bundle_import_partscurrent lifecycle state per dispatched part: quarantined, staged, activated, failed, superseded
bundle_import_part_eventsappend-audit trail of part state changes (partitioned by 006)

Migrations 004, 005 and 009 only correct retention-class comments; applied SQL is never edited (ADR-004). The airgap schema and the predecessor’s private export_schema_version ledger are not reproduced; the standard startup-migration ledger lives in the offlinekit schema and doctor.offlinekit.migration.sole-ledger measures that it is the only one.

7. Configuration

Keys are shown in configuration form; the overlay devops/compose/docker-compose.offlinekit-consolidated.yml spells them with __ separators and maps shell variables onto them as noted.

Database and identity

KeyNotes
STELLAOPS_POSTGRES_OFFLINEKIT_CONNECTIONrequired by both roles; fails closed
Router:Enabled, Router:IdentityEnvelopeSigningKey, Router:Frame:Envelope:*, Router:Messaging:*gateway registration; OFFLINEKIT_ROUTER_ENABLED (default true), STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEY (required)
Authority:ResourceServer:AuthorityOFFLINEKIT_AUTHORITY_URL
OfflineKit:ServiceIdentity:{Authority,TokenEndpoint,ClientId,ClientSecret}OFFLINEKIT_DOCTOR_CLIENT_ID / OFFLINEKIT_DOCTOR_CLIENT_SECRET; required when any replica or doctor registration is on
Doctor:Registration:{Enabled,PlatformBaseAddress,SelfEndpoint,Tenant}OFFLINEKIT_DOCTOR_REGISTRATION_ENABLED (default false)
Catalog:Replication:Tenants:{Enabled,FeedBaseAddress,AuthMode,AuthTenant}tenants replica; enabled in the overlay; OFFLINEKIT_TENANTS_REPLICA_FEED_BASE_ADDRESS
Catalog:Replication:EnvironmentState:*posture replica (default off)

Import custody (web)

KeyNotes
OfflineKit:Import:StorageRootabsolute, not a filesystem root; /var/lib/stella/offlinekit-import
OfflineKit:Import:TrustedPublicKeyPathmust exist at startup; bind-mounted from OFFLINEKIT_IMPORT_TRUST_PUBLIC_KEY_PATH
OfflineKit:Import:TrustedKeyIdoptional key-id pin; OFFLINEKIT_IMPORT_TRUST_KEY_ID
OfflineKit:Import:MaxBundleBytes, MaxManifestBytes, MaxSignatureBytes, MaxDecodedSignatureBytes, MaxConcurrentImportsOFFLINEKIT_IMPORT_MAX_*
OFFLINEKIT_IMPORT_CAS_BUDGET_BYTESshell-safe alias for the doctor state budget of volume:offlinekit-import-cas
OfflineKit:ScannerActivation:{ScannerBaseAddress,Authority,TokenEndpoint}OFFLINEKIT_SCANNER_BASE_ADDRESS
OfflineKit:VulnerabilitiesActivation:{VulnerabilitiesBaseAddress,Authority,TokenEndpoint}OFFLINEKIT_VULNERABILITIES_BASE_ADDRESS
OFFLINEKIT_ACTIVATOR_AUTHORITY_CLIENT_SECRETactivator client secret, environment only

Export plane (web)

KeyNotes
ExportCenter:ObjectStore:Kind, ExportCenter:ObjectStore:RootPathFileSystem, /var/lib/stella/exportcenter/audit-bundles; audit-bundle payloads fail closed without it
ExportCenter:SbomAudit:BaseUrl, ExportCenter:VulnAudit:BaseUrl, ExportCenter:TimelineAudit:BaseUrlaudit-bundle section sources
ExportCenter:RuntimePlugins:*mounted signed exporter bundles (none ship today)
Export:Attestation:Ed25519:{PrivateKeyBase64File,KeyId,PreviousKeyId,PreviousPublicKeyBase64File,Provider}export-attestation signer; STELLAOPS_EXPORTCENTER_ED25519_*
Export:Nis2Soa:{StorageRoot,SigningKeyId,SigningAlgorithmId,SigningPrivateKeyPemFile,TrustRootMode,TrustRoots:*,ServiceAccount:*,AuthorityBaseUrl}NIS2 SoA; STELLAOPS_EXPORTCENTER_NIS2_SOA_*, STELLAOPS_EXPORTCENTER_AUTHORITY_CLIENT_{ID,SECRET}
Export:Assurance:{StorageRoot,SigningKeyId,TrustRoots:*,Nis2Effectiveness:*,DoraRoi:*,DoraMajorIncidentReport:*,DoraInfoSharing:*}Assurance exports; STELLAOPS_EXPORTCENTER_*_HMAC_KEY*
Export:Retention:{RiskBundle,ExceptionReport,AuditBundle,TimelineNotifications}:*OFFLINEKIT_RETENTION_*; measured by doctor.offlinekit.retention.bounded-state-budgets
AirGap:Egress:*egress policy of the export plane
EvidenceLocker:BaseUrlrequired outside Testing

Worker

KeyNotes
Export:Runs:Worker:EnabledOFFLINEKIT_EXPORT_RUNS_ENABLED (default false); composes the executor and its repositories
Export:Runs:Worker:{WorkerId,PollInterval,ExpirySweepInterval,MaxRunsPerTenantPerPass}lease loop
Export:Runs:StorageRoot/var/lib/stella/exportcenter/runs
Export:Runs:DeclaredInputRoots:Ncontainment roots for declared inputs; overlay lists /var/lib/stella/exportcenter
Export:Carrier:{SigningKeyId,SigningAlgorithmId,SigningProviderHint,SigningPrivateKeyPemFile,Producer}carrier signer (EA-1); OFFLINEKIT_CARRIER_SIGNING_KEY_ID, STELLAOPS_OFFLINEKIT_CARRIER_SIGNING_KEY_PEM_FILE; unset key id = no carrier, noted on the run
OfflineKit:ScannerActivation:{ScannerBaseAddress,Authority,TokenEndpoint} + OFFLINEKIT_ACTIVATOR_AUTHORITY_CLIENT_SECRETthe Scanner exchange identity the owner-part pull mints (same section and secret file as the web host’s activator)
RiskBundles:{Enabled,Storage:RootPath,Signing:Key,Signing:KeyId}OFFLINEKIT_RISK_BUNDLES_ENABLED, OFFLINEKIT_RISK_BUNDLE_SIGNING_KEY*
DevPortalOffline:{Enabled,Storage:RootPath,Signing:*}OFFLINEKIT_DEVPORTAL_OFFLINE_ENABLED
LIVENESS_PATH, READINESS_PATH/health/liveness, /health/readiness

8. Security and custody

9. Design record