Offline Kit (OUK) — Air-Gap Bundle

Audience: operators deploying or updating Stella Ops on air-gapped or otherwise network-restricted hosts.

Repository verification. Verified against source commit a59d4693d6 (re-verified 2026-09-14). Re-verify custody and Scanner-part activation with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/OfflineKit/__Tests/StellaOps.OfflineKit.WebService.Tests/StellaOps.OfflineKit.WebService.Tests.csproj -Method "*OfflineKitImport*", the bounded atomic-promotion policy with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/OfflineKit/__Tests/StellaOps.OfflineKit.WebService.Tests/StellaOps.OfflineKit.WebService.Tests.csproj -Class "*OfflineKitAtomicDirectoryMoveTests*", the bounded Scanner activator with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/OfflineKit/__Tests/StellaOps.OfflineKit.WebService.Tests/StellaOps.OfflineKit.WebService.Tests.csproj -Method "*ScannerArtifactBomActivatorTests*", and the service graph with pwsh ./tools/scripts/build-boundary/generate-build-boundary-report.ps1 -Check.

Ownership and implementation status (Q-25, owner 2026-08-27; re-stated 2026-09-14 after the OK-10 window). OfflineKit is the sole canonical HTTP owner for import/status/manifest/validate at /api/offlinekit/v1. It owns verified carrier handling, quarantine/CAS, bundle lifecycle and its import ledger. A carrier-level verified custody row and manifest-derived artifact declarations are custody, not activation. Failed verification removes its temporary quarantine and creates no verified ledger row.

It is now deployed. The 2026-09-13 consolidation window (SPRINT_20260722_025 OK-10) stopped export-web, export-worker and airgap-controller and moved both planes onto offlinekit-web/offlinekit-worker. Live probe 2026-09-14 through the gateway: /api/offlinekit/v1/status401, /api/offlinekit/v1/exports/profiles401 (served and auth-gated); the retired bare path /v1/exports404. The schema moved with them — stellaops_offlinekit holds export_center, export_center_app and offlinekit, while the predecessor stellaops_exportcenter is retained untouched as the window’s rollback.

It still does not own every payload database. Domain content is activated by its owner through a bounded owner API, and OfflineKit records quarantined → staged → activated|failed per part. Two activators are registered: scanner/artifact-boms.v1.json (Scanner) and vulnerabilities/mirror-seed.v1.ndjson (Vulnerabilities, added at OK-7V, which also replaced the Scanner-hardcoded manifest validator with one driven off the activator registry). Aggregate contentActivated is a real computed field — ANY part activated, not a placeholder.

What is built but NOT yet observed live: Vulnerabilities mirror-seed activation. The stellaops-offlinekit-activator client cannot currently authenticate to Authority, so the end-to-end activation has not been demonstrated on this estate. Also still open: orphan-CAS reconciliation and transparency verification. The separate CLI-local stella airgap import / MirrorBundleImportService path is not this HTTP plane and remains explicit convergence debt.

Signer-custodied package producer. Verified against source commit 68e4d27cd59fb7a7eded6886421f268bf62aaebd. Re-verify with python -m unittest devops/offline/kit/test_build_offline_kit.py -v, pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Attestor/StellaOps.Signer/StellaOps.Signer.Tests/StellaOps.Signer.Tests.csproj -Method "*SignDsse_*", and pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/OfflineKit/__Tests/StellaOps.OfflineKit.WebService.Tests/StellaOps.OfflineKit.WebService.Tests.csproj -Method "*OfflineKitDsseVerifierTests*".

The Offline Kit (historically the Offline Update Kit, “OUK”) packages everything Stella Ops needs to run on a completely isolated network:

