OfflineKit Architecture
Verified against commit
942a18727c(2026-09-26). Re-verify with:dotnet test src/OfflineKit/__Tests/StellaOps.OfflineKit.WebService.Tests/StellaOps.OfflineKit.WebService.Tests.csproj(a database test suite; needs Docker for Testcontainers) andgrep -n 'offlinekit' devops/release/bundle/docker-compose.yml.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.
- 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: the
StellaOps.AirGap.{Bundle,Importer,Time.Verification},StellaOps.OfflineKit.{Persistence,TrustBundle.Contracts}andStellaOps.ExportCenter.*libraries live undersrc/OfflineKit/__Libraries/; the domain-neutralStellaOps.AirGap.Policylives undersrc/__Libraries/.StellaOps.AirGap.Syncwas withdrawn on 2026-09-14 (EA-8) andStellaOps.Mirror.Creator(projectStellaOps.Mirror.Creator.Core, a fail-closed creator service with no consumer) on 2026-09-26, both tosrc/__Obsoleted/OfflineKit/__Libraries/. TheStellaOps.ExportCenter.*namespaces were kept on purpose: the lift was byte-identical.
Exception-report application withdrawal (DIP-36 / OD-1)
Exception reports cover Policy exceptions and optional exception history. The includeApplications request member and application output are withdrawn with Policy’s unused application repository. OfflineKit migration 015 removes the persisted include_applications bit; it does not remove report contents or tenant ownership. The Policy-owned exception/history API reader and deployment acceptance are tracked by PCD-871. Source tests and generated OpenAPI do not establish live acceptance.
PCD-871 supplies the tenant-bound exception-report reader. The local estate enables it in the existing ok5 service profile. Its dedicated Authority reader client has only policy:read, an allowlist and no singular tenant pin; both caller and outbound read identities bind the same authenticated tenant. Completed contents remain owned by OfflineKit and readable independently of Policy availability. Doctor/catalog identity settings are preserved.
Source verification for DIP-36: commit 394532d7114420507cb520549f9f335c9e31955d (2026-10-20). Re-verify the named native controls and generated OpenAPI; the deployment receipt is separate.
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 |
offlinekit-exportcenter-init | (compose service only) | stellaops/offlinekit-web with an sh -c entrypoint, network_mode: none, user 0:0 | one-shot: mkdir -p /var/lib/stella/exportcenter && chown -R 10001:10001 on the shared export volume; both roles depend on it with service_completed_successfully |
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, activation history, 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). The chain contains files001to014at source5afa95c916(2026-10-06);013retiresbundle_versionsand014adds the export-run tenant registry described in section 6. - 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. The repository overlay
devops/compose/docker-compose.offlinekit-consolidated.yml(included bydocker-compose.stella-ops.yml) names themcompose_offlinekit-dataandcompose_exportcenter-object-store-data; the release bundle (devops/release/bundle/docker-compose.yml) names them${STELLA_VOLUME_PREFIX:-stellaops}_offlinekit-dataand${STELLA_VOLUME_PREFIX:-stellaops}_exportcenter-object-store-data.
| 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 (overlay) / ./config/etc/exportcenter (bundle) | /app/etc/exportcenter (read-only) | operator-mounted export signing material; the directory is operator-created and keys/ is git-ignored |
bind OFFLINEKIT_IMPORT_TRUST_PUBLIC_KEY_PATH (overlay) / ${STELLA_OFFLINE_DIR:-./offline}/offlinekit/import-trust.pem (bundle) | /app/etc/offlinekit/import-trust.pem (read-only) | the operator public key import verifies against |
bind OFFLINEKIT_TIME_ANCHOR_TRUST_ROOTS_PATH | /app/etc/offlinekit/time-anchor-trust-roots.pem (read-only) | the timestamp-authority public chain the sealed-import gate re-verifies time anchors against |
bind etc/offlinekit/appsettings.Production.json | /app/appsettings.Production.json (read-only) | binds Crypto:PublicTrust:CertificateBundles:timestamp-authority to the PEM above (the hyphenated key cannot pass through the sh -c entrypoint as an environment variable) |
| bind carrier signing key (worker) | /app/etc/offlinekit/carrier-signing.pem (read-only) | overlay STELLAOPS_OFFLINEKIT_CARRIER_SIGNING_KEY_HOST_PATH; bundle ./secrets/offlinekit/carrier-signing.pem, generated by the installer |
- 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,doctor.offlinekit.retention.mirror-cursor-budget. Two further owned-state measurements cover the export volume (OfflineKitExportOwnedStateMeasurer):volume:exportcenter-object-store-data(rootOfflineKit:OwnedState:ExportVolumeRoot, default/var/lib/stella/exportcenter) andvolume:offlinekit-export-runs(Export:Runs:StorageRoot), reported asdoctor.standard.state.size-budget.volume-exportcenter-object-store-dataanddoctor.standard.state.size-budget.volume-offlinekit-export-runs; see operations/export-storage-measurement.md. Registration with the Platform doctor registry is opt-in (Doctor:Registration:Enabled, default off in both compose files). - 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.
Withdrawn export deprecation helpers (PCD-281, 2026-10-04): the old endpoint registry, sunset/header annotations and notification helpers are unsupported under the pre-release contract. Their sources and historical tests are frozen under src/__Obsoleted/OfflineKit/; the web host no longer registers their notification service. The supported export surface is the native route table above. Assurance profile metadata advertises complete /api/offlinekit/v1/exports/... HTTP paths; the owning host tests check each entry’s method and template against that table.
Withdrawal and advertised-route correction verified against c5cc6723ce. Recheck the compiled-type absence with pwsh -NoProfile -File tools/scripts/test-targeted-xunit.ps1 -Project src/OfflineKit/__Tests/StellaOps.ExportCenter.Tests/StellaOps.ExportCenter.Tests.csproj -Class "*DeprecationWithdrawalTests" -BuildProjectReferences -Restore, and the served route entries with the owning host’s ExportSurfacingClientRouteTableTests through the same wrapper.
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 throughexport_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. - Risk-bundle activation.
AddRiskBundleWorkerLoop(StellaOps.ExportCenter.WorkerRuntime) composes the risk-bundle loop only whenRiskBundles:Enabledis true. Disabled, which is the shipped default, the worker registers no signer, object store, job or hosted loop, soRiskBundles:Signing:KeyandRiskBundles:Storage:RootPathare not required and a fresh install starts. Enabled, both are validated when the host starts, and a missing value fails the start withRiskBundles:Signing:Key must be configured.orRiskBundles:Storage:RootPath must be configured.There is no unsigned risk-bundle mode.
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 advisory:ingest and vex:ingest (claim values in VulnerabilityMirrorSeedExchangeContract, DAY-7c); 30 min client timeout |
Activators authenticate as the stellaops-offlinekit-activator client with client credentials from OfflineKit:ScannerActivation and OfflineKit:VulnerabilitiesActivation ({Scanner,Vulnerabilities}BaseAddress, Authority, TokenEndpoint) plus the secret in OFFLINEKIT_ACTIVATOR_AUTHORITY_CLIENT_SECRET, read from the process environment. The repository overlay supplies it through .local-secrets/offlinekit-activator-authority.env; the release bundle passes the same .env value to authority, offlinekit-web and offlinekit-worker.
The release installer’s offline vulnerability-data seed (SPRINT_20260925_004 DAY-7, devops/release/bundle/install.sh step 8b) does not use this pipeline: the installer mints a token for the same activator client and posts the seed straight to the hub’s import route. 6. Ledgers and status. /status reports the latest verified carrier and parts[]; contentActivated is derived from parts and never from the carrier verdict. bundle_version_history is the append-audit table reserved for activation history. The service does not enforce import version monotonicity server-side: VersionMonotonicityChecker is used by the CLI with its file-based version store, and no OfflineKit host registers a version store. The baseline’s bundle_versions table, which claimed that gate and which nothing wrote or read, is retired by 013 (guarded: a populated table stops startup with SQLSTATE 55000). 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_incidents is operational-state since 012: only Resolved (4) and FalsePositive (5) incidents are reclaimed, by age on last_updated_at and by a newest-first cap that counts terminal incidents only, and a reclaimed incident’s update trail cascades with it |
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; read by tenant and job ID, and an upsert never changes a row’s tenant |
risk_bundle_jobs, risk_bundle_job_events | risk-bundle jobs and their event trail (events partitioned by 006) |
mirror_run_cursors (007, retention 011) | resume position per (tenant, profile, source), replaced only by a succeeded mirror run; cascades with the profile; cursors for sources a profile no longer declares are pruned after a successful run; budget Export:Retention:MirrorCursors:MaxCursorsPerProfile (code default 512), measured by doctor.offlinekit.retention.mirror-cursor-budget |
export_run_tenants (014) | RLS-free tenant-id registry filled atomically by export-run inserts; the worker binds each tenant before querying its FORCE-protected runs; no run content and no age prune while run history preserves custody |
export_center_app: no tables; require_current_tenant(), update_updated_at(), register_export_run_tenant() and tenants_with_pending_runs(status).
Migration 014 corrects the applied 007 function’s FORCE-RLS assumption without editing its SQL. SECURITY DEFINER does not exempt a NOBYPASSRLS table owner from FORCE policies. Enumeration now reads registry keys and tests the requested status under each tenant’s bound context, then restores the caller’s context. The forward startup backfill takes an exclusive run-table lock and temporarily lifts FORCE only if it was already set; it restores that posture before the migration transaction and ledger commit. The owner never gains BYPASSRLS. The shipped table posture is otherwise preserved. Fresh and upgrade tests exercise this through the real PostgresExportRunRepository under a NOSUPERUSER NOBYPASSRLS owner.
offlinekit (baseline 001, custody 002, parts 003, states 008; 013 retires bundle_versions):
| Table | Purpose |
|---|---|
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) |
Incident retention (SPRINT_20260916_001 RFC-17, 2026-09-23). export_incidents carried a source-of-truth label from 001, but its manager has always deleted resolved incidents, so the P13 census read it as operational state by shape with no proven obligations. Before RFC-17 the count cap ranked every incident, so more than MaxStoredIncidents incidents in total made the next read delete the least recently updated ones, open or resolved. Both branches are now terminal-only, the budget is bound from Export:Retention:Incidents:*, and the Doctor budget check measures an incidents family. Re-verify:
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/OfflineKit/__Tests/StellaOps.ExportCenter.Tests/StellaOps.ExportCenter.Tests.csproj -Class "*ExportIncidentRetentionTests" -BuildProjectReferences
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/OfflineKit/__Tests/StellaOps.OfflineKit.WebService.Tests/StellaOps.OfflineKit.WebService.Tests.csproj -Method "*RetentionBudgets*" -BuildProjectReferences
python tools/scripts/validate/check-p13-retention.py --repo-root . --tables
Migrations 004, 005, 009, 011 and 012 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 (compose default false; the release bundle’s .env.example sets true) |
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,Incidents}:* | OFFLINEKIT_RETENTION_*; measured by doctor.offlinekit.retention.bounded-state-budgets. Incidents:{RetentionPeriod,MaxStoredIncidents} default to 1 day and 200 terminal incidents |
Export:Retention:MirrorCursors:MaxCursorsPerProfile | mirror-cursor budget; code default 512, reported absent until declared |
Export:Runs:StorageRoot, OfflineKit:OwnedState:ExportVolumeRoot | measurement roots for the export-volume Doctor states; OFFLINEKIT_EXPORT_RUN_STORAGE_ROOT, OFFLINEKIT_EXPORT_VOLUME_ROOT |
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 (the web role’s healthcheck uses /health/ready for both) |
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).
- Exception-report tenant binding (PCD-871). All four exception-report routes (section 3.1) take the caller’s tenant from the authenticated principal’s
stellaops:tenantclaim through the tenant resolver; no header, query or body names a tenant, and a principal without one gets 400tenant_required. Status and download read the job by tenant and job ID (ExceptionReport/ExceptionReportJobStore.cs), so another tenant’s job ID answers the same 404 as an unknown ID: no body for status, the genericreport_not_found_or_not_readyerror for download. A later write never moves a job to another tenant; the durable store refuses it. Verified against7d1fc4768f; re-runpwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/OfflineKit/__Tests/StellaOps.OfflineKit.WebService.Tests/StellaOps.OfflineKit.WebService.Tests.csproj -Class "*ExceptionReportTenantBindingTests". - 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.
Offline carrier test boundaries
Test source verified against 039164bd8e (PCD-360, 2026-10-04); re-run the commands below.
src/__Tests/offline/StellaOps.Offline.E2E.Tests/OfflineKitRoundTripTests.cs uses OfflineKitCarrierWriter and the serving OfflineKitDsseVerifier, replacing the withdrawn snapshot writer/reader. It checks both signed subjects, exact manifest payload binding, public-trust mismatch, unsigned-envelope refusal, archive tampering, every declared part’s bytes/digest and deterministic archive/manifest bytes. ECDSA envelope bytes are not required to be deterministic. The fixture provisions ephemeral keys through the existing crypto provider and purpose resolver; only public trust is written to disk.
This local artifact round-trip does not claim host or database acceptance. The owner’s OfflineKitImportEndpointsTests separately runs the shared writer’s carrier through the HTTP import, real PostgreSQL custody ledger and the owner activation seam. Re-run the complete Offline.E2E and AirGap.Bundle test projects with the canonical tools/scripts/test-targeted-xunit.ps1 -All -BuildProjectReferences -Restore wrapper, then run StellaOps.OfflineKit.WebService.Tests with -Class "*OfflineKitImportEndpointsTests" -BuildProjectReferences -Restore.
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.
- Format records under
../export-center/: profiles.md, determinism.md, provenance-and-signing.md, mirror-bundles.md, devportal-offline.md, registry-compatibility.md, timestamp-trust-bundle-v1.md, cli.md, operations/. - Export storage measurement: operations/export-storage-measurement.md.
- 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 (sealed and offline operation) and Mirror (retained format records).
