db as source of truth: wiki→seeds, archive old artifacts, knowledge ingestion

- Migrations 010 (content_hash) + 011 (search tsvector column)
- new: internal/knowledge/seed.go — knowledge seed ingest engine
- new: internal/httpapi/knowledge.go — SearchKnowledge + GetEntityKnowledge
- wire knowledge ingest into oikos seed pipeline
- convert all 36 wiki docs + 6 investigations + 12 runbooks → seeds/knowledge.yaml
- archive: knowledge/wiki/→archive/, oikos/cards/→archive/, .hermes/plans/→archive/
- delete: 9 superseded Python kernel files, ledger/, mcp/build_host_files.py
- remove empty knowledge/ directory tree
This commit is contained in:
2026-07-07 20:22:30 +02:00
parent b2bfa26f64
commit 6b75f7302d
125 changed files with 7557 additions and 1938 deletions

View File

View File

@@ -1,302 +0,0 @@
#!/usr/bin/env python3
"""oikos/approve.py — the approval engine (escalate route of the OODA loop).
Repo-side half of the Week-3 approval flow. This module owns the request/
grant lifecycle and the HMAC signing; it does NOT talk to Matrix directly.
There is no dedicated Matrix bot in this homelab — alerts already go out
as the operator's own Hermes agent posting to @dtoro:avispero (see
knowledge/wiki/infrastructure/monitoring.md's homelab-health-watchdog). The integration
contract is:
1. An agent or the Week-3 scheduler calls `request()` (or the CLI
`request` subcommand) to open an approval. This prints the Matrix-
ready message text (evidence, options, reply format).
2. Hermes posts that text to Matrix using its existing send capability
(the same one homelab-health-watchdog already uses) and later reads
the operator's reply/reaction.
3. Hermes calls back `reply()` (or `oikos/approve.py reply <id>
approve|deny`) with the operator's decision. This module verifies
`requires_phrase` for destructive actions, then issues a short-TTL
HMAC-signed grant.
4. Mutating `homelab` commands call `check_grant()` before executing a
config_mutation/destructive action.
Storage: approvals/<YYYY-MM>.jsonl, same append-only-JSONL-with-latest-
state-wins convention as oikos/signal.py.
Grant signing key: secrets/oikos-approval-hmac.yaml (SOPS, recipients:
apps + hubris — see .sops.yaml). Decrypted on demand; never cached to
disk in plaintext.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parent.parent
APPROVALS_DIR = REPO / "approvals"
HMAC_SECRET = REPO / "secrets" / "oikos-approval-hmac.yaml"
AGE_KEY = Path(os.environ.get("SOPS_AGE_KEY_FILE", "/etc/age/key.txt"))
VALID_STATES = ("pending", "approved", "denied", "expired", "executed")
REQUEST_TTL_HOURS = 24
GRANT_TTL_MINUTES = 15
def _month_path(dt: datetime | None = None) -> Path:
dt = dt or datetime.now(timezone.utc)
return APPROVALS_DIR / f"{dt.strftime('%Y-%m')}.jsonl"
def _all_entries() -> list[dict]:
if not APPROVALS_DIR.exists():
return []
out = []
for path in sorted(APPROVALS_DIR.glob("*.jsonl")):
for line in path.read_text().splitlines():
if not line.strip():
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
continue
return out
def _next_id(dt: datetime | None = None) -> str:
dt = dt or datetime.now(timezone.utc)
prefix = f"appr-{dt.strftime('%Y-%m-%d')}-"
existing = [e["id"] for e in _all_entries() if e.get("id", "").startswith(prefix)]
n = 1
while f"{prefix}{n:04d}" in existing:
n += 1
return f"{prefix}{n:04d}"
def _append(entry: dict) -> dict:
APPROVALS_DIR.mkdir(exist_ok=True)
entry = {k: v for k, v in entry.items() if v is not None}
with _month_path().open("a") as f:
f.write(json.dumps(entry, sort_keys=False) + "\n")
return entry
def current(request_id: str) -> dict | None:
matches = [e for e in _all_entries() if e.get("id") == request_id]
if not matches:
return None
matches.sort(key=lambda e: e.get("ts", ""))
return matches[-1]
def list_approvals(state: str | None = None) -> list[dict]:
latest: dict[str, dict] = {}
for e in sorted(_all_entries(), key=lambda e: e.get("ts", "")):
rid = e.get("id")
if rid:
latest[rid] = e
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
out = []
for e in latest.values():
if e.get("state") == "pending" and e.get("expires_at") and e["expires_at"] < now:
e = {**e, "state": "expired"}
if state and e.get("state") != state:
continue
out.append(e)
out.sort(key=lambda e: e.get("ts", ""), reverse=True)
return out
def _matrix_message(entry: dict) -> str:
lines = [
f"[Oikos approval {entry['id']}] {entry['entity']}{entry['action']}",
f"risk: {entry['risk']}",
f"evidence: {entry['evidence']}",
]
if entry.get("verification"):
lines.append(f"verification: {entry['verification']}")
if entry.get("requires_phrase"):
lines.append(f'reply: "approve {entry["id"]} {entry["confirmation_phrase"]}" or "deny {entry["id"]}"')
else:
lines.append(f'reply: ✅ to approve, ❌ to deny (or "approve {entry["id"]}" / "deny {entry["id"]}")')
return "\n".join(lines)
def request(entity: str, action: str, risk: str, evidence: str, *,
verification: str | None = None, signal_id: str | None = None,
requires_phrase: bool = False, requested_by: str | None = None,
ttl_hours: int = REQUEST_TTL_HOURS) -> dict:
"""Open a new approval request. `requires_phrase=True` (use for
`destructive`-class actions per oikos/policy.yaml) generates a
confirmation phrase the operator must echo back — a reaction alone
can't authorize it."""
rid = _next_id()
now = datetime.now(timezone.utc)
phrase = f"{action} {entity}" if requires_phrase else None
entry = {
"id": rid,
"ts": now.isoformat(timespec="seconds"),
"entity": entity,
"action": action,
"risk": risk,
"evidence": evidence,
"verification": verification,
"signal_id": signal_id,
"requires_phrase": requires_phrase,
"confirmation_phrase": phrase,
"requested_by": requested_by or os.environ.get("HOMELAB_AGENT_ID"),
"state": "pending",
"expires_at": (now + timedelta(hours=ttl_hours)).isoformat(timespec="seconds"),
}
recorded = _append(entry)
recorded["matrix_message"] = _matrix_message(recorded)
return recorded
@lru_cache(maxsize=1)
def _hmac_key() -> str:
if not HMAC_SECRET.exists():
raise RuntimeError(f"no secret at {HMAC_SECRET} — has it been provisioned?")
env = {**os.environ}
if AGE_KEY.exists():
env["SOPS_AGE_KEY_FILE"] = str(AGE_KEY)
proc = subprocess.run(["sops", "-d", str(HMAC_SECRET)],
capture_output=True, text=True, env=env)
if proc.returncode != 0:
raise RuntimeError(
f"sops decrypt of oikos-approval-hmac failed (is this host a recipient?): "
f"{proc.stderr.strip()}")
return yaml.safe_load(proc.stdout)["hmac_key"]
def _sign(request_id: str, entity: str, action: str, expires_at: str) -> str:
key = _hmac_key().encode()
msg = f"{request_id}:{entity}:{action}:{expires_at}".encode()
return hmac.new(key, msg, hashlib.sha256).hexdigest()
def reply(request_id: str, decision: str, *, phrase: str | None = None,
decided_by: str | None = None) -> dict:
"""Record the operator's decision. On approval, issues a short-TTL
HMAC-signed grant token. Raises ValueError if a required confirmation
phrase is missing or wrong, or if the request already expired."""
if decision not in ("approve", "deny"):
raise ValueError("decision must be 'approve' or 'deny'")
entry = current(request_id)
if entry is None:
raise ValueError(f"unknown approval id: {request_id}")
if entry.get("state") != "pending":
raise ValueError(f"{request_id} is not pending (state={entry.get('state')})")
now_s = datetime.now(timezone.utc).isoformat(timespec="seconds")
if entry.get("expires_at") and entry["expires_at"] < now_s:
_append({**entry, "ts": now_s, "state": "expired"})
raise ValueError(f"{request_id} expired at {entry['expires_at']}")
if decision == "deny":
return _append({**entry, "ts": now_s, "state": "denied", "decided_by": decided_by})
if entry.get("requires_phrase"):
if phrase != entry.get("confirmation_phrase"):
raise ValueError(
"confirmation phrase missing or incorrect — a reaction alone "
"cannot approve a destructive action")
grant_expires = (datetime.now(timezone.utc) + timedelta(minutes=GRANT_TTL_MINUTES)) \
.isoformat(timespec="seconds")
grant_token = _sign(request_id, entry["entity"], entry["action"], grant_expires)
return _append({**entry, "ts": now_s, "state": "approved", "decided_by": decided_by,
"grant_token": grant_token, "grant_expires": grant_expires})
def check_grant(request_id: str, entity: str, action: str) -> tuple[bool, str]:
"""Verify a request carries a valid, unexpired, matching grant, AND
consume it — a grant is exact-bound (this exact request id + entity +
action) and single-use: this call both checks and marks it "executed"
in the same step, so a second call for the same request_id fails with
"not approved" even if the grant's TTL hasn't expired yet. Callers
(homelab CLI mutating commands) must call this immediately before
executing, exactly once."""
entry = current(request_id)
if entry is None:
return False, "unknown approval id"
if entry.get("state") != "approved":
return False, f"not approved (state={entry.get('state')})"
if entry.get("entity") != entity or entry.get("action") != action:
return False, "grant does not match entity/action"
now_s = datetime.now(timezone.utc).isoformat(timespec="seconds")
if not entry.get("grant_expires") or entry["grant_expires"] < now_s:
return False, "grant expired"
expected = _sign(request_id, entity, action, entry["grant_expires"])
if not hmac.compare_digest(expected, entry.get("grant_token", "")):
return False, "grant signature invalid"
_append({**entry, "ts": now_s, "state": "executed"})
return True, "ok"
def main() -> int:
import argparse
p = argparse.ArgumentParser(description="oikos approval engine")
sub = p.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("request")
r.add_argument("entity")
r.add_argument("action")
r.add_argument("risk")
r.add_argument("evidence")
r.add_argument("--verification")
r.add_argument("--signal-id")
r.add_argument("--requires-phrase", action="store_true")
r.add_argument("--ttl-hours", type=int, default=REQUEST_TTL_HOURS)
ls = sub.add_parser("list")
ls.add_argument("--state", choices=VALID_STATES)
rp = sub.add_parser("reply")
rp.add_argument("id")
rp.add_argument("decision", choices=["approve", "deny"])
rp.add_argument("--phrase")
rp.add_argument("--decided-by")
ck = sub.add_parser("check")
ck.add_argument("id")
ck.add_argument("entity")
ck.add_argument("action")
args = p.parse_args()
if args.cmd == "request":
entry = request(args.entity, args.action, args.risk, args.evidence,
verification=args.verification, signal_id=args.signal_id,
requires_phrase=args.requires_phrase, ttl_hours=args.ttl_hours)
print(json.dumps({k: v for k, v in entry.items() if k != "matrix_message"}, indent=2))
print()
print("--- post this to Matrix ---")
print(entry["matrix_message"])
elif args.cmd == "list":
for e in list_approvals(state=args.state):
print(json.dumps(e))
elif args.cmd == "reply":
try:
entry = reply(args.id, args.decision, phrase=args.phrase, decided_by=args.decided_by)
except (ValueError, RuntimeError) as e:
print(f"error: {e}", file=sys.stderr)
return 1
print(json.dumps(entry, indent=2))
elif args.cmd == "check":
ok, reason = check_grant(args.id, args.entity, args.action)
print(f"{'GRANTED' if ok else 'DENIED'}: {reason}")
return 0 if ok else 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,21 +0,0 @@
# apps (host:apps)
- kind: lxc (LXC 105)
- state: active
- runs-on: host:hubris
- role: docker-apps
- address: 192.168.8.205 (mesh: tailscale:apps)
- mounts: /mnt/library
- doc: knowledge/wiki/containers/105-apps.md
- secrets: enrolled (age key present)
## Blast radius
- impacts: service:artifacto, service:homelab_mcp, service:secrets_issuance
- affected by: host:hubris, mount:/mnt/library, repo:dtoro/Artifacto, repo:dtoro/Homelab-Docs
- full blast radius: service:artifacto, service:homelab_mcp, service:secrets_issuance
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- 2026-07-06T11:57:21+00:00 deploy-oikos-console (config_mutation) — ok

