OfflineKit — importing a signed carrier
Verified against the running estate on 2026-09-14 (
SPRINT_20260722_025OK-7/OK-10 close-out, evidencedocs/implplan/_evidence/20260914-ok-closeout/). Re-verify withpwsh ./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):
| File | Content | Verified by |
|---|---|---|
offline-kit.tar.gz | The bundle: one entry per part, at the path the manifest names | its sha256 is a subject of the bundle statement |
manifest.json | OfflineKitCarrierManifest (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.json | DSSE 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.json | DSSE 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
| Exit | Meaning | Where it comes from |
|---|---|---|
| 0 | Carrier verified, parts admitted; status names the activation outcome per part | offlinekit.bundle_imports row verified, bundle_import_parts activated |
| 2 | The 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 |
| 1 | The call itself failed: credentials, no grant, transport, or a server fault | HTTP 401/403/5xx or no response |
| 4 | Operator input incomplete; nothing was sent | missing 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:
scanner/application/vnd.stellaops.scanner.artifact-boms.v1+json/1→ Scanner’sPOST /api/internal/v1/offlinekit/artifact-boms/activate(receipt: total/inserted/unchanged rows)vulnerabilities/ mirror-seed media type /1→ the Vulnerabilities mirror-seed activator
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
- Doctor:
GET /doctor/offlinekit/checks(scopeops.health) — the owned-state check reports the carrier CAS bytes. - Ledger:
offlinekit.bundle_imports,offlinekit.bundle_import_parts,offlinekit.bundle_import_part_eventsinstellaops_offlinekit. - Evidence of a full run, positive and negative:
docs/implplan/_evidence/20260914-ok-closeout/.
