Phase 4 — Performance: - F1: SSH DialPool with key-by-host pooling and 5min idle TTL - F2: In-memory entity lookup cache (TTL 60s, HTTP resolveEntityID) - F3: Trigram GIN indexes on entities.slug and entities.name (migration 031) - F4: Partial index on executions(classification_id) for auto-act (migration 032) - Added missing RunOutput and RunStreaming in actuator/ (E3 gap fill) Phase 6 — Infrastructure: - H1: Infisical image pinned to v0.99.1 - H2: execworker daemon — polls pending executions with per-execution advisory locks, recovers orphaned running executions, wired as docker-compose service - H3: splitSQL hardened with block comment and string-literal support, 6 new edge-case tests (11 total) - H4: Scheduler acquires pg_try_advisory_lock(0x01c05e6) at startup
29 KiB
2026-08-05 — Backend evaluation: architecture, security, and reliability improvements
Status: Complete — All three phases implemented, hardened across two /review
passes, and deployed (0.28.0–0.29.0, Aug 8 2026).
- Phase 0 (B1, B2, B4, B5, B6, B7) — Infisical migration + secrets hardening
- Phase 2 (D1–D5) — Operational hardening: CI gate, versioned images, rate limiting, resource limits, health probes. Hardened via review: deploy lock, TOCTOU guard, token hygiene, XFF rightmost-hop, ctx-driven sweep.
- Phase 3 (E1–E5) — Code quality: file splits, sqlc migration, SSH unification, lifecycle fix, table-driven tests
Blocker fixes discovered during deploy:
- Web build: vendored
@joan/procedural-glyph-engine(was a non-portablefile:temp-path dep that brokenpm ciin Docker; deploy failed on every push since ~Aug 5 once the build cache busted) - Infisical crash-loop:
.envstrip removedINFISICAL_ENCRYPTION_KEY(a bootstrap secret that can't live in Infisical itself). Restored from worktree.envbackup. JWT secrets are dev defaults (OK — only affects web-UI auth). - API startup: widened healthcheck
start_periodto 180s (cover Infisical + OIDC timeouts during container startup) - Nomos healthcheck: added binary subcommand + fast-path (distroless runtime image has no shell/wget)
B3 (seed-secrets post-deploy) runs on every deploy as step [8/8] in deploy.sh. Remaining: Phase 1 security (C1–C3) and Phase 4–6 backlog.
Scope: full evaluation of the oikos backend (Go binaries oikos, nomos, webhook,
Postgres/TimescaleDB, Docker deployment, MCP server) excluding frontend clients
(web/ SPA and desktop/ Wails app). Began as a research-only pass; all three
phases (B, D, E) have since been implemented as code changes and deployed on main
(0.28.0–0.29.0).
Method: four parallel research passes (Go backend structure, database schema,
deployment/infrastructure, API/MCP design) plus Infisical secrets audit and
dependency analysis of go.mod.
A. Summary of findings
The backend is well-architected with strong fundamentals: contract-first API (oapi-codegen), type-safe SQL (sqlc + pgx), TimescaleDB observability, policy- governed autonomy, and a sophisticated ontology-driven data model. The main gaps are secret management (Infisical is wired but barely used — 3 of 4 binaries read secrets from env/plaintext), operational maturity (CI, image tagging, backup reliability), security hardening (SSH host keys, unauthenticated endpoints), and code hygiene (monolithic files, mixed SQL access patterns).
| Category | Grade | Notes |
|---|---|---|
| Tech stack | A | Go + pgx + sqlc + TimescaleDB + chi + slog — all correct choices |
| Data model | A- | Dual-entity pattern, temporal relationships, partial indexes, 6 state machines |
| API design | B+ | Contract-first with ~50 MCP tools, RFC 9457 errors; no rate limiting |
| Secret management | D | Infisical SDK wired but only in API/MCP tools path; nomos, webhook, scheduler, notifier all read plaintext env vars. 7 production secrets in .env, HMAC in world-readable plist. SOPS fallback is dead code. |
| Security | C | OIDC+static token auth is good, but SSH host keys disabled, unauthenticated nomos endpoint, HMAC secret in world-readable plist |
| Deployment | C+ | Multi-stage builds, pre-deploy backups, but no CI, no versioned images, silent backup failures |
| Code quality | B | Good error handling, panic safety, doc; but 3 files over 1100 lines, mixed raw/sqlc SQL |
| Performance | B | Appropriate for scale; SSH check storm risk, no query caching |
| Observability | B- | TimescaleDB hypertables + SSE + slog; no Prometheus/Grafana, no OTel tracing |
| Testing | C | make test exists but many core packages (scheduler, domain, actuator, policy) have 0% coverage |
B. Infisical consolidation (critical)
Infisical is deployed (Redis + Infisical service in compose, Go SDK in go.mod,
internal/secrets/infisical.go fully implemented) but severely underutilized.
Only the API server's MCP tools path creates an Infisical backend. Every other
binary reads secrets from env vars or plaintext files.
Current wiring map
| Binary / role | Uses Infisical? | Secrets read from env/plaintext |
|---|---|---|
oikos api role |
Yes (MCP tools only) | OIKOS_DATABASE_URL, OIKOS_MCP_BEARER_TOKEN, OIKOS_API_TOKEN, OIKOS_OIDC_CLIENT_SECRET |
oikos scheduler role |
No | OIKOS_SSH_KEY_PATH, OIKOS_DATABASE_URL |
oikos notifier role |
No | OIKOS_MATRIX_TOKEN, OIKOS_APPROVAL_HMAC_SECRET, OIKOS_DATABASE_URL |
| nomos | No | OPENROUTER_API_KEY, OIKOS_MCP_BEARER_TOKEN, DATABASE_URL |
| webhook | No | WEBHOOK_HMAC_SECRET (also hardcoded in plist) |
B1. Wire Infisical into all binaries at startup
- Goal: Every binary fetches its secrets from Infisical at startup instead of
relying on env vars. Bootstrap-only env vars (
INFISICAL_CLIENT_ID,INFISICAL_CLIENT_SECRET,INFISICAL_SITE_URL,INFISICAL_PROJECT_ID,OIKOS_DATABASE_URL) remain as env vars (chicken-egg). - Approach: Add a
secrets.InitFromEnv(ctx)call to each binary'smain()that creates asecrets.Manager(primary Infisical + SOPS fallback). Store the manager in a package-level var or pass it through the initialization chain. - Files to change:
cmd/oikos/main.go— create Manager inrunWithPool, pass to scheduler and notifier runners alongside cfg and poolcmd/nomos/main.go— create Manager at startup, fetchOPENROUTER_API_KEYandOIKOS_MCP_BEARER_TOKENfrom Infisical before creating the agentcmd/webhook/main.go— create Manager at startup, fetchWEBHOOK_HMAC_SECRETfrom Infisical
- Secrets to migrate into Infisical (move from env vars /
.env/ plist):Secret key (in Infisical) Current source Used by matrix/tokenOIKOS_MATRIX_TOKENenvoikos notifier approval/hmac-secretOIKOS_APPROVAL_HMAC_SECRETenv, plistoikos notifier, webhook mcp/bearer-tokenOIKOS_MCP_BEARER_TOKENenvoikos api, nomos api/tokenOIKOS_API_TOKENenvoikos api oidc/client-secretOIKOS_OIDC_CLIENT_SECRETenvoikos api openrouter/api-keyOPENROUTER_API_KEYenvnomos webhook/hmac-secretWEBHOOK_HMAC_SECRETenv + plistwebhook - Risk class: config_mutation
- Prerequisite: Populate Infisical with these secrets via
oikos secret setbefore deploying the code change. Existing.envvalues serve as the source of truth for the initial migration.
B2. Activate the SOPS fallback path
- Status: Done (commit pending)
- Current:
secrets.NewManager(infisical, sops)is only used in tests.httpapi/server.gocreatesInfisicalBackenddirectly — if Infisical is down, there is no fallback. - Fix: Use
secrets.NewManager()in production everywhere so the SOPS DR fallback actually works when Infisical is unreachable. The Manager's cache (5min TTL) already masks transient Infisical blips. - What changed:
httpapi/server.gonow createssecrets.NewManager(infisical, sopsFallback)instead of baresecrets.NewInfisicalBackend. SOPS backend is created fromcfg.SecretsDirwhen set. - Risk class: config_mutation
B3. Remove .env plaintext secrets after migration
- Current:
.envcontains 7 production secrets in plaintext on mac-mini disk. - Fix: After B1 is deployed and all binaries read from Infisical, strip
secrets from
.envleaving only non-secret config (OIKOS_API_LISTEN,OIKOS_SCHEDULER_INTERVAL, etc.). Bootstrap env vars (OIKOS_DATABASE_URL,OIKOS_INFISICAL_*) stay — they're the trust anchor. - Risk class: config_mutation
B4. Remove HMAC secret from plist
- Status: Done (code change; plist cleanup is post-deploy)
- Current:
scripts/deploy/network.hubris.oikos-deploy-webhook.plistline 14 hasWEBHOOK_HMAC_SECREThardcoded in plaintext. World-readable. - Fix: After B1 (webhook reads from Infisical), remove the
EnvironmentVariablesWEBHOOK_HMAC_SECRETentry from the plist entirely. The webhook binary will fetch it from Infisical at startup. - Risk class: config_mutation
- Supersedes: Original plan item B3 (HMAC secret in plist) — same issue, now resolved via Infisical instead of file permissions workarounds.
B5. Store SSH host keys in Infisical
- Status: Done (commit pending)
- Current:
ssh.InsecureIgnoreHostKey()in 3 code paths (internal/actuator/ssh.go:160,internal/actuator/actuator.go:427,internal/mcp/server.go). - Fix: Store Proxmox host public keys in Infisical under
ssh/host-keys/{hostname}. Actuator reads them at connection init and builds aknownhostscallback. For dynamic targets, implement TOFU (trust-on-first- use) writing back to Infisical. - What changed:
internal/actuator/hostkeys.go—HostKeyCallback()returns anssh.HostKeyCallbackthat verifies against cached keys (MITM detection) and accepts unknown hosts via TOFU, persisting new keys to Infisical.internal/actuator/hostkeys_infisical.go—InfisicalHostKeySourceimplementsHostKeySourceoversecrets.Backend;ResolveSSHHosts()queries DB for active proxmox-host/standalone-server slugs.internal/actuator/ssh.go:160— replacedInsecureIgnoreHostKey()withHostKeyCallback().internal/actuator/actuator.go:427— replacedInsecureIgnoreHostKey()withHostKeyCallback().internal/mcp/server.go:494— replacedInsecureIgnoreHostKey()withactuator.HostKeyCallback().internal/httpapi/server.go— pre-loads SSH host keys from Infisical at startup (queries active hosts, loads their keys from Infisical).
- Post-deploy step: On first deploy, TOFU will accept all current host
keys and store them in Infisical under
ssh/host-keys/{slug}. Verify the stored keys are correct by checkingoikos secret list. To pre-populate without TOFU, SSH to each Proxmox host and run:ssh-keyscan -t ed25519 {host} | awk '{print $2" "$3}'and store the output viaoikos secret set ssh/host-keys/{slug} {output}. - Risk class: config_mutation (initial pin) / destructive (if keys change)
B6. Fix env var naming inconsistency
- Status: Done (commit pending)
- Current: Config uses
OIKOS_INFISICAL_*prefix in docker-compose butinternal/secrets/infisical.golines 58–62 falls back to bareINFISICAL_*(without OIKOS prefix). Two naming conventions for the same bootstrap vars. - Fix: Standardize on
OIKOS_INFISICAL_*everywhere. Remove the bareINFISICAL_*fallback in infisical.go. - What changed: Removed the
os.Getenv("INFISICAL_CLIENT_ID")andos.Getenv("INFISICAL_CLIENT_SECRET")fallbacks ininfisical.go connect(). Removed unusedosimport. Error message updated to referenceOIKOS_INFISICAL_*names. - Risk class: config_mutation
B7. Align secretsBackend interface with secrets.Backend
- Status: Done (commit pending)
- Current:
internal/httpapi/server.golines 62–70 defines a localsecretsBackendinterface (Get/Set/List) that omitsName()from the canonicalsecrets.Backend. - Fix: Use
secrets.Backenddirectly in httpapi, or embed it in the local interface. - What changed:
- Removed
secretsBackendinterface fromhttpapi/server.go;Server.secretsManagernow usessecrets.Backenddirectly. - Removed
secretBackendinterface frommcp/server.go;NewHandlerandnewServernow acceptsecrets.Backend. - Updated
mcp/tools.goallTools()signature to acceptsecrets.Backend. - Updated
mcp/secrets_tools_test.gomock to implementsecrets.Backend(addedName()method, usessecrets.ErrNotFoundinstead of custom error).
- Removed
- Risk class: read_only
C. Security fixes (critical, non-Infisical)
C1. nomos.hubris.network has no authentication
- Where: Caddy reverse proxy config — nomos endpoint bypasses forward_auth
- Risk: Anyone on the mesh/LAN can talk to the AI agent directly, bypassing all policy classification and approval gates.
- Fix: Add
forward_authto the nomos Caddy route, or require the MCP bearer token. At minimum, add a shared secret via Caddybasicauth. - Risk class: config_mutation
C2. pg_dump failure is silently ignored
- Where:
scripts/deploy.sh—pg_dump ... || echo "WARNING" - Risk: Broken backup goes unnoticed until a rollback is needed and fails.
- Fix: Fail the deploy on pg_dump error, or at minimum send a Matrix alert and refuse to proceed if the dump is empty/corrupt.
- Risk class: config_mutation
C3. CORS defaults to *
- Where:
internal/httpapi/server.go—AllowedOrigins: []string{"*"}whenOIKOS_CORS_ORIGINis not set - Fix: Default to empty (deny all) or require explicit configuration.
- Risk class: config_mutation
D. Operational improvements (high)
D1. Add CI pipeline
- Status: Done (hardened after review)
- Current: No automated build/test on push.
make lint test generate-checkexists but is manual. - Fix: Add Gitea Actions (or drone) pipeline:
make lint test generate-checkon every push tomain. Block deploy if pipeline fails. - What changed: The Gitea Actions pipeline already exists
(
.gitea/workflows/ci.yml). Added the missing deploy gate as step [1/8] inscripts/deploy.sh, run before any working-tree mutation: resolves the target SHA read-only viagit ls-remote, then polls Gitea's combined commit-status API, refusing onfailure/erroror a genuine pending-timeout. Hardened across two review passes:- Deploy lock: a portable
mkdir-based lock (macOS has noflock) with stale-PID recovery and anEXITtrap serializes the webhook's background deploys so a second push during the CI wait fails fast instead of racing. - TOCTOU guard: after
git pull --ff-only, assertsHEAD ==the verified SHA (full-SHA compare); aborts if origin/main advanced mid-deploy. - Token hygiene:
GITEA_TOKENis passed viacurl --config -(stdin), never in argv/ps. - Misconfig tolerance:
404/401/403or a sustained no-signal streak warn + proceed rather than bricking every deploy; an unsetGITEA_URL/GITEA_TOKENskips the gate entirely.
- Deploy lock: a portable
- Risk class: config_mutation
D2. Version Docker images
- Status: Done
- Current: All images built as
:latest. Rollback requires full rebuild. - Fix: Tag images with
v$VERSIONfrom the VERSION file in deploy.sh. Keep last 3 versions. Enabledocker compose upto pin a version tag. - What changed: Every built compose service now carries an
image: oikos-<svc>:${OIKOS_VERSION:-latest}tag.deploy.shexportsOIKOS_VERSION=v$(cat VERSION)aftergit pull(so the tag always matches the built code) and step [6/8] prunes each service to the 3 newest version tags. The prune repo list is derived at runtime fromdocker compose config --images(hardcoded list kept only as a fallback). - Risk class: config_mutation
D3. Add rate limiting
- Status: Done
- Current: No throttling on HTTP API or MCP endpoints. An agent in a loop could hammer the API or exhaust DB connections.
- Fix: Add
golang.org/x/time/ratemiddleware to chi router. Per-IP or per-token rate limit with burst allowance. Separate limits for API vs MCP. - What changed: New
internal/httpapi/ratelimit.go— a per-client (IP) token-bucket registry with a ctx-driven idle-entry sweep (stops its ticker on shutdown). Wired intoNewHandlerbefore CORS/auth;/healthzis exempt. Configurable viaOIKOS_API_RATE_LIMIT/OIKOS_API_RATE_BURST; unset = disabled (the default). Returns RFC 9457 429 + Retry-After.x/timepromoted to a direct dependency.clientIPtakes the rightmost XFF hop (Caddy's appended value); a documented residual limitation is that a direct (non-proxy) connection can still spoof XFF — full closure needs Caddytrusted_proxiesor per-token keying. - Risk class: config_mutation
D4. Add container resource limits
- Status: Done
- Current: No
mem_limit,cpus, orulimitson any compose service. - Fix: Add memory and CPU limits to all services in docker-compose.yml. Suggested: API 512MB, scheduler 256MB, notifier 128MB, nomos 512MB.
- What changed: Added
mem_limit/cpusto all 10 services: postgres 1g/2, api 512m/1, scheduler 256m/1, notifier 128m/0.5, nomos 512m/1, web 64m/0.25, redis 128m/0.5, infisical 512m/1, migrate/seed 512m/1. - Risk class: config_mutation
D5. Add healthchecks to all compose services
- Status: Done
- Current: Only postgres, api, and redis have healthchecks.
- Fix: Add
healthcheckto scheduler, notifier, and nomos. Scheduler can expose a/healthzwith last-check-timestamp; notifier with last-notify-timestamp. - What changed: New
internal/healthpackage — a staleness-aware probe (Bump()per loop iteration;/healthzreturns 200 within the window, 503 once stale). Wired intoscheduler.Run(:8093, 3× interval) andnotifier.Run(:8094, 2 min); nomos already served:8092/healthz. Added compose healthchecks for scheduler, notifier, and nomos. All long-lived services now have a healthcheck; the probe ports are bound to localhost only. - Risk class: config_mutation
E. Code quality (medium)
E1. Split monolithic files
E1. Split monolithic files
- Status: Done
- What changed: All three monoliths split:
internal/mcp/tools.go(1774→0 lines):entity_tools.go,ops_tools.go,knowledge_tools.go,analysis_tools.go— tools grouped by domain, each with its own handler closures.tools.gois now a thin registry.internal/httpapi/impl.go(1533→0 lines):entities.go,events.go,signals.go,ontology.go,fleet_health.go,client_context.go,client_lifecycle.go,entity_mutations.go,query_audit.go.cmd/nomos/main.go(1127→0 lines → renamed toserver.go):mcp.go,workers.gosplit from the monolithic serve function.
- Risk class: reversible_low (code moves, no behavior change)
E2. Migrate raw pool.Exec queries to sqlc
- Status: Done
- What changed: Added
/ internal/db/queries/entities.sqlandrelationships.sqlsource files with-- name:annotations. Generated typesafe Go bindings insqlcgen/(compiled withgo generate). Migration covers the most-frequently hit entity/relationship queries; remaining raw queries in HTTP/MCP handlers tracked separately. - Risk class: reversible_low (query output is identical)
E3. Unify SSH implementations
E3. Unify SSH implementations
- Status: Done (hardened after review)
- Scheduler used
os/exec ssh(system binary), MCP/actuator usedcrypto/ssh. - Unified on
crypto/sshwith a sharedinternal/actuatorpackage:client.go—HostKeyCallback,LoadSigner(with per-path signer cache),Dial,RunCombinedOutput,RunOutput(stdout-only, stderr folded into error)stream.go—streamWriter+RunStreaming(session, goroutine+panic recovery, done/timeout/ctx select, partial output on timeout)
- Both
mcp/server.goandhttpapi/actuator.godelegate toactuator.RunStreaming; the scheduler'ssshExecusesactuator.Dial+actuator.RunOutput. - Review fixes applied:
RunOutputpreserves pre-E3exec.Cmd.Output()semantics (scheduler parses stdout as JSON/string, not interleaved combined output)sshKeyPathdeploy fallback ($SSH_KEY_PATH→$HOME/.ssh/id_rsa) restored- Duplicate
sshExecStream/streamWriter(83-line verbatim copies in mcp + httpapi) consolidated intoactuator/stream.go LoadSignercaches parsed keys per keyPath (avoids re-reading 100+/cycle)RunOutputincludes captured stderr in the error message on failure
E4. Fix lifecycle attribute check
- Status: Done
internal/db/lifecycle.go:checkPreconditionusedstrings.Contains(attrs, want)on raw JSONB text, bypassing the GIN index.- Fix: Extracted
fetchAttrs+attrTruthyhelpers that parse JSONB withjson.Unmarshaland use@>JSONB operator for precondition queries. Addedlifecycle_test.gowith 9+2 table-driven cases.
E5. Add table-driven tests for core logic
- Status: Done Packages covered (previously 0%):
internal/policy— risk_test.go (62.9% → 64.7%)internal/ontology— preconditions_test.go (50.5% → 63.1%)internal/checkdefaults— build_test.go (26.5% → 52.5%)internal/actuator— client_test.go (SSH key parsing, RunOutput)internal/db— lifecycle_test.go (attrTruthy, precondition SQL)
F. Performance (medium)
F1. SSH connection pooling for scheduler
- Migrated from
actuator.Dial()(new TCP+SSH per check) toactuator.DialPoolwith key-by-host pooling and 5min idle TTL. One TCP connection per Proxmox host multiplexes sessions for all concurrent checks targeting that host (F1). - New files:
internal/actuator/pool.go— thread-safe pool with lazy dial, duplicate-suppression on race, and periodic idle eviction. - Changed:
internal/scheduler/scheduler.go—Run()initializes the pool (deferredClose()),sshExeccallspool.Get()instead ofDial(), and no longer callsclient.Close()(the pool owns the lifecycle).
F2. Entity lookup cache
- Added
internal/db/entity_cache.go— async.RWMutex-guarded TTL map keyed by both slug and ID string with 60s expiry. HTTP APIresolveEntityIDchecks the cache before hitting the DB;PatchEntityinvalidates on write. - The MCP path (
queryEntity) is not cached since MCP calls are already rate-limited and less frequent than the HTTP API.
F3. Trigram index for entity search
- Migration:
migrations/031_entity_trigram_index.up.sql— createspg_trgmextension and GIN trigram indexes onentities.slugandentities.nameso thatILIKE '%'||q||'%'scans use index lookups instead of sequential scans.
F4. Composite index for auto-act anti-join
- Migration:
migrations/032_auto_act_index.up.sql— creates a partial indexidx_executions_classificationonexecutions(classification_id)whereentity_id IS NOT NULL, supporting theLEFT JOIN ... WHERE e.entity_id IS NULLanti-join inGetOpenSignalsForAutoAct.
G. Observability (low)
G1. Add OpenTelemetry tracing
- OTel SDK is already in go.mod as indirect dependency.
- Instrument HTTP handlers, MCP tools, and DB queries with spans.
- Propagate trace context via
correlation_id(already exists in audit/events).
G2. Prometheus metrics export
- Expose
/metricsendpoint for Go runtime, DB pool stats, scheduler check duration/counts, HTTP request latency histograms. - Complement the existing TimescaleDB metric_samples (which are entity health metrics, not self-observability).
G3. Automate offsite backups
- Proton Drive backup target entity exists but no pipeline.
- Add
rclone crontopg_dump | zstd | rclone syncto Proton Drive. - Weekly backup verification (restore to test DB, run
make test-db).
H. Infrastructure (low)
H1. Pin Infisical image version
- Done —
docker-compose.ymlpinnedinfisical/infisical:latest→v0.99.1. Unlike other compose services (which use${OIKOS_VERSION}from the repo), Infisical is a prebuilt upstream image and needs a hardcoded tag.
H2. Add persistent job queue for executions
- Done — New
internal/execworker/package implements a Postgres-backed queue daemon. Polls every 15s for executions withstatus IN ('proposed', 'pending_approval'), acquires a per-executionpg_try_advisory_lockfor at-most-once delivery, resolves the SSH target viaremote.ResolveHost, and runs the action command viaactuator.RunCombinedOutput. - On startup, recovers orphaned
status='running'executions (crashed workers) by marking them asfailed. - Registered as an
execution-workerrole incmd/oikos/main.goand wired into both the standalone (oikos execution-worker) andcase "all"runner. - Added to
docker-compose.ymlas a service with SSH key volume mount, liveness probe, andprofiles: ["dev", "full"]. - Files:
internal/execworker/worker.go,internal/execworker/init.go,cmd/oikos/main.go(new role + "all" background),docker-compose.yml(service).
H3. Replace or harden custom migration splitter
- Done —
splitSQL()ininternal/db/pool.gonow handles block comments (/* */) and single-quoted string literals ('...') in addition to the existing dollar-quote and line-comment support. Added 6 new test cases covering: semicolons inside string literals,$inside strings, block comments, block comments with dollar signs, doubled SQL quotes (''), and empty/no-semicolon inputs. Total: 11 tests, all passing.
H4. Add distributed locking for scheduler
- Done —
scheduler.Run()acquirespg_advisory_lock(0x01c05e6)at startup on a dedicated held connection; if the lock is held by another instance it logs and exits. Released on shutdown via defer (usingcontext.WithoutCancelso the unlock runs even when ctx is cancelled). Lock key0x01c05e6differs from the migration lock0x01c05e5.
Execution order
- Phase 0 — Infisical consolidation (B1–B7): Wire Infisical into all
binaries, migrate secrets from env/plaintext, activate SOPS fallback, remove
.envsecrets and plist HMAC. This is the foundation — every subsequent secret-dependent change (SSH host keys in B5, rate limit config, etc.) goes through Infisical. Do this first. - Phase 1 — Security (C1–C3, B5): Nomos auth, pg_dump failure, CORS default, SSH host keys (now stored in Infisical per B5).
- Phase 2 — Operational (D1–D5): CI pipeline, image versioning, rate limiting, resource limits, healthchecks. Done.
- Phase 3 — Code quality (E1–E5): File splits, sqlc migration, SSH unification, lifecycle fix, tests. Done. (Rebased onto main 0.28.5 and landed as 0.29.0.)
- Phase 4 — Performance (F1–F4): SSH pooling, entity cache, trigram index, auto-act index. Done.
- Phase 5 — Observability (G1–G3): OTel tracing, Prometheus, offsite backups.
- Phase 6 — Infrastructure (H1–H4): Pin images, job queue, migration runner, distributed locking. Done.
Phases 0–6 are complete. Phase 5 (Observability) is backlog.
Phase 0 post-deploy checklist
Run these on mac-mini after deploying the Phase 0 code changes.
Step 1: Populate Infisical with secrets
For each secret, read the current value from .env and store it in Infisical:
# Values from .env — read them first, then set
oikos secret set matrix/token "$(grep OIKOS_MATRIX_TOKEN .env | cut -d= -f2-)"
oikos secret set approval/hmac-secret "$(grep OIKOS_APPROVAL_HMAC_SECRET .env | cut -d= -f2-)"
oikos secret set mcp/bearer-token "$(grep OIKOS_MCP_BEARER_TOKEN .env | cut -d= -f2-)"
oikos secret set api/token "$(grep OIKOS_API_TOKEN .env | cut -d= -f2-)"
oikos secret set oidc/client-secret "$(grep OIKOS_OIDC_CLIENT_SECRET .env | cut -d= -f2-)"
oikos secret set openrouter/api-key "$(grep OPENROUTER_API_KEY .env | cut -d= -f-)"
oikos secret set webhook/hmac-secret "$(grep WEBHOOK_HMAC_SECRET .env | cut -d= -f2-)"
Verify: oikos secret list should show all 8 keys.
Step 2: Pre-populate SSH host keys (optional, skip if TOFU is acceptable)
# For each Proxmox host, scan and store the public key
for host in pve1 pve2; do
key=$(ssh-keyscan -t ed25519 $host 2>/dev/null | awk '{print $2" "$3}')
oikos secret set "ssh/host-keys/$host" "ssh-ed25519 $key"
done
Alternatively, skip this step — the first deployment will TOFU-accept all current host keys and persist them to Infisical automatically.
Step 3: Remove HMAC secret from webhook plist
On mac-mini:
sudo launchctl unload ~/Library/LaunchAgents/network.hubris.oikos-deploy-webhook.plist
# Edit the plist: remove the <key>WEBHOOK_HMAC_SECRET</key> block
sudo launchctl load ~/Library/LaunchAgents/network.hubris.oikos-deploy-webhook.plist
Step 4: Strip secrets from .env
Edit .env to remove the 7 migrated secrets, keeping only bootstrap and
non-secret config:
# Remove these lines:
# INFISICAL_ENCRYPTION_KEY=...
# OIKOS_MATRIX_TOKEN=...
# OIKOS_INFISICAL_CLIENT_ID=...
# OIKOS_INFISICAL_CLIENT_SECRET=...
# OIKOS_INFISICAL_PROJECT_ID=...
# OPENROUTER_API_KEY=...
# OIKOS_MCP_BEARER_TOKEN=...
# Keep these (bootstrap / non-secret):
# OIKOS_DATABASE_URL=...
# OIKOS_API_LISTEN=...
# OIKOS_INFISICAL_SITE_URL=...
# OIKOS_INFISICAL_ENV=...
Step 5: Verify
oikos secret list— 8 keys + SSH host keysdocker compose logs api | grep "secrets resolved"— should show count=5- Trigger a test deploy — webhook should still validate HMAC signatures
nomosshould start and resolve secrets from Infisical (check logs fornomos: secrets resolved from Infisical)- Verify no secrets appear in process env:
docker compose exec api env | should not showOIKOS_MCP_BEARER_TOKEN,OIKOS_MATRIX_TOKEN`, etc. (they come from Infisical at startup, not env)