View File

@@ -1,20 +0,0 @@
# arriman (host:arriman)
- kind: lxc (LXC 122)
- state: active
- runs-on: host:strong
- role: arr-stack
- address: 192.168.8.245 (mesh: tailscale:arr)
- mounts: /mnt/media_local
- doc: knowledge/wiki/containers/122-arriman.md
## Blast radius
- impacts: service:arr_stack
- affected by: host:strong, mount:/mnt/media_local
- full blast radius: service:arr_stack
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,18 +0,0 @@
# auth-outpost (host:auth-outpost)
- kind: lxc (LXC 106)
- state: active
- runs-on: host:hubris
- role: authentik-gateway
- address: 192.168.8.6
- doc: knowledge/wiki/containers/106-auth-outpost.md
## Blast radius
- impacts: (none)
- affected by: host:hubris
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# caddy (host:caddy)
- kind: lxc (LXC 121)
- state: active
- runs-on: host:hubris
- role: reverse-proxy
- address: 192.168.8.175
- doc: knowledge/wiki/containers/121-caddy.md
## Blast radius
- impacts: service:caddy
- affected by: host:hubris, repo:dtoro/caddy-conf
- full blast radius: service:caddy
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# dns (host:dns)
- kind: lxc (LXC 107)
- state: active
- runs-on: host:hubris
- role: dns-server
- address: 192.168.8.2
- doc: knowledge/wiki/containers/107-dns.md
## Blast radius
- impacts: service:dns
- affected by: host:hubris
- full blast radius: service:dns
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- 2026-07-06T11:40:28+00:00 add-record (config_mutation) — ok

View File

