Mirror Bundle Contract (AIRGAP-56)

Currency check 2026-10-07: owner paths/source placement verified against 3238bd511a37e199ae99d4f17be2f13eabec8dd7. The supported boundary is Vulnerabilities publications and OfflineKit carrier custody (src/Vulnerabilities/StellaOps.Vulnerabilities.WebService/Program.cs). Re-verify the host mount and owning endpoint extension together. This is source verification; deployment and broader audit coverage are separate evidence. Other dated findings retain their stated dates. Re-verify: rg -n "MapGroup|Map.*Endpoints|AddHostedService" src/Vulnerabilities/StellaOps.Vulnerabilities.WebService/Program.cs, then inspect the endpoint extension or library pointer cited at the operation.

Supported implementation

Vulnerabilities owns corpus publication and mirror-seed activation. OfflineKit owns the carrier, private quarantine/CAS, transport verification and custody/part ledgers; it never writes content directly into a sibling database. Platform owns environment posture.

OperationCurrent route
Corpus streamGET /api/vulnerabilities/v1/corpus/export
Current publicationGET /api/vulnerabilities/v1/corpus/publications/current
Publication manifestGET /api/vulnerabilities/v1/corpus/publications/{artifactRef}/manifest
Detached sectionGET /api/vulnerabilities/v1/corpus/publications/{artifactRef}/sections/{sectionName}
Domain activationPOST /api/vulnerabilities/v1/mirror/seeds/import
Carrier custodyPOST /api/offlinekit/v1/import, GET /api/offlinekit/v1/status

The exchange part is vulnerabilities / application/vnd.stellaops.mirror-seed+ndjson / generation 1 (VulnerabilityMirrorSeedExchangeContract). Activation requires the hub’s advisory-ingest and VEX-ingest policies. Carrier verification can succeed while a part is failed or unsupported; report both verdicts.

The old Excititor distribution/domain-index and registration APIs and Concelier mirror-seed tool are predecessor history. They are not renamed Vulnerabilities endpoints. Use publication/activation contracts instead of changing their hostname.

Source pointers:

Operator references

Historical Excititor/Concelier mirror contract 2026-09-11 (superseded 2026-09-14)

The remainder records the predecessor implementation or unlanded design at the dates stated in its text. All routes, host names, source paths, DTOs, metrics and examples in this section are historical comparison, including sentences that originally said “current” or “today”. They are not instructions for the consolidated estate. The supported section and owner source above supersede them. Do not rebuild or deploy a predecessor from these examples.

Contract ID: CONTRACT-MIRROR-BUNDLE-003 Version: 1.0 Status: Published Last Updated: 2026-09-11

Part of the Stella Ops Contracts index. Audience: Excititor, Concelier, and AirGap implementers who produce, register, or validate mirror bundles, and operators running offline/air-gapped synchronization. Related: Sealed Mode, Verification Policy, Export Bundle.

Reconciliation note (verified against source 2026-05-30). This contract was reconciled against the live implementation. Several field names, the export-format set, the bundle signature algorithm, and the entire Registration API section were corrected to match code. Where the original document described shapes that do not exist in any source, they are marked NOT IMPLEMENTED or replaced with the real contract. Ground-truth symbols are cited inline.

Overview

This contract defines the mirror bundle format used for air-gap/offline operation. Mirror bundles package VEX advisories and vulnerability-feed exports for transport to sealed environments, register them in a per-tenant catalog, and expose provenance/staleness for synchronization monitoring.

Current ownership spans these services:

Implementation References

NOT IMPLEMENTED / FLAGGED: the previously referenced JSON schema docs/modules/airgap/schemas/mirror-bundle.schema.json does not exist in the repository. The docs/modules/airgap/schemas/ directory contains manifest.schema.json, receipt.schema.json, av-report.schema.json, and the time-anchor schemas only. The reference has been removed; the canonical shapes are the C# record types cited above.

Note also that src/AirGap/StellaOps.AirGap.Importer/ is the import validator (no bundle DTOs), and the withdrawn mirror-sync planner (MirrorSyncPlan/MirrorSyncResult, frozen on 2026-09-26 under src/__Obsoleted/OfflineKit/__Libraries/StellaOps.Mirror.Creator/) does not define the bundle/export shapes below.

