v9.1 is the Onboarding Gate — the smallest set of operator-facing tools that turn a fresh clone into a working install in 15 minutes without reading the source. Six acceptance criteria: AC-89 (pcy init — .env bootstrap with 32-byte OsRng secrets, mode 0600, no secret bytes on stdout), AC-90 (pcy doctor — seven ordered self-diagnosis checks with strict-mode + JSON output; eighth sandbox-preflight check deferred to v9.2 as AC-90b), AC-91 (pcy backup / pcy restore — tarball with pg_dump --format=custom, manifest with schema-version forward-incompatibility refusal, optional --include-vault-key round-trip, 0o600 recovered-key extraction), AC-92 (docs/onboarding.md — single one-page first-run gate, ≤250 lines, every fenced pcy command tied to a real clap verb), AC-93 (pcy provider {add|list|use|remove} — first-class per-workspace LLM provider rows with credentialed key resolution; refuses raw --key at clap layer), AC-94 (honesty pass — README five-row "Security Model" table reflects shipped code; aspirational design-vocabulary names that were never code are removed from the live surface). Two new crates (tar, flate2), one new migration (llm_providers), three new event types (backup_taken, backup_restored, llm_provider_env_fallback). v9.1 ship gate CLEAR after REVIEW (round 3 PASS) + RECONCILE + VERIFY (PASS with declared MLP residual risks).
v9.0 is the security-and-correctness wave. It closes thirteen acceptance criteria identified by the v9 TLA+ + security audit: AC-53 (process sandbox — bubblewrap + seccomp + landlock + pincery-init), AC-76 (12-payload sandbox-escape suite live in CI), AC-77 (default-deny seccomp allowlist + sandbox_blocked SIGSYS event), AC-78 (per-agent SHA-256 event-log hash chain with BEFORE INSERT trigger, pcy audit verify, and startup verify gate that exits 5 on chain breakage), AC-79 (prompt-injection floor: untrusted-content delimiters, per-wake canary, JSON-Schema tool-call gate, per-wake tool-call rate limit), AC-80 (single-use TTL-bounded workspace-scoped capability nonces), AC-81 (TLA+ spec-coverage manifest + commit-msg hook), AC-82 (fine-grained ten-state wake lifecycle with CAS-only DB transitions and canonical-JSON lifecycle_transition events — the final v9.0 ship blocker), AC-83 (kernel-floor preflight at startup), AC-84 (kernel-floor library helper), AC-85/AC-86/AC-87 (sandbox hardening follow-ups), and AC-88 (devshell pin + parity tests). v8.0 agentic-harness CLI polish, v7 credential vault, v5 operator onramp, v4 self-host hardening, and the v1–v3 feature set carry forward unchanged. v9.0 ship gate CLEAR as of merge of PR #4 to main (2026-05-08).
A multi-agent platform runtime implementing the Open Pincery architecture: event-sourced agents with CAS lifecycle management, LLM-powered wake/sleep cycles, maintenance projections, HTTP API, graceful shutdown, Docker Compose deployment, API rate limiting, webhook ingress, agent management, structured JSON logging, Prometheus metrics, health/readiness split, CI pipeline, signed release artifacts with SBOMs, and operator runbooks. v4 adds self-host hardening: non-root container user, runtime budget-cap enforcement with transactional cost accounting, authenticated webhook-secret rotation, a pcy CLI binary, a vanilla-JS control plane UI, and a published v4 API stability contract. v7 adds an AES-256-GCM credential vault with reasoner-cooperative PLACEHOLDER dispatch. v8.0 lands the agentic-harness CLI polish: auto-generated OpenAPI, named connection contexts with pcy whoami, idempotent pcy login/bootstrap, JSON-by-default piped output (--output), shell completions (pcy completion), and a clap-tree naming lint that forced every subcommand to carry a real about description. v9.0 lands the security-and-correctness wave described in the v9.0 Summary above. Single-binary Rust server backed by PostgreSQL.
cp .env.example .env— configure (setLLM_API_KEY,OPEN_PINCERY_BOOTSTRAP_TOKEN)docker compose up -d— starts both the app and PostgreSQLPOST /api/bootstrapwith bearer token → get session tokenPOST /api/agents→ create agents (response includeswebhook_secret)POST /api/agents/:id/messages→ send messages (triggers wake cycle)
docker compose up -d db— start PostgreSQL onlycp .env.example .env— configurecargo build --release && source .env && ./target/release/open-pincery
- Agent lifecycle: Agents transition
asleep → awake → maintenance → asleepvia atomic CAS operations - Wake loop: On message, agent wakes, calls LLM iteratively with tools (shell, plan, sleep), records all events
- Maintenance: After each wake, LLM updates agent identity, work list, and summary
- Drain check: If new messages arrive during wake, agent re-wakes instead of sleeping
- Stale recovery: Background job detects agents stuck awake and force-releases them
- Event log: Append-only, ordered, complete history of every agent action
- Projections: Versioned, immutable snapshots of agent state after each wake
- Graceful shutdown: SIGTERM/Ctrl-C stops accepting connections, waits up to 30s for in-flight wakes and background tasks to complete
- Rate limiting: Per-IP rate limits — 10 req/min unauthenticated, 60 req/min authenticated. Returns 429 with
Retry-Afterheader - Webhook ingress: External systems post events via HMAC-SHA256-signed webhooks with idempotency deduplication
- Agent management: PATCH to rename or enable/disable agents; DELETE to soft-delete (sets
is_enabled=false, disabled_reason='deleted'; disabled agents cannot wake) - Docker deployment: Multi-stage Dockerfile with health check, docker-compose.yml with app + postgres
- Linux kernel with Landlock ABI >= 6 (Linux >= 6.7)
- seccomp-bpf enabled (
CONFIG_SECCOMP_FILTER) - cgroup v2 mounted (
/sys/fs/cgroup/cgroup.controllerspresent) - bubblewrap (
bwrap) >= 0.8.0 on$PATH
At startup the server performs a fail-closed preflight for these requirements. If unmet, startup aborts with exit code 4 and emits sandbox_kernel_floor_unmet. Operators can opt into reduced compatibility with OPEN_PINCERY_SANDBOX_FLOOR=relaxed only when paired with OPEN_PINCERY_ALLOW_UNSAFE=true; this emits sandbox_floor_relaxed at startup.
- AC-11: Graceful shutdown via CancellationToken +
with_graceful_shutdown - AC-12: Docker Compose one-command start (
docker compose up -d) - AC-13: Per-IP rate limiting using
governorcrate - AC-14: Webhook ingress with HMAC-SHA256 verification and idempotency dedup
- AC-15: PATCH/DELETE agent management endpoints
- AC-16: CI workflow (
.github/workflows/ci.yml) running fmt + clippy + tests (against Postgres 16 service container) +cargo deny checkon every push/PR.deny.tomlenforces license allow-list and denies unknown registries/git sources. - AC-17: Structured JSON logging. Set
LOG_FORMAT=jsonto emit one JSON object per line (timestamp,level,target,fields.message+ span context) for log pipelines; unset for human-readable output. - AC-18: Prometheus
/metricsendpoint (opt-in viaMETRICS_ADDR, served on its own port). Eight counters (open_pincery_wake_started_total,open_pincery_wake_completed_total{reason},open_pincery_llm_call_total,open_pincery_llm_prompt_tokens_total,open_pincery_llm_completion_tokens_total,open_pincery_tool_call_total,open_pincery_webhook_received_total,open_pincery_rate_limit_rejected_total) +open_pincery_active_wakesgauge +open_pincery_wake_duration_secondshistogram. - AC-19: Split
/health(liveness — always 200 while the process serves HTTP) from/ready(readiness — 200 only when DB reachable AND all migrations applied AND both background tasks alive). 503 responses name the failing subsystem in afailingfield. - AC-20: Tag-triggered signed release workflow (
.github/workflows/release.yml). Buildsx86_64-unknown-linux-gnuandaarch64-unknown-linux-gnubinaries with LTO + strip + codegen-units=1 ([profile.release]inCargo.toml), generates CycloneDX SBOM, signs binary + SBOM with cosign keyless (GitHub OIDC), publishes viasoftprops/action-gh-release. Prerelease auto-detected from-rc/-beta/-alphatag suffixes. - AC-21: Five operator runbooks under
docs/runbooks/— stale wake triage, DB restore, migration rollback, rate-limit tuning, webhook debugging. Each includes Symptom / Diagnostic Commands / Remediation / Escalation sections with concrete copy-paste commands.
- AC-22: Dockerfile runtime stage now creates a dedicated
pcysystem user (UID 10001) and drops to it viaUSER pcy; all runtimeCOPYdirectives use--chown=pcy:pcy. Verified by a static guard test (tests/dockerfile_nonroot_test.rs). - AC-23: Runtime LLM budget cap enforced before CAS wake acquire.
background::listenerchecksagents.budget_used_usdagainstagents.budget_limit_usdand appends abudget_exceededevent instead of waking when the cap is reached. Cost accounting is now real end-to-end:LlmClientcarries aPricingstruct for primary and maintenance calls,wake_loop/maintenancecomputecost_usd = llm.estimate_cost(usage, is_maintenance), andinsert_llm_callincrementsagents.budget_used_usdin the same transaction as thellm_callsinsert. Configured viaLLM_PRICE_INPUT_PER_MTOK/LLM_PRICE_OUTPUT_PER_MTOK/LLM_MAINTENANCE_PRICE_INPUT_PER_MTOK/LLM_MAINTENANCE_PRICE_OUTPUT_PER_MTOK(defaults 3.0 / 15.0 / 3.0 / 15.0 USD per million tokens). - AC-24: Authenticated webhook-secret rotation endpoint
POST /api/agents/:id/rotate-webhook-secret. Workspace-scoped viascoped_agent, returns the new secret exactly once, appends awebhook_secret_rotatedevent (no secret material in payload), and rotates the agent row atomically in the same transaction. - AC-25:
pcyCLI binary ([[bin]] pcyinCargo.toml) with subcommandslogin,agent(create/list/show/disable/rotate-secret),message,events,budget(show/set/reset), andstatus. Thin shim atsrc/bin/pcy.rs; shared HTTP client atsrc/api_client.rs. - AC-26: Vanilla-JS ES-module control plane UI served at
/fromstatic/. Split acrossstatic/js/{app,api,state,ui}.jsplusstatic/js/views/{login,agents,detail,settings}.js; no bundler, no CDN, no single file exceeds 132 lines. Covers login, agent list, agent detail with long-poll event stream, and settings including secret rotation. - AC-27:
docs/api.mdpublishes the v4 HTTP surface as the stable contract, documents the three auth models (bootstrap token, session token, webhook HMAC), the common error shape, every endpoint with request/response examples, and the client coverage matrix against thepcyCLI and the static UI.
- AC-28:
docker-compose.ymlenv block rewritten — every runtime-read env var forwarded via${VAR:-default}interpolation; required secrets (OPEN_PINCERY_BOOTSTRAP_TOKEN,LLM_API_BASE_URL,LLM_API_KEY) use:?fail-fast guards. No hardcoded tokens or credentials remain. - AC-29:
.env.examplerefreshed to cover everyenv::varcall in the source. Grouped by function (server, LLM, auth, budget, stale recovery, observability), commented with purpose and defaults. OpenRouter default + commented OpenAI alternative. - AC-30: End-to-end smoke scripts (
scripts/smoke.sh+scripts/smoke.ps1) exercisedocker compose up --wait→ health poll →pcy login --bootstrap-token→ agent create → message → event query → assertmessage_received. Both usecurl.exeexplicitly to avoid PowerShell alias issues. - AC-31:
README.mdQuick Start rewritten — Web UI path,pcyCLI path, curl/HTTP appendix, signed binary install, troubleshooting (7 anchors), reset, going public with HTTPS, observability. API table includes canonicalPOST /api/agents/:id/webhook/rotatewith compat note for legacyrotate-webhook-secretspelling. - AC-32: Secure-by-default compose — host ports bound to
127.0.0.1,.env.exampledefaultsOPEN_PINCERY_HOST=0.0.0.0so the app is reachable inside the Docker network (loopback restricted by port mapping on host side). - AC-33: Caddy TLS overlay (
docker-compose.caddy.yml+Caddyfile.example) for HTTPS exposure.docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -dadds Caddy fronting the app on ports 80/443.
- AC-34:
AgentStatusenum (Resting,WakeAcquiring,Awake,WakeEnding,Maintenance) insrc/models/agent.rswith compile-time-aligned TLA+ state names. All SQL CAS status literals route throughAgentStatus::DB_*consts +as_db_str/from_db_str; a static guard test (tests/no_raw_status_literals.rs) prevents relapse. Migration20260420000001_agent_status_states.sqladditively widens theagents.statusCHECK constraint. - AC-35: Tool capability gate.
src/runtime/capability.rsdefinesToolCapability(5 variants) andPermissionMode(Yolo,Supervised,Locked, fail-closed on unknown).dispatch_toolconsultsmode_allowsbefore any executor side effect; denials emit atool_capability_deniedevent (source=runtime, payload{required_capability, permission_mode}) and never spawn a child. 9 tests including a DB-backed integration test proving a Locked agent's shell call is denied, audited, and the probe file is never created. - AC-36: Hardened
ProcessExecutorbehind aToolExecutortrait (src/runtime/sandbox.rs). Every shell invocation now runs under: (1) pre-spawn rejection of any command containing asudotoken (tokenised on shell word-boundaries — catchesecho ok && sudo …,(sudo -i), etc.); (2) fresh per-call tempdir as cwd; (3)Command::new("sh").env_clear()with aPATH-only allowlist re-added; (4)kill_on_drop(true); (5) 30s wall-clock timeout viatokio::time::timeout. Exactly oneCommand::new(exists undersrc/runtime/— enforced bytests/no_raw_command_new.rs. 6 sandbox tests (env scrub, timeout-does-not-hang, three sudo-reject variants incl. chained, Ok path). - AC-37: Zero-advisory-or-allowlisted-exception floor.
deny.toml[advisories]uses cargo-deny v2 (implicit vulnerability deny) withyanked = "deny". Theignorelist contains exactly one dated, documented entry —RUSTSEC-2023-0071(transitiversavia unusedsqlx-mysql, no upstream fix, not reachable in our runtime).tests/deny_config_test.rspinsALLOWED_ADVISORIES = ["RUSTSEC-2023-0071"]and requires a non-emptyreasonon every entry, so adding a new exception requires a deliberate co-edit of bothdeny.tomland the test.
- New permission mode field: agents carry
permission_mode(defaultyolofor v5 compatibility). Set tolockedto fully disable tool execution;supervisedis reserved for future approval flows (currently behaves likelockedfor destructive tools). - No behaviour change for existing agents: v6 is additive. v5 agents continue to wake, call tools, and complete cycles. The security baseline kicks in only when
permission_modeis tightened. - Audit trail: every gate denial is persisted as a
tool_capability_deniedevent alongsidetool_result/tool_error, so denials are queryable via the existing event API.
- AC-38: AES-256-GCM credential vault (
src/runtime/vault.rs). Master key loaded fromOPEN_PINCERY_VAULT_KEY(base64-encoded 32 bytes); startup fails fast if missing, wrong length, or invalid base64.Vault::seal(workspace_id, name, plaintext) → SealedCredential {nonce, ciphertext}binds the credential to its{workspace_id}:{name}AAD;Vault::open(workspace_id, name, sealed)collapses all failure modes (wrong key, wrong workspace, wrong name, tampered ciphertext, wrong nonce length) to a singleVaultError::Authenticationvariant — no oracle. Random 96-bit nonce per seal viaOsRng.credential::Credentialis deliberately NOTSerialize; onlyCredentialSummary(id, name, created_at, revoked_at) ever leaves the process. - AC-39: REST credential API under
/api/workspaces/:id/credentials. POST accepts{name, value}(name^[A-Z][A-Z0-9_]{0,63}$, value ≤ 32 KiB), seals it, and returns the summary only (never the value). GET lists active (non-revoked) summaries. DELETE/api/workspaces/:id/credentials/:namemarks revoked. All three routes are workspace-admin gated viascoped_workspace. Unique partial index (name) WHERErevoked_at IS NULLenforces one-active-per-name at the DB layer. - AC-40:
pcy credential add|list|revokeCLI.addusesrpasswordso the secret is never echoed, never in argv, never in shell history; auto-resolvesworkspace_idviaGET /api/me(cached inCliConfig.workspace_idafter first use).listprints name + created_at + age in a table.revoketakes--name, confirms, and calls DELETE. - AC-41:
list_credentialsreasoner tool registered withToolCapability::ReadLocal. Returns{credentials:[{name, created_at}, ...]}— names only, never values. The gate in AC-35 still applies; a Locked agent cannot list.workspace_id: Uuidis now a required param ondispatch_toolso every tool call is workspace-scoped. - AC-42: Hardened wake system prompt v2. Migration
20260420000003_prompt_template_credentials.sqldeactivates v1 and inserts v2 with five mandatory substrings (REFUSE contract, "never reveal", "never echo", thePLACEHOLDER:<name>syntax, and thelist_credentialstool reference).tests/prompt_v2_credential_test.rspins all five substrings so a silent prompt rewrite fails the build. - AC-43: PLACEHOLDER dispatch handshake.
ShellArgs.env: HashMap<String,String>lets the reasoner passPLACEHOLDER:<name>values;dispatch_toolresolves them pre-spawn viacredential::find_active+vault.openinto a privateHashMap, which becomesShellCommand.envand is injected AFTER thePATH-only allowlist. On any failure (missing, revoked, invalid nonce, authentication, non-UTF-8, lookup error) the call fails closed — no executor spawn — and acredential_unresolvedevent is written with payload{name, reason}only. Plaintext never appears in any event, log line, or tool output (leak-canary test scans every event row for the agent after a successful resolve).
- New required env var:
OPEN_PINCERY_VAULT_KEY— 32 random bytes, base64-encoded. Generate once withopenssl rand -base64 32; store alongsideOPEN_PINCERY_BOOTSTRAP_TOKEN; losing it means losing access to every stored credential. Rotation requires re-sealing — deferred to v8. - New CLI verbs:
pcy credential add|list|revoke. Theaddpath prompts for the value via rpassword and never touches argv/history. - New tool available to agents:
list_credentials(names only). The reasoner is prompted to usePLACEHOLDER:<name>inenvon any shell call instead of ever pasting a secret value.
- Zero runtime substitution outside dispatch: There is no network-level redaction or proxy. If an agent names a credential and also echoes the raw value in its own text, the harness cannot prevent that — the v2 prompt makes this refusal contract explicit. Cryptographic isolation of secrets from the reasoner (Zerobox-style) is the v8/v9 step.
- Additive migrations: Three new migration files; no v6 row is mutated.
v8 was originally scoped as a 9-AC unified-surface rework (OpenAPI generator, noun-verb tree with legacy shims, MCP stdio server, signed installer, two-file schema lints). Mid-stream review narrowed the v8.0 ship to the slice that unblocks downstream automation; the remaining work is tracked as v8.1.
- AC-44: Auto-generated OpenAPI 3.1 document at
/openapi.jsonfrom utoipa annotations covering agents, credentials, contexts, workspaces, events, bootstrap/login/me, webhook ingress, and health. - AC-45:
pcy loginis idempotent and is the sole auth verb — matches thegh auth login/oc login/terraform loginergonomic.login --bootstrap-token <token>attemptsPOST /api/bootstrapfirst; on HTTP 409 (already bootstrapped) it silently falls back toPOST /api/loginusing the same token. Output JSON carriesalready_bootstrapped: boolso CI jobs can distinguish first-run from re-run. The standalonepcy bootstrapsubcommand no longer exists; callers that need the raw endpoint usecurl POST /api/bootstrapdirectly. - AC-47: Global
--outputand--no-colorflags land on the rootCliand are propagated to every data-printing leaf.--outputacceptstable|json|yaml|name|jsonpath=<expr>; default istableon a TTY,jsonwhen stdout is piped.--no-coloris an alias forNO_COLOR=1.pcy credential listis the first v7-era noun migrated ontooutput::render(uniform with the v8contextnoun). - AC-48: Named connection contexts on disk (
~/.config/open-pincery/config.toml).pcy context list|current|use|set|deletemanages them; legacy flat fields are kept as a mirror of the active context for backward compatibility with every v1–v7 call-site. A one-shot.pre-v8backup migrates v4-shaped configs on first load. Newpcy whoamiprints{context, url, user_id?, workspace_id?}as one JSON line for scripts. - AC-51:
pcy completion <bash|zsh|fish|powershell>emits a completion script viaclap_complete::generate. Follows theaws/kubectl/ghconvention. - AC-52b:
tests/cli_naming_test.rswalks the clap command tree and enforces the project-wide conventions: every subcommand hasabout,--formatis banned (only--output),--yesis allowlisted tocredential revoke,--outputand--no-colorare declared global. Shipping this lint surfaced naked subcommands (login, agent/, message, events, budget/, status) that previously showed blank descriptions in--help; all now carry one-line guidance.
pcy loginis safe to run twice. Runbooks and CI jobs can re-invoke bootstrap without branching on error strings.pcy whoamireplaces hand-rolledcurl /api/me | jqprobes.- JSON by default when piped.
pcy credential list | jqworks with no flags. TTY users still see a formatted table. - Shell completion is one command away.
pcy completion bash | sudo tee /etc/bash_completion.d/pcyetc. --formatis gone. If any v7 script or doc used it, switch to--output. No legacy shim — the lint refuses to compile with--formatanywhere.
- AC-46: Full noun-verb tree (
pcy credential/pcy agent/pcy budget/pcy event) with byte-identical legacy-shim delegates. - AC-49: MCP stdio server (
pcy mcp serve) exposing the OpenAPI surface as Model Context Protocol tools. - AC-50: Signed installer script (
curl ... | sh) with cosign verification andpcy --versionself-check. - AC-52a: OpenAPI schema-layer naming lint (
tests/api_naming_test.rs) — plural collections,{id}params, summary length.
This wave adds the seven P0 acceptance criteria identified by the v9 TLA+ + security audit (AC-76..AC-82). v9.0 ships when AC-76 + AC-77 + AC-78 + AC-79 + AC-80 + AC-81 + AC-82 are all closed. The list below tracks the slices already on main/PR #4.
-
AC-76: 12-payload sandbox-escape suite (filesystem 4 + privesc 3 + resource 3 + net 3) running live on every CI run via the privileged
sandbox real-bwrap smokejob. Memory-cap probe ships an explicit Enforced/NotEnforced/Skipped tri-state with kernel evidence;enforce_memory_cap_at_startuprefuses boot (exit 4) when the running kernel does not enforcememory.max, unlessOPEN_PINCERY_ALLOW_UNSAFE=truearms the relaxed path. AC-76 closed at 9db7525 + 75a7760. -
AC-77: Default-deny seccomp allowlist replacing the pre-v9 denylist. Captured-corpus + escape-primitive negative control + size floor/ceiling +
SYS_clonenamespace-lockout arg filter +sandbox_syscall_deniedevent on SIGSYS (exit 159) + integration tests. Closed at a546c8d after iterative kernel-audit-driven syscall capture (final allowlist = 75 syscalls). -
AC-78 (this commit): Per-agent SHA-256 event-log hash chain with tamper detection.
- Migration
20260501000001_add_event_hash_chain.sqladdsprev_hash/entry_hashcolumns and aBEFORE INSERTPL/pgSQL trigger that, underpg_advisory_xact_lockplusSELECT ... FOR UPDATE, computesentry_hash = sha256(prev || canonical_payload || created_at). Pre-image is length-prefixed (u32 BE len + UTF-8 bytesper text field, thenint4be(8) || int8be(micros)) so adjacent fields cannot be ambiguously concatenated. Trigger also strict-monotonic-bumpscreated_atwhen a microsecond tie would let a subsequent walker disagree with the trigger about which sibling was prior. One-transaction migration: ADD → backfill → SET NOT NULL → CREATE TRIGGER. - Verifier (
src/background/audit_chain.rs):verify_audit_chain(pool, agent_id) -> ChainStatus::{Verified, Broken{first_divergent_event_id, events_walked}}walks every event for an agent in(created_at, id)order, recomputes the canonical pre-image in Rust byte-for-byte, and returns the first divergence.verify_workspaceruns the walker for every agent under a workspace;verify_and_emitwrites oneaudit_chain_verifiedoraudit_chain_brokenevent per agent (sourceruntime, payload includesfirst_divergent_event_idon break). Verifier never mutates the existing rows. - CLI:
pcy audit verify [--agent <uuid>] [--workspace <id>]returns exit code 2 (EXIT_CODE_CHAIN_BROKEN) when any agent's chain is broken, exit 0 when all clean. Pretty stderr summary + raw JSON on stdout. - HTTP:
POST /api/audit/chain/verifyandPOST /api/audit/chain/verify/agents/{id}— both workspace-admin gated viacredential::is_workspace_admin; admin gate runs before agent lookup so unauthorized callers get 403 (not 404 leak). Per-agent route usesscoped_agentfor cross-workspace isolation. - Startup gate:
enforce_audit_chain_floor_at_startupruns after migrations and before listener bind; iterates every workspace, walks every agent, and on broken chain callsstd::process::exit(5)(EXIT_CODE_AUDIT_CHAIN_BROKEN). Override armed only by bothOPEN_PINCERY_AUDIT_CHAIN_FLOOR=relaxedandOPEN_PINCERY_ALLOW_UNSAFE=true; under override, boot proceeds and oneaudit_chain_floor_relaxedevent is emitted per broken agent so the audit log retains evidence. - Operator runbook:
docs/runbooks/audit_chain_recovery.md— three labeled recovery paths (A: restore from backup, B: forensic preservation viapg_dump --table=events+ quarantine restart, C: time-boxed override). - Tests: 13 in
tests/audit_chain_test.rs(genesis/per-agent isolation/NOT-NULL/concurrent inserts/manual-update detection/verifier emits/verifier no-mutate/startup gate Err(5)+override Ok), 4 intests/audit_api_test.rs(200 happy / 200 broken-after-tamper / 404 cross-workspace / 403 non-admin on both routes), 2 intests/cli_audit_verify_test.rs(pcy audit verifyexit 0 clean / exit 2 tampered). - All 6 BUILD slice CI runs (G3a..G3e + REVIEW-fix) green: 25239486359, 25241087887, 25241550477, 25241912717, 25242016522, 25242261543.
- Migration
-
AC-79 (this commit): Prompt-Injection Defense Floor — untrusted-content delimiter wrapping + per-wake canary discipline + JSON-Schema tool-call gate + per-wake tool-call rate limit. Closed at
6862d58after REVIEW-fix-1 + VERIFY-fix-1.- Untrusted-content delimiters (T-AC79-1, T-AC79-2): every event class that arrives from outside the system prompt —
message_received,tool_result,memory_read,wake_summary_loaded— is wrapped in<<untrusted:NONCE>>...<<end:NONCE>>before being inlined into the LLM prompt. NONCE is a fresh 32-hex token fromOsRngregenerated every wake (mint_wake_prompt_contextinsrc/runtime/wake_loop.rs). Theis_untrustedpredicate insrc/runtime/prompt.rsis a closed exhaustive match — adding a new untrusted event class is a one-line change with a unit test that fails until the predicate is updated. - Per-wake canary (T-AC79-7, T-AC79-8):
mint_wake_prompt_contextalso draws a fresh 16-byte (32-hex) canary fromOsRng. The canary is appended to the system prompt after themax_prompt_charstruncation (so a tight budget cannot drop the line).scan_for_canaryruns before anyassistant_messageortool_callevent is appended, scanschoices[*].message.contentand every tool call'sfunction.name/function.arguments/id, and on hit emitsprompt_injection_suspectedwith payload{"where_found": "<audit-tag>", "model_attempted_tool_calls": N}(where_found is a tag likechoice[0].tool_calls[1].function.arguments— never the surrounding bytes). Wake terminates withtermination_reason = "prompt_injection_suspected". The canary value is never persisted in any event column. - JSON-Schema tool-call gate (T-AC79-4..6): every tool call from the LLM is validated against the schema declared in
tool_definitions()before anytool_callevent is appended and beforedispatch_toolis invoked. Validators are compiled once at startup into aOnceLock<HashMap<String, jsonschema::Validator>>cache (jsonschema 0.28, MIT). On schema mismatch the runtime emitsmodel_response_schema_invalidwith payload{"tool_name":"...","schema_errors":["..."],"attempt":N,"retry_cap":N}and re-prompts the LLM. AfterConfig::schema_invalid_retry_capconsecutive failures (default 3, envOPEN_PINCERY_SCHEMA_INVALID_RETRY_CAP, 0 rejected), the wake terminates withFailureAuditPending. Schema-invalid retries do not bumpiteration_cap. - Per-wake tool-call rate limit (T-AC79-10):
Config::tool_call_rate_limit_per_wake(default 32, envOPEN_PINCERY_TOOL_CALL_RATE_LIMIT_PER_WAKE, 0 rejected) enforces an upper bound on real tool dispatches per wake, independent ofiteration_cap. On exceedance the runtime emitstool_call_rate_limit_exceededwith payload{"limit":N,"attempted":M}and terminates withFailureAuditPending. - System prompt v3 (T-AC79-3): migration
20260501000002_add_prompt_injection_floor.sqldeactivates v2 and inserts v3 as the activewake_system_prompt. v3 is a strict superset of v2 (preserves every AC-42 substring:pcy credential add,REFUSE,POST /api/workspaces/,PLACEHOLDER:,list_credentials) and adds a## CRITICAL: Untrusted Content Boundariessection instructing the model to treat anything inside<<untrusted:NONCE>>...<<end:NONCE>>as data, and never to echo the canary token. - Audit chain transparency (T-AC79-9, T-AC79-11): all four new event types (
prompt_injection_canary_emitted,prompt_injection_suspected,model_response_schema_invalid,tool_call_rate_limit_exceeded) register withsource = "runtime"and chain through the AC-78 SHA-256 audit trigger transparently.event::append_eventsignature is unchanged — no caller-side changes needed. - Tests: 6 in
tests/prompt_injection_test.rsdriven end-to-end against postgres:16 + wiremock —injected_webhook_payload_is_wrapped_in_untrusted_delimiters_no_smuggled_dispatch(delimiter wrapping + zero non-sleepdispatch onIGNORE PREVIOUS INSTRUCTIONSpayload),forged_canary_echo_in_response_content_terminates_wake_with_prompt_injection_suspected(customRespondreflector echoes the per-wake canary; wake terminates before anyassistant_messageortool_calllands),malformed_tool_call_args_emit_schema_invalid_event_then_recover(sequential responder returns malformed JSON args then valid sleep; exactly 1 schema-invalid event with structured payload, recovers cleanly),tool_call_rate_limit_exceeded_terminates_wake_with_failure_audit_pending(limit=2, attempted=3, exactly 2 dispatches + 1 rate-limit event),canary_emitted_event_lands_once_per_wake_without_canary_value(canary never persisted),wake_system_prompt_v3_is_active_and_contains_required_substrings(L-AC79-2 runtime proof — replays the migration viainclude_str!and asserts every required substring). Plus 7 unit tests insrc/runtime/{wake_loop,tools,prompt}.rs. - All evidence:
cargo build --testsclean,cargo clippy --all-targets -- -D warningsclean,cargo deny check licenses bans sourcesgreen (jsonschema 0.28 MIT),cargo test --tests --no-fail-fast100% green (104 lib runtime tests + ~70 integration suites including the new prompt_injection_test).
- Untrusted-content delimiters (T-AC79-1, T-AC79-2): every event class that arrives from outside the system prompt —
-
AC-80 (this commit): Capability nonce / freshness — closes the AC-35 replay window. Every
IssueToolCallmust present a one-shot nonce minted at the matchingAuthorizeExecutionboundary. Closed onv6-01_implementationat G5d.- Schema (T-AC80-8):
migrations/20260501000003_create_capability_nonces.sqladds thecapability_noncestable with columns(id uuid PK, wake_id uuid NN, tool_name text NN, capability_shape text NN, nonce bytea NN, expires_at timestamptz NN, consumed_at timestamptz, workspace_id uuid NN, created_at timestamptz NN DEFAULT now()), aUNIQUE (workspace_id, nonce)index that serializes concurrent consumes, and a(expires_at)index for the deferred sweeper. Theevent::append_eventsignature is unchanged; the newcapability_nonce_rejectedevent chains through the AC-78 audit hash trigger transparently (T-AC80-7). - Module (T-AC80-1, T-AC80-3):
src/runtime/capability_nonce.rsexposesmint(pool, wake_id, workspace_id, tool_name, args_json) -> Result<CapabilityNonceTicket, sqlx::Error>andconsume(pool, &nonce, wake_id, workspace_id, tool_name, capability_shape) -> Result<(), RejectionReason>. Random bytes come fromOsRng::try_fill_bytes(rand 0.9TryRngCore). The publiccapability_shapehelper is SHA-256 of canonical JSON (recursive sorted keys, no whitespace, hex-lower).RejectionReason::{Replay, CrossWake, Expired, ShapeMismatch, Unknown}with stableas_str()literals matches thecapability_nonce_rejectedevent payloadreasonfield. Constants:CAPABILITY_NONCE_LEN = 16,CAPABILITY_NONCE_TTL_SECS = 60. - Mint at AuthorizeExecution (G5b):
src/runtime/wake_loop.rs::run_wake_loopmints a fresh ticket per claimed tool call AFTER the AC-79 schema validation gate and the per-wake rate-limit gate, BEFOREtools::dispatch_tool. Each ticket is bound to(wake_id, workspace_id, tool_name, capability_shape)and expires 60s after mint. - Consume at IssueToolCall (G5c):
dispatch_toolgains a 9th parameternonce: &CapabilityNonceTicket. The atomicUPDATE capability_nonces SET consumed_at = now() WHERE nonce = $1 AND wake_id = $2 AND tool_name = $3 AND capability_shape = $4 AND workspace_id = $5 AND consumed_at IS NULL AND expires_at > now() RETURNING idruns AFTER the AC-35 capability gate (so an AC-35-denied call MUST NOT consume a nonce; T-AC80-11) and BEFORE the per-tool match arms. On any rejection (Replay,CrossWake,Expired,ShapeMismatch,Unknown) the runtime emits exactly onecapability_nonce_rejectedevent (source = "runtime", content payload{wake_id, tool_name, reason}) and short-circuits dispatch withToolResult::Error("capability nonce rejected")— no per-tool side effect ever runs. - Tests: 7 unit tests in
src/runtime/capability_nonce.rs(canonical-JSON determinism, key-order invariance, value/nested-distinction, non-JSON fallback,RejectionReasonpayload literals, TTL constant), 9 adversarial integration tests intests/capability_nonce_test.rsagainst postgres:16:valid_nonce_consumes_once_then_replay_is_rejected,cross_wake_reuse_is_rejected,cross_workspace_reuse_is_rejected,expired_nonce_is_rejected(UPDATE-backdatesexpires_at),shape_mismatch_is_rejected,unknown_nonce_is_rejected,dispatch_tool_emits_capability_nonce_rejected_on_replay(asserts exactly one TRUSTED runtime event withreason="replay"),ac35_denied_call_does_not_consume_nonce, plus a public-surface guard. Six existingdispatch_toolcall sites intests/{capability_gate,landlock_audit,list_credentials_tool,placeholder_dispatch,sigsys_event}_test.rsmint a real ticket inline.cargo build --libandcargo build --testsboth clean.
- Schema (T-AC80-8):
-
AC-81 (this commit): Binding commitments — every code change to
src/runtime/**orsrc/api/**now MUST cite the canonical TLA+ action(s) it implements. Closed onv6-01_implementationat G6e.- Spec coverage table (G6a, T-AC81-1): new
scaffolding/spec_coverage.md— three columns (AC-*| canonical action(s) | invariant) with one row per AC in{AC-53..AC-88}. Every non-—action token appears verbatim in the body ofNext ==indocs/input/OpenPinceryCanonical.tla(line ~1949). Pure docs/UI/CLI ACs use—and are exempt from the trailer requirement. - Lint (G6b, T-AC81-2):
tests/spec_coverage_lint.rs— 5 tests parse both files and asserttable_well_formed,all_acs_present_with_canonical_actions,every_cited_action_is_in_canonical_next,no_duplicate_ac_rows,no_empty_action_cells. Multi-action cells (e.g. AC-78's six commit-chain actions) are pipe-separated via markdown-escaped\|; the lint splits on the unescaped delimiter and validates every token. - Commit-msg hook (G6c, T-AC81-3):
.github/hooks/commit-msg-spec-refis a path-conditional bash hook. It checksgit diff --cached --name-onlyagainst^src/(runtime|api)/; commits that touch neither path are accepted unconditionally. Gated commits MUST contain at least onecanonical_action=<Name>trailer where<Name>appears inscaffolding/spec_coverage.md. Unknown trailers are rejected with the offending name printed to stderr. The hook resolves the coverage doc viagit rev-parse --show-toplevel, parses pipe-escaped cells correctly, and fails closed on missing inputs. - Devshell installer (G6d, T-AC81-4):
scripts/devshell.shrunsinstall_commit_msg_hookearly on every invocation. The installer copies the source hook to.git/hooks/commit-msgif and only if no hook is present, the present hook is the unmodifiedcommit-msg.sample, or the hook is byte-identical to the source (no-op). User-customized hooks are NEVER overwritten. Skipped silently outside a git working tree, and also skipped whenOPEN_PINCERY_DEVSHELL_SKIP_HOOK_INSTALLis set (escape hatch for CI). - Tests: 5 in
tests/spec_hook_test.rsdriving the hook end-to-end with synthetic git repos:rejects_runtime_change_without_trailer,accepts_runtime_change_with_valid_trailer(usescanonical_action=AuthorizeExecution),accepts_docs_only_commit,rejects_unknown_canonical_action,devshell_installs_hook_idempotently(asserts run-1 installs, run-2 is a no-op, and a user-customized hook survives run-3 untouched). Plus the 5 lint tests intests/spec_coverage_lint.rs. All 10 pass; full suite remains green.
- Spec coverage table (G6a, T-AC81-1): new
-
AC-82 (this commit): Fine-grained wake lifecycle — the wake state machine is decomposed from the legacy
(asleep | awake | maintenance)triplet into ten fine-grained states with one CAS-only DB transition per canonical TLA+ action. Closed onv6-01_implementationat G7g + review-fix56c8209+ reconcileb57ba8f. v9.0 ship gate now CLEAR.- Schema (G7a, T-AC82-1):
migrations/20260507000001_agent_status_fine_grained.sqlwidens theagents.statusCHECK constraint to admit ten values:asleep,wake_acquiring,prompt_assembling,awake,tool_dispatching,tool_executing,tool_result_processing,mid_wake_event_polling,wake_ending,maintenance. Forward-only additive migration; existingawake/maintenancerows remain valid. - CAS helpers (G7a/G7b, T-AC82-2):
src/models/agent.rsadds ten single-source CAS helpers (one per fine-grained transition) and one multi-source terminal CAS (enter_wake_endingadmits the five live-wake states). Every status write in the codebase is now confined to this file — pinned by the static lint below. - Canonical-JSON event emitter (G7b, T-AC82-3): new
src/runtime/lifecycle.rsemits alifecycle_transitionevent per CAS, hand-building a byte-stable canonical-JSON payload with alphabetically-ordered keys (canonical_action,from,to,wake_id). Three unit tests pin canonical key order, JSON round-trip, and byte-stability. - Wake-loop wiring (G7b/G7c/G7d, T-AC82-4..T-AC82-6):
src/runtime/wake_loop.rs::run_wake_loopowns the full lifecycle chain — entry chain at top (WakeAcquireSucceeds+PromptAssemblyCompletes), per-tool-call body emitsToolDispatches+AuthorizeExecution+ReceiveToolResult+ToolResultProcessedToolLoop+MidWakePollFindsNothing, and bottom multi-source terminal block reads the actual prior status beforeenter_wake_endingand emitsTerminalEndsWake+WakeEndTransitionsToMaintenance. The Sleep early-return arm runs an inline terminal chain.src/background/listener.rsno longer callstransition_to_maintenance;src/runtime/drain.rs::check_draincallsdrain_attempt_wake_acquire(Maintenance → WakeAcquiring) and the rest of the chain fires transparently insiderun_wake_loop. - End-to-end tests (T-AC82-5):
tests/lifecycle_transition_test.rsadds two scenarios (wake_loop_emits_canonical_lifecycle_chain_for_sleep_terminal,wake_loop_iteration_cap_terminal_records_actual_prior_status) that driverun_wake_loopagainst a wiremock LLM, read theeventstable forlifecycle_transitionrows ordered by(created_at, ctid), and assert (1) the canonical-action sequence matches AC-82's pipe-list, (2) timeline coverage, (3)(prev.to == next.from)chain agreement, (4) one event per CAS, (5) exactly oneTerminalEndsWakeper wake (Inv_TerminalSuccession). - Static CAS-only lint (G7f, T-AC82-7):
tests/status_writes_lint_test.rs::assert_status_writes_are_cas_onlywalkssrc/, whole-file whitespace-normalizes each.rs, and asserts the literalupdate agents set status(case-insensitive) appears only insrc/models/agent.rs. Catches both single-line and multi-line UPDATE/SET shapes — defends R-AC82-3 against any future caller bypassing the CAS helpers. - Stale recovery widening (review-fix, R-AC82-3 soundness):
src/models/agent.rs::find_stale_agentsandforce_releaseWHERE clauses widened from(awake | maintenance)to the full nine-state in-flight set. A transient DB failure between fine-grained CASes can no longer leave an agent permanently invisible to the AC-8 stale-recovery job. - Spec coverage (G7g, T-AC82-8):
scaffolding/spec_coverage.mdAC-82 row promoted from placeholder to the full canonical-action pipe-list (10 actions includingAuthorizeExecution) andInv_TerminalSuccessioninvariant. The TLA+ invariant exists atdocs/input/OpenPinceryCanonical.tla:2081.
- Schema (G7a, T-AC82-1):
- New env var (optional):
OPEN_PINCERY_AUDIT_CHAIN_FLOORacceptsstrict(default) orrelaxed. Pair withOPEN_PINCERY_ALLOW_UNSAFE=trueto allow boot when an existing chain is detected broken (e.g. during forensic recovery). Documented in.env.example. - New exit codes: 4 = sandbox memory-cap floor unenforced (AC-76 G1c.x.2); 5 = audit chain broken at startup (AC-78 G3d). Operators should treat both as boot refusals — see the runbook for triage.
- New CLI verb:
pcy audit verifyfor ad-hoc chain audits (admin-only). - New event types:
audit_chain_verified,audit_chain_broken,audit_chain_floor_relaxed,sandbox_syscall_denied,sandbox_memory_cap_*. All emitted with sourceruntimeand queryable via the existing event API. - No reasoner-side change: AC-78 trigger fills
prev_hash/entry_hashserver-side; existingevent::append_eventcallers are unchanged. AC-77 seccomp allowlist may surface SIGSYS for syscalls not in the default-deny set — capture viatests/fixtures/seccomp/capture_seccomp_corpus.shand append toadditions.txtwith kernel evidence. - Two new env vars (AC-79, both optional):
OPEN_PINCERY_SCHEMA_INVALID_RETRY_CAP(default 3, must be > 0). Number of consecutivemodel_response_schema_invalidfailures before the wake terminates withFailureAuditPending. Schema-invalid retries do not consumeiteration_capbudget.OPEN_PINCERY_TOOL_CALL_RATE_LIMIT_PER_WAKE(default 32, must be > 0). Hard upper bound on real tool dispatches per wake, independent ofiteration_cap. On exceedance the wake terminates withFailureAuditPendingand emitstool_call_rate_limit_exceeded.
- Four new AC-79 event types:
prompt_injection_canary_emitted(one per wake, payload only carries the wake_id — never the canary value),prompt_injection_suspected(canary echo detected; payload{where_found, model_attempted_tool_calls}),model_response_schema_invalid(tool-call schema gate; payload{tool_name, schema_errors[], attempt, retry_cap}),tool_call_rate_limit_exceeded(payload{limit, attempted}). All emitted withsource = "runtime"and chained through the AC-78 audit trigger. - System prompt v3: existing reasoners running against v2 will see a strict-superset prompt with new untrusted-content boundary discipline. No tool API change. Reasoners that already followed the v2 credential-handling refusal pattern need no behavior change; the new v3 instructions tell the model to treat
<<untrusted:NONCE>>...<<end:NONCE>>-wrapped content as data and never to echo the per-wake<<canary:HEX>>token. - AC-82 fine-grained agent statuses: dashboards or external monitors that previously matched
agents.statusagainst the literal set{asleep, awake, maintenance}MUST now also accept the seven new in-flight values (wake_acquiring,prompt_assembling,tool_dispatching,tool_executing,tool_result_processing,mid_wake_event_polling,wake_ending). The migration is forward-only and additive; no row rewrite. The newlifecycle_transitionevent type is emitted once per CAS with a canonical-JSON payload{"canonical_action": "<TLA+Action>", "from": "<prev_status>", "to": "<new_status>", "wake_id": "<uuid>"}— alphabetical key order is byte-stable and chains through the AC-78 audit hash trigger transparently. The AC-8 stale-recovery job now sees all nine in-flight states and will unwedge agents stuck mid-wake by a transient DB failure between fine-grained CASes.
- L-v91-1 — backup audit events not in
eventstable:backup_takenandbackup_restoredemit viatracing::info!(target: "open_pincery::audit", …)+eprintln!rather than as rows inevents. The events table requiresagent_id NOT NULLand the v9.1 budget (T-v91-2) sanctioned only thellm_providersmigration. Operators relying on audit-log scrapers for backup compliance must consume stdout/stderr until v9.2 adds anoperator_eventstable. - AC-90b — sandbox-smoke doctor check deferred to v9.2:
pcy doctorships seven of the eight originally scoped checks. The eighth ("re-run a no-op sandboxed command and verify exit 0 +sandbox_blockednot emitted") needs a bootstrapped DB + agent at probe time and was out of the v9.1 7.5-day budget. TheProbe::sandbox_smoketrait method is preserved as a forward-compat stub. - AC-93c — key-in-process-memory probe deferred to v9.2: The wake-loop resolver reads the credentialed LLM key from the vault into a
Stringand hands it toLlmClient::new. The AC-71 "key value never appears in agent process memory" guarantee is satisfied by construction at the--keyflag layer (clap rejects raw keys) and at the env-var layer (noLLM_API_KEYlookup when a provider row exists), but it is not yet asserted with a live-process memory probe. Closing requires eithersecret_proxyextension to the LLM client or a/proc/self/mapsgrep in VERIFY. - AC-91 live
pg_dump/pg_restoreround-trip exercised in CI only: The backup/restore in-process tests (manifest shape, forward-incompatible refusal, vault-key 0o600 extraction, key-bytes byte-grep) all pass locally; the end-to-end binary round-trip runs on CI with a real Postgres +postgresql-client. pg_dump/pg_restoreare operator-side tools: Backup and restore are designed to run on the host, not inside the runtime container. The runtime image does not bundle the Postgres client utilities.
- Host-level sandbox only: v6 ships env-clear + tempdir + 30s timeout + sudo-token rejection via
ProcessExecutor. This is defense-in-depth, not isolation — a process running as thepcyuser can still read any file that user can read. True container-level isolation (Zerobox) is on the roadmap.
- Sudo reject is token-based, not path-based: commands containing a
sudotoken are rejected pre-spawn; commands invoking/usr/bin/sudoby absolute path are not caught by the tokeniser and rely onenv_clear+ tempdir + no-tty for defense. Documented insrc/runtime/sandbox.rs. - Credential substitution is reasoner-cooperative: v7 protects against accidental leakage through event/log/argv paths and against unauthorised dispatch spawns, but a malicious or confused reasoner that types a PLACEHOLDER value into plaintext content will still produce plaintext. Cryptographic isolation (v8/v9) is the structural fix.
- Vault master-key rotation not yet implemented:
OPEN_PINCERY_VAULT_KEYis single-valued; re-sealing on rotation is a v8 item. - AC-80 nonce sweep deferred to v9.1: the
capability_noncesUNIQUE(workspace_id, nonce)index makes accumulated consumed/expired rows unreachable from production code paths, but a periodicDELETE WHERE expires_at < now() - interval '7 days'background sweeper is not yet wired. Steady-state insert rate is bounded by the per-wake tool-call rate limit (AC-79; default 32) so unbounded growth is not a near-term operational risk. - No inter-agent messaging: Single-agent operation only
- Single workspace enforcement: Multi-tenancy schema exists;
scoped_agentenforces workspace isolation on agent-level handlers, but cross-workspace administration is not yet exposed via API - Webhook secrets: Still surfaced exactly once — on creation or after
POST /api/agents/:id/rotate-webhook-secret. Operators must capture the response immediately. - Rate limiting is in-process: Not shared across multiple server instances
- Metrics recorder is process-global: Only one Prometheus recorder per process; unit tests that install a recorder must run single-threaded.
- Release workflow not yet exercised:
cosign verify-blobagainst a real tagged artifact will happen on firstv*tag push. - RUSTSEC-2023-0071 (medium, CVSS 5.9):
rsa 0.9.10pulled in transitively via unusedsqlx-mysql. Not reachable at runtime (MySQL driver is never loaded); documented allowlist entry indeny.tomlkeyed bytests/deny_config_test.rs. Revisit on upstreamrsafix, sqlx 0.9 stable, or migration offsqlx::FromRowderive.
- Runtime: Single Rust binary (~15MB release), or Docker image
- Database: PostgreSQL 16 (19 migration files)
- External: One OpenAI-compatible LLM API
- Stack additions in v7:
aes-gcm,rpassword,walkdir(dev-only) - Cost: PostgreSQL hosting + LLM API usage. No other infrastructure costs.