@@ -1,19 +0,0 @@
# elementsynapse (host:elementsynapse)
- kind: lxc (LXC 118)
- state: active
- runs-on: host:strong
- role: matrix-server
- address: 192.168.8.242
- doc: knowledge/wiki/containers/118-elementsynapse.md
## Blast radius
- impacts: service:matrix
- affected by: host:strong
- full blast radius: service:matrix
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,20 +0,0 @@
# gitea (host:gitea)
- kind: lxc (LXC 104)
- state: active
- runs-on: host:hubris
- role: git-server
- address: 192.168.8.121 (mesh: tailscale:gitea)
- mounts: /mnt/library
- doc: knowledge/wiki/containers/104-gitea.md
## Blast radius
- impacts: service:gitea
- affected by: host:hubris, mount:/mnt/library, repo:dtoro/gitea-customizations
- full blast radius: service:gitea
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,20 +0,0 @@
# grimmory (host:grimmory)
- kind: lxc (LXC 130)
- state: active
- runs-on: host:strong
- role: book-library
- address: 192.168.8.247
- mounts: /mnt/media_local
- doc: knowledge/wiki/containers/130-grimmory.md
- secrets: enrolled (age key present)
## Blast radius
- impacts: (none)
- affected by: host:strong, mount:/mnt/media_local
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# haos (host:haos)
- kind: vm (VM 108)
- state: active
- runs-on: host:hubris
- role: home-automation
- address: 192.168.8.101 (mesh: tailscale:homeassistant)
- doc: knowledge/wiki/vms/108-haos.md
## Blast radius
- impacts: service:haos
- affected by: host:hubris
- full blast radius: service:haos
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# house (host:house)
- kind: lxc (LXC 129)
- state: active
- runs-on: host:strong
- role: family-planner
- address: 192.168.8.244
- doc: knowledge/wiki/containers/129-house.md
- secrets: enrolled (age key present)
## Blast radius
- impacts: (none)
- affected by: host:strong
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,20 +0,0 @@
# hubris (host:hubris)
- kind: proxmox-host
- state: active
- role: hypervisor
- address: 192.168.8.77 (mesh: netbird:proxmox-server.netbird.selfhosted)
- mounts: /mnt/library
- doc: knowledge/wiki/hosts/hubris.md
- secrets: enrolled (age key present)
## Blast radius
- impacts: host:apps, host:auth-outpost, host:caddy, host:dns, host:gitea, host:haos, host:mule-images, host:nextcloud, host:nfs-export, host:paperless, host:sophia, host:teddycloud, host:trmnl, host:zimaos, service:proxmox_ui
- affected by: mount:/mnt/library
- full blast radius: host:apps, host:auth-outpost, host:caddy, host:dns, host:gitea, host:haos, host:mule-images, host:nextcloud, host:nfs-export, host:paperless, host:sophia, host:teddycloud, host:trmnl, host:zimaos, service:artifacto, service:caddy, service:dns, service:gitea, service:haos, service:homelab_mcp, service:nextcloud, service:paperless, service:photos, service:proxmox_ui, service:secrets_issuance, service:teddycloud, service:trmnl, service:zimaos
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,20 +0,0 @@
# jellyfin (host:jellyfin)
- kind: lxc (LXC 101)
- state: active
- runs-on: host:strong
- role: media-server
- address: 192.168.8.246 (mesh: tailscale:jellyfin)
- mounts: /mnt/media_local
- doc: knowledge/wiki/containers/101-jellyfin.md
## Blast radius
- impacts: service:jellyfin
- affected by: host:strong, mount:/mnt/media_local
- full blast radius: service:jellyfin
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,17 +0,0 @@
# mac-mini (host:mac-mini)
- kind: workstation
- state: active
- role: dev
- address: 192.168.178.182 (mesh: netbird:mac-mini-234-17.netbird.selfhosted)
- secrets: enrolled (age key present)
## Blast radius
- impacts: (none)
- affected by: (none)
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,20 +0,0 @@
# mule-images (host:mule-images)
- kind: lxc (LXC 120)
- state: active
- runs-on: host:hubris
- role: photo-management
- address: 192.168.8.136 (mesh: tailscale:muleimage)
- mounts: /mnt/library
- doc: knowledge/wiki/containers/120-mule-images.md
## Blast radius
- impacts: service:photos
- affected by: host:hubris, mount:/mnt/library, repo:dtoro/mule-image
- full blast radius: service:photos
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,17 +0,0 @@
# netbird-vps (host:netbird-vps)
- kind: external
- state: active
- role: netbird-mgmt
- address: (mesh: netbird:netbird-ionos.netbird.selfhosted)
## Blast radius
- impacts: service:authentik
- affected by: (none)
- full blast radius: service:authentik
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,20 +0,0 @@
# nextcloud (host:nextcloud)
- kind: lxc (LXC 114)
- state: active
- runs-on: host:hubris
- role: file-sync
- address: 192.168.8.224 (mesh: tailscale:nextcloud)
- mounts: /mnt/library
- doc: knowledge/wiki/containers/114-nextcloud.md
## Blast radius
- impacts: service:nextcloud
- affected by: host:hubris, mount:/mnt/library
- full blast radius: service:nextcloud
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,18 +0,0 @@
# nfs-export (host:nfs-export)
- kind: lxc (LXC 102)
- state: active
- runs-on: host:hubris
- role: storage-export
- address: 192.168.8.200
- doc: knowledge/wiki/containers/102-nfs-export.md
## Blast radius
- impacts: (none)
- affected by: host:hubris
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,20 +0,0 @@
# paperless (host:paperless)
- kind: lxc (LXC 103)
- state: active
- runs-on: host:hubris
- role: document-archive
- address: 192.168.8.130 (mesh: tailscale:paperless)
- mounts: /mnt/library
- doc: knowledge/wiki/containers/103-paperless.md
## Blast radius
- impacts: service:paperless
- affected by: host:hubris, mount:/mnt/library
- full blast radius: service:paperless
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,17 +0,0 @@
# rclone (host:rclone)
- kind: lxc
- state: active
- role: backup
- address: (mesh: netbird:rclone.netbird.selfhosted)
- secrets: enrolled (age key present)
## Blast radius
- impacts: (none)
- affected by: (none)
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,16 +0,0 @@
# republic-laptop (host:republic-laptop)
- kind: workstation
- state: active
- role: primary-dev
- address: (mesh: netbird:republic-laptop.netbird.selfhosted)
## Blast radius
- impacts: (none)
- affected by: (none)
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# romm (host:romm)
- kind: lxc (LXC 134)
- state: active
- runs-on: host:strong
- role: rom-manager
- address: 192.168.8.249
- mounts: /mnt/media_local
- doc: knowledge/wiki/containers/134-romm.md
## Blast radius
- impacts: (none)
- affected by: host:strong, mount:/mnt/media_local
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# seanime (host:seanime)
- kind: lxc (LXC 133)
- state: active
- runs-on: host:strong
- role: anime-media-server
- address: 192.168.8.248
- mounts: /mnt/media_local/anime
- doc: knowledge/wiki/containers/133-seanime.md
## Blast radius
- impacts: (none)
- affected by: host:strong, mount:/mnt/media_local/anime
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# sophia (host:sophia)
- kind: lxc (LXC 119)
- state: active
- runs-on: host:hubris
- role: workshop
- address: 192.168.8.109 (mesh: tailscale:sophia)
- mounts: /mnt/library
- doc: knowledge/wiki/containers/119-sophia.md
## Blast radius
- impacts: (none)
- affected by: host:hubris, mount:/mnt/library
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# strong (host:strong)
- kind: proxmox-host
- state: active
- role: hypervisor
- address: 192.168.178.181
- doc: knowledge/wiki/hosts/strong.md
- secrets: enrolled (age key present)
## Blast radius
- impacts: host:arriman, host:elementsynapse, host:grimmory, host:house, host:jellyfin, host:romm, host:seanime
- affected by: (none)
- full blast radius: host:arriman, host:elementsynapse, host:grimmory, host:house, host:jellyfin, host:romm, host:seanime, service:arr_stack, service:jellyfin, service:matrix
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,20 +0,0 @@
# teddycloud (host:teddycloud)
- kind: lxc (LXC 131)
- state: active
- runs-on: host:hubris
- role: teddycloud
- address: 192.168.8.150
- mounts: /mnt/library
- doc: knowledge/wiki/containers/131-teddycloud.md
## Blast radius
- impacts: service:teddycloud
- affected by: host:hubris, mount:/mnt/library
- full blast radius: service:teddycloud
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- 2026-07-06T11:05:35+00:00 activate (config_mutation) — ok

View File

@@ -1,19 +0,0 @@
# trmnl (host:trmnl)
- kind: lxc (LXC 128)
- state: active
- runs-on: host:hubris
- role: trmnl-middleware
- address: 192.168.8.211
- doc: knowledge/wiki/containers/128-trmnl.md
## Blast radius
- impacts: service:trmnl
- affected by: host:hubris, repo:dtoro/terminalito
- full blast radius: service:trmnl
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# zimaos (host:zimaos)
- kind: vm (VM 100)
- state: active
- runs-on: host:hubris
- role: nas-frontend-eval
- address: 192.168.8.195
- doc: knowledge/wiki/vms/100-zimaos.md
## Blast radius
- impacts: service:zimaos
- affected by: host:hubris
- full blast radius: service:zimaos
## Safe actions
- see the services this host runs for action-level risk classes
## Recent changes
- (none yet)

View File

@@ -1,17 +0,0 @@
# arr_stack (service:arr_stack)
- backend: host:arriman
- doc: knowledge/wiki/containers/122-arriman.md
## Blast radius
- impacts: (none)
- affected by: host:arriman
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -1,20 +0,0 @@
# artifacto (service:artifacto)
- backend: host:apps
- url: https://artifacto.hubris.network
- doc: knowledge/wiki/containers/105-apps.md
- config repo: dtoro/Artifacto
## Blast radius
- impacts: (none)
- affected by: host:apps
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# authentik (service:authentik)
- backend: host:netbird-vps
- url: https://auth.hubris.network
- doc: knowledge/wiki/containers/106-auth-outpost.md
- risk notes: SSO provider — outage locks login to OIDC/forward-auth services
## Blast radius
- impacts: (none)
- affected by: host:netbird-vps
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -1,20 +0,0 @@
# caddy (service:caddy)
- backend: host:caddy
- doc: knowledge/wiki/containers/121-caddy.md
- config repo: dtoro/caddy-conf
- risk notes: wide blast radius — every *.hubris.network route rides on it (see oikos/policy.yaml service_overrides)
## Blast radius
- impacts: (none)
- affected by: host:caddy
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — config_mutation (approval: operator)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- 2026-07-06T11:29:56+00:00 add-site-block (config_mutation) — ok

View File

