ADR 31: Personal kit updates and in-app update checks
Status: Proposed
Date: 2026-06-02
Last Updated: 2026-06-02
Context
Self-hosted Substratum ships as a kit of versioned artifacts, not a single binary:
| Component | Role today | Typical install location |
|---|---|---|
| Gateway | API, OAuth, drives | {install_root}/bin/ |
| Edge (Caddy) | Static UI + /api proxy | {install_root}/edge/, serves {install_root}/web/ |
| File explorer UI | Mithril SPA (built assets) | {install_root}/web/ |
| Substratum desktop app | Tauri shell (apps/substratum-explorer) | ~/Applications/Substratum.app (macOS) |
| Installer | Orchestrator wizard (apps/installer-gui) | User-installed .app / package |
ADR 23 names an orchestrator and a versioned kit manifest but leaves update mechanics as “in-app updater, store updates, or replacement bundles.” The alpha implementation already refreshes pieces only through the installer:
- Express install re-stages gateway, edge, and
web/from the installer’s bundled resources. - Open Substratum calls
ensure_desktop_app_installed, comparing embeddedstage-manifest.json(bundle_version,staged_at) with the staged explorer underresources/bundled/explorer/. Substratum.appdoes not check for updates on launch;window.openand other shell behavior require a newer desktop bundle the user must obtain via the installer.
Operators with an already installed kit therefore depend on remembering to open the installer after each release. That does not scale for family users, security patches, or mobile parity (ADR 24). We need a long-term, consistent update model with built-in checks in the applications users actually run, while preserving the installer as the privileged orchestrator for service restarts and elevated steps.
Constraints:
- Split trust domains: gateway runs as a background service; the desktop shell is a separate signed bundle; the installer may require elevation (packages, hosts, LaunchAgents).
- Same-origin OAuth (operations runbook): updating
web/or edge URLs must not breakpublic_base_url/ CORS without a coordinated manifest repair step. - Offline-first home servers: update checks must degrade gracefully (no blocking startup on network failure).
- Hosted SaaS (
hosting_topology: cloud_connected) will use deployment pipelines, not the personal kit channel—this ADR focuses onself_hostedand desktop installer distribution.
Decision
1. Single source of truth: Kit Release Manifest
Introduce a Kit Release Manifest (KRM), versioned JSON, describing the expected versions of every installable component for a given release channel.
Local authoritative copy (written by install/upgrade):
{install_root}/substratum-kit-release.json
Optional remote catalog (fetched on check):
https://<release-host>/personal-kit/<channel>/latest.json
(or Tangled/GitHub Releases asset URL—host is a release-pipeline detail)
Minimum fields per component:
{
"schema_version": 1,
"release_id": "2026.06.02-alpha.3",
"channel": "stable",
"components": {
"gateway": { "version": "1.0.0", "artifact_digest": "sha256:…" },
"web": { "version": "1.0.0", "artifact_digest": "sha256:…" },
"edge": { "version": "1.0.0" },
"explorer_shell": {
"bundle_version": "0.2.0",
"staged_at": 1780400000
}
},
"min_installer_version": "0.3.0"
}Rules:
- Express install and successful upgrades atomically write this file after staging artifacts.
- Staging scripts (
stage-manifest.jsonfor explorer) remain the build-time input; KRM is the runtime contract across components. release_idis opaque to users; UI shows friendly copy (“Update available: June 2026 security patch”).
2. Update Coordinator (orchestrator module)
All apply operations go through one Rust module (proposed: apps/installer-gui/src-tauri/src/self_hosted/kit_update.rs, later extractable to a shared crate if Android/desktop agents need it).
Responsibilities:
| Responsibility | Owner |
|---|---|
| Read local KRM + installed digests | Update Coordinator |
| Compare to remote catalog / bundled “shipping” manifest | Update Coordinator |
| Plan ordered upgrade steps (web → gateway → edge reload → explorer shell) | Update Coordinator |
Execute file copies, manifest repairs (repair_gateway_oauth_manifest), service reload | Existing platform installers + manifests |
| Elevated OS packages (Postgres major bumps) | Installer wizard only |
Apply order (default) minimizes downtime and OAuth breakage:
- Stage
web/(static UI). - Replace gateway binary; run DB migrations if
release_idrequires it. - Rewrite edge Caddyfile if needed; reload edge (not full uninstall).
- Replace desktop shell if
explorer_shellchanged. - Write new KRM; append line to
{install_root}/logs/installer.log.
Gateway restart and edge reload use existing platform::*Installer hooks (ADR 24).
3. Built-in update checks (not necessarily silent apply)
Every user-facing application performs a non-blocking check on startup (and on a manual “Check for updates” action). Checks never require network for cold start; they schedule after first paint / tray idle.
| Application | Check behavior | Apply behavior |
|---|---|---|
Installer (installer-gui) | Compare bundled shipping manifest vs local KRM; show Step 0 / Step 4 banner if behind | Full Express install or new “Update home server” command (thin wrapper over Coordinator) |
Substratum (substratum-explorer) | Invoke Tauri command kit_check_updates → returns { status, components[] } | Phase 1: deep-link or modal: “Open Installer to update.” Phase 2: shell-only updates via tauri-plugin-updater when signed feed exists |
| File explorer (browser or webview) | GET /api/v1/system/kit-status (read-only) from gateway | Display banner; link opens installer custom URL scheme or OS default handler |
| Installer (self) | Compare own CFBundleVersion / crate version to min_installer_version in remote catalog | Prompt to download new installer bundle (separate channel) |
Check outcomes (shared enum in API):
up_to_dateupdate_available(informational)update_required(breaking schema / security; block sensitive actions only)check_skipped(offline, throttled, or user disabled)check_failed(logged; no user-blocking modal)
Throttle: at most one remote check per app per 24 hours unless user clicks “Check now.” Persist last_check_at in {install_root}/substratum-kit-release.json or a small sidecar file.
4. Channels and distribution
| Channel | Audience | Remote catalog |
|---|---|---|
stable | Default self-hosted | Signed manifest on release host |
beta | Opt-in (SUBSTRATUM_UPDATE_CHANNEL env or profile flag) | Same host, different path |
dev | Engineers | Bundled manifest only; no remote fetch |
Installer remains the only component that may apply full kit updates without extra consent in Phase 1. Substratum.app may apply shell-only updates in Phase 2 via Tauri updater only when artifacts are code-signed and the feed URL is pinned in tauri.conf.json.
5. Security and integrity
- Remote KRM and artifact bundles must be served over HTTPS.
- Phase 1: digests in KRM compared after download; shipping manifest embedded in installer at build time (tamper-evident via signed installer bundle).
- Phase 2: minisign or platform codesign on each artifact; gateway refuses to advertise
update_requiredunless signature verifies. - No auto-execute of scripts from the network; Coordinator copies known artifact types into fixed paths under
install_root. - Log every check and apply:
{install_root}/logs/installer.log(existing) + optionalkit-update.log.
6. UX principles
- Inform, don’t nag: one banner per session; snooze 7 days.
- Explicit apply: default requires user confirmation for service restart (“Updates need ~30s downtime”).
- Transparent components: show what will change (Gateway, App, Web UI)—not a single opaque version number.
- Repair stays automatic: manifest repairs (OAuth loopback base URL, PDS provider) run as migration steps inside apply, not separate operator rituals.
7. Phased delivery
| Phase | Scope | User-visible result |
|---|---|---|
| 0 (today) | Installer-only refresh via Express install / Open Substratum | No in-app check |
| 1 | Local KRM + kit_check_updates + installer/explorer banners + GET /api/v1/system/kit-status | “Update available” with Open Installer |
| 2 | Remote catalog + signed downloads + Coordinator apply_kit_update command | One-click update from installer; explorer still defers shell to installer unless Tauri feed ready |
| 3 | Background check (LaunchAgent / scheduled task optional) + update_required for CVEs | Automatic notify; optional auto-apply for shell-only |
Explicitly out of scope for Phase 1–2: updating the installer app itself in place (replace bundle download only); Android/iOS store update flows (separate ADR alignment with ADR 23); mesh triangle rotation via update channel.
8. API sketch (gateway ingress)
Read-only status for the file explorer (DTOs live in crates/ingress/src/models per global AGENTS rules):
GET /api/v1/system/kit-status
→ { release_id, channel, components: [{ id, installed_version, status }], update_available: bool }Optional authenticated endpoint for apply remains installer-local (Tauri IPC), not public HTTP, to avoid remote drive-by upgrades.
Consequences
- Positive: Operators get a predictable upgrade path; engineering aligns releases to one manifest; explorer and installer share vocabulary; OAuth/CORS repairs become migrations in the Coordinator; path to signed auto-update for the desktop shell without forked logic.
- Negative: Release CI must publish KRM + digests; more integration tests (install vN → check → apply vN+1); dual paths during Phase 1–2 (installer vs future Tauri updater) require clear docs.
- Neutral: Alpha users keep using Open Substratum until Phase 1 ships; behavior is backward compatible if KRM is absent (treat as “unknown / check skipped”).
Related
- ADR 23: Personal Unified Installer and Cross-Platform Kit
- ADR 24: Installer post-MVP — mesh modes, explicit roles, and cloud relay
- ADR 13: Twelve-Factor App — config and release discipline for gateway
- ADR 20: File Explorer Service Layer — UI banner and health surfaces
- Self-hosted installer troubleshooting — interim manual update steps until Phase 1
substratum-installer-profile-v1substratum-gateway-application-v1