AirGap architecture
Stella Ops AirGap — the air-gapped deployment controller, importer, and time-anchor services.
Scope. Implementation-ready architecture for AirGap: the controller, importer, and time anchor subsystems enabling StellaOps operation in disconnected/air-gapped environments with sealed-mode state management.
0) Mission & boundaries
Mission. Enable fully offline, air-gapped operation of StellaOps with sealed-mode state management, bundle-based updates, and cryptographic time anchors for staleness detection.
Boundaries.
- AirGap does not connect to external networks in sealed mode.
- AirGap does not generate vulnerability data. It imports pre-packaged bundles.
- Bundle verification is mandatory. Unsigned, tampered, or untrusted bundles are rejected before extraction.
- Time anchors are cryptographically verified using Roughtime or RFC3161.
1) Solution & project layout
src/AirGap/
├─ StellaOps.AirGap.Controller/ # ASP.NET host: seal/unseal/status/verify + staleness enforcement
│ ├─ Domain/AirGapState.cs # Persisted per-tenant state record (Sealed flag + seal context)
│ ├─ Endpoints/
│ │ ├─ AirGapEndpoints.cs # Minimal-API map for /system/airgap/{status,seal,unseal,verify}
│ │ ├─ RequestValidation.cs # Seal/verify request validators
│ │ └─ Contracts/ # SealRequest, VerifyRequest, AirGapStatusResponse
│ ├─ Services/
│ │ ├─ AirGapStateService.cs # Seal/unseal/get-status orchestration
│ │ ├─ ReplayVerificationService.cs # Bridges /verify to the Importer ReplayVerifier
│ │ ├─ AirGapTelemetry.cs # Metrics + trace + structured logs
│ │ └─ AirGapStartupDiagnosticsHostedService.cs # Sealed-startup gate (fails closed)
│ ├─ Stores/ # IAirGapStateStore + DurableRequiredAirGapStateStore (throws)
│ ├─ Infrastructure/PostgresAirGapStateStore.cs # Durable Postgres state store (airgap.state)
│ ├─ Security/AirGapPolicies.cs # Authorization policy + scope assertion helper
│ ├─ Auth/HeaderScopeAuthenticationHandler.cs # Dev/test header-scope auth handler
│ └─ Program.cs # Host composition, auth policies, health, router wiring
│
├─ StellaOps.AirGap.Importer/ # Bundle verification library (no HTTP surface)
│ ├─ Contracts/ # ReplayDepth, ReplayVerificationRequest, TrustRootConfig
│ ├─ Planning/BundleImportPlanner.cs # Deterministic import plan + pre-flight checks
│ ├─ Validation/
│ │ ├─ ReplayVerifier.cs # Hash/staleness/policy-freeze replay enforcement
│ │ ├─ TufMetadataValidator.cs # root/snapshot/timestamp TUF consistency
│ │ ├─ RootRotationPolicy.cs # Trust-root rotation approval rules
│ │ ├─ TrustStore.cs / DsseEnvelope.cs / ReferrerValidator.cs
│ ├─ Versioning/ # BundleVersion + VersionMonotonicityChecker (rollback guard)
│ ├─ Quarantine/ # FileSystemQuarantineService + QuarantineOptions
│ ├─ Reconciliation/ # Evidence graph reconciler + SBOM/VEX/attestation parsers
│ ├─ Policy/ # OfflineVerificationPolicy (keys/tlog/attestations/constraints)
│ └─ Telemetry/OfflineKitMetrics.cs # offlinekit_* + attestor_rekor_* metrics
│
├─ StellaOps.AirGap.Time/ # ASP.NET host (Microsoft.NET.Sdk.Web): time-anchor status + ingest API
│ ├─ Program.cs # Host composition (router service "airgap-time"); Postgres anchor store mandatory outside Testing
│ ├─ Controllers/TimeStatusController.cs # GET /api/v1/time/status + POST /api/v1/time/anchor
│ ├─ Auth/AirGapTimePolicies.cs # StatusRead→airgap:status:read, AnchorWrite→airgap:seal
│ ├─ Models/ # TimeAnchor, StalenessBudget, StalenessEvaluation, AirGapOptions, SetAnchorRequest, TimeStatus(Dto)
│ ├─ Parsing/ # TimeTokenFormat (Roughtime|Rfc3161), TimeTokenParser
│ ├─ Services/
│ │ ├─ StalenessCalculator.cs # Overall + per-content staleness evaluation
│ │ ├─ TimeVerificationService.cs # Dispatches to format-specific verifiers
│ │ ├─ TimeAnchorPolicyService.cs # Anchor policy / drift / operation gating (config "AirGap:Policy")
│ │ ├─ TimeStatusService.cs / TimeTelemetry.cs / TimeAnchorLoader.cs / TrustRootProvider.cs
│ │ ├─ RoughtimeVerifier.cs (partial) / Rfc3161Verifier.cs (partial)
│ ├─ Config/AirGapOptionsValidator.cs / Health/TimeAnchorHealthCheck.cs / Hooks/SealedStartupHostedService.cs
│ └─ Stores/ # ITimeAnchorStore + PostgresTimeAnchorStore (also self-creates airgap.state) + DurableRequiredTimeAnchorStore
│
├─ StellaOps.AirGap.Policy/ # Egress policy (sealed-mode network control)
│ ├─ StellaOps.AirGap.Policy/ # IEgressPolicy, EgressPolicyMode, EgressDecision, EgressRequest
│ ├─ StellaOps.AirGap.Policy.Analyzers/ # Roslyn analyzers forbidding raw egress in sealed code
│ └─ StellaOps.AirGap.Policy.Tests/
│
├─ __Libraries/
│ ├─ StellaOps.AirGap.Bundle/ # Bundle manifest (v2) models, builder, extractors, import targets
│ ├─ StellaOps.AirGap.Persistence/ # Embedded SQL migrations (state, bundle_versions, history)
│ └─ StellaOps.AirGap.Sync/ # Offline job-log HLC merge for disconnected ordering
│
├─ __Tests/ # Controller/Importer/Time/Sync/Persistence test projects
│
└─ AGENTS.md # Guild charter
Component note. There is no
ISealingController/SealingController/StatusService, noIBundleVerifier/CatalogImporter, and noRoughtimeAnchor/Rfc3161Anchortype. The seal state machine is implemented byAirGapStateServiceover a persistedAirGapStaterecord; bundle verification lives inStellaOps.AirGap.Importer.Validation; time verification usesRoughtimeVerifier/Rfc3161Verifierkeyed byTimeTokenFormat. Two projects are ASP.NET hosts (Microsoft.NET.Sdk.Web):StellaOps.AirGap.Controller(the seal/unseal/status/verify surface) andStellaOps.AirGap.Time(the time-anchor status/ingest surface, registered as router serviceairgap-time).StellaOps.AirGap.Importer,StellaOps.AirGap.Policy, andStellaOps.AirGap.Bundleare libraries with no HTTP surface.
2) External dependencies
- PostgreSQL - Seal state (
airgap.state) and bundle-version tracking (airgap.bundle_versions,airgap.bundle_version_history). Both the Controller and the Time host store onairgap.state. - Authority - Scope enforcement. There are three AirGap scopes, all declared as named constants in the canonical catalog (
StellaOps.Auth.Abstractions.StellaOpsScopes):airgap:seal(:188),airgap:import(:193), andairgap:status:read(:198). The four policies (AirGap.StatusRead/AirGap.Seal/AirGap.Import/AirGap.Verify) map onto those three scopes:AirGap.VerifyrequiresStellaOpsScopes.AirgapStatusRead(Controller/Program.cs:59-69), because verification is a read-only integrity check — it mirrors the way the Attestor verify surface collapses toattest:read. There is noairgap:verifyscope; the literal never existed in the catalog and was granted to no client. The Time host reusesairgap:status:readfor its status read andairgap:sealfor anchor ingest (AirGapTimePolicies.AnchorWriteScope = StellaOpsScopes.AirgapSeal). - Cryptography - DSSE/TUF/time-anchor signature verification (Importer library + Time host). The Time host references
System.Security.Cryptography.Pkcsfor RFC3161 verification and bundles an Ed25519 implementation for Roughtime. - Filesystem - Bundle staging and quarantine are file-backed (
FileSystemQuarantineService,QuarantineOptions.QuarantineRoot); object storage is not required by the Controller.
Runtime state ownership
Controller seal state and time-anchor state converge on the same durable PostgreSQL record in airgap.state. StellaOps.AirGap.Time and StellaOps.AirGap.Controller do not register authoritative in-memory runtime state. Live runtime requires the same PostgreSQL-backed state path for both hosts; test harnesses that run without PostgreSQL must replace the state-store interfaces explicitly. AirGap bundle import adapters likewise do not ship process-local raw advisory, VEX, or policy-pack stores in production assemblies. Hosts must bind the owning Concelier, Excititor, and Policy durable stores; tests provide any volatile raw-store fixtures from src/AirGap/__Tests/**. AirGap sync uses file-backed HLC state in the offline job log data directory by default, so disconnected job ordering survives process restarts without requiring network access. Test harnesses may still inject a custom IHlcStateStore explicitly.
Runtime authentication boundary
StellaOps.AirGap.Controller uses Authority-backed bearer-token validation outside test harnesses. Scope-bearing request headers are accepted only by Testing, or by Development when AirGap:Authentication:AllowHeaderScopeAuthentication=true is set explicitly. Production deployments must not trust caller-supplied scope or scp headers for airgap:seal or airgap:status:read decisions.
Both hosts (Controller and Time) register app.UseIdentityEnvelopeAuthentication() ahead of UseAuthentication() (Controller/Program.cs, Time/Program.cs; SPRINT_20260712_009 CLO-7). The JwtBearer OnMessageReceived bridge only promotes an already-hydrated StellaRouterEnvelope principal — it does not parse the envelope headers itself. On the messaging (Valkey) transport AspNetRouterRequestDispatcher performs that hydration + HMAC verification before the ASP.NET pipeline runs, so the omission was invisible on the live stack; on the HTTP reverse-proxy transport (gateway strips Authorization, forwards only X-StellaOps-Identity-Envelope[-Signature]) nothing hydrated them, so authenticated calls would 401. The middleware closes that gap and is a strict no-op when the principal is already authenticated (messaging path, direct-Bearer callers). The signing key is supplied by Router:IdentityEnvelopeSigningKey (the x-router-microservice-defaults compose anchor), so no per-service config is required.
3) Contracts & data model
3.1 Seal State
The durable seal state is a single per-tenant AirGapState record (StellaOps.AirGap.Controller.Domain.AirGapState). The state is a Sealed boolean plus the context captured at seal time — there is no Transitioning enum value and no SealStatus/SealTransition type; transitions are atomic state writes. See section 12.2 for the implemented state machine.
public sealed record AirGapState
{
public const string SingletonId = "singleton";
public string Id { get; init; } = SingletonId;
public string TenantId { get; init; } = "default";
public bool Sealed { get; init; } = false;
public string? PolicyHash { get; init; } = null; // policy hash bound at seal time
public TimeAnchor TimeAnchor { get; init; } = TimeAnchor.Unknown;
public DateTimeOffset LastTransitionAt { get; init; } = DateTimeOffset.MinValue;
public StalenessBudget StalenessBudget { get; init; } = StalenessBudget.Default; // overall budget
// Wall-clock minus anchor time at seal; used to detect later clock drift.
public long DriftBaselineSeconds { get; init; } = 0;
// Per-content budgets, defaulting "advisories"/"vex"/"policy" to the overall budget.
public IReadOnlyDictionary<string, StalenessBudget> ContentBudgets { get; init; } =
new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
}
The status read returned to callers is shaped by AirGapStatusResponse (tenant id, sealed, policyHash, timeAnchor, staleness, driftSeconds, driftBaselineSeconds, secondsRemaining, per-content contentStaleness, lastTransitionAt, evaluatedAt).
3.2 Bundle manifest (v2.0.0)
The offline bundle manifest is modelled by StellaOps.AirGap.Bundle.Models.BundleManifest (schema version 2.0.0); the advisory-spec subset lives in BundleManifestV2. The manifest inventories typed component arrays (feeds, policies, crypto materials, catalogs, crypto providers, rule bundles) rather than a flat contents list, and carries offline-verification material (canonicalManifestHash, subject, timestamps, rekorProofs). Inline artifacts (no path) are capped at 4 MiB; larger artifacts are written under artifacts/.
{
"schemaVersion": "2.0.0",
"bundleId": "airgap-2025-01-15-abc123",
"name": "advisory-snapshot",
"version": "2025.01.15.001",
"createdAt": "2025-01-15T10:30:00Z",
"expiresAt": "2025-02-15T10:30:00Z",
"exportMode": "full", // "light" | "full"
"feeds": [ /* FeedComponent[]: digest, format, path */ ],
"policies": [ /* PolicyComponent[] */ ],
"cryptoMaterials": [ /* CryptoComponent[] */ ],
"catalogs": [ /* CatalogComponent[] */ ],
"ruleBundles": [ /* RuleBundleComponent[] */ ],
"rekorSnapshot": { /* RekorSnapshot? */ },
"totalSizeBytes": 0,
"bundleDigest": "sha256:...",
"canonicalManifestHash": "sha256:...", // sha256 over canonical JSON, deterministic
"subject": { /* BundleSubject: sha256 (+ optional sha512) digest of the target */ },
"timestamps": [ /* TimestampEntry[]: RFC3161/eIDAS TSA chain/OCSP/CRL refs */ ],
"rekorProofs": [ /* RekorProofEntry[]: entry body + inclusion proof + signed entry timestamp */ ],
"verify": { /* BundleVerifySection: keys + expectations */ }
}
The
signature/bundleType/contentsshape shown in earlier drafts of this doc was illustrative and does not match the implemented manifest. Signing/verification expectations live underverify,timestamps, andrekorProofs; bundle classification is by typed component arrays plusexportMode.
3.3 Time anchor & staleness
StellaOps.AirGap.Time.Models.TimeAnchor is the canonical representation extracted from a signed time token. There is no Proof/Servers/Verified/Timestamp shape; verification is performed by the verifiers (section 3.4) and the resulting anchor records only the trusted extracted values:
public sealed record TimeAnchor(
DateTimeOffset AnchorTime,
string Source, // e.g. roughtime, rfc3161
string Format, // matches TimeTokenFormat
string SignatureFingerprint,
string TokenDigest)
{
public static TimeAnchor Unknown => new(DateTimeOffset.MinValue, "unknown", "unknown", "", "");
}
Staleness uses two records (note: no StalenessResult type exists). StalenessBudget holds the tolerated WarningSeconds/BreachSeconds (default 3600/7200; warning must not exceed breach), and StalenessCalculator.Evaluate(...) produces a StalenessEvaluation:
public sealed record StalenessBudget(long WarningSeconds, long BreachSeconds); // Default = (3600, 7200)
public sealed record StalenessEvaluation(
long AgeSeconds,
long WarningSeconds,
long BreachSeconds,
bool IsWarning,
bool IsBreach)
{
public long SecondsRemaining => Math.Max(0, BreachSeconds - AgeSeconds);
public static StalenessEvaluation Unknown => new(0, 0, 0, false, false);
}
When the anchor is Unknown (AnchorTime == DateTimeOffset.MinValue), evaluation returns StalenessEvaluation.Unknown rather than reporting a breach.
3.4 Time-token verification
Token verification is dispatched by TimeVerificationService to one of two format verifiers keyed by TimeTokenFormat:
TimeTokenFormat.Roughtime→RoughtimeVerifierTimeTokenFormat.Rfc3161→Rfc3161Verifier
Each returns a TimeAnchorValidationResult and, on success, the extracted TimeAnchor.
4) REST API (Controller)
This module exposes two HTTP hosts: StellaOps.AirGap.Controller(the seal/unseal/status/ verify surface) and StellaOps.AirGap.Time(the time-anchor status/ingest surface, router service airgap-time). The controller’s operational endpoints are grouped under /system/airgap(not /api/v1/airgap) and require an Authority-issued scope; production scope checks are derived from Authority-issued tokens, not request headers (see the runtime authentication boundary in section 2). The full, implemented controller surface is documented in section 12.3 — summarised here:
GET /system/airgap/status airgap:status:read → AirGapStatusResponse (seal state + staleness)
POST /system/airgap/seal airgap:seal → AirGapStatusResponse (records policy hash, anchor, budgets)
POST /system/airgap/unseal airgap:seal → AirGapStatusResponse (sealed=false)
POST /system/airgap/verify airgap:status:read → VerifyResponse { valid, reason }
The Time host (TimeStatusController, route prefix /api/v1/time) exposes:
GET /api/v1/time/status airgap:status:read → TimeStatusDto (anchor + overall/per-content staleness)
POST /api/v1/time/anchor airgap:seal → TimeStatusDto (ingest a signed time token; SetAnchorRequest)
SetAnchorRequest carries tenantId, hexToken, format (TimeTokenFormat), optional trust-root override fields (trustRootKeyId/trustRootAlgorithm/trustRootPublicKeyBase64 — accepted only when AirGap:AllowRequestSuppliedTrustRoots=true under Development/Testing), and optional warningSeconds/breachSeconds. Both Time routes resolve the tenant from the token tenant claim and reject a mismatched tenantId (tenant-claim-required / tenant-mismatch).
Anonymous (no scope), controller host: GET /healthz (liveness), GET /readyz (readiness), GET /buildinfo.json (alias GET /api/v1/buildinfo, build provenance), and the OpenAPI document. The Time host exposes its readiness health check at GET /healthz/ready (and an anonymous OpenAPI document) instead.
There are no /transitions/{id} endpoints (transitions are atomic, not long-running jobs), and there is no airgap:import HTTP endpoint in the controller — the airgap:import scope and the AirGap.Import authorization policy are registered, but no route currently consumes them (see the orphaned-surface note in section 4.1).
4.1 Importer has no HTTP surface; Time is a separate host
The StellaOps.AirGap.Importer project is a library, not a service: there are no POST /bundles/* upload/verify/import endpoints in this module. Bundle ingestion and import are driven through the export/import flows and the Bundle import targets (ConcelierAdvisoryImportTarget, ExcititorVexImportTarget, etc.); replay verification is reachable over HTTP only via POST /system/airgap/verify, which the controller bridges to the Importer’s ReplayVerifier.
StellaOps.AirGap.Time is a host (not a library) and exposes the /api/v1/time/* endpoints above. It owns its own time-anchor record on airgap.state (PostgresTimeAnchorStore) and runs a sealed-mode startup gate (SealedStartupHostedService) and a time_anchor health check (/healthz/ready) independent of the controller.
Orphaned / roadmap: the
airgap:importscope +AirGap.Importpolicy are defined but not attached to any controller route today. They are reserved for a future in-band sealed-mode import endpoint. Treat them as roadmap, not current behavior.
5) Configuration
There are two configuration surfaces: the controller host (IConfiguration keys read in Program.cs / AddAirGapController) and the operator policy template (etc/airgap.yaml.sample).
5.1 Controller host keys (IConfiguration)
# PostgreSQL state storage (REQUIRED outside the Testing environment; startup throws if absent).
# First non-empty of these is used; SchemaName defaults to "airgap".
Postgres:
AirGap:
ConnectionString: "Host=postgres;Database=airgap;Username=...;Password=..."
SchemaName: "airgap"
# Fallbacks also honored: ConnectionStrings:Default, Database:ConnectionString
AirGap:
Authentication:
# Development-only escape hatch. Header-supplied scopes are accepted only under the
# Testing environment, or under Development when this is explicitly true. Ignored otherwise.
AllowHeaderScopeAuthentication: false
Startup: # AirGapStartupOptions — sealed-startup diagnostics gate
TenantId: "default"
EgressAllowlist: [] # null/empty => sealed startup is blocked
Trust: # all three required when sealed (TUF root/snapshot/timestamp JSON)
RootJsonPath: ""
SnapshotJsonPath: ""
TimestampJsonPath: ""
Rotation: # validated only when PendingKeys is non-empty
ActiveKeys: {}
PendingKeys: {}
ApproverIds: []
Telemetry: # AirGapTelemetryOptions
MaxTenantEntries: 1000 # bound on the per-tenant gauge cache
Quarantine: # QuarantineOptions (section "AirGap:Quarantine")
QuarantineRoot: "/updates/quarantine"
RetentionPeriod: "30.00:00:00" # 30 days
MaxQuarantineSizeBytes: 10737418240 # 10 GiB
EnableAutomaticCleanup: true
In the controller host there is no
AirGap:Controller:{InitialState,TransitionTimeoutSeconds,RequireApproval}section and noAirGap:Importersection. Those keys in earlier drafts were aspirational. The controller supplies staleness budgets per-seal in the seal request, not via static config. The Time host is the host that binds theAirGap(AirGapOptions) section — see section 5.3.
5.2 Operator policy template (etc/airgap.yaml.sample)
The sample template (consumed by the snapshot export/import tooling; STELLAOPS_AIRGAP_-prefixed env vars override it) documents the broader policy surface:
schemaVersion: 1
staleness:
maxAgeHours: 168 # reject beyond 7 days
warnAgeHours: 72
requireTimeAnchor: true
staleAction: block # "warn" | "block"
contentBudgets: # per-content warning/breach seconds (advisories, vex, policy, ...)
advisories: { warningSeconds: 86400, breachSeconds: 259200 }
vex: { warningSeconds: 86400, breachSeconds: 604800 }
policy: { warningSeconds: 604800, breachSeconds: 2592000 }
export:
outputDirectory: "./snapshots"
compressionLevel: 6
includeTrustRoots: true
import:
quarantineDirectory: "./quarantine"
quarantineTtlHours: 168
quarantineMaxSizeMb: 1024
verifySignature: true
verifyMerkleRoot: true
enforceMonotonicity: true # version monotonicity / rollback prevention
trustStore:
rootBundlePath: "/etc/stellaops/trust-roots.pem"
allowedAlgorithms: ["ES256", "ES384", "Ed25519", "RS256", "RS384"]
rotation: { requireApproval: true, pendingTimeoutHours: 24 }
timeAnchor:
defaultSource: "roughtime" # "roughtime" | "rfc3161" | "local"
roughtimeServers: ["roughtime.cloudflare.com:2003", "roughtime.google.com:2003"]
rfc3161Servers: ["http://timestamp.digicert.com", "http://timestamp.comodoca.com"]
maxClockDriftSeconds: 60
egressPolicy:
mode: allowlist # maps to EgressPolicyMode (Unsealed=advisory / Sealed=enforced)
allowedHosts: []
allowLocalhost: true
telemetry:
activitySourceName: "StellaOps.AirGap"
5.3 Time host keys (IConfiguration)
The StellaOps.AirGap.Time host reads PostgreSQL settings the same way as the controller (Postgres:AirGap:ConnectionString → ConnectionStrings:Default → Database:ConnectionString, schema default airgap; mandatory outside Testing). It binds two additional sections:
AirGap: # AirGapOptions (StellaOps.AirGap.Time.Models)
TenantId: "default"
Staleness: { WarningSeconds: 3600, BreachSeconds: 7200 } # StalenessOptions
ContentBudgets: # defaults advisories/vex/policy to the global staleness budget
advisories: { WarningSeconds: 3600, BreachSeconds: 7200 }
vex: { WarningSeconds: 3600, BreachSeconds: 7200 }
policy: { WarningSeconds: 3600, BreachSeconds: 7200 }
TrustRootFile: "docs/airgap/time-anchor-trust-roots.json" # JSON trust-root bundle
AllowUntrustedAnchors: false # offline testing only
AllowRequestSuppliedTrustRoots: false # honored only under Development/Testing
Policy: { } # TimeAnchorPolicyOptions (section "AirGap:Policy")
6) Sealing State Machine
The state machine is a two-state, atomic model over the Sealed boolean — there is no Transitioning intermediate state. Each transition is a single durable write (PostgresAirGapStateStore.SetAsync), so a request is either fully sealed or fully unsealed:
seal() (record policyHash, timeAnchor, budgets, drift baseline)
┌──────────────────────────────────────────────────►┐
┌──┴────────────┐ ┌────▼─────────┐
│ Unsealed │ │ Sealed │
│ (Sealed=false)│ │ (Sealed=true)│
└──▲────────────┘ └────┬─────────┘
└──────────────────────────────────────────────────◄┘
unseal() (flip Sealed=false, update LastTransitionAt)
Transition actions (as implemented in AirGapStateService)
Seal (SealAsync, Sealed: false → true):
- Require a non-empty
PolicyHash; validate theStalenessBudget. - Compute the drift baseline =
now − anchor.AnchorTime(seconds), when the anchor is known. - Resolve per-content budgets, defaulting
advisories/vex/policyto the overall budget. - Persist the new
AirGapState(policy hash, time anchor, overall + per-content budgets, drift baseline,LastTransitionAt). The response carries the freshly evaluated staleness.
Unseal (UnsealAsync, Sealed: true → false):
- Load the current state.
- Write it back with
Sealed = falseand an updatedLastTransitionAt. Staleness is reported asUnknownfor the now-open tenant.
Network egress is governed separately by
StellaOps.AirGap.Policy(EgressPolicyMode), not flipped as a step inside seal/unseal. The controller does not “verify pending work”, “verify connectivity”, or “refresh the anchor” during a transition — those steps from earlier drafts are not implemented. Sealed-mode enforcement (egress allowlist, trust material, anchor freshness) is applied at startup byAirGapStartupDiagnosticsHostedService, which fails closed when a sealed tenant’s preconditions are missing (see section 8).
7) Bundle Import Flow
1. Plan (BundleImportPlanner.CreatePlan)
├─ Require a bundle path
├─ Require ≥1 trusted key fingerprint (else "trust-roots-required")
└─ Reject an inverted trust window (NotAfter < NotBefore)
2. Verify
├─ TUF metadata consistency (TufMetadataValidator: root/snapshot/timestamp versions,
│ expiry > epoch, timestamp→snapshot hash match)
├─ Replay verification (ReplayVerifier): expected vs computed manifest+bundle sha256,
│ staleness window, and — at ReplayDepth.PolicyFreeze — bundle vs sealed policy hash
└─ Trust-root rotation rules (RootRotationPolicy) when pending keys exist
3. Version gate (VersionMonotonicityChecker)
├─ First activation is always allowed ("FIRST_ACTIVATION")
├─ Otherwise require the incoming BundleVersion to be newer ("MONOTONIC_OK")
└─ Non-monotonic activation throws unless force-activated with a non-empty reason
4. Import & record
├─ Extract / hand off to import targets (ConcelierAdvisoryImportTarget, ExcititorVexImportTarget, …)
└─ Record activation in airgap.bundle_versions / bundle_version_history
5. On failure: quarantine (FileSystemQuarantineService)
└─ Move to QuarantineRoot with a reason code; TTL cleanup honors RetentionPeriod / MaxQuarantineSizeBytes
ReplayDepth controls verification rigor: HashOnly, FullRecompute (default), or PolicyFreeze (additionally pins the bundle’s policy hash to the sealed policy hash). Missing/empty hashes fail with hash-missing; mismatches fail with manifest-hash-drift / bundle-hash-drift; an over-age manifest fails with manifest-stale.
Snapshot bundle export fails closed when signing is enabled without a configured PEM signing key. The writer does not create ephemeral/keyless signatures because those cannot be mapped to an offline trust root. Snapshot imports default to RequireTrustedSignature=true and RequireValidMerkleRoot=true. The caller must provide a trusted manifest public key; otherwise import fails before archive extraction. Signature metadata can still be inspected without a key, but the bundle is not treated as verified unless at least one manifest signature validates against the configured public key.
8) Security & compliance
- Signature verification: Bundles must validate against a trusted manifest public key before import; snapshot import defaults to
RequireTrustedSignature=trueandRequireValidMerkleRoot=true. - Trust roots & rotation: Configurable trusted key fingerprints; trust-root rotation is gated by
RootRotationPolicy(approver IDs) when pending keys exist. - Sealed-startup gate:
AirGapStartupDiagnosticsHostedServicefails the process closed when a sealed tenant is missing an egress allowlist, time anchor, valid TUF trust material, or valid rotation — emittingairgap_startup_blocked_totaland throwingsealed-startup-blocked:<reasons>. - Quarantine: Failed imports isolated under
QuarantineRootwith reason codes and TTL cleanup. - Audit trail: Seal/unseal/status and import activations logged; bundle activations recorded in
airgap.bundle_version_history. - Scope enforcement: Authority-issued scopes (
airgap:status:read,airgap:seal,airgap:import) enforced via assertion-based authorization policies;/verifysharesairgap:status:read. Header-supplied scopes accepted only in Testing/Development. - Tenant isolation: Every request resolves a tenant from the token tenant claim (or
tid) and/orx-tenant-idheader; mismatches and invalid ids are rejected (tenant_required/tenant_invalid/ forbid). - Rollback prevention:
VersionMonotonicityCheckerrejects non-monotonic bundle activation unless force-activated with a recorded reason.
9) Performance targets
These are design targets, not measured/enforced SLOs — there are no corresponding benchmarks or threshold assertions in the codebase as of 2026-05. Seal/unseal/status are single PostgreSQL upserts/reads, so latency is dominated by the database round-trip rather than computation.
- Seal/unseal transition: < 30s (target)
- Bundle verification: < 10s for a 1 GB bundle (target)
- Bundle import: < 60s for a typical advisory update (target)
- Time anchor verification: < 5s (target)
10) Observability
Controller metrics (meter StellaOps.AirGap.Controller, in AirGapTelemetry):
airgap_seal_total{tenant,sealed}— counterairgap_unseal_total{tenant,sealed}— counterairgap_startup_blocked_total{tenant,reason}— counter (sealed-startup gate failures)airgap_time_anchor_age_seconds{tenant}— observable gaugeairgap_staleness_budget_seconds{tenant}— observable gauge
Time host metrics (meter StellaOps.AirGap.Time, in TimeTelemetry):
airgap_time_anchor_status_total{tenant,is_warning,is_breach}— counterairgap_time_anchor_warning_total{tenant,...}— counterairgap_time_anchor_breach_total{tenant,...}— counterairgap_time_anchor_age_seconds{tenant}— observable gauge (distinct from the controller meter’s same-named gauge)
Importer metrics (meter StellaOps.AirGap.Importer, in OfflineKitMetrics):
offlinekit_import_total{status,tenant_id}— counterofflinekit_attestation_verify_latency_seconds{attestation_type,success}— histogramattestor_rekor_success_total{mode},attestor_rekor_retry_total{reason}— countersrekor_inclusion_latency{success}— histogram
Tracing: ActivitySource StellaOps.AirGap.Controller emits airgap.status.read and airgap.startup.validation spans (tagged with tenant, sealed, policy hash, anchor source, age). Seal/unseal and status reads also emit structured logs.
The dot-notation names (
airgap.state,airgap.bundles.imported_total, …) used in earlier drafts are not emitted; use the underscore names above.
11) Testing matrix
- Controller tests (
__Tests/StellaOps.AirGap.Controller.Tests): seal/unseal/status/verify endpoints, tenant resolution, staleness enforcement, durable-state-required guard. - Importer tests (
__Tests/StellaOps.AirGap.Importer.Tests):ReplayVerifier,TufMetadataValidator,RootRotationPolicy, version monotonicity, evidence reconciliation/SBOM-normalization. - Time tests (
__Tests/StellaOps.AirGap.Time.Tests): staleness calculations, Roughtime/RFC3161 verification, options validation. - Persistence tests (
__Tests/StellaOps.AirGap.Persistence.Tests): migration application +PostgresBundleVersionStore(upsert/read/history/force-activation). - Bundle / Sync tests: snapshot writer/reader round-trip + import trust; offline-job-log HLC merge.
SnapshotBundleWriterincludestime-anchor.jsonin the manifest Merkle root, andSnapshotBundleReaderreconstructs that same entry from the manifest digest before accepting the root. Missing, digest-mismatched, or omitted time-anchor bytes therefore fail closed. These are library contracts; no production host currently registers the knowledge-snapshot writer, reader, or importer as an export/import workflow. - Offline E2E harness (
src/__Tests/offline/StellaOps.Offline.E2E.Tests): default-lane tests exercise a realSnapshotBundleWriter→SnapshotBundleReaderround-trip with in-process EC signing keys, frozen fixture content, signature verification, merkle-root verification, tamper detection, key-mismatch rejection, and byte-stable digest determinism. All paths run underNetworkIsolatedTestBase, which fails the test if any socket attempt is made. Two regression guards (NoSimulationGuardTests) use reflection to forbidSimulate*methods or in-test mock result types from re-entering the suite, since those previously silently turned a missing-bundle environment into a passed outcome. An optionalexternal-offline-bundleprofile (gated by theSTELLAOPS_OFFLINE_BUNDLEenvironment variable) emits an explicit xUnit v3Skipoutcome with a reason when the bundle is absent — never a silent pass. See sprintdocs-archive/implplan/SPRINT_20260501_054_AirGap_offline_e2e_real_harness.mdfor context.
12) AirGap Controller (sealing service)
StellaOps.AirGap.Controller (src/AirGap/StellaOps.AirGap.Controller/) is the runtime service that owns the sealed-mode state and staleness-enforcement surface for a tenant. It is the concrete sealing/orchestration host described abstractly in sections 3, 4, and 6: the seal state machine, the controller REST surface, and the durable state record converge here. The controller’s module-doc directory (docs/modules/airgap-controller/) is a pointer stub; this section is its canonical architecture target.
12.1 Role within the module
The controller is the sealing/staleness orchestration component. It does not import bundles, verify signatures, or fetch time itself — it composes the other AirGap subsystems:
- Importer (
StellaOps.AirGap.Importer) — the controller’sverifypath reuses the importer’sReplayVerifier(viaReplayVerificationService) to check a bundle’s manifest/ bundle digests and policy hash against the recorded sealed state. Bundle ingestion itself stays in the Importer; the controller only consumes its replay-verification contracts. - Policy (
StellaOps.AirGap.Policy) — staleness-budget evaluation rules. The controller records aPolicyHashat seal time and rejectsverifyrequests whose policy hash does not match the sealed value. - Time (
StellaOps.AirGap.Time) — referenced by the controller for theTimeAnchormodel and theStalenessCalculatorit uses to evaluate overall and per-content staleness against the configured budgets. Note thatStellaOps.AirGap.Timeis also a standalone ASP.NET host (router serviceairgap-time) exposingGET/POST /api/v1/time/{status,anchor}and writing its own anchor record toairgap.state; it is not merely a library to the controller. See section 4 for its API. - Persistence (
StellaOps.AirGap.Persistence) — backs the PostgreSQL state store (airgapschema). The controller resolves seal state throughIAirGapStateStore; the durable Postgres implementation is mandatory outside theTestingenvironment (a fresh process with no configured store bindsDurableRequiredAirGapStateStore, which throws on access, so seal state can never silently live only in memory).
Authentication is Authority-backed bearer-token validation in production. A development/test escape hatch (AirGap:Authentication:AllowHeaderScopeAuthentication) accepts header-supplied scopes only under Testing, or under Development when explicitly enabled — see the runtime authentication boundary note in section 2.
12.2 Sealed-mode state machine (as implemented)
The controller persists a single per-tenant AirGapState record keyed by tenant id. At the controller level the durable state is a Sealed boolean plus the context captured at seal time (see section 3.1 for the full record shape). Transitions are atomic durable writes — there is no intermediate “transitioning” phase:
- Seal (
Sealed = false → true): records the suppliedPolicyHash,TimeAnchor, overallStalenessBudget, per-content budgets (defaultingadvisories,vex, andpolicyto the overall budget when not supplied), the transition timestamp, and a drift baseline (wall-clock minus anchor time at seal) used later to detect clock drift. The response carries the freshly evaluated staleness. - Unseal (
Sealed = true → false): loads the current state and flipsSealedto false, updating the transition timestamp; staleness is reported as unknown for the now-open tenant. - Status / staleness enforcement: every status read recomputes overall and per-content staleness from the recorded time anchor and budgets against the current time, exposing age, seconds-remaining, drift, and warning/breach flags per content class. Staleness is therefore evaluated on read rather than cached, so a sealed tenant’s content can transition into a warning/breach state purely by the passage of time without any state write.
Tenant resolution is enforced on every request: the tenant id is taken from the token’s tenant claim (or tid) and/or an x-tenant-id header, with mismatches and invalid ids rejected.
12.3 API surface
All controller endpoints live under /system/airgap and require an Authority-issued scope; the build-provenance endpoints are anonymous. Described at the architectural level (see ../airgap-controller/api-reference.mdfor the generated field-level reference):
| Endpoint | Scope | Purpose |
|---|---|---|
GET /system/airgap/status | airgap:status:read | Current seal state plus overall and per-content staleness evaluation for the tenant. |
POST /system/airgap/seal | airgap:seal | Seal the tenant: record policy hash, time anchor, and staleness budget(s); returns the updated status with staleness evaluation. |
POST /system/airgap/unseal | airgap:seal | Unseal the tenant, restoring normal connectivity; returns the updated unsealed status. |
POST /system/airgap/verify | airgap:status:read | Verify the sealed state against a supplied policy hash and deterministic replay evidence (manifest/bundle digests, staleness window); returns a pass/fail verification result. Read-only, so it shares the status-read scope (policy AirGap.Verify, Program.cs:59-69). |
GET /buildinfo.json (alias GET /api/v1/buildinfo) | anonymous | Image build provenance (module, git SHA/commit time, build time, branch) for drift detection. |
Liveness/readiness probes are exposed separately and anonymously at /healthz and /readyz.
12.4 Cross-links
- Controller API reference (generated):
../airgap-controller/api-reference.md - Controller module-doc pointer stub:
../airgap-controller/README.md - Module-wide seal model and state machine: sections 3.1 and 6 above.
13) Persistence & migrations (airgap schema)
The AirGap module owns the PostgreSQL airgap schema (configurable via Postgres:AirGap:SchemaName, default airgap). Three tables exist:
| Table | Owner | Purpose |
|---|---|---|
state | Seal state | Per-tenant seal record (PK tenant_id): sealed, policy_hash, time_anchor JSONB, staleness_budget JSONB, content_budgets JSONB, drift_baseline_seconds, last_transition_at. |
bundle_versions | Version gate | Current activated bundle per (tenant_id, bundle_type): semver parts, bundle_digest, activated_at, force-activation flag/reason. |
bundle_version_history | Audit | Append-only activation history (incl. deactivated_at) for rollback-prevention auditing. |
There are two distinct persistence wiring paths (a real source nuance to be aware of):
- Controller host (
AddAirGapController→Program.cs): registersStellaOps.AirGap.Controller.Infrastructure.PostgresAirGapStateStore, which self-creates only thestatetable inline (EnsureSchemaAsync, driven byPostgresAirGapStateStoreStartupService). The controller host does not referenceStellaOps.AirGap.Persistenceand does not create thebundle_versions/bundle_version_historytables. PostgreSQL is mandatory outsideTesting; a fresh process with no configured store bindsDurableRequiredAirGapStateStore, which throws on access. - Consolidated persistence library (
AddAirGapPersistenceinStellaOps.AirGap.Persistence): registers a secondPostgresAirGapStateStoreplusPostgresBundleVersionStore, and anAirGapStartupMigrationHostthat applies the embedded migrationMigrations/001_initial_schema.sql(creating all three tables).PostgresBundleVersionStoreasserts the bundle tables exist and throws…tables missing… Run AirGap migrationsotherwise — so the version store requires the persistence-library migration path, not the controller’s inline schema.
Note the two same-named
PostgresAirGapStateStoretypes in different namespaces (…Controller.Infrastructurevs…Persistence.Postgres.Repositories). They are not interchangeable; a host wires exactly one. The README’s “EF Core v10” scaffolding workflow refers to the persistence library; the controller host uses raw Npgsql, not EF Core.
The embedded SQL migration is IF NOT EXISTS-idempotent and matches the auto-migration mandate (AGENTS.md §2.7) for the bundle tables; the controller’s state table is converged by its own inline EnsureSchemaAsync on startup. Both paths converge a fresh database without manual psql.
13.1 Snapshot bundle OCI image transport
KnowledgeSnapshotManifest snapshot bundles use schema 2.1.0 for OCI image-layer transport. The change is additive: older readers that only understand the prior snapshot sections can ignore ociImages, while SnapshotBundleReader includes the new files in Merkle verification when present.
Packed images are stored as an OCI image-layout subtree:
oci/
oci-layout
index.json
blobs/
sha256/
<hex>
ociImages[] records the canonical repo key, the upstream subject digest, optional masked stella+oci:// reference, manifest/config digests, layer digests, referrer digests, and total blob bytes. Blobs are content-addressed by sha256:<hex> and deduplicated across images, so shared base layers are stored once. The writer verifies caller-supplied layer/referrer digests before writing and fails closed on mismatch. OciImageLayoutVerifier is the reusable import-side integrity check: it recomputes every blob hash from disk and verifies that every index.json manifest descriptor resolves to a present blob. Deployment-decision referrer manifests are included in index.json alongside image manifests so the offline agent can import signed decision verdicts from the extracted layout.
Related Documentation
- Evidence reconciliation:
./evidence-reconciliation.md - Exporter coordination:
./exporter-cli-coordination.md - Mirror DSSE plan:
./mirror-dsse-plan.md - Offline Kit:
../../OFFLINE_KIT.md - Time anchor schema:
./guides/time-anchor-schema.md