@@ -1,18 +0,0 @@
# dns (service:dns)
- backend: host:dns
- doc: knowledge/wiki/containers/107-dns.md
- risk notes: LAN-wide resolver — misconfig breaks name resolution for every client
## Blast radius
- impacts: (none)
- affected by: host:dns
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -1,21 +0,0 @@
# gitea (service:gitea)
- backend: host:gitea
- url: https://git.hubris.network
- doc: knowledge/wiki/containers/104-gitea.md
- config repo: dtoro/gitea-customizations
- risk notes: hosts all config repos + deploy webhooks; outage blocks auto-deploy and sync
## Blast radius
- impacts: (none)
- affected by: host:gitea
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -1,17 +0,0 @@
# haos (service:haos)
- backend: host:haos
- doc: knowledge/wiki/vms/108-haos.md
## Blast radius
- impacts: (none)
- affected by: host:haos
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -1,21 +0,0 @@
# homelab_mcp (service:homelab_mcp)
- backend: host:apps
- url: https://mcp.hubris.network/mcp
- doc: knowledge/wiki/infrastructure/homelab-context.md
- config repo: dtoro/Homelab-Docs
- risk notes: agents' primary read surface — outage degrades every agent to grepping the clone
## Blast radius
- impacts: (none)
- affected by: host:apps
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# jellyfin (service:jellyfin)
- backend: host:jellyfin
- url: https://media.hubris.network
- doc: knowledge/wiki/containers/101-jellyfin.md
- risk notes: native Authentik OIDC via SSO-Auth plugin, no Caddy forward-auth gate; VAAPI transcode depends on GPU passthrough on strong
## Blast radius
- impacts: (none)
- affected by: host:jellyfin
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# matrix (service:matrix)
- backend: host:elementsynapse
- url: https://matrix.hubris.network
- doc: knowledge/wiki/containers/118-elementsynapse.md
- risk notes: alert/approval channel for Oikos — outage silences agent escalation
## Blast radius
- impacts: (none)
- affected by: host:elementsynapse
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -1,18 +0,0 @@
# nextcloud (service:nextcloud)
- backend: host:nextcloud
- url: https://cloud.hubris.network
- doc: knowledge/wiki/containers/114-nextcloud.md
## Blast radius
- impacts: (none)
- affected by: host:nextcloud
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# paperless (service:paperless)
- backend: host:paperless
- url: https://paperless.hubris.network
- doc: knowledge/wiki/containers/103-paperless.md
- risk notes: document archive — treat data as irreplaceable; DB operations are destructive-class
## Blast radius
- impacts: (none)
- affected by: host:paperless
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -1,20 +0,0 @@
# photos (service:photos)
- backend: host:mule-images
- url: https://photos.hubris.network
- doc: knowledge/wiki/containers/120-mule-images.md
- config repo: dtoro/mule-image
## Blast radius
- impacts: (none)
- affected by: host:mule-images
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# proxmox_ui (service:proxmox_ui)
- backend: host:hubris
- url: https://proxmox.hubris.network
- doc: knowledge/wiki/hosts/hubris.md
- risk notes: hypervisor UI — changes here affect every guest on the node
## Blast radius
- impacts: (none)
- affected by: host:hubris
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -1,21 +0,0 @@
# secrets_issuance (service:secrets_issuance)
- backend: host:apps
- url: https://secrets.hubris.network/issue
- doc: .agents/operations/agent-enrollment.md
- config repo: dtoro/Homelab-Docs
- risk notes: identity issuance — any change is security-sensitive; key operations are destructive-class
## Blast radius
- impacts: (none)
- affected by: host:apps
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -1,19 +0,0 @@
# teddycloud (service:teddycloud)
- backend: host:teddycloud
- url: https://teddy.hubris.network
- doc: knowledge/wiki/containers/131-teddycloud.md
- risk notes: no Caddy forward-auth gate (unlike sab.hubris.network on the same Caddyfile) — reachable to anyone on the LAN/mesh who can resolve teddy.hubris.network; undocumented in inventory.yaml until 2026-07-06 (drift-caught)
## Blast radius
- impacts: (none)
- affected by: host:teddycloud
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -1,20 +0,0 @@
# trmnl (service:trmnl)
- backend: host:trmnl
- url: https://trmnl.hubris.network
- doc: knowledge/wiki/containers/128-trmnl.md
- config repo: dtoro/terminalito
## Blast radius
- impacts: (none)
- affected by: host:trmnl
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
- edit-config-and-deploy — config_mutation (approval: operator)
## Recent changes
- (none yet)

View File

@@ -1,18 +0,0 @@
# zimaos (service:zimaos)
- backend: host:zimaos
- url: https://zimaos.hubris.network
- doc: knowledge/wiki/vms/100-zimaos.md
## Blast radius
- impacts: (none)
- affected by: host:zimaos
## Safe actions
- health-check — read_only (approval: none)
- view-logs — read_only (approval: none)
- view-docs — read_only (approval: none)
- restart — reversible_low (approval: none)
## Recent changes
- (none yet)

View File