Bundle Structure

Mirror domain index (MirrorDomainIndex)

The export-listing surface served at GET /excititor/mirror/domains/{domainId}/index is the closest top-level “bundle of exports” object. It is built in MirrorEndpoints.HandleDomainIndexAsync and serialized with VexCanonicalJsonSerializer.

{
 "id": "vex-advisories",
 "displayName": "VEX Advisories",
 "generatedAt": "2025-12-05T10:00:00Z",
 "exports": [
 {... }
 ]
}
FieldTypeSource fieldDescription
idstringMirrorDomainIndex.IdDomain identifier (operator-configured)
displayNamestringMirrorDomainIndex.DisplayNameHuman-readable name (falls back to id)
generatedAtdatetimeMirrorDomainIndex.GeneratedAtISO-8601 generation timestamp
exportsarrayMirrorDomainIndex.ExportsExport index entries (see below)

NOT IMPLEMENTED: the originally documented top-level object with schemaVersion, targetRepository, and a domainId field on the index does not exist. A separate MirrorBundleDocument (the StellaOps-mirror connector bundle, not the export index) does carry schemaVersion, generatedAt, targetRepository, domainId, displayName, advisoryCount, advisories[], and sources[] — those are advisories-mirror fields, not VEX-export fields.

Export entry (MirrorExportIndexEntry / MirrorExportMetadata)

Each export in the index, and the standalone export metadata served at GET /excititor/mirror/domains/{domainId}/exports/{exportKey}, is projected from a VexExportManifest.

{
 "exportKey": "vex-openvex-all",
 "exportId": "550e8400-e29b-41d4-a716-446655440000",
 "querySignature": "format=openvex&sourceVendor=anchore",
 "format": "openvex",
 "createdAt": "2025-12-05T10:00:00Z",
 "artifact": { "algorithm": "sha256", "digest": "7d9cd5f1a2a0dd9a41a2c43a5b7d8a0bcd9e34cf39b3f43a70595c834f0a4aee" },
 "sizeBytes": 1048576,
 "sourceProviders": ["anchore", "github", "redhat"],
 "consensusRevision": "rev-2025-12-05-001",
 "attestation": {
 "predicateType": "https://stella.ops/attestation/vex-export/v1",
 "rekorLocation": "https://rekor.sigstore.dev/api/v1/log/entries/...",
 "envelopeDigest": "sha256:...",
 "signedAt": "2025-12-05T10:00:01Z"
 },
 "status": null
}
FieldTypeSource symbolDescription
exportKeystringMirrorExportIndexEntry.ExportKeyOperator-defined export key within the domain
exportIdstringVexExportManifest.ExportIdUnique export identifier
querySignaturestringVexExportManifest.QuerySignature (VexQuerySignature.Value)Deterministic key=value&... filter signature
formatstringVexExportManifest.FormatExport format, lower-cased (see Export Formats)
createdAtdatetimeVexExportManifest.CreatedAtExport creation timestamp
artifactobjectVexExportManifest.Artifact (VexContentAddress)Content address with algorithm + digest
sizeBytesintegerVexExportManifest.SizeBytesArtifact size in bytes
sourceProvidersarrayVexExportManifest.SourceProvidersContributing providers (sorted) — present on MirrorExportMetadata, omitted from the slim index entry
consensusRevisionstring?VexExportManifest.ConsensusRevisionOptional consensus revision id
attestationobject?VexExportManifest.Attestation (MirrorExportAttestation)Optional attestation descriptor
statusstring?MirrorExportIndexEntry.StatusNon-null on the index entry when the export is unavailable (e.g. manifest_not_found, invalid_export_configuration)

The full VexExportManifest carries additional fields not surfaced on the mirror index/metadata DTOs: claimCount, fromCache, policyRevisionId, policyDigest, consensusDigest (VexContentAddress), scoreDigest (VexContentAddress), and quietProvenance[].

Corrected field names (doc was wrong): artifactSizeBytes → sizeBytes; artifactDigest (a sha256:... string) → artifact (a {algorithm, digest} content address); the key field → exportKey. policyRevisionId, policyDigest, consensusDigest, and scoreDigest live on VexExportManifest but are not emitted on the mirror endpoint DTOs.

