# ADR 0011 — Client lifecycle sequence diagrams **Status:** Accepted **Date:** 2026-07-08 ## Context The Oikos client lifecycle spans two distinct onboarding paths — workstation self-enrollment and compute entity (LXC/VM) provisioning — sharing the same infrastructure lifecycle state machine (`planned → provisioning → active → migrating → deprecated → destroyed`). These flows must be documented and validated against the Go implementation. ## Decision All machines in the homelab follow the same lifecycle state machine defined in `seeds/ontology.yaml` under the `infrastructure` lifecycle. The implementation lives in `internal/httpapi/impl.go` (handlers), `internal/actuator/actuator.go` (SSH provisioning), and `internal/ontology/validate.go` (transition check enforcement). ## Workstation self-enrollment flow ```mermaid sequenceDiagram participant Op as Operator participant BS as bootstrap.sh participant API as Oikos API (:8090) participant DB as Postgres participant Inf as Infisical Op->>API: POST /entities {slug:"ws:laptop", type:"workstation", state:"planned"} API->>DB: INSERT entities (planned) API-->>Op: 201 + ETag Op->>BS: curl bootstrap.sh | sudo bash BS->>BS: detect hostname, mesh IP BS->>BS: fetch CLIENTS.md, AGENTS.md, OIKOS.md, tools/ BS->>BS: install age, curl, jq BS->>API: POST /clients/enroll {slug, hostname, mesh_ip} API->>DB: validate state = planned|provisioning API->>API: generateAgeKeypair() API->>API: store age key in Infisical (best-effort) API->>DB: UPDATE state→provisioning, set age_pubkey, mesh_ip, enrolled_at API->>DB: INSERT audit_log (client.enrolled) API-->>BS: {age_private_key, age_public_key, infisical_client_id, infisical_client_secret} BS->>BS: write /etc/age/key.txt (0600) BS->>BS: write /etc/infisical/identity (0600) BS->>BS: install context-poller.sh (launchd/systemd, 5min) loop Every 5 minutes BS->>API: GET /clients/ws:laptop/context?since={timestamp} API->>DB: SELECT context_files, context_version API-->>BS: {agent_files_changed, sops_config_changed, tools_changed} end Op->>API: PATCH /entities/ws:laptop {state:"active"} API->>DB: validate lifecyle transition provisioning→active API->>DB: UPDATE state→active, version+1 API->>DB: INSERT audit_log (client.activated) API-->>Op: 200 + ETag ``` ### Enforced transition checks Before `provisioning → active`, the `TransitionChecks` map in `internal/ontology/validate.go` validates: | Check | Workstation | LXC/VM | |-------|------------|--------| | `age-key-enrolled-if-needed` | Requires `age_pubkey` in attrs | Skipped (no age key for compute entities) | | `mesh-joined-if-needed` | Requires `mesh_ip` in attrs | Skipped | | `health-check-answering` | Queries `entity_status.health != 'down'` | Same | | `doc-page-complete` | Requires at least 1 `documents` edge | Same | ### Before `deprecated → destroyed`: | Check | Description | |-------|-------------| | `no-inbound-edges` | Zero `depends-on`, `hosts`, `provides`, `mounts`, `routes-to`, `stores-on` edges | | `secrets-revoked-and-rekeyed` | `age_pubkey` must be removed from attributes | | `backups-verified` | Audit log must have a `backup-verified` entry in last 30 days | | `ingress-and-dns-removed` | No remaining `routes-to`/`provides`/`hosts` edges | ## Compute entity provisioning flow ```mermaid sequenceDiagram participant Op as Operator/Hermes participant API as Oikos API (:8090) participant DB as Postgres participant Act as Actuator participant PVE as Proxmox Host Op->>API: POST /entities/provision {slug:"lxc:jellyfin", host:"host:hubris", attrs:{vmid,cores,...}} API->>DB: validate host exists, slug not taken API->>DB: INSERT entities (planned) API->>DB: INSERT executions (provision) API->>DB: INSERT provisioning_steps (6 steps, all pending) API->>DB: INSERT relationships (host:hubris hosts lxc:jellyfin) API->>DB: INSERT audit_log (entity.provisioned) API-->>Op: 201 {entity, execution_id} Note over Act,PVE: Actuator loop picks up provisioning execution Act->>API: GET execution for lxc:jellyfin Act->>DB: resolve host:hubris → (mesh IP, ssh user) par Provisioning steps Act->>DB: UPDATE provisioning_step[1] (validate-constraints) Act->>PVE: ssh: pct status {vmid} PVE-->>Act: "does not exist" → ok Act->>DB: UPDATE provisioning_step[1] (ok) Act->>DB: UPDATE provisioning_step[2] (create-container) Act->>PVE: ssh: pct create {vmid} --cores N --memory M --rootfs ... PVE-->>Act: container created Act->>DB: UPDATE provisioning_step[2] (ok) Act->>PVE: ssh: pct exec {vmid} -- apt install -y service1 service2 Act->>DB: UPDATE provisioning_step[4] (ok) Act->>PVE: ssh: pct set {vmid} -mp0 /mnt/library,/mnt/library Act->>DB: UPDATE provisioning_step[5] (ok) Act->>PVE: ssh: pct exec {vmid} -- systemctl is-system-running PVE-->>Act: "running" → ok Act->>DB: UPDATE provisioning_step[6] (ok) end Act->>DB: UPDATE entity state→active Act->>DB: UPDATE execution status→completed ``` ## Deprecation and destruction flow ```mermaid sequenceDiagram participant Op as Operator participant API as Oikos API participant DB as Postgres Note over Op,DB: Active entity → Deprecated Op->>API: PATCH /entities/lxc:jellyfin {state:"deprecated"} API->>DB: validate lifecycle transition active→deprecated API->>DB: UPDATE state→deprecated API-->>Op: 200 Note over Op,DB: Deprecated → Destroyed (with gate checks) Op->>API: PATCH /entities/lxc:jellyfin {state:"destroyed"} alt Inbound edges exist API->>DB: TransitionChecks["no-inbound-edges"] → count > 0 API-->>Op: 409 (inbound edges still exist) else All checks pass API->>DB: TransitionChecks → all pass API->>DB: UPDATE state→destroyed API->>DB: INSERT audit_log (client.destroyed) API-->>Op: 200 end ``` ## Full lifecycle state diagram ```mermaid stateDiagram-v2 [*] --> planned : POST /entities planned --> provisioning : POST /clients/enroll (workstation)
POST /entities/provision (compute) planned --> destroyed : cancelled via PATCH provisioning --> active : PATCH state="active"
checks: age-key, mesh, health provisioning --> failed : error during provisioning active --> migrating : PATCH state="migrating" active --> deprecated : PATCH state="deprecated" active --> failed : PATCH state="failed" migrating --> active : PATCH state="active" migrating --> failed : PATCH state="failed" failed --> active : PATCH state="active"
check: recovery-verified deprecated --> active : PATCH state="active" (un-deprecate) deprecated --> destroyed : PATCH state="destroyed"
checks: no-inbound-edges,
secrets-revoked, backups-verified,
ingress-dns-removed destroyed --> [*] ``` ## Consequences - **Workstations** self-enroll via `bootstrap.sh` → `POST /clients/enroll`. The age keypair is generated server-side and delivered once. - **Compute entities** are provisioned by the actuator over SSH. The operator declares intent via `POST /entities/provision`; the actuator executes step-by-step with DB-tracked progress. - **Transition gates** are enforced by named checks in `internal/ontology/validate.go`. The `deprecated → destroyed` gate blocks until all inbound edges are severed — preventing orphan references. - **Thin clients** poll `GET /clients/{slug}/context` for agent file deltas instead of `git pull`. The control plane host (mac-mini) keeps the full repo clone.