OfflineKit Architecture
Verified against source commit
5a216be61150edfac39a64765b5c081e91767c28, 2026-09-14. Re-verify withdotnet 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
- Owns: export profiles, runs, inputs, distributions and attestations; audit-bundle, exception-report and risk-bundle jobs; carrier custody (quarantine, content-addressed store, import ledger) and the per-part activation ledger; the importer’s bundle-version state.
- Reads, never stores: environment posture. The
airgap-sealandtime-anchordocuments live in Platform’s environment-state custodian (platform.environment_state); OfflineKit replicates and interprets them (src/__Libraries/StellaOps.AirGap.Policy, posture classes inPosture/AirGapSealDocument.csandPosture/AirGapTimeAnchorDocument.cs). - Never activates domain content itself. A verified carrier is custody only. Parts are handed to their owning service through that service’s bounded API (Scanner, Vulnerabilities); OfflineKit never writes to a sibling database.
- Retired predecessors:
export-web/export-workerare frozen atsrc/__Obsoleted/ExportCenter/;airgap-controller/airgap-timeatsrc/__Obsoleted/AirGap/. The predecessor databasestellaops_exportcenterwas dropped after parity verification (see../export-center/README.md). - Library placement (2026-09-14): the
StellaOps.AirGap.{Bundle,Sync,Importer,Time.Verification},StellaOps.Mirror.Creator,StellaOps.OfflineKit.{Persistence,TrustBundle.Contracts}andStellaOps.ExportCenter.*libraries live undersrc/OfflineKit/__Libraries/; the domain-neutralStellaOps.AirGap.Policylives undersrc/__Libraries/. TheStellaOps.ExportCenter.*namespaces were kept on purpose: the lift was byte-identical.
2. Runtime topology
| Role | Project | Image | Serves |
|---|---|---|---|
offlinekit-web | src/OfflineKit/StellaOps.OfflineKit.WebService | stellaops/offlinekit-web | the export plane, the custody/import plane, the doctor surface, /health/ready |
offlinekit-worker | src/OfflineKit/StellaOps.OfflineKit.Worker | stellaops/offlinekit-worker | the 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.
- Database:
stellaops_offlinekit, one role. Connection resolves only fromSTELLAOPS_POSTGRES_OFFLINEKIT_CONNECTION(or the configuration keyPostgres:OfflineKit:ConnectionString) and fails closed otherwise (src/OfflineKit/__Libraries/StellaOps.OfflineKit.Persistence/Extensions/ConsolidatedOfflineKitPersistenceExtensions.cs). - Schemas:
offlinekit(custody, part ledger, bundle versions, migration ledger),export_center(the export engine),export_center_app(tenant-isolation functions the engine’s row-level security calls),catalog_replicaandeventing(the tenants and environment-state replicas, converged by the replication seams fromsrc/__Libraries/StellaOps.Catalog.Replicationandsrc/__Libraries/StellaOps.Eventing.Reliability). - Migrations: embedded SQL under
src/OfflineKit/__Libraries/StellaOps.OfflineKit.Persistence/Migrations/, applied byAddStartupMigrations, forward-only (ADR-004). Files001to009at the verification commit. - Gateway prefix: the router resolves
^/api/offlinekit/v1(.*)to this host by its HELLO nameofflinekit(OfflineKitSchemaTopology.RouterServiceName). One process publishes one name; the predecessor nameexportcenterand its bare/v1/exportspaths are gone. The identity envelope signed by the gateway is the host’s identity source after the route swap, soRouter:IdentityEnvelopeSigningKeyis mandatory in the overlay. - Volumes (
devops/compose/docker-compose.offlinekit-consolidated.yml):
| Volume | Mount | Holds |
|---|---|---|
compose_offlinekit-data | /var/lib/stella | import custody: OfflineKit:Import:StorageRoot = /var/lib/stella/offlinekit-import with quarantine/, cas/, multipart-buffer/ |
compose_exportcenter-object-store-data | /var/lib/stella/exportcenter | runs/ (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 |
- Doctor:
GET /doctor/offlinekit/checks, scope-gated onops.health(policyofflinekit.ops.health). The surface runs the three standard checks (doctor.standard.db.connection,doctor.standard.db.migration-status,doctor.standard.db.size-budget), the owned-state budget check over the import CAS (doctor.standard.state.size-budget.*for state idvolume:offlinekit-import-cas, budget fromOFFLINEKIT_IMPORT_CAS_BUDGET_BYTES) and the family’s own checks (src/OfflineKit/StellaOps.OfflineKit.WebService/Doctor/OfflineKitDoctorChecks.cs):doctor.offlinekit.database.own-database,doctor.offlinekit.migration.sole-ledger,doctor.offlinekit.posture.not-stored-here,doctor.offlinekit.tenancy.resolution-source,doctor.offlinekit.export-engine.inventory,doctor.offlinekit.gateway.envelope-identity,doctor.offlinekit.gateway.router-registration,doctor.offlinekit.retention.partition-runway,doctor.offlinekit.retention.bounded-state-budgets. Registration with the Platform doctor registry is opt-in (Doctor:Registration:Enabled, default off). - Service identity:
OfflineKit:ServiceIdentity:{Authority,TokenEndpoint,ClientId,ClientSecret}is required, and must not be blank, whenever doctor registration or either catalog replica is enabled. The overlay binds it to thestellaops-offlinekit-doctorclient.
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:
| Group | Prefix under /api/offlinekit | Source |
|---|---|---|
| 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}/verify | Api/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 none | Api/ExportApiEndpoints.cs, Api/ExportRunCarrierDescriptor.cs |
| Export attestations | /v1/exports/... | Attestation/AttestationEndpoints.cs |
| Promotion attestations | /v1/promotions | Attestation/PromotionAttestationEndpoints.cs |
| Incidents | /v1/incidents | Incident/IncidentEndpoints.cs |
| Risk bundles | /v1/risk-bundles | RiskBundle/RiskBundleEndpoints.cs |
| Simulation exports | /v1/exports/simulations | SimulationExport/SimulationExportEndpoints.cs |
| Audit bundles | /v1/audit-bundles | AuditBundle/AuditBundleEndpoints.cs |
| Exception reports | /v1/exports/exceptions | ExceptionReport/ExceptionReportEndpoints.cs |
| Lineage evidence packs | /v1/lineage-evidence-packs | Lineage/LineageExportEndpoints.cs |
| Assurance exports | /v1/exports/assurance | AssuranceExports/AssuranceExportEndpoints.cs |
| NIS2 SoA | /v1/exports/nis2/soa | Nis2Soa/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:
| Route | Scope | Purpose |
|---|---|---|
POST /api/offlinekit/v1/import | airgap:import | verify a carrier, take custody, dispatch parts to owners; answers 202 pointing at /status |
GET /api/offlinekit/v1/status | airgap:status:read | latest verified carrier and per-part activation state for the tenant |
GET /api/offlinekit/v1/manifest | airgap:status:read | the manifest of the latest verified carrier |
POST /api/offlinekit/v1/validate | airgap:import | validate 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/.
- Profiles.
export_center.export_profilesrows carry akind(ExportProfileKind:AdHoc=1,Scheduled=2,EventDriven=3,Continuous=4,Mirror=5), ascope_json, aformat_jsonand asigning_json.Mirroris a profile kind, not a second engine (DC-22): there is one bundle-production engine and mirror production is one of its materialisers. Profile and format options:../export-center/profiles.md. - Submission.
POST /api/offlinekit/v1/exports/profiles/{profileId}/runswrites anexport_runsrow in statusQueued(statuses:Queued,Running,Completed,PartiallyCompleted,Failed,Cancelled; triggers:Manual,Scheduled,Event,Api). The web host does not execute runs. - Lease.
ExportRunWorkeronofflinekit-worker(gated byExport:Runs:Worker:Enabled) first asks which tenants haveQueuedruns through theSECURITY DEFINERapertureexport_center_app.tenants_with_pending_runs(status), then opens a tenant-scoped session and leases one run at a time withPostgresExportRunRepository.LeaseNextAsync: anUPDATE ... WHERE run_id = (SELECT ... FOR UPDATE SKIP LOCKED)that moves the oldestQueuedrun toRunning. Each pass is capped per tenant (Export:Runs:Worker:MaxRunsPerTenantPerPass, default 4); the poll interval defaults to 5 s. A separate sweep (ExpirySweepInterval, default 15 min) failsRunningruns that outlived their ownexpires_at. Shutdown leaves a leased runRunningrather than requeueing it, because the process cannot know whether artifacts were already written. - Materialisers.
ExportRunExecutorcreates<Export:Runs:StorageRoot>/<tenant>/<run>/and dispatches on the profile kind:DeclaredInputsExportRunMaterializer(kinds 1 to 4) readsDeclaredExportScopefromscope_json:inputs[]withsourceRef,kind,nameand an optional expectedsha256. Inputs are read only from directories listed inExport:Runs:DeclaredInputRoots; an empty list refuses every declared input. This is a containment boundary:scope_jsonis operator-authored data in a tenant-scoped row. The per-run input manifest is written toexport_inputsbefore the format adapter runs, so a failed run still records what it was asked to export. The adapter is resolved through the registry populated byAddExportAdapters.MirrorExportRunMaterializer(kind 5) readsMirrorProfileScope:sources[]withsourceId,sourceUri,contentKinds[]andenabled.sourceUrimust be an absolute path or afile://URI; anything else is refused. It resumes fromexport_center.mirror_run_cursorsand writesmirror-<runId>.tar.gzthroughMirrorBundleBuilder. Layout:../export-center/mirror-bundles.md.
- Carrier (SPRINT_20260914_002 EA-1). After a materialiser succeeds,
ExportRunExecutorhands the produced artifacts toExportRunCarrierWriter, which writes the run’s OfflineKit carrier into<run>/carrier/through the family’s own envelope library (StellaOps.AirGap.Bundle/Models/OfflineKitCarrierWriter.cs):offline-kit.tar.gzholding every artifact as a part, the canonicalmanifest.json(OfflineKitCarrierManifest, the one type the import parses,schemaVersion stella.ops/offlinekit-manifest@v1),bundle.dsse.json(in-toto carrier statement binding both digests),manifest.dsse.json(DSSE over the manifest bytes) and the CLI sidecaroffline-kit.tar.gz.metadata.json. The five files become artifacts of kindcarrier(metadataofflinekit.carrierRole) so the ledger records them and the artifact download route serves them. Parts are keyed(domain, mediaType, generation): an artifact a materialiser stamped with an owner contract (see owner-part below) activates on import; any other artifact ships as<kind>/<name>and is verified and recordedPART_ACTIVATOR_NOT_REGISTEREDuntil a domain claims it. Signing custody is the crypto registry, key resolved by id (Export:Carrier:SigningKeyId,CryptoRegistryCarrierSigner); the lab mounts an operator EC P-256 PEM (OperatorMountedSigningKeys, the NIS2 SoA shape), production points the id at a sovereign provider. With no key id configured the run completes without a carrier and recordsEXPORT_RUN_CARRIER_NOT_WRITTENon the row; a carrier that cannot be written fails the run withEXPORT_RUN_CARRIER_FAULTED. - Owner-part inputs (EA-1). A declared-inputs profile may declare
{"kind":"owner-part","sourceRef":"scanner:artifact-boms","tenant":"<slug>"}. The materialiser resolves thesourceRefthrough a registeredIOwnerPartSource(ScannerArtifactBomOwnerPartSourcepullsGET /api/internal/v1/offlinekit/artifact-bomswith the Scanner exchange identity — the samestellaops-offlinekit-activatorclient andscanner:offlinekit:artifact-bomsscope the import-side activator mints, sectionOfflineKit:ScannerActivationon both hosts), writes the owner’s bytes verbatim as an artifact stamped with the owner contract’s part path and key, and records the manifest row under the owner route. Owner parts never pass through a format adapter. Thetenantslug is declared by the profile author because the worker holds only the run’s tenant UUID; the owner’s package names its tenant and the import re-checks it against the authenticated one. - Distributions. Every produced artifact is recorded as an
export_distributionsrow of kindFileSystem, statusDistributed, through the tenant-scoped idempotency key (uq_export_distributions_tenant_idempotency_key), so a replayed record collapses instead of duplicating. The artifact API (/v1/exports/runs/{runId}/artifacts) is a projection over these rows; there is no separate artifacts table.retention_expires_atcarries the run’sexpires_at. - Completion. Cursors are written only after a mirror run succeeds and its artifacts are durably recorded. The run ends
Completed,PartiallyCompleted(some items failed) orFailedwith a codederror_json. - Attestations. DSSE export attestations and promotion-attestation assemblies persist in
export_center.export_attestationsandexport_center.promotion_attestation_assemblies; the Ed25519 export-attestation signer is bound fromExport:Attestation:Ed25519. Verification readback (.../verify,.../verify/manifest,.../verify/attestation) is served fromexport_verification_runsandexport_verification_artifacts. Lineage and signing rules:../export-center/provenance-and-signing.md, reproducibility rules:../export-center/determinism.md. - Other loops. The audit-bundle background worker is hosted by
offlinekit-web(registered with the Postgres job handler inside the export runtime composition); the risk-bundle worker (RiskBundles:Enabled) and the DevPortal-offline job (DevPortalOffline:Enabled) are hosted byofflinekit-worker. All three job families carry aNOT NULLtenant-scopedidempotency_key, so an at-least-once submission returns the existing job.
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}).
- Carrier.
POST /importis multipart: the bundle archive, the manifest JSON, a DSSE envelope over the bundle and a DSSE envelope over the manifest. Size caps come fromOfflineKit:Import:Max{Bundle,Manifest,Signature,DecodedSignature}Bytes; concurrency fromOfflineKit:Import:MaxConcurrentImports(1 to 8). Multipart buffering is pinned inside the custody volume (multipart-buffer/), never the process temp path. - Verification. Parts land in a private quarantine directory. Both envelopes are verified by
OfflineKitDsseVerifieragainst the operator public key atOfflineKit:Import:TrustedPublicKeyPath(ES256 or PS256, SHA-256 digests, optionalTrustedKeyIdpin). 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. - 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 with409when 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. - Custody. The verified carrier is promoted from quarantine into the content-addressed store under
OfflineKit:Import:StorageRoot/cas/and recordedverifiedinofflinekit.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). - Parts and activators.
OfflineKitPartExtractorwalks the archive once for every declared part a registeredIOfflineKitPartActivatorclaims;OfflineKitPartActivatorRegistryrefuses to compose when two activators claim one triple. Each part is recordedquarantined, thenstaged, and endsactivated,failedorsuperseded(owner considered it and declined) inofflinekit.bundle_import_parts, with the trail inbundle_import_part_events. A part no activator claims isfailedwithPART_ACTIVATOR_NOT_REGISTEREDwhile the carrier staysverified: custody and activation are separate verdicts. Registered owners:
| Activator | Triple | Owner API |
|---|---|---|
ScannerArtifactBomPartActivator | scanner / application/vnd.stellaops.scanner.artifact-boms.v1+json / 1 | POST /api/internal/v1/offlinekit/artifact-boms/activate on Scanner, scope scanner:offlinekit:artifact-boms |
VulnerabilityMirrorSeedPartActivator | vulnerabilities / application/vnd.stellaops.mirror-seed+ndjson / 1 | POST /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):
| Table | Purpose |
|---|---|
export_profiles | operator-authored export configuration; kind 1 to 5, status Draft/Active/Paused/Archived, scope_json, format_json, signing_json |
export_runs | one export execution and its outcome; status 1 to 6, trigger 1 to 4, item counters, error_json, expires_at |
export_inputs | resolved input items per run, written before the adapter runs |
export_distributions | produced artifacts and their distribution state; backs the artifact API; idempotency key, retention expiry, soft-delete markers |
export_attestations | DSSE export attestations for readback and verification |
promotion_attestation_assemblies | promotion attestation assemblies |
export_verification_runs, export_verification_artifacts | verification readback |
export_incidents, export_incident_updates | incident lifecycle (updates partitioned by 006) |
export_timeline_notifications | durable timeline notification sink |
audit_bundle_jobs | audit-bundle jobs with lease owner/expiry and payload storage keys |
exception_report_jobs | exception report jobs |
risk_bundle_jobs, risk_bundle_job_events | risk-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):
| Table | Purpose |
|---|---|
bundle_versions | the active imported bundle version per (tenant, bundle_type); the monotonicity gate reads it |
bundle_version_history | append-audit trail of activations (partitioned by 006) |
bundle_imports | tenant-scoped custody ledger of verified carriers; status is always verified |
bundle_import_parts | current lifecycle state per dispatched part: quarantined, staged, activated, failed, superseded |
bundle_import_part_events | append-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
| Key | Notes |
|---|---|
STELLAOPS_POSTGRES_OFFLINEKIT_CONNECTION | required 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:Authority | OFFLINEKIT_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)
| Key | Notes |
|---|---|
OfflineKit:Import:StorageRoot | absolute, not a filesystem root; /var/lib/stella/offlinekit-import |
OfflineKit:Import:TrustedPublicKeyPath | must exist at startup; bind-mounted from OFFLINEKIT_IMPORT_TRUST_PUBLIC_KEY_PATH |
OfflineKit:Import:TrustedKeyId | optional key-id pin; OFFLINEKIT_IMPORT_TRUST_KEY_ID |
OfflineKit:Import:MaxBundleBytes, MaxManifestBytes, MaxSignatureBytes, MaxDecodedSignatureBytes, MaxConcurrentImports | OFFLINEKIT_IMPORT_MAX_* |
OFFLINEKIT_IMPORT_CAS_BUDGET_BYTES | shell-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_SECRET | activator client secret, environment only |
Export plane (web)
| Key | Notes |
|---|---|
ExportCenter:ObjectStore:Kind, ExportCenter:ObjectStore:RootPath | FileSystem, /var/lib/stella/exportcenter/audit-bundles; audit-bundle payloads fail closed without it |
ExportCenter:SbomAudit:BaseUrl, ExportCenter:VulnAudit:BaseUrl, ExportCenter:TimelineAudit:BaseUrl | audit-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:BaseUrl | required outside Testing |
Worker
| Key | Notes |
|---|---|
Export:Runs:Worker:Enabled | OFFLINEKIT_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:N | containment 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_SECRET | the 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
- Identity. After the route swap the gateway-signed identity envelope is the web host’s only identity source, and the envelope middleware does not fail closed without a key. The overlay therefore requires the signing key, and
doctor.offlinekit.gateway.envelope-identitymeasures the key side while the host wires the middleware side. - Scopes. Export plane:
export.viewer,export.operator,export.admin. Custody plane:airgap:import,airgap:status:read. Doctor:ops.health. Service identity:catalog:replicateandplatform:doctor:register. Canonical catalogue:src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs. - Import trust. Carrier signatures verify only against the operator-mounted public key; the service never trusts a key carried by the upload and never falls back to its content root for storage. Sealed imports are freshness-gated against the declared trusted-time anchor.
- Signing custody. Export signing keys are operator-mounted under
devops/etc/exportcenter/keys/(git-ignored) and referenced by file path. NIS2 SoA and Assurance trust roots come from machine-local environment (STELLAOPS_EXPORTCENTER_NIS2_SOA_TRUST_ROOT_PUBLIC_KEY_PEM,STELLAOPS_EXPORTCENTER_NIS2_SOA_TRUST_ROOT_FINGERPRINT). The Signer service owns the artifact attestation keys; the kit builder receives only an entitlement proof, a DPoP key and a least-privilege client (Q-25;../../runbooks/airgap/offlinekit-signer-custody.md). No private key material is committed. - Isolation. One database, one role, tenant row-level security on every ledger; cross-service access is owner API only (Scanner, Vulnerabilities, EvidenceLocker, Timeline, SbomService).
- Bounded state. Import quarantine and multipart buffering live inside the custody volume; the CAS budget and the export retention budgets are measured by the doctor and enforced by the write pause and the prune paths.
9. Design record
- DC-18, DC-21, DC-22 in the design-challenges register: the unified carrier envelope (DC-18), envelope authority as an evolution of
AirGap.Bundlerather than a parallel format (DC-21), and one bundle-production engine with mirror as a profile kind (DC-22). - Consolidation design (frozen): schema dispositions, the data move and the posture ruling that left posture with Platform.
- ADR-039 database-per-service ownership, ADR-004 forward-only migrations, database ownership matrix.
10. Related documents
- README.md: the family overview and the part-activation seam.
- Formats that remain current under
../export-center/: profiles.md, determinism.md, provenance-and-signing.md, mirror-bundles.md, cli.md, operations/. - Operator guide:
../../OFFLINE_KIT.md. - Runbooks:
../../runbooks/airgap/, in particular offlinekit-import.md (the operator import path, exit codes and the posture gate), signer custody, the OK-6 route swap and the OK-9 staged stack. - Capacity guard: operations/import-cas-capacity-guard.md.
- Neighbours: Platform (environment-state custodian), Scanner and Vulnerabilities (part owners), Signer (attestation keys), AirGap and Mirror (retained format records).
