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.
69 lines
2.5 KiB
Python
69 lines
2.5 KiB
Python
"""audit hardening migration — minimal standalone mirror of 0002.
|
|
|
|
Revision ID: 0002
|
|
Revises: 0001
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from alembic import op
|
|
|
|
revision = "0002"
|
|
down_revision = "0001"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
OLD_CONTENT_STATUSES = ("DRAFT", "APPROVED", "PUBLISHED")
|
|
NEW_CONTENT_STATUSES = ("DRAFT", "APPROVED", "PUBLISHED", "SKIPPED")
|
|
|
|
|
|
def upgrade() -> None:
|
|
with op.batch_alter_table("audit_log") as batch:
|
|
batch.add_column(sa.Column("prev_hash", sa.String(length=64), nullable=True))
|
|
batch.add_column(sa.Column("entry_hash", sa.String(length=64), nullable=True))
|
|
batch.create_index("ix_audit_log_entry_hash", ["entry_hash"])
|
|
|
|
with op.batch_alter_table("proposals") as batch:
|
|
batch.alter_column("risk_score", new_column_name="match_score")
|
|
|
|
with op.batch_alter_table("mail_messages") as batch:
|
|
batch.add_column(sa.Column("approved_hash", sa.String(length=64), nullable=True))
|
|
batch.add_column(sa.Column("approved_by", sa.String(length=128), nullable=True))
|
|
|
|
if op.get_bind().dialect.name == "postgresql":
|
|
op.execute("ALTER TYPE contentstatus ADD VALUE IF NOT EXISTS 'SKIPPED'")
|
|
with op.batch_alter_table("content_items") as batch:
|
|
batch.alter_column(
|
|
"status",
|
|
existing_type=sa.Enum(*OLD_CONTENT_STATUSES, name="contentstatus"),
|
|
type_=sa.Enum(*NEW_CONTENT_STATUSES, name="contentstatus"),
|
|
existing_nullable=False,
|
|
)
|
|
batch.add_column(sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"))
|
|
batch.add_column(sa.Column("approved_hash", sa.String(length=64), nullable=True))
|
|
|
|
|
|
def downgrade() -> None:
|
|
with op.batch_alter_table("content_items") as batch:
|
|
batch.drop_column("approved_hash")
|
|
batch.drop_column("attempts")
|
|
batch.alter_column(
|
|
"status",
|
|
existing_type=sa.Enum(*NEW_CONTENT_STATUSES, name="contentstatus"),
|
|
type_=sa.Enum(*OLD_CONTENT_STATUSES, name="contentstatus"),
|
|
existing_nullable=False,
|
|
)
|
|
|
|
with op.batch_alter_table("mail_messages") as batch:
|
|
batch.drop_column("approved_by")
|
|
batch.drop_column("approved_hash")
|
|
|
|
with op.batch_alter_table("proposals") as batch:
|
|
batch.alter_column("match_score", new_column_name="risk_score")
|
|
|
|
with op.batch_alter_table("audit_log") as batch:
|
|
batch.drop_index("ix_audit_log_entry_hash")
|
|
batch.drop_column("entry_hash")
|
|
batch.drop_column("prev_hash")
|