teleo-infrastructure/telegram/approval_stages.py
m3taversal 681afad506
Some checks failed
CI / lint-and-test (push) Has been cancelled
Consolidate pipeline code from teleo-codex + VPS into single repo
Sources merged:
- teleo-codex/ops/pipeline-v2/ (11 newer lib files, 5 new lib modules)
- teleo-codex/ops/ (agent-state, diagnostics expansion, systemd units, ops scripts)
- VPS /opt/teleo-eval/telegram/ (10 new bot files, agent configs)
- VPS /opt/teleo-eval/pipeline/ops/ (vector-gc, backfill-descriptions)
- VPS /opt/teleo-eval/sync-mirror.sh (Bug 2 + Step 2.5 fixes)

Non-trivial merges:
- connect.py: kept codex threshold (0.65) + added infra domain parameter
- watchdog.py: kept infra version (stale_pr integration, superset of codex)
- deploy.sh: codex rsync version (interim, until VPS git clone migration)
- diagnostics/app.py: codex decomposed dashboard (14 new route modules)

81 files changed, +17105/-200 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:52:26 +01:00

241 lines
8 KiB
Python

"""Pluggable approval architecture — extensible voting stages for content approval.
Design constraint from m3ta: the approval step must be a pipeline stage, not hardcoded.
Current stage: 1 human approves via Telegram.
Future stages (interface designed, not implemented):
- Agent pre-screening votes (weighted by CI score)
- Multi-human approval
- Domain-agent substance checks
- Futarchy-style decision markets on high-stakes content
Adding a new approval stage = implementing ApprovalStage and registering it.
Threshold logic aggregates votes across all stages.
Epimetheus owns this module.
"""
import logging
import sqlite3
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Optional
logger = logging.getLogger("approval-stages")
class Vote(Enum):
APPROVE = "approve"
REJECT = "reject"
ABSTAIN = "abstain"
@dataclass
class StageResult:
"""Result from a single approval stage."""
stage_name: str
vote: Vote
weight: float # 0.0 - 1.0, how much this stage's vote counts
reason: str = ""
metadata: dict = field(default_factory=dict)
@dataclass
class AggregateResult:
"""Aggregated result across all approval stages."""
approved: bool
total_weight_approve: float
total_weight_reject: float
total_weight_abstain: float
stage_results: list[StageResult]
threshold: float # what threshold was used
@property
def summary(self) -> str:
status = "APPROVED" if self.approved else "REJECTED"
return (
f"{status} (approve={self.total_weight_approve:.2f}, "
f"reject={self.total_weight_reject:.2f}, "
f"threshold={self.threshold:.2f})"
)
class ApprovalStage:
"""Base class for approval stages.
Implement check() to add a new approval stage.
The method receives the approval request and returns a StageResult.
Stages run in priority order (lower = earlier).
A stage can short-circuit by returning a REJECT with weight >= threshold.
"""
name: str = "unnamed"
priority: int = 100 # lower = runs earlier
weight: float = 1.0 # default weight of this stage's vote
def check(self, request: dict) -> StageResult:
"""Evaluate the approval request. Must be overridden."""
raise NotImplementedError
# ─── Built-in Stages ─────────────────────────────────────────────────
class OutputGateStage(ApprovalStage):
"""Stage 0: Deterministic output gate. Blocks system content."""
name = "output_gate"
priority = 0
weight = 1.0 # absolute veto — if gate blocks, nothing passes
def check(self, request: dict) -> StageResult:
from output_gate import gate_for_tweet_queue
content = request.get("content", "")
agent = request.get("originating_agent", "")
gate = gate_for_tweet_queue(content, agent)
if gate:
return StageResult(self.name, Vote.APPROVE, self.weight,
"Content passed output gate")
else:
return StageResult(self.name, Vote.REJECT, self.weight,
f"Blocked: {', '.join(gate.blocked_reasons)}",
{"blocked_reasons": gate.blocked_reasons})
class OpsecStage(ApprovalStage):
"""Stage 1: OPSEC content filter. Blocks sensitive content."""
name = "opsec_filter"
priority = 1
weight = 1.0 # absolute veto
def check(self, request: dict) -> StageResult:
from approvals import check_opsec
content = request.get("content", "")
violation = check_opsec(content)
if violation:
return StageResult(self.name, Vote.REJECT, self.weight, violation)
else:
return StageResult(self.name, Vote.APPROVE, self.weight,
"No OPSEC violations")
class HumanApprovalStage(ApprovalStage):
"""Stage 10: Human approval via Telegram. Currently the final gate.
This stage is async — it doesn't return immediately.
Instead, it sets up the Telegram notification and returns ABSTAIN.
The actual vote comes later when Cory taps Approve/Reject.
"""
name = "human_approval"
priority = 10
weight = 1.0
def check(self, request: dict) -> StageResult:
# Human approval is handled asynchronously via Telegram
# This stage just validates the request is properly formatted
if not request.get("content"):
return StageResult(self.name, Vote.REJECT, self.weight,
"No content to approve")
return StageResult(self.name, Vote.ABSTAIN, self.weight,
"Awaiting human approval via Telegram",
{"async": True})
# ─── Stage Registry ──────────────────────────────────────────────────
# Default stages — these run for every approval request
_DEFAULT_STAGES: list[ApprovalStage] = [
OutputGateStage(),
OpsecStage(),
HumanApprovalStage(),
]
# Custom stages added by agents or plugins
_CUSTOM_STAGES: list[ApprovalStage] = []
def register_stage(stage: ApprovalStage):
"""Register a custom approval stage."""
_CUSTOM_STAGES.append(stage)
_CUSTOM_STAGES.sort(key=lambda s: s.priority)
logger.info("Registered approval stage: %s (priority=%d, weight=%.2f)",
stage.name, stage.priority, stage.weight)
def get_all_stages() -> list[ApprovalStage]:
"""Get all stages sorted by priority."""
all_stages = _DEFAULT_STAGES + _CUSTOM_STAGES
all_stages.sort(key=lambda s: s.priority)
return all_stages
# ─── Aggregation ─────────────────────────────────────────────────────
def run_sync_stages(request: dict, threshold: float = 0.5) -> AggregateResult:
"""Run all synchronous approval stages and aggregate results.
Stages with async=True in metadata are skipped (handled separately).
Short-circuits on any REJECT with weight >= threshold.
Args:
request: dict with at minimum {content, originating_agent, type}
threshold: weighted approve score needed to pass (0.0-1.0)
Returns:
AggregateResult with the decision.
"""
stages = get_all_stages()
results = []
total_approve = 0.0
total_reject = 0.0
total_abstain = 0.0
for stage in stages:
try:
result = stage.check(request)
except Exception as e:
logger.error("Stage %s failed: %s — treating as ABSTAIN", stage.name, e)
result = StageResult(stage.name, Vote.ABSTAIN, 0.0, f"Error: {e}")
results.append(result)
if result.vote == Vote.APPROVE:
total_approve += result.weight
elif result.vote == Vote.REJECT:
total_reject += result.weight
# Short-circuit: absolute veto
if result.weight >= threshold:
return AggregateResult(
approved=False,
total_weight_approve=total_approve,
total_weight_reject=total_reject,
total_weight_abstain=total_abstain,
stage_results=results,
threshold=threshold,
)
else:
total_abstain += result.weight
# Final decision based on non-abstain votes
active_weight = total_approve + total_reject
if active_weight == 0:
# All abstain — pass to async stages (human approval)
approved = False # not yet approved, awaiting human
else:
approved = (total_approve / active_weight) >= threshold
return AggregateResult(
approved=approved,
total_weight_approve=total_approve,
total_weight_reject=total_reject,
total_weight_abstain=total_abstain,
stage_results=results,
threshold=threshold,
)