ComponentContents
Merged vulnerability feedsOSV, GHSA plus optional NVD 2.0, CNNVD, CNVD, ENISA, JVN and BDU
Container imagesstella-ops, Zastava sidecar, advisoryai-web, and advisoryai-worker (x86-64 & arm64)
ProvenanceCosign signature, SPDX 2.3 SBOM, in-toto SLSA attestation
Builder manifestoffline-manifest.json; when a Signer URL is configured, the builder obtains the bundle and manifest DSSE envelopes from Signer and emits the portable sidecars consumed by the canonical import API (see sections 0.1 and 2).
Signed release manifestmanifest/product-update-manifest.dsse.json plus release trust roots under manifest/trust-roots/ for offline update-channel verification.
Delta patchesDaily diff bundles keep size < 350 MB
Scanner plug-insOS analyzers plus the Node.js, Go, .NET, Python, Ruby, Rust, and PHP language analyzers packaged under plugins/scanner/analyzers/** with manifests so Workers load deterministically offline.
Debug store.debug artefacts laid out under debug/.build-id/<aa>/<rest>.debug with debug/debug-manifest.json mapping build-ids to originating images for symbol retrieval.
Secret Detection RulesDSSE-signed rule bundles under rules/secrets/<version>/ with manifest, JSONL rules, and signature envelope for air-gapped secret leak detection.
Telemetry collector bundletelemetry/telemetry-offline-bundle.tar.gz plus .sha256, containing OTLP collector config, Helm/Compose overlays, and operator instructions.
CLI + Task Packscli/ binaries from release/cli, Task Runner bootstrap (bootstrap/task-runner/task-runner.yaml.sample), and task-pack docs under docs/modules/packs-registry/guides/** + docs/modules/taskrunner/**.
Orchestrator/Export/Notifier kitsOrchestrator service, worker SDK, Postgres snapshot, dashboards (jobengine/**), Export Center bundles (export-center/**), Notifier offline packs (notifier/**).
Container air-gap bundlesAny tar/tgz under containers/ or images/ (mirrored registries) plus docs/modules/airgap/guides/mirror-bundles.md.
Surface.SecretsEncrypted secrets bundles and manifests (surface-secrets/**) for sealed-mode bootstrap.

RU BDU note: ship the official Russian Trusted Root/Sub CA bundle (certificates/russian_trusted_bundle.pem) inside the kit so concelier:httpClients:source.bdu:trustedRootPaths can resolve it when the service runs in an air-gapped network. Drop the most recent vulxml.zip alongside the kit if operators need a cold-start cache.

Language analyzers: the kit now carries the restart-only Node.js, Go, .NET, Python, Ruby, Rust, and PHP plug-ins (plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Node/, ...Lang.Go/, ...Lang.DotNet/, ...Lang.Python/, ...Lang.Ruby/, ...Lang.Rust/, ...Lang.Php/). Drop the directories alongside Worker binaries so the unified plug-in catalog can load them without outbound fetches.

Ruby analyzer features:

The PHP analyzer parses composer.lock for Composer dependencies and supports optional runtime evidence via the stella-trace.php shim; set STELLA_PHP_OPCACHE=1 to enable opcache statistics collection.

Secret Detection Rules:

The Offline Kit includes DSSE-signed rule bundles for secret leak detection, enabling fully offline scanning for exposed credentials, API keys, and other sensitive data.

Bundle Structure:

rules/secrets/<version>/
  secrets.ruleset.manifest.json    # Bundle metadata (version, rule count, signer)
  secrets.ruleset.rules.jsonl      # Rule definitions (one JSON per line)
  secrets.ruleset.dsse.json        # DSSE signature envelope
  SHA256SUMS                       # File checksums

Manifest Format:

{
  "bundleId": "secrets.ruleset",
  "bundleType": "secrets",
  "version": "2026.01",
  "ruleCount": 150,
  "signerKeyId": "stellaops-secrets-signer",
  "signedAt": "2026-01-04T00:00:00Z",
  "files": [
    {
      "name": "secrets.ruleset.rules.jsonl",
      "digest": "sha256:...",
      "sizeBytes": 45678
    }
  ]
}

Installation:

# Verify bundle signature using local attestor mirror
export STELLA_ATTESTOR_URL="file:///mnt/offline-kit/attestor-mirror"
devops/offline/scripts/install-secrets-bundle.sh \
  /mnt/offline-kit/rules/secrets/2026.01 \
  /opt/stellaops/plugins/scanner/analyzers/secrets

Bundle Rotation:

# Upgrade to new version with automatic backup
devops/offline/scripts/rotate-secrets-bundle.sh \
  /mnt/offline-kit/rules/secrets/2026.02

Enable Feature:

scanner:
  features:
    experimental:
      secret-leak-detection: true

Verify Bundle is Loaded:

kubectl logs -l app=scanner-worker --tail=100 | grep SecretsAnalyzerHost
# Expected: SecretsAnalyzerHost: Loaded bundle 2026.01 with 150 rules

Python analyzer features:

Surface.Env configuration: Scanner Worker and WebService components use the Surface.Env library for configuration discovery. In air-gapped deployments, configure the following environment variables (see docs/modules/scanner/design/surface-env.md for details):

VariableDescriptionAir-gap Default
SCANNER_SURFACE_FS_ENDPOINTBase URI for Surface.FS / RustFS storagehttp://rustfs:8080
SCANNER_SURFACE_FS_BUCKETBucket for manifests/artefactssurface-cache
SCANNER_SURFACE_CACHE_ROOTLocal cache directory/var/lib/stellaops/surface
SCANNER_SURFACE_CACHE_QUOTA_MBCache quota in MB (64-262144)4096
SCANNER_SURFACE_PREFETCH_ENABLEDEnable manifest prefetchfalse
SCANNER_SURFACE_TENANTTenant namespacedefault
SCANNER_SURFACE_SECRETS_PROVIDERSecrets provider (file, kubernetes)file
SCANNER_SURFACE_SECRETS_ROOTRoot path for file provider/etc/stellaops/secrets
SCANNER_SURFACE_SECRETS_ALLOW_INLINEAllow inline secretsfalse

For Helm deployments, configure via values.yaml:

surface:
  fs:
    endpoint: "http://rustfs:8080"
    bucket: "surface-cache"
  cache:
    root: "/var/lib/stellaops/surface"
    quotaMb: 4096
  tenant: "default"
  secrets:
    provider: "file"
    root: "/etc/stellaops/secrets"

For Docker Compose, these variables are pre-configured in docker-compose.airgap.yaml with sensible defaults.

Advisory AI volume primer: ship a tarball containing empty queue/, plans/, and outputs/ directories plus their ownership metadata. During import, extract it onto the RWX volume used by advisoryai-web and advisoryai-worker so pods start with the expected directory tree even on air-gapped nodes.

Scanner core: C# 12 on .NET 10 LTS. Imports are idempotent by verified carrier identity and require no service downtime. CAS promotion and lifecycle-ledger persistence are not a cross-resource atomic operation; the failure and reconciliation boundary is described in Import custody.

0 · Prepare the debug store

Before packaging the Offline Kit, mirror the release debug artefacts (GNU build-id .debug files and the associated manifest) into the staging directory:

./ops/offline-kit/mirror_debug_store.py \
  --release-dir out/release \
  --offline-kit-dir out/offline-kit

The helper copies debug/.build-id/**, validates debug/debug-manifest.json against its recorded SHA-256, and writes out/offline-kit/metadata/debug-store.json with a short summary (platforms, artefact counts, sample build-ids). The command exits non-zero if an artefact referenced by the manifest is missing or has the wrong digest, so run it as part of every kit build.


0.1 · Automated packaging

The packaging workflow is scripted via devops/offline/kit/build_offline_kit.py. It verifies the release artefacts, runs the Python analyzer smoke suite, mirrors the debug store, and emits a metadata-normalized tarball for an unchanged staging tree plus its manifest set.

What it picks up automatically (if present under --release-dir):

python devops/offline/kit/build_offline_kit.py \
  --version 2025.10.0 \
  --channel edge \
  --release-dir out/release \
  --staging-dir out/offline-kit/staging \
  --output-dir out/offline-kit/dist

# Optional: regenerate the telemetry collector bundle prior to packaging.
python ops/devops/telemetry/package_offline_bundle.py --output out/telemetry/telemetry-offline-bundle.tar.gz

To carry a completed vulnerability generation, first resolve the opaque current publication from GET /api/vulnerabilities/v1/corpus/publications/current, download its exact manifest and every declared section href, and save each section as <section-name>.ndjson. Then add both inputs:

  --vulnerability-corpus-manifest ./corpus/manifest.json \
  --vulnerability-corpus-sections-dir ./corpus/sections

The builder requires the pair, validates the hub manifest version, completed generation, required section set, byte lengths and SHA-256 digests, and records every part as domain vulnerabilities in the signed OfflineKit manifest. It never reserializes the hub bytes. Signer custody remains the existing offlinekit-builder purpose described below.

Published outputs:

Policy Engine configuration bundle (formerly Policy Gateway)

Note: The Policy Gateway service has been merged into Policy Engine. All gateway endpoints (exceptions, deltas, gates, governance, tool-lattice) are now served directly by the policy-engine process.

To generate both canonical import envelopes, stage the dedicated Authority client, typed producer allowlist, PoE entitlement and route-scoped DPoP configuration in the Signer-custody runbook. Set the gateway origin with --signer-url (or STELLAOPS_OFFLINEKIT_SIGNER_URL). When no pre-minted bearer token is supplied, the builder mints the fixed signer:read signer:sign client-credentials token at --authority-token-url using the same operator-held P-256 key as every Signer request proof. The P-256 key authenticates the client; it is not the artifact-signing key. Signer retains artifact-key custody, verifies the exact offlinekit-builder source digest, signs each envelope, and verifies its own response before the builder writes a sidecar. No transparency-log bypass or local artifact signing mode exists in this path.


1 · Download & verify

curl -LO https://get.stella-ops.org/ouk/stella-ops-offline-kit-<version>-<channel>.tar.gz
curl -LO https://get.stella-ops.org/ouk/stella-ops-offline-kit-<version>-<channel>.tar.gz.sha256
curl -LO https://get.stella-ops.org/ouk/stella-ops-offline-kit-<version>-<channel>.tar.gz.dsse.json
curl -LO https://get.stella-ops.org/ouk/stella-ops-offline-kit-<version>-<channel>.manifest.json
curl -LO https://get.stella-ops.org/ouk/stella-ops-offline-kit-<version>-<channel>.manifest.json.dsse.json

sha256sum -c stella-ops-offline-kit-<version>-<channel>.tar.gz.sha256

CLI shortcut. A carrier written by a completed export run is pulled with stella airgap pull --run <runId> --output ./offline-kit (SPRINT_20260914_002 EA-1): it downloads the bundle, manifest and both DSSE envelopes from the run’s carrier descriptor and writes the .metadata.json sidecar stella airgap import --bundle resolves the siblings from. The former offline kit pull is retired — its descriptor route was never served after the predecessor plane went.

The checksum commands are transport-integrity prechecks, not authenticity verification. Canonical authenticity verification occurs during offline kit import, against the target installation’s operator-configured public key. Cross-check the expected digest against the changelog.

Inspect the manifest before distribution:

jq '{capturedAt: .bundle.capturedAt, artifacts: [.artifacts[] | {name, sha256, size}]}' \
  offline-manifest.json

The manifest records one bundle-level bundle.capturedAt timestamp and enumerates every artefact with name, sha256, and size. It is signed with the same key registry as Authority revocation bundles. Operators can ship the manifest alongside the tarball so downstream mirrors can re-verify without unpacking the kit.

The product update manifest is a separate Signer DSSE envelope that binds the release version, image digests, SBOM references, support policy, and fixed advisory list. Before importing an OUK, verify it without network access:

stella verify release-manifest \
  ./manifest/product-update-manifest.dsse.json \
  --trust-root ./manifest/trust-roots \
  --json

build_offline_kit.py copies Signer-produced manifests named product-update-manifest.dsse.json, product-update-manifest.v1.dsse.json, or release-manifest.dsse.json from the release output into manifest/. It also copies release trust roots named product-update-trust-root.pem, release-trust-root.pem, or trust-root.pem into manifest/trust-roots/. The bundle metadata exposes counts.releaseManifestSignatures and counts.releaseTrustRoots so operators can reject incomplete release payloads before unpacking.

Example excerpt (2025-10-23 kit) showing the Go and .NET analyzer plug-in payloads:

{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Go/StellaOps.Scanner.Analyzers.Lang.Go.dll",
  "sha256": "a6dc850fc51151c8967ef46a3c4730f08b549667e041079431f39a8a72d0b641",
  "size": 33792,
  "capturedAt": "2025-10-23T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Go/StellaOps.Scanner.Analyzers.Lang.Go.pdb",
  "sha256": "6cbdabf155282f458b89edf267e7f6bb2441a93029aad7aad45c8a9ec58b1b3b",
  "size": 32152,
  "capturedAt": "2025-10-23T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Go/manifest.json",
  "sha256": "c19bfca2fcbb7cb18f1082b5d0d5a8f15fc799c648b50e95fce8d8b109ce48c9",
  "size": 622,
  "capturedAt": "2025-10-23T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.DotNet/StellaOps.Scanner.Analyzers.Lang.DotNet.dll",
  "sha256": "0734d23e33277ce2ccb596782d2d42cfe394b3d372dc34da9cb28b59df9b9d22",
  "size": 70144,
  "capturedAt": "2025-10-23T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.DotNet/StellaOps.Scanner.Analyzers.Lang.DotNet.pdb",
  "sha256": "b853c1ff4b196715f5bd1447e1a13edeb4940917527ec9bf153b5048da49abaf",
  "size": 40400,
  "capturedAt": "2025-10-23T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.DotNet/manifest.json",
  "sha256": "5d483885f825f01bfd9943dcf2889ec2e0beba38ede92ecfe67d4f506cf14e37",
  "size": 647,
  "capturedAt": "2025-10-23T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Python/StellaOps.Scanner.Analyzers.Lang.Python.dll",
  "sha256": "a4f558f363394096e3dd6263f35b180b93b4112f9cf616c05872da8a8657d518",
  "size": 47104,
  "capturedAt": "2025-10-26T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Python/StellaOps.Scanner.Analyzers.Lang.Python.pdb",
  "sha256": "ef2ad78bc2cd1d7e99bae000b92357aa9a9c32938501899e9033d001096196d0",
  "size": 31896,
  "capturedAt": "2025-10-26T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Python/manifest.json",
  "sha256": "668ad9a1a35485628677b639db4d996d1e25f62021680a81a22482483800e557",
  "size": 648,
  "capturedAt": "2025-10-26T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.dll",
  "sha256": "<computed-at-release>",
  "size": 0,
  "capturedAt": "2025-11-27T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.pdb",
  "sha256": "<computed-at-release>",
  "size": 0,
  "capturedAt": "2025-11-27T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Ruby/manifest.json",
  "sha256": "<computed-at-release>",
  "size": 0,
  "capturedAt": "2025-11-27T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Rust/StellaOps.Scanner.Analyzers.Lang.Rust.dll",
  "sha256": "d90ba8b6ace7d98db563b1dec178d57ac09df474e1342fa1daa38bd55e17b185",
  "size": 54784,
  "capturedAt": "2025-11-01T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Rust/StellaOps.Scanner.Analyzers.Lang.Rust.pdb",
  "sha256": "6fac88640a4980d2bb8f7ea2dd2f3d0a521b90fd30ae3a84981575d5f76fa3df",
  "size": 36636,
  "capturedAt": "2025-11-01T00:00:00Z"
}
{
  "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Rust/manifest.json",
  "sha256": "1ec47d1a2103ad5eff23e903532cb76b1ed7ded85d301c1a6631ff21aa966ed4",
  "size": 658,
  "capturedAt": "2025-11-01T00:00:00Z"
}

2 · Import on the air-gapped host

docker compose --env-file .env \
  -f docker-compose.stella-ops.yml \
  exec stella-ops \
  stella admin import-offline-usage-kit stella-ops-offline-kit-<version>-<channel>.tar.gz

Alternatively, run

stellaops-cli offline kit import stella-ops-offline-kit-<version>-<channel>.tar.gz \
  --manifest stella-ops-offline-kit-<version>-<channel>.manifest.json \
  --bundle-signature stella-ops-offline-kit-<version>-<channel>.tar.gz.dsse.json \
  --manifest-signature stella-ops-offline-kit-<version>-<channel>.manifest.json.dsse.json

The two signature options above are operator-trusted DSSE envelopes. The bundle envelope uses application/vnd.in-toto+json and a strict in-toto Statement v1 profile whose exactly two named subjects, bundle and manifest, bind both uploaded SHA-256 digests under predicate type https://stella-ops.org/attestations/offlinekit-carrier/v1. The manifest envelope uses application/vnd.stellaops.offlinekit.manifest.v1+json and carries the exact manifest bytes. Each envelope contains exactly one signature. These are the canonical API inputs, not detached Cosign signatures. The builder submits the carrier statement and exact canonical manifest bytes to Signer with typed producer integrity and a PoE, rejects a response that Signer cannot verify, and writes Signer’s canonical 64-byte P1363 ES256 signatures into the two portable envelopes. It also records both sibling sidecar names in bundle metadata. The builder converts DER to P1363 only while minting its DPoP authentication proofs; the artifact-signing key never leaves Signer. Verified against 68e4d27cd59fb7a7eded6886421f268bf62aaebd; use the re-verification commands at the top of this document.

The signature profile is selected by the operator-trusted SubjectPublicKeyInfo key and is closed:

The in-tree builder accepts only Signer responses using the ES256 profile. A different producer may supply a conforming RSA-PSS envelope, but it must still satisfy the canonical import trust contract.

Import verification needs no live Signer, but it requires an absolute operator-owned public-key file at OfflineKit:Import:TrustedPublicKeyPath (and optionally pins OfflineKit:Import:TrustedKeyId). Uploaded or bundle-carried keys/certificates cannot authorize their own carrier; they remain content for the owning domain to verify after custody succeeds.

The backend offline kit import client validates recorded digests (when .metadata.json is present) before streaming the multipart payload and both DSSE envelopes to the canonical /api/offlinekit/v1/import endpoint.

Import custody

2.1 Validation, staging and domain-owner activation

The Q-25 contract separates carrier custody from payload-owner activation:

  1. Carrier acceptance (OfflineKit-owned): authenticate the request, verify that the strict carrier statement binds both uploaded bundle and manifest digests, verify that the manifest DSSE carries the exact supplied manifest bytes under its OfflineKit-specific payload type, process the files in temporary quarantine, place the verified carrier in OfflineKit CAS, and write a carrier-level verified ledger row. Current source parses every signed artifact declaration and, for the one exact Scanner artifact-BOM tuple, also extracts the referenced tar entry and verifies its bounded size, digest and owner-contract JSON before activation. Other domain declarations remain declarations until their owner adapters land. The finally path deletes the temporary quarantine on both success and failure; a verification failure creates no verified ledger row and never calls an activation adapter. A later ledger failure may retain an unreferenced verified CAS object for future reconciliation, but cannot produce verified status.
  2. Domain activation (payload-owner-owned): the exact Scanner artifact-BOM part now moves quarantined -> staged -> activated|failed and is sent with an exact-tenant token to Scanner’s owner API; the receipt is validated and retained with the part state. Vulnerability/advisory/VEX data still awaits its Vulnerabilities-owned adapter. Policies and other domains follow the same ownership rule.
  3. Honest status: status/manifest return the latest verified carrier and its signed artifact inventory. The top-level contentActivated: false is deliberately aggregate/custody-shaped; parts[] is the activation authority and exposes each handled part’s path, domain, state, failure code and update time. A missing adapter or owner refusal is never converted into success.
  4. No direct database procedure: do not run Concelier/Excititor SQL from OfflineKit. Those old runtime planes were retired by the Vulnerabilities consolidation, and direct sibling-database writes would violate ADR-039/CoC §8.2.

The Scanner artifact-BOM extractor, owner adapter and per-part state model are implemented. The generic media-type dispatch registry, Vulnerabilities/VEX adapter, orphan reconciliation and their live forcing functions remain OK-7/OK-10 work.

Verified against OfflineKitImportContracts.cs:57-100 and OfflineKitImportService.cs:187-221, the current GET /api/offlinekit/v1/status wire contract is deliberately custody-shaped: latestVerifiedCarrier contains nullable bundleId, channel, kind, isDelta, baseBundleId and capturedAt, plus bundleSha256, bundleSize and verifiedAt; the top level adds custodyStatus: "verified", aggregate contentActivated: false, manifestArtifacts, and parts[]. manifestArtifacts are signed {path,digest,sizeBytes} declarations; they are not an activation signal. For the recognized Scanner artifact-BOM tuple, parts[] reports the independently verified part as quarantined, staged, activated, or failed; other declarations have no part row until an owner adapter handles them. The current builder does not emit bundle.bundleId, bundle.kind or bundle.isDelta, so those status values remain null rather than being inferred from unsigned multipart fields or from bundle.version. There are no current, components, manifestComponents or importedAt compatibility aliases. GET /api/offlinekit/v1/manifest returns the exact verified raw manifest JSON, or 204 when no verified carrier exists.

Authority scope sanity check

Offline installs rely on the bundled etc/authority.yaml.sample. Before promoting the kit, confirm the sample clients keep the Aggregation-Only guardrails:

Authority now rejects tokens that request advisory:read, vex:read, or any signals:* scope without aoc:verify; the sample has been updated to match. Air-gap scopes (airgap:*) also require an explicit tenant assignment—match the updated roles (airgap-viewer, airgap-operator, airgap-admin) so automation fails closed when misconfigured.

Quick smoke test: before import, verify the tarball carries the Go analyzer plug-in:

tar -tzf stella-ops-offline-kit-<version>-<channel>.tar.gz 'plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Go/*' 'plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.DotNet/*' 'plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Python/*' 'plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Ruby/*' 'plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Php/*'

The manifest lookup above and this tar listing should both surface the Go analyzer DLL, PDB, and manifest entries before the kit is promoted.

Release guardrail. The automated release pipeline now publishes the Python, Ruby, Rust, and PHP plug-ins from source and executes dotnet run --project src/Tools/LanguageAnalyzerSmoke --configuration Release -- --repo-root <checkout> --analyzer <id> to validate manifest integrity and cold/warm determinism within the < 30 s / < 5 s budgets (differences versus repository goldens are logged for triage). Run ops/offline-kit/run-python-analyzer-smoke.sh, ops/offline-kit/run-ruby-analyzer-smoke.sh, ops/offline-kit/run-rust-analyzer-smoke.sh, and ops/offline-kit/run-php-analyzer-smoke.sh locally before shipping a refreshed kit if you rebuild artefacts outside CI or when preparing the air-gap bundle.

Debug store mirror

Offline symbols (debug/.build-id/**) must accompany every Offline Kit to keep symbol lookup deterministic. The release workflow is expected to emit out/release/debug/ containing the build-id tree plus debug-manifest.json and its .sha256 companion. After a release completes:

python ops/offline-kit/mirror_debug_store.py \
  --release-dir out/release \
  --offline-dir out/offline-kit \
  --summary out/offline-kit/metadata/debug-store.json

The script mirrors the debug tree into the Offline Kit staging directory, verifies SHA-256 values against the manifest, and writes a summary under metadata/debug-store.json for audit logs. If the release pipeline does not populate out/release/debug, the tooling now logs a warning (DEVOPS-REL-17-004)—treat it as a build failure and re-run the release once symbol extraction is enabled.


2.2 · Reachability & Proof Bundle Extensions

The Offline Kit supports deterministic replay and reachability analysis in air-gapped environments through additional bundle types.

Reachability Bundle Format

/offline/reachability/<scan-id>/
  ├── callgraph.json.zst         # Compressed call-graph (cg_node + cg_edge)
  ├── manifest.json               # Scan manifest with frozen feed hashes
  ├── manifest.dsse.json          # DSSE signature envelope
  ├── entrypoints.json            # Discovered entry points
  └── proofs/
      ├── score_proof.cbor        # Canonical CBOR proof ledger
      ├── score_proof.dsse.json   # DSSE signature for proof
      └── reachability.json       # Reachability verdicts per finding

Bundle contents:

FilePurposeFormat
callgraph.json.zstStatic call-graph extracted from artifactZstd-compressed JSON
manifest.jsonScan parameters + frozen Concelier/Excititor snapshot hashesJSON
manifest.dsse.jsonDSSE envelope signing the manifestJSON (in-toto DSSE)
entrypoints.jsonDiscovered entry points (controllers, handlers, etc.)JSON array
proofs/score_proof.cborDeterministic proof ledger with Merkle rootCBOR (RFC 8949)
proofs/score_proof.dsse.jsonDSSE signature attesting to proof integrityJSON (in-toto DSSE)
proofs/reachability.jsonReachability status per CVE/findingJSON

Ground-Truth Corpus Bundle

For validation and regression testing of reachability analysis:

/offline/corpus/ground-truth-v1.tar.zst
  ├── corpus-manifest.json        # Corpus metadata and sample count
  ├── dotnet/                     # .NET test cases (10 samples)
  │   ├── sample-001/
  │   │   ├── artifact.tar.gz     # Source/binary artifact
  │   │   ├── expected.json       # Ground-truth reachability verdicts
  │   │   └── callgraph.json      # Expected call-graph
  │   └── ...
  └── java/                       # Java test cases (10 samples)
      ├── sample-001/
      └── ...

Corpus validation:

stella scan validate-corpus --corpus /offline/corpus/ground-truth-v1.tar.zst

Expected output:

Proof Replay in Air-Gap Mode

To replay a scan with frozen feeds:

# Import the reachability bundle
stella admin import-reachability-bundle /offline/reachability/<scan-id>/

# Replay the score calculation
stella score replay --scan <scan-id> --verify-proof

# Expected: "Proof root hash matches: <hash>"

The replay command:

  1. Loads the frozen Concelier/Excititor snapshots from the manifest
  2. Re-executes scoring with the same inputs
  3. Computes a new proof root hash
  4. Verifies it matches the original (bit-identical determinism)

CLI Commands for Reachability

# Extract a Rust call graph locally (Rust is in-process; Go uses the packaged extractor)
stella scan graph --lang rust --target /path/to/crate --output callgraph.json

# Run reachability analysis
stella scan reachability --callgraph callgraph.json --sbom sbom.json --output reachability.json

# Package for offline transfer
stella scan export-bundle --scan <scan-id> --output /offline/reachability/<scan-id>/

2.3 · Provcache Air-Gap Integration

The Provenance Cache (Provcache) supports air-gapped environments through minimal proof bundles with lazy evidence fetching.

Proof Bundle Density Levels

DensityContentsTypical SizeAir-Gap Usage
LiteDecisionDigest + ProofRoot + Manifest~2 KBRequires lazy fetch for evidence
Standard+ First ~10% of evidence chunks~200 KBPartial evidence, lazy fetch remaining
Strict+ All evidence chunksVariableFull compliance, no network needed

Export Workflow

# Export lite bundle for minimal transfer size
stella prov export --verikey sha256:<key> --density lite --output proof-lite.json

# Export standard bundle (balanced)
stella prov export --verikey sha256:<key> --density standard --output proof-std.json

# Export strict bundle with full evidence + signature
stella prov export --verikey sha256:<key> --density strict --sign --output proof-full.json

Evidence Chunk Export for Sneakernet

For fully air-gapped environments using lite/standard bundles:

# Export all evidence chunks to directory for transport
stella prov export-chunks --proof-root sha256:<root> --output /mnt/usb/evidence/

# Output structure:
/mnt/usb/evidence/
  ├── sha256-<proof_root>/
  │   ├── manifest.json
  │   ├── 00000000.chunk
  │   ├── 00000001.chunk
  │   └── ...

Import Workflow on Air-Gapped Host

# Import with lazy fetch from file directory (sneakernet)
stella prov import proof-lite.json --lazy-fetch --chunks-dir /mnt/usb/evidence/

# Import with lazy fetch from local server (isolated network)
stella prov import proof-lite.json --lazy-fetch --backend http://provcache-server:8080

# Import strict bundle (no network needed)
stella prov import proof-full.json --verify

Programmatic Lazy Fetch

// File-based fetcher for air-gapped environments
var fileFetcher = new FileChunkFetcher(
    basePath: "/mnt/usb/evidence",
    logger);

var orchestrator = new LazyFetchOrchestrator(repository, logger);

// Fetch and verify all missing chunks
var result = await orchestrator.FetchAndStoreAsync(
    proofRoot: "sha256:...",
    fileFetcher,
    new LazyFetchOptions
    {
        VerifyOnFetch = true,
        BatchSize = 100
    });

if (result.Success)
    Console.WriteLine($"Fetched {result.ChunksStored} chunks");

Bundle Format (v1)

{
    "version": "v1",
    "exportedAt": "2025-01-15T10:30:00Z",
    "density": "standard",
    "digest": {
        "veriKey": "sha256:...",
        "verdictHash": "sha256:...",
        "proofRoot": "sha256:...",
        "trustScore": 85
    },
    "manifest": {
        "proofRoot": "sha256:...",
        "totalChunks": 42,
        "totalSize": 2752512,
        "chunks": [...]
    },
    "chunks": [...],
    "signature": {
        "algorithm": "ECDSA-P256",
        "signature": "base64...",
        "signedAt": "2025-01-15T10:30:01Z"
    }
}

2.3.1 · Compact vulnerability database (vuln-db.sqlite) — two-artifact structure & manifest

Since 2026-07 the compact offline vulnerability DB ships as a license-segregated artifact pair (counsel directive: docs/legal/decisions/compact-vulndb-license-segregation-counsel-thread-reply.md):

<db-version>/
  ├── vuln-db.sqlite                     # BUSL-core: license-cleared sources only
  ├── vuln-db.manifest.json              # sidecar manifest for the core artifact
  ├── vuln-db-sharealike.sqlite          # OPTIONAL companion: CC-BY-SA / share-alike sources,
  │                                      #   per-record attribution (record_attribution table)
  └── vuln-db-sharealike.manifest.json   # sidecar manifest for the companion

2.4 · Async federation export

Concelier’s federation bundle export now runs as a scheduled job. Air-gap operators kick off an export and pick up the artefact from S3/RustFS when the platform’s notification surface signals completion.

StepAction
1POST /api/v1/federation/exports with { sinceCursor?, maxItems?, compressLevel?, sign? }. Returns 202 Accepted + { exportId, runId, status: "queued" } and a Location: /api/v1/federation/exports/{exportId} header.
2The federation:bundle:export job streams the zstd bundle directly to S3/RustFS (feedser-bundle/{tenant}/{yyyymmdd}/{sha256}.zst).
3On terminal status, Concelier emits concelier.federation.bundle.ready or .failed to Notify; the requester receives an in-app inbox row plus an email (template seeded per tenant). Both carry a 24-hour pre-signed download URL.
4Poll GET /api/v1/federation/exports/{exportId} (or open the in-app notification) to retrieve a fresh signed URL after expiry.

The synchronous GET /api/v1/federation/export and GET /api/v1/federation/export/preview endpoints are deprecated; they remain only as a short-circuit for small (≤10k canonicals) exports. New air-gap pipelines should adopt the async surface so exports survive HTTP timeouts and are reproducibly content- addressed.

2.4.1 Advisory transport readiness

Routine air-gap advisory transport is the feedser-bundle/1.0 federation export. stellaops-mirror-seed-v1 (PostgreSQL COPY chunks) is reclassified as disaster-recovery / replica-bootstrap only — it is unfit for routine air-gap installs (6 GB compressed -> ~200 GB expanded -> 50+ h COPY import vs. the bundle’s ~tens-of-MB single pull applied in seconds).

PathCurrent useReadiness
feedser-bundle/1.0 federation exportRoutine advisory synchronization between connected and isolated Concelier instances.Production-ready (routine path). The full export/import pipeline is proven streaming + flat-working-set (FederationExportWorkingSetTests: 141 753 canonicals through the real pipeline, ~1.3 s / ~4 MB peak heap, keyset-only paging) and the populated-DB primary→replica round-trip is proven against real PostgreSQL backends (FederationPostgresRoundTripTests): a populated primary Postgres exports the bundle and a fresh replica Postgres imports it with row-count parity verified via raw SQL (active/withdrawn/edge counts + multi-source shape). A smaller live federation E2E proof is still outstanding.
json:raw / json:policyEvidence escrow, downstream normalization, and operator-readable advisory archives.Available; the JSON live proof has landed. Keep the format compatible with Export Center profiles.
trivy:dbScanner-feed transport for Trivy consumers.⛔ RETIRED 2026-08-09 (owner ruling: Stella Ops does not support trivy-db). The clean-room BoltDB writer, the export:trivy-db job, and the trivy:db / trivy:java-db profiles are severed and the sources moved to src/__Obsoleted/. Use the compact vuln DB row below instead.
Compact vuln DB (stella vuln-db export)Our own digest-attested offline vulnerability database — the replacement for the retired trivy:db path.Available. The CLI-owned exporter reads a retained Vulnerabilities hub publication through required --from-hub; the former Concelier server job/plugin is retired.
Thin bundleStrategic removable-media bundle owned by Mirror Thin Bundle Assembler.Strategic path from MIRROR-CRT-56-001; no sprint currently documents the first end-to-end “bundle on USB -> air-gap import in <5 min” demo.
stellaops-mirror-seed-v1PostgreSQL COPY seed for disaster recovery or replica bootstrap.DR / replication only; do NOT use as the routine update transport.

This classification is captured in ADR-003: Concelier Export Center Air-Gap Alignment.

For local development, devops/compose/docker-compose.dev.yml brings up a Mailpit container (mail.stella-ops.local:1025 SMTP, :8025 web UI) so the notification emails can be inspected end-to-end without a real SMTP relay. Apply the docker-compose.notifier-mailpit.override.yml override to point the Notifier worker’s DefaultSmtpOptions fall-back at the Mailpit instance via env vars (NOTIFIER__EMAIL__SMTP__HOST=mail.stella-ops.local, NOTIFIER__EMAIL__SMTP__PORT=1025). Full walkthrough lives in docs/modules/notify/operations/dev-smtp.md.


3 · Delta patch workflow

  1. Connected site fetches stella-ouk-YYYY-MM-DD.delta.tgz.
  2. Transfer via any medium (USB, portable disk).
  3. stella admin import-offline-usage-kit <delta> applies only changed CVE rows & images.

Daily deltas are < 30 MB; weekly roll-up produces a fresh full kit.


4 · Quota behaviour offline

The scanner enforces the same fair-use limits offline:

Soft reminder at 200 scans; throttle above the ceiling but never block. See the quota enforcement flow in QUOTA_ENFORCEMENT_FLOW.md.


5 · Troubleshooting

SymptomExplanationFix
could not verify SBOM hashBundle corrupted in transitRe-download / re-copy
Import hangs at Applying feeds…Low disk space in /var/lib/stellaFree ≥ 2 GiB before retry
quota exceeded same day after importImport resets counters at UTC 00:00 onlyWait until next UTC day or load a JWT

Operator environment of the consolidated overlay (2026-09-14)

Machine-local values devops/compose/docker-compose.offlinekit-consolidated.yml reads from devops/compose/.env (git-ignored). None is a secret; all are per-estate.

VariableFeedsWhy it is not a committed literal
OFFLINEKIT_EXPORT_RUNS_ENABLED (default false)Export__Runs__Worker__Enabled on offlinekit-worker — the OK-12 export-run executoran estate opts into running exports
STELLAOPS_EXPORTCENTER_NIS2_SOA_TRUST_ROOT_PUBLIC_KEY_PEMExport__Nis2Soa__TrustRoots__0__PublicKeyPem and Export__Assurance__TrustRoots__0__PublicKeyPemthe PUBLIC half of the operator-owned signing key mounted from devops/etc/exportcenter/keys/ (git-ignored); derive with openssl pkey -in nis2-soa-signing.pem -pubout
STELLAOPS_EXPORTCENTER_NIS2_SOA_TRUST_ROOT_FINGERPRINTthe matching …__Fingerprint keyssha256: + SHA-256 of the SubjectPublicKeyInfo DER (EuSigningContracts.ComputePublicKeyFingerprint)
OFFLINEKIT_IMPORT_TRUST_PUBLIC_KEY_PATHthe bind at /app/etc/offlinekit/import-trust.pem (OfflineKit__Import__TrustedPublicKeyPath)the carrier trust anchor is operator-selected, never shipped
OFFLINEKIT_TIME_ANCHOR_TRUST_ROOTS_PATHthe bind at /app/etc/offlinekit/time-anchor-trust-roots.pem, bound to the timestamp-authority public-trust purpose by the committed devops/etc/offlinekit/appsettings.Production.json mounted on offlinekit-web (a JSON file, not an environment variable: the hardened image’s shell entrypoint drops a variable whose name carries the purpose’s hyphen — docs/modules/cryptography/timestamp-public-trust.md)the timestamp authority’s PUBLIC chain the sealed-import gate re-verifies a declared time anchor against (SPRINT_20260914_002 EA-5); a bundle carrying a private key is refused; missing it, every import under seal is refused SEALED_IMPORT_TIME_ANCHOR_UNREADABLE
OFFLINEKIT_ACTIVATOR_AUTHORITY_CLIENT_SECRETAuthority’s stellaops-offlinekit-activator client; OfflineKit loads the same value from .local-secrets/offlinekit-activator-authority.env — now on BOTH roles: offlinekit-web activates parts with it, offlinekit-worker pulls owner-part inputs with it (EA-1)both sides MUST hold one value — the 2026-09-14 window found them different (activation 401)
OFFLINEKIT_CARRIER_SIGNING_KEY_ID (default empty)Export__Carrier__SigningKeyId on offlinekit-worker — the key id the run engine signs carriers with (SPRINT_20260914_002 EA-1); empty = runs complete without a carrier and say socustody is the crypto registry: an operator-mounted EC P-256 PEM in a lab, a sovereign provider in production
STELLAOPS_OFFLINEKIT_CARRIER_SIGNING_KEY_HOST_PATH (default .local-secrets/offlinekit/carrier-signing.pem)the bind at /app/etc/offlinekit/carrier-signing.pem (Export__Carrier__SigningPrivateKeyPemFile)the PRIVATE half stays operator-mounted and git-ignored; its PUBLIC half is what offlinekit-web trusts at OfflineKit__Import__TrustedPublicKeyPath, so a run’s carrier imports on the same estate In the release bundle the installer generates it as secrets/offlinekit/carrier-signing.pem (SPRINT_20260914_004 BGF-1); the mount is projected there.

A multi-line PEM in .env must be one line with escapes inside double quotes; a literal multi-line value reaches the container as its first line only.