homelab-helper — Backlog¶
The concrete, actionable task list. roadmap.md is the narrative (phases,
rationale, stop-here value); this is the punch list of what's actually left,
grounded in the current state of src/. All phases are tracked here:
remaining Phase 1 items, the landed Phase 3–5 slices (with their
queued follow-ups), and the Phase 6 trust-gradient build.
Priorities are within a phase: P0 = on the critical path, P1 =
needed to meet the phase's acceptance criteria, P2 = strengthens but
doesn't block. Acceptance-criterion references (AC1–AC5, P6-AC1–6) point at
roadmap.md.
Current status snapshot¶
Phases 1 (core), 3, 4, 5 and 6 are build-complete, and Phase 7 slice 1
(agent-triggered execution behind a phone-tap approval, guest migrate,
Kubernetes workload actions) has landed; Phase 2 (continuous agent /
time-series) landed as Phase 7 slice 3's daemon. Full suite: 985+ tests green.
Packaging is release-ready (uv tool install, per-user dirs, tag-driven PyPI
release — see releasing.md).
Live-fleet validation (runbook: docs/live-validation.md): Phases 4–5 swept
10/03/2026 (two n/a on this fleet, onboarding still to run), Phase 6 covered by
the Phase 7 live sessions, Phase 7 signed off end to end (first unattended run 10/05/2026).
Phase-1 foundation, built and green:
- [x] Slice 1 schema — all 11 models + Alembic initial migration
- [x] Probe plugin SDK (
probes/base.py,registry.py, entry-point registration) — meets AC5 - [x] Warm host probes:
host.identity,host.cpu,host.memory,host.network,host.storage,host.services - [x]
KernelSSHAdapter(shared SSH session) +ProbeRunner(probe → Observation rows) - [x]
FingerprintGenerator(exists; not yet consumed) - [x] CLI:
version,db init|status|reset|migrate,discover host,discover show,probes
The rest of Phase 1, and all of Phase 6, is below.
Phase 1 — remaining work¶
P0 — critical path¶
- [ ] Reconciler (
engine/reconciler.py) — the keystone; everything below leans on it. - [x] Plug-rule architecture (
HostProjectionRuleregistry) — extensible by appending entries; future probes don't change the reconciler core - [x]
host.identity.*slice →Host.capabilitiesprojection + freshness markers (discovery_last_run,last_verified) - [x] Latest-observation-per-key precedence (single-source; multi-source lands with non-SSH probes)
- [x] Host-field idempotency: re-run is a no-op (returns empty deltas)
- [x] CLI wiring:
helper discover hostinvokes the reconciler after the probe batch - [x] Replay-style test pattern (in-process fixtures); migrates to YAML when example fixture lands
- [x]
transformhook onHostProjectionRule(raw observation value → typed column domain) +normalize_archmapper - [x]
host.cpu.*slice:host.cpu.architecture→Host.arch(typed); model, vendor, sockets, cores, threads, threads_per_core, freq, cache, flags, interesting_flags → capabilities (cpu_*keys) - [x]
host.memory.*slice (totals only): mem/swap/hugepage totals from/proc/meminfo→ capabilities. DIMM-level keys land with the lineage slice below as first-class rows, not capabilities. - [x] DIMM
PhysicalPart/Placementlineage:host.memory.dimmsobservation contract (list of populated-slot dicts) → serial-keyedPhysicalPartupsert with field enrichment; append-onlyPlacementopen/close; cross-host move closes prior placement; DIMMs without serial counted inparts_skipped_no_identity; no-DIMM-observation safety (doesn't close existing placements). Future dmidecode-driven probe will emit the observation contract. - [x]
host.storage.*slice: scalar projections (disk_count,disk_names,total_disk_bytes→ capabilities) + per-devicePhysicalPart/Placementlineage fromhost.storage.devices. WWN-preferred / serial-fallback identity (USB enclosures forge serials); kind dispatched per-device (NVMe/SSD/HDD/OTHER) with reclassification on better evidence; partitions/LVM/RAID ignored. Kind-filtered close-loop prevents DIMM reconcile from disturbing storage placements (latent bug fix landed in this slice). - [x]
host.network.*slice: scalar projections (network_interface_count,network_interface_names→ capabilities) + NICPhysicalPart/Placementlineage fromhost.network.interfaces. MAC-keyed (lowercased into theserialcolumn); virtual interfaces filtered by name prefix and counted inparts_skipped_filtered(distinct fromparts_skipped_no_identity); slot is the kernel interface name. Cross-kind regression tests confirm storage reconcile doesn't disturb NIC placements (and vice versa). Future probe version emitting PCI addresses can replace the prefix heuristic with a positive has-PCI-backing test. - [x] Multi-source precedence rules: highest-confidence observation per (host, key) wins (VERIFIED > ASSERTED > INFERRED > STALE), recency only breaks ties within a tier — so a management-plane source fills gaps but never clobbers a kernel-verified fact (
_obs_precedence/_best_per_key) - [x] Finding generation with deterministic fingerprint dedup:
INVENTORY_GAPemitted per skipped part (DIMM no serial, storage no WWN+serial, NIC no MAC); fingerprint =sha256(kind|host|root-cause)[:16]; idempotent re-run hits the same row and bumpslast_seen. - [x] Finding-level idempotency: re-runs update
last_seen, don't duplicate (AC4). Plus auto-resolve when a previously-flagged condition clears, scoped per-category so an absent observation never silently auto-resolves findings of that category. Resolved findings reopen with the same fingerprint when the condition recurs. - Unblocks AC2, AC3, AC4. Expect to rewrite parts of it twice (per roadmap risk note); build the replay test fixture alongside it.
P1 — needed for the acceptance criteria¶
- [x] NetBoxAdapter — first slice (
adapters/netbox.py): httpx-based async client; Device CRUD (list / get-by-name / update); custom-field CRUD (list / create); pagination;Authorization: Tokenauth; config viaHOMELAB_HELPER_NETBOX_URL+HOMELAB_HELPER_NETBOX_TOKENenv vars; structuredNetBoxAPIErrorcarrying status + method + path.sync_host(host)PATCHes a Device's custom fields by hostname match (won't create Devices — schema doc invariant: NetBox owns canonical inventory facts, harness owns CF values). Unblocks AC1/AC2's NetBox push (partial — Device CF surface only). - [x] Clusters + VirtualMachines CRUD +
sync_cluster_vms(Phase-3 virtualization slice; create+update only, never reaps operator VMs) - [x] Harness-row VM sync + id write-back (
engine/netbox_vm_sync.py+helper netbox sync-cluster <name>) — syncs the persistedVirtualMachinerows into NetBox and writesnetbox_cluster_id/netbox_vm_idback, so re-syncs match by id (rename-safe). Completes the Proxmox→harness→NetBox round-trip.--dry-runpreviews + rolls back. - [ ] Interfaces / IPs / VLANs / Prefixes / Services CRUD
- [x] InventoryItem CRUD + reconciler write path: list / create / update / delete via
/api/dcim/inventory-items/;sync_inventory_items(device_id, placements)diffs against existingdiscovered=Trueitems (slot label = diff key) and applies create/update/delete.helper netbox sync-hostnow runs both passes by default (--skip-fields/--skip-inventoryfor either alone). Human-edited InventoryItems are invisible to the sync because the list query passesdiscovered=true— sync never reaps an operator's hand-entered row. - [x]
NETBOX_DIVERGENCEfinding on hand-edit conflict (skip write, never overwrite) — three-way merge in the VM sync: a per-VMnetbox_baseline(last write) onVirtualMachine.attributes; when NetBox's current state diverges from it, an operator hand-edited it → skip the write, raise aNETBOX_DIVERGENCE(MEDIUM) finding (reopen/resolve lifecycle). NewFindingKind.NETBOX_DIVERGENCE(no migration — SQLite SAEnum has no CHECK). - [x] NetBox bootstrap —
bootstrap_custom_fields()adapter method +helper netbox bootstrap [--dry-run]CLI verb. Idempotent: lists existing CFs first, creates only what's missing; re-running after upstream NetBox upgrades is safe. Ships 10 Device CFs (power policy/state, discovery source/last-run, last-verified, capabilities, arch, hypervisor type, idle/max power draw). Schema doc open-question #4 resolved in favour of bootstrap. - [x] AssertionEngine (
engine/assertions.py, one-shot mode) — verifier dispatch (OBSERVATION_PREDICATEfully wired;SSH_COMMAND/HTTP_CHECK/API_QUERY/FILE_HASHSKIP with a clear reason until their adapter slices land),AssertionRunrows persisted,CONFIG_DRIFTfinding lifecycle on FAIL (deterministic fingerprint dedup, reopen-on-recurrence) and auto-resolve on PASS. CLI:helper assert list|show|runwith--name/--all/--include-disabled, non-zero exit on FAIL/ERROR for CI gating. - [x] Seed assertion library (
engine/assertion_library.py+fixtures/assertion-library-starter.yaml) — YAML schema (v1) + idempotent upsert-by-name loader;helper assert load <path> [--dry-run]verb. Starter pack ships 14 generic assertions across four categories (probe coverage, sane baselines, architecture/capability, network basics) that exercise the OBSERVATION_PREDICATE verifier against keys the existing probes produce. Hostname references resolve to Host UUIDs at load time; missing hosts skip with a clear reason rather than erroring (tolerant by design — land the library, discover hosts, re-load). Combined with the reconciler's INVENTORY_GAP findings,helper auditnow reports the day-one finding corpus once a fleet is seeded — partial AC3. - [x] Network probes —
network.subnet-scan(asyncio TCP connect-scan against a configurable port panel, bounded concurrency, no nmap/no raw sockets) andnetwork.fingerprint(SSH banner + HTTPServerheader + port heuristic; identifies Proxmox VE, Cockpit, K8s API). Probes wired through the existing SDK + entry-points. Unblocks AC1. NetBox push still pending the adapter slice. - [ ] CLI verbs:
- [x]
helper discover network <cidr> [--ports a,b,c] [--timeout SECS] [--concurrency N] [--no-fingerprint]— runs subnet-scan, fingerprints each discovered host on its observed open ports, prints the result table. Persists observations through the existing ProbeRunner. - [x]
helper audit— high-level roll-up (inventory counts, severity × status crosstab, top-N open findings) — AC3 read-side - [x]
helper findings list|show|ack|resolve|suppress— fingerprint-prefix matching everywhere; status/severity/kind/host filters on list - [x]
helper host show <name>— identity + capabilities (grouped by prefix) + current placements + open findings table - [x]
helper config(cli/config.py) — read-only view of effective config (database URL + state, NetBox URL/token presence, verify-ssl, SSH key) and each setting's source (env/default/unset); tokens reported set/unset, never printed. - [x] Replayable lab fixture + loader (
engine/lab_replay.py+data/labs/example-lab.yaml+helper discover replay) — seeds Host rows + Observations from a committed synthetic fixture, reconciles each host, and loads+runs a bundled assertion library — no live SSH. The fixture yields 13 day-one findings (inventory-gap + storage-provenance + config-drift, with an arch-scoped SKIP). Integration testtests/test_lab_replay.pyasserts ≥11 findings (AC3) and idempotent re-runs (AC4). The committed generic assertion library isfixtures/assertion-library-starter.yaml; operators bind it to their own hostnames in a local (un-committed) copy. Phase 9.1 added an optionalclusters:block (Cluster + VirtualMachine rows) and a second lab,asymmetric; 9.3 moved both into the wheel (data/labs/) so the on-ramp works from a bare install.
Discovery sources & probes (landed)¶
- [x] UniFi adapter (
adapters/unifi.py+helper discover unifi) — third management-plane source: read-only UniFi Network REST client (X-API-KEYauth, injectable for tests), reads internal DNS records, known clients (hostname↔IP), and network/VLAN definitions. Pure per-row parse functions; self-signed cert default. Read-only at L1. The DNS-record source that feeds internalServiceEndpointreconciliation. - [x] ServiceEndpoint reconciliation (
db/models/service.py+engine/dns_reconcile.py+ migration5372e3c99f7e) —Service+ServiceEndpointmodels capturing DNS split-brain (a hostname resolving to different IPs internally vs externally).reconcile_internal_endpointsupserts internal endpoints from a DNS source's A/AAAA records, keyed by(service, scope, resolver, hostname); scope-disciplined so it never touches external/other-resolver endpoints, and cleans up orphaned Services on removal.helper discover unifi --persist [--dry-run]wires it. NewResolutionScopeenum (internal/external). - [x] Cross-source view builder (
cli/view.py+helper view service|host) — the Phase-3 query surface.view service <name>synthesizes a service across its internal/external endpoints, makes the DNS split-brain explicit, and cross-references the VM/Host carrying the name (AC#2, minus the Cloudflare/ArgoCD columns which land with those adapters).view host <name>synthesizes the guests a host runs, endpoints resolving to it, and its open findings. - [x] Cloudflare adapter (
adapters/cloudflare.py+helper discover cloudflare) — the external half of the DNS split-brain: read-only Cloudflare v4 API client (scoped Bearer token,Zone.DNS:Read; zone-name→id resolution cached, or explicit zone id; paginatedlist_dns_records; success-envelope unwrapping; injectable for tests).reconcile_external_endpoints(engine/dns_reconcile.py, refactored to a scope-parametrizedreconcile_endpointscore with internal/external wrappers) upserts(scope=external, resolver=cloudflare)endpoints; reciprocal scope discipline means an external sync never touches internal (UniFi) rows and vice versa, so a hostname carried by both resolvers pairs on oneService— ready forview serviceto flag.helper discover cloudflare --persist [--dry-run]wires it. - [x] Argo CD adapter (
adapters/argocd.py+helper discover argocd) — the git-desired-state source: read-only Argo CD API client (Bearer token, self-signed default, injectable for tests).list_applicationspulls each Application's git source (repo/path/target revision) alongside Argo CD's ownsync/healthverdict and the individual out-of-sync resources;application_is_driftedflagsOutOfSync/non-Healthyapps.helper discover argocdlists applications and highlights drift (read-only; drift→findings persistence is queued below). - [x] Kubernetes adapter (
adapters/kubernetes.py+helper discover k8s) — second management-plane source: read-onlykubectl-subprocess client (injectable runner; kubeconfig/context config).discover_k8s_nodes(engine/k8s_import.py) records each node's facts as INFERRED Observations on its Host (matched by name/internal-ip) and reconciles — so K8s-only facts (kubelet/runtime versions, roles → capabilitiesk8s_*) enrich the host while the kernel-VERIFIED facts (kernel/arch/cpu/memory) hold by precedence. Live-validated against a real cluster (6 nodes). The first source that genuinely exercises multi-source precedence on shared hosts. - [x] Proxmox adapter (
adapters/proxmox.py+helper discover proxmox) — first management-plane source (Phase 3): read-only Proxmox VE REST client (API-token auth, injectable for tests), reads cluster status + VM/LXC + node + storage. Read-only at L1 (no mutate methods). Live-validated against a real 4-node cluster over HTTPS with a PVEAuditor token. - [x] Virtualization schema + persistence —
Cluster+VirtualMachinemodels + Alembic migrationb2f1a9c7d3e4;engine/virt_reconcile.pyupserts them from Proxmox discovery (idempotent, keyed by name / (cluster, vmid); resolves a guest's node to itsHost).helper discover proxmox --persistwrites the cluster + guests to the harness DB;--netbox-syncproposes them into an existing NetBox cluster viasync_cluster_vms;helper auditnow reports cluster/VM counts. Live-validated: cluster + 20 guests persisted, re-run idempotent. A guest deleted in Proxmox has its row removed on the next persisted run (#105). Removal is scoped to the listed cluster, and nothing is removed when the cluster reports itself non-quorate or the listing is empty. - [x] Talos adapter (
adapters/talos.py+probes/talos/host.py+helper discover talos) —talosctl-subprocess adapter (injectable runner for tests) + atalos.hostprobe that pulls COSI resources (nodename,systeminformation,disks,links,addresses) plus/proc/cpuinfo&/proc/meminforeads andversion, projecting them onto the canonicalhost.*keys so the reconciler/assertions/audit consume Talos nodes with no downstream change. Physical-NIC filtering is positive (linkkindempty + realbusPath), avoiding the SSH path's name-prefix heuristic. First non-SSH source — also unblocks reconciler multi-source precedence. - [x]
host.smartprobe (probes/host/smart.py) — per-drive S.M.A.R.T. viasmartctl -a -jover kernel-ssh (root;sudo -nwhen not root). Emitshost.smart.devices(health, power-on hours, temperature, reallocated/pending sectors, CRC errors, NVMe wear),host.smart.health_all_passed,host.smart.unhealthy_devices; WWN in lsblk0x…form to cross-reference storage parts. Graceful-skips when smartctl is absent, so it's safe in the default host-probe set. Chosen over an OMV JSON-RPC adapter (vendor-specific, needs API creds) and an MCP path (can't run in the headless probe pipeline). - [x] Network-scan importer (
engine/scan_import.py+helper discover import <csv> [--dry-run]) — ingest an external host-scan CSV (ip,hostname,os_class,os_detail,ssh_banner,ttl,mac,nic_vendor) into Host rows; classify by scan signature (full OpenSSH → deep-probeable; embedded/dropbear/IoT/no-banner → agentless; Windows → agentless-for-now) and raise aDISCOVERY_AGENTLESS_NEEDED(INFO) finding per unprobeable host. Existing hosts match by primary_ip or short hostname and are enriched, not clobbered. Idempotent. - [x] Deep-probe records coverage —
helper discover host/talossetHost.discovery_source = KERNEL_PROBEon a successful run; the importer treats probed hosts (incl. Talos nodes with no SSH banner) as covered and auto-resolves stale agentless findings. - [x]
_resolve_hostmatches by primary_ip too — a probe run passing a short name no longer duplicates a row another source created under an FQDN at the same IP. Regression testtests/test_cli_resolve_host.py. - [x]
host.memoryDIMM lineage — the memory probe now emitshost.memory.dimms(slot/size/serial/vendor/part/type) fromdmidecode -t memory(root;sudo -nwhen not root, graceful-skip otherwise), the exact shape the reconciler's DIMM lineage consumes — soPhysicalPart/Placementrows populate per DIMM (closes the AC2 DIMM gap). Since #106 it also emits the board's system-memory array from the same output (dimm_slots,dimm_slots_free,memory_max_bytes→ capabilities), which the DIMM planners read throughengine/dimm_fit.py. - [x]
host.pci+host.gpuprobes — PCI enumeration vialspci -vmmnn(shared parser) and GPU detection from PCI class03xx. Graceful-skip with no PCI bus. The last two P1 warm-probe deliverables.
Discovery sources & probes (landed, cont.)¶
- [x] OpenMediaVault adapter (
adapters/openmediavault.py+helper discover omv) — read-only NAS management-plane source over OMV's JSON-RPC API (/rpc.php, lazysession.loginwith the cookie carried by the client; RPC method names centralized as constants; injectable client for tests). Reads mounted filesystems/pools, per-disk S.M.A.R.T. identity, shared folders, and service states — the storage facts a headless probe can't reach without vendor creds (this is why thehost.smartprobe was chosen over an OMV adapter for the SSH path; OMV now lands as its own management-plane source, same pattern as UniFi/Proxmox). Read-only at L1. Also enumerates NFS/SMB exports (NFS/SMBgetShareList) so the exported shares join the shared-folder inventory;discover omvrenders an exports table. - [x] Argo CD drift → findings (
engine/argocd_drift.py+helper diff git-vs-cluster) — persistsapplication_is_driftedresults asDRIFT_CANDIDATEfindings via the deterministic fingerprint (argocd-app/<name>/argocd-drift) + reopen-on-recurrence machinery, mirroring the AssertionEngine lifecycle.Degraded/Missing→ HIGH,OutOfSync+healthy → MEDIUM. Invariant #1 respected: only apps Argo CD reports as healthy resolve; a vanished app's finding is left open.helper diff git-vs-clusterprints the drift table and, with--persist [--dry-run], records the findings so they surface inhelper findings/helper audit. - [x] Stray-config detection (
engine/stray_config.py, wired intohelper discover unifi --persist) — cross-references UniFi network/VLAN definitions against known clients by subnet membership (IP-based, deterministic — no reliance on UniFi's internal id linkage). An enabled network with a routable subnet and zero clients in it opens aSTRAY_CONFIG(LOW) finding, with the fingerprint + reopen-on-recurrence lifecycle; a network that gains a client resolves it. Invariant #1 respected (a vanished network isn't auto-resolved); networks without a subnet are skipped. Structural L1 signal — the roadmap's "no traffic in N days" refinement still leans on the Phase-2 time-series. - [x] Cross-resolver service identity (
dns_reconcile.service_key+ service-level split-brain inview) — the DNS reconcile now keys aServiceby the leftmost DNS label (canonical short name), so internalha.lanand externalha.example.comattach to oneService(ha) while each endpoint keeps its full FQDN.helper view servicedetects split-brain at the service level (internal IPs vs external IPs), not per exact hostname — so differently- suffixed internal/external names now surface as split-brain. Colliding short names merge (acceptable for a homelab); an explicit alias map can override later.
Discovery sources & probes (next up)¶
-
[x] MikroTik RouterOS adapter —
adapters/mikrotik.pyover the RouterOS 7 REST API (basic auth, read-only user): identity/resource, interfaces,/ip/addresssubnets, DHCP leases, static DNS. Records are shaped like UniFi's, so the same reconcilers consume them: static DNS → internal endpoints under resolvermikrotik[:<name>], addresses + leases → stray-config.helper discover mikrotik [--persist] [--dry-run],run_discovery("mikrotik"),mikrotiksource inhelper config.DiscoverySource.MIKROTIK(no migration; SQLite precedent). Not yet: routes, firewall address-lists, CAPsMAN/wireless clients. -
[x] OMV export → stray-export detection —
engine/stray_export.py: an NFS export or SMB share whose shared folder is gone, or whose folder's backing filesystem is not mounted (matched by device, mountpoint uuid, or label), becomes aSTRAY_CONFIGfinding keyed per export with the standard reopen/resolve lifecycle; absent exports never auto-resolve.helper discover omvprints the hits,--persistrecords them,run_discovery("omv")persists. "An export no client mounts" still waits on Phase-2 temporal data. - [x] Explicit service alias map —
engine/service_aliases.py+HOMELAB_HELPER_SERVICE_ALIASES(fixtures/service-aliases.example.yaml): exact hostnames and globs → service name, exceptions only. The DNS reconcile names new endpoints through it and re-points existing ones (moved), cleaning the orphaned service.helper service aliasesshows the map as loaded.
Phase 4 — conversational layer (started)¶
- [x] MCP server (
mcp_server.py+helper mcp serve|tools) — the query surface as Model Context Protocol tools over stdio (officialmcpSDK):list_hosts,get_host(identity + guests + endpoints + open findings),list_findings/get_finding(stable fingerprints as identities),list_services/get_service(split-brain surfaced as data),audit_summary, andrun_discoveryfor the six management-plane sources (reads live source, persists to harness DB — never writes to the lab). Lookup misses and adapter errors return{"error": ...}payloads so LLM callers can react instead of hitting protocol faults. Registers with Claude Code viaclaude mcp add homelab -- uv run --directory <repo> helper mcp serve. - [x] LLMRouter (
llm/router.py+llm/backends.py) — the single entry point for every LLM call: task class → minimum capability tier (Tiny/Small/Mid/Frontier) → privacy policy → backend. Backends: Ollama (default, always present atlocalhost:11434, availability-probed, model pulled-check), BYOK Anthropic + OpenAI (only built when a key is set), and OpenAI-compatible for local vLLM/llama.cpp/LM Studio (counts as local). Policies:strict-local(cloud is not a candidate at all),prefer-local(default; capable local before capable cloud),open(most capable first, local wins ties) viaHOMELAB_HELPER_LLM_PRIVACY. Failover on unavailable/erroring backends; never a silent downgrade — no capable backend →RouterRefusalnaming the task, needed tier, policy, what each backend offered, and the operator's options (P4-AC5). Local tiers are operator-declared (*_TIERenv vars).helper configshows the roster. - [x]
helper chat(cli/chat.py) — one-shot and REPL chat grounded inllm/context.py's bounded fact sheet (hosts, guests, services + split-brain, open findings with fingerprints) — the synthesized-view trust boundary: no raw probe output, no secrets. Multi-turn history in the REPL; every reply prints a[backend: model (tier, local/cloud)]footer; refusals exit 2 with the router's message (P4-AC1). - [x] Narrator agent (
llm/narrator.py+helper findings narrate) — findings → prose atTaskClass.NARRATION(Tiny+). Renders reconciled finding rows (fingerprint, severity, description, proposed actions, affected) into the prompt; narration cites fingerprints so prose is presentation, never the source of truth.narratetakes fingerprint prefixes or a status filter (P4-AC2's machinery; the Ceph demo needs the populated fleet). - [x] Conversational Discovery Agent (
llm/discovery.py+helper onboard) — interview-style host onboarding (P4-AC3). The first agent that leads to a harness-DB write, so the trust split is the design: the LLM interviews and proposes; deterministic Python validates; the operator confirms; only then is anything written. Protocol: one JSON object per model turn ({"say", "proposal", "done"}), tolerantly parsed (fences, prose) with corrective retries and a bail-out after 3 consecutive protocol failures.validate_proposalenforces what the model cannot waive: hostname syntax, IP syntax, arch enum, duplicate host/IP detection, and rejection of anything that looks like key material — the agent collects an SSH username and key path only (stored asHost.credentials_ref), never secrets. Confirmed hosts land asdiscovery_source=manualwith role in capabilities;--probethen runs the sharedengine/host_probe.pywarm-discovery path, otherwise the exacthelper discover hostcommand is printed. Top-level verb (helper onboard, not a chat subcommand) because a click group callback with a positional argument is ambiguous with subcommand resolution. - [x] Skill Inferer (
engine/skill_inferer.py+skill_profiletable +helper skills) — passive per-domain proficiency from chat (P4-AC6). Deliberately deterministic: a curated lexicon maps terms to domains (storage / container-orchestration / networking / virtualization / linux-admin), basic terms weight 1 and advanced terms 3; levels (novice→advanced) are thresholds over accumulated evidence. No LLM in the path — the profile feeds the Phase-6 trust gradient's per-domain hints, so keeping model judgment out of anything trust-adjacent is the point (an LLM-assessed refinement can layer later). Everyhelper chatmessage is observed passively; levels only ratchet up;helper skills setpins a domain (source=manual) that inference can never change. The profile is injected into the chat system prompt so answers match the operator's depth. Migratione5a2c8f1b9d0. - [x] MCP host-discovery tool (
probe_host) — SSH deep-probe exposed over MCP, so kernel-level facts are reachable conversationally and not just from the CLI (run_discoverycovers management planes only). Orchestration moved out of the Typer command intoengine/host_probe.py(probe_host,resolve_host,select_host_probes) so both surfaces run the identical sequence; the CLI passes anon_probecallback to stream progress. Credential story: key path orHOMELAB_HELPER_SSH_KEYonly — passwords are not accepted through tool args at all, which is the "no raw secrets through MCP" constraint this item was waiting on. - [x] Config surface +
.envloading (config.py+ rewrittenhelper config+config_statusMCP tool) —python-dotenvwas a declared dependency that nothing imported, so.envfiles were silently ignored. Now loaded at both entry points (CLI callback and MCP module import) withoverride=False, from a project.env(walk bounded at the.gitroot so a stray ancestor file is never pulled into a process that talks to live infra) then~/.env.SOURCESis the single declaration of each source's required/ optional/secret variables, rendered by both surfaces; secrets report as set/unset and their values are never returned. - [x] Findings lifecycle over MCP —
ack_finding,resolve_finding,suppress_finding, sharing the CLI's fingerprint-prefix matching (ambiguity reported, never guessed). Harness-DB writes only. - [x] Host retire + part merge — landed as
engine/retire.py:helper host retire(DECOMMISSIONING intent, explicit placement close, findings resolved, idempotent; planners skip retired hosts, chat tags them; MCPretire_host),helper part show|merge(operator-driven fold of a duplicate identity, audited underattributes.merged_from), andhelper service resolvers|retire-resolverfor the orphaned-slice case below, with the endpoint reconcile now reportingsuperseded_resolverswhen a sync duplicates another slice. Original notes: the cleanup path that decommissioned and repurposed hardware needs, and the reason stale rows currently accumulate.IntentState.DECOMMISSIONINGexists in the enum with zero consumers; needs the OperationalIntent write path, reconciler consumption, a CLI verb, and an MCP tool. Two cautions: it is really Phase 2 intent work surfacing late, not native Phase 4; and it touches load-bearing invariant #1 — a retire must close placements explicitly without weakening the rule that an absent observation never auto-resolves. Motivating case: three Pi control-plane nodes (pi-cp1/2/3) were reflashed and moved to the Wyola site aswyhome/wynode2/wynode3. NIC lineage tracked the move by MAC, but the disk placements stayed open on the retired hostnames because the USB enclosures forge a shared WWN and Talos vs. Ubuntu report different serial fields for the same drive — so no identity links the two eras. Part merge is therefore operator-driven, not inferable.
Second case — renaming a UniFi controller strands its resolver slice.
ServiceEndpoint is keyed by (service, scope, resolver, hostname) and a
sync reaps only the (scope, resolver) slice it was called for — the scope
discipline that lets two gateways coexist without deleting each other's rows.
The cost is that changing a controller's name changes its resolver tag, and
the previous slice is left behind with nothing to ever reap it. Observed live:
a single-controller lab adopting the multi-controller form renamed default →
covington, so 107 endpoints under resolver unifi were silently superseded
by 107 identical rows under unifi:covington. Nothing errored, nothing
reported it, and the duplicates are invisible until someone groups endpoints
by resolver. Cleaned up by hand this time.
Same shape as the hardware case above: an identity change strands rows that
no reconcile pass owns. Whatever retire/merge verb lands should cover
resolver slices too, not just hosts and parts. Cheap partial mitigation
worth doing first — have the endpoint reconcile warn when it creates a slice
whose rows duplicate an existing slice's (hostname, ip) set, which turns a
silent orphan into a visible one without needing the full verb.
Phase 5 — planning & recommendations (started)¶
- [x] WorkloadProfile schema + starter library (
engine/workloads.py+fixtures/workload-library.yaml) — the planner's unit of reasoning: baselines (cores/RAM/footprint), scaling shape, arch support, gpu none/optional/required (+purpose), dependencies, data gravity (the large dataset a workload wants to live near — distinct from its own footprint), deployment artifacts. 57 starter entries across media/automation/network/ files/monitoring/nvr/dev/database/downloads/misc (P5-AC1's ≥50); baselines are starter estimates.HOMELAB_HELPER_WORKLOAD_LIBRARYlayers an operator file on top, same-named entries winning — the community-contribution path. - [x] Placement recommender (
engine/placement.py+helper plan add-workload, P5-AC2) — deterministic and explainable: hard constraints reject with a stated reason (arch, RAM vs baseline + 1 GiB OS reserve, GPU required but absent, decommissioning intent — the first IntentState consumer); survivors scored on RAM headroom, CPU threads, optional-GPU bonus, data-gravity affinity to bulk storage, minus a running-guest load penalty — every score component carries a reason. Unknown facts are caveats, not rejections ("we don't know" ≠ "it doesn't fit"). Reconciler now projectshost.gpu.count/vendors→gpu_count/gpu_vendorscapabilities. - [x] Planner agent (
llm/planner.py) — narrates the finished report atTaskClass.PLANNING(Mid+); it cannot reorder, add, or drop candidates.--narraterefusals degrade to the already-printed deterministic table. - [x] NetworkPath abstraction (
engine/network_path.py+helper plan path, P5-AC6) — typed site/link graph, operator-declared YAML (no probe can see that the inter-site route rides a VPN; seefixtures/network-topology.example.yaml, pointed at byHOMELAB_HELPER_NETWORK_TOPOLOGY). Path characteristics inherit the only honest way: bandwidth = min, latency = sum, reliability = worst link — one VPN hop makes the whole path VPN-grade. Dijkstra by latency; no topology declared = single-site assumption, nothing degrades. Workload profiles gainednetwork_class(any / lan-preferred / lan-required; ceph-osd + etcd added as lan-required, media servers marked lan-preferred) and placement now folds path verdicts in: lan-required across a non-LAN-grade path to the data-gravity anchor is refused with the worst-link explanation (AC6's Wyola↔Covington case, verified through the real CLI), lan-preferred takes a penalty + caveat.helper plan path A B [--workload X]shows hops, inheritance, and the verdict. - [x] Rebalance solver (
engine/rebalance.py+helper plan rebalance [--narrate], P5-AC3) — fleet load model (per-host RAM capacity vs running VMs' committed memory, from the reconciled DB) plus three candidate plans spanning three cost classes: current-hardware (VM migrations only), one-dimm-move (smallest spare DIMM from the emptiest donor with ≥2 DIMMs → the constrained host, then fewer migrations), one-part-purchase (smallest standard DIMM that relieves the constrained host). Both DIMM plans go throughengine/dimm_fit.py(#106): a full receiver can only swap out its smallest DIMM, so it gains the difference; a swap that gains nothing is not proposed; the board's stated maximum is a warning in the tradeoffs, never a refusal; unobserved slots keep the additive answer with a caveat. Every plan carries typed steps, tradeoffs, and the resulting per-host load. Deterministic bounded greedy — each move must strictly improve the pairwise max ratio (the oscillation guard) — not OR-Tools; the plan/step shapes are solver-agnostic if constraint interactions ever outgrow greedy. Migrations respect cluster membership and never cross a non-LAN-grade path (NetworkPath). Unknown- RAM hosts are excluded from the math and listed, never treated as empty. Balanced fleets produce no plans. Narration vianarrate_rebalanceat planning tier. - [x] Bottleneck analyzer (
engine/bottlenecks.py+helper bottlenecks [--persist] [--narrate], P5-AC4) — deterministic pattern library over the reconciled fleet; the patterns are curated, the output is constructed from detected facts (hosts, speeds, VMs — never canned text). Patterns: cluster-link-asymmetry →CEPH_BOTTLENECK(nodes of one cluster with differing fastest-NIC speeds; generates the four classic mitigations from the detected facts — CRUSH-reweight away from the slow node, upgrade its link to the observed fleet speed, relocate OSDs to a named full-speed node, accept as a tier — AC4's four, verified through the real CLI); memory-pressure →CHOKEPOINT(>90% committed; migrate the named largest VM to the named idle host / add RAM / accept); storage-single-uplink →CHOKEPOINT(bulk-storage host on a single ≤1 GbE NIC serving a multi-consumer fleet).--persistuses the standard fingerprint + reopen lifecycle; because every pattern runs every call, a previously open analyzer finding whose condition cleared resolves (category observed — not absence-auto-resolve), and findings from other generators sharing these kinds are never touched (evidence-tag scoped). Mitigations land asproposed_actionsso chat/narrator can cite them (the P4-AC2 Ceph narration path). - [x] Reconfiguration reasoner (
engine/reconfigure.py+helper plan surplus [--narrate], P5-AC5) — the mirror image: hosts with low commitment AND reconfigurable slack (stopped VMs, spare DIMMs beyond a kept minimum). Options generated from facts: spin the named stopped VMs back up; move the named spare DIMMs to the first loaded host they would actually grow, counting free slots and swaps (#106), or say no loaded host has room (pointing athelper plan rebalance); accept — declare the reserve deliberate via OperationalIntent / stopped-by-design so it stops resurfacing. AC5's node2 case (24 GB RAM, two stopped VMs, named CPU) verified through the real CLI. Low commitment alone is not a hit — there must be something to reconfigure.
Reconciler / assertions (landed)¶
- [x] Forged-WWN collision guard —
Reconciler._resolve_storage_identity+_guard_forged_wwn: when a storage WWN is already owned by a part with a different serial, the WWN is forged (some USB/SATA enclosures report a constant WWN) — re-key the device by its unique serial (recovered from the by-id symlink) and raise aSTORAGE_PROVENANCE_DELTA(MEDIUM) finding instead of merging distinct drives into one "moving" part. First-created part wins WWN ownership (deterministic via uuid7/created_at); re-runs stable. Testtest_forged_wwn_collision_keys_by_serial_and_flags. - [x] SMART-health assertion pattern — a
<host>.smart_all_healthy(HIGH) check usinghost.smart.health_all_passed eq trueturns a failed SMART status into a finding via the existing assertion engine.
Known gaps (found during validation)¶
- [ ] Planners have no "is a hypervisor" notion (P5-AC5, 10/03/2026):
plan surpluscalls covomv's RAM surplus although it is a NAS running Docker;plan rebalancehad the mirror defect (fixed in #51 via cluster membership). Surplus should skip hosts that are nodes of no cluster, or carry a role. -
[ ] Runbook text assumes the day-one 1 GbE / 2.5 GbE asymmetry (P4-AC2, P5-AC4): the fleet is symmetric now; the criteria are marked n/a with a note.
-
[x] P5-AC3 on the live fleet (10/03/2026) produced no migrations-only plan, then — after the first fix — plans that migrated Proxmox guests onto a NAS, arm64 Pis and Talos workers, with one VM ping-ponging. Three defects in
engine/rebalance.py: the greedy mover tried only the single emptiest host as destination; an empty host counted as "joinable" to any cluster; nothing stopped a VM moving twice. Fixed: destinations are searched emptiest-first past illegal ones, a target must be a node of the guest's cluster (from the cluster's persistednodeslist, else the guests it already runs), and a VM moves at most once per plan.virt_reconcilenow recordsCluster.attributes["nodes"]. - [x] P4-AC1 on the live fleet (10/03/2026): Ollama was not running, the router
answered from Anthropic under
prefer-local, and the footer said "cloud" but not why local was passed over —RouterResultdropped the exclusion reasons on success. NowRouterResult.skippedcarries them andhelper chatprints askipped:line under a cloud footer. -
[x] Cloudflare account-owned tokens were rejected (10/04/2026): the adapter's health check called
/user/tokens/verify, which answers only for user-owned tokens and returns1000 Invalid API Tokenfor a validcfat_…account token. The check is now a zone read — the permission the adapter actually needs. -
[x] NIC virtual-interface filter — added Proxmox firewall prefixes (
fwbr/fwln/fwpr) to the host.network reconciler heuristic so they no longer leak through as spurious NIC parts. (A positive PCI-backing test is still the eventual ideal.) - [x] DIMM no-identity — now emits an
INVENTORY_GAPfor DIMMs the probe reports without a serial, same as storage, since the dmidecode probe populateshost.memory.dimms. - [x] Per-assertion arch filter — capability assertions take an optional
arch:scope (verifier_spec.applies_to_arch); off-arch hosts SKIP rather than FAIL, so the library binds cleanly to mixed amd64/arm fleets. - [x]
host.raid/host.sharesprobes (Phase 9.5) —host.raidreads/proc/mdstat(+mdadm --detailfor the UUID when sudo allows): level, state, members and roles,[n/m]slots, recovery/resync/reshape/check. The reconciler projects a summary ontocapabilities["raid"]and raisesstorage-healthfindings (raid-degradedHIGH,raid-rebuildingMEDIUM,raid-inactiveHIGH; a scrub raises nothing), resolved per host. Level 1 by the operator's choice; the volume model is a GitHub issue.host.sharesreadsexportfs -v(or/etc/exports) andtestparm -s, plus fstab/findmnt, and feeds the existing stray-export check scopedhost:<name>— fstab bind mounts count as declared backing, which is how OpenMediaVault serves NFS. Live 10/09/2026: covomv md0 raid5 3/3 healthy; 21 exports, 16 of 17 paths judgeable, no strays. - [x]
talos.hostCPU/DIMM depth (Phase 9.5) —memorymodules→ the canonicalhost.memory.dimms(sizeMiB→ bytes; placeholder serials → a gap, not a part);processors→ sockets/cores/threads/max MHz. CPU model and vendor now come from/proc/cpuinfo— they were read fromsysteminformation, i.e. the chassis (bmax4's "CPU" wasLENOVO 10MUS0BV00), and threads equalled cores. Hardware resources are best effort. Live: bmax4 i5-6500T 4c, two serialled Micron DIMMs; talos-cp1 (VM) one serial-less DIMM.
P2 — strengthens, doesn't block¶
- [x] Verify
host.memoryemits per-DIMM identity sufficient forPhysicalPart/Placementcreation — it does, and has since the dmidecode layer landed:parse_dmidecode_memoryemits slot/size/serial/vendor/part,_reconcile_dimm_lineagekeys parts by serial. Verified 10/09/2026 by replaying the bundledasymmetriclab: three serialled DIMMs → threePhysicalPart(kind=DIMM)rows, three open placements, no gap finding. This row was stale; see the 9.5 note. - [x] Docs site scaffold — Phase 9.3 (#74).
Hygiene¶
- [ ]
engine/__init__.pydocstring listsReconciler/AssertionEngine/Scheduler/ProposalManageras if present — keep as forward-looking, or trim to what's implemented, as those components land - [x] CI workflow (
.github/workflows/ci.yml) — runs on push + PR againstmain, with concurrency cancel-in-progress on the same branch: - [x]
uv sync --all-extras --group dev(uv cache keyed byuv.lockviaastral-sh/setup-uv@v6 enable-cache: true) - [x]
uv run ruff check src tests - [x]
uv run ruff format --check src tests - [x]
uv run mypy src— fixed the two pre-existing_resolve_hosterrors as part of this slice so the gate is genuine, not vacuous - [x]
uv run pytest -q - [ ]
uv run pre-commit run --all-files(deferred — explicit ruff+mypy+pytest steps overlap, add only if a contributor lands the hooks-locally workflow) - [ ] Decide on Python version matrix for CI — at minimum the
.python-versionpin (3.12); consider also testing on 3.13 once it stabilises in upstream deps
Phase 6 — L2 Execution & Trust Gradient¶
Depends on Phases 1–5 (the proposal stream, blast-radius/rollback metadata,
and planner output it executes). Specified in architecture.md ("Trust
gradient") and harness-schema-slice1.md ("Trust gradient tables"). The
safety machinery is the bulk of the effort, not the executor itself.
Schema & policy core¶
- [x] Enums
AutonomyLevel,TrustDomain - [x] Tables + migration
f7b3d9a2c4e1:Domain,CellTrust,TrustBoundary,ElevationWindow,TrustHistory— per the forward spec; domains seeded idempotently byhelper db init(every default PROPOSE; SECRETS absolute with a PROPOSE ceiling) - [x]
decide(action, context) → BLOCK|PROPOSE|CONFIRM|AUTONOMOUS(engine/trust.py) — pure function over frozen dataclasses (load_trust_contextdoes the DB reads,decidejudges); walks the three floor tiers (cell level → domain max + boundary ceilings + verified-rollback → absolute floors), windows lift only the soft-hard tier, every clamp carries a human-readable reason. No LLM in the authorization path is a regression test (subprocess import check), not a comment. Probation caps at CONFIRM. P6-AC1 groundwork: with every cell atPROPOSE, decide() returns PROPOSE and no executor exists; Phases 1–5 behaviour unchanged. - [x] CLI first slice:
helper trust show|grant— grants are operator-attributed (HOMELAB_HELPER_OPERATOR, OS-user fallback), refused above the domain ceiling, recorded inTrustHistory
Execution¶
- [x] Executor (
engine/executor.py, migrationa9c4e7f2d8b3) — consumes pendingProposalLogaction manifests ({"kind": "action", ...}; manifest is untrusted input: validated, and the declared domain must match the guest kind so a manifest can't shop for a softer cell); gates throughload_trust_context+decide(); BLOCK/PROPOSE never dispatch and never write a receipt; CONFIRM needs the operator callback; rollback state (prior power state) captured before dispatch; exactly oneExecutionReceiptper dispatch (success or failure — failure stays PENDING/retryable, success closes the proposal USER_ACCEPTED). P6-AC2. - [x] First write path — Proxmox guest power (
vm_power: start|stop|shutdown|restart→reboot, qemu+lxc;vm_current_statusfor rollback capture); the only write surface in any adapter, executor-only by block-comment contract. AC2's canonical cell iscontainers/restart/single-host. MockTransport-only tests; live execution waits on the user's fleet-validation gate. - [ ] Remaining write-gate wiring as more adapter writes land (NetBox
cf_*sync already has its own diff/confirm path; anything new routes through the executor) - [x] Snapshot/rollback orchestrator (
engine/rollback.py, migrationc1d8f4a6e2b7) — reversibility is verified, not claimed. Until PR Drollback_verifiedcame off the manifest, so the one input todecide()that an untrusted (possibly LLM-drafted) artifact could set in its own favour turned the AUTONOMOUS→CONFIRM floor into an honour system. Now the orchestrator probes the target: prior-power-state (is a restorable status readable?) or snapshot (does this guest's storage support one?), and the manifest's claim is recorded beside the finding so a false one stays visible. Three phases around the gate —verify_rollback(read-only),capture_rollback(post-authorization, may snapshot),restore. The gate runs twice so a refused action never touches the target even to probe it: pessimistically first (assuming no rollback, which can only under-state the outcome), then again with the finding.helper exec rollback <receipt>undoes an execution from its captured state, writes its own receipt, and links the original (rolled_back_at+rollback_receipt_id; receipts are never edited). Rollback is deliberately not decide()-gated — a safety valve the policy could lock shut is not a safety valve. P6-AC4. - [ ] ZFS/LVM snapshot + config-backup strategies as those write paths land (the strategy seam is in place; today's are prior-power-state + Proxmox guest snapshot)
Trust dynamics¶
- [x] Auto-escalation engine (
engine/escalation.py) — per-cell clean-streak tracking; N = 5 clean approvals buy one rung (PROPOSE → CONFIRM → AUTONOMOUS) for eligible cells only (reversible action kind andblast ∈ {metadata-only, single-host}); one bad outcome drops the cell straight to PROPOSE with probation, and only an explicittrust grantclears it. A blocked cell banks nothing — on probation the streak stays at zero, and any other block (domain ceiling, top of ladder, ineligible) holds it at N, so lifting a block never backdates credit. Promotions/demotions areTrustHistoryevents (auto-promote/demotewith cause);granted_byis NULL on an auto-promotion so grants stay distinguishable. Fed by the executor (success/failure) and byhelper exec accept|reject(hand-applied proposals — the PROPOSE rung's evidence). Rejection resets the streak but never demotes. P6-AC3. - [x] Override (
OverrideGrant+helper exec run --override) — per-action, owner-only, interactive, high-friction (you retype the cell key; a reason is required), logged as a distinctTrustHistoryevent only when it changed the decided level, so the spine records authority changes rather than gestures. Crosses the soft-hard floors and nothing else: it never lifts a BLOCK/PROPOSE cell, never crosses an absolute domain or an absolute host boundary, never forges a window id, and covers exactly one action. Never agent-invokable — the grant is constructed only by the interactive CLI, never loaded from or derived from DB state. - [x] Elevation window (
engine/trust.py+helper window) — scoped to domains/hosts/cells and never blanket (a scopeless window is refused), hard expiry capped atMAX_WINDOW_MINUTES(8h) with no auto-renew, best-effort snapshot captured under elevation even though rollback isn't required, receipts tagged with the window id; outside any window, autonomous-meets-floor still degrades toCONFIRM. Open/revoke areTrustHistoryevents. P6-AC5. - [x] Kill switch (
helper window kill) — revokes every open window in one gesture. In-flight work halts at the executor's pre-dispatch checkpoint: a decision that leaned on a window re-tests it immediately before dispatch, so a run authorized under a window that has since been killed refuses instead of executing. P6-AC5. - [x] Absolute floors —
Domain.is_absolute(secretsships true) andTrustBoundary.absolute;open_windowrefuses to name an absolute domain at all, and an absolute host boundary clamps even with a window open.helper trust boundary <host> <ceiling> [--absolute]sets the per-host ceiling. Config-edit only to change the absolute ones. P6-AC6.
Surface & audit¶
- [x] CLI: the authorization surface is complete —
trust show|grant(PR A),exec list|run|receipts(PR B),exec accept|reject+trust history(PR C),exec rollback(PR D),window open|list|revoke|kill+trust boundary(PR E) - [x] Receipts + audit spine:
ExecutionReceipt(decision level + full reason trace + window id + rollback state + outcome/error/duration) with write-back toProposalLog;TrustHistoryappend-only for grants (the auto-promote / demote / override / window events land with their features) - [x] MCP trust surface (
trust_status,list_receipts,pending_actions) — read-only by construction: a model can see the gradient, the receipts, and what policy would say about each pending action (computed pessimistically, since verifying reversibility means probing the target and a query tool has no business doing that). There is no MCP tool to grant, elevate, override, roll back, or execute; two mechanical tests enforce the absence, one on the tool roster and one on the module's imports and call sites. - [ ] Decide the
AuditLog/receipt detail beyondProposalLog+TrustHistory
Agent access (scoped, not started)¶
Six items from the pre-alpha readiness review — planner MCP tools, a Home
Assistant adapter, the stdio-only transport decision, cell-keyed adapter
write surfaces, propose_action, and a secrets store — are scoped with
designs, files, and sequencing in agent-access-scope.md. The two blockers
from the same review are done:
- [x] Packaged Alembic config (
db/migrate.py; migrations ship in the wheel,helper db initworks from an install) - [x] Scoped MCP
probe_host(known hosts at their recorded address;HOMELAB_HELPER_MCP_PROBE_ALLOWglobs for anything else) - [x] Item 1 — planners as MCP tools (
list_workloads,recommend_placement,plan_rebalance,analyze_bottlenecks,analyze_surplus,network_path) +probe_talos(scoped likeprobe_host;discover talossharesengine/talos_probe.py) - [x] Item 2 — Home Assistant adapter v1 (
adapters/homeassistant.py,engine/hass_import.py,discover hass --persist,run_discovery("hass")); v2 device registry +device_trackeridentity still open - [x] Item 3 — stdio-only MCP transport documented (README + architecture trust table)
- [x] Item 4 —
engine/manifest.py(pydantic authoring schema, agreement test withparse_manifest) +tests/test_write_isolation.py(Proxmox writes reachable only via executor/rollback). Dispatch registry and the SSH restart cell stay open until a second write adapter / a rollback strategy exists - [x] Item 5 — MCP
propose_action/list_proposals/get_proposal(draft-only; provenance clamp dropped — the gate is cell trust, per the executor's tests) - [x] Item 6 — secret references (
secrets.py:env:/file:incl. age + sops /keyring:),helper configreports the scheme, MCP error strings redacted - [ ] Item 6 follow-up — move
Host.credentials_ref(ssh:<user>:<path>) onto references;op:///vault://schemes; redact CLI-printed adapter errors
Phase 7 — Agentic Operations¶
Specified in roadmap.md ("Phase 7") and architecture.md ("Agent-triggered
execution"). The invariant does not move: an LLM never authorizes. What changes
is who may trigger, and how much of the lab has an executor-gated write path.
Slice 1 — trigger + approval channel + two surfaces (landed)¶
- [x]
engine/approval.py—ApprovalChannelprotocol,ApprovalResult, and the Home Assistant channel (actionable notification with Approve/Deny; the tap read back over HA's websocket event bus; timeout = no). Config:HOMELAB_HELPER_APPROVAL_NOTIFY_SERVICE,HOMELAB_HELPER_APPROVAL_TIMEOUT_S. - [x] Executor: a channel's answer is written to
TrustHistoryas anapprovalevent (channel, responder, approved);confirm_cbmay return a bool (CLI) or anApprovalResult(channel). - [x] MCP
execute_proposal— runs the executor withoverride=None; AUTONOMOUS runs, CONFIRM waits on the channel, PROPOSE/BLOCK refused with the reason and thehelper exec runcommand. Mechanical tests: the tool can never carry an override; every cell at PROPOSE executes nothing through it. P7-AC1, AC5. - [x] Proxmox
migrate(live/offline, named target node) with theprior-noderollback strategy (verified: both nodes online; restore: migrate back). - [x] Kubernetes
workload-restart/workload-scaleon deployment, statefulset, daemonset; newsingle-serviceblast radius; K8s adapter writesrollout_restart,scale_workload,rollout_undo(executor-only); strategiesrollout-undo(verified by rollout history; undo to the pre-restart revision) andprior-replicas. - [x] Manifest: two target shapes (
ActionTarget,WorkloadTarget), the authoring helperbuild_workload_artifact, MCPpropose_workload_action,propose_action(target_node=, online=)for migrate. - [x]
REVERSIBLE_ACTION_KINDSgrew by the three new kinds;LOW_BLAST_RADIIbysingle-service. - [x] Live validation (
live-validation.md, Part 3) — 10/03/2026 on the Covington lab: migrate + rollback, workload restart + undo, Approve and Deny, all throughexecute_proposalwith a phone tap. P7-AC2, AC3.
Slice 2 — surfaces (landed)¶
- [x] Guest
cpu-type(QEMU;set_vm_config, applied at next stop/start; rollbackprior-configrestores the previous type or removes the key) - [x] Argo CD
argocd-sync(optional pinned revision, prune) — adapter gainsget_application(deployed revision + sync history +auto_sync),sync_application,rollback_application; rollbackargocd-historyreturns to the current history entry unless the app has automated sync, where Argo CD refuses the rollback API and the verifier reports unverifiable (found live 10/03/2026) - [x] UniFi
dns-record(upsert one name + type on a named controller) — adapter gainsfind_dns_record,create/update/delete_dns_record, keeps_id; rollbackprior-dns-recordrestores the prior value or deletes the created record - [x]
ExecutionReceipt.approval(migrationb4e7c2a9d1f3): channel, responder, approved — the same facts as theTrustHistoryevent, denormalized forlist_receipts - [x]
helper approvals show— channel config, what each pending proposal would get if triggered now, recent answers - [x] MCP
propose_argocd_sync,propose_dns_record;propose_action(cpu_type=);execute_proposalresolves the Argo CD / UniFi adapter a manifest needs - [ ]
helper exec listen— deferred: it needs a marker for which pending proposals an operator-side daemon should ask about (a denied proposal must not be re-asked every loop). Design it with the playbooks in slice 3. - [ ] LXC lifecycle beyond power; NetBox / OMV writes — each behind the executor with a verified inverse, when a use case asks for them
Slice 3 — proactive (landed)¶
- [x]
WORKLOAD_UNHEALTHYfindings (engine/k8s_workloads.py): the K8s adapter lists every Deployment / StatefulSet / DaemonSet; a settled workload with fewer ready than desired opens a finding (HIGH at zero ready); healthy again resolves it; absent is untouched. Wired intodiscover k8sandrun_discovery("k8s"). - [x] Playbooks (
engine/playbooks.py): deterministic registry —argocd-resync(DRIFT_CANDIDATE → argocd-sync),workload-restart(WORKLOAD_UNHEALTHY → workload-restart). One live proposal per finding, 6 h cooldown after a decision,finding.proposed_actionsrecords the draft. MCPdraft_remediations. P7-AC6 first half. - [x] Listener (
engine/listener.py): asks about pendingplaybook:*/agent:*proposals that policy would allow, through the executor with the approval channel; never re-asks an answered one; skips PROPOSE/BLOCK cells and hand-authored proposals. This is thehelper exec listenthe slice-2 notes deferred, with the marker question answered by "an approval event exists". - [x]
helper daemon run(cli/daemon.py): discovery → playbooks → listener on APScheduler cadences;--oncefor cron;--no-ask/--no-playbooks/--sources ''to disable jobs. P7-AC6 second half. - [x] Live validation of the loop (runbook Part 3 step 7, 10/03/2026): app-wirestudio
resync asked → approved → executed via the listener. Two refinements fell out of it:
argocd-resynconly for OutOfSync apps (Synced/Degraded = a failed job, a sync cannot help); a 15-minmin_agedebounce + withdrawal of pending drafts whose finding resolved (app-of-apps blipped OutOfSync and Argo's own automated sync fixed it inside 3 min — the phone should never have been asked). - [ ] More playbooks as findings grow identities: guest expected-on but stopped
(needs an expected-state model for VMs), stray DNS →
dns-record. - [ ] Probe-level schedules and assertion cadences inside the daemon (the rest of the Phase-2 spec).
Slice 4 — tell me afterwards (landed)¶
- [x]
engine/notify.py:should_notify(AUTONOMOUS, any failure, auto-promote / demote),render,HomeAssistantNotifieron the approval channel's config,notifier_from_env, best-effortnotify_after_run. Executor calls it after the receipt + escalation flush;ExecutionResult.notificationsays what happened; wired fromhelper exec run, MCPexecute_proposal, the listener and the daemon. P7-AC4 "with a receipt and a notification". - [x] Live validation (10/05/2026, runbook step 8):
workload-restartgranted AUTONOMOUS, an agent-drafted homepage restart ran unattended from the daemon and the phone got the ✓ notice after the receipt. The demotion notice was not forced live (needs a failing write behind a succeeding read); covered by tests. - [ ] Daily digest (one notification summarising the day's receipts) — only if the per-run notices turn out noisy.
Approval prompts, revised (10/06/2026)¶
Found live: eight rightsize prompts went out one at a time, each titled "homelab-helper: approve this action?" with a technical body and a 5-minute fuse; the first two expired unseen and, as "answers", were never re-asked.
- [x] Prompt title = the proposal title; body = why (the finding's first sentence), when it takes effect, the cell, and the expand hint.
- [x] Dedicated
homelab-helper approvalschannel;priority: high,ttl: 0,sticky, iOStime-sensitive; the prompt is cleared once answered or expired. - [x] The listener asks every eligible proposal concurrently, then executes the approved ones one at a time; default window 15 minutes.
-
[x] An expired prompt is not an answer: re-asked after
REASK_AFTER(2 h), at mostMAX_ASKS(3) times; Approve / Deny stay final. -
[x] Found 10/09/2026: approved VM resizes were redrafted six hours later — a VM resized but not yet restarted still measures as the old size, so its rightsizing finding stays open and the cooldown expired. Playbooks gain
redraft_after_success(False forrightsize): an action identical to one already executed for the same finding is never drafted again (skipped_done). The three duplicates were superseded and their findings acknowledged by hand.
Test hygiene (found during slice 1)¶
- [x]
tests/test_mcp_server.pyflakes (one random failure or error per run): the unclosed event loop was pytest-asyncio remembering an "old" loop that Python 3.12's default policy had just conjured becauseasyncio.run(any CliRunner test) leaves the main thread loop-less;conftest.pynow installs a policy that refuses to create loops implicitly. The UniFi/OMV tests reading the operator's exportedHOMELAB_HELPER_*credentials is fixed separately by scrubbing them inconftest.py.
Phase 8 — Operate & Optimize¶
See roadmap.md Phase 8. Slices land in this order.
- [x] 8.1 Version currency —
engine/versions.py,helper discover versions,run_discovery("versions"):version-driftfindings for Proxmox package lag (node's cached apt list, Debian/Proxmox split, pve-manager called out) and mixed cluster versions, OS end of life fromdata/os-eol.yaml, kubelet/Talos skew, Home Assistantupdate.*pending (platform MEDIUM, devices LOW). Categories resolve only when observed (invariant 1). First live run 10/05/2026: bmax1–3 on 9.2.11 with 120–123 pending, bmax0 on 9.2.20, 7 HA updates. - [x] Ceph (10/09/2026):
ceph-eolfrom/cluster/ceph/metadataagainst aceph:section ofdata/os-eol.yaml(reef 2026-03-20, squid 2026-10-31, tentacle 2027-06-01, upstream dates),ceph-mixedwhen daemons run different versions. Prompted by the 10/09 maintenance: Squid goes EOL 10/31/2026 and only a forum thread said so. - [ ] Follow-ups: Argo CD image tags vs upstream releases; Debian security-update count (needs the node's apt sources, not exposed by the API); UniFi/OMV firmware; the X1/X4 Pis now have
os_*facts via the schedule, so their Buster/Bullseye EOL findings should appear on the next pass — verify. - [x] 8.2 Backup posture —
engine/backups.py,helper discover backups,run_discovery("backups"):backup-gapfindings for guests no enabled job selects, selected guests whose newest backup is older than twice the job interval (or missing), newest backup failing verification, backups retained for vmids that no longer exist, backup storage ≥ 80/90%. Shared reconcile extracted toengine/category_findings.py(8.1 uses it too). First live run 10/05/2026: all 23 live guests covered, fresh, verified; 1.3 TiB logical retained for deleted guests 102/107/110 (prune keeps a group's newest backups forever). - [ ] Follow-ups: offsite freshness (offsite-push publishes status to the Wyola HA's MQTT — read it once HA instances are multi-site in the adapter); restore-drill results; state outside Proxmox (HA config, covomv data) as operator-declared backup targets.
- [x] 8.3 Usage history —
usage_sampletable (migrationa7d3e9c2b5f1),engine/usage.py,helper discover usage,helper usage [subject] [--days], MCPusage_summary. Hourly rollups from the RRDmonthtimeframe (30-min points), daily fromyear(6-h points): mean of AVERAGE, peak of MAX. First run backfills ~30 days hourly and up to a year daily; reruns upsert;prune_usagekeeps 45 days hourly / 730 days daily (HOMELAB_HELPER_USAGE_HOURLY_DAYS/_DAILY_DAYS). First live run 10/05/2026: 17,504 rollups for 26 subjects in 7 s. - [ ] Follow-ups: K8s pod usage (metrics-server), OMV/covomv disks, non-Proxmox hosts via probes.
- [x] 8.4 Rightsizing & real-usage placement —
engine/rightsizing.py,helper plan rightsize [--days] [--persist], MCPrightsizing;plan rebalance --basis usage/plan_rebalance(basis="usage").rightsizingfindings (cpu-grow, cpu-shrink, mem-shrink, mem-grow for containers only, idle), each naming allocation, p95, peak, window and proposed value; < 7 days of hourly history → no verdict and no resolution (observed_targetsadded to the shared reconcile). VM memory is never grown: Proxmox's figure includes guest page cache (measured: ubuntu-dev reported 14.6 GiB, 4 GiB used in-guest). First live run 10/06/2026: Home Assistant CPU-bound 2 → 3 cores; ESPHome builders 4 GiB → 1–2 GiB; proxmox-dc 2 → 1 core, 4 → 1.5 GiB; esphome-lxc idle. - [x]
resizeaction kind (10/06/2026): cores and/ormemory_mibon a guest, through the executor viaset_vm_config; refused at dispatch beyond the node's CPUs/physical memory (no write); a QEMU balloon floor above the new memory is lowered with it; QEMU without hotplug reports pending until next stop/start, containers apply live. Rollback:prior-configgeneralised to the keys an action changes (cpu; cores/memory/balloon). InREVERSIBLE_ACTION_KINDS.rightsizeplaybook drafts one resize per cpu-/mem- rightsizing finding (never foridle); rightsizing evidence now carries node/vmid/kind. MCPpropose_action(cores=, memory_mib=). - [ ] Follow-ups: in-guest memory via the QEMU guest agent so VM memory can grow too; K8s requests vs usage; an optional restart-after-resize for VMs (today the operator restarts, or a
restartproposal). - [x] 8.5 Storage efficiency —
engine/storage.py,helper discover storage,run_discovery("storage"),FindingKind.STORAGE_EFFICIENCY(no migration — SQLite SAEnum has no CHECK). Five categories throughengine/category_findings.py: storage-headroom— least-squares slope on a pool's daily usage history → "fills in about N days", HIGH inside 30 days, MEDIUM inside 90. Needs history, so 8.3 now records storage pools too (subject_type="storage",STORAGE_FIELDS,ProxmoxAdapter.storage_rrd, one reader per pool since a shared pool is reported by every node — no migration,subject_typeis a free string). A flat, shrinking or sub-10 MiB/day pool gets no projection, and fewer than 7 samples is not a trend. Deliberately linear: a straight line is explainable in a finding ("68 GiB/day"). 8.2'sbackup-capacitystill owns the point-in-time ratio on backup storages — this answers the different question of when, for every pool, so one pool can raise both.storage-snapshot-stale— snapshots older than 30 days, which pin blocks the guest has since overwritten. The harness's ownhelper-rollback captures are named separately and raised to MEDIUM: that is its own litter.storage-detached-disk—unusedNentries: on the pool, attached to nothing, invisible in the guest's own usage.storage-template-clutter— ISOs and container templates no guest config references (disks,unusedNslots andostemplateall count as a reference), grouped per storage with the bytes.storage-released-pv— Kubernetes PVs inReleased: claim gone, volume retained, nothing can bind it again.- [ ] Follow-ups: backup retention cost (8.2's
backup-orphansalready names the groups kept for deleted guests; a per-tier cost breakdown is reporting rather than a finding); OMV/covomv filesystem headroom via the OMV adapter; ZFS snapshot space accounting (written/refer) so a stale snapshot's real cost is a number rather than an explanation. - [x] 8.6 Weekly digest —
engine/digest.py,db/models/digest.py(migrationb4e1f7a9c3d2),helper digest show|send|history, daemon--digestjob. Contents are chosen from rows only (findings, proposals, receipts,TrustHistory) and a subprocess test asserts the module never importshomelab_helper.llm— P8-AC6's "an LLM may narrate it but does not choose its contents". Three sections: what was done (receipts, rollbacks marked), authority changes, what changed (opened/resolved, worst-first), what is recommended (open findings + pending proposals). - Windows tile: each digest records
window_start/window_end, so the next one starts where the last stopped — no change reported twice, none lost in a gap.--daysoverrides;showdeliberately does not record, or reading the page would eat a week of changes. - A quiet window is recorded but not sent (
--quiet-okto override), and standing open findings do not make a window busy — otherwise one known LOW finding buzzes the phone every week, which is the stream this slice replaces. - The daemon job is gated on
MIN_DIGEST_DAYSrather than its interval, so a restart or a 15-minute cron tick cannot turn the weekly summary into a stream. - [ ] Follow-ups: LLM narration of the page (the contents are already fixed,
so this is presentation only); a digest-scoped
--since <receipt>for re-reading an old window; HTML output if the Markdown page wants a browser. - [x] 8.7 Service suggestions —
engine/suggestions.py,helper discover suggestions,run_discovery("suggestions"),FindingKind.SERVICE_SUGGESTION. Two categories, both INFO: capability-idle-gpu— a host reporting display/compute adapters (gpu_count/gpu_vendors, from thehost.gpuprobe) while nothing the library knows as GPU-capable appears to run. The suggestion names each candidate'sgpu_purpose, so it says what the silicon would be for.building-block-missing— the three the roadmap names (metrics, alerting, and the local model the LLM router prefers), each a set of library entries; missing when none is present. Grafana counts for metrics and alerting both.- Presence is decided by name, because the harness tracks guests,
services and endpoints rather than installed software: a guest called
mediarunning Plex is invisible here. Every finding says it matched on names only, and both categories are INFO rather than problems. Containment is word-level, soplexiglass-inventoryis not Plex. - The pass reads stored facts only (no adapter calls), so it cannot fail for a source being down and both categories are always observed.
- [ ] Follow-ups: match on the
host.servicesprobe's unit names and on container image names (K8s + Proxmox LXC) so presence stops depending on what a guest was called;depends_onchains (suggestmosquittowhenzigbee2mqttis present without it); accelerator kinds beyond PCI display-class (Coral TPU on USB). - [x] Update orchestration — the
node-updateaction kind, the first Phase-8 write path and the first action with no inverse at all. KernelSSHAdapter.apt_dist_upgradeis the only write on that adapter and runs a fixed command (DEBIAN_FRONTEND=noninteractive apt-get -y --force-confold --force-confdef dist-upgrade, viasudo -nfor a non-root user). The manifest names a node and nothing else:NodeTargetforbids extra fields andparse_manifestre-checks, so a proposal can never become remote code execution gated by one cell. InWRITE_METHODS.- An undrained node is refused at dispatch, before any write — a dist-upgrade restarts cluster services and usually wants a reboot, so running it under live guests risks them. Stopped guests and guests on other nodes don't block.
- Rollback is the new
no-inversestrategy: the verifier always reports unverified with the reason, andrestorerefuses rather than pretending. So AUTONOMOUS degrades to CONFIRM, andnode-updateis deliberately absent fromREVERSIBLE_ACTION_KINDS— it can never be auto-promoted, which is how it "lands at PROPOSE" and stays there. node-updateplaybook drafts one update perpve-updatesfinding (never forpve-mixed: that is fixed by updating each node, which the per-node draft already covers). The draft states the drain prerequisite and the absence of a rollback, so the proposal is honest before it is run.- Sequencing is an operator procedure, not one action: drain with
migrateproposals →node-update→ reboot → verify → next node. Written up indocs/live-validation.md. One action = one decision = one receipt is the gradient's unit, and a composite action would hide which step failed. - [ ] Follow-ups: a
node-rebootaction kind (today the operator reboots, which is one click and keeps the hairiest failure mode — a node that does not come back — outside an action whose receipt would already say "succeeded"); amaintenance_runrow to sequence drain → update → reboot across a cluster with its own state, each step still gated individually;pvesh-based update once Proxmox exposes an install endpoint.
Phase 9 — Road to 1.0¶
See roadmap.md Phase 9. Adds no capability: it makes the existing capability
real for someone other than its author. Slices in delivery order. 1.0.0rc1
was cut 10/09/2026 with 9.2–9.7a done and Postgres in CI; what separates it
from 1.0.0 is the operator-time list under 9.1.
- [ ] 9.1 Close the validation gate — the
⏳rows indocs/live-validation.md. Everything still open here is operator time on real hardware, not code; the one item that was blocked on this lab's shape rather than on access is done. - [x] P4-AC2 narration (10/09/2026):
helper bottlenecks --narrateagainst the asymmetric fixture through the router namedceph-c, the 1000 vs 2500 Mbps links and the four mitigations with their trade-offs. - [x] P4-AC3 onboarding (10/11/2026): a never-seen devbox clone, interview → confirm → registered → warm discovery; retired and destroyed afterwards.
- [x] P6 step 5 live (10/11/2026): a
migrateof a nonexistent vmid — Proxmox 500 on the POST, failed receipt, proposal pending, cell demoted topropose (probation), demote row on the spine, phone notice. - [x] P6 step 6 live (10/11/2026): window open, run stopped at the confirm
prompt,
window kill --yesfrom the side — refused before dispatch. - [ ] Part 4 rolling
node-updateon one drained node, including the zero-API-call check at the propose floor (read the node's auth log) — waits for the next node with pending updates; the only open row. - [x] P4-AC2 / P5-AC4: the bundled
asymmetriclab — a three-node cluster with one node left at 1 GbE.discover replay+bottlenecksproduce theCEPH_BOTTLENECKfinding and all four mitigations through the real CLI, with no hardware (tests/test_lab_replay_asymmetric.py, including the runbook's anti-hardcoding check). Needed aclusters:block in the replay fixture schema: the fleet-shape analysers read cluster membership off the guest rows, so a fixture without guests has no cluster.- [ ] P4-AC2's narration half: one
helper bottlenecks --narrateagainst the fixture and the operator's own router. No hardware, but not a test.
- [ ] P4-AC2's narration half: one
- [x] 9.2 De-assume the lab
- [x] Derived, not stored:
engine/cluster_nodes.pyis the one rule for "is this host a placement target" — a node of a cluster, by the cluster's node list or by the guests it runs — andplan rebalance,plan surplusand the memory-pressure pattern all read it. The NAS is reported as not a placement target with the reason, never as surplus;plan surplusprints the rule. No role column: NetBox ownsrole, and a stored role that disagreed with what a host runs would be the worse signal. - [x] Runbook text: the P4-AC2/P5-AC4 sections name no speeds of their own (done with 9.1); the remaining "USB 2.5GbE" remedy is now "upgrade the slow link".
- [x] Sweep: the memory-pressure pattern offered any roomy host as a
migration destination (the test asserted it); link-asymmetry membership
came from guests only, so a drained OSD host vanished from the pattern;
the daemon's default discovery sources were the author's stack
(
argocd,k8s,proxmox) — now whateverhelper configreports configured; the example topology and alias fixtures, four docstrings and three README examples named the lab's sites and hosts. Replay fixtures can declare a cluster'snodes:the way discovery persists it. Written down rather than changed:plan placementis deliberately not restricted to cluster nodes — a Docker host is a legitimate target for a container. - [x] 9.3 Docs site (mkdocs-material;
mkdocs.yml, pages indocs/, published to GitHub Pages frommainby.github/workflows/docs.yml, built--strictin CI so a broken link is red). Getting-started is led byhelper discover replay, which needed one change to be an on-ramp at all: the labs lived infixtures/, which auv tool installuser never has. They now ship in the wheel (data/labs/) anddiscover replaytakes a bundled name,exampleby default, or a path. Also: the CLI reference, generated from the Typer app (mkdocs-clickovercli/main.py::click_app) so it cannot drift; the trust-gradient explainer from the operator's seat; the probe and adapter guides; the NetBox custom-field reference with who writes each field. The existing design docs joined the site unchanged. - [ ] Enable Pages once: repository Settings → Pages → Source "GitHub
Actions". Until then
docs.ymlfails onmainwithout touching CI. - [x] 9.4 Declare the 1.0 surface —
docs/stability.md: every public surface and its promise (the invariants first; CLI verbs; MCP tool names; the probe contract; the manifest envelope and shipped kinds; the DB as forward-only migrations with the schema explicitly not an API; theHOMELAB_HELPER_*variables, references and precedence; the operator YAML formats, versioned or not; NetBox fields; Python versions; and what is not stable, the Python API), plus a deprecation policy for before and after 1.0.tests/test_stability_doc.pyholds the CLI and MCP tables and the Python versions to the code. The gate ran green under 3.13 before it was declared; CI now runs it on 3.12 and 3.13 as separately named jobs, so the existing required check keeps its name. - [x] Postgres in CI: a
quality-postgresjob runs the whole suite against Postgres 16.tests/conftest.pyredirects every engine the code builds toHELPER_TEST_DATABASE_URL(the Alembic env included) and empties the tables between tests; the handful of tests that inspect the SQLite file carry@pytest.mark.sqlite_only. The first run found the real bug: every timestamp column wastimestamp without time zone, which asyncpg refuses to bind an awarenow()to —Base.type_annotation_mapnow mapsdatetimetoDateTime(timezone=True)and migrationc8d1e4f7a2b9converts existing Postgres columns (a no-op on SQLite). - [x] 9.5 The last Phase-1 probes —
host.raid,host.shares, andtalos.hostCPU/DIMM depth (see the P1 rows); dmidecode DIMM depth was already done. Phase 9 AC #5 met: a host with an mdraid array reports its composition, and a serialled DIMM becomes aPhysicalPart+Placement— now for Talos nodes too. - [x] 9.6 Finish the Phase-2 tail —
engine/schedule.py,helper daemon run --schedule(orHOMELAB_HELPER_SCHEDULE),helper schedule [--due],fixtures/schedule.example.yaml. Per-host / per-Talos probe intervals and per-assertion intervals from one YAML file; no new state — a probe is due when its lastDiscoveryRunfor that host is older than its interval, an assertion when its lastAssertionRunis — so--oncefrom a cron tick honours a 15-minute RAID cadence and a daily SMART cadence from the same file. Interval syntax only (90s 15m 6h 1d 1w); cron is refused, not guessed. An assertion's ownschedulecolumn wins over the file. A target with nothing due costs no connection; an unreachable target fails alone. AC #6 met: two probes on different cadences from one daemon, an assertion on its own schedule (tests/test_schedule.py). - [x] 8.3/8.4 follow-up (10/09/2026) —
discover usagedied on every run after 8.5 added storage pools (rolluplooked up a memory field a pool's RRD does not have, and dropped every pool point for lackingcpu); pools now roll up on their own leading field.summarizerestarts a guest's history at its last cores/memory change (allocation_changed_at,samples_in_window), andevaluatecounts such a guest as observed with nothing to say until the fresh history is long enough — so a finding whose resize was applied resolves instead of re-asking against stale buckets. - [x] 9.7a Status endpoint (
engine/status.py+status_api.py+helper status show|serve,docs/status-endpoint.md) — the dashboard answer to "anything wrong, anything waiting on me?": open findings by severity and kind, action proposals awaiting approval, newest discovery and assertion runs, trust cells by level, the day's receipts, a three-stephealth. Served asGET /status+GET /healthz(FastAPI, optionalHOMELAB_HELPER_STATUS_TOKEN) for a Homepagecustomapitile or a Home Assistant REST sensor; GET-only and executor-free by test. The UI is the operator's existing dashboard, which is why there is no 9.7b yet. - [x] 9.8 Planner constraints from the live review (10/09/2026) — the first
plan rebalanceagainst the full inventory proposed migrating the Talos control planes (node-local disks) and filling the one node the operator keeps light. Two constraints, both explained in the plan's caveats: Proxmox discovery now records each guest'sstoragesandshared_storage(annotate_guest_storage, onevm_configread per guest; an ISO onlocalcounts) and the planner never migrates a guest that is not on shared storage;helper host intent <host> --no-new-guestsrecords a host-levelOperationalIntent(newIntentState, Postgres enum value via migrationd2e7f1a9b4c6) that rebalance never fills andplan add-workloadrejects. Alsok8s-eol/talos-eolversion-currency categories fromkubernetes:andtalos:sections ofos-eol.yaml(one finding per minor in use), so a cluster two Talos minors behind is a finding rather than a surprise. - [ ] 9.7b Web UI (the deferred 4.5) — optional and last: chat, findings browser, trust surface read-only. Not needed for 1.0; the CLI, MCP tools and the status tile cover it.
Not in Phase 9: new adapters, new action kinds, predictive work, hosted service. It earns its keep by finishing things rather than starting them.
Phase 10 — Predictive operations¶
See roadmap.md Phase 10. Thresholds and trends on data the harness already
keeps; no model anywhere.
- [x] 10.1 Disk health (10/11/2026) —
engine/disk_health.py: the newesthost.smart.devicesobservation judged against the host's own earlier ones (90-day window,Reconciler._observation_history). Categories understorage-health:disk-failing(SMART FAILED, HIGH),disk-reallocating(MEDIUM; HIGH when the count grew in the window or ≥ 50),disk-link-errors(CRC errors that grew, MEDIUM — a static count raises nothing),disk-hot(window peak over the line for that kind of drive: 50/55 °C spinning, 65/70 °C flash),disk-worn(NVMe/SSD ≥ 80% used, HIGH ≥ 95%, or a rate over ≥ 14 days of history that reaches 100% within 180 days),disk-aged(spinning drive past 50 000 h, LOW). Identity is the serial, so a replaced drive closes the old finding; resolution is per host, like RAID. - [ ] Follow-ups: per-drive temperature baselines instead of fixed lines once
a season of history exists; NVMe
media_errors/available_spare(the probe does not record them yet); ahelper host showdrive table. - [x] 10.2 Capacity trends (10/11/2026) —
engine/capacity.py,helper discover capacity,run_discovery("capacity"),FindingKind.CAPACITY:filesystem-full(newest sample ≥ 85%, HIGH ≥ 95%) andfilesystem-headroom(8.5's least-squares fit reaching full within 90 days, HIGH within 30) for every node root filesystem in the usage history and for NAS filesystems, which the OpenMediaVault discoverers now record as one dailyfilesystemusage sample each (record_filesystem_samples);memory-headroomandcpu-headroomwhen a node's daily mean trends across 90% of RAM / 80% CPU inside the horizon. Two things the first live preview taught: a step is not a trend (the fit restarts after the last day-to-day jump above 10% of the total — HA moving onto bmax2 read as "crosses 90% in 11 days"), and a node's memory climb is guests touching their allocations, so the memory trend is judged only where the running guests' commitment plus the OS reserve could carry it across the line. The digest leads with "What runs out first" (capacity and 8.5 headroom findings, soonest first). Alsodb/enum_sync.py+ migratione3f8b2c6d9a1: every Python enum member missing from its Postgres type is added (ALTER TYPE … ADD VALUE IF NOT EXISTS, derived from the models) — a Postgres database upgraded by migrations could not store eight finding kinds until now; apostgres_onlytest holds the live types to the enums. - [ ] Follow-ups: filesystems on hosts outside Proxmox and OMV (a
df-style probe orhost.storageFSAVAIL into usage samples); per-guest root filesystems; the digest's lead as a phone notification when the soonest is under 30 days. - [x] 10.3 Baselines and anomalies (10/11/2026) —
engine/baselines.py,helper discover anomalies,run_discovery("anomalies"),FindingKind.ANOMALY(migrationf4a9c3d7e2b5, one line viaenum_statements()). Every guest and node with ≥ 14 days of hourly history gets p10/p50/p95 bands per metric (CPU, network, disk I/O, memory) over its own last 28 days; a departure is the newest 3 hourly samples all pastp95 × 1.5(and an absolute floor) —<metric>-above-baseline— or, for CPU and network on a subject whose p10 clears the floor, all underp10 × 0.5—<metric>-below-baseline(the backup that silently stopped). LOW, MEDIUM after 6 hours; stale history (> 6 h) is not judged. Live calibration on the fleet's fat-tailed hours (HA's CPU p95 is 3.5× its median) is why the band is a whole-day percentile with a margin rather than an hour-of-day band. - [ ] Follow-ups: hour-of-day / weekday shape once a season of history exists; a per-subject "expected quiet" declaration so a nightly job's silence is judged in its window only; K8s pod baselines from metrics-server.
- [x] 10.4 Maintenance runs (10/12/2026) —
node-rebootis the second no-inverse action kind (KernelSSHAdapter.reboot_node, executor dispatch that refuses an undrained node, issuessystemctl reboot, waits for the API to see the node leave and then for online + quorate + SSH, and fails the receipt after 15 minutes rather than succeeding the moment the command returned; never promotable).engine/maintenance.py+MaintenanceRun(migrationa1b7c4d9e6f2) plan one node's run as ordinary proposals — migrate each running guest to the emptiest cluster-mate with room (honouringno-new-guestsandshared_storage; node-local guests are shut down instead),node-update,node-reboot, then the mirror image — and execute them in order throughexecute_proposal, relaying one operator consent to every CONFIRM, stopping at the first failed or refused step and rejecting the rest.helper maintenance plan|run|show|list|abort; MCPplan_maintenance_run,list_maintenance_runs,execute_maintenance_run(one phone prompt per run). Also fixed:helper exec runnever passed an SSH adapter, so anode-updatecould only be run from MCP. - [x]
plan --resume <run>(10/12/2026): a run that stopped after its drain is finished by a second one — the guests the stopped run moved and never returned join the new run's return leg, checked against where each guest is now. Born of the first live run, which drained bmax3 and stopped at the update step for want of acredentials_ref. - [x] Hardening from the first live runs (10/12/2026): a preflight that stops
before the target is touched (undrained node, missing login, node already on
the release, cluster not healthy) is a
refusedreceipt — the proposal stays pending and the cell is not demoted (the first run's step-6 refusal had costhost-os/node-updateits grant twice); an unexpected exception in a dispatch is afailedreceipt rather than a crash with no record;planmarks every step the gate would refuse today ("would be refused today: … is at propose — grant it first"); the run commits after each step so a crash later cannot roll back the record of what already moved;httpx<1pinned after anuv tool upgradefrom an rc installed with--prerelease allowpulled httpx 1.0.dev6 (noAsyncClient). - [x]
maintenance runandexec runignore SIGHUP (10/12/2026): the first full run on bmax3 executed every step from an SSH session that dropped as the last step returned; the process died before its commit and nothing was recorded. Reconstructed from bmax3's journal and the pveproxy access logs. - [ ] Follow-ups: the Talos node variant landed as 11.3; pre-flight "newest installed kernel is the running one" after the reboot; Ceph "PGs clean" in the return wait when the cluster runs Ceph.
Phase 11 — Talos as a managed plane¶
See roadmap.md Phase 11. The Kubernetes nodes join the gradient through the
talosctl adapter that already probes them.
- [x] 11.1
talos-upgrade(10/12/2026) —TalosTarget {node, version}inengine/manifest.py(build_talos_artifact;versionmust match^v\d+\.\d+\.\d+$, extra fields refused) and the executor's_parse_talos_target;TalosAdapter.install_image(the one field read from the machine config — the rest, which holds the cluster secrets, is discarded),machine_ready, theupgrade_node/rollback_nodewrites andwait_for_tag;prior-imagerollback strategy (verifier: node answers, is ready, runs a release other than the target; restore:talosctl rollbackthen wait for the prior tag) — inREVERSIBLE_ACTION_KINDS, the first node action that can earn autonomy;_dispatch_talos_upgradederives the image from the node's ownmachine.install.imagewith the tag swapped, refuses a node already on the tag or not ready, and only succeeds once the node is back on the new tag and ready (fails after 15 minutes).propose_talos_upgradeover MCP;helper exec run|rollbackbuild aTalosAdapterwhen the manifest or receipt calls for one. - [ ] Follow-ups: a CLI draft path (lands with 11.3's
helper maintenance plan --talos);talosctl healthin the return wait; honour a per-nodetalosconfigfromschedule.yamlwhen building the executor's adapter. - [x] 11.2
k8s-upgrade(10/12/2026) —K8sUpgradeTarget {control_plane, version}(build_k8s_upgrade_artifact; the executor refuses any blast radius butcluster, so the change is always gated on the cell its size names);TalosAdapter.kubernetes_version(kubelet image tag),members(hostname, type, address, Talos release) andcluster_health(talosctl health, read-only) plus the executor-onlyupgrade_k8swrite;_dispatch_k8s_upgradepreflights — target newer and at most one minor ahead, one Talos release across members, cluster healthy — runs the synchronousupgrade-k8s, then requires the kubelet to report the target and health to pass again.no-inverse, so CONFIRM for good.propose_k8s_upgradeover MCP. - [x] 11.3 Talos maintenance run (10/12/2026) —
MaintenanceRun.plane(proxmox|talos, migrationb2c8d4e9f1a3);plan_talos_maintenancereads the members through one control-plane node and drafts atalos-upgradeper member — control planes first, then workers, members already on the target skipped — plus an optional closingk8s-upgrade(at blast radiuscluster);execute_maintenancegainedtalos_adapterand ahealth_cbasked before every step after the first, so a node that came back wrong (talosctl healthfailing) stops the run with the check's words on the step.helper maintenance plan-talos <cp> --to vX [--kubernetes Y], MCPplan_talos_maintenance_run;runbuilds the Talos adapter and the health gate from the row's plane. - [ ] Follow-ups: a playbook that drafts the run from a
talos-eol/k8s-eolfinding (the registry drafts one proposal; a run is many — needs aDraftthat carries a plan); pre-pull the installer image before the first node; a per-membertalosconfig. - [x] 11.4 Workload usage (10/12/2026) —
run_discovery("usage")now also reads metrics-server (K8sAdapter.pod_metrics,list_pods) and sums pod usage and declared resources per Deployment / StatefulSet / DaemonSet (adapters/kubernetes.py::workload_usage; a ReplicaSet's pod is attributed to its Deployment by thepod-template-hash, Jobs and bare pods are left out);engine/usage.py::record_workload_usagewrites one hourlyworkloadsample per reading (cpu= fraction of summed CPU limits, else requests, else one core —cpussays which; the latest reading in an hour wins), andengine/baselines.pyjudgesworkloadsubjects like guests. Skipped without kubectl; a cluster without metrics-server reports the error in the result. - [ ] Follow-ups: an hourly
helper discover usage(cron;schedule.yamlhas no source cadences yet — addingsources:there is the clean fix) is what gives workloads a baseline, the 4-hourly sweep leaves gaps; workload rightsizing (requests/limits from history) and the digest's workload rows; a mean within the hour rather than last-reading-wins. Live 10/12/2026: 67 workloads recorded on the first run; none declares CPU limits or requests, socpuis cores used againstcpus = 1.0.
Integrations¶
- [x] print-warden source (10/17/2026) —
adapters/printwarden.pyreads print-warden's unauthenticatedGET /healthz(status api 1; any other api is refused rather than misread).engine/printwarden.pyturns it intoSERVICE_UNHEALTHYfindings (new kind, migrationc6e1a9f4d2b7; no playbook maps it) in four categories:printwarden-unreachable,printer-offline,printer-klippy,printer-telemetry-stale(> 10 min). Unreachable observes only its own category, and Klipper state and telemetry age are observed only for connected printers, so nothing resolves for want of a reading.run_discovery("printwarden"), aSOURCESentry (the daemon picks it up),helper discover printwarden [--persist] [--dry-run]. - [x] Warden events (10/17/2026, print-warden 0.4 status fields): a Klipper
finding names the shutdown's classified cause and hint (
last_shutdown);printer-failing(≥3 prints in 7 days, ≥50% failed, labels winning) andprinter-maintenance(maintenance_due) come from the warden's ledger, so they are observed for every printer that reports them, connected or not, and never for an older warden that does not. - [x] A printer removed from the warden (10/17/2026): the status lists every printer, so one that has left it is observed gone and its open findings resolve on the next successful read.
- [ ] Follow-ups: live validation against the operator's warden.
- [x] JetKVM source (10/23/2026) —
adapters/jetkvm.pyreads a JetKVM: the openGET /device/status,GET /deviceafter a local-password login, andconsole_snapshot(), one PNG frame taken as a receive-only WebRTC viewer (optional extrajetkvm: aiortc + pillow). No input, power or media method, by design: console input is unbounded remote code execution.engine/jetkvm.pyrecords ajetkvmService (with the operator-declaredattached_tohost) and reconciles two categories:jetkvm-unreachable(SERVICE_UNHEALTHY, observed every run) andhost-unreachable(new kindHOST_UNREACHABLE, migratione7d3b1a9c5f2, observed only when the attached host resolved to an address and only for that host). A down host's finding carries the snapshot path; a failed snapshot is absent evidence and never raises.run_discovery("jetkvm"), aSOURCESentry (the daemon picks it up), the MCPconsole_snapshottool,helper discover jetkvm [--persist] [--dry-run] [--snapshot PATH]. - [ ] Follow-ups: live validation; a
host-unreachablefinding for a host the KVM was moved away from stays open until resolved by hand (only the currently attached host is observed).
Not tracked here¶
Phase 2 (continuous agent / time-series) is specified in roadmap.md but
deliberately deferred — Phase 7 slice 2 picks it up as the scheduler. Post-roadmap
(Phase 11+) items are deliberately not tracked.