Export Formats (VexExportFormat)

Format valueEnum memberDescription
jsonVexExportFormat.JsonStandard JSON
jsonlVexExportFormat.JsonLinesNewline-delimited JSON
openvexVexExportFormat.OpenVexOpenVEX format
csafVexExportFormat.CsafCSAF VEX format
cyclonedxVexExportFormat.CycloneDxCycloneDX VEX format

Corrected: the enum is defined in VexExportManifest.cs with exactly these five members. The previously listed spdx format does not exist, and ndjson is spelled jsonl (VexExportFormat.JsonLines).

Attestation descriptor (VexAttestationMetadata / MirrorExportAttestation)

Attestation metadata for signed exports. On the mirror DTOs this is flattened to MirrorExportAttestation(PredicateType, RekorLocation, EnvelopeDigest, SignedAt), where RekorLocation is taken from VexAttestationMetadata.Rekor?.Location.

{
 "predicateType": "https://stella.ops/attestation/vex-export/v1",
 "rekorLocation": "https://rekor.sigstore.dev/...",
 "envelopeDigest": "sha256:...",
 "signedAt": "2025-12-05T10:00:01Z"
}

The underlying VexAttestationMetadata.Rekor (VexRekorReference) additionally carries apiVersion, logIndex, and inclusionProofUri when present.

Bundle signature (DSSE)

Bundles are signed with a DSSE envelope, not a detached signature record. Verification is performed by DsseVerifier using RSA-PSS / SHA-256 (PS256) over the DSSE pre-authentication encoding (PAE).

NOT IMPLEMENTED / corrected: the BundleSignature object with path, algorithm: ES256, keyId, provider, signedAt does not match any verification path. DsseVerifier.IsAlgorithmAllowed accepts only PS256 / RSASSA-PSS-SHA256 / RSA-PSS-SHA256; ES256 is rejected. ES256 appears only as an example default string in the MirrorSigningOptions.Algorithm doc-comment (publisher side) and is not the enforced verification algorithm. The DSSE envelope itself uses keyid on each signature (DsseEnvelope); trust is established via TrustRootConfig.TrustedKeyFingerprints + PublicKeys.

Mirror Domains

Domains are operator-configured under the Excititor:Mirror configuration section (MirrorDistributionOptions.SectionName), not a fixed enumeration. Each MirrorDomainOptions defines:

Config keyTypeDefaultDescription
Idstring—Domain identifier
DisplayNamestringIdHuman-readable name
RequireAuthenticationboolfalseWhen true, index/metadata/download require an authenticated principal
MaxIndexRequestsPerHourint120Per-domain index rate limit
MaxDownloadRequestsPerHourint600Per-domain download rate limit
Exports[]array—MirrorExportOptions (Key, Format, Filters, Sort, Limit, Offset, View)

Top-level Excititor:Mirror keys include Enabled, OutputRoot, DirectoryName (default mirror), TargetRepository, RefreshIntervalMinutes (default 60), AutoRefreshEnabled, and a Signing block (Enabled, Algorithm, KeyId, Provider, KeyPath).

NOT IMPLEMENTED: the fixed “Standard domain identifiers” table (vex-advisories, vulnerability-feeds, policy-packs, sbom-catalog) is not enforced anywhere in code. Domain IDs are whatever operators configure. Those strings are illustrative only.

Validation Requirements

Validation is orchestrated by ImportValidator.ValidateAsync in strict order; any failure quarantines the bundle (FileSystemQuarantineService) and returns a BundleValidationResult with a reason code.

