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.
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.