Merchants write in Spanish; negations like nunca, lamentablemente or
no podemos must veto approval exactly like their English counterparts.
Previously an all-Spanish rejection mail could fall through to OTHER,
and a mixed mail with the approval word in English approved despite a
Spanish negation.
- negation list adds: nunca, jamas, tampoco, ningun/ninguna, nadie,
nada, no podemos, no es posible, no aceptamos, rechaz*, descartad*,
lamentablemente, no ha sido aprobad*, no procede
- new Spanish REJECTION rule (rechaz*, solicitud denegada, no cumple
los requisitos) and Spanish APPROVAL rule (aprob*, aceptad*,
bienvenid*, encantados de trabajar)
- veto rule unchanged: any negation anywhere -> needs_review
- tests: 15 new (12 veto cases incl. 2 mixed-language, pure-Spanish
approval still approves, Spanish rejection classifies rejection)
Gate: ruff 0 · 96 unit + 1 PG integration · gitleaks clean.
- verify_audit_chain(): rows with entry_hash IS NULL are only tolerated
BEFORE the first hashed row (documented legacy prefix). A NULL hash
AFTER the chain started is tampering and fails verification at that
row id — previously any NULL row anywhere was silently skipped, so an
attacker with UPDATE could erase intermediate evidence undetected.
- migration 0004: audit_log becomes append-only. SQLite: BEFORE UPDATE/
DELETE triggers RAISE(ABORT). Postgres: BEFORE triggers RAISE EXCEPTION.
Closes the hole for the table owner and direct SQL connections.
- monitor.verify_chain_against_anchors(): compares the chain tail against
anchors published to Nostr. Explicit statuses: no_anchors (never
anchored / notes missing on relays), local_only (no fetcher), ok
(relay note attests the tail or the last local anchor), mismatch.
- tests: NULL-before-genesis ok, NULL-in-middle fails at that id, NULL
late fails, UPDATE rejected, DELETE rejected, INSERT still allowed,
no-anchors explicit, local-only explicit, mismatch detected, match ok.
Gate: ruff 0 · 81 unit + 1 PG integration · alembic 0001→0004 on PG ·
gitleaks clean · make check exit 0.
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).
- audit() writes are serialized: pg_advisory_xact_lock on Postgres;
in-process RLock held until session commit/rollback on SQLite
(test: 4 concurrent writers x 25 entries -> chain verifies, 100 rows)
- legacy rows (entry_hash NULL, pre-0002) are skipped: the chain begins
GENESIS at the first hashed row; migrated DBs verify clean by design
- monitor anchor: publishes the last chain hash as a signed NIP-01 note
once per day, logs it in the audit log (self-verified before publish)
tests: concurrent writers; legacy-genesis verification; anchor signs,
logs once per day.
Replaces per-pattern negation lists: if ANY negation token (not, no, never,
cannot, cannot, will not, has not, is not, no longer, unable, decline,
regret, unfortunately) appears anywhere in the mail, an approval
classification downgrades to needs_review. Approval requires a mail with
zero negations. The LLM prompt braces stay escaped (round-1 fix retained).
tests: the six mandated sentences all classify non-approval.
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.
All 32 findings resolved: regex literals kept single-line with noqa
(pattern IS the reviewed artifact), HTML rows string-concatenated for
line length, walrus tuple in BIP-340 verify split into named generator,
unused pytest import dropped. No behavior changes; 44/44 tests pass.
Full-gate discipline restored: ruff exit 0.
- self-contained HTML (no external assets/JS), everything html-escaped
- daemon health from daemon_runs (last status + last run per daemon)
- proposal queue counts by state; human-gate counters (mail drafts awaiting
approval, content draft/approved)
- audit tail: actor/action/result only — no detail payloads, no addresses,
no subjects, no content bodies (tests assert the negatives)
- DaemonRun bookkeeping like every other daemon
tests: 3 new (sections render, leak-negatives, XSS escape) — 44 total