@@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""oikos/decide.py — the Decide stage of the OODA loop (decision classifier).
Scores a proposed action against three inputs and routes it:
1. risk class — oikos/policy.yaml (read_only / reversible_low /
config_mutation / destructive)
2. blast radius — oikos/relations.py transitive impact graph
3. confidence — prior ledger history of this exact action on this
exact entity, falling back to "is this a
well-known mechanical action" when there's no
history yet
Routes: **auto-act** (execute — risk is within unattended policy, blast
radius is contained, confidence isn't low) or **escalate** (request
operator approval via oikos/approve.py — anything else). The classifier
can only make an action MORE cautious than policy says, never less: if
policy already requires approval, escalate always wins regardless of
confidence or radius.
Note on the third OODA route, "queue": that's the Signal engine's
info-severity routing (oikos/signal.py SEVERITY_ROUTE — informational
findings that need no action land in the console/weekly report, not
here). This module only classifies things that might actually need to
run, so it only ever returns auto-act or escalate.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
from oikos import ledger as oikos_ledger # noqa: E402
from oikos import policy as oikos_policy # noqa: E402
from oikos import relations as oikos_relations # noqa: E402
# Actions we consider "well-known mechanical" absent any ledger history —
# mirrors oikos/policy.py's safe_actions_for_service() baseline.
_ROUTINE_ACTIONS = {"restart", "service-restart", "health-check", "view-logs",
"view-docs", "sync", "cache-clear"}
# A blast radius at or below this size counts as "contained" for auto-act
# purposes. Anything wider always escalates regardless of risk/confidence.
_CONTAINED_RADIUS = 1
def _confidence(action: str, entity_id: str) -> tuple[str, str]:
hist = oikos_ledger.history(entity_id, limit=50)
successes = [h for h in hist if h.get("action") == action and h.get("result") == "ok"]
failures = [h for h in hist
if h.get("action") == action and str(h.get("result", "")).startswith("failed")]
if failures and not successes:
return "low", f"{len(failures)} prior failed attempt(s) of '{action}' on this entity"
if successes:
return "high", f"{len(successes)} prior successful run(s) of '{action}' on this entity"
if action in _ROUTINE_ACTIONS:
return "medium", "no history yet, but this is a well-known mechanical action"
return "low", "no ledger history and not a recognized routine action"
def classify(action: str, entity: str, *, service_name: str | None = None,
record: bool = False) -> dict:
"""Classify one proposed action against one entity. `entity` may be a
bare name (resolved via oikos/relations.py) or an already-namespaced
id ("service:x" / "host:x"). If bare and ambiguous (matches both a
host and a service), the first resolved id is used — pass a
namespaced id explicitly to disambiguate.
"""
entity_ids = oikos_relations.resolve(entity)
entity_id = entity_ids[0]
action = oikos_policy.canonical_action(action)
# Per-service policy overrides are keyed by service name (e.g. "caddy").
# Most single-purpose nodes are named identically to the service they
# run, so infer it from the entity's bare name when not given explicitly.
if service_name is None:
service_name = entity_id.split(":", 1)[1] if ":" in entity_id else entity_id
risk = (oikos_policy.classify_action(action, service_name)
or oikos_policy.classify_command(action))
if risk is None:
risk = "config_mutation" # unknown action: default to the cautious class
approval = oikos_policy.approval_for(risk)
radius = oikos_relations.blast_radius(entity_id)
contained = len(radius) <= _CONTAINED_RADIUS
confidence, why = _confidence(action, entity_id)
if approval != "none":
route = "escalate"
reasoning = f"risk class '{risk}' requires approval ({approval}) per oikos/policy.yaml"
elif not contained:
route = "escalate"
reasoning = f"blast radius not contained ({len(radius)} entities: {', '.join(radius)})"
elif confidence == "low":
route = "escalate"
reasoning = f"low confidence — {why}"
else:
route = "auto-act"
reasoning = (f"risk '{risk}' is unattended-safe, blast radius contained "
f"({radius or 'none'}), confidence {confidence}{why}")
result = {
"entity": entity_id,
"action": action,
"risk": risk,
"approval": approval,
"blast_radius": radius,
"contained": contained,
"confidence": confidence,
"confidence_reason": why,
"route": route,
"reasoning": reasoning,
}
if record:
oikos_ledger.append(entity_id, f"decide:{action}", risk,
result=route, notes=reasoning)
return result
def main() -> int:
import argparse
p = argparse.ArgumentParser(description="oikos decision classifier")
p.add_argument("action")
p.add_argument("entity")
p.add_argument("--service-name", help="disambiguate policy overrides for a service action")
p.add_argument("--record", action="store_true",
help="append this classification to the change ledger")
args = p.parse_args()
result = classify(args.action, args.entity, service_name=args.service_name,
record=args.record)
print(json.dumps(result, indent=2))
return 0 if result["route"] == "auto-act" else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,302 +0,0 @@
#!/usr/bin/env python3
"""oikos/drift.py — drift detectors (Week 3 reliability layer).
Each detector returns a list of finding dicts:
{kind, severity, entity, evidence, likely_cause, recommended_action,
verification}
matching the Signal schema in oikos/signal.py. `run_all()` runs every
detector and raises a Signal for each finding, skipping ones that already
have an open Signal for the same (entity, kind) — see
oikos/signal.py open_signal_for(). SSH-based detectors degrade to a
"probe-unreachable" info finding instead of a false drift signal when the
target can't be reached (no ssh, no network) — drift means "state
disagrees," not "couldn't check."
Two detectors are fully local (no SSH, always safe to run from anywhere
with the repo checked out): SOPS-recipient-vs-inventory and lifecycle
consistency. Two need live access to hubris (pct list, caddy backend
config) and gracefully no-op when unreachable.
Not yet implemented — needs data this repo doesn't structure yet:
- DNS records vs inventory services (no Technitium API wiring here)
- Generic tracked-config-repo cleanliness for every service (needs the
`mutation_path`/local-checkout-path field per service; only caddy's
path is documented today, so that's the one config-cleanliness check
implemented below)
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
from oikos import relations as oikos_relations # noqa: E402
from oikos import signal as oikos_signal # noqa: E402
import yaml # noqa: E402
SOPS_FILE = REPO / ".sops.yaml"
INVENTORY = REPO / "inventory.yaml"
_AGE_RE = re.compile(r"age1[a-z0-9]+")
def _load_inventory() -> dict:
return yaml.safe_load(INVENTORY.read_text())
def _sops_rules() -> list[dict]:
"""Parse .sops.yaml's creation_rules: [{path_regex, recipients: [...]}]."""
doc = yaml.safe_load(SOPS_FILE.read_text())
return [
{"path_regex": r.get("path_regex", ""),
"recipients": _AGE_RE.findall(r.get("age", "") or "")}
for r in doc.get("creation_rules", [])
]
def check_sops_recipients(inv: dict | None = None) -> list[dict]:
"""Two directions of drift:
1. A recipient pubkey in .sops.yaml that matches no host's age_pubkey
in inventory — likely a removed/revoked host whose secrets weren't
fully cleaned up (or a stale rule).
2. An enrolled host (non-empty age_pubkey) missing from the
secrets/hello.yaml rule — every enrolled client should be able to
decrypt the bootstrap canary; absence usually means enrollment
stalled partway.
"""
inv = inv or _load_inventory()
hosts = inv.get("hosts", {})
known_pubkeys = {e["age_pubkey"]: name for name, e in hosts.items()
if e.get("age_pubkey")}
rules = _sops_rules()
findings = []
all_recipients = {pk for r in rules for pk in r["recipients"]}
for pk in all_recipients:
if pk not in known_pubkeys:
findings.append({
"kind": "sops-orphan-recipient", "severity": "warning",
"entity": "repo:Homelab-Docs",
"evidence": f"age recipient {pk[:20]}... appears in .sops.yaml but "
f"matches no host's age_pubkey in inventory.yaml",
"likely_cause": "host removed without full secret revocation, or a stale rule",
"recommended_action": {"runbook": "lifecycle-destroy-node", "risk": "destructive"},
"verification": "grep <pubkey> .sops.yaml; check inventory.yaml + archaeology",
})
hello_rule = next((r for r in rules if "hello" in r["path_regex"]), None)
if hello_rule:
for pubkey, name in known_pubkeys.items():
if pubkey not in hello_rule["recipients"]:
findings.append({
"kind": "sops-missing-recipient", "severity": "warning",
"entity": f"host:{name}",
"evidence": f"{name}'s age_pubkey is not a recipient of secrets/hello.yaml",
"likely_cause": "client-add --finalize-pubkey ran incompletely",
"recommended_action": {"runbook": "client-enrollment", "risk": "config_mutation"},
"verification": f"homelab client add {name} --finalize-pubkey <key>",
})
return findings
def check_lifecycle_consistency(inv: dict | None = None) -> list[dict]:
"""state: vs reality, using the ontology graph (oikos/relations.py) —
no SSH needed, this only reasons over inventory.yaml itself."""
inv = inv or _load_inventory()
hosts = inv.get("hosts", {})
archaeology = inv.get("archaeology", {})
findings = []
for name, e in hosts.items():
state = e.get("state", "active")
if state == "deprecated":
rel = oikos_relations.relations(f"host:{name}", inv)
if rel["affected_by"]:
findings.append({
"kind": "lifecycle-inconsistent", "severity": "warning",
"entity": f"host:{name}",
"evidence": f"deprecated but still has inbound edges: {', '.join(rel['affected_by'])}",
"likely_cause": "deprecation started before dependents were migrated off",
"recommended_action": {"runbook": "lifecycle-deprecate-node", "risk": "config_mutation"},
"verification": f"homelab node {name} relations",
})
elif state == "destroyed":
findings.append({
"kind": "lifecycle-inconsistent", "severity": "critical",
"entity": f"host:{name}",
"evidence": "state is 'destroyed' but the host still has a live hosts.<name>: entry "
"(destroyed nodes belong in the archaeology: section, not hosts:)",
"likely_cause": "lifecycle-destroy-node runbook step 6 (move to archaeology) was skipped",
"recommended_action": {"runbook": "lifecycle-destroy-node", "risk": "destructive"},
"verification": "grep -A3 '^ " + name + ":' inventory.yaml",
})
# A pve_id collision between an active host and an archaeology entry on
# the same Proxmox node usually means the ID was reused without the old
# entry's data being fully accounted for.
active_by_id = {(e.get("host"), e.get("pve_id")): name
for name, e in hosts.items() if e.get("pve_id")}
for aname, ae in archaeology.items():
key = (None, ae.get("pve_id")) # archaeology doesn't record which node
for (host, pve_id), name in active_by_id.items():
if pve_id == ae.get("pve_id") and name != aname:
findings.append({
"kind": "lifecycle-pve-id-reuse", "severity": "info",
"entity": f"host:{name}",
"evidence": f"pve_id {pve_id} is both active ({name}) and in archaeology ({aname}, "
f"destroyed {ae.get('destroyed', '?')})",
"likely_cause": "normal ID reuse after destroy — informational only",
"recommended_action": None,
"verification": None,
})
return findings
def _ssh_hubris(cmd: list[str]) -> subprocess.CompletedProcess | None:
"""Best-effort ssh to hubris. Returns None (not a CompletedProcess with
nonzero rc) if ssh itself can't run/connect at all, so callers can
distinguish "unreachable" from "reachable but the command failed."""
try:
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"root@192.168.8.77", " ".join(cmd)],
capture_output=True, text=True, timeout=15)
except (subprocess.TimeoutExpired, OSError):
return None
def check_pct_list(inv: dict | None = None) -> list[dict]:
"""Inventory LXCs/VMs on hubris vs live `pct list` + `qm list`."""
inv = inv or _load_inventory()
hosts = inv.get("hosts", {})
proc = _ssh_hubris(["pct", "list"])
if proc is None or proc.returncode != 0:
return [{
"kind": "probe-unreachable", "severity": "info", "entity": "host:hubris",
"evidence": "could not reach hubris to run `pct list`",
"likely_cause": "no network path from this client to hubris, or ssh key not authorized",
"recommended_action": None, "verification": "homelab ssh hubris -- pct list",
}]
live_ids = set()
for line in proc.stdout.splitlines()[1:]:
parts = line.split()
if parts and parts[0].isdigit():
live_ids.add(int(parts[0]))
inventory_ids = {e["pve_id"] for name, e in hosts.items()
if e.get("kind") == "lxc" and e.get("host") == "hubris" and e.get("pve_id")}
findings = []
for missing in sorted(inventory_ids - live_ids):
name = next(n for n, e in hosts.items() if e.get("pve_id") == missing)
findings.append({
"kind": "inventory-vs-live", "severity": "critical", "entity": f"host:{name}",
"evidence": f"pve_id {missing} ({name}) is in inventory.yaml but absent from "
f"`pct list` on hubris",
"likely_cause": "destroyed outside the lifecycle-destroy-node runbook, or migrated "
"without updating inventory",
"recommended_action": {"runbook": "lifecycle-destroy-node", "risk": "destructive"},
"verification": "homelab ssh hubris -- pct list",
})
for extra in sorted(live_ids - inventory_ids):
findings.append({
"kind": "inventory-vs-live", "severity": "warning", "entity": "host:hubris",
"evidence": f"pve_id {extra} exists on hubris (`pct list`) but has no inventory.yaml entry",
"likely_cause": "created outside the provision-node runbook",
"recommended_action": {"runbook": "lifecycle-provision-node", "risk": "config_mutation"},
"verification": "homelab ssh hubris -- pct config " + str(extra),
})
return findings
def check_caddy_backends(inv: dict | None = None) -> list[dict]:
"""Caddy's /etc/caddy (a git checkout of dtoro/caddy-conf, per
knowledge/wiki/containers/121-caddy.md) vs inventory service backend IPs. Best-effort
grep for reverse_proxy targets; skips services whose Caddyfile snippet
doesn't use a bare IP (e.g. references a Caddy snippet/import)."""
inv = inv or _load_inventory()
hosts = inv.get("hosts", {})
services = inv.get("services", {})
caddy_ip = hosts.get("caddy", {}).get("lan_ip")
if not caddy_ip:
return []
try:
proc = subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", f"root@{caddy_ip}",
"grep -rhoE 'reverse_proxy[^{]*' /etc/caddy/ 2>/dev/null"],
capture_output=True, text=True, timeout=15,
)
except (subprocess.TimeoutExpired, OSError):
proc = None
if proc is None or proc.returncode != 0:
return [{
"kind": "probe-unreachable", "severity": "info", "entity": "host:caddy",
"evidence": "could not reach caddy to inspect /etc/caddy",
"likely_cause": "no network path from this client to caddy, or ssh key not authorized",
"recommended_action": None, "verification": "homelab ssh caddy -- grep -r reverse_proxy /etc/caddy",
}]
live_ips = set(re.findall(r"\d+\.\d+\.\d+\.\d+", proc.stdout))
findings = []
for svc, e in services.items():
if not isinstance(e, dict):
continue
backend = e.get("backend")
if not backend or not (e.get("url") or e.get("public_host")):
continue
backend_ip = hosts.get(backend, {}).get("lan_ip")
if backend_ip and live_ips and backend_ip not in live_ips:
findings.append({
"kind": "caddy-backend-mismatch", "severity": "warning", "entity": f"service:{svc}",
"evidence": f"inventory backend IP {backend_ip} ({backend}) not found in any "
f"reverse_proxy directive on caddy",
"likely_cause": "backend migrated (lan_ip changed) without updating the Caddyfile, "
"or the service uses a snippet/import this grep can't resolve",
"recommended_action": {"runbook": "config-change-deploy", "risk": "config_mutation"},
"verification": f"homelab service {svc} health",
})
return findings
DETECTORS = [check_sops_recipients, check_lifecycle_consistency,
check_pct_list, check_caddy_backends]
def run_all(inv: dict | None = None, *, raise_signals: bool = False) -> list[dict]:
inv = inv or _load_inventory()
findings: list[dict] = []
for detector in DETECTORS:
findings.extend(detector(inv))
if raise_signals:
for f in findings:
if oikos_signal.open_signal_for(f["entity"], f["kind"]) is not None:
continue # already open, don't duplicate
oikos_signal.raise_signal(
f["kind"], f["severity"], f["entity"], f["evidence"],
likely_cause=f.get("likely_cause"),
recommended_action=f.get("recommended_action"),
verification=f.get("verification"),
)
return findings
def main() -> int:
import argparse
import json
p = argparse.ArgumentParser(description="oikos drift detectors")
p.add_argument("--raise-signals", action="store_true",
help="raise a Signal for each new finding (default: print only)")
args = p.parse_args()
findings = run_all(raise_signals=args.raise_signals)
for f in findings:
print(json.dumps(f))
print(f"\n{len(findings)} finding(s)", file=sys.stderr)
return 1 if any(f["severity"] == "critical" for f in findings) else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,109 +0,0 @@
#!/usr/bin/env python3
"""oikos/ledger.py — append-only change ledger.
Every mutation an agent or operator performs (reversible_low and above,
per oikos/policy.yaml) gets one JSON line in ledger/<YYYY-MM>.jsonl:
timestamp, acting agent identity, entity, action, risk class, approval
reference, verification result. Append-only, committed like any tracked
file — never edited or reordered in place.
CLI:
python3 oikos/ledger.py append <entity> <action> <risk> [--verification ...] [--result ...]
python3 oikos/ledger.py history <entity> [--limit N]
"""
from __future__ import annotations
import json
import os
import socket
import sys
from datetime import datetime, timezone
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
LEDGER_DIR = REPO / "ledger"
def _agent_identity() -> str:
"""Best-effort identity for the acting agent. HOMELAB_AGENT_ID lets
approval-engine callers stamp the real requester; falls back to the
local hostname."""
return os.environ.get("HOMELAB_AGENT_ID") or socket.gethostname().split(".")[0]
def append(entity: str, action: str, risk: str, *, verification: str | None = None,
result: str | None = None, approval_ref: str | None = None,
agent: str | None = None, notes: str | None = None) -> dict:
"""Append one change entry. Returns the recorded dict (None fields dropped)."""
entry = {
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"agent": agent or _agent_identity(),
"entity": entity,
"action": action,
"risk": risk,
"approval_ref": approval_ref,
"verification": verification,
"result": result,
"notes": notes,
}
entry = {k: v for k, v in entry.items() if v is not None}
LEDGER_DIR.mkdir(exist_ok=True)
month = datetime.now(timezone.utc).strftime("%Y-%m")
path = LEDGER_DIR / f"{month}.jsonl"
with path.open("a") as f:
f.write(json.dumps(entry, sort_keys=False) + "\n")
return entry
def history(entity: str, limit: int = 20) -> list[dict]:
"""Most recent `limit` ledger entries for `entity`, newest first."""
entries: list[dict] = []
if not LEDGER_DIR.exists():
return entries
for path in sorted(LEDGER_DIR.glob("*.jsonl")):
for line in path.read_text().splitlines():
if not line.strip():
continue
try:
e = json.loads(line)
except json.JSONDecodeError:
continue
if e.get("entity") == entity:
entries.append(e)
entries.sort(key=lambda e: e.get("ts", ""), reverse=True)
return entries[:limit]
def main() -> int:
import argparse
p = argparse.ArgumentParser(description="oikos change ledger")
sub = p.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("append")
sp.add_argument("entity")
sp.add_argument("action")
sp.add_argument("risk")
sp.add_argument("--verification")
sp.add_argument("--result")
sp.add_argument("--approval-ref")
sp.add_argument("--notes")
sh = sub.add_parser("history")
sh.add_argument("entity")
sh.add_argument("--limit", type=int, default=20)
args = p.parse_args()
if args.cmd == "append":
entry = append(args.entity, args.action, args.risk,
verification=args.verification, result=args.result,
approval_ref=args.approval_ref, notes=args.notes)
print(json.dumps(entry, indent=2))
elif args.cmd == "history":
for e in history(args.entity, args.limit):
print(json.dumps(e))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,72 +0,0 @@
"""oikos/policy.py — load oikos/policy.yaml and classify actions.
Shared by bin/homelab, mcp/server.py, and oikos/gen-topology.py so every
surface agrees on risk classes. See OIKOS.md for the operating model.
"""
from __future__ import annotations
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parent.parent
POLICY_FILE = REPO / "oikos" / "policy.yaml"
def load() -> dict:
return yaml.safe_load(POLICY_FILE.read_text())
def classify_command(cmd: str) -> str | None:
"""Risk class for a `homelab <cmd>` subcommand."""
return load().get("commands", {}).get(cmd)
# Synonyms for the canonical action keys in oikos/policy.yaml `actions:`.
# `homelab restart <service>` is the real CLI verb; "restart" is what an
# agent proposing the action is most likely to say. Keep this list in sync
# with anything oikos/decide.py or the CLI classifies by name.
ACTION_ALIASES = {
"restart": "service-restart",
}
def canonical_action(action: str) -> str:
return ACTION_ALIASES.get(action, action)
def classify_action(action: str, service: str | None = None) -> str | None:
"""Risk class for a generic action, honoring per-service overrides."""
action = canonical_action(action)
pol = load()
if service:
override = pol.get("service_overrides", {}).get(service, {}).get(action)
if override:
return override
return pol.get("actions", {}).get(action)
def approval_for(risk: str) -> str:
return load().get("risk_classes", {}).get(risk, {}).get("approval", "unknown")
def safe_actions_for_service(name: str, svc_entry: dict) -> list[dict]:
"""Actions an agent can propose for this service, each tagged with its
risk class and whether operator approval is required. Derived from what
the service entry actually declares — no action is offered that the
service doesn't support.
"""
out = [
{"action": "health-check", "risk": "read_only", "approval": "none"},
{"action": "view-logs", "risk": "read_only", "approval": "none"},
{"action": "view-docs", "risk": "read_only", "approval": "none"},
]
if svc_entry.get("backend"):
risk = classify_action("service-restart", name)
out.append({"action": "restart", "risk": risk, "approval": approval_for(risk)})
if svc_entry.get("config_repo"):
risk = classify_action("tracked-config-edit", name)
out.append({"action": "edit-config-and-deploy", "risk": risk,
"approval": approval_for(risk)})
return out

