ADR-0026: Local-first container and advanced remote deployment profiles
- Status: Accepted architecture; profile control plane and host control foundation implemented; runtime gates remain open
- Date: 2026-08-09
- Decision owner: AncestryLLM maintainer
- Supersedes: no prior ADR
- Extends: ADR-0025
- Security record: data-flow threat model
Context and decision boundary
AncestryLLM is local-first, single-household genealogy software. ADR-0025 accepted Electron as a UI-only desktop adapter over a private authenticated FastAPI sidecar. Issue #346 asks whether later releases may reuse that service surface in containers and, only after an explicit operator decision, across a remote boundary.
This ADR accepts the target architecture. Unreleased Issue #347 implements the shared, non-secret deployment-profile control plane: the schema, Local Desktop default, reviewed mode copy, explicit previews, confirmation-bound local recovery, diagnostics, and redacted backup/support metadata. Issue #363 adds a host-only minimum container-control foundation inside Electron Main, with an exact policy and plan, app-owned Docker selection, bounded lifecycle commands, and owned-resource reconciliation. It remains deliberately unwired from the profile executor, renderer, application image, secret broker, and genealogy services. The current release still has no supported container, LAN, public, browser, or remote application runtime. Each non-local profile remains unavailable until its linked issues, threat-model gates, native-platform evidence, operator documentation, and release decision pass. In particular, accepting Host Remote does not make the current internal API public and does not approve a multi-user service.
Decision
Three explicit deployment intents are accepted:
- Local Desktop is the default. Electron Main owns a local backend and the renderer stays a sandboxed presentation process. No non-loopback listener is opened. A future container implementation may replace the native sidecar, but it must preserve the same application contracts, privacy policy, and local-only behavior.
- Connect Remote is an advanced desktop-client mode. A user deliberately enrolls the desktop with one pre-existing, HTTPS remote deployment. Profile switching is transactional and never inferred from environment variables, Docker availability, a discovered endpoint, or stale state.
- Host Remote is an advanced, self-supported operator mode for one trusted household. The operator separately provisions the host, DNS, TLS edge, identity provider, firewall, backups, upgrades, and recovery. Only the TLS gateway is public. Internal application services remain private and authenticated.
Issue #347 persists those intents as a versioned configuration contract shared by CLI, future desktop first-run/settings UI, diagnostics, upgrades, backup metadata, and support evidence. A profile switch never starts a runtime, opens a listener, discovers a server, moves a family tree, or migrates data. The current executor can safely retain or recover Local Desktop; Connect Remote and Host Remote activation fail closed until their enrollment and host-setup authorities exist.
Issue #363 establishes the minimum host-only control interface required by
#348 and #349. Its closed schema-v1 policy binds Darwin arm64 to an app-owned
runtime profile, Docker context, Unix socket, configuration directory, working
directory, Engine identity and compatibility range, and exact Compose resource
labels. Before and after a lifecycle command, Main revalidates the socket’s
canonical identity, owner, mode, device and inode, the selected endpoint, and
the Engine identity. It ignores ambient Docker selection and runs only fixed,
bounded, no-shell commands with a minimal environment. Start, repair, and both
uninstall choices require operation-bound authorization; stop is bounded but
non-destructive. The accepted plan requires immutable image digests, a non-root
user, a read-only root filesystem, all capabilities dropped,
no-new-privileges, named volumes, internal networks, and loopback-only ports.
Only exact app-owned resources may be reconciled. Neither the renderer nor any
container receives the socket, context, executable, generic process authority,
or a supervisor bridge. This foundation does not yet select or install a
runtime, render an application image, broker secrets, grant family-tree
sources, migrate storage, activate a profile, or expose an application route.
The native macOS arm64 evidence record exercises the #363 subset against an
isolated Colima profile and app-owned context, including start, stop, repair,
preserving uninstall, deleting uninstall, conflict rejection, and cleanup:
issue-363-macos-arm64-container-supervisor.json.
It does not satisfy the remaining G5 or G7 runtime, application-image,
secret, storage, workload, budget, recovery, or cross-platform gates.
The offline invariant is stronger than a provider-egress restriction:
provider=none is incompatible with Connect Remote and Host Remote. Selecting
it forces Local Desktop/local execution, rejects remote-profile activation,
and opens no network socket even when remote endpoints or ambient credentials
exist. Remote use requires a separate explicit profile and may never claim or
silently translate provider=none. It selects the socket-free native
application-service path and does not start the container backend, host
supervisor, Engine API, gateway, workers, or any container. The Local Desktop
container subprofile is therefore unavailable while provider=none is active.
Docker Engine API compatibility and Docker Compose are the portable container contract. On macOS arm64 the open-source default is Colima/Lima with a Docker-compatible Engine API and Compose. Docker Desktop is an optional, separately selected and separately licensed runtime; it is neither bundled nor the default. Linux and Windows support require their own native validation and documented runtime choice before a release claim.
Electron, FastAPI, Uvicorn, SQLCipher, OS keyring integration, transport-neutral
application contracts, and the existing CommandInvocation/CommandExecutor
boundary remain the preferred components. A new UI command registry, generic
command endpoint, renderer networking path, or duplicated domain policy is
prohibited.
Architecture and trust boundaries
Local Desktop
flowchart LR
User["Signed-in OS user"]
Renderer["Sandboxed Electron renderer\nuntrusted presentation"]
Main["Electron Main\nprofile and lifecycle authority"]
Broker["Host secret broker\nOS keyring authority"]
Engine["Verified Docker Engine API\nhost-administrative boundary"]
Gateway["Authenticated local gateway\nloopback publication only"]
Workers["Private app workers\nno host ports"]
Data["SQLCipher volume\nkey stored separately"]
FamilyTrees["Grant-authorized family_trees source\nread-only"]
User --> Renderer
Renderer -->|"fixed typed bridge"| Main
Main --> Broker
Main -->|"narrow lifecycle API"| Engine
Engine --> Gateway
Engine --> Workers
Gateway --> Workers
Workers --> Data
FamilyTrees --> Workers
Broker -->|"scoped secret delivery"| Gateway
Broker -->|"scoped secret delivery"| Workers
The Engine API is root-equivalent host authority even when the daemon runs
inside a VM. Electron Main may invoke only a narrow, identity-verified lifecycle
surface. Neither renderer nor containers receive the Docker socket, a generic
Docker proxy, client certificates, SSH context, or arbitrary build, exec, copy,
mount, device, or volume authority. Application containers run non-root, drop
capabilities, use read-only filesystems where practical, have bounded resources
and logs, and mount only application-owned paths. The one source-data exception
is an allowlisted read-only family_trees mount. Electron Main must first
resolve an opaque native-dialog grant through the host supervisor, which
canonicalizes and revalidates the immutable source before rendering that exact
mount into Compose. The renderer receives only the grant ID, never the path.
Compose validation rejects writable, broad, ungranted, aliased, or additional
host mounts, and only the worker that performs the authorized operation may
receive the source mount.
Local Desktop publishes exactly one authenticated loopback gateway and no
worker, database, administration, or Docker endpoint. Network membership is
never identity: every sensitive service route authenticates a workload or user
credential before parsing a body. provider=none remains network-free and
cannot be combined with a remote profile. Under provider=none, this
socket-backed container topology is not started; the native application-service
path handles the authorized local operation directly.
Connect Remote and Host Remote
flowchart LR
User["Household user"]
Renderer["Sandboxed renderer"]
Main["Electron Main\nenrolled client authority"]
Internet["Hostile network"]
Edge["TLS reverse proxy\nonly public listener: 443"]
Identity["Operator-managed identity provider"]
Gateway["Authenticated application gateway\nprivate network"]
Workers["Private workers\nworkload identity"]
Data["SQLCipher volume"]
Operator["Trusted remote operator\nhost root, DNS, TLS, backup"]
User --> Renderer
Renderer -->|"fixed typed bridge"| Main
Main -->|"HTTPS after enrollment"| Internet
Internet --> Edge
Edge --> Gateway
Gateway <--> Identity
Gateway --> Workers
Workers --> Data
Operator --> Edge
Operator --> Gateway
Operator --> Data
Host Remote cannot start unless preflight validates TLS, the exact public origin, trusted proxy sources, OIDC issuer and audience, redirect and callback ownership, PKCE/state/nonce, hardened sessions, CSRF defenses, rate limits, default-deny route authorization, administrative reauthentication, recovery, and clock bounds. No anonymous health, schema, documentation, bootstrap, setup, or internal service route is public. Enrollment is explicit, bound to the server/user/client/profile, short-lived, and single-use.
Host Remote v1 has exactly one authorized household principal. The configured OIDC issuer, audience, and subject identify that principal; every other OIDC subject is rejected before route or object access. Administrative actions require fresh authentication by that same principal. Multiple people are not represented as separately authorized users, and credential sharing is not a multi-user authorization model. Supporting another principal requires a new ADR and authorization threat model.
The remote operator is a trusted data custodian. Host root or control of the container runtime can observe application memory, keys during use, plaintext records, and backups. Containers are a defense-in-depth boundary, not a defense against a malicious or compromised operator. Host Remote is not multi-tenant; unrelated or mutually distrusting households require separate hosts, secrets, volumes, and identity realms.
Renderer invariants in every profile
The renderer receives no:
- Node.js or Electron objects;
- filesystem, database, keyring, shell, process, or Docker capability;
- raw network, socket, URL-fetching, or browser-authentication authority;
- Docker socket, client credentials, context, or enrollment bootstrap material;
- API bearer, session secret, SQLCipher key, provider secret, or secret value;
- unrestricted path, raw stderr, stack, or sensitive support data.
Only a fixed, versioned, typed preload bridge may cross into Electron Main. Main owns endpoint selection, certificate validation, enrollment, API calls, profile state, supervision, and error sanitization. Switching profiles clears all endpoint-specific session and capability state before the new profile is committed; a failed transition rolls back without starting a listener or mutating data.
Ownership and lifecycle
| Concern | Local Desktop owner | Host Remote owner | Required behavior |
|---|---|---|---|
| Intent and consent | Electron Main plus signed-in OS user | Operator explicitly enables hosting; each client explicitly enrolls | Default to Local Desktop; never infer or auto-migrate a profile. |
| Runtime bootstrap | Host supervisor | Operator | Verify engine identity/version and the rendered Compose model before use. |
| Images and configuration | Release tooling and host supervisor | Operator using project release metadata | Pull by immutable digest; verify platform, SBOM, provenance, and configuration policy. |
| Start/readiness | Host supervisor | Operator runbook | Start private networks and services in dependency order; readiness proves authenticated service identity, not merely an open port. |
| Secrets | OS keyring and narrow host broker | Operator-approved secret file/manager and narrow delivery path | Containers do not query the desktop keyring; delivery uses non-pageable or locked no-swap memory, or a no-swap memory-backed filesystem with equivalent platform evidence. No plaintext fallback, Compose value, image, environment, argument, log, swap, or renderer readback is allowed. |
| Database and migrations | Application services over SQLCipher volume | Same application services; operator schedules maintenance | One active backend; staged migration; integrity check; atomic rollback before reporting ready. |
| Backup and restore | Desktop workflow and user-selected separate destination | Operator | Keep keys separate; verify a cross-container restore before release/upgrade; never treat a live volume snapshot alone as a backup. |
| Upgrade and rollback | Host supervisor after informed approval | Operator | Preserve the previous trusted version and recoverable data; no silent channel switch or downgrade. |
| Stop, repair, uninstall | Host supervisor; destructive action requires confirmation | Operator | Bounded graceful stop, orphan reconciliation by exact project labels, and explicit preserve/export/delete choices. Never delete unrelated resources. |
| Monitoring and support | Privacy-minimal local diagnostics | Operator monitoring; project provides self-support docs only | No payload/access logging by default; support artifacts contain no genealogy data, secrets, tokens, raw paths, or container output. |
Remote mode carries no project-operated hosting, uptime, incident-response, or backup SLA. The project may supply a validated Compose model and self-service runbooks. The operator owns host hardening, capacity, availability, certificates, identity availability, vulnerability response, backups, and disaster recovery.
Component and license policy
| Component | Role | License/support decision |
|---|---|---|
| Electron | Desktop Main, preload, and renderer | MIT; project-packaged and lifecycle-owned. |
| Bundled Chromium and Node.js | Electron runtime internals, never renderer authority | Chromium BSD-style plus third-party notices; Node.js MIT; versions follow the supported Electron release. |
| Python | Application runtime | PSF-2.0; project-packaged and pinned. |
| FastAPI / Uvicorn | Transport adapter and ASGI server | MIT / BSD-3-Clause; private gateway by default. |
| SQLCipher / keyring | Encrypted workspace and host secret integration | BSD-style / MIT; keys remain separate from database data. |
| Docker Engine (Moby) / Compose | Portable runtime API and declarative topology | Apache-2.0; supported API/version range must be pinned and tested. |
| Colima / Lima | Open-source macOS arm64 runtime default | MIT / Apache-2.0; selected or installed separately, not silently bundled. |
| Docker Desktop | Optional runtime | Proprietary/commercial terms; user selects and licenses it separately; never bundled or required by default. |
| TLS edge / OIDC provider | Remote ingress and identity | No implementation selected by this ADR. The first supported reference stack must pin, license-inventory, threat-model, and lifecycle-own its choice; a materially new platform requires a measured ADR. |
| OCI registry | Artifact distribution | Release-maintainer controlled metadata; images are consumed by digest, not mutable tag. |
Exact versions, transitive notices, supported lifetimes, hashes/digests, and license files are release evidence derived from lockfiles and SBOMs. The table does not replace that evidence or grant permission to redistribute a component.
Quantitative acceptance budgets
These are fail-closed release budgets measured on every supported native host and architecture. Virtualization or emulation evidence must be labeled and does not substitute for native support.
| Budget | Limit | Verification method |
|---|---|---|
| Local cold readiness | p95 at or below 60 seconds across 10 clean launches | Time from user launch through engine verification, service start, migration check, and authenticated capability response on the minimum supported host. |
| Local warm/reconnect readiness | p95 at or below 15 seconds across 10 launches | Reuse an already-running healthy runtime; still authenticate and verify version/identity. |
| Connect Remote readiness | p95 at or below 10 seconds, excluding interactive identity-provider time | Time endpoint validation and authenticated capability response for 10 trials over the documented reference network. |
| Graceful shutdown | at or below 20 seconds before a coded recovery state | Verify bounded stop, child cleanup, and restart recovery after normal, stalled, and interrupted shutdown. |
| Idle application memory | Electron plus application containers at or below 1.5 GiB RSS after 10 idle minutes | Record host process metrics and docker stats (or Engine API equivalents); exclude the container VM but report it separately. |
| macOS runtime VM memory | configured ceiling at or below 4 GiB for the minimum profile | Inspect Colima/Lima configuration and repeat the canonical fictional workload without OOM or swapping-induced readiness failure. |
| OCI image footprint | total compressed application image set at or below 1.5 GiB; no application image above 1 GiB | Inspect native and multi-architecture manifests by digest; base/runtime duplication counts against the total. |
| Local exposure | one authenticated loopback listener; zero wildcard/LAN, worker, data, admin, and Docker publications | Scan IPv4 and IPv6 from host, runtime VM, and peer LAN host; inspect Compose and host firewall state. |
| Host Remote exposure | one public TCP 443 edge; zero direct backend, worker, data, admin, and Docker publications | Scan externally, from host, and from sibling containers; inspect IPv4/IPv6 listeners, proxy routes, firewall, and network attachments. |
| Offline profile | zero network sockets or traffic with provider=none; remote profiles are rejected and the container backend remains stopped |
Assert that the supervisor, Engine API, gateway, workers, and containers never start; instrument host sockets and traffic during the native canonical fictional workload with ambient provider credentials and remote state present. |
The following per-profile ceilings are release defaults, not sizing suggestions. A supported profile may lower them. Raising one requires measured evidence, updated one-over tests, and an ADR amendment. “Aggregate” covers all application-owned processes or containers in that profile; infrastructure outside the application remains separately operator-bounded.
| Budget | Local Desktop | Connect Remote client | Host Remote reference deployment |
|---|---|---|---|
| CPU quota | 2.0 host CPU cores aggregate | 1.0 host CPU core aggregate over the canonical remote workload | 4.0 host CPU cores aggregate |
| PID ceiling | 256 aggregate | 128 aggregate | 512 aggregate |
| Writable storage | 20 GiB encrypted data plus scratch | 1 GiB endpoint/session cache; no genealogy database | 100 GiB encrypted data plus scratch |
| Inode ceiling | 100,000 | 20,000 | 500,000 |
| Log retention | 100 MiB aggregate, at most 5 files | 50 MiB aggregate, at most 5 files | 500 MiB aggregate, at most 10 files |
| Concurrent connections | 64 at the loopback gateway | 8 outbound to the enrolled origin | 256 at the TLS edge and 128 at the private gateway |
| API workers | exactly 1 gateway worker | 0 server workers | exactly 1 gateway worker |
| Concurrent jobs | 2 bounded genealogy jobs | 0 server jobs | 4 bounded genealogy jobs |
| Non-file request body | 1 MiB | 1 MiB | 1 MiB |
Typed file-ingress limits remain operation-specific and do not inherit the
non-file request limit. Boundary and one-over tests must prove the profile
rejects excess work with a stable coded error (413 or 429 at HTTP
boundaries), does not mutate genealogy data, retains bounded host control, and
returns to a ready or documented recovery state within the 20-second shutdown
budget. Disk-full, inode-full, fork, connection, request, job, and log-growth
tests are mandatory evidence for AB-14 and TM-K01.
A budget change requires measured evidence and an ADR amendment. A release may support a smaller host matrix than the architecture target, but must name it and must not convert emulation, an untested engine, or an untested architecture into a native support claim.
Non-goals and prohibited shortcuts
This decision does not accept:
- Kubernetes, a service mesh, Redis, an external database, a message broker, or an orchestration control plane without demonstrated need, measurements, an ownership model, and a separate ADR;
- multi-user or multi-tenant hosting, anonymous access, a general public API, a browser client, federation, synchronization, project-operated SaaS, or an availability SLA;
- direct renderer networking, filesystem, Docker, keyring, database, provider, shell, or enrollment authority;
- Docker Desktop as a bundled requirement, a remote Docker context, or a container-mounted Docker socket;
- trust based only on loopback, a container network, source IP, service name, or an open health port;
- automatic public firewall changes, DNS/TLS provisioning, port forwarding, remote-mode activation, profile inference, or destructive migration;
- plaintext databases, secrets embedded in images/Compose/environment/logs, or a backup stored only with the live volume or its key.
Abuse cases and residual risks
The canonical details and evidence status are AB-11 through AB-21 in the
threat model. They cover profile
confusion; Docker-context impersonation; daemon/runtime compromise; container
escape and exhaustion; network spoofing/lateral movement; secret and volume
disclosure; remote TLS/identity/session failure; enrollment theft; registry and
bootstrap compromise; destructive lifecycle behavior; and operator compromise.
The following residual risks cannot be designed away:
- a trusted remote operator or compromised host root can access plaintext data and secrets while the application is running;
- Docker/VM/kernel and native-parser vulnerabilities can cross intended isolation boundaries before an update is available;
- a compromised identity, TLS, DNS, registry, runtime, or update authority can cause outage or targeted attack despite validation and recovery controls;
- self-supported remote operation can fail through missed patches, certificate expiry, inadequate capacity, or untested backups;
- traffic metadata and authentication events remain visible to the operator and relevant infrastructure even when genealogy payloads are encrypted.
These risks remain at their inherent rating until the threat model’s negative tests and release evidence support a residual rating. Critical or High residual risk blocks the affected gate.
Linked-issue reconciliation
| Issue | Contract retained by this ADR | Status effect |
|---|---|---|
| #98 | Architecture and threat-model approval precede implementation. | Closed baseline; ADR-0026 adds, not replaces, its gates. |
| #101 | Electron Main is the only renderer-host authority; use the existing typed bridge and application contracts. | Open implementation dependency. |
| #102 | Reusable supervision, one active backend, bounded readiness/recovery, no renderer/container Docker socket. | Open lifecycle dependency. |
| #363 | Electron Main is the sole Docker authority; endpoint, Engine, plan, and owned-resource identity fail closed around bounded lifecycle operations. | Host-control foundation and one native macOS arm64 evidence row implemented; runtime integration and remaining platform/release evidence stay open. |
| #105 | OS keyring is Local Desktop root of trust; containers use a broker; secrets support presence/write/delete, never readback. | Closed source-level secret foundation; runtime broker evidence remains open. |
| #107 | Local convenience still authenticates traffic; Host Remote needs explicit TLS, identity, authorization, enrollment, and recovery. | Open authentication dependency. |
| #108 | Profiles and consent are explicit, endpoint-bound, transactional, and never inferred. | Open settings/consent dependency. |
| #123 | SQLCipher data and key remain separate; migrations and cross-container backup/restore fail safely. | Open persistence dependency. |
| #131 | Native engine/lifecycle, multi-architecture, auth, network, offline, secret, recovery, adversarial, and budget tests are release evidence. | Open quality dependency. |
| #132 | Pre-1.0 artifacts need checksums, SBOM, provenance, immutable OCI digests, and no embedded credentials/daemon authority. | Open distribution dependency. |
| #199 | Minimal networks, authenticated workloads, explicit ingress/egress, and zero egress for provider=none. |
Open network dependency. |
| #291 | OpenAPI remains transport-neutral; remote routes require a separately secured gateway and may not expose internal operations by default. | Open API dependency. |
Alternatives rejected
- Keep native sidecars forever: viable today, but rejects a portable, separately testable backend without a demonstrated security advantage.
- Expose the current FastAPI sidecar: it is a private control adapter and lacks the remote authentication, authorization, ingress, and operations boundary required here.
- Give renderer or containers Docker access: turns compromise into host-administrative authority.
- Make Docker Desktop the default: introduces a proprietary licensing and support dependency where an open-source macOS arm64 path is available.
- Add Kubernetes, service mesh, Redis, broker, or external database now: increases supply chain, operations, attack surface, and resource cost without measured need.
- Trust an internal network: permits sibling-container impersonation and lateral movement; authenticated workload identity is required.
- Auto-enable remote mode: violates local-first consent and can unexpectedly publish private genealogy services.
Consequences
The architecture now has a ratified direction for local containerization and advanced self-hosting while retaining Local Desktop as the safe default. Work can be decomposed against named security and release gates without redesigning domain behavior.
The cost is a larger native-platform test matrix, a privileged host supervisor, operator-facing lifecycle and recovery work, runtime/license maintenance, and substantial remote identity and ingress assurance. Until those costs are paid and independently reviewed, no container or remote application runtime described here is supported or shipped.