Files
cayce-ops/cayceops/models.py
T
cayce 90e6cfcc19 fix: address external audit — criticals 1-5 + mediums
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.
2026-10-19 23:06:29 +00:00

273 lines
10 KiB
Python

"""ORM models and lifecycle states shared by every component.
Status machines
---------------
proposal: proposed -> approved -> running -> done | failed
proposed -> rejected
mail: (inbound) classified: approval|rejection|question|other|needs_review
(any) draft: none -> drafted -> approved -> sent | skipped
content: draft -> approved -> published | skipped (terminal)
Audit chain
-----------
audit_log is hash-chained: each entry stores sha256(prev_hash | fields).
``cayceops.models.verify_audit_chain`` re-walks it; the monitor surfaces breaks.
Not tamper-proof (DB writes can rewrite the chain) but tamper-EVIDENT against
selective edits. See AUDIT.md / audit response 2026-10-20.
"""
from __future__ import annotations
import enum
import hashlib
import json
from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
def utcnow() -> datetime:
return datetime.now(UTC)
def _content_hash(text: str) -> str:
"""Binding hash for human-approved text (drafts, captions)."""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
class Base(DeclarativeBase):
pass
# --- proposals ---------------------------------------------------------------
class ProposalStatus(enum.StrEnum):
PROPOSED = "proposed"
APPROVED = "approved"
REJECTED = "rejected"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
class ProposalSource(enum.StrEnum):
SCANNER = "scanner"
MANUAL = "manual"
class Proposal(Base):
__tablename__ = "proposals"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source: Mapped[ProposalSource] = mapped_column(
Enum(ProposalSource), default=ProposalSource.SCANNER
)
kind: Mapped[str] = mapped_column(String(64)) # affiliate|partnership|content_gap|other
title: Mapped[str] = mapped_column(String(512))
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
detail: Mapped[dict] = mapped_column(JSON, default=dict)
# keyword-match relevance 0..1 — NOT risk. auto-approve takes highest first.
match_score: Mapped[float] = mapped_column(Float, default=0.0)
status: Mapped[ProposalStatus] = mapped_column(
Enum(ProposalStatus), default=ProposalStatus.PROPOSED, index=True
)
attempts: Mapped[int] = mapped_column(Integer, default=0)
dedupe_key: Mapped[str | None] = mapped_column(String(256), unique=True, nullable=True)
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)
# --- audit (hash-chained) ----------------------------------------------------
class AuditEntry(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
actor: Mapped[str] = mapped_column(String(128)) # human | auto:policy | daemon:<name>
proposal_id: Mapped[int | None] = mapped_column(
ForeignKey("proposals.id", ondelete="SET NULL"), nullable=True, index=True
)
action: Mapped[str] = mapped_column(String(64)) # approve|reject|execute|classify|...
detail: Mapped[dict] = mapped_column(JSON, default=dict)
result: Mapped[str] = mapped_column(String(32), default="ok") # ok | error
# tamper-evident chain
prev_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
entry_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
def audit(
sess,
actor: str,
action: str,
proposal_id: int | None = None,
detail: dict | None = None,
result: str = "ok",
) -> AuditEntry:
"""Record one audited action, chained to the previous entry.
Chain: entry_hash = sha256(prev_hash | ts | actor | action | proposal_id | detail | result).
Every component writes through this helper.
"""
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
ts = utcnow()
# epoch int: identical after a sqlite/pg round-trip (tz-aware isoformat is not)
ts_int = int(ts.timestamp())
detail_json = json.dumps(detail or {}, sort_keys=True, ensure_ascii=False)
payload = "|".join(
[
prev_hash or "GENESIS",
str(ts_int),
actor,
action,
str(proposal_id),
detail_json,
result,
]
)
entry = AuditEntry(
ts=ts,
actor=actor,
action=action,
proposal_id=proposal_id,
detail=detail or {},
result=result,
prev_hash=prev_hash,
entry_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
)
sess.add(entry)
return entry
def verify_audit_chain(sess) -> tuple[bool, int | None]:
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None)."""
prev_hash = "GENESIS"
for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all():
detail_json = json.dumps(entry.detail or {}, sort_keys=True, ensure_ascii=False)
ts = entry.ts
if ts.tzinfo is None:
ts = ts.replace(tzinfo=UTC)
payload = "|".join(
[
prev_hash,
str(int(ts.timestamp())),
entry.actor,
entry.action,
str(entry.proposal_id),
detail_json,
entry.result,
]
)
expected = hashlib.sha256(payload.encode("utf-8")).hexdigest()
if entry.prev_hash != prev_hash or entry.entry_hash != expected:
return False, entry.id
prev_hash = entry.entry_hash
return True, None
# --- mail --------------------------------------------------------------------
class MailDirection(enum.StrEnum):
INBOUND = "in"
OUTBOUND = "out"
class MailClassification(enum.StrEnum):
APPROVAL = "approval"
REJECTION = "rejection"
QUESTION = "question"
OTHER = "other"
NEEDS_REVIEW = "needs_review"
class DraftStatus(enum.StrEnum):
NONE = "none"
DRAFTED = "drafted"
APPROVED = "approved" # human approved the draft; SMTP may send it
SENT = "sent"
SKIPPED = "skipped"
class MailMessage(Base):
__tablename__ = "mail_messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
direction: Mapped[MailDirection] = mapped_column(Enum(MailDirection))
message_id: Mapped[str | None] = mapped_column(String(998), nullable=True, index=True)
in_reply_to: Mapped[str | None] = mapped_column(String(998), nullable=True)
from_addr: Mapped[str | None] = mapped_column(String(320), nullable=True)
to_addr: Mapped[str | None] = mapped_column(String(320), nullable=True)
subject: Mapped[str | None] = mapped_column(String(998), nullable=True)
body: Mapped[str | None] = mapped_column(Text, nullable=True)
classification: Mapped[MailClassification | None] = mapped_column(
Enum(MailClassification), nullable=True
)
confidence: Mapped[float] = mapped_column(Float, default=0.0)
proposal_id: Mapped[int | None] = mapped_column(
ForeignKey("proposals.id", ondelete="SET NULL"), nullable=True, index=True
)
draft_status: Mapped[DraftStatus] = mapped_column(
Enum(DraftStatus), default=DraftStatus.NONE
)
draft_reply: Mapped[str | None] = mapped_column(Text, nullable=True)
# binding: hash of the draft text the human approved + who approved it
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# --- content -----------------------------------------------------------------
class ContentStatus(enum.StrEnum):
DRAFT = "draft"
APPROVED = "approved"
PUBLISHED = "published"
SKIPPED = "skipped" # terminal: disabled channel or attempt budget exhausted
class ContentItem(Base):
__tablename__ = "content_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
theme: Mapped[str] = mapped_column(String(128))
channel: Mapped[str] = mapped_column(String(32)) # nostr|facebook|instagram
caption: Mapped[str] = mapped_column(Text)
media_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
link: Mapped[str | None] = mapped_column(String(1024), nullable=True)
status: Mapped[ContentStatus] = mapped_column(
Enum(ContentStatus), default=ContentStatus.DRAFT, index=True
)
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
post_ref: Mapped[str | None] = mapped_column(String(512), nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
# binding: hash of the caption the human approved
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# --- daemon runs (monitor input) ---------------------------------------------
class DaemonRun(Base):
__tablename__ = "daemon_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# scanner | executor | maild | publisher | monitor
daemon: Mapped[str] = mapped_column(String(32), index=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(16), default="ok") # ok | error
detail: Mapped[dict] = mapped_column(JSON, default=dict)