View File

@@ -1,131 +0,0 @@
"""oikos/relations.py — walk the ontology graph derived from inventory.yaml.
Wires up the subset of oikos/ontology.yaml relationships that are already
structured data today: hosts, mounts, provides, configured-by, depends-on.
Everything else in the ontology (physical, external, identity domains) is
documented but thin — not yet backed by inventory fields, so it doesn't
appear in the graph until those fields are populated.
Entities are namespaced ("host:name", "service:name", "repo:name",
"mount:path") because host and service names collide in this inventory
(e.g. "jellyfin" is both a service and its own LXC).
Impact polarity: build_impacts() returns edges in the direction
"if SOURCE fails/disappears, TARGET is affected" — this is not the same
direction as how the fact is stored in inventory (e.g. a mount is stored
as guest -> pool, but if the POOL fails the GUEST is impacted, so the
impact edge runs pool -> guest).
"""
from __future__ import annotations
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parent.parent
INVENTORY = REPO / "inventory.yaml"
def _hid(name: str) -> str:
return f"host:{name}"
def _sid(name: str) -> str:
return f"service:{name}"
def _rid(name: str) -> str:
return f"repo:{name}"
def _mid(name: str) -> str:
return f"mount:{name}"
def load_inventory() -> dict:
return yaml.safe_load(INVENTORY.read_text())
def resolve(entity: str, inv: dict | None = None) -> list[str]:
"""Map a bare name (as typed on the CLI) to every namespaced id it
could refer to. A bare name may match a host AND a service."""
inv = inv or load_inventory()
if ":" in entity:
return [entity]
ids = []
if entity in inv.get("hosts", {}):
ids.append(_hid(entity))
if entity in inv.get("services", {}):
ids.append(_sid(entity))
if not ids:
ids.append(entity)
return ids
def build_impacts(inv: dict | None = None) -> dict[str, set[str]]:
inv = inv or load_inventory()
hosts = inv.get("hosts", {})
services = inv.get("services", {})
impacts: dict[str, set[str]] = {}
def add(source: str, target: str) -> None:
impacts.setdefault(source, set()).add(target)
for name, e in hosts.items():
hid = _hid(name)
parent = e.get("host")
if parent:
add(_hid(parent), hid) # host failing -> guest impacted
for mount in e.get("mounts", []):
add(_mid(mount), hid) # pool failing -> mounter impacted
for dep in e.get("depends_on", []) or []:
add(_hid(dep), hid) # dependency failing -> dependent impacted
for svc, e in services.items():
if not isinstance(e, dict):
continue
sid = _sid(svc)
backend = e.get("backend")
if backend and backend in hosts:
add(_hid(backend), sid) # backend failing -> service impacted
config_repo = e.get("config_repo")
if config_repo:
add(_rid(config_repo), _hid(backend)) # bad config -> backend impacted
return impacts
def blast_radius(entity_id: str, inv: dict | None = None) -> list[str]:
"""Transitive closure: every entity affected if `entity_id` fails."""
impacts = build_impacts(inv)
seen: set[str] = set()
stack = [entity_id]
while stack:
cur = stack.pop()
for nxt in impacts.get(cur, ()):
if nxt not in seen:
seen.add(nxt)
stack.append(nxt)
return sorted(seen)
def relations(entity_id: str, inv: dict | None = None) -> dict:
"""One entity's direct edges plus its full transitive blast radius."""
impacts = build_impacts(inv)
impacts_on = sorted(impacts.get(entity_id, ()))
affected_by = sorted(src for src, targets in impacts.items() if entity_id in targets)
return {
"entity": entity_id,
"impacts": impacts_on,
"affected_by": affected_by,
"blast_radius": blast_radius(entity_id, inv),
}
def relations_for_name(name: str, inv: dict | None = None) -> list[dict]:
"""CLI/MCP entry point: resolve a bare name and report relations for
every matching entity id (usually one; two if host and service share
a name)."""
inv = inv or load_inventory()
return [relations(eid, inv) for eid in resolve(name, inv)]