Order of checks (as implemented)

  1. TUF metadata (TufMetadataValidator.Validate over RootJson / SnapshotJson / TimestampJson) — failure code TUF_INVALID.
  2. DSSE signature (DsseVerifier.Verify) — RSA-PSS/SHA256, trusted-fingerprint check, allowed-algorithm check, trust-window check; failure code DSSE_INVALID.
  3. Merkle root (MerkleRootCalculator.ComputeRoot over PayloadEntries) compared against the merkleRoot / merkle_root field in manifest.json — failure codes HASH_MISMATCH, MERKLE_ROOT_MISSING, MERKLE_ROOT_MISMATCH.
  4. Trust-root rotation (RootRotationPolicy.Validate over active/pending keys + approver IDs) — failure code ROTATION_INVALID.
  5. Referrer validation (ReferrerValidator.Validate) — only for bundle types mirror-bundle and offline-kit; tracks missing/checksum/size mismatches and orphaned referrers — failure code REFERRER_VALIDATION_FAILED.
  6. Version monotonicity (VersionMonotonicityChecker.CheckAsync) — rejects non-monotonic manifestVersion unless ForceActivate is set with a non-empty ForceActivateReason — failure codes VERSION_PARSE_FAILED, VERSION_NON_MONOTONIC, FORCE_ACTIVATE_REASON_REQUIRED.

On success the validator records activation via VersionMonotonicityChecker.RecordActivationAsync and returns BundleValidationResult.Success("import-validated").

DSSE verification details

  1. Require trust roots (TrustedKeyFingerprints and PublicKeys non-empty).
  2. Enforce allowed signature algorithms (RSA-PSS/SHA256 only).
  3. Enforce the trust window (NotBeforeUtc / NotAfterUtc).
  4. Decode the base64 payload, build PAE (DssePreAuthenticationEncoding.Encode).
  5. Match each signature keyid to a trusted public key whose SHA-256 fingerprint is allowlisted, then verify with the crypto provider (PS256).

manifest.json (canonical format)

Per docs-archive/modules/airgap/guides/mirror-bundles.md, the staged bundle carries a manifest.json with:

Import / Catalog Flow

1. Stage bundle package (offline media / mirror)
2. Concelier aggregates it into the air-gap catalog
3. ImportValidator runs TUF → DSSE → Merkle → rotation → referrer → monotonicity
4. On success, register in the per-tenant bundle catalog (IAirgapImportStore)
5. Emit a timeline event (IBundleTimelineEmitter) recording actor, scope, and stats
6. Apply to the sealed environment

Registration & Query API

Mirror bundle registration / query (Excititor)

Served by MirrorRegistrationEndpoints under /airgap/v1/mirror/bundles; all routes require the tenant header (RequireTenant()) and the excititor.vex.read policy (scope vex:read).

GET /airgap/v1/mirror/bundles?publisher={p}&importedAfter={iso}&limit=50&offset=0
 -> MirrorBundleListResponse { bundles[], totalCount, limit, offset, queriedAt }

GET /airgap/v1/mirror/bundles/{bundleId}
 -> MirrorBundleDetailResponse { bundleId, mirrorGeneration, tenantId, publisher,
 signedAt, importedAt, provenance{payloadHash, signature, payloadUrl,
 transparencyLog, manifestHash}, staleness{...}, paths{portableManifestPath,
 evidenceLockerPath}, timeline[], queriedAt }

GET /airgap/v1/mirror/bundles/{bundleId}/timeline
 -> MirrorBundleTimelineResponse { bundleId, mirrorGeneration, timeline[], queriedAt }

MirrorBundleSummary fields: bundleId, mirrorGeneration, publisher, signedAt, importedAt, payloadHash, stalenessSeconds, status.

Mirror distribution (Excititor)

Served by MirrorEndpoints under /excititor/mirror:

GET /excititor/mirror/domains (anonymous)
GET /excititor/mirror/domains/{domainId} (anonymous)
GET /excititor/mirror/domains/{domainId}/index (excititor.vex.read; per-domain auth + rate limit)
GET /excititor/mirror/domains/{domainId}/exports/{exportKey} (excititor.vex.read)
GET /excititor/mirror/domains/{domainId}/exports/{exportKey}/download (excititor.vex.read; rate limited)

Air-gap catalog / sources / import (Concelier)

Served by AirGapEndpointExtensions under /api/v1/concelier/airgap (gated by ConcelierOptions.AirGap.Enabled; tenant header required):

