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.


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, no IBundleVerifier/CatalogImporter, and no RoughtimeAnchor/Rfc3161Anchor type. The seal state machine is implemented by AirGapStateService over a persisted AirGapState record; bundle verification lives in StellaOps.AirGap.Importer.Validation; time verification uses RoughtimeVerifier/Rfc3161Verifier keyed by TimeTokenFormat. Two projects are ASP.NET hosts (Microsoft.NET.Sdk.Web): StellaOps.AirGap.Controller (the seal/unseal/status/verify surface) and StellaOps.AirGap.Time (the time-anchor status/ingest surface, registered as router service airgap-time). StellaOps.AirGap.Importer, StellaOps.AirGap.Policy, and StellaOps.AirGap.Bundle are libraries with no HTTP surface.


2) External dependencies

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/contents shape shown in earlier drafts of this doc was illustrative and does not match the implemented manifest. Signing/verification expectations live under verify, timestamps, and rekorProofs; bundle classification is by typed component arrays plus exportMode.

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:

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:import scope + AirGap.Import policy 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 no AirGap:Importer section. 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 the AirGap (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:ConnectionStringConnectionStrings:DefaultDatabase: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):

  1. Require a non-empty PolicyHash; validate the StalenessBudget.
  2. Compute the drift baseline = now − anchor.AnchorTime (seconds), when the anchor is known.
  3. Resolve per-content budgets, defaulting advisories/vex/policy to the overall budget.
  4. 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):

  1. Load the current state.
  2. Write it back with Sealed = false and an updated LastTransitionAt. Staleness is reported as Unknown for 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 by AirGapStartupDiagnosticsHostedService, 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


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.


10) Observability

Controller metrics (meter StellaOps.AirGap.Controller, in AirGapTelemetry):

Time host metrics (meter StellaOps.AirGap.Time, in TimeTelemetry):

Importer metrics (meter StellaOps.AirGap.Importer, in OfflineKitMetrics):

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


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:

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:

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):

EndpointScopePurpose
GET /system/airgap/statusairgap:status:readCurrent seal state plus overall and per-content staleness evaluation for the tenant.
POST /system/airgap/sealairgap:sealSeal the tenant: record policy hash, time anchor, and staleness budget(s); returns the updated status with staleness evaluation.
POST /system/airgap/unsealairgap:sealUnseal the tenant, restoring normal connectivity; returns the updated unsealed status.
POST /system/airgap/verifyairgap:status:readVerify 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)anonymousImage 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.


13) Persistence & migrations (airgap schema)

The AirGap module owns the PostgreSQL airgap schema (configurable via Postgres:AirGap:SchemaName, default airgap). Three tables exist:

TableOwnerPurpose
stateSeal statePer-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_versionsVersion gateCurrent activated bundle per (tenant_id, bundle_type): semver parts, bundle_digest, activated_at, force-activation flag/reason.
bundle_version_historyAuditAppend-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):

Note the two same-named PostgresAirGapStateStore types in different namespaces (…Controller.Infrastructure vs …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.