View File

@@ -1,230 +0,0 @@
#!/usr/bin/env python3
"""oikos/scheduler.py — the Observe stage: periodic probes -> Signals + state cache.
Intended to run under a systemd timer (see scripts/sync/linux/ for the
existing timer pattern this should mirror — deploy target: LXC 105
alongside the MCP server) every 5-15 minutes. Each run:
1. Probes every service's HTTP health and writes oikos/state.json — the
snapshot `homelab service <name> health` serves by default (cache-
first reads); pass --live on that command to force a fresh probe
instead of trusting the cache.
2. Probes disk usage on hubris + strong (best-effort SSH; degrades to a
"probe-unreachable" info Signal rather than a false disk-threshold
alarm when unreachable).
3. Runs the Week-3 drift detectors (oikos/drift.py).
4. Raises Signals for anything over threshold or drifted — skipping
duplicates via oikos_signal.open_signal_for() — and auto-resolves
any open Signal whose condition has since cleared.
Temperature probing is NOT implemented here: LXCs don't expose host
sensors, and hubris/strong's actual sensor path (lm-sensors vs vendor
tool) hasn't been confirmed on either box yet — a real check needs that
groundwork first rather than a guessed command. Backup-freshness is
likewise deferred: `backs-up-to` isn't populated in inventory yet (see
oikos/ontology.yaml — it's a documented thin field), so there's nothing
structured to check freshness against.
CLI:
python3 oikos/scheduler.py run # probe, raise signals, write state.json
python3 oikos/scheduler.py run --dry-run # probe and print only, no side effects
"""
from __future__ import annotations
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
from oikos import drift as oikos_drift # noqa: E402
from oikos import signal as oikos_signal # noqa: E402
INVENTORY = REPO / "inventory.yaml"
STATE_FILE = REPO / "oikos" / "state.json"
DISK_WARN_PCT = 85
DISK_CRIT_PCT = 95
HEALTH_TIMEOUT_S = 5
def _load_inventory() -> dict:
return yaml.safe_load(INVENTORY.read_text())
# Services whose public `url`/`endpoint` isn't a plain-GET health check —
# mirrors the special-casing bin/homelab's `doctor` command already does.
# A generic per-service `health:` inventory field (checked URL/command
# distinct from the public url) would remove the need for this; until
# that's backfilled, these are the two known exceptions.
_HEALTH_OVERRIDES = {
# FastMCP's streamable-http endpoint expects a JSON-RPC POST handshake,
# not a bare GET — a plain curl gets 4xx even when the server is fully
# healthy (confirmed against the live server: consistently 400, never
# 2xx/3xx, for a GET). Any HTTP response at all (vs. no response/
# connection refused) proves the process is up and answering, which is
# what "service-down" alerting actually cares about here.
"homelab_mcp": {"extra_curl_args": ["-H", "Accept: text/event-stream"],
"any_response_ok": True},
"secrets_issuance": {
"url_transform": lambda url: url.rstrip("/").rsplit("/", 1)[0] + "/health",
},
# Token-gated at the app level (401 without a token is correct, not
# down) — see knowledge/wiki/containers/128-trmnl.md, which documents a dedicated
# /health endpoint returning 200 unauthenticated.
"trmnl": {"url_transform": lambda url: url.rstrip("/") + "/health"},
}
def probe_service_health(name: str, entry: dict) -> dict:
url = entry.get("url") or entry.get("endpoint")
if not url:
return {"service": name, "checked": False}
override = _HEALTH_OVERRIDES.get(name, {})
check_url = override.get("url_transform", lambda u: u)(url)
curl_cmd = ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", str(HEALTH_TIMEOUT_S), *override.get("extra_curl_args", []), check_url]
try:
proc = subprocess.run(curl_cmd, capture_output=True, text=True)
code = proc.stdout.strip()
except OSError:
code = ""
ok = bool(code) if override.get("any_response_ok") else code.startswith(("2", "3"))
return {"service": name, "checked": True, "url": url, "checked_url": check_url,
"http_code": code or None, "ok": ok}
def _ssh(host_ip: str, remote_cmd: str, timeout: int = 10) -> str | None:
try:
proc = subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", f"ConnectTimeout={min(timeout, 5)}",
f"root@{host_ip}", remote_cmd],
capture_output=True, text=True, timeout=timeout,
)
except (subprocess.TimeoutExpired, OSError):
return None
return proc.stdout.strip() if proc.returncode == 0 else None
def probe_disk(name: str, entry: dict) -> dict | None:
"""`df -P /` on a proxmox-host's own root fs. LXC-level disk usage is
already covered by MCP's get_lxc_state; this probe is host-level."""
if entry.get("kind") != "proxmox-host" or not entry.get("lan_ip"):
return None
out = _ssh(entry["lan_ip"], "df -P / | tail -1")
if out is None:
return {"host": name, "checked": False}
parts = out.split()
if len(parts) < 5 or not parts[4].rstrip("%").isdigit():
return {"host": name, "checked": False}
return {"host": name, "checked": True, "used_pct": int(parts[4].rstrip("%"))}
def run(dry_run: bool = False) -> dict:
inv = _load_inventory()
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
services_state = {}
for name, entry in inv.get("services", {}).items():
if not isinstance(entry, dict):
continue
result = probe_service_health(name, entry)
services_state[name] = result
if not result.get("checked"):
continue
sig_kind = "service-down"
if result["ok"]:
open_sig = oikos_signal.open_signal_for(f"service:{name}", sig_kind)
if open_sig and not dry_run:
oikos_signal.resolve(open_sig["id"], note="health probe recovered")
else:
if not dry_run and oikos_signal.open_signal_for(f"service:{name}", sig_kind) is None:
oikos_signal.raise_signal(
sig_kind, "critical", f"service:{name}",
f"{result['url']} -> {result.get('http_code') or 'no response'}",
likely_cause="backend down, crashed, or ingress misconfigured",
recommended_action={"runbook": "service-health-check", "risk": "reversible_low"},
verification=f"homelab service {name} health --live",
)
hosts_state = {}
for name, entry in inv.get("hosts", {}).items():
disk = probe_disk(name, entry)
if disk is None:
continue
hosts_state[name] = disk
if not disk.get("checked"):
continue
pct = disk["used_pct"]
sig_kind = "disk-threshold"
if pct >= DISK_CRIT_PCT:
severity = "critical"
elif pct >= DISK_WARN_PCT:
severity = "warning"
else:
severity = None
open_sig = oikos_signal.open_signal_for(f"host:{name}", sig_kind)
if severity is None:
if open_sig and not dry_run:
oikos_signal.resolve(open_sig["id"], note=f"disk usage back to {pct}%")
elif open_sig is None and not dry_run:
oikos_signal.raise_signal(
sig_kind, severity, f"host:{name}", f"root filesystem {pct}% used",
likely_cause="data growth or a runaway log",
recommended_action={"runbook": "config-change-deploy", "risk": "config_mutation"},
verification=f"homelab ssh {name} -- df -h /",
)
drift_findings = oikos_drift.run_all(inv, raise_signals=not dry_run)
snapshot = {
"generated_at": now,
"services": services_state,
"hosts": hosts_state,
"drift_findings": len(drift_findings),
}
if not dry_run:
STATE_FILE.write_text(json.dumps(snapshot, indent=2) + "\n")
return snapshot
def read_state() -> dict | None:
"""Load the last scheduler snapshot, or None if it's never run."""
if not STATE_FILE.exists():
return None
try:
return json.loads(STATE_FILE.read_text())
except json.JSONDecodeError:
return None
def cached_service_health(name: str) -> dict | None:
state = read_state()
if state is None:
return None
entry = state.get("services", {}).get(name)
if entry is None:
return None
return {**entry, "as_of": state.get("generated_at")}
def main() -> int:
import argparse
p = argparse.ArgumentParser(description="oikos scheduler (Observe stage)")
sub = p.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("run")
r.add_argument("--dry-run", action="store_true")
args = p.parse_args()
if args.cmd == "run":
snapshot = run(dry_run=args.dry_run)
print(json.dumps(snapshot, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,239 +0,0 @@
#!/usr/bin/env python3
"""oikos/signal.py — the Signal engine (attention layer).
A Signal is anything the lab notices that needs attention and possibly
action: pending updates, high temperature, low disk, a service down, a
cert expiring, a stale backup, inventory drift, a node stuck mid-
lifecycle-transition. See OIKOS.md / oikos/ontology.yaml "Signals — the
attention layer".
Storage: signals/<YYYY-MM>.jsonl, same append-only-JSONL convention as
oikos/ledger.py, but a signal's *state* changes over its lifecycle
(raised -> acknowledged -> acting -> resolved | muted). Each state change
is a NEW line with the same id — never edit a line in place, so git
history stays a legible append-only audit trail. `current()` /
`list_signals()` reduce the log to each id's latest state.
CLI:
python3 oikos/signal.py raise <kind> <severity> <entity> <evidence> [--likely-cause] [--action-runbook] [--action-risk] [--verification]
python3 oikos/signal.py list [--state raised] [--entity strong] [--severity warning]
python3 oikos/signal.py ack <id>
python3 oikos/signal.py resolve <id> [--note ...]
python3 oikos/signal.py mute <id> [--ttl-hours 24]
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SIGNALS_DIR = REPO / "signals"
VALID_SEVERITIES = ("info", "warning", "critical")
VALID_STATES = ("raised", "acknowledged", "acting", "resolved", "muted")
# Routing per oikos/ontology.yaml "Signals" section.
SEVERITY_ROUTE = {
"info": "console+report",
"warning": "matrix-digest",
"critical": "matrix-immediate",
}
def _month_path(dt: datetime | None = None) -> Path:
dt = dt or datetime.now(timezone.utc)
return SIGNALS_DIR / f"{dt.strftime('%Y-%m')}.jsonl"
def _all_entries() -> list[dict]:
if not SIGNALS_DIR.exists():
return []
out = []
for path in sorted(SIGNALS_DIR.glob("*.jsonl")):
for line in path.read_text().splitlines():
if not line.strip():
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
continue
return out
def _next_id(dt: datetime | None = None) -> str:
dt = dt or datetime.now(timezone.utc)
prefix = f"sig-{dt.strftime('%Y-%m-%d')}-"
existing = [e["id"] for e in _all_entries() if e.get("id", "").startswith(prefix)]
n = 1
while f"{prefix}{n:04d}" in existing:
n += 1
return f"{prefix}{n:04d}"
def _append(entry: dict) -> dict:
SIGNALS_DIR.mkdir(exist_ok=True)
entry = {k: v for k, v in entry.items() if v is not None}
with _month_path().open("a") as f:
f.write(json.dumps(entry, sort_keys=False) + "\n")
return entry
def raise_signal(kind: str, severity: str, entity: str, evidence: str, *,
likely_cause: str | None = None,
recommended_action: dict | None = None,
verification: str | None = None,
signal_id: str | None = None) -> dict:
"""Raise a new Signal, or (if `signal_id` names an existing one and it's
not currently open) re-raise it. Returns the recorded entry."""
if severity not in VALID_SEVERITIES:
raise ValueError(f"severity must be one of {VALID_SEVERITIES}")
sid = signal_id or _next_id()
entry = {
"id": sid,
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"kind": kind,
"severity": severity,
"entity": entity,
"evidence": evidence,
"likely_cause": likely_cause,
"recommended_action": recommended_action,
"verification": verification,
"state": "raised",
"route": SEVERITY_ROUTE.get(severity, "console+report"),
}
return _append(entry)
def _transition(signal_id: str, state: str, *, note: str | None = None,
mute_until: str | None = None) -> dict:
if state not in VALID_STATES:
raise ValueError(f"state must be one of {VALID_STATES}")
prior = current(signal_id)
if prior is None:
raise ValueError(f"unknown signal id: {signal_id}")
entry = {**prior, "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"state": state, "note": note, "mute_until": mute_until}
return _append(entry)
def acknowledge(signal_id: str, note: str | None = None) -> dict:
return _transition(signal_id, "acknowledged", note=note)
def start_acting(signal_id: str, note: str | None = None) -> dict:
return _transition(signal_id, "acting", note=note)
def resolve(signal_id: str, note: str | None = None) -> dict:
return _transition(signal_id, "resolved", note=note)
def mute(signal_id: str, ttl_hours: int = 24, note: str | None = None) -> dict:
until = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).isoformat(timespec="seconds")
return _transition(signal_id, "muted", note=note, mute_until=until)
def current(signal_id: str) -> dict | None:
"""Latest recorded state for one signal id, or None if unknown."""
matches = [e for e in _all_entries() if e.get("id") == signal_id]
if not matches:
return None
matches.sort(key=lambda e: e.get("ts", ""))
return matches[-1]
def list_signals(state: str | None = None, entity: str | None = None,
severity: str | None = None, kind: str | None = None) -> list[dict]:
"""Every signal's latest state, optionally filtered. Auto-expires mute
(a signal muted past its mute_until is treated as raised again)."""
latest: dict[str, dict] = {}
for e in sorted(_all_entries(), key=lambda e: e.get("ts", "")):
sid = e.get("id")
if sid:
latest[sid] = e
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
out = []
for e in latest.values():
if e.get("state") == "muted" and e.get("mute_until") and e["mute_until"] < now:
e = {**e, "state": "raised", "note": "mute expired"}
if state and e.get("state") != state:
continue
if entity and e.get("entity") != entity:
continue
if severity and e.get("severity") != severity:
continue
if kind and e.get("kind") != kind:
continue
out.append(e)
out.sort(key=lambda e: e.get("ts", ""), reverse=True)
return out
def open_signal_for(entity: str, kind: str) -> dict | None:
"""The currently-open (raised/acknowledged/acting) signal for this
entity+kind, if any. Used by probes to avoid raising duplicates and to
auto-resolve when a condition clears."""
for e in list_signals(entity=entity, kind=kind):
if e.get("state") in ("raised", "acknowledged", "acting"):
return e
return None
def main() -> int:
import argparse
p = argparse.ArgumentParser(description="oikos signal engine")
sub = p.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("raise")
r.add_argument("kind")
r.add_argument("severity", choices=VALID_SEVERITIES)
r.add_argument("entity")
r.add_argument("evidence")
r.add_argument("--likely-cause")
r.add_argument("--action-runbook")
r.add_argument("--action-risk")
r.add_argument("--verification")
ls = sub.add_parser("list")
ls.add_argument("--state", choices=VALID_STATES)
ls.add_argument("--entity")
ls.add_argument("--severity", choices=VALID_SEVERITIES)
ls.add_argument("--kind")
for name in ("ack", "resolve"):
sp = sub.add_parser(name)
sp.add_argument("id")
sp.add_argument("--note")
mt = sub.add_parser("mute")
mt.add_argument("id")
mt.add_argument("--ttl-hours", type=int, default=24)
mt.add_argument("--note")
args = p.parse_args()
if args.cmd == "raise":
action = None
if args.action_runbook or args.action_risk:
action = {"runbook": args.action_runbook, "risk": args.action_risk}
entry = raise_signal(args.kind, args.severity, args.entity, args.evidence,
likely_cause=args.likely_cause, recommended_action=action,
verification=args.verification)
print(json.dumps(entry, indent=2))
elif args.cmd == "list":
for e in list_signals(state=args.state, entity=args.entity,
severity=args.severity, kind=args.kind):
print(json.dumps(e))
elif args.cmd == "ack":
print(json.dumps(acknowledge(args.id, args.note), indent=2))
elif args.cmd == "resolve":
print(json.dumps(resolve(args.id, args.note), indent=2))
elif args.cmd == "mute":
print(json.dumps(mute(args.id, args.ttl_hours, args.note), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())