Some checks failed
CI / lint-and-test (push) Has been cancelled
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>
241 lines
8 KiB
Python
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,
|
|
)
|