Files
cayce-ops/docs/DECISIONS.md
cayce 0d24309433
ci / lint-test-leaks (pull_request) Canceled after 0s
audit-D16: operator key must never be an agent identity
Background: CAYCE_OPS_OPERATOR_PUBKEY was set to Cayce nostr pubkey —
Cayce was approving itself. Signature was technically valid but semantically
null: human approval signed by the agent is not human approval.

- signing.py: validate_no_self_approval() rejects the operator pubkey when
  it matches (a) the pubkey derived from any private key in the config,
  or (b) any pubkey in CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS deny-list.
- executor/maild/publisher _signing() now calls the validation, so every
  daemon and every CLI approve-* path refuses to operate (D16).
- tests: 6 new (agent-key refusal, human key passes, empty passes,
  explicit denylist, end-to-end daemon refusal, derivation roundtrip).
- docs/DECISIONS.md: D16.

The operator signature is now produced OUTSIDE the VPS. CAYCE_OPS_OPERATOR_PUBKEY
must stay empty until the external signer exists — daemons refuse, which is
correct. The Cayce nsec itself is NOT touched (still the agent identity for
publishing and anchoring).
2026-10-28 19:51:04 +00:00

7.9 KiB

Architectural decisions

Each entry: decision, why, and what it costs us. Newest last.

D1 — uv + uv.lock as the lockfile

uv manages the pinned interpreter (3.12) and produces uv.lock. CI installs with uv sync --frozen; nothing runs against unpinned trees. Cost: contributors need uv (single static binary).

D2 — Tests on SQLite; migrations authoritative for Postgres

Tests run on file-backed SQLite via metadata.create_all (fast, no service dependency). The Alembic chain is the production schema path; make migrate-check (CI job) smoke-runs alembic upgrade head on SQLite. Cost: Postgres-specific features (e.g. JSONB, partial indexes) stay unused — the schema uses portable sa.JSON instead.

D3 — LLM behind an in-repo provider interface; null by default

cayceops.llm.LLMProvider is the only seam. null provider raises — components fall back to deterministic rules. openai-compat uses stdlib urllib against any OpenAI-shaped endpoint; no SDK dependency. Cost: no retries/streaming/tool-calls — acceptable for classification and draft drafting. Add providers by implementing complete().

D4 — Nostr client implemented in-repo (coincurve + websockets)

NIP-01 event build/sign/publish is ~150 auditable lines vs. a black-box SDK. Keys accepted as 32-byte hex only. Relays are plain env config; publish is fire-and-wait with a timeout; the event id is stored as post_ref. Cost: no NIP-05, no retries across relay pools — fine for kind-1 notes.

D5 — AUTONOMOUS_MODE semantics (the only non-human approval path)

Default false: only human-approved proposals execute, only human-approved drafts are sendable/publishable. true adds exactly one capability: the executor may auto-approve proposed items whose kind is in CAYCE_OPS_AUTONOMOUS_KINDS, up to CAYCE_OPS_AUTONOMOUS_DAILY_CAP per day, recording actor auto:policy in the audit log. Everything else still requires a human. Auto-approval never touches mail drafts or content items. Cost: two code paths; both audited; the autonomous one is off by default and cap-bounded.

D6 — CI workflow files ship now; enforcement gate is make check until a runner exists

.gitea/workflows/ci.yml (Gitea Actions) and .github/workflows/ci.yml (identical) run ruff, alembic smoke, pytest, gitleaks on every PR/push. The current host runs no Gitea Actions runner, so until one is provisioned the same gate is enforced locally: scripts/pre-push runs make check (lint + tests + migration smoke + full-history secret scan) before every push, and the audited commit hash in AUDIT.md is the one that passed it. Cost: CI results are deferred on the current host; the gate itself is identical to CI.

D7 — gitleaks allowlist for test fixtures

tests/ contains invented data (fake Nostr key, fake merchant mail). It is allowlisted in .gitleaks.toml so the history scan stays signal-only. Cost: a real secret committed under tests/ would not trip the scanner — compensating control: fixtures are reviewed in PRs, and no fixture ever reads the environment.

D8 — Proposal history kept when retiring the grant text

The repo began as a grant proposal; the retirement commit replaces it with the project README (no grant references in the tree). The old commits stay — the audit story ("this started as X, the operator mandated Y, here is the diff") is more honest than a rewritten birth. Cost: git log still shows the word "OpenSats" in old messages. The tree and README carry none.