GET /api/v1/concelier/airgap/catalog (Concelier.Advisories.Read)
GET /api/v1/concelier/airgap/sources (Concelier.Advisories.Read)
POST /api/v1/concelier/airgap/sources (Concelier.Advisories.Ingest)
GET /api/v1/concelier/airgap/sources/{sourceId} (Concelier.Advisories.Read)
DELETE /api/v1/concelier/airgap/sources/{sourceId} (Concelier.Advisories.Ingest)
POST /api/v1/concelier/airgap/sources/{sourceId}/validate (Concelier.Advisories.Ingest)
GET /api/v1/concelier/airgap/status (Concelier.Advisories.Read)
POST /api/v1/concelier/airgap/bundles/{bundleId}/import (Concelier.Advisories.Ingest)
 body: { tenantId (required), scope, actorId, actorType, actorDisplayName, evidenceBundleRef }
 -> BundleImportResponseDto { eventId, bundleId, tenantId, stats, occurredAt }

Note: the import handler currently emits a timeline event and import stats but the underlying ingestion is a placeholder (// TODO: Wire actual bundle import logic here in AirGapEndpointExtensions); stats are derived from catalog metadata, not a live ingest. Treat POST.../import as partially implemented.

Seal / unseal / verify (AirGap Controller)

Served by AirGapEndpoints under /system/airgap (scope-asserted via AirGapPolicies):

GET /system/airgap/status (airgap:status:read)
POST /system/airgap/seal (airgap:seal)
POST /system/airgap/unseal (airgap:seal)
POST /system/airgap/verify (airgap:status:read)

NOT IMPLEMENTED / corrected: the Registration API (POST /api/v1/airgap/bundles with a {bundlePath, trustRootsPath} body returning {importId, status: "validating"}, and GET /api/v1/airgap/bundles/{bundleId} returning {bundleId, domainId, status, exportCount}) does not exist on any route. No endpoint accepts bundlePath/trustRootsPath or returns an importId. The real surfaces are listed above.

Authorization Scopes

Verified against StellaOps.Auth.Abstractions/StellaOpsScopes.cs:

ScopeConstUsed by
airgap:sealAirgapSeal/system/airgap/seal, /unseal
airgap:importAirgapImportoffline bundle import authorization
airgap:status:readAirgapStatusRead/system/airgap/status, /system/airgap/verify
vex:readVexRead (policy excititor.vex.read)mirror bundle registration/query + distribution index/metadata/download

There is no airgap:verify scope. /system/airgap/verify is gated by the AirGapPolicies.Verify policy, which asserts StellaOpsScopes.AirgapStatusRead (Program.cs:59-69) via AirGapScopeAssertion — verification is a read-only integrity check, so it shares the air-gap read scope.

Determinism Guarantees

  1. Digest verification: all payload entries are hashed and folded into a Merkle root compared against manifest.json.
  2. Stable ordering: files[] sorted by path; sourceProviders sorted; query signatures built from ordinally-sorted key=value pairs (VexQuerySignature.FromFilters).
  3. Immutable content: bundle content is immutable once signed; re-imports of the same bundleId/mirrorGeneration are rejected (AIRGAP_DUPLICATE_IMPORT).
  4. Canonical JSON: mirror responses serialized via VexCanonicalJsonSerializer.
  5. Traceability: full provenance + timeline per bundle (MirrorBundleProvenance, MirrorBundleTimelineEntry).

Error Codes

Sealed-mode / air-gap errors are mapped by AirgapErrorMapping.FromErrorCode (AirgapMirrorContracts.cs) to a structured AirgapErrorResponse (errorCode, message, category, retryable, details, remediation):

Error codeCategoryRetryable
AIRGAP_EGRESS_BLOCKEDsealed_modeno
AIRGAP_SOURCE_UNTRUSTEDtrustno
AIRGAP_SIGNATURE_MISSINGvalidationno
AIRGAP_SIGNATURE_INVALIDvalidationno
AIRGAP_PAYLOAD_STALEvalidationyes
AIRGAP_PAYLOAD_MISMATCHtrustno
AIRGAP_DUPLICATE_IMPORTduplicateno
AIRGAP_BUNDLE_NOT_FOUNDnot_foundno

Staleness categories (StalenessCalculator.CategorizeAge): fresh (<1h), recent (<1d), stale (<1w), old (<30d), very_old (≥30d).

Unblocks

This contract unblocks the following tasks: