OfflineKit — importing a signed carrier

Verified against the running estate on 2026-09-14 (SPRINT_20260722_025 OK-7/OK-10 close-out, evidence docs/implplan/_evidence/20260914-ok-closeout/). Re-verify with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/OfflineKit/__Tests/StellaOps.OfflineKit.WebService.Tests/StellaOps.OfflineKit.WebService.Tests.csproj -Class "*OfflineKitImportEndpointsTests*".

This runbook is the operator path for stella airgap import: what a carrier is, what the custody plane checks, what each exit code means, and how the posture gate behaves on a sealed estate.

1. What a carrier is

Four files, produced by whoever owns the content (an export run, a mirror profile, or an operator tool):

FileContentVerified by
offline-kit.tar.gzThe bundle: one entry per part, at the path the manifest namesits sha256 is a subject of the bundle statement
manifest.jsonOfflineKitCarrierManifest (application/vnd.stellaops.offlinekit.manifest.v1+json): bundle{bundleId, channel, kind, capturedAt, isDelta, baseBundleId, producer} + artifacts[]{name, sha256, size, domain, mediaType, generation}its sha256 is a subject of the bundle statement; its bytes are the payload of the manifest envelope
bundle.dsse.jsonDSSE envelope over an in-toto v1 statement whose subjects are bundle and manifest (predicate type https://stella-ops.org/attestations/offlinekit-carrier/v1)the operator trust key
manifest.dsse.jsonDSSE envelope over the manifest bytes (payload type = the manifest media type)the operator trust key

The trust key is the operator-mounted public key at OfflineKit:Import:TrustedPublicKeyPath (P-256/ES256 or RSA/PS256 SubjectPublicKeyInfo). Signatures are ES256 over the DSSE PAE with IEEE P1363 r||s encoding. Nothing inside the carrier can authorize its own import.

The model is src/OfflineKit/__Libraries/StellaOps.AirGap.Bundle/Models/OfflineKitCarrierManifest.cs; its Create/Serialize produce the exact bytes to sign.

1a. Get a carrier from a completed export run (SPRINT_20260914_002 EA-1)

Every export run completed by a worker with a carrier signing key configured IS a carrier. Pull its five files with the run id (the same identity and tenant rules as the import apply; export.viewer is the scope):

stella airgap pull --run <runId> --output ./carrier-<runId> --json
# GET /api/offlinekit/v1/exports/runs/{runId}/carrier -> offline-kit.tar.gz, manifest.json,
# bundle.dsse.json, manifest.dsse.json, offline-kit.tar.gz.metadata.json

A 404 means the run does not exist for this tenant, has not completed, or the worker wrote no carrier — its error_json then reads EXPORT_RUN_CARRIER_NOT_WRITTEN (no Export:Carrier:SigningKeyId on the worker). To produce a carrier whose part ACTIVATES on import, declare an owner-part input on the profile, e.g. Scanner’s artifact-BOM package:

{ "inputs": [ { "kind": "owner-part", "sourceRef": "scanner:artifact-boms", "tenant": "default" } ] }

2. Run the import

export STELLAOPS_BACKEND_URL=https://stella-ops.local
export STELLAOPS_AUTHORITY_URL=https://stella-ops.local
export STELLAOPS_AUTHORITY_CLIENT_ID=stellaops-cli
export STELLAOPS_AUTHORITY_PROVIDER=standard
export STELLAOPS_AUTHORITY_TENANT=default
export STELLAOPS_TENANT=default            # overrides ~/.stellaops/profile.json activeTenant
export STELLAOPS_AUTHORITY_SCOPE="openid airgap:import airgap:status:read"
# STELLAOPS_AUTHORITY_USERNAME / STELLAOPS_AUTHORITY_PASSWORD from the operator's secret store

stella airgap import --bundle offline-kit.tar.gz --manifest manifest.json \
  --bundle-signature bundle.dsse.json --manifest-signature manifest.dsse.json --json

With a sidecar <bundle>.metadata.json beside the bundle (manifestPath, bundleSignaturePath, manifestSignaturePath) the three path options can be omitted.

Tenant. The import lands under the tenant of the authenticated identity. A global-admin operator with a persisted ~/.stellaops/profile.json sends that profile’s tenant as the tenant-switch header and the import goes THERE, silently. Set STELLAOPS_TENANT explicitly before importing on behalf of a tenant. A Scanner part whose package names another tenant is refused with SCANNER_PART_TENANT_MISMATCH (422).

3. Exit codes

ExitMeaningWhere it comes from
0Carrier verified, parts admitted; status names the activation outcome per partofflinekit.bundle_imports row verified, bundle_import_parts activated
2The custody plane read the carrier and REFUSED it; the message carries the service’s reason_code (e.g. VERIFICATION_FAILED, SEALED_IMPORT_TIME_ANCHOR_MISSING, SCANNER_PART_TENANT_MISMATCH)HTTP 400/409/413/422/507
1The call itself failed: credentials, no grant, transport, or a server faultHTTP 401/403/5xx or no response
4Operator input incomplete; nothing was sentmissing file or path

--json changes the renderer only; the exit code is identical in both modes.

4. The posture gate

The import reads Platform’s airgap-seal environment state for scope installation (stella airgap status). Unsealed or undeclared: the import proceeds. Sealed: the import is freshness-gated and REQUIRES a declared trusted-time anchor (stella airgap time-anchor declare --token <rfc3161.der> --trust-roots <chain.pem>); without one it is refused with 409 SEALED_IMPORT_TIME_ANCHOR_MISSING, with a stale or unreadable one with the sibling codes. Declaring the anchor needs an Authority client holding envstate:time-anchor:write; stellaops-cli holds it since SPRINT_20260914_002 EA-5 (migration S060, standard.yaml). The gate does not take the declaration on faith: offlinekit-web re-verifies the stored RFC3161 proof against the estate timestamp authority’s PUBLIC chain (OFFLINEKIT_TIME_ANCHOR_TRUST_ROOTS_PATH, bound to Crypto:PublicTrust:CertificateBundles:timestamp-authority by the mounted devops/etc/offlinekit/appsettings.Production.json — a JSON file because the image’s shell entrypoint drops an environment variable whose name carries that hyphen). Without that bundle a sealed import is refused SEALED_IMPORT_TIME_ANCHOR_UNREADABLE (time-anchor-trust-roots-unreadable) even though stella airgap time-anchor status shows the anchor as declared — measured live on 2026-09-14. The host’s posture replica polls Platform every 15 s, so a seal or an anchor declared seconds before an import may not be visible to it yet.

5. What “activated” means

Verification admits the CARRIER; each declared part is then handed to its domain’s activator by (domain, mediaType, generation) and the ledger records the owner’s receipt:

A part with no registered activator is recorded failed with PART_ACTIVATOR_NOT_REGISTERED while the carrier stays verified. Read the outcome with stella airgap status or GET /api/offlinekit/v1/status.

6. Observing it