plan: rev 2 — add thin-client API distribution and compute entity provisioning

Two onboarding paths share the same lifecycle state machine:

1. Workstation self-enrollment: curl bootstrap.sh | bash → API enroll
   → thin client (no git clone, no sync timer). Context poller replaces
   5-minute pull. Only AGENTS.md, OIKOS.md, tools/ fetched to disk.

2. Compute entity provisioning: POST /entities/provision → Oikos
   actuator creates LXC/VM/container on Proxmox host. Validates VMID,
   IP, capacity, template. Creates relationship edges (hosts, provides,
   mounts, depends-on) atomically. No self-enrollment, no age key.

Adds: context endpoint for agent file deltas, provisioning_steps table,
actuator provision methods, type-specific transition checks, full
verification matrix covering both paths.
This commit is contained in:
2026-07-07 23:56:07 +02:00
parent 638e313c66
commit 79dc87d584

View File

@@ -1,15 +1,23 @@
# Plan: Client lifecycle — enrollment through deprecation in Oikos Go
**Status:** Planned (2026-07-07)
**Status:** Planned (2026-07-07, rev 2) — rev 2 adds thin-client API distribution model
and compute entity (LXC/VM/container) provisioning flow.
## Goal
Define and implement the complete lifecycle of a homelab client in the Oikos Go
runtime: how a new machine is provisioned, enrolled, given secrets, synced,
operated, and eventually deprecated (or decommissioned or destroyed). Every
state transition feeds the Postgres DB as the authoritative source of truth.
No step depends on the archived Python `secrets-issuance` server or the
non-existent `bin/homelab` CLI.
Define and implement the complete lifecycle of every compute entity in the Oikos
Go runtime — workstations, Proxmox hosts, LXCs, VMs, and containers. Two
onboarding paths share the same lifecycle state machine:
1. **Workstation self-enrollment**: the machine calls the API to enroll itself
2. **Compute entity provisioning**: Oikos creates the entity (LXC/VM/container)
on a host via the actuator, and the DB is the sole source of truth from the
first API call
Every state transition feeds the Postgres DB. No step depends on the archived
Python `secrets-issuance` server or the non-existent `bin/homelab` CLI. The
full repo clone + git-sync timer is replaced with a thin API-distribution model
for workstations.
## Current state — what exists vs. what runs
@@ -34,31 +42,36 @@ secrets, state, and lifecycle transitions.
```
┌──────────────────────────────────────────────────────────────┐
NEW CLIENT (bare machine)
WORKSTATION (bare machine) │
│ │
│ 1. curl bootstrap.sh | sudo bash
│ → clones repo, installs sync timer
│ → calls POST /api/v1/clients/enroll (new endpoint)
│ → receives age keypair from Oikos API
│ → writes /etc/age/key.txt
│ → sync timer starts pulling every 5 min
│ 1. curl bootstrap.sh | sudo bash (from raw Gitea URL)
│ → fetches AGENTS.md, OIKOS.md, tools/ to /opt/homelab/
│ → calls POST /api/v1/clients/enroll
│ → receives age keypair + Infisical identity
│ → writes /etc/age/key.txt, /etc/infisical/identity
│ → polls GET /api/v1/clients/{slug}/context for updates
│ (no git clone, no sync timer) │
└──────────────────────────┬───────────────────────────────────┘
│ POST /api/v1/clients/enroll
┌──────────────────────────────────────────────────────────────┐
│ OIKOS API (Go, :8090) │
│ │
│ Workstation endpoints: │
│ POST /api/v1/clients/enroll — issue age key, set state │
│ POST /api/v1/clients/{slug}/activate — provisioning→active │
│ POST /api/v1/clients/{slug}/deprecate — active→deprecated │
│ POST /api/v1/clients/{slug}/destroy — deprecated→destroyed │
│ GET /api/v1/clients/{slug}/secrets — client's accessible │
secrets (Infisical lookup by machine identity)
MCP whoami(hostname) — client self-introspection
MCP explain(service) — compact context card
MCP preflight(service) — risk classification
MCP get_change_history(entity) — ledger entries
MCP get_state_snapshot() — last scheduler pass
│ GET /api/v1/clients/{slug}/secrets — accessible secrets
GET /api/v1/clients/{slug}/context — agent context delta
Compute entity endpoints:
POST /api/v1/entities/provision — create LXC/VM/container
GET /api/v1/entities/{slug}/provision/status — progress
│ MCP tools: │
│ whoami, explain, preflight, get_change_history, │
│ get_state_snapshot, list_my_secrets │
└──────────────────────────┬───────────────────────────────────┘
│ writes
@@ -73,18 +86,241 @@ secrets, state, and lifecycle transitions.
└──────────────────────────────────────────────────────────────┘
```
## Client lifecycle: state machine
## Thin client model — why the clone + sync timer goes away
The repo was the source of truth when Oikos was Python + YAML files. Now the DB
is the source of truth. A full clone of the repo onto every workstation is
unnecessary — the only things a workstation actually needs on disk are:
| Artifact | Why local? | How delivered |
|----------|-----------|---------------|
| `AGENTS.md` + `OIKOS.md` | Agent reads these at startup; can't query MCP before knowing MCP exists | Fetched once by bootstrap.sh, cached to `/opt/homelab/` |
| `.sops.yaml` | Recipient rules for age decryption (DR fallback if Infisical is down) | Served by `GET /api/v1/clients/{slug}/context` |
| SOPS-encrypted secrets | DR cold recovery (Infisical is primary) | Served by `GET /api/v1/secrets/sops-backups` or fetched on enrollment |
| Age private key | Decrypt SOPS files | Delivered once in enroll response, written to `/etc/age/key.txt` |
| Infisical identity | Authenticate to Infisical for primary secrets | Delivered once in enroll response, written to `/etc/infisical/identity` |
| `tools/` (caveman, hermes-soul) | Auto-setup after pull | Fetched by bootstrap.sh once; API context endpoint serves updates |
Everything else — topology, knowledge, policy, runbooks, ledger — is queried
live from the DB via MCP.
### What replaces the sync timer
The 5-minute `git pull` is replaced by a lightweight API poll:
```
GET /api/v1/clients/{slug}/context?since=2026-07-07T12:00:00Z
```
Returns a JSON delta:
```json
{
"agent_files_changed": ["AGENTS.md", "OIKOS.md"],
"sops_config_changed": true,
"tools_changed": ["setup-caveman.sh"],
"since": "2026-07-07T12:05:00Z"
}
```
The client fetches only changed files. Poll interval: 5 minutes (same as old
timer, but HTTP instead of git). On delta, the client writes updated files to
`/opt/homelab/` and re-runs any changed `tools/*.setup.sh` scripts.
### What stays a full clone
The **control plane host** (mac-mini, running the Oikos Docker stack) keeps the
full clone at `/opt/homelab-context/`. It is the deployment target, the seed
source, and the operator's working copy. All other workstations are thin clients.
### bootstrap.sh changes (thin client)
```bash
# Old: full clone
git clone "$REPO_HTTPS" "$CLONE_DIR"
# New: fetch only what the agent needs
mkdir -p /opt/homelab/.agents/shared /opt/homelab/.agents/skills
curl -s "$RAW_URL/AGENTS.md" -o /opt/homelab/AGENTS.md
curl -s "$RAW_URL/.agents/OIKOS.md" -o /opt/homelab/OIKOS.md
curl -s "$RAW_URL/.agents/shared/caveman.md" -o /opt/homelab/caveman.md
# Then enroll via API (gets age key + Infisical identity)
# Then start the context poller
```
No `git` dependency on workstations. No `post-pull.sh`. No sync timer unit
files. Just a cron/launchd job that hits `GET /context` every 5 minutes.
### Transition for existing enrolled clients
Existing clients with full clones continue working. The context poller is
additive — it writes updated files to `/opt/homelab/` alongside the clone.
Once the poller is proven, the clone can be removed and the git-based timer
disabled. Backward compatible, no flag day.
## Compute entity lifecycle — LXC, VM, container provisioning
Workstations self-enroll. Compute entities (LXCs, VMs, Docker containers) are
**created by Oikos** — an operator (or Hermes) issues an intent, and Oikos
provisions the entity on a Proxmox host via the actuator. The DB is the source
of truth from the first API call; there is no bootstrap.sh, no mesh join, no
age key — the entity exists because Oikos put it there.
### Provisioning flow
```
Operator (or Hermes via MCP):
"create LXC 130 on hubris running jellyfin, 2 cores, 2GB RAM, 20GB disk,
mount /mnt/library, IP 192.168.8.130, privileged"
POST /api/v1/entities/provision
{
"slug": "lxc:jellyfin",
"type": "lxc",
"name": "Jellyfin",
"host": "host:hubris",
"attributes": {
"vmid": 130,
"cores": 2,
"ram_mb": 2048,
"disk_gb": 20,
"ip": "192.168.8.130",
"privileged": true,
"mounts": [{"source": "/mnt/library", "target": "/mnt/library"}],
"template": "debian-12-standard",
"services": ["jellyfin"]
}
}
│ Oikos API:
│ 1. Creates entity in DB: lxc:jellyfin, state = planned
│ 2. Validates: IP not in use, VMID not taken, host has capacity
│ 3. Policy classifier: config_mutation → requests operator approval
│ 4. On approval, transitions to provisioning
│ 5. Creates execution record
│ 6. Actuator SSHs to host:hubris:
│ pct create 130 /var/lib/vz/template/cache/debian-12-standard.tar.zst \
│ --cores 2 --memory 2048 --rootfs local-lvm:20 \
│ --net0 name=eth0,bridge=vmbr0,ip=192.168.8.130/24,gw=192.168.8.2 \
│ --unprivileged 0
│ pct start 130
│ pct exec 130 -- apt update && apt install -y jellyfin-server
│ # mount, service enable, firewall rules
│ 7. Polls health check until passing
│ 8. Transitions to active
│ 9. Creates relationship edges: host:hubris hosts lxc:jellyfin,
│ lxc:jellyfin provides service:jellyfin, etc.
│ 10. Writes audit log
Entity in DB: lxc:jellyfin, state = active
Relationships: host:hubris → hosts → lxc:jellyfin
lxc:jellyfin → provides → service:jellyfin
lxc:jellyfin → mounts → storage:library
```
### State machine (shared by workstations and compute entities)
```
┌─────────────────────────────┐
│ WORKSTATION │
│ Operator runs bootstrap.sh │
│ → self-enrolls via API │
│ → gets age key + Infisical │
│ → polls /context for deltas │
└──────────────┬──────────────┘
[planned] ──→ provisioning ──→ active ──→ migrating ──→ active
│ │
▲ ▲
│ │ ├──→ deprecated ──→ destroyed
│ │ │
└──→ failed └──→ failed
└──→ destroyed (cancelled)
└──→ failed
│ └──→ failed ┌─────────────────────────────┐
│ │ COMPUTE ENTITY │
│ │ POST /entities/provision │
└──→ destroyed (cancelled) │ → Oikos actuator creates │
│ LXC/VM/container on host │
│ → no self-enrollment │
│ → no mesh join │
│ → no age key │
└─────────────────────────────┘
```
### Provisioning differences by type
| Aspect | Workstation | LXC | VM | Docker container |
|--------|------------|-----|----|-----------------|
| Who creates the entity | Operator via API/seed | Operator/Hermes via `POST /entities/provision` | Same as LXC | Same as LXC |
| How it reaches `provisioning` | Self-enrolls via `POST /clients/enroll` | API transitions automatically after operator approval | Same as LXC | Same as LXC |
| Age keypair | Generated, delivered to client | None — no SOPS access needed | None | None |
| Infisical identity | Created, scoped to `/clients/<slug>/*` | Only if the LXC hosts services that need secrets | Same as LXC | Same as LXC |
| Mesh join | Netbird/Tailscale (if remote access needed) | No — reachable via host LAN IP | Optional | No |
| Health check | Scheduler probes HTTP/TCP/SSH | Scheduler probes service port on host LAN IP | Same as LXC | Scheduler probes container port |
| `provisioning → active` checks | `age-key-enrolled`, `mesh-joined`, `health-check-answering` | `health-check-answering`, `service-running`, `mounts-verified` | Same as LXC + `vm-agent-responding` | `health-check-answering`, `container-running` |
| `deprecated → destroyed` checks | `secrets-revoked`, `no-inbound-edges`, `ingress-dns-removed` | `backups-verified`, `no-inbound-edges`, `ingress-dns-removed` | Same as LXC | `no-inbound-edges` |
| Secrets to revoke on destroy | Age key, Infisical identity | Infisical identity (if any), service tokens | Same as LXC | Infisical identity (if any) |
### Compute entity attributes schema
```json
{
"vmid": 130,
"cores": 2,
"ram_mb": 2048,
"disk_gb": 20,
"ip": "192.168.8.130",
"privileged": true,
"os": "debian-12",
"template": "debian-12-standard",
"mounts": [
{"source": "/mnt/library", "target": "/mnt/library", "options": "ro"}
],
"services": ["jellyfin"],
"provisioned_by": "ws:mac-mini",
"provisioned_at": "2026-07-07T14:00:00Z",
"host": "host:hubris"
}
```
### API endpoints for compute entity provisioning
| Method | Path | Scope | Purpose |
|--------|------|-------|---------|
| `POST` | `/api/v1/entities/provision` | operator | Declare intent to create an LXC/VM/container. Creates entity in `planned`, validates constraints, queues execution for operator approval. |
| `GET` | `/api/v1/entities/{slug}/provision/status` | operator | Poll provisioning progress: steps completed, current step, errors |
| `POST` | `/api/v1/entities/{slug}/provision/retry` | operator | Retry a failed provisioning step |
### What `POST /entities/provision` validates
1. **Slug is available**: no existing entity with same slug
2. **VMID not in use**: query entities where `attributes->>'vmid'` = requested VMID
3. **IP not in use**: query entities where `attributes->>'ip'` = requested IP
4. **Host exists and is active**: `host:hubris` must be an entity with state `active`
5. **Host has capacity**: check entity_status for CPU/RAM/disk headroom on host
6. **Template exists**: Proxmox template must be available on the host
7. **Mount source exists**: storage entity must exist for each mount
8. **Policy classification**: `config_mutation` → operator approval required (auto-act only if Hermes has autonomy for this specific action type)
### Relationship edges created on provision
When an LXC is provisioned, these edges are written to the `relationships` table:
| Source | Edge type | Target | Why |
|--------|-----------|--------|-----|
| `host:hubris` | `hosts` | `lxc:jellyfin` | Physical colocation |
| `lxc:jellyfin` | `provides` | `service:jellyfin` | Service mapping |
| `lxc:jellyfin` | `mounts` | `storage:library` | Storage dependency |
| `lxc:jellyfin` | `depends-on` | `host:hubris` | Blast radius: hubris down → jellyfin down |
| `service:jellyfin` | `depends-on` | `lxc:jellyfin` | Service availability ties to container |
| `lxc:jellyfin` | `depends-on` | `service:dns` | DNS resolution |
| `lxc:jellyfin` | `depends-on` | `service:caddy` | If ingress-exposed |
All edges are created atomically during provisioning. The `blast_radius()` CTE
walks these edges to answer "what breaks if hubris goes down?"
### State: `planned`
The operator declares intent. A client entity exists in the DB with state
@@ -348,46 +584,68 @@ Add to `api/openapi.yaml`, regenerate with `make generate`, implement in
| File | Change |
|------|--------|
| `bootstrap.sh` | Replace `secrets.hubris.network/issue` call with `POST /api/v1/clients/enroll`. Remove dead symlinks. |
| `api/openapi.yaml` | Add client enrollment, lifecycle, and secret endpoints |
| `internal/httpapi/impl.go` | Implement client lifecycle handlers |
| `internal/db/queries/clients.sql` | Add client-specific sqlc queries |
| `bootstrap.sh` | Replace git clone with thin fetch of AGENTS.md + OIKOS.md + tools/. Replace `secrets.hubris.network/issue` with `POST /api/v1/clients/enroll`. Replace sync timer with context poller. Remove dead symlinks. |
| `api/openapi.yaml` | Add workstation enrollment, lifecycle, secret, and context endpoints. Add compute entity provision, status, retry endpoints. |
| `internal/httpapi/impl.go` | Implement workstation lifecycle handlers. Implement compute entity provisioning handlers. |
| `internal/db/queries/clients.sql` | Add client-specific sqlc queries (lookup by slug+type, capacity checks) |
| `internal/db/queries/entities.sql` | Add provision validation queries (VMID collision, IP collision, template availability) |
| `internal/mcp/server.go` | Register whoami, explain, preflight, get_change_history, get_state_snapshot, list_my_secrets |
| `internal/secrets/infisical.go` | Add `CreateMachineIdentity`, `DeleteMachineIdentity`, `StoreClientKey` |
| `internal/ontology/validate.go` | Implement lifecycle transition checks for infrastructure lifecycle |
| `seeds/ontology.yaml` | Add client-specific attributes schema for machine types |
| `migrations/012_client_enrollment.up.sql` | Index for slug+type lookups |
| `AGENTS.md` | Update MCP tool list to match actual implementation |
| `CLIENTS.md` | Update enrollment flow to reference Oikos API, not Python issuance |
| `CONTRIBUTING.md` | Add client lifecycle as a documented extension point |
| `internal/actuator/actuator.go` | Add `ProvisionLXC`, `ProvisionVM` methods (pct create, start, exec, health poll) |
| `internal/ontology/validate.go` | Implement lifecycle transition checks for infrastructure lifecycle (both workstation and compute variants) |
| `seeds/ontology.yaml` | Add workstation-specific and compute-entity attribute schemas. Add provision-entity skill definition. |
| `migrations/012_client_enrollment.up.sql` | Index for slug+type lookups. Add `provisioning_steps` tracking table. |
| `tools/context-poller.sh` | New: lightweight daemon that polls `GET /context` and applies deltas |
| `AGENTS.md` | Update MCP tool list. Document thin client model. |
| `CLIENTS.md` | Update enrollment flow: thin client, API-based, no git clone. |
| `CONTRIBUTING.md` | Add compute entity provisioning as an extension point. |
## Files deleted or deprecated
| File | Disposition |
|------|-------------|
| `archive/secrets-issuance/` | Already archived. Add deprecation notice referencing this plan. |
| `archive/secrets-sops-backup/` | Keep for DR. Add note that new clients use Infisical, SOPS is fallback. |
| `archive/secrets-sops-backup/` | Keep for DR. SOPS backups served via `GET /api/v1/secrets/sops-backups`. |
| `scripts/sync/` (install.sh, linux/*, macos/*) | Deprecated. Replaced by `tools/context-poller.sh`. |
| `tools/post-pull.sh` | Keep on control plane host only. Remove from thin client distribution. |
| Any reference to `bin/homelab` | Delete or comment out in bootstrap.sh; CLI doesn't exist. |
| `tools/*.setup.sh` references | Either create the files or remove the auto-setup convention from post-pull.sh. |
| `tools/*.setup.sh` references | Either create the files or remove the auto-setup convention. Context poller can trigger setup scripts on delta. |
| Root `inventory.yaml` | Eventually deprecated. `seeds/inventory.yaml` + DB are authoritative. Keep during transition. |
## Phased implementation
### Phase 1 — API + DB (P0, this week)
1. Write `migrations/012_client_enrollment.up.sql`
2. Add client endpoints to `api/openapi.yaml`
1. Write `migrations/012_client_enrollment.up.sql` (add `provisioning_steps` table)
2. Add workstation + compute entity endpoints to `api/openapi.yaml`
3. Run `make generate`
4. Implement enrollment handler (`POST /api/v1/clients/enroll`):
- Age key generation
- Infisical machine identity creation
- Entity attribute update
- Audit log write
5. Implement lifecycle transition handlers (activate, deprecate, destroy, fail)
5. Implement workstation lifecycle transition handlers (activate, deprecate, destroy, fail)
6. Implement `GET /api/v1/clients/{slug}/secrets`
7. Update `seeds/ontology.yaml` with client attribute schemas
8. Add sqlc queries in `internal/db/queries/clients.sql`
7. Implement `GET /api/v1/clients/{slug}/context` (agent file deltas)
8. Update `seeds/ontology.yaml` with workstation and compute attribute schemas
9. Add sqlc queries in `internal/db/queries/clients.sql`
### Phase 2 — MCP tools (P1, next week)
### Phase 2 — Compute entity provisioning (P0, this week)
1. Implement `POST /api/v1/entities/provision`:
- Entity creation in `planned` state
- Constraint validation (VMID, IP, capacity, template, mounts)
- Policy classification + operator approval flow
- Transition to `provisioning` on approval
2. Implement actuator provisioning methods:
- `ProvisionLXC(slug, attributes)``pct create`, `pct start`, package install, mount, service enable
- `ProvisionVM(slug, attributes)``qm create`, `qm start`
3. Implement `GET /api/v1/entities/{slug}/provision/status`
4. Implement `POST /api/v1/entities/{slug}/provision/retry`
5. Write relationship edges atomically on provision completion
6. Add validation queries for collision detection
### Phase 3 — MCP tools (P1, next week)
1. Register `whoami(hostname)` in `internal/mcp/server.go`
2. Register `explain(service)` — compact context card from DB
@@ -396,44 +654,71 @@ Add to `api/openapi.yaml`, regenerate with `make generate`, implement in
5. Register `get_state_snapshot()`
6. Register `list_my_secrets(caller_pubkey?)`
### Phase 3Bootstrap script cleanup (P1, next week)
### Phase 4Thin client distribution (P1, next week)
1. Replace secrets issuance URL with Oikos API endpoint
2. Remove `--no-secrets` / `--no-mesh` or rewire them to degraded modes
3. Remove `bin/homelab` symlink
4. Update Infisical identity file creation
5. Test full enrollment on a fresh machine
1. Write `tools/context-poller.sh` — polls `GET /context`, writes deltas, triggers setup scripts
2. Rewrite `bootstrap.sh`:
- Fetch AGENTS.md, OIKOS.md, tools/ from raw Gitea URL (not full clone)
- Call `POST /api/v1/clients/enroll`
- Install context poller (cron/launchd)
- Remove git dependency for workstations
3. Create missing `tools/setup-caveman.sh` and `tools/setup-hermes-soul.sh` (or serve from context endpoint)
4. Test full enrollment on a fresh machine
### Phase 4 — Transition check enforcement (P2, within 2 weeks)
### Phase 5 — Transition check enforcement (P2, within 2 weeks)
1. Implement all `provisioning → active` checks in `internal/ontology/validate.go`
2. Implement all `deprecated → destroyed` checks
1. Implement all `provisioning → active` checks (workstation and compute variants)
2. Implement all `deprecated → destroyed` checks (workstation and compute variants)
3. Wire checks into lifecycle transition handlers
4. Test that `POST /activate` fails when checks don't pass
5. Test that `POST /destroy` fails when inbound edges exist
6. Test that provision fails when VMID/IP collision detected
### Phase 5 — Cleanup (P2, within 2 weeks)
### Phase 6 — Cleanup + existing client migration (P2, within 2 weeks)
1. Delete or comment-out dead code in bootstrap.sh
2. Recreate `tools/setup-caveman.sh` and `tools/setup-hermes-soul.sh` (or remove references)
3. Update AGENTS.md MCP tool list
4. Update CLIENTS.md enrollment flow
5. Archive Python secrets-issuance with final deprecation note
6. Run `make generate-check` and full test suite
2. Deprecate `scripts/sync/` (install.sh, systemd timer, launchd plist)
3. Keep `tools/post-pull.sh` for control plane host only
4. Update AGENTS.md MCP tool list + thin client model
5. Update CLIENTS.md enrollment flow
6. Archive Python secrets-issuance with final deprecation note
7. Add backward-compat: existing clients with full clones continue working;
context poller runs alongside until operator removes the clone
8. Run `make generate-check` and full test suite
## Verification
- Fresh machine with no prior state: `curl bootstrap.sh | sudo bash` → machine
shows up in DB as `provisioning` with age pubkey, Infisical identity, and sync
timer running
### Workstation enrollment
- Fresh machine with no prior state: `curl bootstrap.sh | sudo bash` → fetches
agent files only (no git clone), enrolls via API, machine shows up in DB as
`provisioning` with age pubkey and Infisical identity
- Context poller running: `GET /context?since=...` returns file deltas
- `POST /api/v1/clients/ws:test-machine/activate` → state → `active`, all checks pass
- `POST /api/v1/clients/ws:test-machine/deprecate` → state → `deprecated`
- `POST /api/v1/clients/ws:test-machine/destroy` → fails if edges exist; succeeds
after edges removed, secrets revoked
- MCP `whoami(ws:test-machine)` returns client record with peers and health
### Compute entity provisioning
- `POST /api/v1/entities/provision` with valid LXC spec → entity created in
`planned`, approval requested, on approval → `provisioning`, actuator creates
LXC on host, health check passes → `active`
- `POST /api/v1/entities/provision` with conflicting VMID → 409, error message
with conflicting entity slug
- `POST /api/v1/entities/provision` with IP already in use → 409
- `POST /api/v1/entities/provision` with unknown host → 400
- Relationship edges created: `hosts`, `provides`, `mounts`, `depends-on`
all present after provisioning
- `GET /entities/lxc:test-lxc/blast-radius` → returns host, dependent services
- `POST /destroy` on a provisioned LXC → validates backups + edges, deletes
relationships, transitions to `destroyed`
### Integrated
- MCP `explain(service:caddy)` returns context card with relations and risk class
- `GET /api/v1/clients/ws:test-machine/secrets` returns secrets list scoped to client
- Existing clients continue working through the sync timer (no regression)
- Thin client survives Infisical outage: SOPS fallback works with local age key + `.sops.yaml`
served via context endpoint
- Existing clients with full clones continue working (no regression)
- `make test test-db generate-check` passes
## Related
@@ -442,10 +727,15 @@ Add to `api/openapi.yaml`, regenerate with `make generate`, implement in
- [seeds/ontology.yaml](../seeds/ontology.yaml) — lifecycle definitions, entity type hierarchy
- [seeds/policy.yaml](../seeds/policy.yaml) — risk classes, approval rules
- [CLIENTS.md](../CLIENTS.md) — client onboarding guide (update after this plan)
- [bootstrap.sh](../bootstrap.sh) — current enrollment script (rewrite in Phase 3)
- [bootstrap.sh](../bootstrap.sh) — current enrollment script (rewrite in Phase 4)
## Changelog
- 2026-07-07 — initial plan. Replaces Python secrets-issuance, defines full
lifecycle in Go, adds client API endpoints, MCP tools, and Infisical
machine identity integration.
- 2026-07-07 rev 2 — added thin-client API distribution model (no git clone,
no sync timer; context poller replaces 5-minute pull). Added compute entity
provisioning flow (LXC/VM/container created by Oikos actuator, not
self-enrolled). Extended state machine to cover both onboarding paths with
type-specific transition checks, attribute schemas, and relationship edges.
- 2026-07-07 rev 1 — initial plan. Replaces Python secrets-issuance, defines
full workstation lifecycle in Go, adds client API endpoints, MCP tools,
and Infisical machine identity integration.