deploy/backup/make-backup.sh (installed as /usr/local/bin/cayce-make-backup):
- pg_dump custom-format of the cayce-ops DB (URL from env or .env,
SQLAlchemy driver qualifier stripped, credentials never logged)
- gitea dump (repos + sqlite + attachments), live; consistency notes in RESTORE.md
- config tar: Caddyfile(+d), gitea app.ini + internal_token, cayce-* units, .env
- EACH artifact encrypted with age BEFORE leaving the tmpdir; recipient
read from /etc/cayce-backup/age-recipient.pub (public key only). The
private age key never touches this server.
- plaintext exists ONLY in a mktemp dir removed by an EXIT trap; a partial
day directory is removed on failure; any step failure => exit != 0 =>
journald entry
- manifest.sha256 over the encrypted artifacts (pull side verifies
integrity WITHOUT the age private key)
- destination /var/backups/cayce/YYYY-MM-DD/ (750 root:backup-pull, 640)
- retention: 7 daily dirs, pruned by mtime
systemd: cayce-backup.service (oneshot, hardened: ProtectSystem=strict,
NoNewPrivileges, ProtectHome=read-only) + cayce-backup.timer (daily
03:15 UTC, Persistent). Timer ENABLED on the VPS per operator instruction;
runs fail loudly (exit 1, clear journald message) until the age recipient
is provided — that is the designed not-configured state.
- cayce-ops-plan.service/.timer (daily): the @ template cannot express
publisher plan, so it gets its own unit
- DECISIONS: D13 no non-human approvals; D14 signatures bind content +
deployment; D15 negation veto classifier
- one least-privilege role per daemon; cayce_approver is the ONLY writer of
status=APPROVED / operator_signature / approved columns
- per-table BEFORE triggers reject approval writes from daemon roles
- no literal passwords: refuses to run without the six -v password vars
(psql exits 3 via missing-table SELECT guard)
- idempotent: revokes + drops trigger/function before recreating
- tests/integration/test_pg_roles.py: applies roles.sql via psql -v, runs a
full cycle per daemon role (propose -> approve -> execute; compose ->
approve -> send; plan -> approve -> publish; anchor) and asserts the
denials; runs the alembic upgrade->downgrade->upgrade round-trip on
Postgres (point 6). Skips cleanly without a reachable PG.
Critical 1 (approval forgery): audit_log is now hash-chained
(prev_hash/entry_hash, verify_audit_chain); executor approve REQUIRES a
BIP-340 signature over sha256(cayce-ops:approve:<id>) when
CAYCE_OPS_OPERATOR_PUBKEY is set; deploy/roles.sql ships least-privilege
per-daemon DB roles.
Critical 2 (threading dead): sends create real OUTBOUND rows (own
Message-ID, In-Reply-To -> inbound); inbound message_id never overwritten;
maild compose adds the first-contact path; _match_proposal falls back to
inbound chains.
Critical 3 (duplicate sends): per-message transactions in send path —
SMTP failure on message N no longer resurrects 1..N-1; failures audited
(send_failed) and the row stays queued.
Critical 4 (classifier): negated approvals (cannot accept / not approved /
not accepting) classify REJECTION at 0.95 before the bare-keyword rule;
LLM prompt braces escaped (.format no longer KeyError into silent fallback).
Critical 5 (systemd): every daemon has a run subcommand
(maild run = poll+send, publisher run = publish-due, monitor run = build);
deploy README documents the separate daily publisher plan timer.
Mediums: approved_hash binds drafts/captions to the approved text (edited
text refuses to send/publish until re-approved); risk_score renamed
match_score (migration 0002) and auto-approve orders HIGHEST relevance
first; compose binds postgres to 127.0.0.1; IMAP marks \\Seen only after
commit (fetch returns (num, raw), mark post-commit); disabled channels are
terminal (ContentStatus.SKIPPED) and publish failures burn a bounded
attempt budget before abandonment; make hooks installs the pre-push gate.
tests: 12 new — 56 total, all green; ruff 0 findings; gitleaks clean.