D9 — Executor plugins are in-process registrations

An action plugin is a Python function registered in cayceops.components.executor.plugins.REGISTRY, mapped by proposal kind. http_post exists but is inert unless CAYCE_OPS_ENABLE_HTTP_ACTIONS=true. Cost: no sandboxing of plugins — plugins are repo code, reviewed like everything else.

D10 — Audit chain is hash-chained; approval can require operator signature (post-audit)

audit_log rows chain sha256(prev_hash|fields); verify_audit_chain walks it, the monitor can surface breaks. With CAYCE_OPS_OPERATOR_PUBKEY set, executor approve REQUIRES a BIP-340 signature over sha256("cayce-ops:approve:") from the operator key — --actor becomes a label, the key is the proof (the audit's "anyone can sign as anyone"). Cost: without a pubkey configured the gate degrades to the old free-text actor; separate DB roles per daemon remain a deployment TODO (SQL to tighten ships in deploy/roles.sql).

D11 — Mail threading now has a real first-contact path (post-audit)

Sends create an OUTBOUND row (own Message-ID, In-Reply-To -> inbound); inbound rows keep their original Message-ID. maild compose creates the initial outreach draft for a proposal. Approval is bound to the draft text via sha256 (approved_hash): an edited draft refuses to send until re-approved. Each send commits in its own transaction — SMTP failure on message N can no longer resurrect 1..N-1 as duplicates.

D12 — IMAP \Seen only after commit; content gates tighten (post-audit)

Messages are fetched unmarked and flagged \Seen after the processing transaction commits (crash mid-batch no longer loses mail). Publisher: caption hash-bound at approval, disabled channels are terminal (SKIPPED), publish failures burn a bounded attempt budget before abandonment. risk_score renamed match_score (keyword relevance) and auto-approval takes the HIGHEST score first.

D13 — No non-human approval path (round 2, supersedes D5)

Operator signatures are mandatory for approvals AND re-verified at execution/send/publish time against current content + deployment id. With that gate, the AUTONOMOUS_MODE auto-approval path cannot exist: it would produce unsigned approvals the gate refuses. _auto_approve is inert and audits autonomy_disabled when invoked. Cost: AUTONOMOUS_MODE is now inert by design.

D14 — Signatures bind content + deployment (round 2)

message = sha256("cayce-ops::<deployment_id>::<content_hash>"). content_hash is canonical-json of the fields that matter per type. Full signatures live on the row (operator_signature) and in the audit detail. Verification happens again at run time: unsigned rows (raw SQL approval) revert to their pre-approval state with an error audit. Cost: a deployment id env var is mandatory; signatures must be recomputed if content changes.

D15 — Approval classifier veto (round 2)

Any negation token anywhere in a mail vetoes an approval classification → needs_review. Simpler and strictly conservative vs. per-pattern negation lists. Cost: genuinely-positive approvals containing stray negations (e.g. "we have no reason not to welcome you") need human triage.

D16 — La clave del operador nunca es una identidad de agente

Fecha: 2026-10-28

Contexto: se descubrió que CAYCE_OPS_OPERATOR_PUBKEY estaba configurada con la pubkey de Cayce — es decir, Cayce se aprobaba a sí misma. La firma BIP-340 era técnicamente válida pero semánticamente nula: "aprobación humana" firmada por el agente no es aprobación humana.

Regla: los daemons y el CLI se niegan a operar si la pubkey del operador coincide con:

  1. La pubkey derivada de cualquier clave privada cargada en la config (CAYCE_OPS_NOSTR_PRIVATE_KEY u otra identidad de agente futura), o
  2. Cualquier pubkey en CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS (deny-list explícita, para claves fuera de este proceso).

Implementación: cayceops.signing.validate_no_self_approval(), llamada desde _signing() en executor/maild/publisher. Lanza SelfApprovalError.

La firma del operador se produce FUERA de la VPS. El CLI approve-* acepta la firma como parámetro (ya lo hace); nunca la genera él. Mientras no exista el signer externo, CAYCE_OPS_OPERATOR_PUBKEY debe permanecer vacía — los daemons se niegan a operar y eso es lo correcto.

La nsec de Cayce NO se toca: sigue siendo la identidad del agente para publicar y anclar. Lo que se prohíbe es usarla como clave de operador.