Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f65e45895 | ||
|
|
b915386ccb | ||
|
|
e5f8209f57 | ||
|
|
4420b2afcb | ||
|
|
057e915664 | ||
|
|
89ed795e33 | ||
|
|
f18c7f1b51 | ||
|
|
9d2843c6b0 | ||
|
|
da0474ce96 | ||
|
|
e48c93ca28 | ||
|
|
97f8340d9a | ||
|
|
11944ef3f4 | ||
|
|
5cf20128d8 | ||
|
|
7e1649808d | ||
|
|
abe81d7f64 |
@@ -16,6 +16,28 @@ from .agent_observability_store import AgentObservabilityStore
|
||||
store = AgentObservabilityStore()
|
||||
dashboard = AgentObservabilityDashboard(store.load())
|
||||
|
||||
_ROLE_WHITELIST = {"user", "group_admin", "owner"}
|
||||
_authenticator = None # callable() -> {"user_id":..., "role":...} | None, injected by host
|
||||
|
||||
|
||||
def set_authenticator(fn):
|
||||
"""Install a server-side authenticator used to derive the querying tenant.
|
||||
|
||||
The callable returns the authenticated principal (dict with "user_id" and
|
||||
a whitelisted "role") or None for anonymous. The client-supplied role and
|
||||
user/group ids are NOT trusted to escalate scope.
|
||||
"""
|
||||
global _authenticator
|
||||
_authenticator = fn
|
||||
|
||||
|
||||
def _principal():
|
||||
if _authenticator is not None:
|
||||
p = _authenticator()
|
||||
if isinstance(p, dict) and p.get("role") in _ROLE_WHITELIST:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def record_agent_execution(payload: dict[str, Any]):
|
||||
record = AgentExecutionRecord(**payload)
|
||||
@@ -23,11 +45,30 @@ def record_agent_execution(payload: dict[str, Any]):
|
||||
|
||||
|
||||
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||
records = store.query(user_id, group_id, role)
|
||||
# The authenticated principal (tenant) is authoritative; the request params
|
||||
# role/user_id/group_id can never choose owner or another tenant.
|
||||
principal = _principal()
|
||||
pr_user = principal.get("user_id") if principal else None
|
||||
pr_role = principal.get("role", "user") if principal else "user"
|
||||
|
||||
if pr_role == "owner":
|
||||
# Server-verified owner may browse a requested scope.
|
||||
q_user, q_group, q_role = user_id, group_id, "owner"
|
||||
summary_scope = None
|
||||
elif pr_user:
|
||||
# Authenticated non-owner: strictly scoped to own tenant.
|
||||
q_user, q_group, q_role = pr_user, None, "user"
|
||||
summary_scope = pr_user
|
||||
else:
|
||||
# Anonymous: nothing scoped to them.
|
||||
q_user, q_group, q_role = None, None, "user"
|
||||
summary_scope = None
|
||||
|
||||
records = store.query(q_user, q_group, q_role)
|
||||
return {
|
||||
"status": "ok",
|
||||
"records": records,
|
||||
"summary": store.load().summary(user_id if role != "owner" else None),
|
||||
"summary": store.load().summary(summary_scope),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,11 +8,15 @@ metadata are stored for Web UI statistics and audit.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .agent_observability import AgentExecutionRecord, AgentObservability
|
||||
|
||||
# Constrain execution IDs (used as filename) to a single safe path segment.
|
||||
_SAFE_EXECUTION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
|
||||
class AgentObservabilityStore:
|
||||
def __init__(self, root: str = "runtime/agent-observability"):
|
||||
@@ -21,9 +25,17 @@ class AgentObservabilityStore:
|
||||
self.records_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
|
||||
path = self.records_dir / f"{record.execution_id}.json"
|
||||
execution_id = str(record.execution_id or "")
|
||||
if not _SAFE_EXECUTION_ID.fullmatch(execution_id) or ".." in execution_id:
|
||||
raise ValueError(f"invalid execution_id: {execution_id!r}")
|
||||
base = self.records_dir.resolve()
|
||||
target = (self.records_dir / f"{execution_id}.json").resolve()
|
||||
# The whitelist forbids separators/traversal, so the resolved target
|
||||
# must sit directly inside the records dir; verify defensively.
|
||||
if target.parent != base:
|
||||
raise ValueError(f"invalid execution_id: {execution_id!r}")
|
||||
data = record.to_dict()
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
target.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return data
|
||||
|
||||
def load(self) -> AgentObservability:
|
||||
|
||||
@@ -0,0 +1,832 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable online task protocol for external AIOS agents.
|
||||
|
||||
The store is deliberately independent from Git/Gitea mutation. It provides a
|
||||
small durable state machine for publishing work, atomic claim/lease ownership,
|
||||
heartbeat, idempotent result submission, explicit server-side verification,
|
||||
lease recovery, and audit events.
|
||||
|
||||
No plaintext agent credential or task lease token is persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
import uuid
|
||||
|
||||
from aios_capability_contract import has_capabilities, normalize_required_capabilities
|
||||
|
||||
|
||||
TERMINAL_STATES = frozenset({"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED"})
|
||||
ACTIVE_LEASE_STATES = frozenset({"CLAIMED", "RUNNING"})
|
||||
TASK_STATES = frozenset({
|
||||
"QUEUED", "CLAIMED", "RUNNING", "VERIFYING",
|
||||
"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED",
|
||||
})
|
||||
VERIFY_DECISIONS = frozenset({"SUCCEEDED", "NEEDS_REWORK", "FAILED"})
|
||||
|
||||
|
||||
class TaskProtocolError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Principal:
|
||||
kind: str
|
||||
identifier: str
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def iso(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def parse_time(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def digest_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _string_list(value: Iterable[object] | None, field: str, *, max_items: int = 128) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result: list[str] = []
|
||||
for item in value:
|
||||
text = str(item).strip()
|
||||
if not text or len(text) > 160:
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result.append(text)
|
||||
if len(result) > max_items:
|
||||
raise TaskProtocolError(field.upper() + "_TOO_LARGE", 400)
|
||||
return sorted(set(result))
|
||||
|
||||
|
||||
def _json_object(value: object | None, field: str, *, max_bytes: int = 128 * 1024) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, Mapping):
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result = dict(value)
|
||||
if len(canonical_json(result).encode("utf-8")) > max_bytes:
|
||||
raise TaskProtocolError(field.upper() + "_TOO_LARGE", 413)
|
||||
return result
|
||||
|
||||
|
||||
def _criteria(value: object | None) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, list):
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_INVALID", 400)
|
||||
if len(value) > 128:
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_TOO_LARGE", 413)
|
||||
if len(canonical_json(value).encode("utf-8")) > 128 * 1024:
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_TOO_LARGE", 413)
|
||||
return list(value)
|
||||
|
||||
|
||||
class TaskStore:
|
||||
"""SQLite-backed durable task store.
|
||||
|
||||
SQLite BEGIN IMMEDIATE serializes claims so two agents cannot successfully
|
||||
claim the same queued task. WAL mode allows concurrent readers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
now: Callable[[], datetime] = utcnow,
|
||||
token_pepper: str = "",
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.now = now
|
||||
self.token_pepper = token_pepper
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
connection.execute("PRAGMA busy_timeout=10000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
agent_type TEXT NOT NULL,
|
||||
agent_version TEXT NOT NULL,
|
||||
capabilities_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
goal TEXT NOT NULL,
|
||||
instructions TEXT NOT NULL,
|
||||
success_criteria_json TEXT NOT NULL,
|
||||
required_capabilities_json TEXT NOT NULL,
|
||||
constraints_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
priority INTEGER NOT NULL,
|
||||
max_attempts INTEGER NOT NULL,
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
claimed_by TEXT,
|
||||
lease_token_hash TEXT,
|
||||
lease_expires_at TEXT,
|
||||
result_json TEXT,
|
||||
result_digest TEXT,
|
||||
result_idempotency_key TEXT,
|
||||
verification_json TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(claimed_by) REFERENCES agents(agent_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS idempotency (
|
||||
scope TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
payload_digest TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(scope, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT,
|
||||
agent_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_queue
|
||||
ON tasks(status, priority DESC, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_lease
|
||||
ON tasks(status, lease_expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_task
|
||||
ON events(task_id, event_id);
|
||||
"""
|
||||
)
|
||||
|
||||
def _token_hash(self, token: str) -> str:
|
||||
return hashlib.sha256((self.token_pepper + "\0" + token).encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _row_task(row: sqlite3.Row, *, include_result: bool = True) -> dict[str, Any]:
|
||||
value: dict[str, Any] = {
|
||||
"task_id": row["task_id"],
|
||||
"goal": row["goal"],
|
||||
"instructions": row["instructions"],
|
||||
"success_criteria": json.loads(row["success_criteria_json"]),
|
||||
"required_capabilities": json.loads(row["required_capabilities_json"]),
|
||||
"constraints": json.loads(row["constraints_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
"deadline": row["deadline"],
|
||||
"priority": row["priority"],
|
||||
"max_attempts": row["max_attempts"],
|
||||
"attempt": row["attempt"],
|
||||
"status": row["status"],
|
||||
"claimed_by": row["claimed_by"],
|
||||
"lease_expires_at": row["lease_expires_at"],
|
||||
"revision": row["revision"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
if include_result:
|
||||
value["result"] = json.loads(row["result_json"]) if row["result_json"] else None
|
||||
value["verification"] = (
|
||||
json.loads(row["verification_json"]) if row["verification_json"] else None
|
||||
)
|
||||
return value
|
||||
|
||||
def _event(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
event_type: str,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
payload: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at)"
|
||||
" VALUES(?,?,?,?,?)",
|
||||
(task_id, agent_id, event_type, canonical_json(dict(payload or {})), iso(self.now())),
|
||||
)
|
||||
|
||||
def register_agent(
|
||||
self,
|
||||
*,
|
||||
agent_id: str,
|
||||
agent_type: str,
|
||||
agent_version: str,
|
||||
capabilities: Iterable[object],
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
agent_id = agent_id.strip()
|
||||
agent_type = agent_type.strip()
|
||||
agent_version = agent_version.strip()
|
||||
if not agent_id or len(agent_id) > 160 or not agent_type or not agent_version:
|
||||
raise TaskProtocolError("AGENT_IDENTITY_INVALID", 400)
|
||||
caps = _string_list(capabilities, "capabilities")
|
||||
meta = _json_object(metadata, "metadata")
|
||||
token = "agt_" + secrets.token_urlsafe(32)
|
||||
now = iso(self.now())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"INSERT INTO agents(agent_id,agent_type,agent_version,capabilities_json,"
|
||||
"metadata_json,token_hash,status,created_at,last_seen_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
agent_id,
|
||||
agent_type,
|
||||
agent_version,
|
||||
canonical_json(caps),
|
||||
canonical_json(meta),
|
||||
self._token_hash(token),
|
||||
"ACTIVE",
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"AGENT_REGISTERED",
|
||||
agent_id=agent_id,
|
||||
payload={"agent_type": agent_type, "agent_version": agent_version},
|
||||
)
|
||||
connection.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise TaskProtocolError("AGENT_ALREADY_EXISTS", 409) from exc
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"agent_type": agent_type,
|
||||
"agent_version": agent_version,
|
||||
"capabilities": caps,
|
||||
"agent_token": token,
|
||||
"credential_returned_once": True,
|
||||
}
|
||||
|
||||
def authenticate_agent(self, token: str) -> dict[str, Any]:
|
||||
if not token:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
token_hash = self._token_hash(token)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM agents WHERE token_hash=? AND status='ACTIVE'",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
connection.execute(
|
||||
"UPDATE agents SET last_seen_at=? WHERE agent_id=?",
|
||||
(iso(self.now()), row["agent_id"]),
|
||||
)
|
||||
return {
|
||||
"agent_id": row["agent_id"],
|
||||
"agent_type": row["agent_type"],
|
||||
"agent_version": row["agent_version"],
|
||||
"capabilities": json.loads(row["capabilities_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
}
|
||||
|
||||
def _idempotent_lookup(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
scope: str,
|
||||
key: str,
|
||||
payload_digest: str,
|
||||
) -> dict[str, Any] | None:
|
||||
row = connection.execute(
|
||||
"SELECT payload_digest,response_json FROM idempotency WHERE scope=? AND key=?",
|
||||
(scope, key),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if not hmac.compare_digest(row["payload_digest"], payload_digest):
|
||||
raise TaskProtocolError("IDEMPOTENCY_CONFLICT", 409)
|
||||
return json.loads(row["response_json"])
|
||||
|
||||
def _idempotent_store(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
scope: str,
|
||||
key: str,
|
||||
payload_digest: str,
|
||||
response: Mapping[str, Any],
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency(scope,key,payload_digest,response_json,created_at)"
|
||||
" VALUES(?,?,?,?,?)",
|
||||
(scope, key, payload_digest, canonical_json(dict(response)), iso(self.now())),
|
||||
)
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
goal: str,
|
||||
instructions: str,
|
||||
success_criteria: object | None,
|
||||
required_capabilities: Iterable[object] | None = None,
|
||||
constraints: Mapping[str, Any] | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
deadline: str | None = None,
|
||||
priority: int = 0,
|
||||
max_attempts: int = 3,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
task_id = (task_id or ("task-" + uuid.uuid4().hex)).strip()
|
||||
goal = goal.strip()
|
||||
instructions = instructions.strip()
|
||||
idempotency_key = idempotency_key.strip()
|
||||
if not task_id or len(task_id) > 180 or not goal or not instructions:
|
||||
raise TaskProtocolError("TASK_BINDING_INVALID", 400)
|
||||
if not idempotency_key or len(idempotency_key) > 160:
|
||||
raise TaskProtocolError("IDEMPOTENCY_KEY_INVALID", 400)
|
||||
if not isinstance(priority, int) or isinstance(priority, bool) or not -100 <= priority <= 100:
|
||||
raise TaskProtocolError("PRIORITY_INVALID", 400)
|
||||
if not isinstance(max_attempts, int) or isinstance(max_attempts, bool) or not 1 <= max_attempts <= 20:
|
||||
raise TaskProtocolError("MAX_ATTEMPTS_INVALID", 400)
|
||||
parsed_deadline = parse_time(deadline)
|
||||
if parsed_deadline and parsed_deadline <= self.now():
|
||||
raise TaskProtocolError("DEADLINE_INVALID", 400)
|
||||
criteria = _criteria(success_criteria)
|
||||
required = _string_list(required_capabilities, "required_capabilities")
|
||||
normalized_required = normalize_required_capabilities(required)
|
||||
if any(value.startswith("exact:") for value in normalized_required):
|
||||
raise TaskProtocolError("TASK_CAPABILITY_UNKNOWN", 403)
|
||||
constraints_value = _json_object(constraints, "constraints")
|
||||
metadata_value = _json_object(metadata, "metadata")
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"goal": goal,
|
||||
"instructions": instructions,
|
||||
"success_criteria": criteria,
|
||||
"required_capabilities": required,
|
||||
"constraints": constraints_value,
|
||||
"metadata": metadata_value,
|
||||
"deadline": iso(parsed_deadline) if parsed_deadline else None,
|
||||
"priority": priority,
|
||||
"max_attempts": max_attempts,
|
||||
}
|
||||
payload_digest = digest_json(payload)
|
||||
now = iso(self.now())
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
replay = self._idempotent_lookup(
|
||||
connection,
|
||||
scope="create_task",
|
||||
key=idempotency_key,
|
||||
payload_digest=payload_digest,
|
||||
)
|
||||
if replay is not None:
|
||||
connection.commit()
|
||||
return {**replay, "idempotent": True}
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO tasks(task_id,goal,instructions,success_criteria_json,"
|
||||
"required_capabilities_json,constraints_json,metadata_json,deadline,priority,"
|
||||
"max_attempts,attempt,status,revision,created_at,updated_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
task_id,
|
||||
goal,
|
||||
instructions,
|
||||
canonical_json(criteria),
|
||||
canonical_json(required),
|
||||
canonical_json(constraints_value),
|
||||
canonical_json(metadata_value),
|
||||
payload["deadline"],
|
||||
priority,
|
||||
max_attempts,
|
||||
0,
|
||||
"QUEUED",
|
||||
1,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise TaskProtocolError("TASK_ID_CONFLICT", 409) from exc
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
result = self._row_task(row)
|
||||
self._idempotent_store(
|
||||
connection,
|
||||
scope="create_task",
|
||||
key=idempotency_key,
|
||||
payload_digest=payload_digest,
|
||||
response=result,
|
||||
)
|
||||
self._event(connection, "TASK_CREATED", task_id=task_id, payload={"priority": priority})
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
def _expire_locked(self, connection: sqlite3.Connection) -> None:
|
||||
now_dt = self.now()
|
||||
now = iso(now_dt)
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM tasks WHERE status IN ('QUEUED','CLAIMED','RUNNING')"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
deadline = parse_time(row["deadline"])
|
||||
if deadline is not None and deadline <= now_dt:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='EXPIRED',claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(now, row["task_id"]),
|
||||
)
|
||||
self._event(connection, "TASK_EXPIRED", task_id=row["task_id"])
|
||||
continue
|
||||
if row["status"] in ACTIVE_LEASE_STATES:
|
||||
lease_expires = parse_time(row["lease_expires_at"])
|
||||
if lease_expires is not None and lease_expires <= now_dt:
|
||||
if row["attempt"] >= row["max_attempts"]:
|
||||
next_state = "FAILED"
|
||||
else:
|
||||
next_state = "QUEUED"
|
||||
if next_state == "FAILED":
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(next_state, now, row["task_id"]),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(next_state, now, row["task_id"]),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"LEASE_EXPIRED",
|
||||
task_id=row["task_id"],
|
||||
agent_id=row["claimed_by"],
|
||||
payload={"next_state": next_state, "attempt": row["attempt"]},
|
||||
)
|
||||
|
||||
def expire_leases(self) -> int:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
before = connection.total_changes
|
||||
self._expire_locked(connection)
|
||||
changed = connection.total_changes - before
|
||||
connection.commit()
|
||||
return changed
|
||||
|
||||
def poll_tasks(self, *, agent_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
if not 1 <= int(limit) <= 100:
|
||||
raise TaskProtocolError("LIMIT_INVALID", 400)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._expire_locked(connection)
|
||||
agent = connection.execute(
|
||||
"SELECT capabilities_json FROM agents WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if agent is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
capabilities = json.loads(agent["capabilities_json"])
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM tasks WHERE status='QUEUED' ORDER BY priority DESC,created_at ASC"
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
required = json.loads(row["required_capabilities_json"])
|
||||
if has_capabilities(capabilities, required):
|
||||
result.append(self._row_task(row, include_result=False))
|
||||
if len(result) >= int(limit):
|
||||
break
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
def _agent_capabilities(self, connection: sqlite3.Connection, agent_id: str) -> set[str]:
|
||||
row = connection.execute(
|
||||
"SELECT capabilities_json FROM agents WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
return set(json.loads(row["capabilities_json"]))
|
||||
|
||||
def claim_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_seconds: int = 300,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(lease_seconds, int) or isinstance(lease_seconds, bool) or not 30 <= lease_seconds <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
lease_token = "lease_" + secrets.token_urlsafe(32)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] != "QUEUED":
|
||||
raise TaskProtocolError("TASK_NOT_CLAIMABLE", 409)
|
||||
capabilities = self._agent_capabilities(connection, agent_id)
|
||||
required = json.loads(row["required_capabilities_json"])
|
||||
if not has_capabilities(capabilities, required):
|
||||
raise TaskProtocolError("AGENT_CAPABILITY_MISMATCH", 403)
|
||||
attempt = int(row["attempt"]) + 1
|
||||
if attempt > int(row["max_attempts"]):
|
||||
raise TaskProtocolError("TASK_ATTEMPTS_EXHAUSTED", 409)
|
||||
expires = self.now() + timedelta(seconds=lease_seconds)
|
||||
now = iso(self.now())
|
||||
updated = connection.execute(
|
||||
"UPDATE tasks SET status='CLAIMED',claimed_by=?,lease_token_hash=?,"
|
||||
"lease_expires_at=?,attempt=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=? AND status='QUEUED'",
|
||||
(
|
||||
agent_id,
|
||||
self._token_hash(lease_token),
|
||||
iso(expires),
|
||||
attempt,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise TaskProtocolError("TASK_CLAIM_CONFLICT", 409)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_CLAIMED",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"attempt": attempt, "lease_expires_at": iso(expires)},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return {
|
||||
**self._row_task(row, include_result=False),
|
||||
"lease_token": lease_token,
|
||||
"lease_token_returned_once": True,
|
||||
}
|
||||
|
||||
def _validate_lease(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
allowed_states: frozenset[str] = ACTIVE_LEASE_STATES,
|
||||
) -> sqlite3.Row:
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] not in allowed_states:
|
||||
raise TaskProtocolError("TASK_LEASE_NOT_ACTIVE", 409)
|
||||
if row["claimed_by"] != agent_id:
|
||||
raise TaskProtocolError("TASK_LEASE_OWNER_MISMATCH", 403)
|
||||
if not row["lease_token_hash"] or not hmac.compare_digest(
|
||||
row["lease_token_hash"], self._token_hash(lease_token)
|
||||
):
|
||||
raise TaskProtocolError("TASK_LEASE_TOKEN_INVALID", 403)
|
||||
expires = parse_time(row["lease_expires_at"])
|
||||
if expires is None or expires <= self.now():
|
||||
raise TaskProtocolError("TASK_LEASE_EXPIRED", 409)
|
||||
return row
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
progress: Mapping[str, Any] | None = None,
|
||||
lease_seconds: int = 300,
|
||||
) -> dict[str, Any]:
|
||||
if not 30 <= int(lease_seconds) <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
progress_value = _json_object(progress, "progress", max_bytes=64 * 1024)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._validate_lease(
|
||||
connection,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
lease_token=lease_token,
|
||||
)
|
||||
expires = self.now() + timedelta(seconds=int(lease_seconds))
|
||||
now = iso(self.now())
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='RUNNING',lease_expires_at=?,revision=revision+1,"
|
||||
"updated_at=? WHERE task_id=?",
|
||||
(iso(expires), now, task_id),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_HEARTBEAT",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"progress": progress_value, "lease_expires_at": iso(expires)},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(row, include_result=False)
|
||||
|
||||
def submit_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
idempotency_key: str,
|
||||
result: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
idempotency_key = idempotency_key.strip()
|
||||
if not idempotency_key or len(idempotency_key) > 160:
|
||||
raise TaskProtocolError("IDEMPOTENCY_KEY_INVALID", 400)
|
||||
result_value = _json_object(result, "result", max_bytes=1024 * 1024)
|
||||
result_digest = digest_json(result_value)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["result_idempotency_key"] == idempotency_key:
|
||||
if not row["result_digest"] or not hmac.compare_digest(row["result_digest"], result_digest):
|
||||
raise TaskProtocolError("IDEMPOTENCY_CONFLICT", 409)
|
||||
connection.commit()
|
||||
return {**self._row_task(row), "idempotent": True}
|
||||
self._validate_lease(
|
||||
connection,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
lease_token=lease_token,
|
||||
)
|
||||
now = iso(self.now())
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='VERIFYING',result_json=?,result_digest=?,"
|
||||
"result_idempotency_key=?,lease_token_hash=NULL,lease_expires_at=NULL,"
|
||||
"revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(
|
||||
canonical_json(result_value),
|
||||
result_digest,
|
||||
idempotency_key,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"RESULT_SUBMITTED",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"result_digest": result_digest, "idempotency_key": idempotency_key},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(row)
|
||||
|
||||
def verify_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
decision: str,
|
||||
verification: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
decision = decision.strip().upper()
|
||||
if decision not in VERIFY_DECISIONS:
|
||||
raise TaskProtocolError("VERIFICATION_DECISION_INVALID", 400)
|
||||
verification_value = _json_object(verification, "verification")
|
||||
verification_value = {"decision": decision, **verification_value}
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] != "VERIFYING":
|
||||
raise TaskProtocolError("TASK_NOT_VERIFYING", 409)
|
||||
if decision == "SUCCEEDED":
|
||||
next_state = "SUCCEEDED"
|
||||
elif decision == "FAILED":
|
||||
next_state = "FAILED"
|
||||
elif int(row["attempt"]) >= int(row["max_attempts"]):
|
||||
next_state = "FAILED"
|
||||
verification_value["reason"] = verification_value.get(
|
||||
"reason", "MAX_ATTEMPTS_EXHAUSTED"
|
||||
)
|
||||
else:
|
||||
next_state = "QUEUED"
|
||||
now = iso(self.now())
|
||||
if next_state in {"SUCCEEDED", "FAILED"}:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,verification_json=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=?",
|
||||
(next_state, canonical_json(verification_value), now, task_id),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,verification_json=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=?",
|
||||
(next_state, canonical_json(verification_value), now, task_id),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_VERIFIED",
|
||||
task_id=task_id,
|
||||
agent_id=row["claimed_by"],
|
||||
payload={"decision": decision, "next_state": next_state},
|
||||
)
|
||||
updated = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(updated)
|
||||
|
||||
def cancel_task(self, *, task_id: str, reason: str = "") -> dict[str, Any]:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] in TERMINAL_STATES:
|
||||
connection.commit()
|
||||
return self._row_task(row)
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='CANCELLED',lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(iso(self.now()), task_id),
|
||||
)
|
||||
self._event(connection, "TASK_CANCELLED", task_id=task_id, payload={"reason": reason})
|
||||
updated = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(updated)
|
||||
|
||||
def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
self.expire_leases()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
return self._row_task(row)
|
||||
|
||||
def get_events(self, task_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM events WHERE task_id=? ORDER BY event_id DESC LIMIT ?",
|
||||
(task_id, max(1, min(int(limit), 1000))),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": row["event_id"],
|
||||
"task_id": row["task_id"],
|
||||
"agent_id": row["agent_id"],
|
||||
"event_type": row["event_type"],
|
||||
"payload": json.loads(row["payload_json"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in reversed(rows)
|
||||
]
|
||||
@@ -6,7 +6,7 @@ The built-in host adapter is READ_ONLY. Mutating operations require separately
|
||||
registered governed adapters with receiver fencing/readback, never arbitrary argv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime,timezone
|
||||
import base64
|
||||
@@ -18,7 +18,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget,EvidenceEnvelope
|
||||
from task_plane.errors import TaskPlaneHTTPError
|
||||
from aios_gitops_job_controller import GitOpsJobController,JobStore,JobState,JobError
|
||||
from .evidence import ReceiptSigner,canonical,digest
|
||||
@@ -69,6 +69,7 @@ class BaselineService:
|
||||
self.store.conn.execute('''CREATE TABLE IF NOT EXISTS baseline_worker_policy(
|
||||
worker_id TEXT PRIMARY KEY,policy_json TEXT NOT NULL)''')
|
||||
self.store.probe_attestation_verifier=self._verify_probe
|
||||
self.store.evidence_attestation_verifier=self._verify_task_evidence
|
||||
|
||||
def _user(self,identity):
|
||||
if not isinstance(identity,Identity) or identity.role not in {'user','owner'}:
|
||||
@@ -379,11 +380,39 @@ class BaselineService:
|
||||
|
||||
def _close_task(self,task,claim,envelope):
|
||||
if not self._receipt_valid(task,envelope): raise ServiceError('RESULT_ATTESTATION_INVALID',403)
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
self.store.complete_claim(task.task_id,claim.claim_id,'COMPLETED',envelope,
|
||||
evidence_entries=[('diagnostic',envelope,claim.worker_identity)])
|
||||
with self.store._transaction():
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
evidence=EvidenceEnvelope.create(task_id=task.task_id,claim_id=claim.claim_id,
|
||||
kind='diagnostic',worker_identity=claim.worker_identity,payload=envelope,
|
||||
previous_evidence_hash=self.store._last_evidence_hash(task.task_id),
|
||||
attestation_ref='baseline-machine:'+self.signer.key_id)
|
||||
proof=canonical({'domain':'aios.baseline.task-evidence.v1',
|
||||
'key_id':self.signer.key_id,'evidence':evidence.digest_payload()})
|
||||
evidence=replace(evidence,signature=base64.b64encode(self.signer.private_key.sign(proof)).decode())
|
||||
self.store.complete_claim_with_evidence_and_event(task.task_id,claim.claim_id,
|
||||
'COMPLETED',envelope,[evidence])
|
||||
|
||||
def _verify_task_evidence(self,evidence):
|
||||
"""Verify the TaskPlane wrapper and its separately signed operation receipt."""
|
||||
try:
|
||||
if not isinstance(evidence,EvidenceEnvelope) or evidence.kind!='diagnostic': return False
|
||||
prefix='baseline-machine:'
|
||||
if not evidence.attestation_ref or not evidence.attestation_ref.startswith(prefix): return False
|
||||
key_id=evidence.attestation_ref[len(prefix):]
|
||||
key=self.signer.trusted_public_keys.get(key_id)
|
||||
if key is None or not evidence.signature: return False
|
||||
proof=canonical({'domain':'aios.baseline.task-evidence.v1',
|
||||
'key_id':key_id,'evidence':evidence.digest_payload()})
|
||||
key.verify(base64.b64decode(evidence.signature,validate=True),proof)
|
||||
claim=self.store.get_claim(evidence.claim_id)
|
||||
task=self.store.get_task(evidence.task_id)
|
||||
if claim.task_id!=task.task_id or claim.worker_identity!=evidence.worker_identity: return False
|
||||
if claim.operation_digest!=task.task_plan.operation_digest(): return False
|
||||
return self._receipt_valid(task,evidence.payload)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def cancel_task(self,identity,task_id):
|
||||
self._user(identity)
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any
|
||||
@@ -13,6 +15,29 @@ class RecoveryEvidenceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
# Relative evidence path: a safe relative path, never absolute, never with
|
||||
# traversal segments, never with backslash separators.
|
||||
_SAFE_EVIDENCE_REL = re.compile(r"^[A-Za-z0-9_./@-]+$")
|
||||
|
||||
|
||||
def _contained_evidence(path: Path, base: Path) -> Path:
|
||||
"""Resolve an evidence path and guarantee it stays under `base`."""
|
||||
raw = str(path)
|
||||
if (
|
||||
os.path.isabs(raw)
|
||||
or "\\" in raw
|
||||
or raw.startswith("/")
|
||||
or ".." in Path(raw).parts
|
||||
or not _SAFE_EVIDENCE_REL.fullmatch(raw)
|
||||
):
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_PATH_INVALID")
|
||||
base_resolved = base.resolve()
|
||||
target = (base_resolved / raw).resolve()
|
||||
if base_resolved != target and not str(target).startswith(str(base_resolved) + os.sep):
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_PATH_ESCAPE")
|
||||
return target
|
||||
|
||||
|
||||
def _digest(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
@@ -81,10 +106,11 @@ def load_recovered_authorization(path: str | Path) -> dict[str, Any]:
|
||||
evidence = receipt["evidence_files"]
|
||||
if not isinstance(evidence, list) or not evidence:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_LIST_INVALID")
|
||||
base = receipt_path.resolve().parent
|
||||
for item in evidence:
|
||||
if not isinstance(item, dict) or set(item) != {"path", "sha256"}:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_ENTRY_INVALID")
|
||||
target = Path(item["path"])
|
||||
target = _contained_evidence(Path(item["path"]), base)
|
||||
if _digest(target) != item["sha256"]:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_DIGEST_MISMATCH")
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Canonical fail-closed capability semantics for formal AgentTask eligibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
# These aliases are the verified C4 contract captured in
|
||||
# ``qa/capability-contract.txt``. Unknown task names become exact requirements;
|
||||
# unknown worker names are never granted.
|
||||
_TASK_ALIASES: dict[str, frozenset[str]] = {
|
||||
"release:candidate": frozenset({"release:candidate"}),
|
||||
"repository_read": frozenset({"repo.read"}),
|
||||
"git.read": frozenset({"repo.read"}),
|
||||
"repo.read": frozenset({"repo.read"}),
|
||||
"test_execution": frozenset({"test.execute"}),
|
||||
"git.test": frozenset({"test.execute"}),
|
||||
"test.execute": frozenset({"test.execute"}),
|
||||
"governed_git_write": frozenset({"governed.git.write.branch", "governed.git.write.file"}),
|
||||
"governed.git.write": frozenset({"governed.git.write.branch", "governed.git.write.file"}),
|
||||
"governed.write.branch": frozenset({"governed.git.write.branch"}),
|
||||
"governed.write.file": frozenset({"governed.git.write.file"}),
|
||||
"shell": frozenset({"runtime.shell"}),
|
||||
"git": frozenset({"runtime.git"}),
|
||||
"python": frozenset({"runtime.python"}),
|
||||
"code.read": frozenset({"legacy.code.read"}),
|
||||
"code.review": frozenset({"legacy.code.review"}),
|
||||
"code.write": frozenset({"legacy.code.write"}),
|
||||
"code.write.patch": frozenset({"legacy.code.write.patch"}),
|
||||
"test.python": frozenset({"legacy.test.python"}),
|
||||
"test.shell.allowlisted": frozenset({"legacy.test.shell.allowlisted"}),
|
||||
"git.commit": frozenset({"legacy.git.commit"}),
|
||||
"git.diff.read": frozenset({"legacy.git.diff.read"}),
|
||||
"git.force_push": frozenset({"legacy.git.force_push"}),
|
||||
"deploy.production": frozenset({"legacy.deploy.production"}),
|
||||
"secret.transform": frozenset({"legacy.secret.transform"}),
|
||||
"secure_file_transformer": frozenset({"legacy.secure_file_transformer"}),
|
||||
"runtime.shadow": frozenset({"legacy.runtime.shadow"}),
|
||||
"review.security": frozenset({"legacy.review.security"}),
|
||||
"review.api-contract": frozenset({"legacy.review.api-contract"}),
|
||||
"gpu.train": frozenset({"legacy.gpu.train"}),
|
||||
}
|
||||
|
||||
_WORKER_ALIASES = {
|
||||
key: value for key, value in _TASK_ALIASES.items()
|
||||
if key not in {"release:candidate", "governed_git_write", "governed.git.write"}
|
||||
}
|
||||
_WORKER_ALIASES.update({
|
||||
"release:candidate": frozenset({"release:candidate"}),
|
||||
"governed.git.write.branch": frozenset({"governed.git.write.branch"}),
|
||||
"governed.git.write.file": frozenset({"governed.git.write.file"}),
|
||||
})
|
||||
|
||||
_EXACT_LIFECYCLE_CAPABILITIES = frozenset(
|
||||
{
|
||||
"agent-task:control",
|
||||
"governed.task.create",
|
||||
"governed.write.files",
|
||||
"production.plan.apply",
|
||||
"production.promote",
|
||||
"production.rollback",
|
||||
"task.principal.issue",
|
||||
"testing",
|
||||
"verification",
|
||||
"writer.lease",
|
||||
}
|
||||
)
|
||||
for _capability in _EXACT_LIFECYCLE_CAPABILITIES:
|
||||
_TASK_ALIASES[_capability] = frozenset({_capability})
|
||||
_WORKER_ALIASES[_capability] = frozenset({_capability})
|
||||
_UNKNOWN_PREFIX = "exact:"
|
||||
|
||||
|
||||
def _clean(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("capability must be a string")
|
||||
text = value.strip().lower()
|
||||
if not text:
|
||||
raise ValueError("capability must not be empty")
|
||||
return text
|
||||
|
||||
|
||||
def normalize_required_capabilities(values: Iterable[object]) -> frozenset[str]:
|
||||
result: set[str] = set()
|
||||
for value in values:
|
||||
text = _clean(value)
|
||||
if "*" in text:
|
||||
result.add(_UNKNOWN_PREFIX + text)
|
||||
else:
|
||||
result.update(_TASK_ALIASES.get(text, frozenset({_UNKNOWN_PREFIX + text})))
|
||||
return frozenset(result)
|
||||
|
||||
|
||||
def normalize_worker_capabilities(values: Iterable[object]) -> frozenset[str]:
|
||||
result: set[str] = set()
|
||||
for value in values:
|
||||
text = _clean(value)
|
||||
if "*" in text:
|
||||
continue
|
||||
mapped = _WORKER_ALIASES.get(text)
|
||||
if mapped is not None:
|
||||
result.update(mapped)
|
||||
if {"governed.git.write.branch", "governed.git.write.file"}.issubset(result):
|
||||
result.add("governed.git.write")
|
||||
return frozenset(result)
|
||||
|
||||
|
||||
def has_capabilities(worker_capabilities: Iterable[object], required_capabilities: Iterable[object]) -> bool:
|
||||
required = normalize_required_capabilities(required_capabilities)
|
||||
effective = normalize_worker_capabilities(worker_capabilities)
|
||||
if any(value.startswith(_UNKNOWN_PREFIX) for value in required):
|
||||
return False
|
||||
return required.issubset(effective)
|
||||
|
||||
|
||||
def required_capabilities_subset(granted_requirements: Iterable[object], required_requirements: Iterable[object]) -> bool:
|
||||
granted = normalize_required_capabilities(granted_requirements)
|
||||
required = normalize_required_capabilities(required_requirements)
|
||||
if any(value.startswith(_UNKNOWN_PREFIX) for value in granted | required):
|
||||
return False
|
||||
return required.issubset(granted)
|
||||
|
||||
|
||||
def normalized_capability_matrix(worker_capabilities: Iterable[object], required_capabilities: Iterable[object]) -> dict[str, object]:
|
||||
worker_values = tuple(worker_capabilities)
|
||||
required_values = tuple(required_capabilities)
|
||||
worker = normalize_worker_capabilities(worker_values)
|
||||
required = normalize_required_capabilities(required_values)
|
||||
return {
|
||||
"worker_effective": sorted(worker),
|
||||
"task_required": sorted(required),
|
||||
"eligible": has_capabilities(worker_values, required_values),
|
||||
}
|
||||
@@ -110,6 +110,11 @@ SECRET_VAL_RE = re.compile(
|
||||
r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})'
|
||||
)
|
||||
|
||||
# Control-plane file read: enforce auth, strict identifier, containment and a
|
||||
# size cap on the command-detail read exposed by /admin/ai-evolution/api/data.
|
||||
MAX_COMMAND_READ_BYTES = 1024 * 1024
|
||||
_COMMAND_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\.md$")
|
||||
|
||||
READ_ONLY_GET_PATHS = frozenset({
|
||||
'/', '/health', '/api/health', '/api/openapi.yaml',
|
||||
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
|
||||
@@ -1042,12 +1047,25 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
||||
commands.append(f.name)
|
||||
command_detail=None
|
||||
if cmd_filter:
|
||||
actor = self.actor()
|
||||
if not actor['ok']:
|
||||
return self.sendj({'error':'unauthorized','hint':'Bearer token required'},401)
|
||||
cmd = str(cmd_filter or '')
|
||||
if not _COMMAND_FILENAME_RE.fullmatch(cmd) or '..' in cmd:
|
||||
return self.sendj({'command':cmd_filter,'error':'unsafe_command'},400)
|
||||
for d in cmd_dirs:
|
||||
if d.exists():
|
||||
fp=d/cmd_filter
|
||||
if fp.exists():
|
||||
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
||||
break
|
||||
if not d.exists():
|
||||
continue
|
||||
base = d.resolve()
|
||||
fp = (base / cmd).resolve()
|
||||
# Strict containment: must resolve directly inside one of the
|
||||
# allowed command directories.
|
||||
if fp.parent != base or not fp.is_file():
|
||||
continue
|
||||
if fp.stat().st_size > MAX_COMMAND_READ_BYTES:
|
||||
return self.sendj({'command':cmd_filter,'error':'command_too_large'},413)
|
||||
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
||||
break
|
||||
if not command_detail:
|
||||
command_detail={'command':cmd_filter,'error':'not_found'}
|
||||
return self.sendj(command_detail)
|
||||
|
||||
@@ -1342,21 +1342,39 @@ def main(
|
||||
BridgeHandler.principal_verifier = task_principal_verifier
|
||||
if task_admission_handlers is None:
|
||||
admission_runtime = None
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_environment,
|
||||
source_activation = os.environ.get("AIOS_SOURCE_PUBLICATION_ACTIVATION_FILE", "").strip()
|
||||
try:
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_environment,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_environment(
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(activation_raw),
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
except TaskAdmissionError as exc:
|
||||
if not source_activation or str(exc) != "TASK_ADMISSION_EPOCH_EXPIRED":
|
||||
raise
|
||||
from source_publication_runtime import expired_admission_runtime
|
||||
admission_runtime = expired_admission_runtime()
|
||||
if source_activation:
|
||||
from source_publication_runtime import (
|
||||
AdmissionRuntimeRouter, build_source_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_environment(
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(activation_raw),
|
||||
post_admission=runtime.project_admitted_task,
|
||||
if admission_runtime is None or task_principal_verifier is not None:
|
||||
raise RuntimeError("SOURCE_ACTIVATION_ORDINARY_RUNTIME_REQUIRED")
|
||||
source_runtime = build_source_runtime_from_activation_file(
|
||||
Path(source_activation), post_admission=runtime.project_admitted_task,
|
||||
ordinary_activation_file=Path(activation_raw),
|
||||
)
|
||||
admission_runtime = AdmissionRuntimeRouter(admission_runtime, source_runtime).bundle()
|
||||
if admission_runtime is not None:
|
||||
task_admission_handlers = admission_runtime.handlers
|
||||
if BridgeHandler.principal_verifier is None:
|
||||
|
||||
@@ -664,6 +664,23 @@ class GitOpsJobController:
|
||||
raise JobError("LEASE_DURATION_EXCEEDED")
|
||||
return _iso_utc(deadline)
|
||||
|
||||
@staticmethod
|
||||
def internal_job_id_for_idempotency_key(client_idempotency_key: str) -> str:
|
||||
"""Return the durable Job ID for a client idempotency key."""
|
||||
if (
|
||||
not isinstance(client_idempotency_key, str)
|
||||
or not client_idempotency_key
|
||||
or len(client_idempotency_key) > 128
|
||||
or not all(
|
||||
character.isalnum() or character in "._:-"
|
||||
for character in client_idempotency_key
|
||||
)
|
||||
):
|
||||
raise JobError("IDEMPOTENCY_KEY_INVALID")
|
||||
return "job-" + sha256_json({
|
||||
"client_idempotency_key": client_idempotency_key,
|
||||
})[:32]
|
||||
|
||||
@staticmethod
|
||||
def _binding(request: Mapping[str, Any]) -> dict[str, str]:
|
||||
if request.get("schema") == "aios.operation-job.v1":
|
||||
@@ -767,7 +784,12 @@ class GitOpsJobController:
|
||||
key = sha256_json({"client_idempotency_key": client_idempotency_key})
|
||||
else:
|
||||
key = sha256_json(binding)
|
||||
job_id = "job-" + key[:32]
|
||||
job_id = (
|
||||
self.internal_job_id_for_idempotency_key(client_idempotency_key)
|
||||
if binding.get("schema") != "aios.operation-job.v1"
|
||||
and client_idempotency_key is not None
|
||||
else "job-" + key[:32]
|
||||
)
|
||||
durable_request = dict(binding)
|
||||
if payload_ref is not None:
|
||||
durable_request.update({"payload_ref": str(payload_ref), "payload_size": int(payload_size)})
|
||||
|
||||
@@ -57,10 +57,36 @@ def seed_base_models():
|
||||
if not one('SELECT * FROM gitops_versions WHERE version=?', ('V1R1C0',)):
|
||||
exec_sql('INSERT INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)', ('V1R1C0','V1R1C0','R','MultiUser runtime selector + GitOps + custom LLM priority registry','ready','system',now()))
|
||||
|
||||
_ROLE_WHITELIST = {'owner', 'admin', 'user'}
|
||||
def token_identity(token: str):
|
||||
"""Resolve the server-authoritative identity bound to a token.
|
||||
|
||||
Format of each TOKEN_FILE line (one per line, ':'-separated):
|
||||
<token> -> authenticated principal, role 'user'
|
||||
<token>:<user>:<role> -> mapped principal; role restricted to owner/admin/user
|
||||
Identity NEVER comes from a client-supplied header. In local dev mode the
|
||||
DEV_ALLOW flag maps a fixed dev principal (must be explicitly enabled).
|
||||
"""
|
||||
if DEV_ALLOW:
|
||||
return {'username': 'dev-owner', 'role': 'owner'}
|
||||
if not token or not TOKEN_FILE.exists():
|
||||
return None
|
||||
for line in TOKEN_FILE.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = [x.strip() for x in line.split(':')]
|
||||
if not parts or parts[0] != token:
|
||||
continue
|
||||
if len(parts) >= 3:
|
||||
role = parts[2] if parts[2] in _ROLE_WHITELIST else 'user'
|
||||
return {'username': safe_name(parts[1]), 'role': role}
|
||||
return {'username': 'user-%s' % token[:8], 'role': 'user'}
|
||||
return None
|
||||
|
||||
def valid_token(token: str) -> bool:
|
||||
if DEV_ALLOW: return True
|
||||
if not token or not TOKEN_FILE.exists(): return False
|
||||
return token.strip() in [x.strip() for x in TOKEN_FILE.read_text().splitlines() if x.strip()]
|
||||
return token_identity(token) is not None
|
||||
def git_cmd(args, check=False):
|
||||
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
||||
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git command failed')
|
||||
@@ -90,8 +116,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
server_version = 'AIOSGitOpsModelAPI/V1R1C0'
|
||||
def actor(self):
|
||||
auth = self.headers.get('Authorization',''); token = auth.replace('Bearer ', '').strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
||||
ok = valid_token(token); user = self.headers.get('X-AIOS-User') or ('dev-owner' if DEV_ALLOW else 'anonymous'); role = self.headers.get('X-AIOS-Role') or ('owner' if DEV_ALLOW else 'user')
|
||||
return {'username': safe_name(user), 'role': role, 'token_ok': ok}
|
||||
ident = token_identity(token)
|
||||
if ident is None:
|
||||
return {'username': 'anonymous', 'role': 'user', 'token_ok': False}
|
||||
return {'username': ident['username'], 'role': ident['role'], 'token_ok': True}
|
||||
def require_write(self, roles=None):
|
||||
a = self.actor()
|
||||
if not a['token_ok']: self.send_json({'error':'unauthorized','hint':'write APIs require Bearer token or AIOS_GITOPS_DEV_ALLOW=1'}, 401); return None
|
||||
|
||||
@@ -0,0 +1,890 @@
|
||||
"""Read-only four-authority evidence provider for one canonical C04 attachment.
|
||||
|
||||
This module never creates, claims, resumes, reconciles, or mutates a Task or
|
||||
Job. The expired Owner epoch is verified as historical provenance only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from app.aios_gitops_job_identity import legacy_task_storage_key, task_storage_key
|
||||
from app.aios_single_host_authority import (
|
||||
DEFAULT_OWNER_SIGNER_ID,
|
||||
OWNER_NAMESPACE,
|
||||
RegistryOwnerApprovalVerifier,
|
||||
SignerRegistry,
|
||||
)
|
||||
from app.task_plane.legacy_attachment import (
|
||||
ATTACH_EVIDENCE_SCHEMA,
|
||||
AuthorityReadbackRef,
|
||||
SOURCE_KIND,
|
||||
VerifiedLegacyAttachmentEvidence,
|
||||
)
|
||||
|
||||
|
||||
class LegacyAttachmentRuntimeError(RuntimeError):
|
||||
"""Stable fail-closed readback error."""
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise LegacyAttachmentRuntimeError(code)
|
||||
|
||||
|
||||
def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
value: dict[str, Any] = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
_fail("DUPLICATE_JSON_FIELD")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
|
||||
def _json_object(raw: bytes | str, code: str) -> dict[str, Any]:
|
||||
try:
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8")
|
||||
value = json.loads(raw, object_pairs_hook=_strict_object)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise LegacyAttachmentRuntimeError(code) from exc
|
||||
if not isinstance(value, dict):
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise LegacyAttachmentRuntimeError("AUTHORITY_JSON_INVALID") from exc
|
||||
|
||||
|
||||
def _sha(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def _hex(value: object, code: str) -> str:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or len(value) != 64
|
||||
or any(ch not in "0123456789abcdef" for ch in value)
|
||||
):
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _text(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip():
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: object, code: str) -> int:
|
||||
if type(value) is not int or value < 0:
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _iso(value: object, code: str) -> str:
|
||||
text = _text(value, code)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise LegacyAttachmentRuntimeError(code) from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
_fail(code)
|
||||
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _observed(clock: Callable[[], float]) -> str:
|
||||
return datetime.fromtimestamp(float(clock()), timezone.utc).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
|
||||
|
||||
def _mapping(value: object, code: str) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
_fail(code)
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _secure_file(
|
||||
path: Path, *, require_root_owner: bool, writable_authority: bool = False
|
||||
) -> os.stat_result:
|
||||
try:
|
||||
info = path.lstat()
|
||||
except OSError as exc:
|
||||
raise LegacyAttachmentRuntimeError("AUTHORITY_FILE_UNAVAILABLE") from exc
|
||||
if path.is_symlink() or not stat.S_ISREG(info.st_mode):
|
||||
_fail("AUTHORITY_FILE_INVALID")
|
||||
if os.name == "posix":
|
||||
if not writable_authority and stat.S_IMODE(info.st_mode) & 0o022:
|
||||
_fail("AUTHORITY_FILE_WRITABLE")
|
||||
if require_root_owner and info.st_uid != 0:
|
||||
_fail("AUTHORITY_FILE_OWNER_INVALID")
|
||||
return info
|
||||
|
||||
|
||||
def _secure_directory(path: Path) -> os.stat_result:
|
||||
try:
|
||||
info = path.lstat()
|
||||
except OSError as exc:
|
||||
raise LegacyAttachmentRuntimeError("AUTHORITY_DIRECTORY_UNAVAILABLE") from exc
|
||||
if path.is_symlink() or not stat.S_ISDIR(info.st_mode):
|
||||
_fail("AUTHORITY_DIRECTORY_INVALID")
|
||||
return info
|
||||
|
||||
|
||||
def _stable_read(path: Path, *, require_root_owner: bool, maximum: int = 2_000_000) -> bytes:
|
||||
before = _secure_file(path, require_root_owner=require_root_owner)
|
||||
if before.st_size > maximum:
|
||||
_fail("AUTHORITY_FILE_TOO_LARGE")
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise LegacyAttachmentRuntimeError("AUTHORITY_FILE_UNAVAILABLE") from exc
|
||||
after = _secure_file(path, require_root_owner=require_root_owner)
|
||||
if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != (
|
||||
after.st_dev,
|
||||
after.st_ino,
|
||||
after.st_size,
|
||||
after.st_mtime_ns,
|
||||
):
|
||||
_fail("AUTHORITY_FILE_CHANGED")
|
||||
return raw
|
||||
|
||||
|
||||
def _ro_connection(path: Path, *, require_root_owner: bool) -> tuple[sqlite3.Connection, os.stat_result]:
|
||||
before = _secure_file(
|
||||
path, require_root_owner=require_root_owner, writable_authority=True
|
||||
)
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
connection = sqlite3.connect(
|
||||
resolved.as_uri() + "?mode=ro", uri=True, timeout=5.0
|
||||
)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA query_only=ON")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("BEGIN")
|
||||
return connection, before
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LegacyAttachmentRuntimeError("AUTHORITY_DB_UNAVAILABLE") from exc
|
||||
|
||||
|
||||
def _finish_ro(connection: sqlite3.Connection, path: Path, before: os.stat_result) -> None:
|
||||
connection.close()
|
||||
after = _secure_file(
|
||||
path, require_root_owner=False, writable_authority=True
|
||||
)
|
||||
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
|
||||
_fail("AUTHORITY_DB_CHANGED")
|
||||
|
||||
|
||||
def _columns(connection: sqlite3.Connection, table: str) -> tuple[str, ...]:
|
||||
return tuple(str(row[1]) for row in connection.execute(f"PRAGMA table_info({table})"))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LegacyC04Binding:
|
||||
mission_id: str
|
||||
root_task_id: str
|
||||
anchor_task_id: str
|
||||
owner_principal_id: str
|
||||
original_acceptance_digest: str
|
||||
source_task_id: str
|
||||
external_job_id: str
|
||||
|
||||
def validate(self) -> None:
|
||||
for value in (
|
||||
self.mission_id,
|
||||
self.root_task_id,
|
||||
self.anchor_task_id,
|
||||
self.owner_principal_id,
|
||||
self.source_task_id,
|
||||
self.external_job_id,
|
||||
):
|
||||
_text(value, "C04_BINDING_INVALID")
|
||||
_hex(self.original_acceptance_digest, "C04_ACCEPTANCE_DIGEST_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CredentialFileBearerSupplier:
|
||||
"""Read a systemd credential for one call without caching or displaying it."""
|
||||
|
||||
path: Path
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.path.is_absolute():
|
||||
_fail("AGENT_TASK_CREDENTIAL_PATH_INVALID")
|
||||
if os.name == "posix":
|
||||
try:
|
||||
self.path.resolve(strict=False).relative_to(Path("/run/credentials"))
|
||||
except ValueError as exc:
|
||||
raise LegacyAttachmentRuntimeError(
|
||||
"AGENT_TASK_CREDENTIAL_PATH_INVALID"
|
||||
) from exc
|
||||
|
||||
def __call__(self) -> str:
|
||||
raw = _stable_read(self.path, require_root_owner=False, maximum=8192)
|
||||
try:
|
||||
value = raw.decode("utf-8").strip()
|
||||
except UnicodeDecodeError as exc:
|
||||
raise LegacyAttachmentRuntimeError(
|
||||
"AGENT_TASK_CREDENTIAL_INVALID"
|
||||
) from exc
|
||||
if len(value) < 32 or len(value) > 4096:
|
||||
_fail("AGENT_TASK_CREDENTIAL_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
|
||||
return None
|
||||
|
||||
|
||||
|
||||
_AGENT_TASK_COLUMNS = (
|
||||
"task_id",
|
||||
"goal",
|
||||
"instructions",
|
||||
"success_criteria_json",
|
||||
"required_capabilities_json",
|
||||
"constraints_json",
|
||||
"metadata_json",
|
||||
"deadline",
|
||||
"priority",
|
||||
"max_attempts",
|
||||
"attempt",
|
||||
"status",
|
||||
"claimed_by",
|
||||
"lease_token_hash",
|
||||
"lease_expires_at",
|
||||
"result_json",
|
||||
"result_digest",
|
||||
"result_idempotency_key",
|
||||
"verification_json",
|
||||
"revision",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuthenticatedAgentTaskReader:
|
||||
"""Authenticated non-mutating proof + read-only authority-row snapshot.
|
||||
|
||||
The authenticated HTTP request is intentionally sent to the task events
|
||||
endpoint because the historical GET /v1/tasks/{id} path calls the global
|
||||
lease-expiry mutator. The task row itself is read from the authoritative
|
||||
SQLite database using query_only mode and an exact schema allowlist.
|
||||
"""
|
||||
|
||||
base_url: str
|
||||
bearer_supplier: Callable[[], str]
|
||||
task_db_path: Path
|
||||
timeout_seconds: float = 10.0
|
||||
clock: Callable[[], float] = time.time
|
||||
require_db_root_owner: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
parsed = urllib.parse.urlsplit(self.base_url)
|
||||
if (
|
||||
parsed.scheme != "http"
|
||||
or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
_fail("AGENT_TASK_URL_INVALID")
|
||||
if not self.task_db_path.is_absolute():
|
||||
_fail("AGENT_TASK_DB_PATH_INVALID")
|
||||
self.base_url = self.base_url.rstrip("/")
|
||||
|
||||
def _authenticated_events(self, task_id: str) -> tuple[list[dict[str, Any]], str]:
|
||||
token = self.bearer_supplier()
|
||||
if not isinstance(token, str) or len(token.strip()) < 32:
|
||||
_fail("AGENT_TASK_CREDENTIAL_INVALID")
|
||||
url = (
|
||||
self.base_url
|
||||
+ "/v1/tasks/"
|
||||
+ urllib.parse.quote(task_id, safe="._:-")
|
||||
+ "/events"
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
method="GET",
|
||||
headers={
|
||||
"Authorization": "Bearer " + token.strip(),
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.build_opener(_NoRedirect).open(
|
||||
request, timeout=self.timeout_seconds
|
||||
) as response:
|
||||
if response.status != 200 or response.geturl() != url:
|
||||
_fail("AGENT_TASK_EVENTS_REJECTED")
|
||||
raw = response.read(2_000_001)
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
raise LegacyAttachmentRuntimeError(
|
||||
"AGENT_TASK_EVENTS_UNAVAILABLE"
|
||||
) from exc
|
||||
finally:
|
||||
token = ""
|
||||
if len(raw) > 2_000_000:
|
||||
_fail("AGENT_TASK_EVENTS_TOO_LARGE")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_strict_object)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
||||
raise LegacyAttachmentRuntimeError("AGENT_TASK_EVENTS_INVALID") from exc
|
||||
if not isinstance(value, list):
|
||||
_fail("AGENT_TASK_EVENTS_INVALID")
|
||||
events: list[dict[str, Any]] = []
|
||||
for event in value:
|
||||
if not isinstance(event, Mapping) or event.get("task_id") != task_id:
|
||||
_fail("AGENT_TASK_EVENTS_INVALID")
|
||||
events.append(dict(event))
|
||||
return events, url
|
||||
|
||||
@staticmethod
|
||||
def _task_projection(row: sqlite3.Row) -> dict[str, Any]:
|
||||
try:
|
||||
body = {
|
||||
"task_id": row["task_id"],
|
||||
"goal": row["goal"],
|
||||
"instructions": row["instructions"],
|
||||
"success_criteria": json.loads(row["success_criteria_json"]),
|
||||
"required_capabilities": json.loads(row["required_capabilities_json"]),
|
||||
"constraints": json.loads(row["constraints_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
"deadline": row["deadline"],
|
||||
"priority": row["priority"],
|
||||
"max_attempts": row["max_attempts"],
|
||||
"attempt": row["attempt"],
|
||||
"status": row["status"],
|
||||
"claimed_by": row["claimed_by"],
|
||||
"lease_expires_at": row["lease_expires_at"],
|
||||
"revision": row["revision"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"result": json.loads(row["result_json"]) if row["result_json"] else None,
|
||||
"verification": (
|
||||
json.loads(row["verification_json"])
|
||||
if row["verification_json"]
|
||||
else None
|
||||
),
|
||||
}
|
||||
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise LegacyAttachmentRuntimeError(
|
||||
"AGENT_TASK_ROW_INVALID"
|
||||
) from exc
|
||||
_mapping(body["constraints"], "AGENT_TASK_CONSTRAINTS_INVALID")
|
||||
_mapping(body["metadata"], "AGENT_TASK_METADATA_INVALID")
|
||||
_integer(body["revision"], "AGENT_TASK_REVISION_INVALID")
|
||||
if body["status"] not in {"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED"}:
|
||||
_fail("AGENT_TASK_NOT_TERMINAL")
|
||||
return body
|
||||
|
||||
def read(self, task_id: str) -> dict[str, Any]:
|
||||
events, events_url = self._authenticated_events(task_id)
|
||||
connection, before = _ro_connection(
|
||||
self.task_db_path, require_root_owner=self.require_db_root_owner
|
||||
)
|
||||
try:
|
||||
if _columns(connection, "tasks") != _AGENT_TASK_COLUMNS:
|
||||
_fail("AGENT_TASK_DB_SCHEMA_INVALID")
|
||||
row = connection.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=? LIMIT 1", (task_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
_fail("AGENT_TASK_NOT_FOUND")
|
||||
body = self._task_projection(row)
|
||||
finally:
|
||||
_finish_ro(connection, self.task_db_path, before)
|
||||
event_digest = _sha(events)
|
||||
return {
|
||||
"body": body,
|
||||
"observed_at": _observed(self.clock),
|
||||
"readback_ref": (
|
||||
events_url
|
||||
+ "#authenticated-events-sha256="
|
||||
+ event_digest
|
||||
+ ";task-db-revision="
|
||||
+ str(body["revision"])
|
||||
),
|
||||
"canonical_sha256": _sha(body),
|
||||
"authenticated_events_sha256": event_digest,
|
||||
}
|
||||
|
||||
|
||||
_ADMISSION_COLUMNS = {
|
||||
"task_admission_envelopes": (
|
||||
"idempotency_key", "envelope_id", "request_digest", "envelope_json", "status", "created_at"
|
||||
),
|
||||
"task_admission_grants": (
|
||||
"envelope_id", "grant_id", "grant_digest", "grant_json", "authorization_digest", "created_at"
|
||||
),
|
||||
"task_admission_receipts": ("envelope_id", "task_id", "receipt_json", "created_at"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdmissionEpochRegistryReader:
|
||||
db_path: Path
|
||||
epoch_root: Path
|
||||
revocation_file: Path
|
||||
registry_path: Path
|
||||
require_db_root_owner: bool = False
|
||||
require_static_root_owner: bool = True
|
||||
clock: Callable[[], float] = time.time
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for path in (self.db_path, self.epoch_root, self.revocation_file, self.registry_path):
|
||||
if not path.is_absolute():
|
||||
_fail("AUTHORITY_PATH_NOT_ABSOLUTE")
|
||||
|
||||
def read(self, task_id: str) -> dict[str, Any]:
|
||||
connection, before = _ro_connection(
|
||||
self.db_path, require_root_owner=self.require_db_root_owner
|
||||
)
|
||||
try:
|
||||
if any(_columns(connection, table) != columns for table, columns in _ADMISSION_COLUMNS.items()):
|
||||
_fail("ADMISSION_SCHEMA_INVALID")
|
||||
rows = connection.execute(
|
||||
"SELECT e.*,g.grant_id,g.grant_digest,g.grant_json,g.authorization_digest,"
|
||||
"g.created_at AS grant_created_at,r.task_id,r.receipt_json,"
|
||||
"r.created_at AS receipt_created_at FROM task_admission_receipts r "
|
||||
"JOIN task_admission_envelopes e ON e.envelope_id=r.envelope_id "
|
||||
"JOIN task_admission_grants g ON g.envelope_id=r.envelope_id WHERE r.task_id=?",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
if len(rows) != 1:
|
||||
_fail("ADMISSION_READBACK_AMBIGUOUS")
|
||||
row = dict(rows[0])
|
||||
except sqlite3.Error as exc:
|
||||
raise LegacyAttachmentRuntimeError("ADMISSION_READBACK_FAILED") from exc
|
||||
finally:
|
||||
_finish_ro(connection, self.db_path, before)
|
||||
|
||||
envelope = _json_object(row["envelope_json"], "ADMISSION_ENVELOPE_INVALID")
|
||||
grant = _json_object(row["grant_json"], "ADMISSION_GRANT_INVALID")
|
||||
receipt = _json_object(row["receipt_json"], "ADMISSION_RECEIPT_INVALID")
|
||||
envelope_id = _text(row["envelope_id"], "ADMISSION_ENVELOPE_INVALID")
|
||||
grant_id = _text(row["grant_id"], "ADMISSION_GRANT_INVALID")
|
||||
epoch_id = _text(grant.get("parent_epoch_id"), "ADMISSION_EPOCH_ID_INVALID")
|
||||
if (
|
||||
row.get("status") != "ADMITTED"
|
||||
or envelope.get("schema") != "aios.task-admission-envelope.v1"
|
||||
or grant.get("schema") != "aios.task-admission-grant.v1"
|
||||
or receipt.get("schema") != "aios.task-admission-receipt.v1"
|
||||
or envelope.get("envelope_id") != envelope_id
|
||||
or grant.get("envelope_id") != envelope_id
|
||||
or receipt.get("envelope_id") != envelope_id
|
||||
or envelope.get("task_id") != task_id
|
||||
or grant.get("task_id") != task_id
|
||||
or receipt.get("task_id") != task_id
|
||||
or grant.get("grant_id") != grant_id
|
||||
or receipt.get("grant_id") != grant_id
|
||||
or grant.get("authorization_kind") != "delegation_epoch"
|
||||
or receipt.get("authorization_kind") != "delegation_epoch"
|
||||
or receipt.get("parent_epoch_id") != epoch_id
|
||||
):
|
||||
_fail("ADMISSION_BINDING_INVALID")
|
||||
request_value = {
|
||||
key: envelope.get(key)
|
||||
for key in (
|
||||
"task_id", "repository", "authority_base_oid", "target_branch",
|
||||
"generation", "execution_digest", "requested_capabilities",
|
||||
"file_allowlist", "risk", "trust_domain", "policy_digest",
|
||||
)
|
||||
}
|
||||
if envelope.get("request_digest") != _sha(request_value) or row.get("request_digest") != _sha(request_value):
|
||||
_fail("ADMISSION_REQUEST_DIGEST_INVALID")
|
||||
grant_digest = _sha(grant)
|
||||
if row.get("grant_digest") != grant_digest or receipt.get("grant_digest_sha256") != grant_digest:
|
||||
_fail("ADMISSION_GRANT_DIGEST_INVALID")
|
||||
auth_value = {
|
||||
"envelope_id": envelope_id,
|
||||
"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": epoch_id,
|
||||
}
|
||||
if row.get("authorization_digest") != _sha(auth_value):
|
||||
_fail("ADMISSION_AUTHORIZATION_DIGEST_INVALID")
|
||||
linked = (
|
||||
"repository", "authority_base_oid", "target_branch", "generation",
|
||||
"execution_digest", "policy_digest", "trust_domain", "risk",
|
||||
)
|
||||
if any(grant.get(key) != envelope.get(key) for key in linked):
|
||||
_fail("ADMISSION_GRANT_SCOPE_INVALID")
|
||||
if grant.get("capabilities") != envelope.get("requested_capabilities") or grant.get("file_allowlist") != envelope.get("file_allowlist"):
|
||||
_fail("ADMISSION_GRANT_SCOPE_INVALID")
|
||||
if receipt.get("execution_digest") != grant.get("execution_digest"):
|
||||
_fail("ADMISSION_RECEIPT_SCOPE_INVALID")
|
||||
|
||||
_secure_directory(self.epoch_root)
|
||||
canonical_path = self.epoch_root / "owner-delegation-epoch.canonical.json"
|
||||
signature_path = self.epoch_root / "owner-delegation-epoch.sig"
|
||||
key_path = self.epoch_root / "owner-delegation-epoch.key-id"
|
||||
canonical = _stable_read(canonical_path, require_root_owner=self.require_static_root_owner)
|
||||
epoch = _json_object(canonical, "OWNER_EPOCH_INVALID")
|
||||
if _canonical(epoch) != canonical:
|
||||
_fail("OWNER_EPOCH_NOT_CANONICAL")
|
||||
signature = _stable_read(signature_path, require_root_owner=self.require_static_root_owner).decode("utf-8")
|
||||
key_id = _stable_read(key_path, require_root_owner=self.require_static_root_owner).decode("utf-8").strip()
|
||||
registry_raw = _stable_read(self.registry_path, require_root_owner=self.require_static_root_owner)
|
||||
registry_json = _json_object(registry_raw, "OWNER_REGISTRY_INVALID")
|
||||
revocations = _json_object(
|
||||
_stable_read(self.revocation_file, require_root_owner=self.require_static_root_owner),
|
||||
"OWNER_REVOCATIONS_INVALID",
|
||||
)
|
||||
if (
|
||||
set(revocations) != {"schema", "revoked_epoch_ids"}
|
||||
or revocations.get("schema") != "aios.owner-delegation-revocations.v1"
|
||||
or not isinstance(revocations.get("revoked_epoch_ids"), list)
|
||||
or epoch_id in revocations["revoked_epoch_ids"]
|
||||
):
|
||||
_fail("OWNER_EPOCH_REVOKED")
|
||||
try:
|
||||
registry = SignerRegistry(
|
||||
self.registry_path, require_root_owner=self.require_static_root_owner
|
||||
)
|
||||
record = registry.require(DEFAULT_OWNER_SIGNER_ID, role="OWNER_AUTHORITY")
|
||||
verified = RegistryOwnerApprovalVerifier(
|
||||
self.registry_path,
|
||||
require_root_owner=self.require_static_root_owner,
|
||||
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
||||
).verify(
|
||||
namespace=OWNER_NAMESPACE,
|
||||
payload=canonical,
|
||||
signature=signature,
|
||||
key_id=key_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise LegacyAttachmentRuntimeError("OWNER_REGISTRY_VERIFICATION_FAILED") from exc
|
||||
now = int(self.clock())
|
||||
provenance_checks = {
|
||||
"signature": verified is True,
|
||||
"fingerprint": record.fingerprint == key_id,
|
||||
"schema": epoch.get("schema") == "aios.owner-delegation-epoch.v1",
|
||||
"epoch_id": epoch.get("epoch_id") == epoch_id,
|
||||
"owner": epoch.get("owner_identity") == grant.get("owner_identity"),
|
||||
"repository": epoch.get("repository") == grant.get("repository"),
|
||||
"trust_domain": epoch.get("trust_domain") == grant.get("trust_domain"),
|
||||
"policy": epoch.get("policy_digest") == grant.get("policy_digest"),
|
||||
"revocation_epoch": epoch.get("revocation_epoch") == 0,
|
||||
"expires_type": type(epoch.get("expires_at")) is int,
|
||||
"not_before_type": type(epoch.get("not_before")) is int,
|
||||
}
|
||||
failed = [name for name, passed in provenance_checks.items() if not passed]
|
||||
if failed:
|
||||
_fail("OWNER_EPOCH_PROVENANCE_INVALID:" + ",".join(sorted(failed)))
|
||||
if (
|
||||
now < epoch["expires_at"]
|
||||
or type(epoch.get("issued_at")) is not int
|
||||
or epoch["issued_at"] > grant.get("issued_at", 0)
|
||||
or grant.get("issued_at", 0) < epoch["not_before"]
|
||||
or grant.get("expires_at", 0) > epoch["expires_at"]
|
||||
):
|
||||
_fail("OWNER_EPOCH_PROVENANCE_INVALID:temporal")
|
||||
if not any(str(grant["target_branch"]).startswith(str(prefix)) for prefix in epoch.get("allowed_branch_prefixes", [])):
|
||||
_fail("OWNER_EPOCH_SCOPE_INVALID")
|
||||
if not set(grant.get("capabilities", [])).issubset(set(epoch.get("allowed_capabilities", []))):
|
||||
_fail("OWNER_EPOCH_SCOPE_INVALID")
|
||||
risk_rank = {"low": 0, "medium": 1, "high": 2}
|
||||
if (
|
||||
grant.get("risk") not in risk_rank
|
||||
or epoch.get("risk_ceiling") not in risk_rank
|
||||
or risk_rank[grant["risk"]] > risk_rank[epoch["risk_ceiling"]]
|
||||
):
|
||||
_fail("OWNER_EPOCH_SCOPE_INVALID")
|
||||
if any(
|
||||
not any(path == prefix.rstrip("/") or path.startswith(prefix.rstrip("/") + "/") for prefix in epoch.get("allowed_file_prefixes", []))
|
||||
for path in grant.get("file_allowlist", [])
|
||||
):
|
||||
_fail("OWNER_EPOCH_SCOPE_INVALID")
|
||||
|
||||
admission_projection = {
|
||||
"envelope": envelope,
|
||||
"grant": grant,
|
||||
"receipt": receipt,
|
||||
"request_digest": row["request_digest"],
|
||||
"grant_digest": row["grant_digest"],
|
||||
"authorization_digest": row["authorization_digest"],
|
||||
"envelope_status": row["status"],
|
||||
}
|
||||
owner_projection = {
|
||||
"epoch": epoch,
|
||||
"epoch_sha256": hashlib.sha256(canonical).hexdigest(),
|
||||
"epoch_key_id": key_id,
|
||||
"signature_verified": True,
|
||||
"epoch_currently_valid": False,
|
||||
"revocations": revocations,
|
||||
"registry_schema": registry_json.get("schema"),
|
||||
"registry_generation": registry.authority_generation,
|
||||
"signer": record.to_dict(),
|
||||
}
|
||||
observed = _observed(self.clock)
|
||||
return {
|
||||
"envelope": envelope,
|
||||
"grant": grant,
|
||||
"receipt": receipt,
|
||||
"epoch": epoch,
|
||||
"epoch_digest": hashlib.sha256(canonical).hexdigest(),
|
||||
"signer_id": record.signer_id,
|
||||
"signer_fingerprint": record.fingerprint,
|
||||
"registry_generation": registry.authority_generation,
|
||||
"observed_at": observed,
|
||||
"admission_ref": f"sqlite:{self.db_path}#task={task_id};envelope={envelope_id}",
|
||||
"admission_sha256": _sha(admission_projection),
|
||||
"registry_ref": f"file:{self.registry_path}#generation={registry.authority_generation};epoch={epoch_id}",
|
||||
"registry_sha256": _sha(owner_projection),
|
||||
}
|
||||
|
||||
|
||||
_JOB_COLUMNS = {
|
||||
"job_aliases": (
|
||||
"external_job_id", "internal_job_id", "task_id", "repository", "target_branch",
|
||||
"branch_mode", "generation", "commit_idempotency_key", "expected_branch_oid",
|
||||
"request_digest", "adopted_legacy", "created_at",
|
||||
),
|
||||
"jobs": (
|
||||
"job_id", "idempotency_key", "campaign_id", "repository", "operation", "branch",
|
||||
"expected_branch_oid", "payload_digest", "payload_ref", "payload_size", "state",
|
||||
"side_effect_state", "revision", "request_json", "receipt_json", "created_at", "updated_at",
|
||||
),
|
||||
"idempotency_keys": ("key", "job_id", "binding_json", "created_at"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BridgeJobSQLiteReader:
|
||||
jobs_root: Path
|
||||
require_root_owner: bool = False
|
||||
clock: Callable[[], float] = time.time
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.jobs_root.is_absolute():
|
||||
_fail("BRIDGE_JOB_ROOT_NOT_ABSOLUTE")
|
||||
|
||||
def read(self, task_id: str, external_job_id: str) -> dict[str, Any]:
|
||||
exact_root = self.jobs_root / task_storage_key(task_id)
|
||||
legacy_root = self.jobs_root / legacy_task_storage_key(task_id)
|
||||
db_path = exact_root / "state.sqlite3"
|
||||
if legacy_root != exact_root and legacy_root.exists():
|
||||
_fail("BRIDGE_JOB_STORE_AMBIGUOUS")
|
||||
_secure_directory(self.jobs_root)
|
||||
_secure_directory(exact_root)
|
||||
try:
|
||||
if db_path.resolve(strict=True).parent != exact_root.resolve(strict=True):
|
||||
_fail("BRIDGE_JOB_STORE_INVALID")
|
||||
except OSError as exc:
|
||||
raise LegacyAttachmentRuntimeError("BRIDGE_JOB_STORE_INVALID") from exc
|
||||
connection, before = _ro_connection(
|
||||
db_path, require_root_owner=self.require_root_owner
|
||||
)
|
||||
try:
|
||||
if any(_columns(connection, table) != columns for table, columns in _JOB_COLUMNS.items()):
|
||||
_fail("BRIDGE_JOB_SCHEMA_INVALID")
|
||||
aliases = connection.execute(
|
||||
"SELECT * FROM job_aliases WHERE external_job_id=? AND task_id=?",
|
||||
(external_job_id, task_id),
|
||||
).fetchall()
|
||||
if len(aliases) != 1:
|
||||
_fail("BRIDGE_JOB_ALIAS_AMBIGUOUS")
|
||||
alias = dict(aliases[0])
|
||||
job_rows = connection.execute(
|
||||
"SELECT * FROM jobs WHERE job_id=?", (alias["internal_job_id"],)
|
||||
).fetchall()
|
||||
key_rows = connection.execute(
|
||||
"SELECT * FROM idempotency_keys WHERE job_id=?", (alias["internal_job_id"],)
|
||||
).fetchall()
|
||||
if len(job_rows) != 1 or len(key_rows) != 1:
|
||||
_fail("BRIDGE_JOB_BINDING_MISSING")
|
||||
job, key = dict(job_rows[0]), dict(key_rows[0])
|
||||
except sqlite3.Error as exc:
|
||||
raise LegacyAttachmentRuntimeError("BRIDGE_JOB_READBACK_FAILED") from exc
|
||||
finally:
|
||||
_finish_ro(connection, db_path, before)
|
||||
request = _json_object(job.get("request_json") or "{}", "BRIDGE_JOB_REQUEST_INVALID")
|
||||
binding = _mapping(request.get("binding", request), "BRIDGE_JOB_REQUEST_INVALID")
|
||||
key_binding = _json_object(key.get("binding_json") or "{}", "BRIDGE_JOB_KEY_BINDING_INVALID")
|
||||
external_commit_key = _text(alias.get("commit_idempotency_key"), "BRIDGE_EXTERNAL_IDEMPOTENCY_INVALID")
|
||||
internal_key = _text(job.get("idempotency_key"), "BRIDGE_INTERNAL_IDEMPOTENCY_INVALID")
|
||||
if (
|
||||
alias.get("external_job_id") != external_job_id
|
||||
or alias.get("task_id") != task_id
|
||||
or job.get("job_id") != alias.get("internal_job_id")
|
||||
or job.get("campaign_id") != task_id
|
||||
or key.get("job_id") != job.get("job_id")
|
||||
or key.get("key") != internal_key
|
||||
or binding.get("task_id", binding.get("campaign_id")) != task_id
|
||||
or binding.get("external_job_id") != external_job_id
|
||||
or binding.get("idempotency_key") != external_commit_key
|
||||
or key_binding.get("task_id", key_binding.get("campaign_id")) != task_id
|
||||
or key_binding.get("external_job_id") != external_job_id
|
||||
or key_binding.get("idempotency_key") != external_commit_key
|
||||
or alias.get("repository") != job.get("repository")
|
||||
or alias.get("repository") != binding.get("repository")
|
||||
or alias.get("target_branch") != job.get("branch")
|
||||
or alias.get("target_branch") != binding.get("branch", binding.get("target_branch"))
|
||||
):
|
||||
_fail("BRIDGE_JOB_BINDING_INVALID")
|
||||
try:
|
||||
if int(alias.get("generation")) != int(binding.get("generation")):
|
||||
_fail("BRIDGE_JOB_BINDING_INVALID")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise LegacyAttachmentRuntimeError("BRIDGE_JOB_BINDING_INVALID") from exc
|
||||
projection = {
|
||||
"alias": alias,
|
||||
"job": job,
|
||||
"idempotency": key,
|
||||
"external_commit_idempotency_key": external_commit_key,
|
||||
"internal_idempotency_key": internal_key,
|
||||
}
|
||||
return {
|
||||
**projection,
|
||||
"observed_at": _observed(self.clock),
|
||||
"readback_ref": f"sqlite:{db_path}#external={external_job_id};internal={job['job_id']}",
|
||||
"canonical_sha256": _sha(projection),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LegacyAttachmentRuntimeProvider:
|
||||
binding: LegacyC04Binding
|
||||
agent_task: AuthenticatedAgentTaskReader
|
||||
admission: AdmissionEpochRegistryReader
|
||||
bridge_job: BridgeJobSQLiteReader
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.binding.validate()
|
||||
|
||||
def read_verified(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
root_task_id: str,
|
||||
anchor_task_id: str,
|
||||
source_kind: str,
|
||||
source_task_id: str,
|
||||
external_job_id: str,
|
||||
original_acceptance_digest: str,
|
||||
) -> VerifiedLegacyAttachmentEvidence:
|
||||
expected = self.binding
|
||||
if (
|
||||
mission_id != expected.mission_id
|
||||
or root_task_id != expected.root_task_id
|
||||
or anchor_task_id != expected.anchor_task_id
|
||||
or source_kind != SOURCE_KIND
|
||||
or source_task_id != expected.source_task_id
|
||||
or external_job_id != expected.external_job_id
|
||||
or original_acceptance_digest != expected.original_acceptance_digest
|
||||
):
|
||||
_fail("C04_AUTHORITY_BINDING_MISMATCH")
|
||||
|
||||
task_read = self.agent_task.read(source_task_id)
|
||||
admission_read = self.admission.read(source_task_id)
|
||||
job_read = self.bridge_job.read(source_task_id, external_job_id)
|
||||
task = task_read["body"]
|
||||
metadata = _mapping(task["metadata"], "AGENT_TASK_METADATA_INVALID")
|
||||
constraints = _mapping(task["constraints"], "AGENT_TASK_CONSTRAINTS_INVALID")
|
||||
execution = _mapping(metadata.get("execution"), "AGENT_TASK_EXECUTION_INVALID")
|
||||
grant = admission_read["grant"]
|
||||
envelope = admission_read["envelope"]
|
||||
alias, job = job_read["alias"], job_read["job"]
|
||||
try:
|
||||
immutable_metadata = _json_object(
|
||||
constraints.get("immutable_metadata", ""), "AGENT_TASK_IMMUTABLE_METADATA_INVALID"
|
||||
)
|
||||
except LegacyAttachmentRuntimeError:
|
||||
raise
|
||||
if (
|
||||
immutable_metadata != metadata
|
||||
or metadata.get("grant_id") != grant.get("grant_id")
|
||||
or metadata.get("grant_digest_sha256") != admission_read["receipt"].get("grant_digest_sha256")
|
||||
or metadata.get("execution_digest") != grant.get("execution_digest")
|
||||
or execution.get("execution_digest") != grant.get("execution_digest")
|
||||
or execution.get("external_job_id") != external_job_id
|
||||
or execution.get("task_id") != source_task_id
|
||||
or metadata.get("repository") != grant.get("repository")
|
||||
or metadata.get("target_branch") != grant.get("target_branch")
|
||||
or metadata.get("generation") != grant.get("generation")
|
||||
or constraints.get("repository") != grant.get("repository")
|
||||
or constraints.get("target_branch") != grant.get("target_branch")
|
||||
or constraints.get("generation") != grant.get("generation")
|
||||
or envelope.get("task_id") != source_task_id
|
||||
or alias.get("repository") != grant.get("repository")
|
||||
or alias.get("target_branch") != grant.get("target_branch")
|
||||
or alias.get("generation") != grant.get("generation")
|
||||
or admission_read["signer_id"] != expected.owner_principal_id
|
||||
):
|
||||
_fail("FOUR_AUTHORITY_BINDING_INVALID")
|
||||
|
||||
return VerifiedLegacyAttachmentEvidence(
|
||||
schema=ATTACH_EVIDENCE_SCHEMA,
|
||||
source_kind=SOURCE_KIND,
|
||||
source_task_id=source_task_id,
|
||||
source_task_created_at=_iso(task.get("created_at"), "AGENT_TASK_CREATED_AT_INVALID"),
|
||||
source_task_status=_text(task.get("status"), "AGENT_TASK_STATUS_INVALID"),
|
||||
source_task_revision=_integer(task.get("revision"), "AGENT_TASK_REVISION_INVALID"),
|
||||
source_task_attempt=_integer(task.get("attempt"), "AGENT_TASK_ATTEMPT_INVALID"),
|
||||
source_task_max_attempts=_integer(task.get("max_attempts"), "AGENT_TASK_MAX_ATTEMPTS_INVALID"),
|
||||
external_job_id=external_job_id,
|
||||
internal_job_id=_text(job.get("job_id"), "BRIDGE_INTERNAL_JOB_INVALID"),
|
||||
internal_idempotency_key=_text(job.get("idempotency_key"), "BRIDGE_INTERNAL_IDEMPOTENCY_INVALID"),
|
||||
job_branch=_text(job.get("branch"), "BRIDGE_JOB_BRANCH_INVALID"),
|
||||
job_state=_text(job.get("state"), "BRIDGE_JOB_STATE_INVALID"),
|
||||
job_revision=_integer(job.get("revision"), "BRIDGE_JOB_REVISION_INVALID"),
|
||||
side_effect_state=_text(job.get("side_effect_state"), "BRIDGE_SIDE_EFFECT_INVALID"),
|
||||
grant_id=_text(grant.get("grant_id"), "ADMISSION_GRANT_INVALID"),
|
||||
grant_digest=_hex(admission_read["receipt"].get("grant_digest_sha256"), "ADMISSION_GRANT_DIGEST_INVALID"),
|
||||
execution_digest=_hex(grant.get("execution_digest"), "ADMISSION_EXECUTION_DIGEST_INVALID"),
|
||||
epoch_id=_text(admission_read["epoch"].get("epoch_id"), "OWNER_EPOCH_ID_INVALID"),
|
||||
epoch_digest=_hex(admission_read["epoch_digest"], "OWNER_EPOCH_DIGEST_INVALID"),
|
||||
historical_signer_id=admission_read["signer_id"],
|
||||
historical_signer_fingerprint=admission_read["signer_fingerprint"],
|
||||
registry_generation=_integer(admission_read["registry_generation"], "OWNER_REGISTRY_GENERATION_INVALID"),
|
||||
signature_verified=True,
|
||||
epoch_currently_valid=False,
|
||||
readbacks=(
|
||||
AuthorityReadbackRef(
|
||||
role="agent_task", authority="aios-agent-task-api",
|
||||
readback_ref=task_read["readback_ref"], canonical_sha256=task_read["canonical_sha256"],
|
||||
observed_at=task_read["observed_at"], revision=task["revision"],
|
||||
),
|
||||
AuthorityReadbackRef(
|
||||
role="admission", authority="aios-task-admission-store",
|
||||
readback_ref=admission_read["admission_ref"], canonical_sha256=admission_read["admission_sha256"],
|
||||
observed_at=admission_read["observed_at"], revision=None,
|
||||
),
|
||||
AuthorityReadbackRef(
|
||||
role="owner_registry", authority="aios-single-host-owner-authority",
|
||||
readback_ref=admission_read["registry_ref"], canonical_sha256=admission_read["registry_sha256"],
|
||||
observed_at=admission_read["observed_at"], revision=admission_read["registry_generation"],
|
||||
),
|
||||
AuthorityReadbackRef(
|
||||
role="bridge_job", authority="aios-gitops-job-controller",
|
||||
readback_ref=job_read["readback_ref"], canonical_sha256=job_read["canonical_sha256"],
|
||||
observed_at=job_read["observed_at"], revision=job["revision"],
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -135,6 +135,11 @@ SECRET_VAL_RE = re.compile(
|
||||
r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})'
|
||||
)
|
||||
|
||||
# Control-plane file read: enforce auth, strict identifier, containment and a
|
||||
# size cap on the command-detail read exposed by /admin/ai-evolution/api/data.
|
||||
MAX_COMMAND_READ_BYTES = 1024 * 1024
|
||||
_COMMAND_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\.md$")
|
||||
|
||||
READ_ONLY_GET_PATHS = frozenset({
|
||||
'/', '/health', '/api/health', '/api/openapi.yaml',
|
||||
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
|
||||
@@ -690,20 +695,6 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path in {
|
||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||
'/getReleaseConsistency', '/runReleaseAcceptance',
|
||||
}:
|
||||
actor = self.need('tag:create')
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_release(path, self.body())
|
||||
if reject_non_read_http(self, 'POST', path):
|
||||
return
|
||||
return self.sendj({'error': 'not_found', 'path': path}, 404)
|
||||
def do_GET(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
||||
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
||||
@@ -949,6 +940,17 @@ class H(BaseHTTPRequestHandler):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
# Governed V2 bridge release routes (previously shadowed by the
|
||||
# redefinition of do_POST in this class).
|
||||
if path in {
|
||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||
'/getReleaseConsistency', '/runReleaseAcceptance',
|
||||
}:
|
||||
actor = self.need('tag:create')
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_release(path, self.body())
|
||||
try:
|
||||
if path == '/createReleaseCandidate':
|
||||
a=self.need('release:candidate')
|
||||
@@ -1248,12 +1250,25 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
||||
commands.append(f.name)
|
||||
command_detail=None
|
||||
if cmd_filter:
|
||||
actor = self.actor()
|
||||
if not actor['ok']:
|
||||
return self.sendj({'error':'unauthorized','hint':'Bearer token required'},401)
|
||||
cmd = str(cmd_filter or '')
|
||||
if not _COMMAND_FILENAME_RE.fullmatch(cmd) or '..' in cmd:
|
||||
return self.sendj({'command':cmd_filter,'error':'unsafe_command'},400)
|
||||
for d in cmd_dirs:
|
||||
if d.exists():
|
||||
fp=d/cmd_filter
|
||||
if fp.exists():
|
||||
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
||||
break
|
||||
if not d.exists():
|
||||
continue
|
||||
base = d.resolve()
|
||||
fp = (base / cmd).resolve()
|
||||
# Strict containment: must resolve directly inside one of the
|
||||
# allowed command directories.
|
||||
if fp.parent != base or not fp.is_file():
|
||||
continue
|
||||
if fp.stat().st_size > MAX_COMMAND_READ_BYTES:
|
||||
return self.sendj({'command':cmd_filter,'error':'command_too_large'},413)
|
||||
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
||||
break
|
||||
if not command_detail:
|
||||
command_detail={'command':cmd_filter,'error':'not_found'}
|
||||
return self.sendj(command_detail)
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""aios_owner_based_executor — Owner 基于授权的可执行 CLI 入口。
|
||||
|
||||
把 "owner 基于授权 → Agent 自主完成受治理写链" 从测试/装配层提升为**可直接执行的命令**。
|
||||
集成 V2R2C3 现场组合根 `field_governed_writer.py`,装配到生产入口
|
||||
`GovernedWriterAutomation.acquire_verified()`。
|
||||
|
||||
子命令:
|
||||
register <authorization_ref> 登记一条 Owner 授权到 authority(可 --policy-json 指定,
|
||||
默认用内置参考授权物 azn-owner-gw-v1)。
|
||||
acquire <authorization_ref> 装配 FieldGovernedWriterFactory,运行 acquire_verified(),
|
||||
输出 GovernedWriterSession JSON。
|
||||
|
||||
行为契约:
|
||||
- 生产入口唯一授权来源是 authority 已登记的 authorization_ref;未登记即 fail-closed。
|
||||
- authority 未装配(--no-authority 或构造时 owner_authority=None)→ AuthorizationDenied。
|
||||
- EXPIRED 任务无预签 recovery → OwnerActionRequired(OWNER_BOUNDARY,非自动恢复)。
|
||||
- status==error 输出到 stderr,退出码非 0;成功输出会话 JSON,退出码 0。
|
||||
- legacy_global_writes_enabled 默认 false = 安全不变式,不是 blocker。
|
||||
|
||||
用法示例:
|
||||
python aios_owner_based_executor.py register azn-owner-gw-v1
|
||||
python aios_owner_based_executor.py acquire azn-owner-gw-v1
|
||||
python aios_owner_based_executor.py acquire azn-owner-gw-v1 --branch-mode continue --head-oid <oid>
|
||||
python aios_owner_based_executor.py acquire azn-owner-gw-v1 --task-status EXPIRED # OwnerBoundary
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.task_plane.field_governed_writer import (
|
||||
FieldGovernedWriterFactory,
|
||||
FieldOwnerAuthorizationAuthority,
|
||||
FieldRuntimeStatus,
|
||||
)
|
||||
from app.task_plane.governance_control_kernel import (
|
||||
ActionLedger,
|
||||
ConsistencyBarrier,
|
||||
ManifestStore,
|
||||
DiscoveryObservation,
|
||||
)
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
OwnerActionRequired,
|
||||
OwnerAuthorizationPolicy,
|
||||
TaskState,
|
||||
)
|
||||
|
||||
# 版本与示例授权引用
|
||||
VERSION = "V2R2C3"
|
||||
DEFAULT_AZN_REF = "azn-owner-gw-v1"
|
||||
|
||||
_AZN_CAPS = frozenset(
|
||||
{
|
||||
"code.write",
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
"test.execute",
|
||||
"writer.lease",
|
||||
"task.principal.issue",
|
||||
}
|
||||
)
|
||||
_AZN_OPS = frozenset(
|
||||
{
|
||||
"claim",
|
||||
"epoch_reissue",
|
||||
"governed_commit",
|
||||
"heartbeat",
|
||||
"job_reconcile",
|
||||
"readback",
|
||||
"reconcile",
|
||||
"successor_recovery",
|
||||
"task_lease",
|
||||
"task_principal",
|
||||
"writer_lease",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 授权物 / 请求构造
|
||||
# ---------------------------------------------------------------------------
|
||||
def reference_policy(*, expires_at: float | None = None) -> OwnerAuthorizationPolicy:
|
||||
"""内置参考授权物 azn-owner-gw-v1(现场登记时 Owner 设定当下有效 expires_at)。"""
|
||||
if expires_at is None:
|
||||
expires_at = time.time() + 30 * 86400.0
|
||||
return OwnerAuthorizationPolicy(
|
||||
authorization_id=DEFAULT_AZN_REF,
|
||||
subject="aios-autonomous-admin",
|
||||
repository="aios/aios",
|
||||
allowed_branch_prefixes=("agent/autonomy-",),
|
||||
allowed_capabilities=_AZN_CAPS,
|
||||
risk_ceiling="medium",
|
||||
trust_domain="aios.owner",
|
||||
revocation_epoch=1,
|
||||
not_before=0.0,
|
||||
expires_at=expires_at,
|
||||
revoked=False,
|
||||
allowed_operations=_AZN_OPS,
|
||||
)
|
||||
|
||||
|
||||
def _default_subject_repo_branch(task: str) -> tuple[str, str, str]:
|
||||
return ("aios-autonomous-admin", "aios/aios", f"agent/autonomy-{task}")
|
||||
|
||||
|
||||
def build_request(
|
||||
*,
|
||||
task_id: str,
|
||||
manifest_id: str,
|
||||
runtime_fingerprint: str,
|
||||
process_start_identity: str,
|
||||
plan_digest: str,
|
||||
base_oid: str,
|
||||
generation: int,
|
||||
risk: str,
|
||||
caps: frozenset[str],
|
||||
files: tuple[str, ...],
|
||||
overrides: dict | None = None,
|
||||
) -> GovernedWriterRequest:
|
||||
subject, repo, branch = _default_subject_repo_branch(task_id)
|
||||
body = dict(
|
||||
subject=subject,
|
||||
task_id=task_id,
|
||||
repository=repo,
|
||||
target_branch=branch,
|
||||
risk=risk,
|
||||
generation=generation,
|
||||
base_oid=base_oid,
|
||||
required_capabilities=caps,
|
||||
file_allowlist=files,
|
||||
plan_digest=plan_digest,
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint=runtime_fingerprint,
|
||||
process_start_identity=process_start_identity,
|
||||
)
|
||||
if overrides:
|
||||
body.update(overrides)
|
||||
return GovernedWriterRequest(**body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 依据配置构造 authority(含 --no-authority fail-closed 场景)
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_authority(
|
||||
*, policy_json_path: str | None, use_reference: bool, expires_at: float | None
|
||||
) -> FieldOwnerAuthorizationAuthority | None:
|
||||
if policy_json_path:
|
||||
data = json.loads(Path(policy_json_path).read_text(encoding="utf-8"))
|
||||
pol = OwnerAuthorizationPolicy(**data["policy"])
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(data["authorization_ref"], pol)
|
||||
return auth
|
||||
if use_reference:
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(DEFAULT_AZN_REF, reference_policy(expires_at=expires_at))
|
||||
return auth
|
||||
return None # authority 未装配 -> acquire_verified 直接 fail-closed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子命令实现
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_register(args: argparse.Namespace) -> int:
|
||||
expires_at = float(args.expires_at) if args.expires_at else None
|
||||
if args.policy_json:
|
||||
data = json.loads(Path(args.policy_json).read_text(encoding="utf-8"))
|
||||
pol = OwnerAuthorizationPolicy(**data["policy"])
|
||||
ref = data.get("authorization_ref", pol.authorization_id)
|
||||
else:
|
||||
pol = reference_policy(expires_at=expires_at)
|
||||
ref = DEFAULT_AZN_REF
|
||||
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(ref, pol)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "registered",
|
||||
"authorization_ref": ref,
|
||||
"policy_fingerprint": pol.policy_fingerprint,
|
||||
"subject": pol.subject,
|
||||
"repository": pol.repository,
|
||||
"allowed_branch_prefixes": list(pol.allowed_branch_prefixes),
|
||||
"risk_ceiling": pol.risk_ceiling,
|
||||
"expires_at": pol.expires_at,
|
||||
"note": "registered in-memory authority; field authority must persist",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_acquire(args: argparse.Namespace) -> int:
|
||||
from app.task_plane.governance_control_kernel import StaleManifest
|
||||
|
||||
# 使用独立工作目录构造 manifest + ledger,避免污染运行目录
|
||||
workdir = Path(args.workdir) if args.workdir else None
|
||||
if workdir is None:
|
||||
workdir = Path(tempfile.mkdtemp(prefix="gw_exec_"))
|
||||
|
||||
store = ManifestStore(str(workdir / "manifest.db"))
|
||||
ledger = ActionLedger(str(workdir / "ledger.db"))
|
||||
barrier = ConsistencyBarrier(["W1"])
|
||||
barrier.submit(
|
||||
DiscoveryObservation.build(
|
||||
worker="W1",
|
||||
runtime_fingerprint=args.runtime,
|
||||
process_start_identity=args.process_start,
|
||||
activation_digest="activation-exec",
|
||||
epoch_id="epoch-exec-prov",
|
||||
policy_digest="policy-exec",
|
||||
plan_provider_root="/plans/exec",
|
||||
task_id=args.task,
|
||||
grant_id="grant-exec",
|
||||
facts={},
|
||||
evidence_refs=(),
|
||||
observed_at=100.0,
|
||||
)
|
||||
)
|
||||
manifest_id = store.freeze(barrier.freeze())
|
||||
|
||||
auth = build_authority(
|
||||
policy_json_path=args.policy_json,
|
||||
use_reference=args.reference,
|
||||
expires_at=float(args.expires_at) if args.expires_at else None,
|
||||
)
|
||||
|
||||
task_status = args.task_status.upper()
|
||||
task = TaskState(task_id=args.task, status=task_status, revision=1)
|
||||
|
||||
if args.branch_mode == "create":
|
||||
branch_exists, head_oid = False, None
|
||||
elif args.branch_mode == "continue":
|
||||
branch_exists, head_oid = True, args.head_oid
|
||||
else: # auto: 由 read 决定(默认新分支)
|
||||
branch_exists, head_oid = (True, args.head_oid) if args.head_oid else (False, None)
|
||||
|
||||
runtime_status = FieldRuntimeStatus(
|
||||
legacy_global_writes_enabled=args.legacy_global,
|
||||
task_principal_writes_enabled=args.principal_writes,
|
||||
task_principal_verifier_ready=args.verifier_ready,
|
||||
governed_write_blockers=tuple(args.blocker or ()),
|
||||
)
|
||||
|
||||
# fail-closed authority 场景:--no-authority 显式不装配
|
||||
if args.no_authority:
|
||||
auth = None
|
||||
|
||||
adapters = _make_adapters(
|
||||
authority=auth,
|
||||
manifest_store=store,
|
||||
action_ledger=ledger,
|
||||
task=task,
|
||||
branch_exists=branch_exists,
|
||||
head_oid=head_oid,
|
||||
repo=args.repo if args.repo else "aios/aios",
|
||||
branch=args.branch if args.branch else f"agent/autonomy-{args.task}",
|
||||
generation=args.generation,
|
||||
runtime_status=runtime_status,
|
||||
)
|
||||
if auth is None:
|
||||
adapters.owner_authority = None
|
||||
|
||||
automation = _automation(store, ledger)
|
||||
request = build_request(
|
||||
task_id=args.task,
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint=args.runtime,
|
||||
process_start_identity=args.process_start,
|
||||
plan_digest=args.plan_digest,
|
||||
base_oid=args.base_oid,
|
||||
generation=args.generation,
|
||||
risk=args.risk,
|
||||
caps=set(_AZN_CAPS),
|
||||
files=tuple(args.files),
|
||||
)
|
||||
|
||||
try:
|
||||
session = automation.acquire_verified(
|
||||
request=request,
|
||||
authorization_ref=args.authorization_ref,
|
||||
readiness=runtime_status_to_readiness(runtime_status),
|
||||
epoch=None,
|
||||
adapters=adapters,
|
||||
now=time.time(),
|
||||
)
|
||||
print(json.dumps({"status": "ok", "version": VERSION, "session": session.to_dict()},
|
||||
ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except OwnerActionRequired as exc:
|
||||
print(json.dumps({"status": "owner_boundary", "version": VERSION, "reason": str(exc)},
|
||||
ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 3
|
||||
except AuthorizationDenied as exc:
|
||||
print(json.dumps({"status": "auth_denied", "version": VERSION, "reason": str(exc)},
|
||||
ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 2
|
||||
except Exception as exc: # noqa: BLE001 - CLI 边界统一收口
|
||||
print(json.dumps({"status": "error", "version": VERSION,
|
||||
"error": f"{type(exc).__name__}: {exc}"},
|
||||
ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
_cleanup_workdir(workdir)
|
||||
|
||||
|
||||
def _make_adapters(
|
||||
*,
|
||||
authority,
|
||||
manifest_store,
|
||||
action_ledger,
|
||||
task,
|
||||
branch_exists,
|
||||
head_oid,
|
||||
repo,
|
||||
branch,
|
||||
generation,
|
||||
runtime_status,
|
||||
):
|
||||
from app.task_plane.field_governed_writer import _DefaultExecutionPlane, _DefaultGit, \
|
||||
_DefaultJobs, _DefaultPrincipalIssuer, _DefaultTrustedSigner, _DefaultWriterLease
|
||||
from app.task_plane.governed_writer_automation import GovernedWriterAdapters
|
||||
from app.task_plane.governed_writer_automation import GovernedWriteReadiness
|
||||
|
||||
return GovernedWriterAdapters(
|
||||
signer=_DefaultTrustedSigner(),
|
||||
execution=_DefaultExecutionPlane(task),
|
||||
principal_issuer=_DefaultPrincipalIssuer(),
|
||||
jobs=_DefaultJobs(repository=repo, target_branch=branch, generation=generation),
|
||||
git=_DefaultGit(exists=branch_exists, head_oid=head_oid),
|
||||
writer_lease=_DefaultWriterLease(),
|
||||
owner_authority=authority,
|
||||
)
|
||||
|
||||
|
||||
def runtime_status_to_readiness(rs: FieldRuntimeStatus):
|
||||
from app.task_plane.governed_writer_automation import GovernedWriteReadiness
|
||||
|
||||
return GovernedWriteReadiness(
|
||||
legacy_global_writes_enabled=rs.legacy_global_writes_enabled,
|
||||
task_principal_writes_enabled=rs.task_principal_writes_enabled,
|
||||
task_principal_verifier_ready=rs.task_principal_verifier_ready,
|
||||
governed_write_blockers=tuple(rs.governed_write_blockers),
|
||||
)
|
||||
|
||||
|
||||
def _automation(store, ledger):
|
||||
from app.task_plane.governed_writer_automation import GovernedWriterAutomation
|
||||
|
||||
return GovernedWriterAutomation(manifest_store=store, action_ledger=ledger)
|
||||
|
||||
|
||||
def _cleanup_workdir(workdir: Path) -> None:
|
||||
import gc
|
||||
import shutil
|
||||
|
||||
try:
|
||||
gc.collect()
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
except Exception: # pragma: no cover - 清理失败不影响结果
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 参数与入口
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
import tempfile # noqa: F401 (确保模块可见)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="AIOS Owner 基于授权执行器 (V2R2C3)",
|
||||
prog="aios_owner_based_executor",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_register = sub.add_parser("register", help="登记 Owner 授权到 authority")
|
||||
p_register.add_argument("authorization_ref", nargs="?", default=DEFAULT_AZN_REF)
|
||||
p_register.add_argument("--policy-json", help="policy JSON 文件(含 authorization_ref + policy)")
|
||||
p_register.add_argument("--expires-at", help="override expires_at (unix)")
|
||||
p_register.set_defaults(func=cmd_register)
|
||||
|
||||
p_acquire = sub.add_parser("acquire", help="运行 acquire_verified() 完成受治理写链")
|
||||
p_acquire.add_argument("authorization_ref", nargs="?", default=DEFAULT_AZN_REF)
|
||||
p_acquire.add_argument("--no-authority", action="store_true",
|
||||
help="显式不装配 authority -> fail-closed")
|
||||
p_acquire.add_argument("--policy-json", help="从文件装载授权物(替代内置参考)")
|
||||
p_acquire.add_argument("--reference", action="store_true",
|
||||
help="使用内置参考授权物 azn-owner-gw-v1")
|
||||
p_acquire.add_argument("--expires-at", help="override expires_at (unix)")
|
||||
p_acquire.add_argument("--task", default="task-gw-exec-0001")
|
||||
p_acquire.add_argument("--task-status", default="QUEUED",
|
||||
choices=["QUEUED", "CLAIMED", "RUNNING", "EXPIRED"])
|
||||
p_acquire.add_argument("--branch-mode", default="create", choices=["create", "continue", "auto"])
|
||||
p_acquire.add_argument("--head-oid", help="继续分支的 live HEAD oid")
|
||||
p_acquire.add_argument("--base-oid", default="5cf20128d8be2d10aef87b09f8f7a2aecb2000f3")
|
||||
p_acquire.add_argument("--repo", help="仓库(默认 aios/aios)")
|
||||
p_acquire.add_argument("--branch", help="目标分支(默认 agent/autonomy-<task>)")
|
||||
p_acquire.add_argument("--generation", type=int, default=1)
|
||||
p_acquire.add_argument("--risk", default="medium")
|
||||
p_acquire.add_argument("--runtime", default="runtime-gw-exec")
|
||||
p_acquire.add_argument("--process-start", default="pid:gw:exec:1")
|
||||
p_acquire.add_argument("--plan-digest", default="sha256:plan-exec")
|
||||
p_acquire.add_argument("--files", nargs="*", default=[
|
||||
"active-runtime/app/task_plane/governance_control_kernel.py",
|
||||
"active-runtime/app/task_plane/governed_writer_automation.py",
|
||||
"active-runtime/app/task_plane/field_governed_writer.py",
|
||||
])
|
||||
p_acquire.add_argument("--workdir", help="manifest/ledger 工作目录(默认临时)")
|
||||
# 运行时状态(W-RUNTIME 只读线)
|
||||
p_acquire.add_argument("--legacy-global", action="store_true",
|
||||
help="legacy_global_writes_enabled(默认 false=不变式)")
|
||||
p_acquire.add_argument("--principal-writes", action="store_true", default=True,
|
||||
help="task_principal_writes_enabled(默认 true)")
|
||||
p_acquire.add_argument("--verifier-ready", action="store_true", default=True,
|
||||
help="task_principal_verifier_ready(默认 true)")
|
||||
p_acquire.add_argument("--blocker", action="append",
|
||||
help="governed_write_blockers 追加")
|
||||
p_acquire.set_defaults(func=cmd_acquire)
|
||||
|
||||
parser.add_argument("--version", action="version", version=VERSION)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return int(args.func(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile # noqa: F401
|
||||
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit(130)
|
||||
@@ -51,15 +51,30 @@ AUTHORIZATION_BUNDLE_SCHEMA = "aios.owner-authorization-bundle.v1"
|
||||
MACHINE_CAPABILITY_SCHEMA = "aios.machine-derived-capability.v1"
|
||||
BOOTSTRAP_STATE_SCHEMA = "aios.single-host-bootstrap-state.v1"
|
||||
OWNER_EPOCH_SCHEMA = "aios.owner-delegation-epoch.v2"
|
||||
SOURCE_PUBLICATION_POLICY_SCHEMA = "aios.source-publication-policy.v1"
|
||||
SOURCE_PUBLICATION_POLICY_ID = "C5_SOURCE_PUBLICATION_ONLY"
|
||||
SOURCE_PUBLICATION_CAPABILITIES = frozenset(
|
||||
{
|
||||
"agent-task:control",
|
||||
"governed.task.create",
|
||||
"governed.write.files",
|
||||
"task.principal.issue",
|
||||
"writer.lease",
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_REGISTRY_PATH = Path("/etc/aios/single-host-signer-registry.json")
|
||||
DEFAULT_OWNER_SIGNER_ID = "local-owner-authority"
|
||||
DEFAULT_MACHINE_SIGNER_ID = "machine-operational"
|
||||
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_CAP = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{2,255}$")
|
||||
_RISK = {"low": 0, "medium": 1, "high": 2}
|
||||
# ``engineering`` is deliberately between medium and high. Adding this rank
|
||||
# does not raise any configured ceiling; existing policy files keep their
|
||||
# existing value (the normal local-owner policy is ``medium``).
|
||||
_RISK = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
_MAX_JSON = 128 * 1024
|
||||
_MAX_KEY = 64 * 1024
|
||||
_MAGIC = b"SSHSIG"
|
||||
@@ -691,6 +706,30 @@ class OwnerAuthorizationBundle:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SourcePublicationPolicyRequest:
|
||||
"""One exact, source-only unattended publication authorization request."""
|
||||
|
||||
policy_id: str
|
||||
repository: str
|
||||
authority_base_oid: str
|
||||
source_oid: str
|
||||
source_tree_oid: str
|
||||
target_agent: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
execution_digest: str
|
||||
exact_files: tuple[str, ...]
|
||||
nonce: str
|
||||
ttl_seconds: int
|
||||
risk: str = "engineering"
|
||||
production_impact: str = "none"
|
||||
renewal: str = "none"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "exact_files", tuple(self.exact_files))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MachineCapabilityRequest:
|
||||
capability_id: str
|
||||
@@ -755,20 +794,28 @@ class LocalOwnerAuthority(LocalFileSigner):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_path: Path,
|
||||
key_path: Path | None,
|
||||
policy: SingleHostOwnerPolicy,
|
||||
*,
|
||||
require_root_owner: bool = True,
|
||||
clock: Any = time.time,
|
||||
registered_signer: Any | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
key_path,
|
||||
key_path or Path("/dev/null"),
|
||||
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.policy = policy
|
||||
self.clock = clock
|
||||
self.registered_signer = registered_signer
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
if self.registered_signer is not None:
|
||||
return str(self.registered_signer.fingerprint)
|
||||
return super().fingerprint
|
||||
|
||||
@staticmethod
|
||||
def _prefix_narrows(requested: str, allowed: Sequence[str]) -> bool:
|
||||
@@ -831,6 +878,111 @@ class LocalOwnerAuthority(LocalFileSigner):
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return self._sign_canonical(value)
|
||||
|
||||
def _sign_canonical(self, value: Mapping[str, Any]) -> SignedArtifact:
|
||||
if self.registered_signer is None:
|
||||
return super()._sign_canonical(value)
|
||||
payload = canonical_json_bytes(dict(value))
|
||||
signature = self.registered_signer._sign_canonical_payload(
|
||||
self.namespace, payload
|
||||
)
|
||||
return SignedArtifact(
|
||||
payload=dict(value),
|
||||
canonical_payload=payload,
|
||||
signature=signature,
|
||||
key_id=self.fingerprint,
|
||||
namespace=self.namespace,
|
||||
)
|
||||
|
||||
def issue_source_publication_policy(
|
||||
self, request: SourcePublicationPolicyRequest
|
||||
) -> SignedArtifact:
|
||||
"""Sign one exact C5 source-publication policy.
|
||||
|
||||
This is an issuance operation only. It cannot change the configured
|
||||
owner policy, its ceiling, branch/file allowlists, or trust domain.
|
||||
"""
|
||||
now = int(self.clock())
|
||||
files = tuple(request.exact_files)
|
||||
def source_scope_narrows(value: str, allowed: Sequence[str]) -> bool:
|
||||
base = value.rstrip("/")
|
||||
return any(
|
||||
base == prefix.rstrip("/")
|
||||
or base.startswith(prefix.rstrip("/") + "/")
|
||||
for prefix in allowed
|
||||
)
|
||||
if (
|
||||
request.policy_id != SOURCE_PUBLICATION_POLICY_ID
|
||||
or request.repository != self.policy.repository
|
||||
or not _HEX40.fullmatch(request.authority_base_oid)
|
||||
or not _HEX40.fullmatch(request.source_oid)
|
||||
or not _HEX40.fullmatch(request.source_tree_oid)
|
||||
or not _ID.fullmatch(request.target_agent)
|
||||
or not request.target_agent.startswith("agent/")
|
||||
or not source_scope_narrows(
|
||||
request.target_branch, self.policy.allowed_branch_prefixes
|
||||
)
|
||||
or not request.target_branch.startswith("agent/")
|
||||
or "\\" in request.target_branch
|
||||
or ".." in PurePosixPath(request.target_branch).parts
|
||||
or request.target_branch.endswith(("/", "."))
|
||||
or type(request.generation) is not int
|
||||
or request.generation < 1
|
||||
or not _HEX64.fullmatch(request.execution_digest)
|
||||
or not files
|
||||
or any(
|
||||
not item
|
||||
or PurePosixPath(item).is_absolute()
|
||||
or "\\" in item
|
||||
or ".." in PurePosixPath(item).parts
|
||||
or item.startswith(".git")
|
||||
or not source_scope_narrows(item, self.policy.allowed_file_prefixes)
|
||||
for item in files
|
||||
)
|
||||
or not SOURCE_PUBLICATION_CAPABILITIES.issubset(self.policy.allowed_capabilities)
|
||||
or len(set(files)) != len(files)
|
||||
or not _ID.fullmatch(request.nonce)
|
||||
or request.risk != "engineering"
|
||||
or request.risk not in _RISK
|
||||
or _RISK[request.risk] > _RISK[self.policy.risk_ceiling]
|
||||
or request.production_impact != "none"
|
||||
or request.renewal != "none"
|
||||
or type(request.ttl_seconds) is not int
|
||||
or not 1 <= request.ttl_seconds <= 3600
|
||||
or request.ttl_seconds > self.policy.maximum_epoch_ttl_seconds
|
||||
):
|
||||
_fail("SOURCE_PUBLICATION_REQUEST_WIDENS_POLICY")
|
||||
value: dict[str, Any] = {
|
||||
"schema": SOURCE_PUBLICATION_POLICY_SCHEMA,
|
||||
"policy_id": SOURCE_PUBLICATION_POLICY_ID,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"repository": request.repository,
|
||||
"trust_domain": self.policy.trust_domain,
|
||||
"policy_digest": self.policy.digest,
|
||||
"authority_base_oid": request.authority_base_oid,
|
||||
"source_oid": request.source_oid,
|
||||
"source_tree_oid": request.source_tree_oid,
|
||||
"target_agent": request.target_agent,
|
||||
"target_branch": request.target_branch,
|
||||
"generation": request.generation,
|
||||
"execution_digest": request.execution_digest,
|
||||
"exact_files": list(files),
|
||||
"capabilities": sorted(SOURCE_PUBLICATION_CAPABILITIES),
|
||||
"risk": "engineering",
|
||||
"production_impact": "none",
|
||||
"renewal": "none",
|
||||
"nonce": request.nonce,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 30),
|
||||
"expires_at": now + request.ttl_seconds,
|
||||
"status": "ACTIVE",
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": self.fingerprint,
|
||||
}
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return self._sign_canonical(value)
|
||||
|
||||
issue_source_only_policy = issue_source_publication_policy
|
||||
|
||||
def issue_authorization_bundle(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -35,6 +35,8 @@ from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
SCHEMA_ENVELOPE = "aios.task-admission-envelope.v1"
|
||||
SCHEMA_GRANT = "aios.task-admission-grant.v1"
|
||||
SCHEMA_EPOCH = "aios.owner-delegation-epoch.v1"
|
||||
SCHEMA_EPOCH_V2 = "aios.owner-delegation-epoch.v2"
|
||||
SUPPORTED_EPOCH_SCHEMAS = frozenset({SCHEMA_EPOCH, SCHEMA_EPOCH_V2})
|
||||
SCHEMA_RECEIPT = "aios.task-admission-receipt.v1"
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
@@ -142,9 +144,30 @@ class OwnerDelegationEpoch:
|
||||
revocation_epoch: int
|
||||
policy_digest: str
|
||||
schema: str = SCHEMA_EPOCH
|
||||
production_scope: str | None = None
|
||||
destructive_policy: str | None = None
|
||||
# Provenance retained by the consumer. These fields are populated by the
|
||||
# signed-directory provider for v2 (and left at their v1-compatible
|
||||
# defaults when reading legacy epochs); they are never used to translate or
|
||||
# re-sign the original artifact.
|
||||
status: str = ""
|
||||
signature_namespace: str = ""
|
||||
signature_key_id: str = ""
|
||||
canonical_payload_digest: str = ""
|
||||
canonical_payload: bytes = b""
|
||||
|
||||
@property
|
||||
def issuer(self) -> str:
|
||||
"""The signed epoch issuer, retained under the legacy identity name."""
|
||||
return self.owner_identity
|
||||
|
||||
@property
|
||||
def canonical_payload_bytes(self) -> bytes:
|
||||
return self.canonical_payload
|
||||
|
||||
def as_public_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value.pop("canonical_payload", None)
|
||||
value["allowed_capabilities"] = sorted(self.allowed_capabilities)
|
||||
value["allowed_branch_prefixes"] = list(self.allowed_branch_prefixes)
|
||||
value["allowed_file_prefixes"] = list(self.allowed_file_prefixes)
|
||||
@@ -309,7 +332,7 @@ def _normalize_capabilities(
|
||||
|
||||
|
||||
def _risk_rank(value: str) -> int:
|
||||
ranks = {"low": 0, "medium": 1, "high": 2}
|
||||
ranks = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
try:
|
||||
return ranks[value]
|
||||
except KeyError:
|
||||
@@ -838,7 +861,7 @@ class TaskAdmissionBootstrapService:
|
||||
now: int,
|
||||
) -> OwnerDelegationEpoch:
|
||||
epoch = self.authorization.verify_delegation_epoch(epoch_id)
|
||||
if epoch.schema != SCHEMA_EPOCH:
|
||||
if epoch.schema not in SUPPORTED_EPOCH_SCHEMAS:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_SCHEMA_INVALID", 403)
|
||||
if epoch.epoch_id != epoch_id:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_ID_MISMATCH", 403)
|
||||
|
||||
@@ -11,10 +11,12 @@ import base64
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import time
|
||||
@@ -24,15 +26,39 @@ import urllib.request
|
||||
|
||||
from aios_task_admission_bootstrap import (
|
||||
OwnerDelegationEpoch,
|
||||
SCHEMA_EPOCH,
|
||||
SCHEMA_EPOCH_V2,
|
||||
TaskAdmissionError,
|
||||
TaskAdmissionGrant,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
from aios_gitops_job_controller import (
|
||||
GitOpsJobController as _FLGitOpsJobController,
|
||||
JobState as _FLJobState,
|
||||
sha256_json as _fl_job_sha256_json,
|
||||
)
|
||||
from aios_gitops_job_identity import (
|
||||
legacy_task_storage_key as _fl_legacy_task_storage_key,
|
||||
task_storage_key as _fl_task_storage_key,
|
||||
)
|
||||
|
||||
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
OWNER_SIGNER_IDENTITY = "aios-task-grant-signer"
|
||||
_EPOCH_V1_FIELDS = frozenset({
|
||||
"epoch_id", "owner_identity", "trust_domain", "repository",
|
||||
"allowed_branch_prefixes", "allowed_capabilities", "allowed_file_prefixes",
|
||||
"risk_ceiling", "issued_at", "not_before", "expires_at",
|
||||
"revocation_epoch", "policy_digest", "schema",
|
||||
})
|
||||
_EPOCH_V2_FIELDS = frozenset({
|
||||
*_EPOCH_V1_FIELDS,
|
||||
"business_goal", "production_scope", "destructive_policy",
|
||||
"signature_namespace", "signature_key_id", "canonical_payload_digest",
|
||||
"status",
|
||||
})
|
||||
_SSH_FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")
|
||||
|
||||
|
||||
class OfficialTaskTransport(Protocol):
|
||||
@@ -452,6 +478,7 @@ class TaskAdmissionPrincipalProvider:
|
||||
class TaskAdmissionRuntimeBundle:
|
||||
handlers: Mapping[str, Any]
|
||||
principal_verifier: TaskAdmissionPrincipalProvider
|
||||
service: Any | None = None
|
||||
|
||||
|
||||
class TaskAdmissionHostError(RuntimeError):
|
||||
@@ -611,11 +638,15 @@ class SignedEpochDirectoryProvider:
|
||||
"TASK_ADMISSION_EPOCH_CANONICAL_MISMATCH",
|
||||
403,
|
||||
)
|
||||
if value.get("schema") != "aios.owner-delegation-epoch.v1":
|
||||
schema = value.get("schema")
|
||||
if schema not in {SCHEMA_EPOCH, SCHEMA_EPOCH_V2}:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EPOCH_SCHEMA_INVALID",
|
||||
403,
|
||||
)
|
||||
expected_fields = _EPOCH_V1_FIELDS if schema == SCHEMA_EPOCH else _EPOCH_V2_FIELDS
|
||||
if set(value) != expected_fields:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_FIELDS_INVALID", 403)
|
||||
if value.get("epoch_id") != epoch_id:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EPOCH_ID_MISMATCH",
|
||||
@@ -631,7 +662,34 @@ class SignedEpochDirectoryProvider:
|
||||
if epoch_id in self._revoked_ids():
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REVOKED", 403)
|
||||
|
||||
# v2 is a producer-authored envelope. Validate its self-digest and
|
||||
# signature binding before the cryptographic verifier sees the exact
|
||||
# bytes from disk. The digest is over the payload with only the digest
|
||||
# member removed; no schema translation is permitted.
|
||||
if schema == SCHEMA_EPOCH_V2:
|
||||
if (
|
||||
value.get("status") != "ACTIVE"
|
||||
or not isinstance(value.get("business_goal"), str)
|
||||
or not value["business_goal"]
|
||||
or not isinstance(value.get("production_scope"), str)
|
||||
or not value["production_scope"]
|
||||
or not isinstance(value.get("destructive_policy"), str)
|
||||
or value["destructive_policy"] not in {"forbidden", "rollback-only"}
|
||||
or value.get("signature_namespace") != OWNER_NAMESPACE
|
||||
or not isinstance(value.get("signature_key_id"), str)
|
||||
or not _SSH_FINGERPRINT_RE.fullmatch(value["signature_key_id"])
|
||||
or not isinstance(value.get("canonical_payload_digest"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", value["canonical_payload_digest"])
|
||||
):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_V2_BINDING_INVALID", 403)
|
||||
digest_value = dict(value)
|
||||
supplied_digest = digest_value.pop("canonical_payload_digest", None)
|
||||
if sha256_json(digest_value) != supplied_digest:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_PAYLOAD_DIGEST_INVALID", 403)
|
||||
|
||||
key_id = key_id_file.read_text(encoding="utf-8").strip()
|
||||
if schema == SCHEMA_EPOCH_V2 and key_id != value["signature_key_id"]:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_KEY_BINDING_INVALID", 403)
|
||||
armored = signature.read_text(encoding="utf-8")
|
||||
if self.verifier.verify(
|
||||
namespace=OWNER_NAMESPACE,
|
||||
@@ -648,6 +706,23 @@ class SignedEpochDirectoryProvider:
|
||||
not_before = value.get("not_before")
|
||||
expires_at = value.get("expires_at")
|
||||
if (
|
||||
not isinstance(value.get("epoch_id"), str)
|
||||
or not value["epoch_id"]
|
||||
or not isinstance(value.get("owner_identity"), str)
|
||||
or not value["owner_identity"]
|
||||
or not isinstance(value.get("trust_domain"), str)
|
||||
or not value["trust_domain"]
|
||||
or not isinstance(value.get("repository"), str)
|
||||
or not value["repository"]
|
||||
or not isinstance(value.get("risk_ceiling"), str)
|
||||
or value["risk_ceiling"] not in {"low", "medium", "engineering", "high"}
|
||||
or not isinstance(value.get("issued_at"), int)
|
||||
or isinstance(value.get("issued_at"), bool)
|
||||
or not isinstance(value.get("revocation_epoch"), int)
|
||||
or isinstance(value.get("revocation_epoch"), bool)
|
||||
or not isinstance(value.get("policy_digest"), str)
|
||||
or not value["policy_digest"]
|
||||
or
|
||||
not isinstance(not_before, int)
|
||||
or isinstance(not_before, bool)
|
||||
or not isinstance(expires_at, int)
|
||||
@@ -661,13 +736,16 @@ class SignedEpochDirectoryProvider:
|
||||
files = value.get("allowed_file_prefixes")
|
||||
if (
|
||||
not isinstance(branches, list)
|
||||
or not branches
|
||||
or not all(isinstance(item, str) and item for item in branches)
|
||||
or not isinstance(capabilities, list)
|
||||
or not capabilities
|
||||
or not all(
|
||||
isinstance(item, str) and item
|
||||
for item in capabilities
|
||||
)
|
||||
or not isinstance(files, list)
|
||||
or not files
|
||||
or not all(isinstance(item, str) and item for item in files)
|
||||
):
|
||||
raise TaskAdmissionError(
|
||||
@@ -690,6 +768,19 @@ class SignedEpochDirectoryProvider:
|
||||
revocation_epoch=int(value["revocation_epoch"]),
|
||||
policy_digest=str(value["policy_digest"]),
|
||||
schema=str(value["schema"]),
|
||||
production_scope=(
|
||||
str(value["production_scope"])
|
||||
if schema == SCHEMA_EPOCH_V2 else None
|
||||
),
|
||||
destructive_policy=(
|
||||
str(value["destructive_policy"])
|
||||
if schema == SCHEMA_EPOCH_V2 else None
|
||||
),
|
||||
status=str(value.get("status", "")),
|
||||
signature_namespace=str(value.get("signature_namespace", "")),
|
||||
signature_key_id=str(value.get("signature_key_id", "")),
|
||||
canonical_payload_digest=str(value.get("canonical_payload_digest", "")),
|
||||
canonical_payload=payload,
|
||||
)
|
||||
|
||||
|
||||
@@ -904,6 +995,7 @@ def _build_task_admission_runtime(
|
||||
task_port=task_port,
|
||||
epoch_id=epoch.epoch_id,
|
||||
),
|
||||
service=service,
|
||||
)
|
||||
|
||||
|
||||
@@ -1069,6 +1161,8 @@ PLAN_FIELDS_V3=frozenset({
|
||||
_SHA256=re.compile(r"^[0-9a-f]{64}$")
|
||||
_OID=re.compile(r"^[0-9a-f]{40}$")
|
||||
_JOB_ID=re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$")
|
||||
_TASK_ID=re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
_TASK_BRANCH=re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
|
||||
|
||||
class ProducerReceiptError(RuntimeError):
|
||||
@@ -1130,6 +1224,38 @@ def require_target_grant(grant:object)->None:
|
||||
fail("TASK_ADMISSION_EXECUTION_DIGEST_INVALID")
|
||||
|
||||
|
||||
def require_reservable_grant(grant:object)->None:
|
||||
"""Validate the authenticated, persisted grant accepted by the producer.
|
||||
|
||||
The historical target keeps its exact tuple. Every other target is driven
|
||||
by the grant persisted by TaskAdmissionBootstrap; no Mission metadata or
|
||||
caller-supplied execution identity is consulted here.
|
||||
"""
|
||||
task_id=_gv(grant,"task_id")
|
||||
if task_id==TARGET_TASK_ID:
|
||||
require_target_grant(grant)
|
||||
return
|
||||
if not isinstance(task_id,str) or not _TASK_ID.fullmatch(task_id):
|
||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_TASK_INVALID")
|
||||
if _gv(grant,"repository")!=TARGET_REPOSITORY:
|
||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_REPOSITORY_INVALID")
|
||||
base=_gv(grant,"authority_base_oid")
|
||||
if not isinstance(base,str) or not _OID.fullmatch(base):
|
||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_BASE_INVALID")
|
||||
branch=_gv(grant,"target_branch")
|
||||
if not isinstance(branch,str) or not _TASK_BRANCH.fullmatch(branch):
|
||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_BRANCH_INVALID")
|
||||
generation=_gv(grant,"generation")
|
||||
if isinstance(generation,bool) or not isinstance(generation,int) or generation<1:
|
||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_GENERATION_INVALID")
|
||||
digest=_gv(grant,"execution_digest")
|
||||
if not isinstance(digest,str) or not _SHA256.fullmatch(digest):
|
||||
fail("TASK_ADMISSION_EXECUTION_DIGEST_INVALID")
|
||||
grant_id=_gv(grant,"grant_id")
|
||||
if not isinstance(grant_id,str) or not grant_id:
|
||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_GRANT_INVALID")
|
||||
|
||||
|
||||
def _safe_path(value:object)->str:
|
||||
if not isinstance(value,str) or not value or "\x00" in value:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_PATH_INVALID")
|
||||
@@ -1152,7 +1278,7 @@ def _unique_strings(value:object,code:str)->list[str]:
|
||||
|
||||
|
||||
def validate_v3_plan(*,grant:object,raw:bytes)->dict[str,Any]:
|
||||
require_target_grant(grant)
|
||||
require_reservable_grant(grant)
|
||||
plan=strict_json_object(raw)
|
||||
if set(plan)!=PLAN_FIELDS_V3:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FIELDS_INVALID")
|
||||
@@ -1195,14 +1321,17 @@ def validate_v3_plan(*,grant:object,raw:bytes)->dict[str,Any]:
|
||||
|
||||
|
||||
def load_v3_plan(root:Path,grant:object)->dict[str,Any]:
|
||||
require_target_grant(grant)
|
||||
require_reservable_grant(grant)
|
||||
try:
|
||||
rst=root.lstat()
|
||||
except OSError:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_UNAVAILABLE")
|
||||
if (
|
||||
stat.S_ISLNK(rst.st_mode) or not stat.S_ISDIR(rst.st_mode)
|
||||
or rst.st_uid!=0 or rst.st_mode&0o022
|
||||
or (
|
||||
_host_posix_metadata_enforced()
|
||||
and (rst.st_uid!=0 or rst.st_mode&0o022)
|
||||
)
|
||||
):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_DIRECTORY_INVALID")
|
||||
path=root/(str(_gv(grant,"execution_digest"))+".json")
|
||||
@@ -1212,7 +1341,10 @@ def load_v3_plan(root:Path,grant:object)->dict[str,Any]:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_UNAVAILABLE")
|
||||
if (
|
||||
stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode)
|
||||
or st.st_uid!=0 or st.st_mode&0o022
|
||||
or (
|
||||
_host_posix_metadata_enforced()
|
||||
and (st.st_uid!=0 or st.st_mode&0o022)
|
||||
)
|
||||
):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_INVALID")
|
||||
try:
|
||||
@@ -1223,25 +1355,30 @@ def load_v3_plan(root:Path,grant:object)->dict[str,Any]:
|
||||
|
||||
|
||||
def reservation_idempotency_key(grant:object)->str:
|
||||
require_target_grant(grant)
|
||||
require_reservable_grant(grant)
|
||||
material=(
|
||||
str(_gv(grant,"grant_id"))+"\0"+str(_gv(grant,"execution_digest"))
|
||||
).encode("utf-8")
|
||||
return "task-admission-execution-reservation-"+hashlib.sha256(material).hexdigest()[:32]
|
||||
|
||||
|
||||
def build_reservation_request(grant:object)->dict[str,Any]:
|
||||
require_target_grant(grant)
|
||||
return {
|
||||
"task_id":TARGET_TASK_ID,
|
||||
"repository":TARGET_REPOSITORY,
|
||||
"base_oid":TARGET_BASE_OID,
|
||||
"target_branch":TARGET_BRANCH,
|
||||
"generation":TARGET_GENERATION,
|
||||
def build_reservation_request(
|
||||
grant:object,*,expected_branch_oid:str|None=None,
|
||||
)->dict[str,Any]:
|
||||
require_reservable_grant(grant)
|
||||
request={
|
||||
"task_id":str(_gv(grant,"task_id")),
|
||||
"repository":str(_gv(grant,"repository")),
|
||||
"base_oid":str(_gv(grant,"authority_base_oid")),
|
||||
"target_branch":str(_gv(grant,"target_branch")),
|
||||
"generation":int(_gv(grant,"generation")),
|
||||
"risk":str(_gv(grant,"risk")),
|
||||
"description":"Reserve one inert durable GitOps Job identity after exact Owner TaskAdmission authorization; no Agent Task claim or execution.",
|
||||
"idempotency_key":reservation_idempotency_key(grant),
|
||||
}
|
||||
if expected_branch_oid is not None:
|
||||
request["expected_branch_oid"]=expected_branch_oid
|
||||
return request
|
||||
|
||||
|
||||
def _mapping(value:object,code:str)->Mapping[str,Any]:
|
||||
@@ -1256,21 +1393,22 @@ def validate_bootstrap_response(
|
||||
request:Mapping[str,Any],
|
||||
response:object,
|
||||
expected_side_effect_state:str,
|
||||
expected_branch_mode:str="CONTINUE_EXISTING",
|
||||
)->str:
|
||||
require_target_grant(grant)
|
||||
require_reservable_grant(grant)
|
||||
result=_mapping(response,"TASK_ADMISSION_EXECUTION_RESERVATION_RESPONSE_INVALID")
|
||||
job_id=result.get("job_id")
|
||||
if not isinstance(job_id,str) or not _JOB_ID.fullmatch(job_id):
|
||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_JOB_ID_INVALID")
|
||||
expected={
|
||||
"task_id":TARGET_TASK_ID,
|
||||
"task_id":_gv(grant,"task_id"),
|
||||
"job_id":job_id,
|
||||
"repository":TARGET_REPOSITORY,
|
||||
"base_oid":TARGET_BASE_OID,
|
||||
"expected_branch_oid":TARGET_BASE_OID,
|
||||
"target_branch":TARGET_BRANCH,
|
||||
"branch_mode":"CONTINUE_EXISTING",
|
||||
"generation":TARGET_GENERATION,
|
||||
"repository":_gv(grant,"repository"),
|
||||
"base_oid":_gv(grant,"authority_base_oid"),
|
||||
"expected_branch_oid":request.get("expected_branch_oid",_gv(grant,"authority_base_oid")),
|
||||
"target_branch":_gv(grant,"target_branch"),
|
||||
"branch_mode":expected_branch_mode,
|
||||
"generation":_gv(grant,"generation"),
|
||||
"state":"BOUND",
|
||||
}
|
||||
for key,value in expected.items():
|
||||
@@ -1333,13 +1471,20 @@ def validate_bootstrap_replay(
|
||||
first:object,
|
||||
replay:object,
|
||||
replay_side_effect_state:str,
|
||||
first_side_effect_state:str="QUEUED",
|
||||
expected_branch_mode:str="CONTINUE_EXISTING",
|
||||
)->str:
|
||||
if first_side_effect_state not in {"QUEUED","SAME_JOB"}:
|
||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_PHASE_INVALID")
|
||||
first_id=validate_bootstrap_response(
|
||||
grant=grant,request=request,response=first,expected_side_effect_state="QUEUED"
|
||||
grant=grant,request=request,response=first,
|
||||
expected_side_effect_state=first_side_effect_state,
|
||||
expected_branch_mode=expected_branch_mode,
|
||||
)
|
||||
replay_id=validate_bootstrap_response(
|
||||
grant=grant,request=request,response=replay,
|
||||
expected_side_effect_state=replay_side_effect_state
|
||||
expected_side_effect_state=replay_side_effect_state,
|
||||
expected_branch_mode=expected_branch_mode,
|
||||
)
|
||||
if first_id!=replay_id:
|
||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_REPLAY_JOB_CONFLICT")
|
||||
@@ -1355,6 +1500,12 @@ import inspect as _fl_inspect
|
||||
_FL_HARDENING_PROVIDER = ExecutionPlanDirectoryProvider
|
||||
_FL_BUILD_FROM_ACTIVATION = build_task_admission_handlers_from_activation_file
|
||||
_FL_BUILD_FROM_ENVIRONMENT = globals().get("build_task_admission_handlers_from_environment")
|
||||
_FL_BUILD_RUNTIME_FROM_ACTIVATION = globals().get(
|
||||
"build_task_admission_runtime_from_activation_file"
|
||||
)
|
||||
_FL_BUILD_RUNTIME_FROM_ENVIRONMENT = globals().get(
|
||||
"build_task_admission_runtime_from_environment"
|
||||
)
|
||||
_FL_APPROVED_BOOTSTRAP_SHA256="758895a8407568890fd2f8eb2f232a3dbf2cfa369e5f65ae8c6975bf8d01e3b5"
|
||||
REPLAY_SIDE_EFFECT_STATE="SAME_JOB"
|
||||
|
||||
@@ -1395,38 +1546,428 @@ def _fl_assert_bootstrap_source(bootstrap:object)->None:
|
||||
if _fl_sha(path)!=_FL_APPROVED_BOOTSTRAP_SHA256:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BOOTSTRAP_SOURCE_DRIFT",503)
|
||||
|
||||
|
||||
def _fl_reservation_refs(runtime:object,grant:object)->tuple[str,str|None,str,str]:
|
||||
require_reservable_grant(grant)
|
||||
authority=getattr(runtime,"authority",None)
|
||||
if authority is None or not callable(getattr(authority,"read_ref",None)):
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
||||
base=str(_gv(grant,"authority_base_oid"))
|
||||
branch=str(_gv(grant,"target_branch"))
|
||||
try:
|
||||
main=authority.read_ref("refs/heads/main")
|
||||
target=authority.read_ref("refs/heads/"+branch)
|
||||
except Exception as exc:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_UNAVAILABLE",503) from exc
|
||||
if main!=base:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_DRIFT",409)
|
||||
if _gv(grant,"task_id")==TARGET_TASK_ID:
|
||||
if target!=base:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_DRIFT",409)
|
||||
return main,target,"CONTINUE_EXISTING",base
|
||||
if target is None:
|
||||
if _gv(grant,"generation")!=1:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_GENERATION_CONFLICT",409)
|
||||
return main,target,"CREATE_NEW",base
|
||||
if target==base:
|
||||
return main,target,"CONTINUE_EXISTING",base
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_DRIFT",409)
|
||||
|
||||
|
||||
def _fl_validate_durable_binding(
|
||||
*,grant:object,request:Mapping[str,Any],durable:object,job_id:str,
|
||||
expected_branch_mode:str,
|
||||
)->None:
|
||||
expected={
|
||||
"task_id":_gv(grant,"task_id"),
|
||||
"job_id":job_id,
|
||||
"repository":_gv(grant,"repository"),
|
||||
"base_oid":_gv(grant,"authority_base_oid"),
|
||||
"expected_branch_oid":request.get("expected_branch_oid"),
|
||||
"target_branch":_gv(grant,"target_branch"),
|
||||
"branch_mode":expected_branch_mode,
|
||||
"generation":_gv(grant,"generation"),
|
||||
"risk":_gv(grant,"risk"),
|
||||
"idempotency_key":request.get("idempotency_key"),
|
||||
"description":request.get("description"),
|
||||
}
|
||||
if any(getattr(durable,field,None)!=value for field,value in expected.items()):
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_DURABLE_BINDING_CONFLICT",409)
|
||||
|
||||
|
||||
def _fl_existing_reservation_readback(
|
||||
*,bootstrap:object,grant:object,request:Mapping[str,Any],durable:object,
|
||||
expected_branch_mode:str,
|
||||
)->dict[str,Any]:
|
||||
"""Verify an existing producer reservation without opening a mutating store.
|
||||
|
||||
``GovernedTaskBootstrap._store`` initializes missing directories and SQLite
|
||||
state, while ``create`` may submit a new Job. Neither is valid after a
|
||||
durable task binding already exists: missing producer state is corruption,
|
||||
not crash recovery. Read the exact Job and idempotency rows through a
|
||||
read-only SQLite URI and return an explicitly typed readback proof. This
|
||||
is deliberately not a producer submit receipt: Job lifecycle state may
|
||||
have advanced since the reservation was first created.
|
||||
"""
|
||||
task_id=str(_gv(grant,"task_id"))
|
||||
job_id=getattr(durable,"job_id",None)
|
||||
if not isinstance(job_id,str) or not _JOB_ID.fullmatch(job_id):
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_JOB_ID_INVALID",409)
|
||||
key=request.get("idempotency_key")
|
||||
if not isinstance(key,str) or not key:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_IDEMPOTENCY_INVALID",409)
|
||||
try:
|
||||
expected_job_id=_FLGitOpsJobController.internal_job_id_for_idempotency_key(key)
|
||||
producer_key=_fl_job_sha256_json({"client_idempotency_key":key})
|
||||
except Exception as exc:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_IDEMPOTENCY_INVALID",409) from exc
|
||||
if job_id!=expected_job_id or job_id!="job-"+producer_key[:32]:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_DURABLE_BINDING_CONFLICT",409)
|
||||
|
||||
job_root=getattr(bootstrap,"job_root",None)
|
||||
if not isinstance(job_root,(str,os.PathLike)):
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
||||
job_root=Path(job_root)
|
||||
store_root=job_root/_fl_task_storage_key(task_id)
|
||||
legacy_root=job_root/_fl_legacy_task_storage_key(task_id)
|
||||
try:
|
||||
legacy_stat=None
|
||||
try:
|
||||
legacy_stat=legacy_root.lstat()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if legacy_root!=store_root and legacy_stat is not None:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_CONFLICT",409)
|
||||
store_stat=store_root.lstat()
|
||||
db_path=store_root/"state.sqlite3"
|
||||
db_stat=db_path.lstat()
|
||||
except FileNotFoundError as exc:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_MISSING",409) from exc
|
||||
except OSError as exc:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_READ_FAILED",503) from exc
|
||||
if (
|
||||
stat.S_ISLNK(store_stat.st_mode) or not stat.S_ISDIR(store_stat.st_mode)
|
||||
or stat.S_ISLNK(db_stat.st_mode) or not stat.S_ISREG(db_stat.st_mode)
|
||||
):
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_INVALID",409)
|
||||
|
||||
values={
|
||||
field:str(request[field]) for field in (
|
||||
"task_id","repository","base_oid","target_branch","risk",
|
||||
"description","idempotency_key",
|
||||
)
|
||||
}
|
||||
payload=json.dumps(values,sort_keys=True,separators=(",",":")).encode()
|
||||
payload_digest=hashlib.sha256(payload).hexdigest()
|
||||
expected_branch_oid=str(
|
||||
request.get("expected_branch_oid") or values["base_oid"]
|
||||
)
|
||||
producer_binding={
|
||||
"campaign_id":task_id,
|
||||
"repository":values["repository"],
|
||||
"operation":"GOVERNED_TASK_CREATE",
|
||||
"branch":values["target_branch"],
|
||||
"expected_branch_oid":expected_branch_oid,
|
||||
"payload_digest":payload_digest,
|
||||
"idempotency_key":key,
|
||||
"task_id":task_id,
|
||||
"lineage_anchor_oid":values["base_oid"],
|
||||
}
|
||||
expected_request={
|
||||
**producer_binding,
|
||||
"binding":producer_binding,
|
||||
"payload_ref":"sha256/"+payload_digest,
|
||||
"payload_size":len(payload),
|
||||
}
|
||||
expected_job={
|
||||
"job_id":job_id,
|
||||
"idempotency_key":producer_key,
|
||||
"campaign_id":task_id,
|
||||
"repository":values["repository"],
|
||||
"operation":"GOVERNED_TASK_CREATE",
|
||||
"branch":values["target_branch"],
|
||||
"expected_branch_oid":expected_branch_oid,
|
||||
"payload_digest":payload_digest,
|
||||
"payload_ref":"sha256/"+payload_digest,
|
||||
"payload_size":len(payload),
|
||||
}
|
||||
# mode=ro prevents schema/data creation while still including committed WAL
|
||||
# rows. query_only supplies a second SQLite-level write prohibition.
|
||||
try:
|
||||
resolved_store=store_root.resolve(strict=True)
|
||||
resolved_db=db_path.resolve(strict=True)
|
||||
if resolved_db.parent!=resolved_store:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_INVALID",409)
|
||||
connection=sqlite3.connect(
|
||||
resolved_db.as_uri()+"?mode=ro",
|
||||
uri=True,timeout=5.0,
|
||||
)
|
||||
connection.row_factory=sqlite3.Row
|
||||
try:
|
||||
connection.execute("PRAGMA query_only=ON")
|
||||
connection.execute("BEGIN")
|
||||
job=connection.execute(
|
||||
"SELECT job_id,idempotency_key,campaign_id,repository,operation,"
|
||||
"branch,expected_branch_oid,payload_digest,payload_ref,payload_size,"
|
||||
"state,side_effect_state,revision,request_json FROM jobs WHERE job_id=?",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
idempotency=connection.execute(
|
||||
"SELECT key,job_id,binding_json FROM idempotency_keys WHERE key=?",
|
||||
(producer_key,),
|
||||
).fetchone()
|
||||
store_after=store_root.lstat()
|
||||
db_after=db_path.lstat()
|
||||
if (
|
||||
(store_stat.st_dev,store_stat.st_ino)!=(store_after.st_dev,store_after.st_ino)
|
||||
or (db_stat.st_dev,db_stat.st_ino)!=(db_after.st_dev,db_after.st_ino)
|
||||
or stat.S_ISLNK(store_after.st_mode)
|
||||
or not stat.S_ISDIR(store_after.st_mode)
|
||||
or stat.S_ISLNK(db_after.st_mode)
|
||||
or not stat.S_ISREG(db_after.st_mode)
|
||||
):
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_CHANGED",409)
|
||||
finally:
|
||||
connection.close()
|
||||
del connection
|
||||
except (OSError,sqlite3.Error) as exc:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STORE_READ_FAILED",503) from exc
|
||||
if job is None:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_MISSING",409)
|
||||
if idempotency is None:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_IDEMPOTENCY_MISSING",409)
|
||||
if any(job[field]!=value for field,value in expected_job.items()):
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_CONFLICT",409)
|
||||
valid_states={item.value for item in _FLJobState}
|
||||
if (
|
||||
job["state"] not in valid_states
|
||||
or not isinstance(job["side_effect_state"],str)
|
||||
or not job["side_effect_state"]
|
||||
or not isinstance(job["revision"],int)
|
||||
or isinstance(job["revision"],bool)
|
||||
or job["revision"]<0
|
||||
):
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_STATE_INVALID",409)
|
||||
try:
|
||||
recorded_request=json.loads(job["request_json"])
|
||||
recorded_binding=json.loads(idempotency["binding_json"])
|
||||
except (TypeError,ValueError) as exc:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_CONFLICT",409) from exc
|
||||
if (
|
||||
recorded_request!=expected_request
|
||||
or idempotency["key"]!=producer_key
|
||||
or idempotency["job_id"]!=job_id
|
||||
or recorded_binding!=producer_binding
|
||||
):
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_JOB_CONFLICT",409)
|
||||
payload_path=store_root/"payloads"/payload_digest
|
||||
descriptor=None
|
||||
try:
|
||||
descriptor=os.open(
|
||||
payload_path,
|
||||
os.O_RDONLY|getattr(os,"O_NOFOLLOW",0),
|
||||
)
|
||||
payload_stat=os.fstat(descriptor)
|
||||
with os.fdopen(descriptor,"rb",closefd=False) as stream:
|
||||
payload_bytes=stream.read(len(payload)+1)
|
||||
payload_after=payload_path.lstat()
|
||||
except FileNotFoundError as exc:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_PAYLOAD_MISSING",409) from exc
|
||||
except OSError as exc:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_PAYLOAD_READ_FAILED",503) from exc
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(payload_stat.st_mode)
|
||||
or stat.S_ISLNK(payload_after.st_mode)
|
||||
or not stat.S_ISREG(payload_after.st_mode)
|
||||
or (payload_stat.st_dev,payload_stat.st_ino)!=(payload_after.st_dev,payload_after.st_ino)
|
||||
or len(payload_bytes)!=len(payload)
|
||||
or not hmac.compare_digest(hashlib.sha256(payload_bytes).hexdigest(),payload_digest)
|
||||
or payload_bytes!=payload
|
||||
):
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_PAYLOAD_CONFLICT",409)
|
||||
|
||||
response=dict(durable.response(idempotent=True))
|
||||
response["reservation_readback"]={
|
||||
"schema":"aios.governed-task-reservation-readback.v1",
|
||||
"job_id":job_id,
|
||||
"state":job["state"],
|
||||
"side_effect_state":job["side_effect_state"],
|
||||
"revision":job["revision"],
|
||||
"producer_idempotency_key":producer_key,
|
||||
"request_idempotency_key":key,
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
def _fl_validate_reservation_readback(
|
||||
*,grant:object,request:Mapping[str,Any],durable:object,
|
||||
response:object,expected_branch_mode:str,
|
||||
)->str:
|
||||
if not isinstance(response,Mapping):
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_READBACK_INVALID",409)
|
||||
result=response
|
||||
expected_response=dict(durable.response(idempotent=True))
|
||||
if (
|
||||
set(result)!=set(expected_response)|{"reservation_readback"}
|
||||
or any(result.get(field)!=value for field,value in expected_response.items())
|
||||
):
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_READBACK_CONFLICT",409)
|
||||
proof=result.get("reservation_readback")
|
||||
if not isinstance(proof,Mapping):
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_READBACK_INVALID",409)
|
||||
expected_keys={
|
||||
"schema","job_id","state","side_effect_state","revision",
|
||||
"producer_idempotency_key","request_idempotency_key",
|
||||
}
|
||||
key=request.get("idempotency_key")
|
||||
try:
|
||||
producer_key=_fl_job_sha256_json({"client_idempotency_key":key})
|
||||
except Exception as exc:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_READBACK_INVALID",409) from exc
|
||||
job_id=getattr(durable,"job_id",None)
|
||||
if (
|
||||
set(proof)!=expected_keys
|
||||
or proof.get("schema")!="aios.governed-task-reservation-readback.v1"
|
||||
or proof.get("job_id")!=job_id
|
||||
or proof.get("producer_idempotency_key")!=producer_key
|
||||
or proof.get("request_idempotency_key")!=key
|
||||
or proof.get("state") not in {item.value for item in _FLJobState}
|
||||
or not isinstance(proof.get("side_effect_state"),str)
|
||||
or not proof.get("side_effect_state")
|
||||
or not isinstance(proof.get("revision"),int)
|
||||
or isinstance(proof.get("revision"),bool)
|
||||
or proof.get("revision")<0
|
||||
or getattr(durable,"branch_mode",None)!=expected_branch_mode
|
||||
):
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_READBACK_CONFLICT",409)
|
||||
return str(job_id)
|
||||
|
||||
class _FLProducerReceiptReserver:
|
||||
def __init__(self,runtime:object):
|
||||
self.runtime=runtime
|
||||
self.readbacks:dict[str,dict[str,Any]]={}
|
||||
|
||||
def validate_v2_replay(self,grant:object,plan:Mapping[str,Any])->None:
|
||||
"""Permit the old v2 contract only as an exact durable replay."""
|
||||
require_reservable_grant(grant)
|
||||
bootstrap=getattr(self.runtime,"bootstrap",None)
|
||||
if bootstrap is None or not callable(getattr(bootstrap,"get_binding",None)):
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
||||
_fl_assert_bootstrap_source(bootstrap)
|
||||
task_id=str(_gv(grant,"task_id"))
|
||||
try:
|
||||
durable=bootstrap.get_binding(task_id)
|
||||
except Exception as exc:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_V2_REPLAY_BINDING_REQUIRED",409) from exc
|
||||
expected={
|
||||
"task_id":task_id,
|
||||
"job_id":plan.get("external_job_id"),
|
||||
"repository":_gv(grant,"repository"),
|
||||
"base_oid":_gv(grant,"authority_base_oid"),
|
||||
"expected_branch_oid":_gv(grant,"authority_base_oid"),
|
||||
"target_branch":_gv(grant,"target_branch"),
|
||||
"generation":_gv(grant,"generation"),
|
||||
"risk":_gv(grant,"risk"),
|
||||
"idempotency_key":"task-admission:"+task_id,
|
||||
"description":"P0 governed execution projection for officially admitted Agent Task.",
|
||||
}
|
||||
if any(getattr(durable,field,None)!=value for field,value in expected.items()):
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_V2_REPLAY_BINDING_CONFLICT",409)
|
||||
if getattr(durable,"branch_mode",None) not in {"CREATE_NEW","CONTINUE_EXISTING"}:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_V2_REPLAY_BINDING_CONFLICT",409)
|
||||
if not isinstance(plan.get("external_job_id"),str) or not _JOB_ID.fullmatch(plan["external_job_id"]):
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_JOB_ID_INVALID",409)
|
||||
|
||||
def existing_v3_job(self,grant:object,plan:Mapping[str,Any])->str|None:
|
||||
"""Reuse an exact durable reservation without reinterpreting live refs.
|
||||
|
||||
Once reserved, CREATE_NEW versus CONTINUE_EXISTING is immutable input
|
||||
lineage. A later TaskPrincipal read must not turn legitimate branch
|
||||
creation or forward progress into a fresh admission decision.
|
||||
"""
|
||||
try:
|
||||
validate_v3_plan(grant=grant,raw=canonical_json_bytes(dict(plan)))
|
||||
except ProducerReceiptError as exc:
|
||||
raise _fl_error(exc.code,403) from exc
|
||||
bootstrap=getattr(self.runtime,"bootstrap",None)
|
||||
bindings=getattr(self.runtime,"bindings",None)
|
||||
if (
|
||||
bootstrap is None
|
||||
or not callable(getattr(bootstrap,"get_binding",None))
|
||||
or not isinstance(bindings,dict)
|
||||
):
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
||||
_fl_assert_bootstrap_source(bootstrap)
|
||||
task_id=str(_gv(grant,"task_id"))
|
||||
try:
|
||||
durable=bootstrap.get_binding(task_id)
|
||||
except Exception as exc:
|
||||
if str(exc)=="TASK_EXECUTION_PROJECTION_MISSING":
|
||||
return None
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_READ_FAILED",503) from exc
|
||||
request=build_reservation_request(
|
||||
grant,expected_branch_oid=str(_gv(grant,"authority_base_oid")),
|
||||
)
|
||||
branch_mode=getattr(durable,"branch_mode",None)
|
||||
if branch_mode not in {"CREATE_NEW","CONTINUE_EXISTING"}:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_DURABLE_BINDING_CONFLICT",409)
|
||||
job_id=getattr(durable,"job_id",None)
|
||||
if not isinstance(job_id,str) or not _JOB_ID.fullmatch(job_id):
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_JOB_ID_INVALID",409)
|
||||
_fl_validate_durable_binding(
|
||||
grant=grant,request=request,durable=durable,job_id=job_id,
|
||||
expected_branch_mode=branch_mode,
|
||||
)
|
||||
readback=_fl_existing_reservation_readback(
|
||||
bootstrap=bootstrap,grant=grant,request=request,durable=durable,
|
||||
expected_branch_mode=branch_mode,
|
||||
)
|
||||
_fl_validate_reservation_readback(
|
||||
grant=grant,request=request,durable=durable,response=readback,
|
||||
expected_branch_mode=branch_mode,
|
||||
)
|
||||
# Keep readback evidence separate from producer submit receipts. In
|
||||
# particular, a principal re-read must not overwrite the real receipt
|
||||
# retained by a fresh admission in this runtime.
|
||||
self.readbacks[task_id]=dict(readback)
|
||||
return job_id
|
||||
|
||||
def __call__(self,grant:object,plan:Mapping[str,Any])->str:
|
||||
try:
|
||||
validate_v3_plan(grant=grant,raw=canonical_json_bytes(dict(plan)))
|
||||
except ProducerReceiptError as exc:
|
||||
raise _fl_error(exc.code,403) from exc
|
||||
authority=getattr(self.runtime,"authority",None)
|
||||
bootstrap=getattr(self.runtime,"bootstrap",None)
|
||||
bindings=getattr(self.runtime,"bindings",None)
|
||||
if (
|
||||
authority is None or bootstrap is None
|
||||
or not callable(getattr(bootstrap,"create",None))
|
||||
or not callable(getattr(bootstrap,"get_binding",None))
|
||||
or not isinstance(bindings,dict)
|
||||
):
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
||||
|
||||
_fl_assert_bootstrap_source(bootstrap)
|
||||
|
||||
try:
|
||||
main_before=authority.read_ref("refs/heads/main")
|
||||
branch_before=authority.read_ref("refs/heads/"+TARGET_BRANCH)
|
||||
except Exception as exc:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_UNAVAILABLE",503) from exc
|
||||
if main_before!=TARGET_BASE_OID or branch_before!=TARGET_BASE_OID:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_DRIFT",409)
|
||||
main_before,branch_before,branch_mode,expected_branch_oid=_fl_reservation_refs(self.runtime,grant)
|
||||
|
||||
request=build_reservation_request(grant)
|
||||
request=build_reservation_request(grant,expected_branch_oid=expected_branch_oid)
|
||||
request_before=dict(request)
|
||||
try:
|
||||
first=bootstrap.create(request)
|
||||
if request!=request_before:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_REQUEST_MUTATED",503)
|
||||
validate_bootstrap_response(grant=grant,request=request,response=first,expected_side_effect_state="QUEUED")
|
||||
first_phase=_mapping(first,"TASK_ADMISSION_EXECUTION_RESERVATION_RESPONSE_INVALID").get("reservation_receipt",{}).get("side_effect_state")
|
||||
if first_phase not in {"QUEUED","SAME_JOB"}:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_PHASE_INVALID",409)
|
||||
validate_bootstrap_response(
|
||||
grant=grant,request=request,response=first,
|
||||
expected_side_effect_state=first_phase,expected_branch_mode=branch_mode,
|
||||
)
|
||||
|
||||
replay=bootstrap.create(request)
|
||||
if request!=request_before:
|
||||
@@ -1434,6 +1975,12 @@ class _FLProducerReceiptReserver:
|
||||
job_id=validate_bootstrap_replay(
|
||||
grant=grant,request=request,first=first,replay=replay,
|
||||
replay_side_effect_state=REPLAY_SIDE_EFFECT_STATE,
|
||||
first_side_effect_state=first_phase,expected_branch_mode=branch_mode,
|
||||
)
|
||||
durable=bootstrap.get_binding(str(_gv(grant,"task_id")))
|
||||
_fl_validate_durable_binding(
|
||||
grant=grant,request=request,durable=durable,job_id=job_id,
|
||||
expected_branch_mode=branch_mode,
|
||||
)
|
||||
except ProducerReceiptError as exc:
|
||||
raise _fl_error(exc.code,409) from exc
|
||||
@@ -1448,11 +1995,12 @@ class _FLProducerReceiptReserver:
|
||||
# Existing runtime's public createGovernedTask projection performs this
|
||||
# in-memory binding update after bootstrap.create(). Mirror it only
|
||||
# after the exact producer receipt + replay proof has passed.
|
||||
bindings[TARGET_TASK_ID]=dict(replay)
|
||||
bindings[str(_gv(grant,"task_id"))]=dict(replay)
|
||||
self.readbacks.pop(str(_gv(grant,"task_id")),None)
|
||||
|
||||
try:
|
||||
main_after=authority.read_ref("refs/heads/main")
|
||||
branch_after=authority.read_ref("refs/heads/"+TARGET_BRANCH)
|
||||
branch_after=authority.read_ref("refs/heads/"+str(_gv(grant,"target_branch")))
|
||||
except Exception as exc:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_UNAVAILABLE",503) from exc
|
||||
if main_after!=main_before or branch_after!=branch_before:
|
||||
@@ -1467,29 +2015,33 @@ class V3ProducerReceiptExecutionPlanDirectoryProvider(_FL_HARDENING_PROVIDER):
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
||||
try:
|
||||
plan=load_v3_plan(_fl_provider_root(self),grant)
|
||||
job_id=self._reserver(grant,plan)
|
||||
job_id=self._reserver.existing_v3_job(grant,plan)
|
||||
if job_id is None:
|
||||
job_id=self._reserver(grant,plan)
|
||||
except ProducerReceiptError as exc:
|
||||
raise _fl_error(exc.code,403) from exc
|
||||
return {**plan,"external_job_id":job_id}
|
||||
return {
|
||||
**plan,
|
||||
"external_job_id":job_id,
|
||||
"execution_digest":_gv(grant,"execution_digest"),
|
||||
}
|
||||
|
||||
def __call__(self,grant:object)->Mapping[str,Any]:
|
||||
if getattr(grant,"task_id",None)!=TARGET_TASK_ID:
|
||||
try:
|
||||
return self._target(grant)
|
||||
except Exception as v3_error:
|
||||
parent=getattr(super(),"__call__",None)
|
||||
if callable(parent):
|
||||
return parent(grant)
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_PROVIDER_CONTRACT_INVALID",503)
|
||||
return self._target(grant)
|
||||
if not callable(parent) or self._reserver is None:
|
||||
raise v3_error
|
||||
try:
|
||||
legacy=parent(grant)
|
||||
except Exception:
|
||||
raise v3_error
|
||||
self._reserver.validate_v2_replay(grant,legacy)
|
||||
return legacy
|
||||
|
||||
def load(self,grant:object)->Mapping[str,Any]:
|
||||
if getattr(grant,"task_id",None)!=TARGET_TASK_ID:
|
||||
parent=getattr(super(),"load",None)
|
||||
if callable(parent):
|
||||
return parent(grant)
|
||||
parent_call=getattr(super(),"__call__",None)
|
||||
if callable(parent_call):
|
||||
return parent_call(grant)
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_PROVIDER_CONTRACT_INVALID",503)
|
||||
return self._target(grant)
|
||||
return self(grant)
|
||||
|
||||
def _fl_runtime_from_post_admission(post_admission:object)->object:
|
||||
runtime=getattr(post_admission,"__self__",None)
|
||||
@@ -1509,31 +2061,93 @@ def _fl_call(builder,*args,post_admission=None,**kwargs):
|
||||
previous=ExecutionPlanDirectoryProvider
|
||||
ExecutionPlanDirectoryProvider=BoundProvider
|
||||
try:
|
||||
return builder(*args,post_admission=post_admission,**kwargs)
|
||||
def exact_post_admission(grant,result):
|
||||
task_id=str(_gv(grant,"task_id"))
|
||||
durable=reserver.runtime.bootstrap.get_binding(task_id)
|
||||
request=build_reservation_request(
|
||||
grant,expected_branch_oid=str(_gv(grant,"authority_base_oid")),
|
||||
)
|
||||
if getattr(durable,"idempotency_key",None)!=request["idempotency_key"]:
|
||||
return post_admission(grant,result)
|
||||
branch_mode=getattr(durable,"branch_mode",None)
|
||||
if branch_mode not in {"CREATE_NEW","CONTINUE_EXISTING"}:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_DURABLE_BINDING_CONFLICT",409)
|
||||
binding=reserver.runtime.bindings.get(task_id)
|
||||
if isinstance(binding,Mapping) and "reservation_receipt" in binding:
|
||||
# Fresh admission must retain the actual producer submit replay
|
||||
# receipt produced by GovernedTaskBootstrap.create().
|
||||
job_id=validate_bootstrap_response(
|
||||
grant=grant,request=request,response=binding,
|
||||
expected_side_effect_state="SAME_JOB",expected_branch_mode=branch_mode,
|
||||
)
|
||||
else:
|
||||
# A process restart may leave only durable producer state. Its
|
||||
# separately typed readback proves immutable identity without
|
||||
# claiming that a producer submit happened in this request.
|
||||
binding=reserver.readbacks.get(task_id)
|
||||
job_id=_fl_validate_reservation_readback(
|
||||
grant=grant,request=request,durable=durable,response=binding,
|
||||
expected_branch_mode=branch_mode,
|
||||
)
|
||||
if result.get("task_id")!=task_id or result.get("external_job_id")!=job_id or result.get("state",result.get("status")) not in {"QUEUED","CLAIMED","RUNNING"}:
|
||||
raise _fl_error("TASK_ADMISSION_PRODUCER_TASK_BINDING_CONFLICT",409)
|
||||
_fl_validate_durable_binding(
|
||||
grant=grant,request=request,durable=durable,job_id=job_id,
|
||||
expected_branch_mode=branch_mode,
|
||||
)
|
||||
return dict(binding)
|
||||
return builder(*args,post_admission=exact_post_admission,**kwargs)
|
||||
finally:
|
||||
ExecutionPlanDirectoryProvider=previous
|
||||
|
||||
def build_task_admission_runtime_from_activation_file(
|
||||
activation_file:Path,*,
|
||||
caller_service:str="aios-chatgpt-bridge-v2-staging.service",
|
||||
post_admission=None,
|
||||
):
|
||||
if not callable(_FL_BUILD_RUNTIME_FROM_ACTIVATION):
|
||||
raise RuntimeError("TASK_ADMISSION_RUNTIME_ACTIVATION_BUILDER_MISSING")
|
||||
return _fl_call(
|
||||
_FL_BUILD_RUNTIME_FROM_ACTIVATION,activation_file,
|
||||
caller_service=caller_service,post_admission=post_admission,
|
||||
)
|
||||
|
||||
|
||||
def build_task_admission_handlers_from_activation_file(
|
||||
activation_file:Path,*,
|
||||
caller_service:str="aios-chatgpt-bridge-v2-staging.service",
|
||||
post_admission=None,
|
||||
):
|
||||
return build_task_admission_runtime_from_activation_file(
|
||||
activation_file,
|
||||
caller_service=caller_service,post_admission=post_admission,
|
||||
).handlers
|
||||
|
||||
|
||||
def build_task_admission_runtime_from_environment(
|
||||
*,
|
||||
caller_service:str="aios-chatgpt-bridge-v2-staging.service",
|
||||
post_admission=None,
|
||||
):
|
||||
if not callable(_FL_BUILD_RUNTIME_FROM_ENVIRONMENT):
|
||||
raise RuntimeError("TASK_ADMISSION_RUNTIME_ENVIRONMENT_BUILDER_MISSING")
|
||||
return _fl_call(
|
||||
_FL_BUILD_FROM_ACTIVATION,activation_file,
|
||||
_FL_BUILD_RUNTIME_FROM_ENVIRONMENT,
|
||||
caller_service=caller_service,post_admission=post_admission,
|
||||
)
|
||||
|
||||
|
||||
def build_task_admission_handlers_from_environment(
|
||||
*,
|
||||
caller_service:str="aios-chatgpt-bridge-v2-staging.service",
|
||||
post_admission=None,
|
||||
):
|
||||
if not callable(_FL_BUILD_FROM_ENVIRONMENT):
|
||||
raise RuntimeError("TASK_ADMISSION_ENVIRONMENT_BUILDER_MISSING")
|
||||
return _fl_call(
|
||||
_FL_BUILD_FROM_ENVIRONMENT,
|
||||
return build_task_admission_runtime_from_environment(
|
||||
caller_service=caller_service,post_admission=post_admission,
|
||||
)
|
||||
).handlers
|
||||
|
||||
ExecutionPlanDirectoryProvider=V3ProducerReceiptExecutionPlanDirectoryProvider
|
||||
# Keep the standalone legacy provider contract. The exported admission builders
|
||||
# install a runtime-bound v3 subclass only while composing their trusted port;
|
||||
# an unbound provider cannot prove a durable producer receipt.
|
||||
ExecutionPlanDirectoryProvider=_FL_HARDENING_PROVIDER
|
||||
# ===== AIOS FORMAL LINEAGE POST-OWNER PRODUCER NATIVE V12 — END =====
|
||||
|
||||
@@ -0,0 +1,779 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from hashlib import sha256
|
||||
import re
|
||||
from typing import Callable, Iterable, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class LineageError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class RelationType(str, Enum):
|
||||
ROOT = "ROOT"
|
||||
CHILD = "CHILD"
|
||||
SUCCESSOR = "SUCCESSOR"
|
||||
RECOVERY = "RECOVERY"
|
||||
|
||||
|
||||
class NextLegalAction(str, Enum):
|
||||
ROOT_COMPLETE = "ROOT_COMPLETE"
|
||||
CREATE_OR_RESUME_NEXT_CHILD = "CREATE_OR_RESUME_NEXT_CHILD"
|
||||
WAIT_DEPENDENCY = "WAIT_DEPENDENCY"
|
||||
RECOVER_EXISTING_CHILD = "RECOVER_EXISTING_CHILD"
|
||||
OWNER_BOUNDARY = "OWNER_BOUNDARY"
|
||||
|
||||
|
||||
TERMINAL_STATUSES = frozenset(
|
||||
{"COMPLETE", "COMPLETED", "SUCCEEDED", "FAILED", "FAILED_FINAL", "CANCELLED"}
|
||||
)
|
||||
RECOVERY_STATUSES = frozenset(
|
||||
{"RECONCILIATION_REQUIRED", "RECOVERY_REQUIRED", "FAILED_RECOVERABLE"}
|
||||
)
|
||||
BLOCKED_STATUSES = frozenset({"BLOCKED", "WAITING_DEPENDENCY", "WAITING_CAPACITY"})
|
||||
|
||||
|
||||
_ALIAS_RE = re.compile(r"^[A-Z0-9][A-Z0-9_-]{1,31}$")
|
||||
_SLUG_RE = re.compile(r"^[A-Z0-9][A-Z0-9-]{1,63}$")
|
||||
|
||||
|
||||
def _require_utc(ts: datetime) -> datetime:
|
||||
if ts.tzinfo is None or ts.utcoffset() is None:
|
||||
raise LineageError("created_at/reviewed_at must be timezone-aware")
|
||||
return ts.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def derive_safe_owner_alias(principal_id: str, preferred_alias: str | None = None) -> str:
|
||||
"""Return a non-secret display alias.
|
||||
|
||||
A trusted identity adapter may supply a safe human alias. Otherwise we use a
|
||||
deterministic pseudonym derived from the principal identifier. We never put
|
||||
raw email addresses, tokens, or principal strings into task names.
|
||||
"""
|
||||
if preferred_alias and "@" not in preferred_alias and ":" not in preferred_alias:
|
||||
candidate = re.sub(r"[^A-Za-z0-9_-]+", "-", preferred_alias.strip()).strip("-_").upper()
|
||||
if 2 <= len(candidate) <= 32 and _ALIAS_RE.fullmatch(candidate):
|
||||
return candidate
|
||||
if not principal_id:
|
||||
raise LineageError("principal_id is required")
|
||||
return "U" + sha256(principal_id.encode("utf-8")).hexdigest()[:9].upper()
|
||||
|
||||
|
||||
def normalize_goal_slug(value: str) -> str:
|
||||
value = re.sub(r"[^A-Za-z0-9]+", "-", value.strip()).strip("-").upper()
|
||||
value = re.sub(r"-+", "-", value)
|
||||
if len(value) > 64:
|
||||
value = value[:64].rstrip("-")
|
||||
if not _SLUG_RE.fullmatch(value):
|
||||
raise LineageError("goal_slug must normalize to 2-64 [A-Z0-9-] characters")
|
||||
return value
|
||||
|
||||
|
||||
def short_ref(task_id: str) -> str:
|
||||
if not task_id:
|
||||
raise LineageError("task_id is required")
|
||||
compact = re.sub(r"[^A-Za-z0-9]", "", task_id).upper()
|
||||
if len(compact) >= 8:
|
||||
return compact[-8:]
|
||||
return sha256(task_id.encode("utf-8")).hexdigest()[:8].upper()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskLineage:
|
||||
task_id: str
|
||||
display_name: str
|
||||
owner_principal_id: str
|
||||
owner_alias: str
|
||||
created_at: datetime
|
||||
mission_id: str
|
||||
root_task_id: str
|
||||
parent_task_id: str | None
|
||||
lineage_depth: int
|
||||
relation_type: RelationType
|
||||
sibling_sequence: int
|
||||
goal_slug: str
|
||||
root_acceptance_ref: str
|
||||
parent_revision: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskRecord:
|
||||
task_id: str
|
||||
revision: int
|
||||
status: str
|
||||
lineage: TaskLineage
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
terminal_evidence_proven: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceCondition:
|
||||
key: str
|
||||
satisfied: bool
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
owner_boundary: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RootAcceptanceSnapshot:
|
||||
conditions: tuple[AcceptanceCondition, ...]
|
||||
|
||||
@property
|
||||
def remaining(self) -> tuple[str, ...]:
|
||||
return tuple(c.key for c in self.conditions if not c.satisfied)
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
return all(c.satisfied for c in self.conditions)
|
||||
|
||||
@property
|
||||
def owner_boundary_required(self) -> bool:
|
||||
return any((not c.satisfied) and c.owner_boundary for c in self.conditions)
|
||||
|
||||
@property
|
||||
def evidence_refs(self) -> tuple[str, ...]:
|
||||
values: list[str] = []
|
||||
for condition in self.conditions:
|
||||
if condition.satisfied:
|
||||
values.extend(condition.evidence_refs)
|
||||
return tuple(dict.fromkeys(values))
|
||||
|
||||
|
||||
def canonicalize_acceptance(
|
||||
acceptance: RootAcceptanceSnapshot,
|
||||
) -> RootAcceptanceSnapshot:
|
||||
if not isinstance(acceptance, RootAcceptanceSnapshot):
|
||||
raise LineageError("acceptance evaluator returned an invalid snapshot")
|
||||
normalized: list[AcceptanceCondition] = []
|
||||
keys: set[str] = set()
|
||||
for condition in acceptance.conditions:
|
||||
if not isinstance(condition, AcceptanceCondition):
|
||||
raise LineageError("acceptance snapshot contains an invalid condition")
|
||||
if not isinstance(condition.key, str) or not condition.key:
|
||||
raise LineageError("acceptance condition key must be a non-empty string")
|
||||
if condition.key in keys:
|
||||
raise LineageError("acceptance condition keys must be unique")
|
||||
if not isinstance(condition.satisfied, bool) or not isinstance(
|
||||
condition.owner_boundary, bool
|
||||
):
|
||||
raise LineageError("acceptance condition flags must be booleans")
|
||||
if not all(isinstance(ref, str) and ref for ref in condition.evidence_refs):
|
||||
raise LineageError("acceptance evidence refs must be non-empty strings")
|
||||
keys.add(condition.key)
|
||||
normalized.append(
|
||||
replace(
|
||||
condition,
|
||||
evidence_refs=tuple(sorted(set(condition.evidence_refs))),
|
||||
)
|
||||
)
|
||||
return RootAcceptanceSnapshot(tuple(sorted(normalized, key=lambda c: c.key)))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RootReviewRecord:
|
||||
review_id: str
|
||||
root_task_id: str
|
||||
reviewed_at: datetime
|
||||
trigger_child_task_id: str
|
||||
root_revision_before: int
|
||||
root_revision_after: int
|
||||
completed_children: tuple[str, ...]
|
||||
active_children: tuple[str, ...]
|
||||
blocked_children: tuple[str, ...]
|
||||
remaining_acceptance_conditions: tuple[str, ...]
|
||||
next_legal_action: NextLegalAction
|
||||
root_complete: bool
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
|
||||
class RootReviewStore(Protocol):
|
||||
def get_task(self, task_id: str) -> TaskRecord: ...
|
||||
def list_descendants(self, root_task_id: str) -> Sequence[TaskRecord]: ...
|
||||
def get_root_review(self, review_id: str) -> RootReviewRecord | None: ...
|
||||
def compare_and_swap_root_review(
|
||||
self,
|
||||
root_task_id: str,
|
||||
expected_root_revision: int,
|
||||
review: RootReviewRecord,
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
AcceptanceEvaluator = Callable[[TaskRecord, Sequence[TaskRecord]], RootAcceptanceSnapshot]
|
||||
|
||||
|
||||
def _timestamp(ts: datetime) -> str:
|
||||
return _require_utc(ts).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def _relation_token(relation: RelationType, sequence: int) -> str:
|
||||
if sequence < 1 or sequence > 99:
|
||||
raise LineageError("non-root sibling_sequence must be 1..99")
|
||||
prefix = {
|
||||
RelationType.CHILD: "C",
|
||||
RelationType.SUCCESSOR: "S",
|
||||
RelationType.RECOVERY: "X",
|
||||
}.get(relation)
|
||||
if not prefix:
|
||||
raise LineageError("ROOT does not use a child relation token")
|
||||
return f"{prefix}{sequence:02d}"
|
||||
|
||||
|
||||
def build_display_name(lineage: TaskLineage) -> str:
|
||||
alias = lineage.owner_alias.upper()
|
||||
if not _ALIAS_RE.fullmatch(alias):
|
||||
raise LineageError("owner_alias must be a non-secret [A-Z0-9_-] alias")
|
||||
goal = normalize_goal_slug(lineage.goal_slug)
|
||||
ts = _timestamp(lineage.created_at)
|
||||
if lineage.relation_type == RelationType.ROOT:
|
||||
return f"TASK-{alias}-{ts}-ROOT-{goal}-{short_ref(lineage.task_id)[:4]}"
|
||||
relation = _relation_token(lineage.relation_type, lineage.sibling_sequence)
|
||||
return (
|
||||
f"TASK-{alias}-{ts}"
|
||||
f"-R{short_ref(lineage.root_task_id)}"
|
||||
f"-P{short_ref(lineage.parent_task_id or '')}"
|
||||
f"-D{lineage.lineage_depth:02d}"
|
||||
f"-{relation}-{goal}"
|
||||
)
|
||||
|
||||
|
||||
def make_root_lineage(
|
||||
*,
|
||||
task_id: str,
|
||||
owner_principal_id: str,
|
||||
owner_alias: str,
|
||||
created_at: datetime,
|
||||
mission_id: str,
|
||||
goal_slug: str,
|
||||
root_acceptance_ref: str,
|
||||
) -> TaskLineage:
|
||||
lineage = TaskLineage(
|
||||
task_id=task_id,
|
||||
display_name="",
|
||||
owner_principal_id=owner_principal_id,
|
||||
owner_alias=owner_alias.upper(),
|
||||
created_at=_require_utc(created_at),
|
||||
mission_id=mission_id,
|
||||
root_task_id=task_id,
|
||||
parent_task_id=None,
|
||||
lineage_depth=0,
|
||||
relation_type=RelationType.ROOT,
|
||||
sibling_sequence=0,
|
||||
goal_slug=normalize_goal_slug(goal_slug),
|
||||
root_acceptance_ref=root_acceptance_ref,
|
||||
parent_revision=None,
|
||||
)
|
||||
lineage = replace(lineage, display_name=build_display_name(lineage))
|
||||
validate_lineage(lineage)
|
||||
return lineage
|
||||
|
||||
|
||||
def make_child_lineage(
|
||||
*,
|
||||
task_id: str,
|
||||
created_at: datetime,
|
||||
parent: TaskRecord,
|
||||
root: TaskRecord,
|
||||
relation_type: RelationType,
|
||||
sibling_sequence: int,
|
||||
goal_slug: str,
|
||||
) -> TaskLineage:
|
||||
if relation_type == RelationType.ROOT:
|
||||
raise LineageError("child relation_type cannot be ROOT")
|
||||
lineage = TaskLineage(
|
||||
task_id=task_id,
|
||||
display_name="",
|
||||
owner_principal_id=root.lineage.owner_principal_id,
|
||||
owner_alias=root.lineage.owner_alias,
|
||||
created_at=_require_utc(created_at),
|
||||
mission_id=root.lineage.mission_id,
|
||||
root_task_id=root.task_id,
|
||||
parent_task_id=parent.task_id,
|
||||
lineage_depth=parent.lineage.lineage_depth + 1,
|
||||
relation_type=relation_type,
|
||||
sibling_sequence=sibling_sequence,
|
||||
goal_slug=normalize_goal_slug(goal_slug),
|
||||
root_acceptance_ref=root.lineage.root_acceptance_ref,
|
||||
parent_revision=parent.revision,
|
||||
)
|
||||
lineage = replace(lineage, display_name=build_display_name(lineage))
|
||||
validate_lineage(lineage, parent=parent, root=root)
|
||||
return lineage
|
||||
|
||||
|
||||
def validate_lineage(
|
||||
lineage: TaskLineage,
|
||||
*,
|
||||
parent: TaskRecord | None = None,
|
||||
root: TaskRecord | None = None,
|
||||
) -> None:
|
||||
_validate_lineage_shape(lineage)
|
||||
|
||||
if lineage.relation_type == RelationType.ROOT:
|
||||
return
|
||||
|
||||
if parent is None or root is None:
|
||||
raise LineageError("non-root validation requires parent and root authority records")
|
||||
if root.lineage.relation_type != RelationType.ROOT:
|
||||
raise LineageError("root authority record must have ROOT lineage")
|
||||
if parent.lineage.root_task_id != root.task_id:
|
||||
raise LineageError("parent is not attached to the selected root")
|
||||
if parent.lineage.owner_principal_id != root.lineage.owner_principal_id:
|
||||
raise LineageError("parent owner does not match root owner")
|
||||
if parent.lineage.mission_id != root.lineage.mission_id:
|
||||
raise LineageError("parent mission does not match root mission")
|
||||
if parent.lineage.root_acceptance_ref != root.lineage.root_acceptance_ref:
|
||||
raise LineageError("parent acceptance reference does not match root")
|
||||
if lineage.root_task_id != root.task_id:
|
||||
raise LineageError("root_task_id mismatch")
|
||||
if lineage.parent_task_id != parent.task_id:
|
||||
raise LineageError("parent_task_id mismatch")
|
||||
if lineage.lineage_depth != parent.lineage.lineage_depth + 1:
|
||||
raise LineageError("lineage_depth must equal parent depth + 1")
|
||||
if lineage.owner_principal_id != root.lineage.owner_principal_id:
|
||||
raise LineageError("cross-owner lineage attachment rejected")
|
||||
if lineage.owner_alias != root.lineage.owner_alias:
|
||||
raise LineageError("owner_alias must match root")
|
||||
if lineage.mission_id != root.lineage.mission_id:
|
||||
raise LineageError("mission_id must match root")
|
||||
if lineage.root_acceptance_ref != root.lineage.root_acceptance_ref:
|
||||
raise LineageError("root_acceptance_ref must match root")
|
||||
if lineage.parent_revision != parent.revision:
|
||||
raise LineageError("stale or mismatched parent revision")
|
||||
|
||||
|
||||
def _validate_lineage_shape(lineage: TaskLineage) -> None:
|
||||
if not lineage.task_id or not lineage.owner_principal_id or not lineage.mission_id:
|
||||
raise LineageError("task_id, owner_principal_id and mission_id are required")
|
||||
if not lineage.root_acceptance_ref:
|
||||
raise LineageError("root_acceptance_ref is required")
|
||||
if not _ALIAS_RE.fullmatch(lineage.owner_alias):
|
||||
raise LineageError("invalid owner_alias")
|
||||
if normalize_goal_slug(lineage.goal_slug) != lineage.goal_slug:
|
||||
raise LineageError("goal_slug must already be normalized")
|
||||
if not lineage.display_name or len(lineage.display_name) > 200:
|
||||
raise LineageError("display_name must be non-empty and <= 200 characters")
|
||||
|
||||
if lineage.relation_type == RelationType.ROOT:
|
||||
if lineage.root_task_id != lineage.task_id:
|
||||
raise LineageError("root_task_id must equal task_id for ROOT")
|
||||
if lineage.parent_task_id is not None:
|
||||
raise LineageError("ROOT parent_task_id must be null")
|
||||
if lineage.lineage_depth != 0:
|
||||
raise LineageError("ROOT lineage_depth must be 0")
|
||||
if lineage.sibling_sequence != 0:
|
||||
raise LineageError("ROOT sibling_sequence must be 0")
|
||||
if lineage.parent_revision is not None:
|
||||
raise LineageError("ROOT parent_revision must be null")
|
||||
return
|
||||
|
||||
if not lineage.root_task_id or not lineage.parent_task_id:
|
||||
raise LineageError("non-root root_task_id and parent_task_id are required")
|
||||
if lineage.lineage_depth < 1:
|
||||
raise LineageError("non-root lineage_depth must be >= 1")
|
||||
if lineage.parent_revision is None or lineage.parent_revision < 0:
|
||||
raise LineageError("non-root parent_revision must be >= 0")
|
||||
_relation_token(lineage.relation_type, lineage.sibling_sequence)
|
||||
|
||||
|
||||
def attach_legacy_lineage(
|
||||
*,
|
||||
existing_task_id: str,
|
||||
created_at: datetime,
|
||||
parent: TaskRecord,
|
||||
root: TaskRecord,
|
||||
relation_type: RelationType,
|
||||
sibling_sequence: int,
|
||||
goal_slug: str,
|
||||
) -> TaskLineage:
|
||||
"""Return lineage metadata for an existing durable task without changing its ID."""
|
||||
return make_child_lineage(
|
||||
task_id=existing_task_id,
|
||||
created_at=created_at,
|
||||
parent=parent,
|
||||
root=root,
|
||||
relation_type=relation_type,
|
||||
sibling_sequence=sibling_sequence,
|
||||
goal_slug=goal_slug,
|
||||
)
|
||||
|
||||
|
||||
def rename_display_only(lineage: TaskLineage, new_display_name: str) -> TaskLineage:
|
||||
if not new_display_name or len(new_display_name) > 200:
|
||||
raise LineageError("display name must be non-empty and <= 200 characters")
|
||||
return replace(lineage, display_name=new_display_name)
|
||||
|
||||
|
||||
|
||||
LINEAGE_SCHEMA = "aios.task-lineage.v1"
|
||||
ROOT_REVIEW_SCHEMA = "aios.root-review.v1"
|
||||
|
||||
_LINEAGE_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"task_id",
|
||||
"display_name",
|
||||
"owner_principal_id",
|
||||
"owner_alias",
|
||||
"created_at",
|
||||
"mission_id",
|
||||
"root_task_id",
|
||||
"parent_task_id",
|
||||
"lineage_depth",
|
||||
"relation_type",
|
||||
"sibling_sequence",
|
||||
"goal_slug",
|
||||
"root_acceptance_ref",
|
||||
"parent_revision",
|
||||
}
|
||||
)
|
||||
_ROOT_REVIEW_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"review_id",
|
||||
"root_task_id",
|
||||
"reviewed_at",
|
||||
"trigger_child_task_id",
|
||||
"root_revision_before",
|
||||
"root_revision_after",
|
||||
"completed_children",
|
||||
"active_children",
|
||||
"blocked_children",
|
||||
"remaining_acceptance_conditions",
|
||||
"next_legal_action",
|
||||
"root_complete",
|
||||
"evidence_refs",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _require_exact_fields(metadata: Mapping[str, object], expected: frozenset[str]) -> None:
|
||||
if not all(isinstance(key, str) for key in metadata):
|
||||
raise LineageError("metadata field names must be strings")
|
||||
actual = frozenset(metadata)
|
||||
if actual != expected:
|
||||
missing = sorted(expected - actual)
|
||||
extra = sorted(actual - expected)
|
||||
raise LineageError(f"metadata fields mismatch: missing={missing} extra={extra}")
|
||||
|
||||
|
||||
def _require_string(metadata: Mapping[str, object], key: str) -> str:
|
||||
value = metadata[key]
|
||||
if not isinstance(value, str) or not value:
|
||||
raise LineageError(f"{key} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _require_int(metadata: Mapping[str, object], key: str) -> int:
|
||||
value = metadata[key]
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise LineageError(f"{key} must be an integer")
|
||||
return value
|
||||
|
||||
|
||||
def _require_string_list(metadata: Mapping[str, object], key: str) -> tuple[str, ...]:
|
||||
value = metadata[key]
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
|
||||
raise LineageError(f"{key} must be a list of non-empty strings")
|
||||
items = tuple(value)
|
||||
if len(items) != len(set(items)):
|
||||
raise LineageError(f"{key} must not contain duplicates")
|
||||
return items
|
||||
|
||||
|
||||
def lineage_to_metadata(lineage: TaskLineage) -> dict[str, object]:
|
||||
return {
|
||||
"schema": LINEAGE_SCHEMA,
|
||||
"task_id": lineage.task_id,
|
||||
"display_name": lineage.display_name,
|
||||
"owner_principal_id": lineage.owner_principal_id,
|
||||
"owner_alias": lineage.owner_alias,
|
||||
"created_at": _require_utc(lineage.created_at).isoformat().replace("+00:00", "Z"),
|
||||
"mission_id": lineage.mission_id,
|
||||
"root_task_id": lineage.root_task_id,
|
||||
"parent_task_id": lineage.parent_task_id,
|
||||
"lineage_depth": lineage.lineage_depth,
|
||||
"relation_type": lineage.relation_type.value,
|
||||
"sibling_sequence": lineage.sibling_sequence,
|
||||
"goal_slug": lineage.goal_slug,
|
||||
"root_acceptance_ref": lineage.root_acceptance_ref,
|
||||
"parent_revision": lineage.parent_revision,
|
||||
}
|
||||
|
||||
|
||||
def lineage_from_metadata(metadata: Mapping[str, object]) -> TaskLineage:
|
||||
_require_exact_fields(metadata, _LINEAGE_FIELDS)
|
||||
if metadata.get("schema") != LINEAGE_SCHEMA:
|
||||
raise LineageError("unsupported lineage metadata schema")
|
||||
try:
|
||||
created_raw = _require_string(metadata, "created_at")
|
||||
if created_raw.endswith("Z"):
|
||||
created_raw = created_raw[:-1] + "+00:00"
|
||||
parent_task_id = metadata["parent_task_id"]
|
||||
if parent_task_id is not None and (
|
||||
not isinstance(parent_task_id, str) or not parent_task_id
|
||||
):
|
||||
raise LineageError("parent_task_id must be null or a non-empty string")
|
||||
parent_revision = metadata["parent_revision"]
|
||||
if parent_revision is not None and (
|
||||
isinstance(parent_revision, bool) or not isinstance(parent_revision, int)
|
||||
):
|
||||
raise LineageError("parent_revision must be null or an integer")
|
||||
lineage = TaskLineage(
|
||||
task_id=_require_string(metadata, "task_id"),
|
||||
display_name=_require_string(metadata, "display_name"),
|
||||
owner_principal_id=_require_string(metadata, "owner_principal_id"),
|
||||
owner_alias=_require_string(metadata, "owner_alias"),
|
||||
created_at=datetime.fromisoformat(created_raw),
|
||||
mission_id=_require_string(metadata, "mission_id"),
|
||||
root_task_id=_require_string(metadata, "root_task_id"),
|
||||
parent_task_id=parent_task_id,
|
||||
lineage_depth=_require_int(metadata, "lineage_depth"),
|
||||
relation_type=RelationType(_require_string(metadata, "relation_type")),
|
||||
sibling_sequence=_require_int(metadata, "sibling_sequence"),
|
||||
goal_slug=_require_string(metadata, "goal_slug"),
|
||||
root_acceptance_ref=_require_string(metadata, "root_acceptance_ref"),
|
||||
parent_revision=parent_revision,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, LineageError) as exc:
|
||||
raise LineageError(f"invalid lineage metadata: {exc}") from exc
|
||||
_validate_lineage_shape(lineage)
|
||||
return lineage
|
||||
|
||||
|
||||
def root_review_to_record(review: RootReviewRecord) -> dict[str, object]:
|
||||
return {
|
||||
"schema": ROOT_REVIEW_SCHEMA,
|
||||
"review_id": review.review_id,
|
||||
"root_task_id": review.root_task_id,
|
||||
"reviewed_at": _require_utc(review.reviewed_at).isoformat().replace("+00:00", "Z"),
|
||||
"trigger_child_task_id": review.trigger_child_task_id,
|
||||
"root_revision_before": review.root_revision_before,
|
||||
"root_revision_after": review.root_revision_after,
|
||||
"completed_children": list(review.completed_children),
|
||||
"active_children": list(review.active_children),
|
||||
"blocked_children": list(review.blocked_children),
|
||||
"remaining_acceptance_conditions": list(review.remaining_acceptance_conditions),
|
||||
"next_legal_action": review.next_legal_action.value,
|
||||
"root_complete": review.root_complete,
|
||||
"evidence_refs": list(review.evidence_refs),
|
||||
}
|
||||
|
||||
|
||||
def root_review_from_record(
|
||||
record: Mapping[str, object], *, expected_review_id: str | None = None
|
||||
) -> RootReviewRecord:
|
||||
_require_exact_fields(record, _ROOT_REVIEW_FIELDS)
|
||||
if record.get("schema") != ROOT_REVIEW_SCHEMA:
|
||||
raise LineageError("unsupported root-review metadata schema")
|
||||
try:
|
||||
reviewed_raw = _require_string(record, "reviewed_at")
|
||||
if reviewed_raw.endswith("Z"):
|
||||
reviewed_raw = reviewed_raw[:-1] + "+00:00"
|
||||
root_complete = record["root_complete"]
|
||||
if not isinstance(root_complete, bool):
|
||||
raise LineageError("root_complete must be a boolean")
|
||||
review = RootReviewRecord(
|
||||
review_id=_require_string(record, "review_id"),
|
||||
root_task_id=_require_string(record, "root_task_id"),
|
||||
reviewed_at=_require_utc(datetime.fromisoformat(reviewed_raw)),
|
||||
trigger_child_task_id=_require_string(record, "trigger_child_task_id"),
|
||||
root_revision_before=_require_int(record, "root_revision_before"),
|
||||
root_revision_after=_require_int(record, "root_revision_after"),
|
||||
completed_children=_require_string_list(record, "completed_children"),
|
||||
active_children=_require_string_list(record, "active_children"),
|
||||
blocked_children=_require_string_list(record, "blocked_children"),
|
||||
remaining_acceptance_conditions=_require_string_list(
|
||||
record, "remaining_acceptance_conditions"
|
||||
),
|
||||
next_legal_action=NextLegalAction(
|
||||
_require_string(record, "next_legal_action")
|
||||
),
|
||||
root_complete=root_complete,
|
||||
evidence_refs=_require_string_list(record, "evidence_refs"),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, LineageError) as exc:
|
||||
raise LineageError(f"invalid root-review metadata: {exc}") from exc
|
||||
if expected_review_id is not None and review.review_id != expected_review_id:
|
||||
raise LineageError("root-review id does not match lookup key")
|
||||
if review.root_revision_before < 0 or (
|
||||
review.root_revision_after != review.root_revision_before + 1
|
||||
):
|
||||
raise LineageError("root-review revision transition is invalid")
|
||||
if review.root_complete != (
|
||||
review.next_legal_action == NextLegalAction.ROOT_COMPLETE
|
||||
):
|
||||
raise LineageError("root_complete does not match next_legal_action")
|
||||
child_sets = (
|
||||
set(review.completed_children),
|
||||
set(review.active_children),
|
||||
set(review.blocked_children),
|
||||
)
|
||||
if any(child_sets[i] & child_sets[j] for i in range(3) for j in range(i + 1, 3)):
|
||||
raise LineageError("root-review child classifications overlap")
|
||||
return review
|
||||
|
||||
def _review_id(
|
||||
root_task_id: str,
|
||||
trigger_child_task_id: str,
|
||||
trigger_revision: int,
|
||||
acceptance: RootAcceptanceSnapshot,
|
||||
) -> str:
|
||||
acceptance_digest = sha256(
|
||||
"\n".join(
|
||||
f"{c.key}:{int(c.satisfied)}:{int(c.owner_boundary)}:"
|
||||
f"{','.join(sorted(set(c.evidence_refs)))}"
|
||||
for c in acceptance.conditions
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
payload = f"{root_task_id}|{trigger_child_task_id}|{trigger_revision}|{acceptance_digest}"
|
||||
return "RR-" + sha256(payload.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
|
||||
def _next_action(
|
||||
descendants: Sequence[TaskRecord],
|
||||
acceptance: RootAcceptanceSnapshot,
|
||||
) -> NextLegalAction:
|
||||
if acceptance.complete:
|
||||
return NextLegalAction.ROOT_COMPLETE
|
||||
if acceptance.owner_boundary_required:
|
||||
return NextLegalAction.OWNER_BOUNDARY
|
||||
if any(d.status in RECOVERY_STATUSES for d in descendants):
|
||||
return NextLegalAction.RECOVER_EXISTING_CHILD
|
||||
if any(d.status in BLOCKED_STATUSES for d in descendants):
|
||||
return NextLegalAction.WAIT_DEPENDENCY
|
||||
return NextLegalAction.CREATE_OR_RESUME_NEXT_CHILD
|
||||
|
||||
|
||||
def review_root_after_child_terminal(
|
||||
*,
|
||||
store: RootReviewStore,
|
||||
trigger_child_task_id: str,
|
||||
acceptance_evaluator: AcceptanceEvaluator,
|
||||
reviewed_at: datetime,
|
||||
max_cas_retries: int = 4,
|
||||
) -> RootReviewRecord:
|
||||
"""Persist a root review after a proven child terminal transition.
|
||||
|
||||
This function is deliberately storage-agnostic. TaskPlane remains the
|
||||
authority and supplies the CAS implementation. The function never creates a
|
||||
new root or mutates a child identity.
|
||||
"""
|
||||
if max_cas_retries < 1:
|
||||
raise LineageError("max_cas_retries must be >= 1")
|
||||
reviewed_at = _require_utc(reviewed_at)
|
||||
|
||||
for _ in range(max_cas_retries):
|
||||
child = store.get_task(trigger_child_task_id)
|
||||
if child.lineage.relation_type == RelationType.ROOT:
|
||||
raise LineageError("root review hook requires a child task")
|
||||
if child.status not in TERMINAL_STATUSES:
|
||||
raise LineageError("child must be terminal before root review")
|
||||
if not child.terminal_evidence_proven:
|
||||
raise LineageError("child terminal evidence is not proven")
|
||||
|
||||
root = store.get_task(child.lineage.root_task_id)
|
||||
if root.lineage.relation_type != RelationType.ROOT:
|
||||
raise LineageError("root_task_id does not resolve to a ROOT task")
|
||||
if child.lineage.owner_principal_id != root.lineage.owner_principal_id:
|
||||
raise LineageError("trigger child owner does not match root owner")
|
||||
if child.lineage.mission_id != root.lineage.mission_id:
|
||||
raise LineageError("trigger child mission does not match root mission")
|
||||
if child.lineage.root_acceptance_ref != root.lineage.root_acceptance_ref:
|
||||
raise LineageError("trigger child acceptance reference does not match root")
|
||||
|
||||
descendants = tuple(
|
||||
sorted(store.list_descendants(root.task_id), key=lambda item: item.task_id)
|
||||
)
|
||||
descendant_ids = tuple(item.task_id for item in descendants)
|
||||
if len(descendant_ids) != len(set(descendant_ids)):
|
||||
raise LineageError("descendant authority returned duplicate task ids")
|
||||
if child.task_id not in descendant_ids:
|
||||
raise LineageError("trigger child missing from authoritative descendants")
|
||||
for descendant in descendants:
|
||||
if descendant.lineage.root_task_id != root.task_id:
|
||||
raise LineageError("descendant is attached to a different root")
|
||||
if descendant.lineage.owner_principal_id != root.lineage.owner_principal_id:
|
||||
raise LineageError("descendant owner does not match root owner")
|
||||
if descendant.lineage.mission_id != root.lineage.mission_id:
|
||||
raise LineageError("descendant mission does not match root mission")
|
||||
if (
|
||||
descendant.lineage.root_acceptance_ref
|
||||
!= root.lineage.root_acceptance_ref
|
||||
):
|
||||
raise LineageError("descendant acceptance reference does not match root")
|
||||
|
||||
acceptance = canonicalize_acceptance(acceptance_evaluator(root, descendants))
|
||||
review_id = _review_id(root.task_id, child.task_id, child.revision, acceptance)
|
||||
|
||||
completed = tuple(
|
||||
d.task_id for d in descendants
|
||||
if d.status in TERMINAL_STATUSES and d.terminal_evidence_proven
|
||||
)
|
||||
active = tuple(
|
||||
d.task_id for d in descendants
|
||||
if d.status not in TERMINAL_STATUSES and d.status not in BLOCKED_STATUSES
|
||||
)
|
||||
blocked = tuple(
|
||||
d.task_id for d in descendants
|
||||
if d.status in BLOCKED_STATUSES
|
||||
)
|
||||
evidence = tuple(sorted(set(child.evidence_refs + acceptance.evidence_refs)))
|
||||
action = _next_action(descendants, acceptance)
|
||||
|
||||
existing = store.get_root_review(review_id)
|
||||
if existing is not None:
|
||||
if (
|
||||
existing.root_task_id != root.task_id
|
||||
or existing.trigger_child_task_id != child.task_id
|
||||
):
|
||||
raise LineageError("persisted root review identity conflicts with trigger")
|
||||
if existing.root_revision_after > root.revision:
|
||||
raise LineageError("persisted root review revision is ahead of authority")
|
||||
expected_semantics = (
|
||||
completed,
|
||||
active,
|
||||
blocked,
|
||||
acceptance.remaining,
|
||||
action,
|
||||
action == NextLegalAction.ROOT_COMPLETE,
|
||||
evidence,
|
||||
)
|
||||
actual_semantics = (
|
||||
existing.completed_children,
|
||||
existing.active_children,
|
||||
existing.blocked_children,
|
||||
existing.remaining_acceptance_conditions,
|
||||
existing.next_legal_action,
|
||||
existing.root_complete,
|
||||
existing.evidence_refs,
|
||||
)
|
||||
if actual_semantics != expected_semantics:
|
||||
raise LineageError("persisted root review conflicts with current semantics")
|
||||
return existing
|
||||
|
||||
review = RootReviewRecord(
|
||||
review_id=review_id,
|
||||
root_task_id=root.task_id,
|
||||
reviewed_at=reviewed_at,
|
||||
trigger_child_task_id=child.task_id,
|
||||
root_revision_before=root.revision,
|
||||
root_revision_after=root.revision + 1,
|
||||
completed_children=completed,
|
||||
active_children=active,
|
||||
blocked_children=blocked,
|
||||
remaining_acceptance_conditions=acceptance.remaining,
|
||||
next_legal_action=action,
|
||||
root_complete=(action == NextLegalAction.ROOT_COMPLETE),
|
||||
evidence_refs=evidence,
|
||||
)
|
||||
if store.compare_and_swap_root_review(root.task_id, root.revision, review):
|
||||
return review
|
||||
|
||||
# Another sibling may have updated root progress. Re-read authority and
|
||||
# recompute; never overwrite a newer root review.
|
||||
|
||||
raise LineageError("root review CAS retry budget exhausted")
|
||||
@@ -0,0 +1,285 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from typing import Mapping
|
||||
|
||||
if not __package__:
|
||||
from aios_task_lineage import AcceptanceEvaluator, RelationType, TaskLineage, lineage_to_metadata, lineage_from_metadata, root_review_to_record
|
||||
from aios_task_lineage_integration import (
|
||||
DISPLAY_NAME_METADATA_KEY,
|
||||
IntegrationError,
|
||||
LINEAGE_METADATA_KEY,
|
||||
NewTaskIdentityRequest,
|
||||
PreparedTaskIdentity,
|
||||
TaskIdentityLineageService,
|
||||
TerminalReviewOutcome,
|
||||
)
|
||||
from aios_task_lineage_taskplane_adapter import (
|
||||
AuthenticatedTaskContext,
|
||||
InProcessTaskPlaneLineageAdapter,
|
||||
require_context_matches_task,
|
||||
)
|
||||
else:
|
||||
from .aios_task_lineage import AcceptanceEvaluator, RelationType, TaskLineage, lineage_to_metadata, lineage_from_metadata, root_review_to_record
|
||||
from .aios_task_lineage_integration import (
|
||||
DISPLAY_NAME_METADATA_KEY,
|
||||
IntegrationError,
|
||||
LINEAGE_METADATA_KEY,
|
||||
NewTaskIdentityRequest,
|
||||
PreparedTaskIdentity,
|
||||
TaskIdentityLineageService,
|
||||
TerminalReviewOutcome,
|
||||
)
|
||||
from .aios_task_lineage_taskplane_adapter import (
|
||||
AuthenticatedTaskContext,
|
||||
InProcessTaskPlaneLineageAdapter,
|
||||
require_context_matches_task,
|
||||
)
|
||||
|
||||
|
||||
class LifecycleHookError(IntegrationError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityPersistenceOutcome:
|
||||
prepared: PreparedTaskIdentity
|
||||
persisted_revision_at_least: int
|
||||
independently_verified: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacyAttachOutcome:
|
||||
lineage: TaskLineage
|
||||
independently_verified: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TerminalHookOutcome:
|
||||
outcome: TerminalReviewOutcome
|
||||
root_review_persisted: bool
|
||||
dispatch_persisted: bool
|
||||
independently_verified: bool
|
||||
|
||||
|
||||
class TaskLineageLifecycleHooks:
|
||||
"""Concrete lifecycle hook coordinator.
|
||||
|
||||
It does not own TaskPlane state. It orchestrates existing TaskPlane CAS,
|
||||
journal/continuation and optional independent authority readback.
|
||||
"""
|
||||
|
||||
def __init__(self, adapter: InProcessTaskPlaneLineageAdapter):
|
||||
self._adapter = adapter
|
||||
self._service = TaskIdentityLineageService(adapter)
|
||||
|
||||
def persist_new_task_identity(
|
||||
self,
|
||||
*,
|
||||
request: NewTaskIdentityRequest,
|
||||
context: AuthenticatedTaskContext,
|
||||
max_cas_retries: int = 4,
|
||||
require_independent_readback: bool = True,
|
||||
) -> IdentityPersistenceOutcome:
|
||||
if max_cas_retries < 1:
|
||||
raise LifecycleHookError("max_cas_retries must be >= 1")
|
||||
if require_independent_readback and not self._adapter.has_independent_readback:
|
||||
raise LifecycleHookError("independent TaskPlane readback is not configured")
|
||||
if request.owner_principal_id != context.principal_id:
|
||||
raise LifecycleHookError("request principal does not match authenticated context")
|
||||
if request.mission_id != context.mission_id:
|
||||
raise LifecycleHookError("request mission does not match authenticated context")
|
||||
|
||||
for _ in range(max_cas_retries):
|
||||
current = self._adapter.get_task_snapshot(request.task_id)
|
||||
require_context_matches_task(context, current)
|
||||
if current.created_at != request.created_at:
|
||||
raise LifecycleHookError("request created_at does not match authority")
|
||||
if (request.relation_type == RelationType.ROOT
|
||||
and request.preferred_owner_alias not in (None, context.preferred_alias)):
|
||||
raise LifecycleHookError("request alias does not match authenticated context")
|
||||
request = replace(request, preferred_owner_alias=context.preferred_alias)
|
||||
prepared = self._service.prepare_new_task_identity(request)
|
||||
|
||||
# Idempotent replay and legacy display-name repair are fail-closed:
|
||||
# never overwrite an existing conflicting lineage record.
|
||||
raw = current.metadata.get(LINEAGE_METADATA_KEY)
|
||||
display = current.metadata.get(DISPLAY_NAME_METADATA_KEY)
|
||||
if raw is not None and not isinstance(raw, Mapping):
|
||||
raise LifecycleHookError("existing lineage metadata is malformed")
|
||||
if isinstance(raw, Mapping):
|
||||
# parent_revision records the creation observation, not the
|
||||
# parent's current progress revision on an idempotent replay.
|
||||
expected = dict(prepared.metadata_patch[LINEAGE_METADATA_KEY])
|
||||
if request.parent_task_id is not None:
|
||||
stored = lineage_from_metadata(dict(raw))
|
||||
if (stored.parent_revision is None
|
||||
or stored.parent_revision > prepared.lineage.parent_revision):
|
||||
raise LifecycleHookError("existing parent revision conflicts with authority")
|
||||
expected["parent_revision"] = stored.parent_revision
|
||||
if raw != expected:
|
||||
raise LifecycleHookError(
|
||||
"existing lineage metadata conflicts with requested identity"
|
||||
)
|
||||
prepared = replace(prepared, lineage=lineage_from_metadata(dict(raw)),
|
||||
metadata_patch={LINEAGE_METADATA_KEY: dict(raw),
|
||||
DISPLAY_NAME_METADATA_KEY: prepared.display_name})
|
||||
if display == prepared.display_name:
|
||||
verified = False
|
||||
if require_independent_readback:
|
||||
self._adapter.verify_task_readback(
|
||||
task_id=request.task_id,
|
||||
expected_revision_at_least=current.revision,
|
||||
expected_metadata=prepared.metadata_patch,
|
||||
)
|
||||
verified = True
|
||||
return IdentityPersistenceOutcome(
|
||||
prepared=prepared,
|
||||
persisted_revision_at_least=current.revision,
|
||||
independently_verified=verified,
|
||||
)
|
||||
if display not in (None, ""):
|
||||
raise LifecycleHookError(
|
||||
"existing display name conflicts with requested identity"
|
||||
)
|
||||
metadata_patch = {
|
||||
DISPLAY_NAME_METADATA_KEY: prepared.display_name,
|
||||
}
|
||||
else:
|
||||
if display not in (None, ""):
|
||||
raise LifecycleHookError(
|
||||
"display name exists without authoritative lineage"
|
||||
)
|
||||
metadata_patch = prepared.metadata_patch
|
||||
|
||||
ok = self._adapter.compare_and_swap_task_metadata(
|
||||
task_id=request.task_id,
|
||||
expected_revision=current.revision,
|
||||
metadata_patch=metadata_patch,
|
||||
)
|
||||
if not ok:
|
||||
continue
|
||||
|
||||
verified = False
|
||||
if require_independent_readback:
|
||||
self._adapter.verify_task_readback(
|
||||
task_id=request.task_id,
|
||||
expected_revision_at_least=current.revision + 1,
|
||||
expected_metadata=prepared.metadata_patch,
|
||||
)
|
||||
verified = True
|
||||
return IdentityPersistenceOutcome(
|
||||
prepared=prepared,
|
||||
persisted_revision_at_least=current.revision + 1,
|
||||
independently_verified=verified,
|
||||
)
|
||||
|
||||
raise LifecycleHookError("identity persistence CAS retry budget exhausted")
|
||||
|
||||
def attach_legacy_identity(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
parent_task_id: str,
|
||||
relation_type: RelationType,
|
||||
goal_slug: str,
|
||||
context: AuthenticatedTaskContext,
|
||||
max_cas_retries: int = 4,
|
||||
require_independent_readback: bool = True,
|
||||
) -> LegacyAttachOutcome:
|
||||
if require_independent_readback and not self._adapter.has_independent_readback:
|
||||
raise LifecycleHookError("independent TaskPlane readback is not configured")
|
||||
before = self._adapter.get_task_snapshot(task_id)
|
||||
require_context_matches_task(context, before)
|
||||
lineage = self._service.attach_legacy_task(
|
||||
task_id=task_id,
|
||||
parent_task_id=parent_task_id,
|
||||
relation_type=relation_type,
|
||||
goal_slug=goal_slug,
|
||||
max_cas_retries=max_cas_retries,
|
||||
expected_owner_principal_id=context.principal_id,
|
||||
expected_mission_id=context.mission_id,
|
||||
)
|
||||
verified = False
|
||||
if require_independent_readback:
|
||||
self._adapter.verify_task_readback(
|
||||
task_id=task_id,
|
||||
expected_revision_at_least=before.revision,
|
||||
expected_metadata={
|
||||
LINEAGE_METADATA_KEY: lineage_to_metadata(lineage),
|
||||
DISPLAY_NAME_METADATA_KEY: lineage.display_name,
|
||||
},
|
||||
)
|
||||
# Domain serialization compatibility check below avoids requiring
|
||||
# TaskLineage to expose a method in older versions.
|
||||
verified = True
|
||||
return LegacyAttachOutcome(lineage=lineage, independently_verified=verified)
|
||||
|
||||
def on_proven_terminal_child(
|
||||
self,
|
||||
*,
|
||||
child_task_id: str,
|
||||
context: AuthenticatedTaskContext,
|
||||
acceptance_evaluator: AcceptanceEvaluator,
|
||||
reviewed_at: datetime,
|
||||
max_cas_retries: int = 4,
|
||||
require_independent_readback: bool = True,
|
||||
) -> TerminalHookOutcome:
|
||||
if require_independent_readback and not self._adapter.has_independent_readback:
|
||||
raise LifecycleHookError("independent TaskPlane readback is not configured")
|
||||
child = self._adapter.get_task_snapshot(child_task_id)
|
||||
require_context_matches_task(context, child)
|
||||
if not child.terminal_evidence_proven:
|
||||
raise LifecycleHookError(
|
||||
"terminal child hook requires proven terminal evidence"
|
||||
)
|
||||
raw = child.metadata.get(LINEAGE_METADATA_KEY)
|
||||
if not isinstance(raw, Mapping):
|
||||
raise LifecycleHookError("terminal child lineage missing")
|
||||
lineage = lineage_from_metadata(dict(raw))
|
||||
root = self._adapter.get_task_snapshot(lineage.root_task_id)
|
||||
require_context_matches_task(context, root)
|
||||
|
||||
def authorized_acceptance(root_record, descendants):
|
||||
if (root_record.lineage.owner_principal_id != context.principal_id
|
||||
or root_record.lineage.mission_id != context.mission_id):
|
||||
raise LifecycleHookError("root authority changed during terminal review")
|
||||
return acceptance_evaluator(root_record, descendants)
|
||||
|
||||
outcome = self._service.on_proven_terminal_child(
|
||||
child_task_id=child_task_id,
|
||||
acceptance_evaluator=authorized_acceptance,
|
||||
reviewed_at=reviewed_at,
|
||||
max_cas_retries=max_cas_retries,
|
||||
)
|
||||
|
||||
persisted_review = self._adapter.get_root_review_record(
|
||||
outcome.review.review_id
|
||||
)
|
||||
if persisted_review is None:
|
||||
raise LifecycleHookError("root review was not persisted")
|
||||
if persisted_review != root_review_to_record(outcome.review):
|
||||
raise LifecycleHookError("persisted review does not match returned review")
|
||||
|
||||
dispatch = self._adapter.get_root_action_dispatch(outcome.review.review_id)
|
||||
if dispatch is None:
|
||||
raise LifecycleHookError("root action dispatch was not persisted")
|
||||
if dispatch != outcome.dispatch:
|
||||
raise LifecycleHookError("persisted dispatch does not match returned receipt")
|
||||
|
||||
verified = False
|
||||
if require_independent_readback:
|
||||
self._adapter.verify_root_readback(
|
||||
root_task_id=outcome.review.root_task_id,
|
||||
expected_review_id=outcome.review.review_id,
|
||||
)
|
||||
verified = True
|
||||
|
||||
return TerminalHookOutcome(
|
||||
outcome=outcome,
|
||||
root_review_persisted=True,
|
||||
dispatch_persisted=True,
|
||||
independently_verified=verified,
|
||||
)
|
||||
@@ -0,0 +1,539 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Mapping, MutableMapping, Protocol, Sequence
|
||||
|
||||
if not __package__:
|
||||
from aios_task_lineage import (
|
||||
AcceptanceEvaluator,
|
||||
LineageError,
|
||||
NextLegalAction,
|
||||
RelationType,
|
||||
RootReviewRecord,
|
||||
RootReviewStore,
|
||||
TaskLineage,
|
||||
TaskRecord,
|
||||
attach_legacy_lineage,
|
||||
derive_safe_owner_alias,
|
||||
lineage_from_metadata,
|
||||
lineage_to_metadata,
|
||||
make_child_lineage,
|
||||
make_root_lineage,
|
||||
normalize_goal_slug,
|
||||
review_root_after_child_terminal,
|
||||
root_review_from_record,
|
||||
root_review_to_record,
|
||||
)
|
||||
else:
|
||||
from .aios_task_lineage import (
|
||||
AcceptanceEvaluator,
|
||||
LineageError,
|
||||
NextLegalAction,
|
||||
RelationType,
|
||||
RootReviewRecord,
|
||||
RootReviewStore,
|
||||
TaskLineage,
|
||||
TaskRecord,
|
||||
attach_legacy_lineage,
|
||||
derive_safe_owner_alias,
|
||||
lineage_from_metadata,
|
||||
lineage_to_metadata,
|
||||
make_child_lineage,
|
||||
make_root_lineage,
|
||||
normalize_goal_slug,
|
||||
review_root_after_child_terminal,
|
||||
root_review_from_record,
|
||||
root_review_to_record,
|
||||
)
|
||||
|
||||
|
||||
LINEAGE_METADATA_KEY = "aios_task_lineage"
|
||||
LINEAGE_REQUEST_DIGEST_KEY = "aios_lineage_request_digest"
|
||||
ROOT_REVIEW_RECORD_KEY = "aios_root_review"
|
||||
DISPLAY_NAME_METADATA_KEY = "aios_task_display_name"
|
||||
|
||||
|
||||
class IntegrationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskAuthoritySnapshot:
|
||||
task_id: str
|
||||
revision: int
|
||||
status: str
|
||||
owner_principal_id: str
|
||||
mission_id: str
|
||||
created_at: datetime
|
||||
metadata: Mapping[str, object]
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
terminal_evidence_proven: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewTaskIdentityRequest:
|
||||
task_id: str
|
||||
owner_principal_id: str
|
||||
preferred_owner_alias: str | None
|
||||
created_at: datetime
|
||||
mission_id: str
|
||||
goal_slug: str
|
||||
root_acceptance_ref: str
|
||||
relation_type: RelationType = RelationType.ROOT
|
||||
parent_task_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedTaskIdentity:
|
||||
task_id: str
|
||||
display_name: str
|
||||
lineage: TaskLineage
|
||||
metadata_patch: Mapping[str, object]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TerminalReviewOutcome:
|
||||
review: RootReviewRecord
|
||||
dispatch: "RootActionDispatchReceipt"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RootActionDispatchReceipt:
|
||||
review_id: str
|
||||
root_task_id: str
|
||||
action: NextLegalAction
|
||||
idempotency_key: str
|
||||
status: str
|
||||
mission_id: str = ""
|
||||
root_revision: int = 0
|
||||
plan_digest: str | None = None
|
||||
execution_key: str | None = None
|
||||
|
||||
|
||||
PERSISTED_DISPATCH_STATUSES = frozenset(
|
||||
{
|
||||
"PENDING",
|
||||
"CLAIMED",
|
||||
"EFFECT_UNKNOWN",
|
||||
"EXECUTED_PENDING_REREVIEW",
|
||||
"WAITING_DEPENDENCY",
|
||||
"WAITING_OWNER",
|
||||
"CLOSED",
|
||||
"SUPERSEDED",
|
||||
"BLOCKED",
|
||||
# Read compatibility only. A consumer must reconcile this legacy
|
||||
# state unless a complete durable receipt is present.
|
||||
"DISPATCHED",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TaskPlaneLineagePort(Protocol):
|
||||
"""Narrow authority port required by the lineage integration service.
|
||||
|
||||
The live adapter must implement these methods over the existing TaskPlane /
|
||||
mission / journal / CAS mechanisms. This protocol deliberately avoids
|
||||
guessing concrete DB tables, HTTP endpoints, or handler names.
|
||||
"""
|
||||
|
||||
def get_task_snapshot(self, task_id: str) -> TaskAuthoritySnapshot: ...
|
||||
|
||||
def list_descendant_snapshots(
|
||||
self, root_task_id: str
|
||||
) -> Sequence[TaskAuthoritySnapshot]: ...
|
||||
|
||||
def reserve_sibling_sequence(
|
||||
self,
|
||||
*,
|
||||
parent_task_id: str,
|
||||
relation_type: RelationType,
|
||||
child_task_id: str,
|
||||
) -> int:
|
||||
"""Idempotently reserve one sibling sequence for child_task_id."""
|
||||
|
||||
def compare_and_swap_task_metadata(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
expected_revision: int,
|
||||
metadata_patch: Mapping[str, object],
|
||||
) -> bool: ...
|
||||
|
||||
def get_root_review_record(
|
||||
self, review_id: str
|
||||
) -> Mapping[str, object] | None: ...
|
||||
|
||||
def compare_and_swap_root_review_record(
|
||||
self,
|
||||
*,
|
||||
root_task_id: str,
|
||||
expected_root_revision: int,
|
||||
review_record: Mapping[str, object],
|
||||
) -> bool: ...
|
||||
|
||||
def get_root_action_dispatch(
|
||||
self, review_id: str
|
||||
) -> RootActionDispatchReceipt | None: ...
|
||||
|
||||
def dispatch_root_action(
|
||||
self,
|
||||
*,
|
||||
review: RootReviewRecord,
|
||||
idempotency_key: str,
|
||||
) -> RootActionDispatchReceipt: ...
|
||||
|
||||
|
||||
def _extract_lineage(snapshot: TaskAuthoritySnapshot) -> TaskLineage:
|
||||
raw = snapshot.metadata.get(LINEAGE_METADATA_KEY)
|
||||
if not isinstance(raw, dict):
|
||||
raise IntegrationError(
|
||||
f"task {snapshot.task_id} does not have authoritative lineage metadata"
|
||||
)
|
||||
lineage = lineage_from_metadata(raw)
|
||||
if lineage.task_id != snapshot.task_id:
|
||||
raise IntegrationError("lineage task_id does not match authority task")
|
||||
if lineage.owner_principal_id != snapshot.owner_principal_id:
|
||||
raise IntegrationError("lineage owner does not match authority owner")
|
||||
if lineage.mission_id != snapshot.mission_id:
|
||||
raise IntegrationError("lineage mission does not match authority mission")
|
||||
if snapshot.created_at.tzinfo is None or snapshot.created_at.utcoffset() is None:
|
||||
raise IntegrationError("authority created_at must be timezone-aware")
|
||||
if lineage.created_at != snapshot.created_at:
|
||||
raise IntegrationError("lineage created_at does not match authority task")
|
||||
display = snapshot.metadata.get(DISPLAY_NAME_METADATA_KEY)
|
||||
if display not in (None, "", lineage.display_name):
|
||||
raise IntegrationError("lineage display name conflicts with authority metadata")
|
||||
return lineage
|
||||
|
||||
|
||||
def _as_task_record(snapshot: TaskAuthoritySnapshot) -> TaskRecord:
|
||||
return TaskRecord(
|
||||
task_id=snapshot.task_id,
|
||||
revision=snapshot.revision,
|
||||
status=snapshot.status,
|
||||
lineage=_extract_lineage(snapshot),
|
||||
evidence_refs=snapshot.evidence_refs,
|
||||
terminal_evidence_proven=snapshot.terminal_evidence_proven,
|
||||
)
|
||||
|
||||
|
||||
def _validate_record_root_binding(record: TaskRecord, root: TaskRecord) -> None:
|
||||
if root.lineage.relation_type != RelationType.ROOT:
|
||||
raise IntegrationError("root_task_id does not resolve to a ROOT task")
|
||||
if record.lineage.root_task_id != root.task_id:
|
||||
raise IntegrationError("task lineage points to a different root")
|
||||
if record.lineage.owner_principal_id != root.lineage.owner_principal_id:
|
||||
raise IntegrationError("task lineage owner does not match root owner")
|
||||
if record.lineage.mission_id != root.lineage.mission_id:
|
||||
raise IntegrationError("task lineage mission does not match root mission")
|
||||
if record.lineage.root_acceptance_ref != root.lineage.root_acceptance_ref:
|
||||
raise IntegrationError("task lineage acceptance reference does not match root")
|
||||
|
||||
|
||||
def _load_authority_record(
|
||||
port: TaskPlaneLineagePort, task_id: str
|
||||
) -> tuple[TaskRecord, TaskRecord]:
|
||||
record = _as_task_record(port.get_task_snapshot(task_id))
|
||||
if record.lineage.relation_type == RelationType.ROOT:
|
||||
return record, record
|
||||
root = _as_task_record(port.get_task_snapshot(record.lineage.root_task_id))
|
||||
current = record
|
||||
seen = {record.task_id}
|
||||
while current.lineage.relation_type != RelationType.ROOT:
|
||||
_validate_record_root_binding(current, root)
|
||||
parent_id = current.lineage.parent_task_id
|
||||
if not parent_id or parent_id in seen:
|
||||
raise IntegrationError("task lineage contains a parent cycle")
|
||||
seen.add(parent_id)
|
||||
parent = _as_task_record(port.get_task_snapshot(parent_id))
|
||||
_validate_record_root_binding(parent, root)
|
||||
if current.lineage.parent_task_id != parent.task_id:
|
||||
raise IntegrationError("task lineage parent does not match authority parent")
|
||||
if current.lineage.lineage_depth != parent.lineage.lineage_depth + 1:
|
||||
raise IntegrationError("task lineage depth does not match authority parent")
|
||||
if current.lineage.parent_revision is None or (
|
||||
current.lineage.parent_revision > parent.revision
|
||||
):
|
||||
raise IntegrationError("task lineage parent revision is not authoritative")
|
||||
current = parent
|
||||
if current.task_id != root.task_id:
|
||||
raise IntegrationError("task lineage parent chain ends at a different root")
|
||||
return record, root
|
||||
|
||||
|
||||
def _validate_dispatch_receipt(
|
||||
receipt: RootActionDispatchReceipt,
|
||||
*,
|
||||
review: RootReviewRecord,
|
||||
idempotency_key: str,
|
||||
existing: bool,
|
||||
) -> None:
|
||||
if (
|
||||
receipt.review_id != review.review_id
|
||||
or receipt.root_task_id != review.root_task_id
|
||||
or receipt.action != review.next_legal_action
|
||||
or receipt.idempotency_key != idempotency_key
|
||||
or receipt.status not in PERSISTED_DISPATCH_STATUSES
|
||||
):
|
||||
noun = "existing root-action dispatch" if existing else "root-action dispatch receipt"
|
||||
raise IntegrationError(f"{noun} conflicts with persisted review")
|
||||
|
||||
|
||||
class _RootReviewStoreAdapter(RootReviewStore):
|
||||
def __init__(self, port: TaskPlaneLineagePort):
|
||||
self._port = port
|
||||
|
||||
def get_task(self, task_id: str) -> TaskRecord:
|
||||
record, _ = _load_authority_record(self._port, task_id)
|
||||
return record
|
||||
|
||||
def list_descendants(self, root_task_id: str) -> Sequence[TaskRecord]:
|
||||
root, _ = _load_authority_record(self._port, root_task_id)
|
||||
if root.lineage.relation_type != RelationType.ROOT:
|
||||
raise IntegrationError("descendant lookup key is not a ROOT task")
|
||||
records: list[TaskRecord] = []
|
||||
for snapshot in self._port.list_descendant_snapshots(root_task_id):
|
||||
record, resolved_root = _load_authority_record(self._port, snapshot.task_id)
|
||||
if resolved_root.task_id != root_task_id:
|
||||
raise IntegrationError("descendant authority returned a cross-root task")
|
||||
_validate_record_root_binding(record, root)
|
||||
records.append(record)
|
||||
return tuple(records)
|
||||
|
||||
def get_root_review(self, review_id: str) -> RootReviewRecord | None:
|
||||
raw = self._port.get_root_review_record(review_id)
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, Mapping):
|
||||
raise IntegrationError("persisted root-review record must be a mapping")
|
||||
try:
|
||||
return root_review_from_record(
|
||||
raw,
|
||||
expected_review_id=review_id,
|
||||
)
|
||||
except LineageError as exc:
|
||||
raise IntegrationError(
|
||||
f"invalid persisted root-review record: {exc}"
|
||||
) from exc
|
||||
|
||||
def compare_and_swap_root_review(
|
||||
self,
|
||||
root_task_id: str,
|
||||
expected_root_revision: int,
|
||||
review: RootReviewRecord,
|
||||
) -> bool:
|
||||
return self._port.compare_and_swap_root_review_record(
|
||||
root_task_id=root_task_id,
|
||||
expected_root_revision=expected_root_revision,
|
||||
review_record=root_review_to_record(review),
|
||||
)
|
||||
|
||||
|
||||
class TaskIdentityLineageService:
|
||||
"""Application service that wires the lineage domain to TaskPlane authority."""
|
||||
|
||||
def __init__(self, port: TaskPlaneLineagePort):
|
||||
self._port = port
|
||||
|
||||
def prepare_new_task_identity(
|
||||
self, request: NewTaskIdentityRequest
|
||||
) -> PreparedTaskIdentity:
|
||||
alias = derive_safe_owner_alias(
|
||||
request.owner_principal_id, request.preferred_owner_alias
|
||||
)
|
||||
|
||||
if request.relation_type == RelationType.ROOT:
|
||||
if request.parent_task_id is not None:
|
||||
raise IntegrationError("ROOT task cannot have parent_task_id")
|
||||
lineage = make_root_lineage(
|
||||
task_id=request.task_id,
|
||||
owner_principal_id=request.owner_principal_id,
|
||||
owner_alias=alias,
|
||||
created_at=request.created_at,
|
||||
mission_id=request.mission_id,
|
||||
goal_slug=request.goal_slug,
|
||||
root_acceptance_ref=request.root_acceptance_ref,
|
||||
)
|
||||
else:
|
||||
if not request.parent_task_id:
|
||||
raise IntegrationError("non-root task requires parent_task_id")
|
||||
parent, root = _load_authority_record(
|
||||
self._port, request.parent_task_id
|
||||
)
|
||||
|
||||
if request.owner_principal_id != root.lineage.owner_principal_id:
|
||||
raise IntegrationError("cross-owner child creation rejected")
|
||||
if request.mission_id != root.lineage.mission_id:
|
||||
raise IntegrationError("cross-mission child creation rejected")
|
||||
if request.root_acceptance_ref != root.lineage.root_acceptance_ref:
|
||||
raise IntegrationError("root acceptance reference mismatch")
|
||||
|
||||
sequence = self._port.reserve_sibling_sequence(
|
||||
parent_task_id=parent.task_id,
|
||||
relation_type=request.relation_type,
|
||||
child_task_id=request.task_id,
|
||||
)
|
||||
lineage = make_child_lineage(
|
||||
task_id=request.task_id,
|
||||
created_at=request.created_at,
|
||||
parent=parent,
|
||||
root=root,
|
||||
relation_type=request.relation_type,
|
||||
sibling_sequence=sequence,
|
||||
goal_slug=request.goal_slug,
|
||||
)
|
||||
|
||||
return PreparedTaskIdentity(
|
||||
task_id=request.task_id,
|
||||
display_name=lineage.display_name,
|
||||
lineage=lineage,
|
||||
metadata_patch={
|
||||
LINEAGE_METADATA_KEY: lineage_to_metadata(lineage),
|
||||
DISPLAY_NAME_METADATA_KEY: lineage.display_name,
|
||||
},
|
||||
)
|
||||
|
||||
def attach_legacy_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
parent_task_id: str,
|
||||
relation_type: RelationType,
|
||||
goal_slug: str,
|
||||
expected_owner_principal_id: str | None = None,
|
||||
expected_mission_id: str | None = None,
|
||||
max_cas_retries: int = 4,
|
||||
) -> TaskLineage:
|
||||
if max_cas_retries < 1:
|
||||
raise IntegrationError("max_cas_retries must be >= 1")
|
||||
|
||||
for _ in range(max_cas_retries):
|
||||
current = self._port.get_task_snapshot(task_id)
|
||||
if (
|
||||
expected_owner_principal_id is not None
|
||||
and current.owner_principal_id != expected_owner_principal_id
|
||||
):
|
||||
raise IntegrationError("legacy task owner changed after authorization")
|
||||
if (
|
||||
expected_mission_id is not None
|
||||
and current.mission_id != expected_mission_id
|
||||
):
|
||||
raise IntegrationError("legacy task mission changed after authorization")
|
||||
raw = current.metadata.get(LINEAGE_METADATA_KEY)
|
||||
if isinstance(raw, Mapping):
|
||||
record, _ = _load_authority_record(self._port, task_id)
|
||||
lineage = record.lineage
|
||||
expected_goal_slug = normalize_goal_slug(goal_slug)
|
||||
if (
|
||||
lineage.parent_task_id != parent_task_id
|
||||
or lineage.relation_type != relation_type
|
||||
or lineage.goal_slug != expected_goal_slug
|
||||
):
|
||||
raise IntegrationError(
|
||||
"existing legacy lineage conflicts with requested binding"
|
||||
)
|
||||
display = current.metadata.get(DISPLAY_NAME_METADATA_KEY)
|
||||
if display == lineage.display_name:
|
||||
return lineage
|
||||
if display not in (None, ""):
|
||||
raise IntegrationError(
|
||||
"legacy display name conflicts with authoritative lineage"
|
||||
)
|
||||
if self._port.compare_and_swap_task_metadata(
|
||||
task_id=current.task_id,
|
||||
expected_revision=current.revision,
|
||||
metadata_patch={
|
||||
DISPLAY_NAME_METADATA_KEY: lineage.display_name,
|
||||
},
|
||||
):
|
||||
return lineage
|
||||
continue
|
||||
|
||||
parent, root = _load_authority_record(self._port, parent_task_id)
|
||||
if root.status == "COMPLETED":
|
||||
raise IntegrationError("cannot attach lineage below a completed root")
|
||||
if (
|
||||
expected_owner_principal_id is not None
|
||||
and root.lineage.owner_principal_id != expected_owner_principal_id
|
||||
):
|
||||
raise IntegrationError("legacy root owner does not match authorization")
|
||||
if (
|
||||
expected_mission_id is not None
|
||||
and root.lineage.mission_id != expected_mission_id
|
||||
):
|
||||
raise IntegrationError("legacy root mission does not match authorization")
|
||||
if current.owner_principal_id != root.lineage.owner_principal_id:
|
||||
raise IntegrationError("legacy task owner does not match root owner")
|
||||
if current.mission_id != root.lineage.mission_id:
|
||||
raise IntegrationError("legacy task mission does not match root mission")
|
||||
|
||||
sequence = self._port.reserve_sibling_sequence(
|
||||
parent_task_id=parent.task_id,
|
||||
relation_type=relation_type,
|
||||
child_task_id=current.task_id,
|
||||
)
|
||||
lineage = attach_legacy_lineage(
|
||||
existing_task_id=current.task_id,
|
||||
created_at=current.created_at,
|
||||
parent=parent,
|
||||
root=root,
|
||||
relation_type=relation_type,
|
||||
sibling_sequence=sequence,
|
||||
goal_slug=goal_slug,
|
||||
)
|
||||
if self._port.compare_and_swap_task_metadata(
|
||||
task_id=current.task_id,
|
||||
expected_revision=current.revision,
|
||||
metadata_patch={
|
||||
LINEAGE_METADATA_KEY: lineage_to_metadata(lineage),
|
||||
DISPLAY_NAME_METADATA_KEY: lineage.display_name,
|
||||
},
|
||||
):
|
||||
return lineage
|
||||
|
||||
raise IntegrationError("legacy lineage CAS retry budget exhausted")
|
||||
|
||||
def on_proven_terminal_child(
|
||||
self,
|
||||
*,
|
||||
child_task_id: str,
|
||||
acceptance_evaluator: AcceptanceEvaluator,
|
||||
reviewed_at: datetime,
|
||||
max_cas_retries: int = 4,
|
||||
) -> TerminalReviewOutcome:
|
||||
store = _RootReviewStoreAdapter(self._port)
|
||||
review = review_root_after_child_terminal(
|
||||
store=store,
|
||||
trigger_child_task_id=child_task_id,
|
||||
acceptance_evaluator=acceptance_evaluator,
|
||||
reviewed_at=reviewed_at,
|
||||
max_cas_retries=max_cas_retries,
|
||||
)
|
||||
existing = self._port.get_root_action_dispatch(review.review_id)
|
||||
if existing is not None:
|
||||
idempotency_key = (
|
||||
f"root-review:{review.review_id}:{review.next_legal_action.value}"
|
||||
)
|
||||
_validate_dispatch_receipt(
|
||||
existing,
|
||||
review=review,
|
||||
idempotency_key=idempotency_key,
|
||||
existing=True,
|
||||
)
|
||||
return TerminalReviewOutcome(review=review, dispatch=existing)
|
||||
|
||||
idempotency_key = f"root-review:{review.review_id}:{review.next_legal_action.value}"
|
||||
receipt = self._port.dispatch_root_action(
|
||||
review=review,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
_validate_dispatch_receipt(
|
||||
receipt,
|
||||
review=review,
|
||||
idempotency_key=idempotency_key,
|
||||
existing=False,
|
||||
)
|
||||
return TerminalReviewOutcome(review=review, dispatch=receipt)
|
||||
@@ -0,0 +1,464 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Callable, Mapping, Protocol, Sequence, runtime_checkable
|
||||
|
||||
if not __package__:
|
||||
from aios_task_lineage import RelationType, RootReviewRecord, lineage_from_metadata
|
||||
from aios_task_lineage_integration import (
|
||||
IntegrationError,
|
||||
LINEAGE_METADATA_KEY,
|
||||
RootActionDispatchReceipt,
|
||||
TaskAuthoritySnapshot,
|
||||
TaskPlaneLineagePort,
|
||||
)
|
||||
else:
|
||||
from .aios_task_lineage import RelationType, RootReviewRecord, lineage_from_metadata
|
||||
from .aios_task_lineage_integration import (
|
||||
IntegrationError,
|
||||
LINEAGE_METADATA_KEY,
|
||||
RootActionDispatchReceipt,
|
||||
TaskAuthoritySnapshot,
|
||||
TaskPlaneLineagePort,
|
||||
)
|
||||
|
||||
|
||||
class AuthorityAdapterError(IntegrationError):
|
||||
pass
|
||||
|
||||
|
||||
def _parse_datetime(value: object) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise AuthorityAdapterError("authority datetime must be timezone-aware")
|
||||
return value.astimezone(timezone.utc)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise AuthorityAdapterError("authority datetime missing")
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise AuthorityAdapterError("authority datetime invalid") from exc
|
||||
if dt.tzinfo is None or dt.utcoffset() is None:
|
||||
raise AuthorityAdapterError("authority datetime must include timezone")
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _load_json_object(value: object, *, field: str) -> dict[str, object]:
|
||||
if value in (None, ""):
|
||||
return {}
|
||||
if isinstance(value, Mapping):
|
||||
return dict(value)
|
||||
if not isinstance(value, str):
|
||||
raise AuthorityAdapterError(f"{field} must be a JSON object")
|
||||
try:
|
||||
obj = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AuthorityAdapterError(f"{field} is invalid JSON") from exc
|
||||
if not isinstance(obj, dict):
|
||||
raise AuthorityAdapterError(f"{field} must decode to an object")
|
||||
return obj
|
||||
|
||||
|
||||
def _metadata_identity(metadata: Mapping[str, object]) -> tuple[str, str]:
|
||||
lineage = metadata.get(LINEAGE_METADATA_KEY)
|
||||
if isinstance(lineage, Mapping):
|
||||
owner = lineage.get("owner_principal_id")
|
||||
mission = lineage.get("mission_id")
|
||||
if isinstance(owner, str) and owner and isinstance(mission, str) and mission:
|
||||
return owner, mission
|
||||
|
||||
# Pre-lineage/legacy compatibility. These keys are accepted only as
|
||||
# authority-provided metadata; missing values fail closed rather than being
|
||||
# invented by this adapter.
|
||||
owner = metadata.get("owner_principal_id")
|
||||
mission = metadata.get("mission_id")
|
||||
if not isinstance(owner, str) or not owner:
|
||||
raise AuthorityAdapterError("task owner_principal_id is not discoverable")
|
||||
if not isinstance(mission, str) or not mission:
|
||||
raise AuthorityAdapterError("task mission_id is not discoverable")
|
||||
return owner, mission
|
||||
|
||||
|
||||
def _metadata_evidence(metadata: Mapping[str, object]) -> tuple[str, ...]:
|
||||
raw = metadata.get("evidence_refs")
|
||||
if raw is None:
|
||||
return ()
|
||||
if not isinstance(raw, list) or not all(isinstance(x, str) for x in raw):
|
||||
raise AuthorityAdapterError("evidence_refs must be a string list")
|
||||
return tuple(dict.fromkeys(raw))
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TaskPlaneReadback(Protocol):
|
||||
"""Independent authority readback surface.
|
||||
|
||||
This may be backed by the TaskPlane query service, a read replica, or a
|
||||
read-only SQLite authority. It must not mutate task state.
|
||||
"""
|
||||
|
||||
def get_task_snapshot(self, task_id: str) -> TaskAuthoritySnapshot: ...
|
||||
|
||||
def list_descendant_snapshots(
|
||||
self, root_task_id: str
|
||||
) -> Sequence[TaskAuthoritySnapshot]: ...
|
||||
|
||||
def get_root_review_record(self, review_id: str) -> Mapping[str, object] | None: ...
|
||||
|
||||
def get_root_action_dispatch(self, review_id: str) -> RootActionDispatchReceipt | None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CallableTaskPlaneReadback:
|
||||
"""Readback adapter for an existing official TaskPlane query/service."""
|
||||
|
||||
get_task: Callable[[str], TaskAuthoritySnapshot]
|
||||
list_descendants: Callable[[str], Sequence[TaskAuthoritySnapshot]]
|
||||
get_review: Callable[[str], Mapping[str, object] | None] | None = None
|
||||
get_dispatch: Callable[[str], RootActionDispatchReceipt | None] | None = None
|
||||
|
||||
def get_task_snapshot(self, task_id: str) -> TaskAuthoritySnapshot:
|
||||
snapshot = self.get_task(task_id)
|
||||
if not isinstance(snapshot, TaskAuthoritySnapshot):
|
||||
raise AuthorityAdapterError("readback returned invalid task snapshot")
|
||||
if snapshot.task_id != task_id:
|
||||
raise AuthorityAdapterError("readback returned wrong task identity")
|
||||
return snapshot
|
||||
|
||||
def list_descendant_snapshots(
|
||||
self, root_task_id: str
|
||||
) -> Sequence[TaskAuthoritySnapshot]:
|
||||
values = tuple(self.list_descendants(root_task_id))
|
||||
if not all(isinstance(v, TaskAuthoritySnapshot) for v in values):
|
||||
raise AuthorityAdapterError("readback returned invalid descendant snapshot")
|
||||
return values
|
||||
|
||||
def get_root_review_record(self, review_id: str) -> Mapping[str, object] | None:
|
||||
if self.get_review is None:
|
||||
raise AuthorityAdapterError("independent review readback is not configured")
|
||||
return self.get_review(review_id)
|
||||
|
||||
def get_root_action_dispatch(self, review_id: str) -> RootActionDispatchReceipt | None:
|
||||
if self.get_dispatch is None:
|
||||
raise AuthorityAdapterError("independent dispatch readback is not configured")
|
||||
return self.get_dispatch(review_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthoritySchemaReport:
|
||||
task_table: str
|
||||
columns: tuple[str, ...]
|
||||
required_columns_present: bool
|
||||
missing_columns: tuple[str, ...]
|
||||
task_count: int
|
||||
|
||||
|
||||
class ReadOnlySQLiteTaskPlane:
|
||||
"""Read-only discovery/readback helper for the current TaskPlane SQLite authority.
|
||||
|
||||
This class never writes. It opens SQLite read-only, including committed WAL
|
||||
data, and exists only for discovery, diagnostics and independent readback.
|
||||
|
||||
Execution writes must go through the live TaskPlane repository/service and
|
||||
are supplied separately through ``TaskPlaneServiceBindings``.
|
||||
"""
|
||||
|
||||
REQUIRED_TASK_COLUMNS = (
|
||||
"task_id",
|
||||
"status",
|
||||
"revision",
|
||||
"metadata_json",
|
||||
"created_at",
|
||||
)
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
if not self.db_path.is_file():
|
||||
raise AuthorityAdapterError(f"TaskPlane DB missing: {self.db_path}")
|
||||
# A live WAL database is not immutable: ignoring its WAL can return
|
||||
# stale state. as_uri also quotes URI metacharacters in file names.
|
||||
uri = self.db_path.resolve().as_uri() + "?mode=ro"
|
||||
con = sqlite3.connect(uri, uri=True)
|
||||
con.row_factory = sqlite3.Row
|
||||
return con
|
||||
|
||||
def inspect_schema(self) -> AuthoritySchemaReport:
|
||||
con = self._connect()
|
||||
try:
|
||||
rows = list(con.execute("PRAGMA table_info(tasks)"))
|
||||
columns = tuple(str(r["name"]) for r in rows)
|
||||
missing = tuple(c for c in self.REQUIRED_TASK_COLUMNS if c not in columns)
|
||||
count = int(con.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]) if rows else 0
|
||||
return AuthoritySchemaReport(
|
||||
task_table="tasks",
|
||||
columns=columns,
|
||||
required_columns_present=not missing,
|
||||
missing_columns=missing,
|
||||
task_count=count,
|
||||
)
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
def _row_to_snapshot(self, row: sqlite3.Row) -> TaskAuthoritySnapshot:
|
||||
keys = set(row.keys())
|
||||
metadata = _load_json_object(
|
||||
row["metadata_json"] if "metadata_json" in keys else None,
|
||||
field="metadata_json",
|
||||
)
|
||||
owner, mission = _metadata_identity(metadata)
|
||||
created_value = row["created_at"] if "created_at" in keys else None
|
||||
return TaskAuthoritySnapshot(
|
||||
task_id=str(row["task_id"]),
|
||||
revision=int(row["revision"]),
|
||||
status=str(row["status"]),
|
||||
owner_principal_id=owner,
|
||||
mission_id=mission,
|
||||
created_at=_parse_datetime(created_value),
|
||||
metadata=metadata,
|
||||
evidence_refs=_metadata_evidence(metadata),
|
||||
# A metadata flag cannot stand in for execution-integrity proof.
|
||||
# The official service adapter must resolve verified receipts.
|
||||
terminal_evidence_proven=False,
|
||||
)
|
||||
|
||||
def get_task_snapshot(self, task_id: str) -> TaskAuthoritySnapshot:
|
||||
con = self._connect()
|
||||
try:
|
||||
row = con.execute("SELECT * FROM tasks WHERE task_id=? LIMIT 1", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise AuthorityAdapterError(f"task not found: {task_id}")
|
||||
return self._row_to_snapshot(row)
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
def list_descendant_snapshots(self, root_task_id: str) -> tuple[TaskAuthoritySnapshot, ...]:
|
||||
con = self._connect()
|
||||
try:
|
||||
rows = list(con.execute("SELECT * FROM tasks ORDER BY task_id"))
|
||||
finally:
|
||||
con.close()
|
||||
out: list[TaskAuthoritySnapshot] = []
|
||||
for row in rows:
|
||||
try:
|
||||
snap = self._row_to_snapshot(row)
|
||||
except AuthorityAdapterError:
|
||||
# Legacy tasks without discoverable identity cannot safely be
|
||||
# attached to a root by this read-only adapter.
|
||||
continue
|
||||
raw = snap.metadata.get(LINEAGE_METADATA_KEY)
|
||||
if (
|
||||
isinstance(raw, Mapping)
|
||||
and raw.get("root_task_id") == root_task_id
|
||||
and snap.task_id != root_task_id
|
||||
):
|
||||
out.append(snap)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskPlaneServiceBindings:
|
||||
"""In-process bindings to the existing TaskPlane authority.
|
||||
|
||||
These callables must be backed by the real TaskPlane repository/service,
|
||||
including its existing transactions, revision/CAS, journal and continuation
|
||||
mechanisms. They must NOT be implemented as out-of-band SQLite mutations.
|
||||
"""
|
||||
|
||||
get_task_snapshot: Callable[[str], TaskAuthoritySnapshot]
|
||||
list_descendant_snapshots: Callable[[str], Sequence[TaskAuthoritySnapshot]]
|
||||
reserve_sibling_sequence: Callable[[str, RelationType, str], int]
|
||||
compare_and_swap_task_metadata: Callable[[str, int, Mapping[str, object]], bool]
|
||||
get_root_review_record: Callable[[str], Mapping[str, object] | None]
|
||||
compare_and_swap_root_review_record: Callable[
|
||||
[str, int, Mapping[str, object]], bool
|
||||
]
|
||||
get_root_action_dispatch: Callable[[str], RootActionDispatchReceipt | None]
|
||||
dispatch_root_action: Callable[
|
||||
[RootReviewRecord, str], RootActionDispatchReceipt
|
||||
]
|
||||
|
||||
|
||||
class InProcessTaskPlaneLineageAdapter(TaskPlaneLineagePort):
|
||||
"""Concrete TaskPlaneLineagePort backed by existing TaskPlane service calls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bindings: TaskPlaneServiceBindings,
|
||||
*,
|
||||
readback: TaskPlaneReadback | None = None,
|
||||
):
|
||||
self._b = bindings
|
||||
self._readback = readback
|
||||
|
||||
def get_task_snapshot(self, task_id: str) -> TaskAuthoritySnapshot:
|
||||
return self._b.get_task_snapshot(task_id)
|
||||
|
||||
def list_descendant_snapshots(self, root_task_id: str) -> Sequence[TaskAuthoritySnapshot]:
|
||||
return tuple(self._b.list_descendant_snapshots(root_task_id))
|
||||
|
||||
def reserve_sibling_sequence(
|
||||
self,
|
||||
*,
|
||||
parent_task_id: str,
|
||||
relation_type: RelationType,
|
||||
child_task_id: str,
|
||||
) -> int:
|
||||
seq = self._b.reserve_sibling_sequence(
|
||||
parent_task_id, relation_type, child_task_id
|
||||
)
|
||||
if not isinstance(seq, int) or seq < 1:
|
||||
raise AuthorityAdapterError("TaskPlane returned invalid sibling sequence")
|
||||
return seq
|
||||
|
||||
def compare_and_swap_task_metadata(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
expected_revision: int,
|
||||
metadata_patch: Mapping[str, object],
|
||||
) -> bool:
|
||||
return bool(
|
||||
self._b.compare_and_swap_task_metadata(
|
||||
task_id, expected_revision, metadata_patch
|
||||
)
|
||||
)
|
||||
|
||||
def get_root_review_record(self, review_id: str) -> Mapping[str, object] | None:
|
||||
return self._b.get_root_review_record(review_id)
|
||||
|
||||
def compare_and_swap_root_review_record(
|
||||
self,
|
||||
*,
|
||||
root_task_id: str,
|
||||
expected_root_revision: int,
|
||||
review_record: Mapping[str, object],
|
||||
) -> bool:
|
||||
return bool(
|
||||
self._b.compare_and_swap_root_review_record(
|
||||
root_task_id, expected_root_revision, review_record
|
||||
)
|
||||
)
|
||||
|
||||
def get_root_action_dispatch(
|
||||
self, review_id: str
|
||||
) -> RootActionDispatchReceipt | None:
|
||||
return self._b.get_root_action_dispatch(review_id)
|
||||
|
||||
def dispatch_root_action(
|
||||
self,
|
||||
*,
|
||||
review: RootReviewRecord,
|
||||
idempotency_key: str,
|
||||
) -> RootActionDispatchReceipt:
|
||||
return self._b.dispatch_root_action(review, idempotency_key)
|
||||
|
||||
def verify_task_readback(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
expected_revision_at_least: int,
|
||||
expected_metadata: Mapping[str, object],
|
||||
) -> TaskAuthoritySnapshot:
|
||||
if self._readback is None:
|
||||
raise AuthorityAdapterError("independent TaskPlane readback is not configured")
|
||||
snap = self._readback.get_task_snapshot(task_id)
|
||||
if snap.task_id != task_id:
|
||||
raise AuthorityAdapterError("TaskPlane readback task identity mismatch")
|
||||
if snap.revision < expected_revision_at_least:
|
||||
raise AuthorityAdapterError("TaskPlane readback revision did not advance")
|
||||
for key, expected in expected_metadata.items():
|
||||
if snap.metadata.get(key) != expected:
|
||||
raise AuthorityAdapterError(
|
||||
f"TaskPlane readback metadata mismatch for {key}"
|
||||
)
|
||||
return snap
|
||||
|
||||
def verify_root_readback(
|
||||
self,
|
||||
*,
|
||||
root_task_id: str,
|
||||
expected_review_id: str,
|
||||
) -> tuple[TaskAuthoritySnapshot, ...]:
|
||||
"""Verify that independent authority descendants remain bound to root.
|
||||
|
||||
The persisted ROOT_REVIEW record itself may live in a journal rather
|
||||
than the task row. This method verifies the task/lineage projection
|
||||
independently while the service binding verifies the journal receipt.
|
||||
"""
|
||||
if self._readback is None:
|
||||
raise AuthorityAdapterError("independent TaskPlane readback is not configured")
|
||||
review = self.get_root_review_record(expected_review_id)
|
||||
dispatch = self.get_root_action_dispatch(expected_review_id)
|
||||
if (review is None or review.get("review_id") != expected_review_id
|
||||
or review.get("root_task_id") != root_task_id or dispatch is None):
|
||||
raise AuthorityAdapterError("root review or dispatch identity mismatch")
|
||||
read_review = getattr(self._readback, "get_root_review_record", None)
|
||||
read_dispatch = getattr(self._readback, "get_root_action_dispatch", None)
|
||||
if not callable(read_review) or not callable(read_dispatch):
|
||||
raise AuthorityAdapterError("independent review/dispatch readback is not configured")
|
||||
if read_review(expected_review_id) != review:
|
||||
raise AuthorityAdapterError("independent root review readback mismatch")
|
||||
if read_dispatch(expected_review_id) != dispatch:
|
||||
raise AuthorityAdapterError("independent dispatch readback mismatch")
|
||||
root = self._readback.get_task_snapshot(root_task_id)
|
||||
raw_root = root.metadata.get(LINEAGE_METADATA_KEY)
|
||||
if root.task_id != root_task_id or not isinstance(raw_root, Mapping):
|
||||
raise AuthorityAdapterError("root readback identity or lineage missing")
|
||||
root_lineage = lineage_from_metadata(dict(raw_root))
|
||||
if (root_lineage.relation_type != RelationType.ROOT
|
||||
or root_lineage.task_id != root_task_id
|
||||
or root_lineage.owner_principal_id != root.owner_principal_id
|
||||
or root_lineage.mission_id != root.mission_id):
|
||||
raise AuthorityAdapterError("root readback authority mismatch")
|
||||
revision = review.get("root_revision_after")
|
||||
if type(revision) is not int or root.revision < revision:
|
||||
raise AuthorityAdapterError("root readback revision is stale")
|
||||
descendants = tuple(self._readback.list_descendant_snapshots(root_task_id))
|
||||
seen = set()
|
||||
for snap in descendants:
|
||||
raw = snap.metadata.get(LINEAGE_METADATA_KEY)
|
||||
if not isinstance(raw, Mapping):
|
||||
raise AuthorityAdapterError("descendant lineage missing")
|
||||
lineage = lineage_from_metadata(dict(raw))
|
||||
if (snap.task_id in seen or snap.task_id == root_task_id
|
||||
or lineage.task_id != snap.task_id
|
||||
or lineage.root_task_id != root_task_id
|
||||
or lineage.owner_principal_id != root.owner_principal_id
|
||||
or lineage.mission_id != root.mission_id
|
||||
or snap.owner_principal_id != root.owner_principal_id
|
||||
or snap.mission_id != root.mission_id
|
||||
or lineage.root_acceptance_ref != root_lineage.root_acceptance_ref):
|
||||
raise AuthorityAdapterError(
|
||||
f"descendant {snap.task_id} is projected under wrong root"
|
||||
)
|
||||
seen.add(snap.task_id)
|
||||
if review.get("trigger_child_task_id") not in seen:
|
||||
raise AuthorityAdapterError("trigger child missing from independent readback")
|
||||
return descendants
|
||||
|
||||
@property
|
||||
def has_independent_readback(self) -> bool:
|
||||
return self._readback is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedTaskContext:
|
||||
principal_id: str
|
||||
preferred_alias: str | None
|
||||
mission_id: str
|
||||
|
||||
|
||||
def require_context_matches_task(
|
||||
context: AuthenticatedTaskContext,
|
||||
task: TaskAuthoritySnapshot,
|
||||
) -> None:
|
||||
if context.principal_id != task.owner_principal_id:
|
||||
raise AuthorityAdapterError("authenticated principal does not own task")
|
||||
if context.mission_id != task.mission_id:
|
||||
raise AuthorityAdapterError("authenticated mission does not match task")
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Narrow staging Mission ingress authenticated by the registered Owner signer.
|
||||
|
||||
The signed payload binds the method, resource, body and idempotency key. This
|
||||
adapter neither signs requests nor grants Git/worker/production capabilities.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import Any
|
||||
|
||||
from .task_plane.errors import (
|
||||
ForbiddenError, TaskPlaneHTTPError, UnauthorizedError, ValidationError,
|
||||
)
|
||||
|
||||
AUDIENCE = "aios.task-mission-authority.staging.v1"
|
||||
NAMESPACE = "aios-owner-approval-v1"
|
||||
MAX_BODY = 131072
|
||||
REQUEST_FIELDS = frozenset({
|
||||
"schema", "audience", "method", "path", "body", "idempotency_key",
|
||||
"issued_at", "expires_at", "nonce",
|
||||
})
|
||||
|
||||
|
||||
def canonical_bytes(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False, allow_nan=False).encode("utf-8")
|
||||
|
||||
|
||||
def strict_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValidationError("duplicate JSON key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
class SignedMissionIngress:
|
||||
def __init__(
|
||||
self, service, verifier, *, terminal_proof_importer=None, clock=time.time
|
||||
):
|
||||
if not callable(getattr(verifier, "verify", None)):
|
||||
raise ValueError("registered Owner verifier required")
|
||||
identity = getattr(verifier, "signer_id", None)
|
||||
if not isinstance(identity, str) or not identity.strip():
|
||||
raise ValueError("registered signer identity required")
|
||||
self.service = service
|
||||
self.verifier = verifier
|
||||
self.identity = identity
|
||||
self.terminal_proof_importer = terminal_proof_importer
|
||||
self.clock = clock
|
||||
|
||||
def handle(self, envelope: dict) -> tuple[int, dict]:
|
||||
if not isinstance(envelope, dict) or set(envelope) != {
|
||||
"request", "signature", "key_id"
|
||||
}:
|
||||
raise ValidationError("invalid signed mission envelope")
|
||||
request = envelope["request"]
|
||||
if not isinstance(request, dict) or set(request) != REQUEST_FIELDS:
|
||||
raise ValidationError("invalid mission request fields")
|
||||
if (request["schema"] != "aios.signed-mission-request.v1"
|
||||
or request["audience"] != AUDIENCE):
|
||||
raise ForbiddenError("mission request audience mismatch")
|
||||
issued, expires = request["issued_at"], request["expires_at"]
|
||||
if (type(issued) is not int or type(expires) is not int
|
||||
or not 0 < expires - issued <= 300
|
||||
or not issued <= self.clock() < expires):
|
||||
raise UnauthorizedError("mission request expired or not yet valid")
|
||||
if (not isinstance(request["nonce"], str)
|
||||
or re.fullmatch(r"[A-Za-z0-9_-]{16,128}", request["nonce"]) is None):
|
||||
raise ValidationError("invalid mission request nonce")
|
||||
if (not isinstance(envelope["signature"], str)
|
||||
or not isinstance(envelope["key_id"], str)):
|
||||
raise UnauthorizedError("Owner signature required")
|
||||
try:
|
||||
verified = self.verifier.verify(
|
||||
namespace=NAMESPACE, payload=canonical_bytes(request),
|
||||
signature=envelope["signature"], key_id=envelope["key_id"],
|
||||
)
|
||||
except Exception:
|
||||
verified = False
|
||||
if verified is not True:
|
||||
raise UnauthorizedError("Owner signature invalid")
|
||||
method, path, body = request["method"], request["path"], request["body"]
|
||||
key = request["idempotency_key"]
|
||||
if method == "POST" and path == "/missions":
|
||||
if not isinstance(body, dict) or not isinstance(key, str) or not key.strip():
|
||||
raise ValidationError("mission body and idempotency key required")
|
||||
return 201, self.service.create_mission(
|
||||
body, key, actor_identity=self.identity,
|
||||
)
|
||||
if (method == "GET" and isinstance(path, str)
|
||||
and re.fullmatch(r"/missions/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}", path)
|
||||
and body is None and key is None):
|
||||
return 200, self.service.get_mission(
|
||||
path.split("/")[2], actor_identity=self.identity,
|
||||
)
|
||||
if (method == "POST" and isinstance(path, str)
|
||||
and re.fullmatch(r"/tasks/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}/delegate", path)):
|
||||
if not isinstance(body, dict) or not isinstance(key, str) or not key.strip():
|
||||
raise ValidationError("child body and idempotency key required")
|
||||
return 201, self.service.delegate_mission(
|
||||
path.split("/")[2], body, key, actor_identity=self.identity,
|
||||
)
|
||||
if (method == "POST" and isinstance(path, str)
|
||||
and re.fullmatch(
|
||||
r"/missions/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}/legacy-attachments",
|
||||
path,
|
||||
)):
|
||||
if not isinstance(body, dict) or not isinstance(key, str) or not key.strip():
|
||||
raise ValidationError(
|
||||
"legacy attachment body and idempotency key required"
|
||||
)
|
||||
result = self.service.attach_legacy_attachment(
|
||||
path.split("/")[2], body, key, actor_identity=self.identity,
|
||||
)
|
||||
return (200 if result.get("idempotent") is True else 201), result
|
||||
if (method == "GET" and isinstance(path, str)
|
||||
and re.fullmatch(
|
||||
r"/missions/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}/"
|
||||
r"legacy-attachments/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}",
|
||||
path,
|
||||
) and body is None and key is None):
|
||||
pieces = path.split("/")
|
||||
return 200, self.service.get_legacy_attachment(
|
||||
pieces[2], pieces[4], actor_identity=self.identity,
|
||||
)
|
||||
if (method == "POST" and isinstance(path, str)
|
||||
and re.fullmatch(
|
||||
r"/tasks/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}/reconcile-terminal-proof",
|
||||
path,
|
||||
)):
|
||||
if body != {} or not isinstance(key, str) or not key.strip():
|
||||
raise ValidationError(
|
||||
"terminal proof trigger requires an empty body and idempotency key"
|
||||
)
|
||||
if self.terminal_proof_importer is None:
|
||||
raise ForbiddenError("terminal proof importer is not configured")
|
||||
return 200, self.terminal_proof_importer.reconcile(
|
||||
path.split("/")[2], key, actor_identity=self.identity,
|
||||
)
|
||||
raise ForbiddenError("operation outside mission ingress scope")
|
||||
|
||||
|
||||
def load_registered_verifier(path: Path):
|
||||
metadata = path.lstat()
|
||||
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0
|
||||
or metadata.st_mode & 0o022):
|
||||
raise ValueError("Owner verifier must be a root-owned immutable regular file")
|
||||
spec = importlib.util.spec_from_file_location("aios_mission_owner_verifier", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ValueError("Owner verifier module unavailable")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module.GrantRootOwnerApprovalVerifier()
|
||||
|
||||
|
||||
def make_handler(ingress: SignedMissionIngress):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass # Do not log signed payloads or transport credentials.
|
||||
|
||||
def respond(self, status, value):
|
||||
raw = canonical_bytes(value)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def do_POST(self):
|
||||
try:
|
||||
if self.path != "/v1/signed-request":
|
||||
raise ForbiddenError("unsupported mission ingress route")
|
||||
if self.headers.get("Transfer-Encoding"):
|
||||
raise ValidationError("transfer encoding is unsupported")
|
||||
lengths = self.headers.get_all("Content-Length", [])
|
||||
if len(lengths) != 1 or not lengths[0].isdigit():
|
||||
raise ValidationError("one content length required")
|
||||
length = int(lengths[0])
|
||||
if not 0 < length <= MAX_BODY:
|
||||
raise ValidationError("mission request too large or empty")
|
||||
self.connection.settimeout(10)
|
||||
raw = self.rfile.read(length)
|
||||
if len(raw) != length:
|
||||
raise ValidationError("truncated mission request")
|
||||
envelope = json.loads(raw, object_pairs_hook=strict_object)
|
||||
status, result = ingress.handle(envelope)
|
||||
self.respond(status, result)
|
||||
except TaskPlaneHTTPError as exc:
|
||||
self.respond(exc.status_code, exc.to_payload())
|
||||
except (ValueError, UnicodeError, RecursionError):
|
||||
self.respond(400, {"error": "invalid_json"})
|
||||
except Exception:
|
||||
self.respond(500, {"error": "mission_authority_unavailable"})
|
||||
return Handler
|
||||
|
||||
|
||||
def main() -> None:
|
||||
from .task_plane.api import TaskPlaneService
|
||||
from .task_plane.mission import MissionAuthority, MissionPrincipalBinding
|
||||
from .task_plane.policy import IdentityVerifier, PolicyEngine
|
||||
from .task_plane.store import TaskPlaneStore
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--db", type=Path, required=True)
|
||||
parser.add_argument("--owner-verifier", type=Path, required=True)
|
||||
parser.add_argument("--port", type=int, required=True)
|
||||
args = parser.parse_args()
|
||||
if not 1024 <= args.port <= 65535:
|
||||
parser.error("unprivileged loopback port required")
|
||||
verifier = load_registered_verifier(args.owner_verifier)
|
||||
identity = verifier.signer_id
|
||||
store = TaskPlaneStore(str(args.db), high_assurance=True)
|
||||
authority = MissionAuthority(store, principal_bindings=[MissionPrincipalBinding(
|
||||
authenticated_identity=identity, owner_principal_id=identity,
|
||||
acceptance_verifier_id="identity-lineage-live-closure-v1",
|
||||
allowed_task_capabilities=(
|
||||
"code.read", "code.write", "test.execute",
|
||||
"governed.write.file", "governed.write.branch",
|
||||
),
|
||||
max_risk="medium",
|
||||
)])
|
||||
service = TaskPlaneService(store, policy=PolicyEngine(
|
||||
identity_verifier=IdentityVerifier({identity}), execution_enabled=False,
|
||||
identity_capabilities={identity: {"task.creator", "task.read", "task.delegate"}},
|
||||
), mission_authority=authority)
|
||||
ingress = SignedMissionIngress(service, verifier)
|
||||
# SQLite's existing connection and transaction lock share one request thread.
|
||||
HTTPServer(("127.0.0.1", args.port), make_handler(ingress)).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,511 @@
|
||||
"""Authenticated, trigger-only import of authoritative terminal proof.
|
||||
|
||||
The caller authorizes reconciliation but supplies no proof fields. Proof is
|
||||
read by a configured server-side provider and is accepted only after the
|
||||
provider validates an authenticated authority document and its immutable
|
||||
cross-system tuple.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from .aios_task_lineage_integration import LINEAGE_METADATA_KEY
|
||||
from .task_plane.errors import ConflictError, ForbiddenError, ValidationError
|
||||
from .task_plane.models import ResultEnvelope, TaskRecord
|
||||
from .task_plane.policy import PolicyDecision
|
||||
|
||||
|
||||
PROOF_SCHEMA = "aios.task-terminal-authority-proof.v1"
|
||||
RECEIPT_SCHEMA = "aios.task-terminal-import-receipt.v1"
|
||||
REQUIRED_SOURCES = frozenset(
|
||||
{
|
||||
"agent_task",
|
||||
"admission",
|
||||
"job_producer",
|
||||
"execution_alias",
|
||||
"governed_job",
|
||||
"bridge",
|
||||
"authority_git",
|
||||
}
|
||||
)
|
||||
DOCUMENT_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"task_id",
|
||||
"mission_id",
|
||||
"root_task_id",
|
||||
"parent_task_id",
|
||||
"acceptance_condition",
|
||||
"agent_task_status",
|
||||
"admission_status",
|
||||
"execution_outcome",
|
||||
"external_job_id",
|
||||
"internal_job_id",
|
||||
"repository",
|
||||
"base_revision",
|
||||
"target_branch",
|
||||
"generation",
|
||||
"execution_plan_digest",
|
||||
"task_principal_ref",
|
||||
"task_lease_ref",
|
||||
"writer_lease_ref",
|
||||
"governed_commit_oid",
|
||||
"authority_ref_oid",
|
||||
"postimage_hashes",
|
||||
"source_receipt_digests",
|
||||
"attestation_ref",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
}
|
||||
)
|
||||
_HEX64 = re.compile(r"[0-9a-f]{64}")
|
||||
_GIT_OID = re.compile(r"[0-9a-f]{40}(?:[0-9a-f]{24})?")
|
||||
_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]{2,255}")
|
||||
|
||||
|
||||
def _canonical_bytes(value: Mapping[str, object]) -> bytes:
|
||||
return json.dumps(
|
||||
dict(value), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _nonempty_string(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > 1024:
|
||||
raise ForbiddenError(f"authoritative proof {field} invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _identifier(value: object, field: str) -> str:
|
||||
text = _nonempty_string(value, field)
|
||||
if _IDENTIFIER.fullmatch(text) is None:
|
||||
raise ForbiddenError(f"authoritative proof {field} invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _utc_time(value: object, field: str) -> str:
|
||||
text = _nonempty_string(value, field)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ForbiddenError(f"authoritative proof {field} invalid") from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
raise ForbiddenError(f"authoritative proof {field} lacks timezone")
|
||||
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedTerminalProof:
|
||||
document: dict[str, object]
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return str(self.document["task_id"])
|
||||
|
||||
@property
|
||||
def attestation_ref(self) -> str:
|
||||
return str(self.document["attestation_ref"])
|
||||
|
||||
@property
|
||||
def started_at(self) -> str:
|
||||
return str(self.document["started_at"])
|
||||
|
||||
@property
|
||||
def completed_at(self) -> str:
|
||||
return str(self.document["completed_at"])
|
||||
|
||||
def evidence(self) -> dict[str, object]:
|
||||
return {
|
||||
"kind": "acceptance-proof",
|
||||
"proof_schema": PROOF_SCHEMA,
|
||||
"authority_document_digest": sha256(
|
||||
_canonical_bytes(self.document)
|
||||
).hexdigest(),
|
||||
"task_id": self.task_id,
|
||||
"mission_id": self.document["mission_id"],
|
||||
"root_task_id": self.document["root_task_id"],
|
||||
"parent_task_id": self.document["parent_task_id"],
|
||||
"acceptance_condition": self.document["acceptance_condition"],
|
||||
"external_job_id": self.document["external_job_id"],
|
||||
"internal_job_id": self.document["internal_job_id"],
|
||||
"repository": self.document["repository"],
|
||||
"base_revision": self.document["base_revision"],
|
||||
"target_branch": self.document["target_branch"],
|
||||
"generation": self.document["generation"],
|
||||
"execution_plan_digest": self.document["execution_plan_digest"],
|
||||
"task_principal_ref": self.document["task_principal_ref"],
|
||||
"task_lease_ref": self.document["task_lease_ref"],
|
||||
"writer_lease_ref": self.document["writer_lease_ref"],
|
||||
"governed_commit_oid": self.document["governed_commit_oid"],
|
||||
"authority_ref_oid": self.document["authority_ref_oid"],
|
||||
"postimage_hashes": self.document["postimage_hashes"],
|
||||
"source_receipt_digests": self.document["source_receipt_digests"],
|
||||
"attestation_ref": self.attestation_ref,
|
||||
}
|
||||
|
||||
def outputs(self) -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"kind": "governed-commit-readback",
|
||||
"repository": self.document["repository"],
|
||||
"target_branch": self.document["target_branch"],
|
||||
"governed_commit_oid": self.document["governed_commit_oid"],
|
||||
"postimage_hashes": self.document["postimage_hashes"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class AuthorityDocumentReader(Protocol):
|
||||
def __call__(self, task_id: str) -> Mapping[str, object]: ...
|
||||
|
||||
|
||||
class AuthorityDocumentVerifier(Protocol):
|
||||
def __call__(self, document: Mapping[str, object]) -> bool: ...
|
||||
|
||||
|
||||
class AuthenticatedTerminalProofProvider:
|
||||
"""Read and validate one aggregate document backed by existing authorities.
|
||||
|
||||
``reader`` and ``verifier`` are mandatory runtime bindings. The verifier
|
||||
must authenticate the referenced AgentTask/admission/Job/Bridge/Git
|
||||
receipts using already configured trust roots. This class does not confer
|
||||
trust on an arbitrary mapping.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
reader: AuthorityDocumentReader,
|
||||
verifier: AuthorityDocumentVerifier,
|
||||
task_reader: Callable[[str], TaskRecord],
|
||||
worker_identity: str,
|
||||
) -> None:
|
||||
if not callable(reader) or not callable(verifier) or not callable(task_reader):
|
||||
raise ValueError("authenticated proof reader, verifier and task reader required")
|
||||
self._reader = reader
|
||||
self._verifier = verifier
|
||||
self._task_reader = task_reader
|
||||
self.worker_identity = _identifier(worker_identity, "worker_identity")
|
||||
|
||||
@staticmethod
|
||||
def _parse(raw: Mapping[str, object]) -> dict[str, object]:
|
||||
if not isinstance(raw, Mapping) or set(raw) != DOCUMENT_FIELDS:
|
||||
raise ForbiddenError("authoritative proof document shape invalid")
|
||||
data = dict(raw)
|
||||
if data["schema"] != PROOF_SCHEMA:
|
||||
raise ForbiddenError("authoritative proof schema invalid")
|
||||
for field in (
|
||||
"task_id", "mission_id", "root_task_id", "parent_task_id",
|
||||
"external_job_id", "internal_job_id", "task_principal_ref", "task_lease_ref",
|
||||
"writer_lease_ref",
|
||||
):
|
||||
data[field] = _identifier(data[field], field)
|
||||
for field in ("acceptance_condition", "repository", "base_revision", "target_branch"):
|
||||
data[field] = _nonempty_string(data[field], field)
|
||||
if data["agent_task_status"] != "SUCCEEDED":
|
||||
raise ForbiddenError("AgentTask is not authoritatively successful")
|
||||
if data["admission_status"] != "ADMITTED":
|
||||
raise ForbiddenError("task admission is not authoritative")
|
||||
if data["execution_outcome"] != "APPLIED_VERIFIED":
|
||||
raise ForbiddenError("governed execution outcome is not verified")
|
||||
if type(data["generation"]) is not int or int(data["generation"]) < 1:
|
||||
raise ForbiddenError("authoritative proof generation invalid")
|
||||
if not isinstance(data["execution_plan_digest"], str) or _HEX64.fullmatch(
|
||||
data["execution_plan_digest"]
|
||||
) is None:
|
||||
raise ForbiddenError("authoritative execution plan digest invalid")
|
||||
for field in ("governed_commit_oid", "authority_ref_oid"):
|
||||
if not isinstance(data[field], str) or _GIT_OID.fullmatch(data[field]) is None:
|
||||
raise ForbiddenError(f"authoritative proof {field} invalid")
|
||||
if data["governed_commit_oid"] != data["authority_ref_oid"]:
|
||||
raise ForbiddenError("authority ref does not read back governed commit")
|
||||
images = data["postimage_hashes"]
|
||||
if (
|
||||
not isinstance(images, Mapping)
|
||||
or not images
|
||||
or len(images) > 256
|
||||
or any(
|
||||
not isinstance(path, str)
|
||||
or not path
|
||||
or path.startswith(("/", "\\"))
|
||||
or ".." in path.replace("\\", "/").split("/")
|
||||
or not isinstance(digest, str)
|
||||
or _HEX64.fullmatch(digest) is None
|
||||
for path, digest in images.items()
|
||||
)
|
||||
):
|
||||
raise ForbiddenError("authoritative postimage hashes invalid")
|
||||
data["postimage_hashes"] = dict(sorted(images.items()))
|
||||
sources = data["source_receipt_digests"]
|
||||
if (
|
||||
not isinstance(sources, Mapping)
|
||||
or set(sources) != REQUIRED_SOURCES
|
||||
or any(
|
||||
not isinstance(digest, str) or _HEX64.fullmatch(digest) is None
|
||||
for digest in sources.values()
|
||||
)
|
||||
):
|
||||
raise ForbiddenError("authoritative source receipt set invalid")
|
||||
data["source_receipt_digests"] = dict(sorted(sources.items()))
|
||||
data["attestation_ref"] = _nonempty_string(data["attestation_ref"], "attestation_ref")
|
||||
data["started_at"] = _utc_time(data["started_at"], "started_at")
|
||||
data["completed_at"] = _utc_time(data["completed_at"], "completed_at")
|
||||
if datetime.fromisoformat(data["completed_at"].replace("Z", "+00:00")) < datetime.fromisoformat(
|
||||
data["started_at"].replace("Z", "+00:00")
|
||||
):
|
||||
raise ForbiddenError("authoritative proof time order invalid")
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _assert_task_binding(task: TaskRecord, data: Mapping[str, object]) -> None:
|
||||
lineage = task.task_plan.metadata.get(LINEAGE_METADATA_KEY)
|
||||
if not isinstance(lineage, Mapping) or lineage.get("relation_type") == "ROOT":
|
||||
raise ForbiddenError("terminal proof requires a canonical lineage child")
|
||||
expected = {
|
||||
"task_id": task.task_id,
|
||||
"mission_id": task.mission_id,
|
||||
"root_task_id": task.root_task_id,
|
||||
"parent_task_id": task.task_plan.parent_task_id,
|
||||
"acceptance_condition": task.task_plan.metadata.get("acceptance_condition"),
|
||||
}
|
||||
if any(data.get(field) != value or value in (None, "") for field, value in expected.items()):
|
||||
raise ForbiddenError("authoritative proof TaskPlane binding mismatch")
|
||||
if any(lineage.get(field) != expected[field] for field in expected if field != "acceptance_condition"):
|
||||
raise ForbiddenError("authoritative proof lineage binding mismatch")
|
||||
|
||||
def collect(self, task: TaskRecord) -> VerifiedTerminalProof:
|
||||
raw = self._reader(task.task_id)
|
||||
if not isinstance(raw, Mapping) or self._verifier(raw) is not True:
|
||||
raise ForbiddenError("authoritative terminal proof authentication failed")
|
||||
data = self._parse(raw)
|
||||
self._assert_task_binding(task, data)
|
||||
return VerifiedTerminalProof(data)
|
||||
|
||||
def verify_evidence(self, task: TaskRecord, evidences: list[dict]) -> bool:
|
||||
proof = self.collect(task)
|
||||
return evidences == [proof.evidence()]
|
||||
|
||||
def verify_result(self, result: ResultEnvelope) -> bool:
|
||||
if result.worker_identity != self.worker_identity or result.status != "COMPLETED":
|
||||
return False
|
||||
try:
|
||||
task = self._task_reader(result.task_id)
|
||||
proof = self.collect(task)
|
||||
except Exception:
|
||||
return False
|
||||
return bool(
|
||||
result.attestation_ref == proof.attestation_ref
|
||||
and result.evidence == [proof.evidence()]
|
||||
and result.outputs == proof.outputs()
|
||||
and result.started_at == proof.started_at
|
||||
and result.completed_at == proof.completed_at
|
||||
and result.operation_digest == task.task_plan.operation_digest()
|
||||
)
|
||||
|
||||
|
||||
class TerminalProofEvidenceVerifier:
|
||||
"""PolicyEngine evidence verifier backed by authenticated authority reads."""
|
||||
|
||||
def __init__(self, provider: AuthenticatedTerminalProofProvider) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def verify(self, task: TaskRecord, evidences: list[dict]) -> PolicyDecision:
|
||||
try:
|
||||
allowed = self.provider.verify_evidence(task, evidences)
|
||||
except Exception:
|
||||
allowed = False
|
||||
return PolicyDecision(allowed, "" if allowed else "terminal proof evidence invalid")
|
||||
|
||||
|
||||
class TerminalProofImporter:
|
||||
"""Reconcile a server-verified proof through TaskPlane claim/result paths."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: Any,
|
||||
*,
|
||||
provider: AuthenticatedTerminalProofProvider | None,
|
||||
worker_id: str | None,
|
||||
worker_identity: str | None,
|
||||
lease_seconds: int = 300,
|
||||
) -> None:
|
||||
self.service = service
|
||||
self.provider = provider
|
||||
self.worker_id = worker_id
|
||||
self.worker_identity = worker_identity
|
||||
if provider is not None:
|
||||
if not worker_id or not worker_identity:
|
||||
raise ValueError("terminal proof worker binding required")
|
||||
_identifier(worker_id, "worker_id")
|
||||
_identifier(worker_identity, "worker_identity")
|
||||
if provider.worker_identity != worker_identity:
|
||||
raise ValueError("terminal proof provider identity mismatch")
|
||||
if not 30 <= int(lease_seconds) <= 3600:
|
||||
raise ValueError("terminal proof lease out of range")
|
||||
self.lease_seconds = int(lease_seconds)
|
||||
|
||||
@staticmethod
|
||||
def expected_idempotency_key(task: TaskRecord) -> str:
|
||||
return f"terminal-proof:{task.mission_id}:{task.task_id}"
|
||||
|
||||
def _authorized_task(self, task_id: str, actor_identity: str) -> TaskRecord:
|
||||
self.service.get_task(task_id, actor_identity)
|
||||
task = self.service.store.get_task(task_id)
|
||||
lineage = task.task_plan.metadata.get(LINEAGE_METADATA_KEY)
|
||||
if (
|
||||
not isinstance(lineage, Mapping)
|
||||
or lineage.get("relation_type") == "ROOT"
|
||||
or lineage.get("task_id") != task.task_id
|
||||
or lineage.get("mission_id") != task.mission_id
|
||||
or lineage.get("root_task_id") != task.root_task_id
|
||||
or lineage.get("parent_task_id") != task.task_plan.parent_task_id
|
||||
):
|
||||
raise ForbiddenError("terminal proof target is not a canonical child")
|
||||
return task
|
||||
|
||||
def _existing_receipt(
|
||||
self, task: TaskRecord, actor_identity: str
|
||||
) -> dict[str, object] | None:
|
||||
if self.service.lineage is None:
|
||||
raise ForbiddenError("TaskPlane lineage is not configured")
|
||||
snapshot = self.service.lineage.binding.get_task_snapshot(task.task_id)
|
||||
if not snapshot.terminal_evidence_proven:
|
||||
return None
|
||||
if snapshot.status != "COMPLETED":
|
||||
raise ConflictError("terminal proof target did not complete successfully")
|
||||
review = self.service.lineage.binding.get_root_review_for_trigger(task.task_id)
|
||||
if review is None:
|
||||
raise ConflictError("terminal proof lacks durable root review")
|
||||
evidence_rows = self.service.store.list_evidence(task.task_id)
|
||||
by_id = {str(row.get("evidence_id")): row for row in evidence_rows}
|
||||
if not snapshot.evidence_refs or any(ref not in by_id for ref in snapshot.evidence_refs):
|
||||
raise ConflictError("terminal proof evidence readback mismatch")
|
||||
envelopes = []
|
||||
for ref in snapshot.evidence_refs:
|
||||
try:
|
||||
envelope = json.loads(str(by_id[ref]["envelope_json"]))
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise ConflictError("terminal proof evidence readback invalid") from exc
|
||||
envelopes.append(envelope)
|
||||
claim_ids = {item.get("claim_id") for item in envelopes}
|
||||
evidence_payloads = [item.get("payload") for item in envelopes]
|
||||
if (
|
||||
len(claim_ids) != 1
|
||||
or len(evidence_payloads) != 1
|
||||
or any(item.get("worker_identity") != self.worker_identity for item in envelopes)
|
||||
or envelopes[0].get("kind") != "acceptance-proof"
|
||||
or not isinstance(evidence_payloads[0], dict)
|
||||
or not self.provider.verify_evidence(task, evidence_payloads)
|
||||
or not self.service.store.verify_evidence_integrity(task.task_id)
|
||||
or not self.service.store.verify_event_chain(task.task_id)
|
||||
):
|
||||
raise ConflictError("terminal proof receipt integrity failed")
|
||||
claim = self.service.store.get_claim(str(next(iter(claim_ids))))
|
||||
if (
|
||||
claim.task_id != task.task_id
|
||||
or claim.worker_id != self.worker_id
|
||||
or claim.worker_identity != self.worker_identity
|
||||
or claim.operation_digest != task.task_plan.operation_digest()
|
||||
or claim.status != "COMPLETED"
|
||||
):
|
||||
raise ConflictError("terminal proof claim binding mismatch")
|
||||
dispatch = self.service.lineage.binding.get_root_action_dispatch(str(review["review_id"]))
|
||||
if dispatch is None:
|
||||
raise ConflictError("terminal proof lacks durable root action dispatch")
|
||||
body: dict[str, object] = {
|
||||
"schema": RECEIPT_SCHEMA,
|
||||
"task_id": task.task_id,
|
||||
"mission_id": task.mission_id,
|
||||
"root_task_id": task.root_task_id,
|
||||
"claim_id": next(iter(claim_ids)),
|
||||
"task_status": snapshot.status,
|
||||
"task_revision": snapshot.revision,
|
||||
"evidence_refs": list(snapshot.evidence_refs),
|
||||
"review_id": review["review_id"],
|
||||
"root_revision_after": review["root_revision_after"],
|
||||
"next_legal_action": review["next_legal_action"],
|
||||
"dispatch_status": dispatch.status,
|
||||
"dispatch_idempotency_key": dispatch.idempotency_key,
|
||||
}
|
||||
body["receipt_digest"] = sha256(_canonical_bytes(body)).hexdigest()
|
||||
return body
|
||||
|
||||
def _exact_active_claim(self, task: TaskRecord):
|
||||
active = self.service.store.active_claim(task.task_id)
|
||||
if active is None:
|
||||
return None
|
||||
if (
|
||||
active.worker_id != self.worker_id
|
||||
or active.worker_identity != self.worker_identity
|
||||
or active.operation_digest != task.task_plan.operation_digest()
|
||||
):
|
||||
raise ConflictError("active claim is owned by another execution binding")
|
||||
return active
|
||||
|
||||
def reconcile(
|
||||
self, task_id: str, idempotency_key: str | None, *, actor_identity: str
|
||||
) -> dict[str, object]:
|
||||
if self.provider is None:
|
||||
raise ForbiddenError("terminal proof provider is not configured")
|
||||
task = self._authorized_task(task_id, actor_identity)
|
||||
if idempotency_key != self.expected_idempotency_key(task):
|
||||
raise ValidationError("terminal proof idempotency key mismatch")
|
||||
existing = self._existing_receipt(task, actor_identity)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
proof = self.provider.collect(task)
|
||||
claim = self._exact_active_claim(task)
|
||||
if claim is None:
|
||||
try:
|
||||
claimed = self.service.claim(
|
||||
task.task_id,
|
||||
{
|
||||
"worker_id": self.worker_id,
|
||||
"lease_seconds": self.lease_seconds,
|
||||
"actor_identity": self.worker_identity,
|
||||
},
|
||||
)
|
||||
claim_id = str(claimed["claim_id"])
|
||||
except Exception:
|
||||
refreshed = self._authorized_task(task_id, actor_identity)
|
||||
replay = self._existing_receipt(refreshed, actor_identity)
|
||||
if replay is not None:
|
||||
return replay
|
||||
claim = self._exact_active_claim(refreshed)
|
||||
if claim is None:
|
||||
raise
|
||||
claim_id = claim.claim_id
|
||||
else:
|
||||
claim_id = claim.claim_id
|
||||
|
||||
try:
|
||||
self.service.complete(
|
||||
task.task_id,
|
||||
{
|
||||
"claim_id": claim_id,
|
||||
"actor_identity": self.worker_identity,
|
||||
"status": "COMPLETED",
|
||||
"outputs": proof.outputs(),
|
||||
"evidence": [proof.evidence()],
|
||||
"started_at": proof.started_at,
|
||||
"completed_at": proof.completed_at,
|
||||
"attestation_ref": proof.attestation_ref,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
refreshed = self._authorized_task(task_id, actor_identity)
|
||||
replay = self._existing_receipt(refreshed, actor_identity)
|
||||
if replay is None:
|
||||
raise
|
||||
return replay
|
||||
refreshed = self._authorized_task(task_id, actor_identity)
|
||||
receipt = self._existing_receipt(refreshed, actor_identity)
|
||||
if receipt is None:
|
||||
raise ConflictError("terminal proof completion lacks authoritative readback")
|
||||
return receipt
|
||||
@@ -4,6 +4,7 @@ import hashlib, html, json, os, sqlite3, time, uuid, urllib.parse
|
||||
from http import cookies
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import hmac # SEC-P0-BOOTSTRAP-CREDENTIAL
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_WEBOPS_ROOT', Path(__file__).resolve().parents[1]))
|
||||
DB_PATH = Path(os.getenv('AIOS_WEBOPS_DB', ROOT / 'data' / 'webops.sqlite3'))
|
||||
@@ -13,7 +14,11 @@ PUBLIC_DOMAIN = os.getenv('AIOS_PUBLIC_DOMAIN','https://cnnetmkt.top')
|
||||
WEBOPS_DOMAIN = os.getenv('AIOS_WEBOPS_DOMAIN','https://ops.cnnetmkt.top')
|
||||
API_DOMAIN = os.getenv('AIOS_API_DOMAIN','https://api.cnnetmkt.top')
|
||||
WECHAT_DOMAIN = os.getenv('AIOS_WECHAT_CALLBACK_DOMAIN','https://wechat.cnnetmkt.top')
|
||||
SECRET = os.getenv('AIOS_WEBOPS_SECRET','change-me-before-production')
|
||||
SECRET = os.getenv('AIOS_WEBOPS_SECRET') or ''
|
||||
# SEC-P0: fail closed — never boot with a missing/insecure default secret.
|
||||
if SECRET in ('', 'change-me-before-production'):
|
||||
raise SystemExit('AIOS_WEBOPS_SECRET not set (or insecure default). Refusing to start: fail closed.')
|
||||
SESSION_TTL = int(os.getenv('AIOS_SESSION_TTL','3600'))
|
||||
ROLES = ['owner','admin','user']
|
||||
NODES = ['server-hw','server-local']
|
||||
PAGES = [('/dashboard','Dashboard'),('/nodes','Nodes'),('/roles','Roles'),('/routing','Routing'),('/failover','Failover'),('/approvals','Approvals'),('/operations','Operations'),('/users','Users'),('/quota','Quota'),('/models','Models'),('/memory','Memory'),('/channels/wechat','WeChat'),('/life','Life Core'),('/patches','Patch Studio'),('/optimization','Optimization'),('/simulation','Simulation'),('/logs','Logs'),('/config','Config'),('/uat','UAT'),('/versions','Versions'),('/evidence','Evidence')]
|
||||
@@ -43,19 +48,46 @@ def audit(actor, action, target='', payload=None):
|
||||
exec_sql('INSERT INTO audit_logs(id,actor,action,target,payload,created_at) VALUES(?,?,?,?,?,?)',(uid('audit'),actor,action,target,jdump(payload or {}),now()))
|
||||
def notify(event_type,payload):
|
||||
exec_sql('INSERT INTO owner_notifications(id,event_type,payload,status,created_at) VALUES(?,?,?,?,?)',(uid('notify'),event_type,jdump(payload),'queued',now()))
|
||||
def hash_pw(p): return hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
||||
def check_pw(stored,p): return stored in (p, hash_pw(p))
|
||||
_PBKDF2_ITERS = int(os.getenv('AIOS_PBKDF2_ITERS','260000'))
|
||||
def _is_pbkdf2(stored): return isinstance(stored,str) and stored.startswith('pbkdf2$') and stored.count('$')==2
|
||||
def hash_pw(p):
|
||||
salt=os.urandom(16).hex()
|
||||
dk=hashlib.pbkdf2_hmac('sha256', p.encode(), bytes.fromhex(salt), _PBKDF2_ITERS).hex()
|
||||
return f'pbkdf2${salt}${dk}'
|
||||
def check_pw(stored,p):
|
||||
if not stored or not isinstance(stored,str) or not stored: return False
|
||||
if _is_pbkdf2(stored):
|
||||
_,salt,dk=stored.split('$')
|
||||
calc=hashlib.pbkdf2_hmac('sha256', p.encode(), bytes.fromhex(salt), _PBKDF2_ITERS).hex()
|
||||
return hmac.compare_digest(dk,calc)
|
||||
# Legacy record path: NEVER accept the plaintext form as a credential.
|
||||
# A legacy (unsalted) sha256 is honored ONLY as a one-time owner migration
|
||||
# credential and never for the known bootstrap default; anything else is
|
||||
# rejected so the account must be re-seeded via a secure channel (fail closed).
|
||||
if stored == p: return False
|
||||
try:
|
||||
old=hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
||||
except Exception:
|
||||
return False
|
||||
return (hmac.compare_digest(old,stored)) and (p not in {'bootstrap-change-me'})
|
||||
def token(username,role):
|
||||
data=f'{username}|{role}|{int(time.time())}'; sig=hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]; return data+'|'+sig
|
||||
exp=int(time.time())+SESSION_TTL
|
||||
data=f'{username}|{role}|{exp}'
|
||||
sig=hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
|
||||
return data+'|'+sig
|
||||
def parse_session(header):
|
||||
if not header: return None
|
||||
c=cookies.SimpleCookie(); c.load(header)
|
||||
if 'aios_session' not in c: return None
|
||||
parts=c['aios_session'].value.split('|')
|
||||
if len(parts)!=4: return None
|
||||
data='|'.join(parts[:3]); sig=parts[3]
|
||||
if hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]!=sig: return None
|
||||
return one('SELECT * FROM users WHERE username=? AND role=? AND status=?',(parts[0],parts[1],'approved'))
|
||||
username,role,exp,sig=parts
|
||||
data=f'{username}|{role}|{exp}'
|
||||
if not hmac.compare_digest(hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest(), sig): return None
|
||||
try:
|
||||
if int(exp) <= int(time.time()): return None
|
||||
except Exception: return None
|
||||
return one('SELECT * FROM users WHERE username=? AND role=? AND status=?',(username,role,'approved'))
|
||||
def require(user, roles): return bool(user and user['role'] in roles)
|
||||
def table(data):
|
||||
if not data: return '<p class="muted">No records.</p>'
|
||||
@@ -215,7 +247,7 @@ class H(BaseHTTPRequestHandler):
|
||||
if path=='/login':
|
||||
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||
u=one('SELECT * FROM users WHERE username=? AND status=?',(data.get('username',[''])[0],'approved'))
|
||||
if u and check_pw(u.get('password_hash') or '',data.get('password',[''])[0]): audit(u['username'],'login','webops',{}); self.redirect('/dashboard',[f"aios_session={token(u['username'],u['role'])}; Path=/; HttpOnly; SameSite=Lax"]); return
|
||||
if u and check_pw(u.get('password_hash') or '',data.get('password',[''])[0]): audit(u['username'],'login','webops',{}); self.redirect('/dashboard',[f"aios_session={token(u['username'],u['role'])}; Path=/; HttpOnly; SameSite=Strict"]); return
|
||||
self.send_body(403,layout('Login Failed','<p>登录失败。</p>',None)); return
|
||||
if path=='/register':
|
||||
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||
|
||||
@@ -4,6 +4,7 @@ import hashlib, html, json, os, sqlite3, time, uuid, urllib.parse
|
||||
from http import cookies
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import hmac # SEC-P0-BOOTSTRAP-CREDENTIAL
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_WEBOPS_ROOT', Path(__file__).resolve().parents[1]))
|
||||
DB_PATH = Path(os.getenv('AIOS_WEBOPS_DB', ROOT / 'data' / 'webops.sqlite3'))
|
||||
@@ -13,7 +14,11 @@ PUBLIC_DOMAIN = os.getenv('AIOS_PUBLIC_DOMAIN','https://cnnetmkt.top')
|
||||
WEBOPS_DOMAIN = os.getenv('AIOS_WEBOPS_DOMAIN','https://ops.cnnetmkt.top')
|
||||
API_DOMAIN = os.getenv('AIOS_API_DOMAIN','https://api.cnnetmkt.top')
|
||||
WECHAT_DOMAIN = os.getenv('AIOS_WECHAT_CALLBACK_DOMAIN','https://wechat.cnnetmkt.top')
|
||||
SECRET = os.getenv('AIOS_WEBOPS_SECRET','change-me-before-production')
|
||||
SECRET = os.getenv('AIOS_WEBOPS_SECRET') or ''
|
||||
# SEC-P0: fail closed — never boot with a missing/insecure default secret.
|
||||
if SECRET in ('', 'change-me-before-production'):
|
||||
raise SystemExit('AIOS_WEBOPS_SECRET not set (or insecure default). Refusing to start: fail closed.')
|
||||
SESSION_TTL = int(os.getenv('AIOS_SESSION_TTL','3600'))
|
||||
ROLES = ['owner','admin','user']
|
||||
NODES = ['server-hw','server-local']
|
||||
PAGES = [('/dashboard','Dashboard'),('/nodes','Nodes'),('/roles','Roles'),('/routing','Routing'),('/failover','Failover'),('/approvals','Approvals'),('/operations','Operations'),('/users','Users'),('/quota','Quota'),('/models','Models'),('/memory','Memory'),('/channels/wechat','WeChat'),('/life','Life Core'),('/patches','Patch Studio'),('/optimization','Optimization'),('/simulation','Simulation'),('/logs','Logs'),('/config','Config'),('/uat','UAT'),('/versions','Versions'),('/evidence','Evidence')]
|
||||
@@ -43,19 +48,46 @@ def audit(actor, action, target='', payload=None):
|
||||
exec_sql('INSERT INTO audit_logs(id,actor,action,target,payload,created_at) VALUES(?,?,?,?,?,?)',(uid('audit'),actor,action,target,jdump(payload or {}),now()))
|
||||
def notify(event_type,payload):
|
||||
exec_sql('INSERT INTO owner_notifications(id,event_type,payload,status,created_at) VALUES(?,?,?,?,?)',(uid('notify'),event_type,jdump(payload),'queued',now()))
|
||||
def hash_pw(p): return hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
||||
def check_pw(stored,p): return stored in (p, hash_pw(p))
|
||||
_PBKDF2_ITERS = int(os.getenv('AIOS_PBKDF2_ITERS','260000'))
|
||||
def _is_pbkdf2(stored): return isinstance(stored,str) and stored.startswith('pbkdf2$') and stored.count('$')==2
|
||||
def hash_pw(p):
|
||||
salt=os.urandom(16).hex()
|
||||
dk=hashlib.pbkdf2_hmac('sha256', p.encode(), bytes.fromhex(salt), _PBKDF2_ITERS).hex()
|
||||
return f'pbkdf2${salt}${dk}'
|
||||
def check_pw(stored,p):
|
||||
if not stored or not isinstance(stored,str) or not stored: return False
|
||||
if _is_pbkdf2(stored):
|
||||
_,salt,dk=stored.split('$')
|
||||
calc=hashlib.pbkdf2_hmac('sha256', p.encode(), bytes.fromhex(salt), _PBKDF2_ITERS).hex()
|
||||
return hmac.compare_digest(dk,calc)
|
||||
# Legacy record path: NEVER accept the plaintext form as a credential.
|
||||
# A legacy (unsalted) sha256 is honored ONLY as a one-time owner migration
|
||||
# credential and never for the known bootstrap default; anything else is
|
||||
# rejected so the account must be re-seeded via a secure channel (fail closed).
|
||||
if stored == p: return False
|
||||
try:
|
||||
old=hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
||||
except Exception:
|
||||
return False
|
||||
return (hmac.compare_digest(old,stored)) and (p not in {'bootstrap-change-me'})
|
||||
def token(username,role):
|
||||
data=f'{username}|{role}|{int(time.time())}'; sig=hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]; return data+'|'+sig
|
||||
exp=int(time.time())+SESSION_TTL
|
||||
data=f'{username}|{role}|{exp}'
|
||||
sig=hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
|
||||
return data+'|'+sig
|
||||
def parse_session(header):
|
||||
if not header: return None
|
||||
c=cookies.SimpleCookie(); c.load(header)
|
||||
if 'aios_session' not in c: return None
|
||||
parts=c['aios_session'].value.split('|')
|
||||
if len(parts)!=4: return None
|
||||
data='|'.join(parts[:3]); sig=parts[3]
|
||||
if hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]!=sig: return None
|
||||
return one('SELECT * FROM users WHERE username=? AND role=? AND status=?',(parts[0],parts[1],'approved'))
|
||||
username,role,exp,sig=parts
|
||||
data=f'{username}|{role}|{exp}'
|
||||
if not hmac.compare_digest(hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest(), sig): return None
|
||||
try:
|
||||
if int(exp) <= int(time.time()): return None
|
||||
except Exception: return None
|
||||
return one('SELECT * FROM users WHERE username=? AND role=? AND status=?',(username,role,'approved'))
|
||||
def require(user, roles): return bool(user and user['role'] in roles)
|
||||
def table(data):
|
||||
if not data: return '<p class="muted">No records.</p>'
|
||||
@@ -215,7 +247,7 @@ class H(BaseHTTPRequestHandler):
|
||||
if path=='/login':
|
||||
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||
u=one('SELECT * FROM users WHERE username=? AND status=?',(data.get('username',[''])[0],'approved'))
|
||||
if u and check_pw(u.get('password_hash') or '',data.get('password',[''])[0]): audit(u['username'],'login','webops',{}); self.redirect('/dashboard',[f"aios_session={token(u['username'],u['role'])}; Path=/; HttpOnly; SameSite=Lax"]); return
|
||||
if u and check_pw(u.get('password_hash') or '',data.get('password',[''])[0]): audit(u['username'],'login','webops',{}); self.redirect('/dashboard',[f"aios_session={token(u['username'],u['role'])}; Path=/; HttpOnly; SameSite=Strict"]); return
|
||||
self.send_body(403,layout('Login Failed','<p>登录失败。</p>',None)); return
|
||||
if path=='/register':
|
||||
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
||||
"""Concrete transport adapter for the governed source publication boundary.
|
||||
|
||||
The adapter composes the already existing live writer executor. It owns no
|
||||
leases, TaskPrincipal, credentials, retry policy, or release state machine.
|
||||
Those remain server side. ``authenticated_readback`` is deliberately an
|
||||
injected port: a local commit response is not evidence of an authenticated
|
||||
source/tree binding.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Protocol
|
||||
|
||||
from governed_unattended_release import SourcePublicationIntent
|
||||
from task_plane.governed_writer_automation import GovernedWriterRequest
|
||||
|
||||
|
||||
class GovernedPublicationError(RuntimeError):
|
||||
"""Stable fail-closed publication error."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class GovernedWriterExecutor(Protocol):
|
||||
def acquire(self, *, request: GovernedWriterRequest, job_id: str) -> Any: ...
|
||||
|
||||
def commit_and_readback(
|
||||
self, *, session: Any, request: GovernedWriterRequest,
|
||||
files: Mapping[str, bytes], expected_sha256: Mapping[str, str],
|
||||
message: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def submit_task_result(
|
||||
self, *, session: Any, result: Mapping[str, Any], idempotency_key: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class SourceFileReader(Protocol):
|
||||
def __call__(self, intent: SourcePublicationIntent) -> Mapping[str, bytes]: ...
|
||||
|
||||
|
||||
class AuthenticatedReadback(Protocol):
|
||||
def __call__(
|
||||
self, *, intent: SourcePublicationIntent, request: GovernedWriterRequest,
|
||||
session: Any, commit_result: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class AdmissionBindingReader(Protocol):
|
||||
"""Read the server-side task/governed binding after admission."""
|
||||
|
||||
def __call__(
|
||||
self, *, intent: SourcePublicationIntent,
|
||||
admission_receipt: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class GitTreeFileReader(Protocol):
|
||||
"""Read bytes from an immutable committed tree object, never a workspace."""
|
||||
|
||||
def __call__(self, *, tree_oid: str, path: str) -> bytes: ...
|
||||
|
||||
|
||||
class CommittedTreeSourceReader:
|
||||
"""Adapt an authoritative git-object reader to the publication port."""
|
||||
|
||||
def __init__(self, reader: GitTreeFileReader) -> None:
|
||||
if not callable(reader):
|
||||
raise ValueError("git_object_reader_required")
|
||||
self.reader = reader
|
||||
|
||||
def __call__(self, intent: SourcePublicationIntent) -> Mapping[str, bytes]:
|
||||
return {
|
||||
path: self.reader(tree_oid=intent.source_tree_oid, path=path)
|
||||
for path in intent.file_allowlist
|
||||
}
|
||||
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _oid(value: Any) -> str:
|
||||
result = str(value or "").lower()
|
||||
if not _OID.fullmatch(result):
|
||||
raise GovernedPublicationError("PUBLICATION_OID_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def _sha(value: Any) -> str:
|
||||
result = str(value or "").lower()
|
||||
if not _SHA.fullmatch(result):
|
||||
raise GovernedPublicationError("PUBLICATION_SHA_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def _canonical(value: Mapping[str, Any]) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
dict(value), sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False, allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_EVIDENCE_INVALID") from exc
|
||||
|
||||
|
||||
class GovernedPublicationClient:
|
||||
"""Run TaskLease -> TaskPrincipal -> WriterLease -> commit through ports.
|
||||
|
||||
``writer_executor`` is normally ``LiveGovernedWriterExecutor`` from
|
||||
``task_plane.live_governed_writer_adapters``. ``source_files`` must return
|
||||
the exact immutable bytes for the intent's allowlist; it is never allowed
|
||||
to return a path or a mutable object. The authenticated readback port
|
||||
must be backed by the authoritative provider/bridge and prove the final
|
||||
source OID, tree OID, task, branch, base, generation, and execution digest.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
writer_executor: GovernedWriterExecutor,
|
||||
source_files: SourceFileReader | None = None,
|
||||
git_tree_file: GitTreeFileReader | None = None,
|
||||
admission_binding: AdmissionBindingReader,
|
||||
authenticated_readback: AuthenticatedReadback,
|
||||
subject: str,
|
||||
manifest_id: str,
|
||||
runtime_fingerprint: str,
|
||||
process_start_identity: str,
|
||||
commit_message: str = "governed source publication",
|
||||
) -> None:
|
||||
if (
|
||||
writer_executor is None
|
||||
or any(
|
||||
not callable(getattr(writer_executor, name, None))
|
||||
for name in (
|
||||
"acquire", "commit_and_readback", "submit_task_result",
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError("writer_executor_required")
|
||||
if source_files is not None:
|
||||
raise ValueError("workspace_source_reader_forbidden")
|
||||
if git_tree_file is None:
|
||||
raise ValueError("git_object_source_reader_required")
|
||||
if not callable(admission_binding) or not callable(authenticated_readback):
|
||||
raise ValueError("authenticated_ports_required")
|
||||
for name, value in (("subject", subject), ("manifest_id", manifest_id),
|
||||
("runtime_fingerprint", runtime_fingerprint),
|
||||
("process_start_identity", process_start_identity),
|
||||
("commit_message", commit_message)):
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(name + "_required")
|
||||
self.writer_executor = writer_executor
|
||||
self.source_files = None
|
||||
self.git_tree_file = git_tree_file
|
||||
self.admission_binding = admission_binding
|
||||
self.authenticated_readback = authenticated_readback
|
||||
self.subject = subject
|
||||
self.manifest_id = manifest_id
|
||||
self.runtime_fingerprint = runtime_fingerprint
|
||||
self.process_start_identity = process_start_identity
|
||||
self.commit_message = commit_message
|
||||
|
||||
def _validate_admission(self, intent: SourcePublicationIntent, receipt: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if not isinstance(receipt, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_INVALID")
|
||||
for key, expected in (("task_id", intent.task_id), ("authority_base_oid", intent.authority_base_oid),
|
||||
("target_branch", intent.target_branch), ("generation", intent.generation),
|
||||
("execution_digest", intent.execution_digest)):
|
||||
if receipt.get(key) != expected:
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_MISMATCH")
|
||||
try:
|
||||
binding = self.admission_binding(intent=intent, admission_receipt=receipt)
|
||||
except Exception as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_UNAVAILABLE") from exc
|
||||
if not isinstance(binding, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_INVALID")
|
||||
expected = {
|
||||
"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest,
|
||||
}
|
||||
if any(binding.get(key) != value for key, value in expected.items()):
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_MISMATCH")
|
||||
job_id = binding.get("job_id")
|
||||
if not isinstance(job_id, str) or not job_id:
|
||||
raise GovernedPublicationError("PUBLICATION_JOB_BINDING_MISSING")
|
||||
return dict(binding)
|
||||
|
||||
def _read_files(self, intent: SourcePublicationIntent) -> tuple[dict[str, bytes], dict[str, str]]:
|
||||
try:
|
||||
raw = {
|
||||
path: self.git_tree_file(tree_oid=intent.source_tree_oid, path=path)
|
||||
for path in intent.file_allowlist
|
||||
}
|
||||
except KeyError as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_FILE_SET_MISMATCH") from exc
|
||||
except Exception as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_SOURCE_READ_FAILED") from exc
|
||||
if not isinstance(raw, Mapping) or set(raw) != set(intent.file_allowlist):
|
||||
raise GovernedPublicationError("PUBLICATION_FILE_SET_MISMATCH")
|
||||
files: dict[str, bytes] = {}
|
||||
hashes: dict[str, str] = {}
|
||||
for path in intent.file_allowlist:
|
||||
data = raw.get(path)
|
||||
if isinstance(data, bytearray):
|
||||
data = bytes(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise GovernedPublicationError("PUBLICATION_FILE_BYTES_REQUIRED")
|
||||
files[path] = bytes(data)
|
||||
hashes[path] = hashlib.sha256(files[path]).hexdigest()
|
||||
return files, hashes
|
||||
|
||||
def publish(self, intent: SourcePublicationIntent, admission_receipt: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(intent, SourcePublicationIntent):
|
||||
raise GovernedPublicationError("PUBLICATION_INTENT_REQUIRED")
|
||||
admission_binding = self._validate_admission(intent, admission_receipt)
|
||||
job_id = str(admission_binding["job_id"])
|
||||
files, hashes = self._read_files(intent)
|
||||
request = GovernedWriterRequest(
|
||||
subject=self.subject, task_id=intent.task_id, repository=intent.repository,
|
||||
target_branch=intent.target_branch, risk=intent.risk, generation=intent.generation,
|
||||
base_oid=intent.authority_base_oid,
|
||||
required_capabilities=frozenset(intent.requested_capabilities),
|
||||
file_allowlist=tuple(intent.file_allowlist),
|
||||
plan_digest="sha256:" + intent.execution_digest,
|
||||
manifest_id=self.manifest_id, runtime_fingerprint=self.runtime_fingerprint,
|
||||
process_start_identity=self.process_start_identity,
|
||||
)
|
||||
try:
|
||||
session = self.writer_executor.acquire(request=request, job_id=job_id)
|
||||
commit_result = self.writer_executor.commit_and_readback(
|
||||
session=session, request=request, files=files,
|
||||
expected_sha256=hashes, message=self.commit_message,
|
||||
)
|
||||
if not isinstance(commit_result, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_READBACK_INVALID")
|
||||
readback = self.authenticated_readback(
|
||||
intent=intent, request=request, session=session,
|
||||
commit_result=commit_result,
|
||||
)
|
||||
except GovernedPublicationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# A transport exception after a mutation is indeterminate. Never
|
||||
# resubmit it here; the caller must reconcile through the existing
|
||||
# Job/recovery service.
|
||||
raise GovernedPublicationError("PUBLICATION_EFFECT_UNKNOWN") from exc
|
||||
if not isinstance(readback, Mapping) or readback.get("authenticated") is not True:
|
||||
raise GovernedPublicationError("PUBLICATION_AUTHENTICATED_READBACK_REQUIRED")
|
||||
required = {
|
||||
"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest,
|
||||
}
|
||||
if any(readback.get(key) != value for key, value in required.items()):
|
||||
raise GovernedPublicationError("PUBLICATION_READBACK_BINDING_MISMATCH")
|
||||
for key in ("task_lease", "task_principal", "writer_lease"):
|
||||
if not isinstance(readback.get(key), Mapping) or readback[key].get("task_id") != intent.task_id:
|
||||
raise GovernedPublicationError("PUBLICATION_LEASE_CHAIN_MISSING")
|
||||
source_oid = _oid(readback.get("source_oid"))
|
||||
tree_oid = _oid(readback.get("tree_oid"))
|
||||
nested_commit = readback.get("commit_receipt")
|
||||
if nested_commit is not None and not isinstance(nested_commit, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_BINDING_MISMATCH")
|
||||
commit_oid = _oid(readback.get("commit_oid") or (nested_commit or {}).get("commit_oid"))
|
||||
if source_oid != commit_oid or tree_oid != intent.source_tree_oid:
|
||||
raise GovernedPublicationError("PUBLICATION_SOURCE_TREE_BINDING_MISMATCH")
|
||||
commit_receipt = readback.get("commit_receipt")
|
||||
if not isinstance(commit_receipt, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_RECEIPT_REQUIRED")
|
||||
# The live governed receipt binds commit_oid; the independently read
|
||||
# Git tree is carried by authenticated readback. Preserve the original
|
||||
# receipt instead of inventing a tree field absent from its schema.
|
||||
if (commit_receipt.get("commit_oid") != commit_oid
|
||||
or ("tree_oid" in commit_receipt and commit_receipt["tree_oid"] != tree_oid)):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_BINDING_MISMATCH")
|
||||
publication = {
|
||||
**dict(readback), "authenticated": True, "task_id": intent.task_id,
|
||||
"job_id": job_id, "source_oid": source_oid, "tree_oid": tree_oid,
|
||||
"generation": intent.generation, "target_branch": intent.target_branch,
|
||||
"authority_base_oid": intent.authority_base_oid,
|
||||
"execution_digest": intent.execution_digest,
|
||||
"commit_receipt": dict(commit_receipt),
|
||||
}
|
||||
task_result = {
|
||||
"schema": "aios.governed-source-publication-result.v1",
|
||||
"task_id": intent.task_id,
|
||||
"job_id": job_id,
|
||||
"repository": intent.repository,
|
||||
"target_branch": intent.target_branch,
|
||||
"generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest,
|
||||
"authority_base_oid": _oid(intent.authority_base_oid),
|
||||
"candidate_source_oid": _oid(intent.source_commit),
|
||||
"published_source_oid": source_oid,
|
||||
"source_tree_oid": tree_oid,
|
||||
"commit_receipt_sha256": hashlib.sha256(
|
||||
_canonical(commit_receipt)
|
||||
).hexdigest(),
|
||||
}
|
||||
result_sha256 = hashlib.sha256(_canonical(task_result)).hexdigest()
|
||||
idempotency_key = "governed-source-result-" + result_sha256
|
||||
try:
|
||||
submitted = self.writer_executor.submit_task_result(
|
||||
session=session, result=task_result,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if (
|
||||
not isinstance(submitted, Mapping)
|
||||
or submitted.get("task_id") != intent.task_id
|
||||
or submitted.get("status") != "VERIFYING"
|
||||
or submitted.get("result") != task_result
|
||||
):
|
||||
raise GovernedPublicationError(
|
||||
"PUBLICATION_TASK_RESULT_READBACK_INVALID"
|
||||
)
|
||||
except GovernedPublicationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# A lost result response may have changed the durable Task. Never
|
||||
# re-register, reclaim or retry here.
|
||||
raise GovernedPublicationError("PUBLICATION_EFFECT_UNKNOWN") from exc
|
||||
publication.update(
|
||||
task_result=task_result,
|
||||
task_result_idempotency_key=idempotency_key,
|
||||
task_result_submission={
|
||||
"task_id": submitted["task_id"], "status": submitted["status"],
|
||||
"revision": submitted.get("revision"),
|
||||
"idempotent": submitted.get("idempotent", False),
|
||||
"result_sha256": result_sha256,
|
||||
},
|
||||
)
|
||||
return publication
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Narrow C5 unattended source-publication policy lifecycle.
|
||||
|
||||
The policy is an Owner-signed, exact-scope admission object. It is checked
|
||||
against the configured Owner policy at issuance and against a live revocation
|
||||
source for every request. It is not an activation/configuration mechanism
|
||||
and does not create release or production authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
OWNER_NAMESPACE,
|
||||
OWNER_IDENTITY,
|
||||
SOURCE_PUBLICATION_CAPABILITIES,
|
||||
SOURCE_PUBLICATION_POLICY_ID,
|
||||
SOURCE_PUBLICATION_POLICY_SCHEMA,
|
||||
SignedArtifact,
|
||||
SingleHostAuthorityError,
|
||||
SourcePublicationPolicyRequest,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
|
||||
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_RISK_ORDER = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
|
||||
_POLICY_FIELDS = frozenset(
|
||||
{
|
||||
"schema", "policy_id", "owner_identity", "repository", "trust_domain",
|
||||
"policy_digest",
|
||||
"authority_base_oid", "source_oid", "source_tree_oid", "target_agent",
|
||||
"target_branch", "generation", "execution_digest", "exact_files",
|
||||
"capabilities", "risk", "production_impact", "renewal", "nonce",
|
||||
"issued_at", "not_before", "expires_at", "status", "signature_namespace",
|
||||
"signature_key_id", "canonical_payload_digest",
|
||||
}
|
||||
)
|
||||
_REQUEST_FIELDS = frozenset(SourcePublicationPolicyRequest.__dataclass_fields__)
|
||||
|
||||
# The typed Owner epoch producer uses the configured policy's production_scope.
|
||||
# The installed C5 source-only policy contract uses ``none`` for production
|
||||
# impact; keep this exact value bound to the C5 policy rather than accepting an
|
||||
# arbitrary non-empty scope from a raw signer.
|
||||
SOURCE_PUBLICATION_EPOCH_PRODUCTION_SCOPE = "none"
|
||||
SOURCE_PUBLICATION_EPOCH_DESTRUCTIVE_POLICY = "forbidden"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskAdmissionPolicyProjection:
|
||||
"""The only policy projection exposed after an exact request guard."""
|
||||
|
||||
max_risk: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
capabilities: tuple[str, ...]
|
||||
exact_files: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _request_dict(request: SourcePublicationPolicyRequest | Mapping[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(request, SourcePublicationPolicyRequest):
|
||||
value = asdict(request)
|
||||
elif isinstance(request, Mapping):
|
||||
value = dict(request)
|
||||
else:
|
||||
raise TypeError("request must be SourcePublicationPolicyRequest or mapping")
|
||||
if set(value) != _REQUEST_FIELDS:
|
||||
raise ValueError("SOURCE_PUBLICATION_REQUEST_FIELDS_INVALID")
|
||||
if "exact_files" in value:
|
||||
value["exact_files"] = tuple(value["exact_files"])
|
||||
return value
|
||||
|
||||
|
||||
class SourcePublicationPolicyVerifier:
|
||||
"""Strict verifier over the existing Owner registry verifier interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
owner_verifier: Any,
|
||||
*,
|
||||
revocation_check: Callable[[Mapping[str, Any]], bool] | None,
|
||||
expected_policy_digest: str | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.owner_verifier = owner_verifier
|
||||
self.revocation_check = revocation_check
|
||||
self.expected_policy_digest = expected_policy_digest
|
||||
self.clock = clock
|
||||
|
||||
def _deny(self, code: str) -> None:
|
||||
raise SingleHostAuthorityError(code)
|
||||
|
||||
def _validate_payload(self, artifact: SignedArtifact) -> dict[str, Any]:
|
||||
if not isinstance(artifact, SignedArtifact):
|
||||
self._deny("SOURCE_PUBLICATION_ARTIFACT_INVALID")
|
||||
payload = dict(artifact.payload)
|
||||
if set(payload) != _POLICY_FIELDS:
|
||||
self._deny("SOURCE_PUBLICATION_UNKNOWN_FIELDS")
|
||||
try:
|
||||
if artifact.canonical_payload != canonical_json_bytes(payload):
|
||||
self._deny("SOURCE_PUBLICATION_CANONICAL_BYTES_MISMATCH")
|
||||
if payload["canonical_payload_digest"] != sha256_json(
|
||||
{k: v for k, v in payload.items() if k != "canonical_payload_digest"}
|
||||
):
|
||||
self._deny("SOURCE_PUBLICATION_PAYLOAD_DIGEST_INVALID")
|
||||
if payload["schema"] != SOURCE_PUBLICATION_POLICY_SCHEMA:
|
||||
self._deny("SOURCE_PUBLICATION_SCHEMA_INVALID")
|
||||
if payload["policy_id"] != SOURCE_PUBLICATION_POLICY_ID:
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_ID_INVALID")
|
||||
if payload["owner_identity"] != OWNER_IDENTITY:
|
||||
self._deny("SOURCE_PUBLICATION_OWNER_INVALID")
|
||||
if not isinstance(payload["policy_digest"], str) or not _HEX64.fullmatch(payload["policy_digest"]):
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_DIGEST_INVALID")
|
||||
if self.expected_policy_digest is not None and payload["policy_digest"] != self.expected_policy_digest:
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_DIGEST_MISMATCH")
|
||||
if payload["signature_namespace"] != OWNER_NAMESPACE:
|
||||
self._deny("SOURCE_PUBLICATION_NAMESPACE_INVALID")
|
||||
if payload["signature_key_id"] != artifact.key_id or artifact.namespace != OWNER_NAMESPACE:
|
||||
self._deny("SOURCE_PUBLICATION_ISSUER_MISMATCH")
|
||||
for name in ("authority_base_oid", "source_oid", "source_tree_oid"):
|
||||
if not isinstance(payload[name], str) or not _HEX40.fullmatch(payload[name]):
|
||||
self._deny("SOURCE_PUBLICATION_OID_INVALID")
|
||||
if not isinstance(payload["execution_digest"], str) or not _HEX64.fullmatch(payload["execution_digest"]):
|
||||
self._deny("SOURCE_PUBLICATION_EXECUTION_DIGEST_INVALID")
|
||||
branch = payload["target_branch"]
|
||||
agent = payload["target_agent"]
|
||||
if (
|
||||
not isinstance(branch, str) or not branch.startswith("agent/")
|
||||
or "\\" in branch or ".." in PurePosixPath(branch).parts
|
||||
or branch.endswith(("/", "."))
|
||||
or not isinstance(agent, str) or not agent.startswith("agent/")
|
||||
):
|
||||
self._deny("SOURCE_PUBLICATION_TARGET_INVALID")
|
||||
if payload["risk"] != "engineering" or payload["production_impact"] != "none" or payload["renewal"] != "none":
|
||||
self._deny("SOURCE_PUBLICATION_SCOPE_INVALID")
|
||||
if payload["capabilities"] != sorted(SOURCE_PUBLICATION_CAPABILITIES):
|
||||
self._deny("SOURCE_PUBLICATION_CAPABILITIES_INVALID")
|
||||
if not isinstance(payload["exact_files"], list) or not payload["exact_files"] or len(set(payload["exact_files"])) != len(payload["exact_files"]):
|
||||
self._deny("SOURCE_PUBLICATION_FILES_INVALID")
|
||||
if type(payload["generation"]) is not int or payload["generation"] < 1:
|
||||
self._deny("SOURCE_PUBLICATION_GENERATION_INVALID")
|
||||
if type(payload["issued_at"]) is not int or type(payload["not_before"]) is not int or type(payload["expires_at"]) is not int:
|
||||
self._deny("SOURCE_PUBLICATION_TIME_INVALID")
|
||||
now = int(self.clock())
|
||||
if not payload["not_before"] <= payload["issued_at"] <= now < payload["expires_at"]:
|
||||
self._deny("SOURCE_PUBLICATION_EXPIRED")
|
||||
if payload["expires_at"] <= payload["issued_at"]:
|
||||
self._deny("SOURCE_PUBLICATION_LIFETIME_INVALID")
|
||||
if payload["expires_at"] - payload["issued_at"] > 3600 or payload["status"] != "ACTIVE":
|
||||
self._deny("SOURCE_PUBLICATION_LIFETIME_INVALID")
|
||||
if self.revocation_check is None:
|
||||
self._deny("SOURCE_PUBLICATION_REVOCATION_CHECK_MISSING")
|
||||
if self.revocation_check(payload) is not True:
|
||||
self._deny("SOURCE_PUBLICATION_REVOKED")
|
||||
except SingleHostAuthorityError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_INVALID") from exc
|
||||
return payload
|
||||
|
||||
def verify(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any] | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
payload = self._validate_payload(artifact)
|
||||
# Verify exactly the bytes supplied by the signed artifact. Do not
|
||||
# reconstruct or reserialize them before invoking the registry API.
|
||||
if self.owner_verifier.verify(
|
||||
namespace=artifact.namespace,
|
||||
payload=artifact.canonical_payload,
|
||||
signature=artifact.signature,
|
||||
key_id=artifact.key_id,
|
||||
) is not True:
|
||||
return False
|
||||
if request is not None:
|
||||
expected = _request_dict(request)
|
||||
for field in (
|
||||
"policy_id", "repository", "authority_base_oid", "source_oid",
|
||||
"source_tree_oid", "target_agent", "target_branch", "generation",
|
||||
"execution_digest", "exact_files", "nonce", "risk",
|
||||
"production_impact", "renewal",
|
||||
):
|
||||
actual = tuple(payload[field]) if field == "exact_files" else payload[field]
|
||||
if actual != expected.get(field):
|
||||
return False
|
||||
if int(payload["expires_at"]) - int(payload["issued_at"]) != int(expected["ttl_seconds"]):
|
||||
return False
|
||||
return True
|
||||
except (SingleHostAuthorityError, TypeError, ValueError, KeyError):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def require(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not self.verify(artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
return dict(artifact.payload)
|
||||
|
||||
|
||||
def require_source_publication_epoch_scope(epoch: Any, policy_payload: Mapping[str, Any]) -> None:
|
||||
"""Require v2 epoch provenance to match the signed C5 policy exactly."""
|
||||
schema = getattr(epoch, "schema", None)
|
||||
if schema is None and isinstance(epoch, Mapping):
|
||||
schema = epoch.get("schema")
|
||||
if schema != "aios.owner-delegation-epoch.v2":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_EPOCH_SCHEMA_REQUIRED")
|
||||
|
||||
def field(name: str) -> Any:
|
||||
if isinstance(epoch, Mapping):
|
||||
return epoch.get(name)
|
||||
return getattr(epoch, name, None)
|
||||
|
||||
if (
|
||||
field("production_scope") != SOURCE_PUBLICATION_EPOCH_PRODUCTION_SCOPE
|
||||
or field("destructive_policy") != SOURCE_PUBLICATION_EPOCH_DESTRUCTIVE_POLICY
|
||||
or field("policy_digest") != policy_payload.get("policy_digest")
|
||||
or field("repository") != policy_payload.get("repository")
|
||||
or field("trust_domain") != policy_payload.get("trust_domain")
|
||||
):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_EPOCH_SCOPE_MISMATCH")
|
||||
|
||||
|
||||
class ScopedSourcePublicationPolicyAdmission:
|
||||
"""Rechecks a policy for every exact request before scoped admission."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verifier: SourcePublicationPolicyVerifier,
|
||||
*,
|
||||
bootstrap: Any | None = None,
|
||||
artifact: SignedArtifact | None = None,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any] | None = None,
|
||||
expected_agent_id: str | None = None,
|
||||
) -> None:
|
||||
self.verifier = verifier
|
||||
self.bootstrap = bootstrap
|
||||
self.artifact = artifact
|
||||
self.request = request
|
||||
self.expected_agent_id = expected_agent_id
|
||||
|
||||
def _check_bootstrap_binding(self, payload: Mapping[str, Any]) -> None:
|
||||
if self.bootstrap is None:
|
||||
return
|
||||
policy = getattr(self.bootstrap, "policy", None)
|
||||
digest_provider = getattr(policy, "normalized_policy_digest", None)
|
||||
digest = digest_provider() if callable(digest_provider) else None
|
||||
trust_domain = getattr(policy, "trust_domain", None)
|
||||
if not isinstance(digest, str) or digest != payload.get("policy_digest"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_POLICY_DIGEST_MISMATCH")
|
||||
if not isinstance(trust_domain, str) or trust_domain != payload.get("trust_domain"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_TRUST_DOMAIN_MISMATCH")
|
||||
if self.expected_agent_id is None or payload.get("target_agent") != self.expected_agent_id:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_TARGET_AGENT_MISMATCH")
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> bool:
|
||||
try:
|
||||
payload = self.verifier._validate_payload(artifact)
|
||||
if self.expected_agent_id is not None and payload.get("target_agent") != self.expected_agent_id:
|
||||
return False
|
||||
self._check_bootstrap_binding(payload)
|
||||
except SingleHostAuthorityError:
|
||||
return False
|
||||
return self.verifier.verify(artifact, request)
|
||||
|
||||
verify = authorize
|
||||
|
||||
def _request_for_raw(self, raw: Mapping[str, Any]) -> SourcePublicationPolicyRequest:
|
||||
if self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_ARTIFACT_REQUIRED")
|
||||
expected_task = getattr(self, "expected_task_id", None)
|
||||
if expected_task is not None and raw.get("task_id") != expected_task:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_PLAN_TASK_MISMATCH")
|
||||
payload = self.verifier._validate_payload(self.artifact)
|
||||
self._check_bootstrap_binding(payload)
|
||||
if self.request is not None:
|
||||
base = _request_dict(self.request)
|
||||
else:
|
||||
base = {
|
||||
"policy_id": payload["policy_id"], "repository": payload["repository"],
|
||||
"authority_base_oid": payload["authority_base_oid"], "source_oid": payload["source_oid"],
|
||||
"source_tree_oid": payload["source_tree_oid"], "target_agent": payload["target_agent"],
|
||||
"target_branch": payload["target_branch"], "generation": payload["generation"],
|
||||
"execution_digest": payload["execution_digest"], "exact_files": tuple(payload["exact_files"]),
|
||||
"nonce": payload["nonce"], "ttl_seconds": payload["expires_at"] - payload["issued_at"],
|
||||
"risk": payload["risk"], "production_impact": payload["production_impact"], "renewal": payload["renewal"],
|
||||
}
|
||||
checks = {
|
||||
"repository": base["repository"], "authority_base_oid": base["authority_base_oid"],
|
||||
"target_branch": base["target_branch"], "generation": base["generation"],
|
||||
"execution_digest": base["execution_digest"], "risk": base["risk"],
|
||||
}
|
||||
for field, expected in checks.items():
|
||||
if raw.get(field) != expected:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_REQUEST_SCOPE_MISMATCH")
|
||||
caps = tuple(raw.get("requested_capabilities", ()))
|
||||
if set(caps) != set(base["capabilities"] if "capabilities" in base else payload["capabilities"]):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_CAPABILITY_SCOPE_MISMATCH")
|
||||
if tuple(raw.get("file_allowlist", ())) != tuple(base["exact_files"]):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_FILE_SCOPE_MISMATCH")
|
||||
return SourcePublicationPolicyRequest(**base)
|
||||
|
||||
def create_envelope(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.bootstrap is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
request = self._request_for_raw(raw)
|
||||
if not self.verifier.verify(self.artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
return self.bootstrap.create_envelope(raw)
|
||||
|
||||
def authorize_and_admit(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.bootstrap is None or self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
if raw.get("authorization_kind") != "delegation_epoch":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_OWNER_SIGNATURE_BYPASS_DENIED")
|
||||
envelope = self.bootstrap._load_envelope(str(raw.get("envelope_id") or ""))
|
||||
envelope_raw = envelope.as_dict() if hasattr(envelope, "as_dict") else dict(envelope)
|
||||
request = self._request_for_raw(envelope_raw)
|
||||
if not self.verifier.verify(self.artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
epoch = self.bootstrap._verify_epoch(
|
||||
epoch_id=str(raw.get("delegation_epoch_id") or ""),
|
||||
envelope=envelope,
|
||||
now=int(self.verifier.clock()),
|
||||
)
|
||||
require_source_publication_epoch_scope(
|
||||
epoch, self.verifier._validate_payload(self.artifact)
|
||||
)
|
||||
return self.bootstrap.authorize_and_admit(raw)
|
||||
|
||||
def status(self, raw: Mapping[str, Any] | str) -> Any:
|
||||
"""Read status only after the same policy and epoch guard as admission."""
|
||||
if self.bootstrap is None or self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
if isinstance(raw, str):
|
||||
raw = {"envelope_id": raw}
|
||||
envelope = self.bootstrap._load_envelope(str(raw.get("envelope_id") or ""))
|
||||
envelope_raw = envelope.as_dict() if hasattr(envelope, "as_dict") else dict(envelope)
|
||||
request = self._request_for_raw(envelope_raw)
|
||||
self.verifier.require(self.artifact, request)
|
||||
epoch_id = str(raw.get("delegation_epoch_id") or "")
|
||||
if not epoch_id:
|
||||
grant = self.bootstrap.store.get_grant_by_task_id(envelope.task_id)
|
||||
epoch_id = grant.parent_epoch_id if grant is not None else ""
|
||||
if not epoch_id or not hasattr(self.bootstrap, "_verify_epoch"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_STATUS_EPOCH_REQUIRED")
|
||||
epoch = self.bootstrap._verify_epoch(
|
||||
epoch_id=epoch_id,
|
||||
envelope=envelope,
|
||||
now=int(self.verifier.clock()),
|
||||
)
|
||||
require_source_publication_epoch_scope(
|
||||
epoch, self.verifier._validate_payload(self.artifact)
|
||||
)
|
||||
if hasattr(self.bootstrap, "status"):
|
||||
return self.bootstrap.status(envelope.envelope_id)
|
||||
return envelope_raw
|
||||
|
||||
def reconcile_status(self, envelope_id: str, authorization: Mapping[str, Any]) -> Any:
|
||||
"""Carry the verified epoch identity when a Task effect has no receipt yet."""
|
||||
if authorization.get("authorization_kind") != "delegation_epoch":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_OWNER_SIGNATURE_BYPASS_DENIED")
|
||||
return self.status({"envelope_id": envelope_id,
|
||||
"delegation_epoch_id": authorization.get("delegation_epoch_id")})
|
||||
|
||||
def project_task_admission_policy(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> TaskAdmissionPolicyProjection:
|
||||
payload = self.verifier.require(artifact, request)
|
||||
return TaskAdmissionPolicyProjection(
|
||||
max_risk="engineering",
|
||||
repository=payload["repository"],
|
||||
target_branch=payload["target_branch"],
|
||||
capabilities=tuple(payload["capabilities"]),
|
||||
exact_files=tuple(payload["exact_files"]),
|
||||
)
|
||||
|
||||
to_task_admission_policy = project_task_admission_policy
|
||||
|
||||
|
||||
# Short aliases for callers that prefer the lifecycle terminology.
|
||||
SourcePublicationPolicyAdmission = ScopedSourcePublicationPolicyAdmission
|
||||
SourcePublicationVerifier = SourcePublicationPolicyVerifier
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Thin unattended release runner.
|
||||
|
||||
This module is an adapter around the existing admission and silent-release
|
||||
services. It deliberately owns no release state machine and never creates
|
||||
approval evidence. The runner only binds immutable source facts, delegates
|
||||
authority, and validates durable readbacks before moving to the next phase.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
IDENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||||
RETRYABLE_CODES = {408, 425, 429, 500, 502, 503, 504}
|
||||
RETRYABLE_NAMES = {
|
||||
"UNAVAILABLE", "BUSY", "TEMPORARY_FAILURE", "TASK_ADMISSION_IN_PROGRESS",
|
||||
}
|
||||
RISKS = {"low", "medium", "engineering", "high"}
|
||||
|
||||
|
||||
class UnattendedReleaseError(RuntimeError):
|
||||
"""A fail-closed runner error with a stable code."""
|
||||
|
||||
def __init__(self, code: str, message: str | None = None):
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
def _canon(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return hashlib.sha256(_canon(value)).hexdigest()
|
||||
|
||||
|
||||
def _require_oid(name: str, value: Any) -> str:
|
||||
value = str(value or "")
|
||||
if not OID_RE.fullmatch(value):
|
||||
raise UnattendedReleaseError("INVALID_" + name.upper())
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _require_sha(name: str, value: Any) -> str:
|
||||
value = str(value or "")
|
||||
if not SHA256_RE.fullmatch(value):
|
||||
raise UnattendedReleaseError("INVALID_" + name.upper())
|
||||
return value.lower()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourcePublicationIntent:
|
||||
"""Immutable, deterministic source and execution binding."""
|
||||
|
||||
repository: str
|
||||
source_commit: str
|
||||
source_tree_oid: str
|
||||
authority_base_oid: str
|
||||
target_branch: str
|
||||
file_allowlist: tuple[str, ...]
|
||||
requested_capabilities: tuple[str, ...]
|
||||
risk: str
|
||||
execution_digest: str
|
||||
generation: int = 1
|
||||
event_id: str = "release"
|
||||
version: str = ""
|
||||
admission_task_id: str = ""
|
||||
admission_idempotency_key: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.repository or not isinstance(self.repository, str):
|
||||
raise UnattendedReleaseError("INVALID_REPOSITORY")
|
||||
for name in ("source_commit", "source_tree_oid", "authority_base_oid"):
|
||||
object.__setattr__(self, name, _require_oid(name, getattr(self, name)))
|
||||
if not isinstance(self.target_branch, str) or not self.target_branch or not self.target_branch.startswith("agent/") or self.target_branch.startswith("-"):
|
||||
raise UnattendedReleaseError("INVALID_TARGET_BRANCH")
|
||||
if ".." in self.target_branch or "//" in self.target_branch or "@{" in self.target_branch:
|
||||
raise UnattendedReleaseError("INVALID_TARGET_BRANCH")
|
||||
if not isinstance(self.generation, int) or isinstance(self.generation, bool) or self.generation < 1:
|
||||
raise UnattendedReleaseError("INVALID_GENERATION")
|
||||
object.__setattr__(self, "execution_digest", _require_sha("execution_digest", self.execution_digest))
|
||||
if not self.file_allowlist or any(not isinstance(p, str) or not p or p.startswith(("/", "\\")) or ".." in p or "\\" in p or p == ".git" or p.startswith(".git/") for p in self.file_allowlist):
|
||||
raise UnattendedReleaseError("INVALID_FILE_ALLOWLIST")
|
||||
if len(set(self.file_allowlist)) != len(self.file_allowlist):
|
||||
raise UnattendedReleaseError("DUPLICATE_FILE_ALLOWLIST")
|
||||
if not self.requested_capabilities or any(not isinstance(c, str) or not c or "*" in c for c in self.requested_capabilities):
|
||||
raise UnattendedReleaseError("INVALID_CAPABILITIES")
|
||||
if not isinstance(self.risk, str) or self.risk.lower() not in RISKS:
|
||||
raise UnattendedReleaseError("INVALID_RISK")
|
||||
object.__setattr__(self, "risk", self.risk.lower())
|
||||
object.__setattr__(self, "file_allowlist", tuple(sorted(self.file_allowlist)))
|
||||
object.__setattr__(self, "requested_capabilities", tuple(sorted(set(self.requested_capabilities))))
|
||||
if not IDENT_RE.fullmatch(self.event_id):
|
||||
raise UnattendedReleaseError("INVALID_EVENT_ID")
|
||||
if self.version and not IDENT_RE.fullmatch(self.version):
|
||||
raise UnattendedReleaseError("INVALID_VERSION")
|
||||
if bool(self.admission_task_id) != bool(self.admission_idempotency_key):
|
||||
raise UnattendedReleaseError("INCOMPLETE_ADMISSION_IDENTITY")
|
||||
for value in (self.admission_task_id, self.admission_idempotency_key):
|
||||
if value and (not isinstance(value, str) or not IDENT_RE.fullmatch(value)):
|
||||
raise UnattendedReleaseError("INVALID_ADMISSION_IDENTITY")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = {
|
||||
"repository": self.repository, "source_commit": self.source_commit.lower(),
|
||||
"source_tree_oid": self.source_tree_oid.lower(), "authority_base_oid": self.authority_base_oid.lower(),
|
||||
"target_branch": self.target_branch, "file_allowlist": list(self.file_allowlist),
|
||||
"requested_capabilities": list(self.requested_capabilities), "risk": self.risk,
|
||||
"execution_digest": self.execution_digest.lower(), "generation": self.generation,
|
||||
"event_id": self.event_id, "version": self.version,
|
||||
}
|
||||
if self.admission_task_id:
|
||||
value.update(admission_task_id=self.admission_task_id,
|
||||
admission_idempotency_key=self.admission_idempotency_key)
|
||||
return value
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.admission_task_id or "release-task-" + _digest(self.as_dict())[:32]
|
||||
|
||||
@property
|
||||
def idempotency_key(self) -> str:
|
||||
return self.admission_idempotency_key or "release-intake-" + _digest({"task_id": self.task_id, "execution_digest": self.execution_digest.lower()})[:32]
|
||||
|
||||
def admission_request(self) -> dict[str, Any]:
|
||||
"""Return the sole normalized request accepted by Task admission."""
|
||||
return {"idempotency_key": self.idempotency_key, "task_id": self.task_id,
|
||||
"repository": self.repository, "authority_base_oid": self.authority_base_oid,
|
||||
"target_branch": self.target_branch, "generation": self.generation,
|
||||
"execution_digest": self.execution_digest, "requested_capabilities": list(self.requested_capabilities),
|
||||
"file_allowlist": list(self.file_allowlist), "risk": self.risk}
|
||||
|
||||
|
||||
def _error_code(exc: BaseException) -> str:
|
||||
code = getattr(exc, "code", None) or getattr(exc, "status", None) or exc.__class__.__name__
|
||||
value = str(code).upper()
|
||||
return value if re.fullmatch(r"[A-Z0-9_]{1,100}", value) else "BACKEND_ERROR"
|
||||
|
||||
|
||||
def _bound_receipt(receipt: Any, intent: SourcePublicationIntent) -> bool:
|
||||
if not isinstance(receipt, Mapping):
|
||||
return False
|
||||
expected = {"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest}
|
||||
return (receipt.get("schema") == "aios.task-admission-receipt.v1"
|
||||
and all(_canon(receipt.get(k)) == _canon(v) for k, v in expected.items())
|
||||
and receipt.get("task_state") in {"QUEUED", "CLAIMED", "RUNNING"}
|
||||
and all(isinstance(receipt.get(k), str) and receipt[k] for k in ("envelope_id", "grant_id")))
|
||||
|
||||
|
||||
def _retryable(exc: BaseException) -> bool:
|
||||
status = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) or getattr(exc, "status", None)
|
||||
if isinstance(status, int):
|
||||
return status in RETRYABLE_CODES
|
||||
code = _error_code(exc)
|
||||
return code in RETRYABLE_NAMES
|
||||
|
||||
|
||||
def _unknown_outcome(exc: BaseException) -> bool:
|
||||
return isinstance(exc, (TimeoutError, ConnectionError, OSError)) or _error_code(exc) in {"TASK_ADMISSION_TASK_STATE_UNKNOWN", "TIMEOUT", "TIMED_OUT", "CONNECTION_LOST"}
|
||||
|
||||
|
||||
class GovernedUnattendedReleaseRunner:
|
||||
"""Phase-separated runner; preparation has no release/deployment rights."""
|
||||
|
||||
def __init__(self, *, admission: Any, release_service: Any = None,
|
||||
publication_adapter: Any = None, owner_approval_broker: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None,
|
||||
deployment_enabled: bool = False, deployment_service: Any = None,
|
||||
max_attempts: int = 3, deadline_seconds: float = 30.0,
|
||||
environment_mode: str = "SIMULATION",
|
||||
clock: Callable[[], float] = time.monotonic, sleep: Callable[[float], None] = time.sleep):
|
||||
if admission is None:
|
||||
raise ValueError("admission is required")
|
||||
if max_attempts < 1 or max_attempts > 10 or deadline_seconds <= 0:
|
||||
raise ValueError("invalid retry bounds")
|
||||
self.admission = admission
|
||||
self.environment_mode = str(environment_mode).upper()
|
||||
if self.environment_mode not in {"SIMULATION", "AUTHORITY"}:
|
||||
raise ValueError("environment_mode must be SIMULATION or AUTHORITY")
|
||||
self.release_service = release_service
|
||||
self.publication_adapter = publication_adapter
|
||||
self.owner_approval_broker = owner_approval_broker
|
||||
self.deployment_enabled = bool(deployment_enabled)
|
||||
self.deployment_service = deployment_service
|
||||
self.max_attempts = max_attempts
|
||||
self.deadline_seconds = deadline_seconds
|
||||
self.clock, self.sleep = clock, sleep
|
||||
|
||||
def intake(self, intent: SourcePublicationIntent) -> dict[str, Any]:
|
||||
if not isinstance(intent, SourcePublicationIntent):
|
||||
raise UnattendedReleaseError("INTENT_TYPE_REQUIRED")
|
||||
return {"phase": "PREPARATION", "mode": self.environment_mode, "task_id": intent.task_id,
|
||||
"idempotency_key": intent.idempotency_key, "intent": intent.as_dict(),
|
||||
"release_rights": False, "deployment_rights": False}
|
||||
|
||||
def admit(self, intent: SourcePublicationIntent, authorization: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(authorization, Mapping):
|
||||
raise UnattendedReleaseError("AUTHORIZATION_REQUIRED")
|
||||
if self.environment_mode == "SIMULATION" and getattr(self.admission, "simulation_only", False) is not True:
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "task_id": intent.task_id, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
raw = intent.admission_request()
|
||||
deadline = self.clock() + self.deadline_seconds
|
||||
envelope_id = None
|
||||
for attempt in range(1, self.max_attempts + 1):
|
||||
if self.clock() >= deadline:
|
||||
return {"phase": "RECONCILIATION_REQUIRED" if envelope_id else "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": "ADMISSION_DEADLINE_EXCEEDED"}
|
||||
try:
|
||||
created = self.admission.create_envelope(raw)
|
||||
envelope = created.get("envelope", created) if isinstance(created, Mapping) else {}
|
||||
envelope_id = envelope.get("envelope_id") or envelope_id
|
||||
if not isinstance(envelope_id, str) or not envelope_id:
|
||||
raise UnattendedReleaseError("ENVELOPE_BINDING_MISSING")
|
||||
auth = dict(authorization)
|
||||
auth["envelope_id"] = envelope_id
|
||||
if self.clock() >= deadline:
|
||||
return self._reconcile(envelope_id, intent, "ADMISSION_DEADLINE_EXCEEDED", authorization)
|
||||
receipt = self.admission.authorize_and_admit(auth)
|
||||
if not _bound_receipt(receipt, intent) or receipt["envelope_id"] != envelope_id:
|
||||
raise UnattendedReleaseError("ADMISSION_RECEIPT_BINDING_MISMATCH")
|
||||
if self.clock() >= deadline:
|
||||
return self._reconcile(envelope_id, intent, "ADMISSION_DEADLINE_EXCEEDED", authorization)
|
||||
return {"phase": "ADMITTED", "mode": self.environment_mode, "attempt": attempt,
|
||||
"envelope": created, "receipt": receipt}
|
||||
except Exception as exc:
|
||||
status_code = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) or getattr(exc, "status", None)
|
||||
if status_code == 403 or _error_code(exc) in {"403", "FORBIDDEN"}:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": _error_code(exc)}
|
||||
# Once authorization may have run, every failure reconciles.
|
||||
# A 5xx is never sufficient proof of NOT_APPLIED.
|
||||
if envelope_id:
|
||||
return self._reconcile(envelope_id, intent, _error_code(exc), authorization)
|
||||
if not _retryable(exc) or self.clock() >= deadline or attempt >= self.max_attempts:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": _error_code(exc)}
|
||||
self.sleep(min(0.05 * (2 ** (attempt - 1)), max(0.0, deadline - self.clock())))
|
||||
raise UnattendedReleaseError("ADMISSION_RETRY_EXHAUSTED")
|
||||
|
||||
def _reconcile(self, envelope_id: str | None, intent: SourcePublicationIntent, error: str,
|
||||
authorization: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not envelope_id:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": error}
|
||||
try:
|
||||
reconcile = getattr(self.admission, "reconcile_status", None)
|
||||
status = reconcile(envelope_id, authorization) if callable(reconcile) else self.admission.status(envelope_id)
|
||||
except Exception as exc:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": error, "status_error": _error_code(exc)}
|
||||
state = str(status.get("status") or status.get("task_state") or "").upper() if isinstance(status, Mapping) else ""
|
||||
if state in {"NOT_APPLIED", "ABSENT", "NOT_FOUND"}:
|
||||
return {"phase": "RETRYABLE_RECONCILIATION", "mode": "AUTHORITY", "task_id": intent.task_id, "status": status, "error": error}
|
||||
if state == "ADMITTED" and _bound_receipt(status.get("task"), intent):
|
||||
return {"phase": "RECONCILED", "mode": self.environment_mode, "task_id": intent.task_id, "status": status, "receipt": status["task"]}
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "status": status, "error": error}
|
||||
|
||||
def publish(self, intent: SourcePublicationIntent, admission_receipt: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.environment_mode != "AUTHORITY":
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
if self.publication_adapter is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_BACKEND_MISSING"}
|
||||
required = ("task_lease", "task_principal", "writer_lease", "commit_receipt", "source_oid", "tree_oid", "authenticated")
|
||||
try:
|
||||
if not _bound_receipt(admission_receipt, intent):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "ADMISSION_RECEIPT_BINDING_MISMATCH"}
|
||||
result = self.publication_adapter.publish(intent, admission_receipt)
|
||||
if not isinstance(result, Mapping) or result.get("authenticated") is not True or any(not result.get(k) for k in required[:-1]):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_LEASE_CHAIN_MISSING"}
|
||||
if any(not isinstance(result[k], Mapping) or result[k].get("task_id") != intent.task_id
|
||||
for k in ("task_lease", "task_principal", "writer_lease")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_LEASE_BINDING_MISMATCH"}
|
||||
commit = result["commit_receipt"]
|
||||
# Existing continuation receipts carry remote OIDs, while the
|
||||
# authenticated task/job readback carries scope. Preserve both
|
||||
# originals; never fabricate missing fields inside a receipt.
|
||||
optional_bindings = {"task_id": intent.task_id, "tree_oid": result["tree_oid"],
|
||||
"execution_digest": intent.execution_digest,
|
||||
"target_branch": intent.target_branch,
|
||||
"base_oid": intent.authority_base_oid}
|
||||
if (not isinstance(commit, Mapping) or commit.get("commit_oid") != result["source_oid"]
|
||||
or any(key in commit and commit[key] != value for key, value in optional_bindings.items())
|
||||
or result.get("generation") != intent.generation):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_COMMIT_BINDING_MISMATCH"}
|
||||
if not OID_RE.fullmatch(str(result["source_oid"])) or str(result["tree_oid"]).lower() != intent.source_tree_oid.lower() or any(str(result.get(k, "")) != str(v) for k, v in (("task_id", intent.task_id), ("authority_base_oid", intent.authority_base_oid), ("target_branch", intent.target_branch), ("execution_digest", intent.execution_digest))):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_RC_BINDING_MISMATCH"}
|
||||
return {"phase": "PUBLISHED", "mode": "AUTHORITY", "readback": dict(result)}
|
||||
except Exception as exc:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
|
||||
def postpublication_rc(self, *, event_id: str, source_commit: str, version: str = "", source_readback: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
if self.environment_mode != "AUTHORITY":
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
if not isinstance(source_readback, Mapping) or source_readback.get("authenticated") is not True or str(source_readback.get("source_commit", "")).lower() != str(source_commit).lower():
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "SOURCE_READBACK_REQUIRED"}
|
||||
if self.release_service is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "RELEASE_BACKEND_MISSING"}
|
||||
try:
|
||||
# Only a host-injected authenticated resolver can admit a source.
|
||||
# The caller's readback object alone is never authorization.
|
||||
resolver = getattr(self.release_service, "source_admission_resolver", None)
|
||||
admitted_source = resolver(source_commit) if callable(resolver) else None
|
||||
if (not isinstance(admitted_source, Mapping) or not admitted_source.get("admission_id")
|
||||
or admitted_source.get("source_commit") != source_commit
|
||||
or admitted_source.get("tree_oid") != source_readback.get("tree_oid")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "AUTHENTICATED_SOURCE_ADMISSION_REQUIRED"}
|
||||
intent = self.release_service.create_release_intent({"event_id": event_id, "source_commit": source_commit, **({"version": version} if version else {})})
|
||||
candidate_id = str(intent.get("candidate_id") or intent.get("candidate", {}).get("candidate_id") or "")
|
||||
if not candidate_id:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "CANDIDATE_BINDING_MISSING"}
|
||||
readers = ("get_release_candidate", "get_release_plan", "get_acceptance")
|
||||
if any(not callable(getattr(self.release_service, name, None)) for name in readers):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "RC_READBACK_BACKEND_MISSING"}
|
||||
candidate = self.release_service.get_release_candidate(candidate_id)
|
||||
plan = self.release_service.get_release_plan(candidate_id)
|
||||
acceptance = self.release_service.get_acceptance(candidate_id)
|
||||
deployment = plan.get("deployment") if isinstance(plan, Mapping) else None
|
||||
if (not isinstance(candidate, Mapping) or not isinstance(plan, Mapping) or not isinstance(deployment, Mapping) or not isinstance(acceptance, Mapping)
|
||||
or str(candidate.get("candidate_id")) != candidate_id
|
||||
or str(candidate.get("source_commit")) .lower() != str(source_commit).lower()
|
||||
or str(plan.get("candidate_id", candidate_id)) != candidate_id
|
||||
or str(deployment.get("source_commit")) .lower() != str(source_commit).lower()
|
||||
or str(deployment.get("tree_oid", "")).lower() != str(source_readback.get("tree_oid", "")).lower()
|
||||
or candidate.get("tree_oid") != deployment.get("tree_oid")
|
||||
or candidate.get("artifact_sha256") != deployment.get("artifact_sha256")
|
||||
or candidate.get("manifest_sha256") != deployment.get("manifest_sha256")
|
||||
or str(acceptance.get("candidate_id")) != candidate_id
|
||||
or acceptance.get("release_plan_sha256") != plan.get("sha256")
|
||||
or str(acceptance.get("status")) != "ACCEPTED"):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "RC_READBACK_BINDING_MISMATCH"}
|
||||
# Public readback is redacted. Reverify original signed bytes inside
|
||||
# the existing service, not by reconstructing them from API output.
|
||||
verify_acceptance = getattr(self.release_service, "verify_release_acceptance", None)
|
||||
if not callable(verify_acceptance):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "BUILDER_ATTESTATION_REQUIRED"}
|
||||
verified = verify_acceptance(candidate_id)
|
||||
if (not isinstance(verified, Mapping) or verified.get("status") != "VERIFIED"
|
||||
or verified.get("candidate_id") != candidate_id
|
||||
or verified.get("release_plan_sha256") != plan.get("sha256")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "BUILDER_ATTESTATION_NOT_VERIFIED"}
|
||||
envelope = self.release_service.get_approval_envelope(candidate_id)
|
||||
if self.owner_approval_broker is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "OWNER_APPROVAL_BACKEND_MISSING"}
|
||||
approval = self.owner_approval_broker(dict(envelope))
|
||||
if not isinstance(approval, Mapping):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "OWNER_APPROVAL_MISSING"}
|
||||
accepted = self.release_service.submit_owner_approval(dict(approval))
|
||||
if not isinstance(accepted, Mapping) or accepted.get("status") != "VERIFIED_AND_STORED":
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "APPROVAL_NOT_VERIFIED"}
|
||||
return {"phase": "APPROVED", "mode": "AUTHORITY", "candidate_id": candidate_id,
|
||||
"readback": {"candidate": dict(candidate), "plan": dict(plan), "acceptance": dict(acceptance)},
|
||||
"approval": accepted}
|
||||
except Exception as exc:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
|
||||
def deploy(self, candidate_id: str) -> dict[str, Any]:
|
||||
if (self.environment_mode != "AUTHORITY" or not self.deployment_enabled or self.deployment_service is None
|
||||
or getattr(self.deployment_service, "execution_enabled", False) is not True):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "DEPLOYMENT_NOT_ENABLED"}
|
||||
try:
|
||||
result = self.deployment_service.start_deployment(candidate_id)
|
||||
saga = self.deployment_service.get_release_saga(candidate_id)
|
||||
if (not isinstance(result, Mapping) or result.get("candidate_id") != candidate_id
|
||||
or result.get("state") != "COMPLETED" or result.get("status") not in {"VERIFIED", "IDEMPOTENT"}
|
||||
or not isinstance(saga, Mapping) or saga.get("state") != "COMPLETED"):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "DEPLOYMENT_NOT_VERIFIED", "result": dict(result) if isinstance(result, Mapping) else {}}
|
||||
return {"phase": "DEPLOYED", "mode": "AUTHORITY", "result": dict(result)}
|
||||
except Exception as exc:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Local adapter for the root-owned registered Owner signer service.
|
||||
|
||||
The adapter intentionally has no public arbitrary-payload signing method.
|
||||
``LocalOwnerAuthority`` calls the private typed seam after validating and
|
||||
canonicalizing a structured Owner authorization object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
DEFAULT_REGISTERED_SIGNER_COMMAND = Path("/usr/local/libexec/aios-grant-signer-adapter")
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
MAX_SIGNATURE_BYTES = 128 * 1024
|
||||
MAX_KEY_ID_BYTES = 512
|
||||
|
||||
|
||||
class RegisteredOwnerSignerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RegisteredOwnerSignerAdapter:
|
||||
"""Invoke the registered signer without reading or copying private keys."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command: Path | str = DEFAULT_REGISTERED_SIGNER_COMMAND,
|
||||
*,
|
||||
key_id: str,
|
||||
owner_verifier: Any,
|
||||
require_root: bool = True,
|
||||
test_mode: bool = False,
|
||||
runner: Callable[..., Any] = subprocess.run,
|
||||
) -> None:
|
||||
self.command = str(command)
|
||||
self.key_id = key_id
|
||||
self.owner_verifier = owner_verifier
|
||||
self.require_root = require_root
|
||||
self.test_mode = test_mode
|
||||
self.runner = runner
|
||||
if not isinstance(key_id, str) or not key_id:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_KEY_ID_REQUIRED")
|
||||
if not test_mode:
|
||||
self._validate_command()
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.key_id
|
||||
|
||||
def _validate_command(self) -> None:
|
||||
path = Path(self.command)
|
||||
if not path.is_absolute() or path.is_symlink() or not path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_INVALID")
|
||||
st = path.stat()
|
||||
if not hasattr(os, "geteuid") or st.st_uid != os.geteuid():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_OWNER_INVALID")
|
||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_PERMISSIONS_INVALID")
|
||||
|
||||
def _read_output(self, path: Path, maximum: int) -> bytes:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_INVALID")
|
||||
st = path.stat()
|
||||
if not self.test_mode and (not hasattr(os, "geteuid") or st.st_uid != os.geteuid()):
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_OWNER_INVALID")
|
||||
if st.st_size > maximum:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_TOO_LARGE")
|
||||
return path.read_bytes()
|
||||
|
||||
def _sign_canonical_payload(self, namespace: str, payload: bytes) -> str:
|
||||
"""Private seam used only by typed ``LocalOwnerAuthority`` issuance."""
|
||||
if namespace != OWNER_NAMESPACE:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_NAMESPACE_INVALID")
|
||||
if not isinstance(payload, bytes) or not payload:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_PAYLOAD_INVALID")
|
||||
if self.require_root:
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_ROOT_REQUIRED")
|
||||
if not self.test_mode:
|
||||
self._validate_command()
|
||||
with tempfile.TemporaryDirectory(prefix="aios-owner-sign-") as directory:
|
||||
root = Path(directory)
|
||||
input_path = root / "payload"
|
||||
signature_path = root / "payload.sig"
|
||||
key_id_path = root / "key-id"
|
||||
input_path.write_bytes(payload)
|
||||
try:
|
||||
result = self.runner(
|
||||
[
|
||||
self.command,
|
||||
"sign",
|
||||
"--namespace",
|
||||
namespace,
|
||||
"--input",
|
||||
str(input_path),
|
||||
"--signature-out",
|
||||
str(signature_path),
|
||||
"--key-id-out",
|
||||
str(key_id_path),
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_FAILED") from exc
|
||||
if getattr(result, "returncode", None) != 0:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_FAILED")
|
||||
if not signature_path.is_file() or not key_id_path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_MISSING")
|
||||
supplied_key_id = self._read_output(key_id_path, MAX_KEY_ID_BYTES).decode("utf-8").strip()
|
||||
if supplied_key_id != self.key_id:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_KEY_ID_MISMATCH")
|
||||
signature = self._read_output(signature_path, MAX_SIGNATURE_BYTES).decode("utf-8")
|
||||
if not signature.startswith("-----BEGIN SSH SIGNATURE-----"):
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_SIGNATURE_INVALID")
|
||||
try:
|
||||
verified = self.owner_verifier.verify(
|
||||
namespace=namespace,
|
||||
payload=payload,
|
||||
signature=signature,
|
||||
key_id=self.key_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_VERIFY_FAILED") from exc
|
||||
if verified is not True:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_VERIFY_REJECTED")
|
||||
return signature
|
||||
@@ -4203,6 +4203,32 @@ class SilentReleaseActionService:
|
||||
"side_effects_executed": False,
|
||||
}
|
||||
|
||||
def verify_release_acceptance(self, candidate_id: str) -> dict[str, Any]:
|
||||
"""Reverify durable originals without exposing signed data via the API."""
|
||||
if self.builder_attestation_verifier is None:
|
||||
raise CapabilityAuthorizationError("builder verifier is required")
|
||||
candidate = self.candidates.load(candidate_id)
|
||||
plan = self.release_plans.load(candidate_id)
|
||||
record = json.loads(
|
||||
_safe_child(self.acceptance_root, candidate_id).read_text(encoding="utf-8")
|
||||
)
|
||||
if (record.get("candidate_id") != candidate_id
|
||||
or record.get("release_plan_sha256") != plan.sha256
|
||||
or record.get("status") != "ACCEPTED"
|
||||
or not isinstance(record.get("attestation"), Mapping)):
|
||||
raise CapabilityAuthorizationError("acceptance binding is invalid")
|
||||
attestation = BuilderAttestation.from_dict(
|
||||
record["attestation"], require_fingerprint=True
|
||||
)
|
||||
self.builder_attestation_verifier.verify(attestation, candidate, plan)
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"candidate_fingerprint": candidate.fingerprint,
|
||||
"release_plan_sha256": plan.sha256,
|
||||
"attestation_fingerprint": attestation.fingerprint,
|
||||
"status": "VERIFIED",
|
||||
}
|
||||
|
||||
def get_capability_status(
|
||||
self, candidate_id: str
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Exact source-only admission composition within the existing Bridge.
|
||||
|
||||
This module neither installs itself nor issues authority. A host activation must
|
||||
pin an Owner-signed policy and an independently verified epoch. The existing
|
||||
Task admission store, V3 producer, Task Plane and principal verifier are reused.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
SignedArtifact, canonical_json_bytes, _open_secure,
|
||||
)
|
||||
from aios_task_admission_bootstrap import TaskAdmissionError
|
||||
from aios_task_admission_bridge_contract import TaskAdmissionBridgeContract
|
||||
from aios_task_admission_live_adapters import TaskAdmissionRuntimeBundle
|
||||
from governed_source_publication_policy import (
|
||||
ScopedSourcePublicationPolicyAdmission, SourcePublicationPolicyVerifier,
|
||||
)
|
||||
|
||||
|
||||
def _read_object(path: Path) -> tuple[bytes, dict[str, Any]]:
|
||||
raw = _open_secure(path, private=False, require_root_owner=True, maximum=1024 * 1024)
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("SOURCE_ACTIVATION_OBJECT_REQUIRED")
|
||||
return raw, value
|
||||
|
||||
|
||||
class ScopedPrincipalProvider:
|
||||
"""Policy revocation also fences principals from already admitted Tasks."""
|
||||
|
||||
def __init__(self, guarded: ScopedSourcePublicationPolicyAdmission, delegate: Any):
|
||||
self.guarded = guarded
|
||||
self.delegate = delegate
|
||||
|
||||
def readiness(self) -> Mapping[str, Any]:
|
||||
try:
|
||||
self.guarded.verifier.require(self.guarded.artifact, self.guarded.request)
|
||||
return self.delegate.readiness()
|
||||
except Exception as exc:
|
||||
return {"ready": False, "mode": "source-publication-only", "error": str(exc)}
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]) -> Any:
|
||||
grant = self.delegate.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
if grant is None or grant.parent_epoch_id != self.delegate.epoch_id:
|
||||
raise TaskAdmissionError("SOURCE_PRINCIPAL_GRANT_REQUIRED", 403)
|
||||
# status rechecks the signed policy, exact durable envelope and original
|
||||
# signed epoch. The delegate then verifies the live TaskLease/state.
|
||||
self.guarded.status({"envelope_id": grant.envelope_id,
|
||||
"delegation_epoch_id": grant.parent_epoch_id})
|
||||
return self.delegate(transport, request)
|
||||
|
||||
|
||||
class _AdmissionContext:
|
||||
"""Metadata only for legacy hosts retaining their signed principal verifier."""
|
||||
def __init__(self, bundle):
|
||||
self.store, self.authorization = bundle.store, bundle.authorization
|
||||
self.task_port, self.epoch_id = bundle.task_port, bundle.epoch_id
|
||||
|
||||
def readiness(self):
|
||||
return {"ready": False, "error": "REGISTERED_SIGNED_PRINCIPAL_VERIFIER_REQUIRED"}
|
||||
|
||||
def __call__(self, *_args, **_kwargs):
|
||||
raise TaskAdmissionError("REGISTERED_SIGNED_PRINCIPAL_VERIFIER_REQUIRED", 403)
|
||||
|
||||
|
||||
def guard_source_runtime(
|
||||
runtime: TaskAdmissionRuntimeBundle, *, artifact: SignedArtifact,
|
||||
verifier: SourcePublicationPolicyVerifier, expected_agent_id: str,
|
||||
) -> TaskAdmissionRuntimeBundle:
|
||||
service = runtime.service
|
||||
if service is None:
|
||||
raise ValueError("SOURCE_ACTIVATION_BOOTSTRAP_REQUIRED")
|
||||
guarded = ScopedSourcePublicationPolicyAdmission(
|
||||
verifier, bootstrap=service, artifact=artifact,
|
||||
expected_agent_id=expected_agent_id,
|
||||
)
|
||||
# Build the typed request through the same exact-envelope guard used by the
|
||||
# HTTP contract, instead of accepting a caller-supplied request projection.
|
||||
payload = artifact.payload
|
||||
request = guarded._request_for_raw({
|
||||
"repository": payload["repository"],
|
||||
"authority_base_oid": payload["authority_base_oid"],
|
||||
"target_branch": payload["target_branch"], "generation": payload["generation"],
|
||||
"execution_digest": payload["execution_digest"], "risk": payload["risk"],
|
||||
"file_allowlist": payload["exact_files"], "requested_capabilities": payload["capabilities"],
|
||||
})
|
||||
guarded.request = request
|
||||
verifier.require(artifact, request)
|
||||
principal = runtime.principal_verifier or _AdmissionContext(runtime)
|
||||
epoch = principal.authorization.verify_delegation_epoch(principal.epoch_id)
|
||||
# The complete per-envelope check runs again on authorization and every
|
||||
# principal use; reject a production-capable epoch already at composition.
|
||||
if (getattr(epoch, "production_scope", None) != "none"
|
||||
or getattr(epoch, "destructive_policy", None) != "forbidden"):
|
||||
raise TaskAdmissionError("SOURCE_ACTIVATION_EPOCH_SCOPE_DENIED", 403)
|
||||
return TaskAdmissionRuntimeBundle(
|
||||
handlers=TaskAdmissionBridgeContract(guarded).handlers(),
|
||||
principal_verifier=ScopedPrincipalProvider(guarded, principal),
|
||||
service=guarded,
|
||||
)
|
||||
|
||||
|
||||
def build_source_runtime_from_activation_file(path: Path, *, post_admission: Any,
|
||||
ordinary_activation_file: Path):
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
load_owner_approval_verifier,
|
||||
)
|
||||
_, config = _read_object(path)
|
||||
if set(config) != {"schema", "policy_artifact", "policy_sha256", "policy_revocations",
|
||||
"admission_activation", "expected_agent_id"} or config["schema"] != "aios.source-publication-host-activation.v1":
|
||||
raise ValueError("SOURCE_ACTIVATION_FIELDS_INVALID")
|
||||
for key in ("policy_artifact", "policy_revocations", "admission_activation"):
|
||||
value = config[key]
|
||||
if not isinstance(value, str) or not value.startswith("/") or ".." in Path(value).parts:
|
||||
raise ValueError("SOURCE_ACTIVATION_PATH_INVALID")
|
||||
raw, signed = _read_object(Path(config["policy_artifact"]))
|
||||
if hashlib.sha256(raw).hexdigest() != config["policy_sha256"]:
|
||||
raise ValueError("SOURCE_ACTIVATION_POLICY_HASH_MISMATCH")
|
||||
if set(signed) != {"payload", "signature", "key_id", "namespace"}:
|
||||
raise ValueError("SOURCE_ACTIVATION_ARTIFACT_INVALID")
|
||||
artifact = SignedArtifact(canonical_payload=canonical_json_bytes(signed["payload"]), **signed)
|
||||
_, ordinary_config = _read_object(ordinary_activation_file)
|
||||
_, source_config = _read_object(Path(config["admission_activation"]))
|
||||
for field in ("state_db", "task_api_url", "task_control_token_file", "owner_verifier_module"):
|
||||
if not ordinary_config.get(field) or source_config.get(field) != ordinary_config[field]:
|
||||
raise ValueError("SOURCE_ACTIVATION_CONTROL_PLANE_BINDING_MISMATCH")
|
||||
|
||||
def not_revoked(payload: Mapping[str, Any]) -> bool:
|
||||
_, value = _read_object(Path(config["policy_revocations"]))
|
||||
if (set(value) != {"schema", "revoked_nonces"}
|
||||
or value["schema"] != "aios.source-publication-revocations.v1"
|
||||
or not isinstance(value["revoked_nonces"], list)
|
||||
or any(not isinstance(nonce, str) or not nonce for nonce in value["revoked_nonces"])):
|
||||
return False
|
||||
return payload["nonce"] not in value["revoked_nonces"]
|
||||
|
||||
runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(config["admission_activation"]), post_admission=post_admission)
|
||||
verifier = SourcePublicationPolicyVerifier(
|
||||
load_owner_approval_verifier(Path(source_config["owner_verifier_module"])),
|
||||
revocation_check=not_revoked,
|
||||
expected_policy_digest=runtime.service.policy.normalized_policy_digest(),
|
||||
)
|
||||
guarded_runtime = guard_source_runtime(runtime, artifact=artifact, verifier=verifier,
|
||||
expected_agent_id=config["expected_agent_id"])
|
||||
payload = artifact.payload
|
||||
plan_path = Path(source_config["execution_plan_dir"]) / (payload["execution_digest"] + ".json")
|
||||
plan_bytes, plan = _read_object(plan_path)
|
||||
if (hashlib.sha256(plan_bytes).hexdigest() != payload["execution_digest"]
|
||||
or canonical_json_bytes(plan) != plan_bytes):
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_DIGEST_MISMATCH")
|
||||
hashes = plan.get("file_sha256")
|
||||
if not isinstance(hashes, dict) or set(hashes) != set(payload["exact_files"]):
|
||||
raise ValueError("SOURCE_ACTIVATION_FILE_HASH_SET_MISMATCH")
|
||||
for field in ("repository", "authority_base_oid", "target_branch", "generation", "risk"):
|
||||
if plan.get(field) != payload[field]:
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_SCOPE_MISMATCH")
|
||||
if sorted(plan.get("capabilities", [])) != sorted(payload["capabilities"]):
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_CAPABILITIES_MISMATCH")
|
||||
if not isinstance(plan.get("task_id"), str) or not plan["task_id"]:
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_TASK_REQUIRED")
|
||||
guarded_runtime.service.expected_file_sha256 = dict(hashes)
|
||||
guarded_runtime.service.expected_task_id = plan["task_id"]
|
||||
return guarded_runtime
|
||||
|
||||
|
||||
class AdmissionRuntimeRouter:
|
||||
"""Route an exact branch/epoch without widening the ordinary policy."""
|
||||
|
||||
def __init__(self, ordinary: TaskAdmissionRuntimeBundle, source: TaskAdmissionRuntimeBundle):
|
||||
self.ordinary = ordinary
|
||||
self.source = source
|
||||
self.guard = source.service
|
||||
self.store = self.guard.bootstrap.store
|
||||
|
||||
def _source_request(self, operation: str, request: Mapping[str, Any]) -> bool:
|
||||
if operation == "getTaskAdmissionEnvelope":
|
||||
return request.get("target_branch") == self.guard.artifact.payload["target_branch"]
|
||||
envelope_id = str(request.get("envelope_id") or "")
|
||||
row = self.store.get_envelope(envelope_id)
|
||||
if row is None:
|
||||
# Never fall through after finding a source envelope, including
|
||||
# when it has expired or been revoked.
|
||||
return False
|
||||
envelope = self.guard.bootstrap._load_envelope(envelope_id)
|
||||
return envelope.target_branch == self.guard.artifact.payload["target_branch"]
|
||||
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
def dispatch(operation: str, request: Mapping[str, Any]):
|
||||
runtime = self.source if self._source_request(operation, request) else self.ordinary
|
||||
return runtime.handlers[operation](request)
|
||||
return {op: (lambda request, op=op: dispatch(op, request)) for op in self.source.handlers}
|
||||
|
||||
def readiness(self) -> Mapping[str, Any]:
|
||||
return {"mode": "scoped-admission-router", "ordinary": self.ordinary.principal_verifier.readiness(),
|
||||
"source_publication": self.source.principal_verifier.readiness()}
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]):
|
||||
grant = self.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
provider = (self.source.principal_verifier if grant is not None
|
||||
and grant.target_branch == self.guard.artifact.payload["target_branch"]
|
||||
else self.ordinary.principal_verifier)
|
||||
return provider(transport, request)
|
||||
|
||||
def bundle(self) -> TaskAdmissionRuntimeBundle:
|
||||
return TaskAdmissionRuntimeBundle(handlers=self.handlers(), principal_verifier=self)
|
||||
|
||||
|
||||
class ExistingPrincipalPolicyFence:
|
||||
"""Add a C5 policy fence while retaining the registered signed verifier.
|
||||
|
||||
Layered staging hosts use a signed TaskPrincipal verifier with their own
|
||||
storage scope. They must not be migrated to a server attestation merely to
|
||||
add source admission. Every request still reaches the original verifier.
|
||||
"""
|
||||
|
||||
def __init__(self, delegate: Any, source_runtime: TaskAdmissionRuntimeBundle):
|
||||
self.delegate = delegate
|
||||
self.guard = source_runtime.service
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(self.delegate, name)
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]):
|
||||
grant = self.guard.bootstrap.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
if grant is not None and grant.target_branch == self.guard.artifact.payload["target_branch"]:
|
||||
self.guard.status({"envelope_id": grant.envelope_id,
|
||||
"delegation_epoch_id": grant.parent_epoch_id})
|
||||
if transport.get("operation") in {"commitFilesGoverned", "commitFileGoverned"}:
|
||||
expected = getattr(self.guard, "expected_file_sha256", None)
|
||||
if not isinstance(expected, dict) or not expected:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_FILE_HASHES_REQUIRED", 403)
|
||||
try:
|
||||
raw = request.get("files")
|
||||
if raw is None:
|
||||
raw = {request["path"]: request["content_base64"]}
|
||||
pairs = list(raw.items()) if isinstance(raw, dict) else [(item["path"], item["content_base64"]) for item in raw]
|
||||
if len({path for path, _ in pairs}) != len(pairs):
|
||||
raise ValueError("duplicate path")
|
||||
actual = {path: hashlib.sha256(base64.b64decode(data, validate=True)).hexdigest()
|
||||
for path, data in pairs}
|
||||
except Exception as exc:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_FILE_PAYLOAD_INVALID", 403) from exc
|
||||
if actual != expected:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_CONTENT_SCOPE_MISMATCH", 403)
|
||||
return self.delegate(transport, request)
|
||||
|
||||
|
||||
def expired_admission_runtime() -> TaskAdmissionRuntimeBundle:
|
||||
"""Keep a verified-expired ordinary epoch denied while serving other routes."""
|
||||
def deny(*_args: Any, **_kwargs: Any):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_EXPIRED", 403)
|
||||
|
||||
class ClosedPrincipal:
|
||||
__call__ = staticmethod(deny)
|
||||
|
||||
def readiness(self):
|
||||
return {"ready": False, "error": "TASK_ADMISSION_EPOCH_EXPIRED"}
|
||||
|
||||
from aios_task_admission_bridge_contract import TASK_ADMISSION_OPERATION_REQUIRED_FIELDS
|
||||
return TaskAdmissionRuntimeBundle(
|
||||
handlers={op: deny for op in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS},
|
||||
principal_verifier=ClosedPrincipal(),
|
||||
)
|
||||
@@ -105,6 +105,28 @@ pre { white-space: pre-wrap; word-break: break-word; }
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Security: all dynamic values (API/git/agent/owner-gate) are rendered with
|
||||
// textContent / DOM APIs. innerHTML is never used to inject untrusted data.
|
||||
function clearNode(node) {
|
||||
if (!node) return;
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
}
|
||||
function setText(id, text) {
|
||||
const n = document.getElementById(id);
|
||||
if (n) n.textContent = text;
|
||||
}
|
||||
function addClass(node, cls) { if (node) node.classList.add(cls); }
|
||||
function div(cls) { const d = document.createElement('div'); if (cls) addClass(d, cls); return d; }
|
||||
function span(cls, text) { const s = document.createElement('span'); if (cls) addClass(s, cls); if (text != null) s.textContent = text; return s; }
|
||||
|
||||
// Centralized CSRF token source: server-issued meta tag, or double-submit cookie.
|
||||
function csrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (meta && meta.content) return meta.content;
|
||||
const m = document.cookie.match(/(?:^|;\s*)aios_csrf=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
const r = await fetch('/admin/ai-evolution/api/data');
|
||||
const data = await r.json();
|
||||
@@ -124,130 +146,204 @@ async function fetchData() {
|
||||
|
||||
async function loadVersions() {
|
||||
const el = document.getElementById('versions-list');
|
||||
el.innerHTML = '<div class="loading">Loading versions...</div>';
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading versions...'; el.appendChild(loading);
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/versions');
|
||||
const data = await r.json();
|
||||
if (data.error) { el.innerHTML = '<div class="error">' + data.error + '</div>'; return; }
|
||||
let html = '<div class="grid">';
|
||||
clearNode(el);
|
||||
if (data.error) { const e = div('error'); e.textContent = data.error; el.appendChild(e); return; }
|
||||
const grid = div('grid');
|
||||
for (const v of data.versions || []) {
|
||||
html += '<div class="card"><div class="card-title">' + v.tag + '</div><div class="card-value">' + (v.version || v.tag) + '</div></div>';
|
||||
const card = div('card');
|
||||
const t = div('card-title'); t.textContent = v.tag || '';
|
||||
const val = div('card-value'); val.textContent = v.version || v.tag || '';
|
||||
card.appendChild(t); card.appendChild(val); grid.appendChild(card);
|
||||
}
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load versions: ' + e.message + '</div>';
|
||||
el.appendChild(grid);
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load versions: ' + e.message; el.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommands() {
|
||||
const el = document.getElementById('commands-list');
|
||||
el.innerHTML = '<div class="loading">Loading commands...</div>';
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading commands...'; el.appendChild(loading);
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/data');
|
||||
const data = await r.json();
|
||||
let cmds = data.commands || [];
|
||||
let html = '<div style="display:flex;gap:24px;margin-bottom:16px;">';
|
||||
html += '<div><div class="card"><div class="card-title">Total</div><div class="card-value">' + cmds.length + '</div></div></div>';
|
||||
html += '<div><div class="card"><div class="card-title">By Category</div>';
|
||||
const cmds = data.commands || [];
|
||||
clearNode(el);
|
||||
const wrapper = div('');
|
||||
wrapper.style.display = 'flex'; wrapper.style.gap = '24px'; wrapper.style.marginBottom = '16px';
|
||||
const totalCard = div('card');
|
||||
const tcTitle = div('card-title'); tcTitle.textContent = 'Total';
|
||||
const tcVal = div('card-value'); tcVal.textContent = String(cmds.length);
|
||||
totalCard.appendChild(tcTitle); totalCard.appendChild(tcVal); wrapper.appendChild(totalCard);
|
||||
const catCard = div('card');
|
||||
const ccTitle = div('card-title'); ccTitle.textContent = 'By Category';
|
||||
catCard.appendChild(ccTitle);
|
||||
const cats = {};
|
||||
for (const c of cmds) { const k = c.category || 'uncategorized'; cats[k] = (cats[k] || 0) + 1; }
|
||||
for (const [k, v] of Object.entries(cats)) html += '<div style="font-size:14px;color:#8b949e;margin:4px 0;">' + k + ': ' + v + '</div>';
|
||||
html += '</div></div></div>';
|
||||
for (const cmd of cmds.slice().reverse()) {
|
||||
html += '<div class="command-item"><div class="meta">' + cmd.category + ' | ' + cmd.filename + '</div><div class="name">' + cmd.title + '</div><div style="font-size:13px;color:#c9d1d9;margin-top:4px;">' + cmd.summary + '</div></div>';
|
||||
for (const [k, v] of Object.entries(cats)) {
|
||||
const line = div(''); line.style.fontSize = '14px'; line.style.color = '#8b949e'; line.style.margin = '4px 0';
|
||||
line.textContent = k + ': ' + v; catCard.appendChild(line);
|
||||
}
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load commands: ' + e.message + '</div>';
|
||||
wrapper.appendChild(catCard); el.appendChild(wrapper);
|
||||
for (const cmd of cmds.slice().reverse()) {
|
||||
const item = div('command-item');
|
||||
const meta = div('meta'); meta.textContent = (cmd.category || '') + ' | ' + (cmd.filename || '');
|
||||
const name = div('name'); name.textContent = cmd.title || '';
|
||||
const summary = div(''); summary.style.fontSize = '13px'; summary.style.color = '#c9d1d9'; summary.style.marginTop = '4px';
|
||||
summary.textContent = cmd.summary || '';
|
||||
item.appendChild(meta); item.appendChild(name); item.appendChild(summary);
|
||||
el.appendChild(item);
|
||||
}
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load commands: ' + e.message; el.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
const el = document.getElementById('agents-list');
|
||||
el.innerHTML = '<div class="loading">Loading agent runs...</div>';
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading agent runs...'; el.appendChild(loading);
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/agents');
|
||||
const data = await r.json();
|
||||
if (data.error) { el.innerHTML = '<div class="error">' + data.error + '</div>'; return; }
|
||||
let html = '';
|
||||
for (const agent of data.agents || []) {
|
||||
html += '<div class="agent-item"><div class="agent-title">' + agent.name + ' <span class="state state-' + (agent.status === 'running' ? 'running' : (agent.status === 'ok' ? 'completed' : 'failed')) + '">' + agent.status + '</span></div><div class="agent-meta">' + (agent.runs || 0) + ' runs | last: ' + (agent.last_run || '-') + '</div></div>';
|
||||
clearNode(el);
|
||||
if (data.error) { const e = div('error'); e.textContent = data.error; el.appendChild(e); return; }
|
||||
const agents = data.agents || [];
|
||||
if (!agents.length) {
|
||||
const none = div(''); none.style.padding = '24px'; none.style.color = '#8b949e';
|
||||
none.textContent = 'No agent runs recorded yet.'; el.appendChild(none); return;
|
||||
}
|
||||
el.innerHTML = html || '<div style="padding:24px;color:#8b949e;">No agent runs recorded yet.</div>';
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load agents: ' + e.message + '</div>';
|
||||
for (const agent of agents) {
|
||||
const item = div('agent-item');
|
||||
const title = div('agent-title');
|
||||
title.appendChild(document.createTextNode(agent.name || ''));
|
||||
const st = (agent.status === 'running') ? 'running' : ((agent.status === 'ok') ? 'completed' : 'failed');
|
||||
const badge = span('state state-' + st, agent.status || '');
|
||||
title.appendChild(badge);
|
||||
const meta = div('agent-meta');
|
||||
meta.textContent = (agent.runs || 0) + ' runs | last: ' + (agent.last_run || '-');
|
||||
item.appendChild(title); item.appendChild(meta); el.appendChild(item);
|
||||
}
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load agents: ' + e.message; el.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
function showPanel(name) {
|
||||
|
||||
async function loadOwnerGate() {
|
||||
const el = document.getElementById('arch-owner-content');
|
||||
el.innerHTML = '<div class="loading">Loading owner gate data...</div>';
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading owner gate data...'; el.appendChild(loading);
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/owner-gate');
|
||||
const data = await r.json();
|
||||
let html = '<div class="row">';
|
||||
html += '<div class="col"><div class="card"><div class="card-title">Pending Requests</div><div class="card-value">' + data.pending_count + '</div></div></div>';
|
||||
html += '<div class="col"><div class="card"><div class="card-title">Approved</div><div class="card-value">' + data.approved_count + '</div></div></div>';
|
||||
html += '<div class="col"><div class="card"><div class="card-title">Rejected</div><div class="card-value">' + data.rejected_count + '</div></div></div>';
|
||||
html += '</div>';
|
||||
html += '<div class="card"><div class="card-title">Pending Approval Requests</div>';
|
||||
if (data.pending_requests && data.pending_requests.length) {
|
||||
for (const req of data.pending_requests) {
|
||||
html += '<div class="command-item">';
|
||||
html += '<div class="name">' + (req.title || req.change_id) + '</div>';
|
||||
html += '<div class="meta">ID: ' + req.change_id + ' | Risk: ' + req.risk + '</div>';
|
||||
html += '<div style="font-size:13px;color:#c9d1d9;margin-top:4px;">' + (req.reason || '') + '</div>';
|
||||
html += '</div>';
|
||||
clearNode(el);
|
||||
const row = div('row');
|
||||
const cols = [['Pending Requests', data.pending_count], ['Approved', data.approved_count], ['Rejected', data.rejected_count]];
|
||||
for (const [label, value] of cols) {
|
||||
const col = div('col'); const card = div('card');
|
||||
const t = div('card-title'); t.textContent = label;
|
||||
const val = div('card-value'); val.textContent = value;
|
||||
card.appendChild(t); card.appendChild(val); col.appendChild(card); row.appendChild(col);
|
||||
}
|
||||
el.appendChild(row);
|
||||
const listCard = div('card');
|
||||
const lt = div('card-title'); lt.textContent = 'Pending Approval Requests'; listCard.appendChild(lt);
|
||||
const reqs = data.pending_requests || [];
|
||||
if (reqs.length) {
|
||||
for (const req of reqs) {
|
||||
const item = div('command-item');
|
||||
const name = div('name'); name.textContent = req.title || req.change_id || '';
|
||||
const meta = div('meta'); meta.textContent = 'ID: ' + (req.change_id || '') + ' | Risk: ' + (req.risk || '');
|
||||
const reason = div(''); reason.style.fontSize = '13px'; reason.style.color = '#c9d1d9'; reason.style.marginTop = '4px';
|
||||
reason.textContent = req.reason || '';
|
||||
item.appendChild(name); item.appendChild(meta); item.appendChild(reason);
|
||||
listCard.appendChild(item);
|
||||
}
|
||||
} else {
|
||||
html += '<div style="color:#8b949e;padding:12px;">No pending requests.</div>';
|
||||
const none = div(''); none.style.color = '#8b949e'; none.style.padding = '12px';
|
||||
none.textContent = 'No pending requests.'; listCard.appendChild(none);
|
||||
}
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
|
||||
el.appendChild(listCard);
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load: ' + e.message; el.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Real governed approve: posts the decision to the server-side governed endpoint.
|
||||
async function approveDecision(changeId, decision, btn) {
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/admin/ai-evolution/api/owner-gate/decision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken() },
|
||||
body: JSON.stringify({ change_id: changeId, decision: decision })
|
||||
});
|
||||
const payload = await res.json();
|
||||
if (!res.ok) throw new Error(payload.error || ('HTTP ' + res.status));
|
||||
await loadApprove();
|
||||
} catch (e) {
|
||||
if (btn) btn.disabled = false;
|
||||
const out = document.getElementById('arch-approve-content');
|
||||
const err = div('error'); err.textContent = 'Decision failed: ' + e.message;
|
||||
if (out) out.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApprove() {
|
||||
const el = document.getElementById('arch-approve-content');
|
||||
el.innerHTML = '<div class="loading">Loading pending approvals...</div>';
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading pending approvals...'; el.appendChild(loading);
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/owner-gate');
|
||||
const data = await r.json();
|
||||
let html = '';
|
||||
if (data.pending_requests && data.pending_requests.length) {
|
||||
html += '<div class="card"><div class="card-title">Pending changes requiring your approval</div>';
|
||||
for (const req of data.pending_requests) {
|
||||
html += '<div class="command-item">';
|
||||
html += '<div class="name">' + (req.title || req.change_id) + '</div>';
|
||||
html += '<div class="meta">Risk: <strong>' + req.risk + '</strong></div>';
|
||||
html += '<div style="font-size:13px;color:#c9d1d9;margin:8px 0;">' + (req.reason || '') + '</div>';
|
||||
html += '<div style="display:flex;gap:8px;">';
|
||||
html += '<button onclick="ownerAction.classList.add('\'approved\');this.disabled=true" style="padding:6px 16px;background:#238636;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">✓ Approve</button>';
|
||||
html += '</div></div>';
|
||||
clearNode(el);
|
||||
const reqs = data.pending_requests || [];
|
||||
if (reqs.length) {
|
||||
const card = div('card');
|
||||
const t = div('card-title'); t.textContent = 'Pending changes requiring your approval'; card.appendChild(t);
|
||||
for (const req of reqs) {
|
||||
const item = div('command-item');
|
||||
const name = div('name'); name.textContent = req.title || req.change_id || '';
|
||||
const meta = div('meta');
|
||||
const strongRisk = document.createElement('strong'); strongRisk.textContent = req.risk || '';
|
||||
meta.appendChild(document.createTextNode('Risk: ')); meta.appendChild(strongRisk);
|
||||
const reason = div(''); reason.style.fontSize = '13px'; reason.style.color = '#c9d1d9'; reason.style.margin = '8px 0';
|
||||
reason.textContent = req.reason || '';
|
||||
const actions = div(''); actions.style.display = 'flex'; actions.style.gap = '8px';
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = '✓ Approve';
|
||||
btn.style.padding = '6px 16px'; btn.style.background = '#238636'; btn.style.color = '#fff';
|
||||
btn.style.border = 'none'; btn.style.borderRadius = '6px'; btn.style.cursor = 'pointer'; btn.style.fontSize = '13px';
|
||||
const changeId = req.change_id;
|
||||
btn.addEventListener('click', function () { approveDecision(changeId, 'approved', btn); });
|
||||
actions.appendChild(btn);
|
||||
item.appendChild(name); item.appendChild(meta); item.appendChild(reason); item.appendChild(actions);
|
||||
card.appendChild(item);
|
||||
}
|
||||
html += '</div>';
|
||||
el.appendChild(card);
|
||||
} else {
|
||||
html += '<div class="card"><div style="padding:24px;text-align:center;color:#8b949e;">✅ All changes processed. No pending approval requests.</div></div>';
|
||||
const done = div('card');
|
||||
const inner = div(''); inner.style.padding = '24px'; inner.style.textAlign = 'center'; inner.style.color = '#8b949e';
|
||||
inner.textContent = '✅ All changes processed. No pending approval requests.';
|
||||
done.appendChild(inner); el.appendChild(done);
|
||||
}
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load: ' + e.message; el.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
function showPanel(name) {
|
||||
document.querySelectorAll('.panel').forEach(p => p.style.display = 'none');
|
||||
document.getElementById('panel-' + name).style.display = '';
|
||||
document.querySelectorAll('.sidebar-nav li').forEach(li => li.classList.remove('active'));
|
||||
document.querySelector('.sidebar-nav li:nth-child(' + ({overview:1,versions:2,commands:3,agents:4}[name] || 1) + ')').classList.add('active');
|
||||
if (name === 'overview') {
|
||||
document.getElementById('panel-overview').innerHTML = '<div class="main-header"><h2>System Overview</h2><div id="header-status" class="status-badge">Loading...</div></div><div id="loading" class="loading">Loading...</div><div id="error" class="error" style="display:none;"></div><div id="content"><div class="row"><div class="col"><div class="card"><div class="card-title">Current Baseline</div><div id="base" class="card-value">-</div></div></div><div class="col"><div class="card"><div class="card-title">Git Branch</div><div id="branch" class="card-value">-</div></div></div><div class="col"><div class="card"><div class="card-title">Uptime</div><div id="uptime" class="card-value">-</div></div></div></div><div class="card" style="margin-top:16px"><div class="card-title">Event Bus Status</div><pre id="bus-status">-</pre></div></div>';
|
||||
fetchData();
|
||||
}
|
||||
document.querySelector('.sidebar-nav li:nth-child(' + ({overview:1,versions:2,commands:3,agents:4,['arch-owner']:5,['arch-approve']:6}[name] || 1) + ')').classList.add('active');
|
||||
if (name === 'overview') fetchData();
|
||||
const loaders = {
|
||||
'arch-owner': loadOwnerGate,
|
||||
'arch-approve': loadApprove,
|
||||
|
||||
@@ -12,6 +12,29 @@ from .task_lifecycle import TaskLifecycle
|
||||
|
||||
lifecycle = TaskLifecycle()
|
||||
|
||||
# Role must come from an authenticated principal, never from the client body.
|
||||
_ROLE_WHITELIST = {"user", "agent", "executor", "dispatcher", "notifier", "group_admin", "owner"}
|
||||
_authenticator = None # callable(payload) -> {"role": ...} | None, injected by host
|
||||
|
||||
|
||||
def set_authenticator(fn):
|
||||
"""Install a server-side authenticator used to derive the actor role.
|
||||
|
||||
`fn` receives the raw request payload and must return either None (or a
|
||||
dict without a whitelisted "role") for anonymous, or a dict containing a
|
||||
server-verified "role". The client-supplied `payload["role"]` is ignored.
|
||||
"""
|
||||
global _authenticator
|
||||
_authenticator = fn
|
||||
|
||||
|
||||
def effective_role(payload: dict[str, Any]) -> str:
|
||||
if _authenticator is not None:
|
||||
principal = _authenticator(payload)
|
||||
if isinstance(principal, dict) and principal.get("role") in _ROLE_WHITELIST:
|
||||
return principal["role"]
|
||||
return "user"
|
||||
|
||||
|
||||
def get_task_actions(task: dict[str, Any], role: str) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -31,7 +54,8 @@ def build_router():
|
||||
|
||||
@router.post("/{task_id}/actions/{action}", operation_id="executeTaskAction")
|
||||
def action(task_id: str, action: str, payload: dict[str, Any]):
|
||||
return execute_task_action(payload.get("task", {}), action, payload.get("role", "user"), payload.get("reason"))
|
||||
# Role is derived from the authenticated principal; body role is ignored.
|
||||
return execute_task_action(payload.get("task", {}), action, effective_role(payload), payload.get("reason"))
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import time
|
||||
import uuid
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from .delegation import DelegationEngine
|
||||
@@ -23,6 +24,8 @@ from .errors import (
|
||||
from .evidence import EvidenceService
|
||||
from .journal import TaskJournal
|
||||
from .lease import LeaseManager
|
||||
from .lineage_store import TaskLineagePolicy, TaskPlaneLineageCoordinator
|
||||
from .mission import MissionAuthority
|
||||
from .models import (
|
||||
EvidenceEnvelope,
|
||||
ResultEnvelope,
|
||||
@@ -33,6 +36,11 @@ from .models import (
|
||||
validate_identifier,
|
||||
)
|
||||
from .policy import PolicyEngine
|
||||
from .root_action_consumer import (
|
||||
RootActionConsumer,
|
||||
RootActionExecutor,
|
||||
RootActionRunResult,
|
||||
)
|
||||
from .store import TaskPlaneStore
|
||||
from .worker_registry import WorkerRegistry
|
||||
|
||||
@@ -53,7 +61,15 @@ def _bounded_int(value: Any, name: str, minimum: int, maximum: int) -> int:
|
||||
|
||||
|
||||
class TaskPlaneService:
|
||||
def __init__(self, store: TaskPlaneStore, policy: PolicyEngine | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
store: TaskPlaneStore,
|
||||
policy: PolicyEngine | None = None,
|
||||
*,
|
||||
lineage_policy: TaskLineagePolicy | None = None,
|
||||
mission_authority: MissionAuthority | None = None,
|
||||
root_action_executor: RootActionExecutor | None = None,
|
||||
):
|
||||
self.store = store
|
||||
self.policy = policy or PolicyEngine(
|
||||
execution_enabled=False,
|
||||
@@ -66,30 +82,260 @@ class TaskPlaneService:
|
||||
self.workers = WorkerRegistry(store)
|
||||
self.evidence = EvidenceService(store)
|
||||
self.delegation = DelegationEngine(store)
|
||||
if mission_authority is not None and mission_authority.store is not store:
|
||||
raise ValueError("mission authority must use the TaskPlane store")
|
||||
self.missions = mission_authority
|
||||
effective_lineage_policy = lineage_policy
|
||||
if mission_authority is not None:
|
||||
effective_lineage_policy = (
|
||||
lineage_policy
|
||||
or TaskLineagePolicy(
|
||||
principal_bindings=(), acceptance_evaluators={}
|
||||
)
|
||||
).with_acceptance_evaluator_resolver(
|
||||
mission_authority.resolve_acceptance_evaluator
|
||||
)
|
||||
self.lineage = (
|
||||
TaskPlaneLineageCoordinator(store, effective_lineage_policy)
|
||||
if effective_lineage_policy is not None
|
||||
else None
|
||||
)
|
||||
self.root_actions = (
|
||||
RootActionConsumer(
|
||||
store,
|
||||
plan_builder=lambda review: mission_authority.build_root_action_plan(
|
||||
review=review, lineage=self.lineage
|
||||
),
|
||||
rereviewer=lambda plan, receipt: mission_authority.rereview_root_action(
|
||||
plan=plan,
|
||||
execution_receipt=receipt,
|
||||
lineage=self.lineage,
|
||||
),
|
||||
executor=root_action_executor,
|
||||
)
|
||||
if mission_authority is not None and self.lineage is not None
|
||||
else None
|
||||
)
|
||||
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
||||
self.store.evidence_attestation_verifier = (
|
||||
self.policy.evidence_attestation_verifier
|
||||
)
|
||||
|
||||
def create_task(self, body: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]:
|
||||
def consume_one_root_action(
|
||||
self,
|
||||
*,
|
||||
consumer_id: str,
|
||||
now: datetime | None = None,
|
||||
lease_seconds: int = 60,
|
||||
) -> RootActionRunResult:
|
||||
if self.root_actions is None:
|
||||
raise ForbiddenError("root action consumer is not configured")
|
||||
return self.root_actions.run_once(
|
||||
consumer_id=consumer_id,
|
||||
now=now or datetime.now(tz=timezone.utc),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
|
||||
def create_mission(
|
||||
self,
|
||||
body: dict[str, Any],
|
||||
idempotency_key: str | None,
|
||||
*,
|
||||
actor_identity: str | None,
|
||||
) -> dict[str, Any]:
|
||||
if self.missions is None or self.lineage is None:
|
||||
raise ForbiddenError("mission authority is not configured")
|
||||
if actor_identity is None:
|
||||
raise ForbiddenError("mission creation requires authenticated identity")
|
||||
if set(body) != {"goal_slug", "original_acceptance"}:
|
||||
raise ValidationError(
|
||||
"mission body requires only goal_slug and original_acceptance"
|
||||
)
|
||||
self.policy.assert_allowed("create", actor_identity)
|
||||
mission = self.missions.create_mission(
|
||||
goal_slug=body["goal_slug"],
|
||||
original_acceptance=body["original_acceptance"],
|
||||
idempotency_key=idempotency_key,
|
||||
authenticated_identity=actor_identity,
|
||||
lineage=self.lineage,
|
||||
)
|
||||
return {
|
||||
**mission.to_dict(),
|
||||
"root_task": self.store.get_task(mission.root_task_id).to_dict(),
|
||||
}
|
||||
|
||||
def get_mission(
|
||||
self, mission_id: str, *, actor_identity: str | None
|
||||
) -> dict[str, Any]:
|
||||
if self.missions is None:
|
||||
raise ForbiddenError("mission authority is not configured")
|
||||
if actor_identity is None:
|
||||
raise ForbiddenError("mission read requires authenticated identity")
|
||||
self.policy.assert_allowed("read", actor_identity)
|
||||
mission = self.missions.get_mission(
|
||||
mission_id, authenticated_identity=actor_identity
|
||||
)
|
||||
return {
|
||||
**mission.to_dict(),
|
||||
"root_task": self.store.get_task(mission.root_task_id).to_dict(),
|
||||
}
|
||||
|
||||
def delegate_mission(
|
||||
self,
|
||||
parent_task_id: str,
|
||||
body: dict[str, Any],
|
||||
idempotency_key: str | None,
|
||||
*,
|
||||
actor_identity: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Admit one server-identified child under an owned canonical Mission."""
|
||||
|
||||
if self.missions is None or self.lineage is None:
|
||||
raise ForbiddenError("mission authority is not configured")
|
||||
if actor_identity is None:
|
||||
raise ForbiddenError("mission delegation requires authenticated identity")
|
||||
# Mission delegation is an admission operation. It deliberately does
|
||||
# not enable the worker execution actions guarded by execution_enabled.
|
||||
self.policy.assert_allowed("create", actor_identity)
|
||||
granted = set(
|
||||
self.policy.identity_capabilities.get(
|
||||
actor_identity,
|
||||
self.policy.identity_capabilities.get("*", set()),
|
||||
)
|
||||
)
|
||||
if "task.delegate" not in granted:
|
||||
raise ForbiddenError("action delegate not permitted")
|
||||
child = self.missions.delegate_child(
|
||||
parent_task_id=parent_task_id,
|
||||
body=body,
|
||||
idempotency_key=idempotency_key,
|
||||
authenticated_identity=actor_identity,
|
||||
lineage=self.lineage,
|
||||
delegation=self.delegation,
|
||||
)
|
||||
return child.to_dict()
|
||||
|
||||
def attach_legacy_attachment(
|
||||
self,
|
||||
mission_id: str,
|
||||
body: dict[str, Any],
|
||||
idempotency_key: str | None,
|
||||
*,
|
||||
actor_identity: str | None,
|
||||
) -> dict[str, Any]:
|
||||
if self.missions is None:
|
||||
raise ForbiddenError("mission authority is not configured")
|
||||
if actor_identity is None:
|
||||
raise ForbiddenError("legacy attachment requires authenticated identity")
|
||||
self.policy.assert_allowed("create", actor_identity)
|
||||
granted = set(
|
||||
self.policy.identity_capabilities.get(
|
||||
actor_identity, self.policy.identity_capabilities.get("*", set())
|
||||
)
|
||||
)
|
||||
if "task.delegate" not in granted:
|
||||
raise ForbiddenError("action delegate not permitted")
|
||||
return self.missions.attach_legacy(
|
||||
mission_id=mission_id,
|
||||
body=body,
|
||||
idempotency_key=idempotency_key,
|
||||
authenticated_identity=actor_identity,
|
||||
)
|
||||
|
||||
def get_legacy_attachment(
|
||||
self,
|
||||
mission_id: str,
|
||||
attachment_id: str,
|
||||
*,
|
||||
actor_identity: str | None,
|
||||
) -> dict[str, Any]:
|
||||
if self.missions is None:
|
||||
raise ForbiddenError("mission authority is not configured")
|
||||
if actor_identity is None:
|
||||
raise ForbiddenError("legacy attachment read requires authenticated identity")
|
||||
self.policy.assert_allowed("read", actor_identity)
|
||||
return self.missions.get_legacy_attachment(
|
||||
mission_id=mission_id,
|
||||
attachment_id=attachment_id,
|
||||
authenticated_identity=actor_identity,
|
||||
)
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
body: dict[str, Any],
|
||||
idempotency_key: str | None = None,
|
||||
*,
|
||||
actor_identity: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if body.get("parent_task_id"):
|
||||
raise ForbiddenError("child tasks must use the delegate endpoint")
|
||||
issuer = body.get("issuer", {})
|
||||
actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
||||
if actor_identity is not None:
|
||||
if issuer and (
|
||||
not isinstance(issuer, dict) or issuer.get("id") != actor_identity
|
||||
):
|
||||
raise ForbiddenError("issuer identity mismatch")
|
||||
body = dict(body)
|
||||
body["issuer"] = {"type": "authenticated", "id": actor_identity}
|
||||
actor = actor_identity or (
|
||||
issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
||||
)
|
||||
self.policy.assert_allowed("create", actor)
|
||||
try:
|
||||
plan = TaskPlan.from_dict(body)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("invalid task plan") from exc
|
||||
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
|
||||
if self.lineage is not None:
|
||||
if actor_identity is None:
|
||||
raise ForbiddenError("lineage admission requires authenticated identity")
|
||||
task = self.lineage.create_root(
|
||||
plan=plan,
|
||||
authenticated_identity=actor_identity,
|
||||
request_digest=self.lineage._request_digest(body),
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
else:
|
||||
task = self.store.create_task_with_event(
|
||||
plan, idempotency_key=idempotency_key
|
||||
)
|
||||
return task.to_dict()
|
||||
|
||||
def _can_read_mission_task(self, task, actor: str | None) -> bool:
|
||||
# Every service sharing the store must honor persisted Mission ownership,
|
||||
# including a legacy launcher that has no Mission authority configured.
|
||||
if self.missions is not None:
|
||||
return self.missions.can_read_task(
|
||||
task, authenticated_identity=actor or ""
|
||||
)
|
||||
with self.store._lock:
|
||||
return self.store.conn.execute(
|
||||
"SELECT 1 FROM task_missions WHERE root_task_id=?",
|
||||
(task.root_task_id,),
|
||||
).fetchone() is None
|
||||
|
||||
def _assert_mission_read(self, task, actor: str | None) -> None:
|
||||
if not self._can_read_mission_task(task, actor):
|
||||
raise ForbiddenError("mission task requires its configured owner authority")
|
||||
|
||||
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("read", actor)
|
||||
return [task.to_dict(include_task=False) for task in self.store.list_tasks()]
|
||||
tasks = self.store.list_tasks()
|
||||
tasks = [task for task in tasks if self._can_read_mission_task(task, actor)]
|
||||
return [task.to_dict(include_task=False) for task in tasks]
|
||||
|
||||
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||
task = self.store.get_task(task_id)
|
||||
self._assert_mission_read(task, actor)
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||
return self.store.get_task(task_id).to_dict()
|
||||
self.policy.assert_allowed("read", actor, task=task)
|
||||
if self.missions is not None and self.missions.manages_root(
|
||||
task.root_task_id
|
||||
):
|
||||
self.missions.assert_task_read(
|
||||
task, authenticated_identity=actor
|
||||
)
|
||||
return task.to_dict()
|
||||
|
||||
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||
if actor is not None:
|
||||
@@ -307,17 +553,51 @@ class TaskPlaneService:
|
||||
)
|
||||
evidence_entries.append(envelope)
|
||||
previous = envelope.evidence_digest
|
||||
self.store.complete_claim_with_evidence_and_event(
|
||||
task_id,
|
||||
claim_id,
|
||||
final_status = (
|
||||
status
|
||||
if status
|
||||
in {"COMPLETED", "FAILED_FINAL", "FAILED_RECOVERABLE", "CANCELLED"}
|
||||
else "COMPLETED",
|
||||
result.to_dict(),
|
||||
evidence_entries,
|
||||
else "COMPLETED"
|
||||
)
|
||||
return result.to_dict()
|
||||
lineage_metadata = task.task_plan.metadata.get("aios_task_lineage")
|
||||
is_lineage_root = bool(
|
||||
self.lineage is not None
|
||||
and isinstance(lineage_metadata, dict)
|
||||
and lineage_metadata.get("relation_type") == "ROOT"
|
||||
)
|
||||
if is_lineage_root and final_status == "COMPLETED":
|
||||
raise ForbiddenError("lineage root completion requires root acceptance review")
|
||||
review_outcome = None
|
||||
with self.store._transaction():
|
||||
self.store.complete_claim_with_evidence_and_event(
|
||||
task_id,
|
||||
claim_id,
|
||||
final_status,
|
||||
result.to_dict(),
|
||||
evidence_entries,
|
||||
)
|
||||
is_lineage_child = bool(
|
||||
self.lineage is not None
|
||||
and isinstance(lineage_metadata, dict)
|
||||
and lineage_metadata.get("relation_type") != "ROOT"
|
||||
)
|
||||
if is_lineage_child and final_status in {"COMPLETED", "FAILED_FINAL", "CANCELLED"}:
|
||||
review_outcome = self.lineage.review_proven_terminal_child(
|
||||
child_task_id=task_id,
|
||||
claim_id=str(claim_id),
|
||||
result_digest=str(result.result_digest),
|
||||
evidence_refs=[entry.evidence_id for entry in evidence_entries],
|
||||
)
|
||||
response = result.to_dict()
|
||||
if review_outcome is not None:
|
||||
response["root_review"] = {
|
||||
"review_id": review_outcome.review.review_id,
|
||||
"root_task_id": review_outcome.review.root_task_id,
|
||||
"root_complete": review_outcome.review.root_complete,
|
||||
"next_legal_action": review_outcome.review.next_legal_action.value,
|
||||
"dispatch_status": review_outcome.dispatch.status,
|
||||
}
|
||||
return response
|
||||
|
||||
def fail(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
claim_id = body.get("claim_id")
|
||||
@@ -346,14 +626,38 @@ class TaskPlaneService:
|
||||
)
|
||||
return updated.to_dict()
|
||||
|
||||
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
def delegate(
|
||||
self,
|
||||
task_id: str,
|
||||
body: dict[str, Any],
|
||||
idempotency_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
actor = body.get("actor_identity") or "owner"
|
||||
self.policy.assert_allowed("delegate", actor)
|
||||
parent = self.store.get_task(task_id)
|
||||
mission_managed = bool(
|
||||
self.lineage is not None
|
||||
and self.missions is not None
|
||||
and self.missions.manages_root(parent.root_task_id)
|
||||
)
|
||||
if mission_managed:
|
||||
plan_body = body.get("task") if "task" in body else {
|
||||
key: value
|
||||
for key, value in body.items()
|
||||
if key != "actor_identity"
|
||||
}
|
||||
return self.delegate_mission(
|
||||
task_id,
|
||||
plan_body,
|
||||
idempotency_key,
|
||||
actor_identity=str(actor),
|
||||
)
|
||||
|
||||
self.policy.assert_allowed("delegate", actor)
|
||||
self.policy.assert_allowed("delegate", actor, task=parent)
|
||||
if "task" in body:
|
||||
plan_body = body["task"]
|
||||
try:
|
||||
child_plan = TaskPlan.from_dict(body["task"])
|
||||
child_plan = TaskPlan.from_dict(plan_body)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("invalid child task plan") from exc
|
||||
else:
|
||||
@@ -364,25 +668,67 @@ class TaskPlaneService:
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("invalid child task plan") from exc
|
||||
child_plan.parent_task_id = task_id
|
||||
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
||||
if self.lineage is not None:
|
||||
if not isinstance(plan_body, dict):
|
||||
raise ValidationError("invalid child task plan")
|
||||
if "task_id" not in plan_body:
|
||||
raise ValidationError("lineage delegation requires a stable task_id")
|
||||
plan_body = dict(plan_body)
|
||||
issuer = plan_body.get("issuer")
|
||||
if issuer and (
|
||||
not isinstance(issuer, dict) or issuer.get("id") != actor
|
||||
):
|
||||
raise ForbiddenError("child issuer identity mismatch")
|
||||
plan_body["issuer"] = {"type": "authenticated", "id": actor}
|
||||
child_plan.issuer = dict(plan_body["issuer"])
|
||||
child = self.lineage.delegate_child(
|
||||
parent_task_id=task_id,
|
||||
plan=child_plan,
|
||||
authenticated_identity=str(actor),
|
||||
request_digest=self.lineage._request_digest(plan_body),
|
||||
delegation=self.delegation,
|
||||
)
|
||||
else:
|
||||
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
||||
return child.to_dict()
|
||||
|
||||
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
||||
task = self.store.get_task(task_id)
|
||||
self._assert_mission_read(task, actor)
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||
self.store.get_task(task_id)
|
||||
self.policy.assert_allowed("audit", actor, task=task)
|
||||
if self.missions is not None and self.missions.manages_root(
|
||||
task.root_task_id
|
||||
):
|
||||
self.missions.assert_task_read(
|
||||
task, authenticated_identity=actor
|
||||
)
|
||||
return self.store.list_events(task_id)
|
||||
|
||||
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
||||
task = self.store.get_task(task_id)
|
||||
self._assert_mission_read(task, actor)
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||
self.store.get_task(task_id)
|
||||
self.policy.assert_allowed("audit", actor, task=task)
|
||||
if self.missions is not None and self.missions.manages_root(
|
||||
task.root_task_id
|
||||
):
|
||||
self.missions.assert_task_read(
|
||||
task, authenticated_identity=actor
|
||||
)
|
||||
return self.evidence.list(task_id)
|
||||
|
||||
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
|
||||
task = self.store.get_task(task_id)
|
||||
self._assert_mission_read(task, actor)
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||
self.store.get_task(task_id)
|
||||
self.policy.assert_allowed("read", actor, task=task)
|
||||
if self.missions is not None and self.missions.manages_root(
|
||||
task.root_task_id
|
||||
):
|
||||
self.missions.assert_task_read(
|
||||
task, authenticated_identity=actor
|
||||
)
|
||||
return self.store.list_children(task_id)
|
||||
|
||||
|
||||
@@ -474,6 +820,10 @@ class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
||||
|
||||
if len(parts) == 1 and parts[0] == "tasks":
|
||||
return 200, self.service.list_tasks(identity)
|
||||
if len(parts) == 2 and parts[0] == "missions":
|
||||
return 200, self.service.get_mission(
|
||||
parts[1], actor_identity=identity
|
||||
)
|
||||
if len(parts) == 2 and parts[0] == "tasks":
|
||||
task_id = parts[1]
|
||||
return 200, self.service.get_task(task_id, identity)
|
||||
@@ -499,6 +849,13 @@ class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
||||
parts = [p for p in path.split("/") if p]
|
||||
headers = {k.lower(): v for k, v in self.headers.items()}
|
||||
|
||||
if len(parts) == 1 and parts[0] == "missions":
|
||||
return 201, self.service.create_mission(
|
||||
body or {},
|
||||
headers.get("idempotency-key"),
|
||||
actor_identity=identity,
|
||||
)
|
||||
|
||||
if len(parts) == 1 and parts[0] == "tasks":
|
||||
issuer = body.get("issuer")
|
||||
if issuer and (
|
||||
@@ -506,7 +863,11 @@ class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
||||
):
|
||||
raise ForbiddenError("issuer identity mismatch")
|
||||
body["issuer"] = {"type": "authenticated", "id": identity}
|
||||
return 201, self.service.create_task(body or {}, headers.get("idempotency-key"))
|
||||
return 201, self.service.create_task(
|
||||
body or {},
|
||||
headers.get("idempotency-key"),
|
||||
actor_identity=identity,
|
||||
)
|
||||
if body.get("actor_identity") not in {None, identity}:
|
||||
raise ForbiddenError("actor identity mismatch")
|
||||
body["actor_identity"] = identity
|
||||
@@ -523,7 +884,9 @@ class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "cancel":
|
||||
return 200, self.service.cancel(parts[1], body or {})
|
||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "delegate":
|
||||
return 200, self.service.delegate(parts[1], body or {})
|
||||
return 200, self.service.delegate(
|
||||
parts[1], body or {}, headers.get("idempotency-key")
|
||||
)
|
||||
|
||||
if len(parts) == 2 and parts[0] == "workers" and parts[1] == "register":
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Agent discovery/claim coordinator over diagnostic schedulability evidence."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
from .schedulability import (
|
||||
DiscoveryDiagnostics,
|
||||
SchedulabilityProof,
|
||||
SchedulabilityService,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveryResult:
|
||||
status: str
|
||||
proof: SchedulabilityProof | None
|
||||
blocker_codes: tuple[str, ...] = ()
|
||||
candidate_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
value: dict[str, Any] = {
|
||||
"status": self.status,
|
||||
"blocker_codes": list(self.blocker_codes),
|
||||
"candidate_count": self.candidate_count,
|
||||
"proof": None,
|
||||
}
|
||||
if self.proof is not None:
|
||||
value["proof"] = {
|
||||
"schema": self.proof.schema,
|
||||
"proof_id": self.proof.proof_id,
|
||||
"task_id": self.proof.task_id,
|
||||
"worker_id": self.proof.worker_id,
|
||||
"created_at": self.proof.created_at,
|
||||
"expires_at": self.proof.expires_at,
|
||||
"blockers": [b.to_dict() for b in self.proof.blockers],
|
||||
"proof_digest": self.proof.proof_digest,
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def _waiting_status(diag: DiscoveryDiagnostics) -> str:
|
||||
codes = set(diag.blocker_codes)
|
||||
if diag.candidate_count == 0:
|
||||
return "NO_CANDIDATES"
|
||||
if not codes:
|
||||
return "NO_CLAIMABLE_TASK"
|
||||
if codes <= {"CAPACITY_EXHAUSTED"}:
|
||||
return "WAITING_CAPACITY"
|
||||
if "AUTHORIZATION_BLOCKED" in codes or "EXACT_SCOPE_MISMATCH" in codes:
|
||||
return "WAITING_AUTHORIZATION"
|
||||
if "DEPENDENCY_BLOCKED" in codes:
|
||||
return "WAITING_DEPENDENCY"
|
||||
if "FREEZE_BLOCKED" in codes:
|
||||
return "WAITING_FREEZE"
|
||||
if "CONFLICTING_LEASE" in codes:
|
||||
return "WAITING_LEASE"
|
||||
if {"STALE_OR_INACTIVE_WORKER", "MISSING_CAPABILITY", "INVALID_AUTHORITY_VALUE"} & codes:
|
||||
return "WORKER_NOT_ELIGIBLE"
|
||||
if "TASK_STATE_NOT_CLAIMABLE" in codes:
|
||||
return "NO_CLAIMABLE_TASK"
|
||||
return "BLOCKED"
|
||||
|
||||
|
||||
class AutoClaimCoordinator:
|
||||
"""No implicit credential derivation; authenticated identity is supplied by host."""
|
||||
|
||||
def __init__(self, schedulability: SchedulabilityService) -> None:
|
||||
self.schedulability = schedulability
|
||||
|
||||
def discover(self, *, worker_id: str, actor_identity: str) -> DiscoveryResult:
|
||||
proof, diag = self.schedulability.discover_with_diagnostics(
|
||||
worker_id=worker_id, actor_identity=actor_identity
|
||||
)
|
||||
if proof is not None:
|
||||
return DiscoveryResult(
|
||||
"CLAIMABLE", proof, diag.blocker_codes, diag.candidate_count
|
||||
)
|
||||
return DiscoveryResult(
|
||||
_waiting_status(diag), None, diag.blocker_codes, diag.candidate_count
|
||||
)
|
||||
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
proof_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
lease_seconds: int = 30,
|
||||
) -> Mapping[str, Any]:
|
||||
return self.schedulability.claim_with_recheck(
|
||||
proof_id=proof_id,
|
||||
worker_id=worker_id,
|
||||
actor_identity=actor_identity,
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,972 @@
|
||||
"""AIOS-native binding layer for the unattended resume controller.
|
||||
|
||||
This module intentionally contains no HTTP client, Unix-socket client, database,
|
||||
systemd integration, scheduler loop, secret handling, or privileged identity
|
||||
mutation.
|
||||
|
||||
It converts existing AIOS authorities into the generic callbacks defined by
|
||||
``autonomous_resume_integration``. Field code supplies the current authoritative
|
||||
implementations for:
|
||||
|
||||
- durable Owner authorization/grant projection;
|
||||
- canonical governed-worker activation/status;
|
||||
- governed task admission;
|
||||
- task/claim/TaskLease/TaskPrincipal/WriterLease/CAS ownership;
|
||||
- bounded source-lane execution and integration;
|
||||
- Gate0;
|
||||
- ReleaseReady/deployability;
|
||||
- PromotionController;
|
||||
- final production acceptance;
|
||||
- mission resume lease/checkpoint/effect reconciliation.
|
||||
|
||||
The adapter is deterministic and fail-closed. It never fabricates any credential,
|
||||
principal, lease, fence, task, commit, receipt, approval, or deployment result.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping, Protocol, Sequence
|
||||
|
||||
from .autonomous_resume_controller import (
|
||||
EffectState,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
StepStatus,
|
||||
)
|
||||
from .autonomous_resume_integration import (
|
||||
AuthorityCallbacks,
|
||||
CallableAutonomousExecutionPort,
|
||||
PhaseCallbacks,
|
||||
)
|
||||
|
||||
|
||||
def _canonical(value) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
class WorkerState(str, Enum):
|
||||
ACTIVE = "ACTIVE"
|
||||
STARTING = "STARTING"
|
||||
INACTIVE = "INACTIVE"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class WorkState(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETE = "COMPLETE"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class GateState(str, Enum):
|
||||
PASS = "PASS"
|
||||
HOLD = "HOLD"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ReadyState(str, Enum):
|
||||
READY = "READY"
|
||||
NOT_READY = "NOT_READY"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class PromotionState(str, Enum):
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
VERIFIED = "VERIFIED"
|
||||
ROLLED_BACK = "ROLLED_BACK"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class AcceptanceState(str, Enum):
|
||||
PASS = "PASS"
|
||||
FAIL_ROLLED_BACK = "FAIL_ROLLED_BACK"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DurableGrantView:
|
||||
grant_id: str
|
||||
active: bool
|
||||
repository: str
|
||||
allowed_target_prefixes: tuple[str, ...]
|
||||
max_task_risk: str
|
||||
expires_at: float | None = None
|
||||
owner_action_required: bool = False
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def permits(self, *, repository: str, branch: str, risk: str) -> bool:
|
||||
if not self.active:
|
||||
return False
|
||||
if self.repository != repository:
|
||||
return False
|
||||
if not any(branch.startswith(prefix) for prefix in self.allowed_target_prefixes):
|
||||
return False
|
||||
rank = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
if risk not in rank or self.max_task_risk not in rank:
|
||||
return False
|
||||
return rank[risk] <= rank[self.max_task_risk]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskLaneSpec:
|
||||
lane: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
description: str
|
||||
write_set: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.lane or not self.task_id or not self.repository:
|
||||
raise ValueError("lane_task_repository_required")
|
||||
if not self.target_branch.startswith("agent/"):
|
||||
raise ValueError("target_branch_must_use_agent_prefix")
|
||||
if not self.write_set:
|
||||
raise ValueError("write_set_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskAdmissionRequest:
|
||||
task_id: str
|
||||
idempotency_key: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
expected_branch_oid: str
|
||||
risk: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskAdmissionReceipt:
|
||||
task_id: str
|
||||
generation: int
|
||||
target_branch: str
|
||||
persisted: bool
|
||||
claimable: bool
|
||||
receipt_ref: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.generation < 1:
|
||||
raise ValueError("task_generation_must_be_positive")
|
||||
if not self.receipt_ref:
|
||||
raise ValueError("task_admission_receipt_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerActivationReceipt:
|
||||
state: WorkerState
|
||||
receipt_ref: str | None = None
|
||||
retry_after_seconds: int | None = None
|
||||
owner_action_required: bool = False
|
||||
code: str = "WORKER_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaneExecutionView:
|
||||
lane: str
|
||||
state: WorkState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "LANE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationView:
|
||||
state: WorkState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "INTEGRATION_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GateView:
|
||||
state: GateState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "GATE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseReadyView:
|
||||
state: ReadyState
|
||||
condition_fingerprint: str
|
||||
material_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "READY_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromotionView:
|
||||
state: PromotionState
|
||||
condition_fingerprint: str
|
||||
material_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "PROMOTION_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceView:
|
||||
state: AcceptanceState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
code: str = "ACCEPTANCE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionEffectView:
|
||||
effect_state: EffectState
|
||||
condition_fingerprint: str
|
||||
requires_reconcile: bool
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class AiosMissionAuthorities(Protocol):
|
||||
"""Existing AIOS authority surface consumed by this adapter."""
|
||||
|
||||
# Live truth.
|
||||
def read_head_oid(self, repository: str) -> str: ...
|
||||
def read_material_digest(self, mission_id: str) -> str: ...
|
||||
def read_generation(self, mission_id: str) -> int: ...
|
||||
def read_effect_view(self, mission_id: str) -> MissionEffectView: ...
|
||||
def owner_action_resolved(self, mission_id: str) -> bool: ...
|
||||
|
||||
# Durable mission state / one-winner resume authority.
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None: ...
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None: ...
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> None: ...
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload: Mapping[str, object],
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str: ...
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
outcome_code: str,
|
||||
) -> None: ...
|
||||
def reconcile_effects(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
# Admission.
|
||||
def read_durable_grant(self, mission_id: str) -> DurableGrantView: ...
|
||||
def ensure_canonical_worker(
|
||||
self,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WorkerActivationReceipt: ...
|
||||
def create_or_resume_task(
|
||||
self,
|
||||
request: TaskAdmissionRequest,
|
||||
) -> TaskAdmissionReceipt: ...
|
||||
def read_task_admission(
|
||||
self,
|
||||
lane: TaskLaneSpec,
|
||||
) -> TaskAdmissionReceipt | None: ...
|
||||
|
||||
# Phase execution.
|
||||
def read_lane_execution(
|
||||
self,
|
||||
lane: TaskLaneSpec,
|
||||
task: TaskAdmissionReceipt,
|
||||
) -> LaneExecutionView: ...
|
||||
def drive_integration(
|
||||
self,
|
||||
lanes: Sequence[TaskLaneSpec],
|
||||
tasks: Sequence[TaskAdmissionReceipt],
|
||||
) -> IntegrationView: ...
|
||||
def run_gate0(self, mission_id: str) -> GateView: ...
|
||||
def build_release_ready(self, mission_id: str) -> ReleaseReadyView: ...
|
||||
def run_promotion(self, mission_id: str) -> PromotionView: ...
|
||||
def run_final_acceptance(self, mission_id: str) -> AcceptanceView: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AiosUnattendedMissionConfig:
|
||||
mission_id: str
|
||||
repository: str
|
||||
lanes: tuple[TaskLaneSpec, ...]
|
||||
admission_risk: str = "medium"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.mission_id or not self.repository:
|
||||
raise ValueError("mission_and_repository_required")
|
||||
if not self.lanes:
|
||||
raise ValueError("at_least_one_lane_required")
|
||||
names = [lane.lane for lane in self.lanes]
|
||||
task_ids = [lane.task_id for lane in self.lanes]
|
||||
branches = [lane.target_branch for lane in self.lanes]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("duplicate_lane")
|
||||
if len(task_ids) != len(set(task_ids)):
|
||||
raise ValueError("duplicate_task_id")
|
||||
if len(branches) != len(set(branches)):
|
||||
raise ValueError("duplicate_target_branch")
|
||||
owners: dict[str, str] = {}
|
||||
for lane in self.lanes:
|
||||
if lane.repository != self.repository:
|
||||
raise ValueError("lane_repository_mismatch")
|
||||
for resource in lane.write_set:
|
||||
if resource in owners:
|
||||
raise ValueError(
|
||||
f"overlapping_lane_resource:{resource}:"
|
||||
f"{owners[resource]}:{lane.lane}"
|
||||
)
|
||||
owners[resource] = lane.lane
|
||||
|
||||
|
||||
class AiosUnattendedCallbackFactory:
|
||||
"""Construct generic controller callbacks from current AIOS authorities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority: AiosMissionAuthorities,
|
||||
config: AiosUnattendedMissionConfig,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.config = config
|
||||
self._admissions: dict[str, TaskAdmissionReceipt] = {}
|
||||
|
||||
def build_port(self) -> CallableAutonomousExecutionPort:
|
||||
return CallableAutonomousExecutionPort(
|
||||
AuthorityCallbacks(
|
||||
read_observation=self.read_observation,
|
||||
load_checkpoint=self.authority.load_checkpoint,
|
||||
acquire_resume_lease=self.authority.acquire_resume_lease,
|
||||
persist_checkpoint=self.authority.persist_checkpoint,
|
||||
persist_owner_action_required=self.authority.persist_owner_action_required,
|
||||
release_resume_lease=self.authority.release_resume_lease,
|
||||
reconcile=self.authority.reconcile_effects,
|
||||
),
|
||||
PhaseCallbacks(
|
||||
admission=self.admission,
|
||||
source_execution=self.source_execution,
|
||||
gate0=self.gate0,
|
||||
release_ready=self.release_ready,
|
||||
promotion=self.promotion,
|
||||
final_acceptance=self.final_acceptance,
|
||||
),
|
||||
)
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation:
|
||||
if mission_id != self.config.mission_id:
|
||||
raise ValueError("mission_id_mismatch")
|
||||
generation = self.authority.read_generation(mission_id)
|
||||
head_oid = self.authority.read_head_oid(self.config.repository)
|
||||
material = self.authority.read_material_digest(mission_id)
|
||||
effect = self.authority.read_effect_view(mission_id)
|
||||
fingerprint = _digest({
|
||||
"schema": "aios.unattended-condition.v1",
|
||||
"mission_id": mission_id,
|
||||
"generation": generation,
|
||||
"head_oid": head_oid,
|
||||
"material_digest": material,
|
||||
"effect_state": effect.effect_state.value,
|
||||
"effect_condition": effect.condition_fingerprint,
|
||||
"requires_reconcile": effect.requires_reconcile,
|
||||
})
|
||||
return MissionObservation(
|
||||
mission_id=mission_id,
|
||||
generation=generation,
|
||||
head_oid=head_oid,
|
||||
material_digest=material,
|
||||
condition_fingerprint=fingerprint,
|
||||
effect_state=effect.effect_state,
|
||||
requires_reconcile=effect.requires_reconcile,
|
||||
owner_action_resolved=self.authority.owner_action_resolved(mission_id),
|
||||
evidence_refs=effect.receipt_refs,
|
||||
)
|
||||
|
||||
def admission(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
grant = self.authority.read_durable_grant(observation.mission_id)
|
||||
grant_refs = tuple(grant.evidence_refs)
|
||||
|
||||
if not grant.active:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if grant.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
"DURABLE_GRANT_NOT_ACTIVE",
|
||||
self._phase_fingerprint(
|
||||
"admission", observation, {"grant_id": grant.grant_id, "active": False}
|
||||
),
|
||||
receipt_refs=grant_refs,
|
||||
owner_action_payload=(
|
||||
{
|
||||
"boundary": "DURABLE_GRANT_NOT_ACTIVE",
|
||||
"grant_id": grant.grant_id,
|
||||
}
|
||||
if grant.owner_action_required else None
|
||||
),
|
||||
)
|
||||
|
||||
for lane in self.config.lanes:
|
||||
if not grant.permits(
|
||||
repository=lane.repository,
|
||||
branch=lane.target_branch,
|
||||
risk=lane.risk,
|
||||
):
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if grant.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
"DURABLE_GRANT_SCOPE_MISMATCH",
|
||||
self._phase_fingerprint(
|
||||
"admission",
|
||||
observation,
|
||||
{
|
||||
"grant_id": grant.grant_id,
|
||||
"lane": lane.lane,
|
||||
"branch": lane.target_branch,
|
||||
"risk": lane.risk,
|
||||
},
|
||||
),
|
||||
receipt_refs=grant_refs,
|
||||
owner_action_payload=(
|
||||
{
|
||||
"boundary": "DURABLE_GRANT_SCOPE_MISMATCH",
|
||||
"grant_id": grant.grant_id,
|
||||
"lane": lane.lane,
|
||||
}
|
||||
if grant.owner_action_required else None
|
||||
),
|
||||
)
|
||||
|
||||
worker = self.authority.ensure_canonical_worker(
|
||||
observation.mission_id,
|
||||
observation.generation,
|
||||
self._idempotency(
|
||||
"worker",
|
||||
observation.mission_id,
|
||||
observation.generation,
|
||||
),
|
||||
)
|
||||
worker_refs = self._refs(grant_refs, (worker.receipt_ref or "",))
|
||||
|
||||
if worker.state is WorkerState.UNKNOWN:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
)
|
||||
if worker.state is WorkerState.BLOCKED:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if worker.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
owner_action_payload=(
|
||||
{"boundary": worker.code}
|
||||
if worker.owner_action_required else None
|
||||
),
|
||||
)
|
||||
if worker.state in {WorkerState.STARTING, WorkerState.INACTIVE}:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
retry_after_seconds=worker.retry_after_seconds,
|
||||
)
|
||||
if worker.state is WorkerState.ACTIVE and not worker.receipt_ref:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"WORKER_ACTIVE_WITHOUT_RECEIPT",
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
)
|
||||
|
||||
task_refs: list[str] = list(worker_refs)
|
||||
admissions: dict[str, TaskAdmissionReceipt] = {}
|
||||
for lane in self.config.lanes:
|
||||
request = TaskAdmissionRequest(
|
||||
task_id=lane.task_id,
|
||||
idempotency_key=self._idempotency(
|
||||
"task",
|
||||
observation.generation,
|
||||
lane.task_id,
|
||||
observation.head_oid,
|
||||
),
|
||||
repository=lane.repository,
|
||||
base_oid=observation.head_oid,
|
||||
target_branch=lane.target_branch,
|
||||
expected_branch_oid=observation.head_oid,
|
||||
risk=lane.risk,
|
||||
description=lane.description,
|
||||
)
|
||||
receipt = self.authority.create_or_resume_task(request)
|
||||
task_refs.append(receipt.receipt_ref)
|
||||
receipt_error = self._admission_receipt_error(
|
||||
lane,
|
||||
receipt,
|
||||
observation=observation,
|
||||
)
|
||||
if receipt_error is not None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
receipt_error,
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
if not receipt.persisted:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_PERSISTENCE_UNKNOWN",
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
if not receipt.claimable:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"TASK_NOT_CLAIMABLE_YET",
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
admissions[lane.lane] = receipt
|
||||
|
||||
self._admissions = admissions
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"GOVERNED_TASKS_ADMITTED",
|
||||
self._phase_fingerprint(
|
||||
"admission",
|
||||
observation,
|
||||
{"tasks": sorted(r.task_id for r in admissions.values())},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
next_phase=MissionPhase.S2_EXECUTION,
|
||||
)
|
||||
|
||||
def source_execution(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
recovery_refs: list[str] = []
|
||||
for lane in self.config.lanes:
|
||||
if lane.lane in self._admissions:
|
||||
continue
|
||||
receipt = self.authority.read_task_admission(lane)
|
||||
if receipt is None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_ADMISSION_NOT_RESOLVED",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
recovery_refs.append(receipt.receipt_ref)
|
||||
receipt_error = self._admission_receipt_error(
|
||||
lane,
|
||||
receipt,
|
||||
observation=observation,
|
||||
)
|
||||
if receipt_error is not None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
receipt_error,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
if not receipt.persisted:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_PERSISTENCE_UNKNOWN",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
if not receipt.claimable:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"TASK_NOT_CLAIMABLE_YET",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
self._admissions[lane.lane] = receipt
|
||||
|
||||
views: list[LaneExecutionView] = []
|
||||
refs: list[str] = list(recovery_refs)
|
||||
for lane in self.config.lanes:
|
||||
view = self.authority.read_lane_execution(
|
||||
lane,
|
||||
self._admissions[lane.lane],
|
||||
)
|
||||
if view.lane != lane.lane:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"LANE_IDENTITY_MISMATCH",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"expected": lane.lane, "actual": view.lane},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if view.state is WorkState.COMPLETE and not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"LANE_COMPLETE_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
views.append(view)
|
||||
refs.extend(view.receipt_refs)
|
||||
|
||||
unknown = [v for v in views if v.state is WorkState.UNKNOWN]
|
||||
if unknown:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
unknown[0].code,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
|
||||
blocked = [v for v in views if v.state is WorkState.BLOCKED]
|
||||
if blocked:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_DETERMINISTIC,
|
||||
blocked[0].code,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
|
||||
pending = [
|
||||
v for v in views
|
||||
if v.state in {WorkState.PENDING, WorkState.RUNNING}
|
||||
]
|
||||
if pending:
|
||||
retry_values = [
|
||||
v.retry_after_seconds
|
||||
for v in pending
|
||||
if v.retry_after_seconds is not None
|
||||
]
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"SOURCE_LANES_IN_PROGRESS",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
retry_after_seconds=min(retry_values) if retry_values else None,
|
||||
)
|
||||
|
||||
integration = self.authority.drive_integration(
|
||||
self.config.lanes,
|
||||
tuple(self._admissions[lane.lane] for lane in self.config.lanes),
|
||||
)
|
||||
refs.extend(integration.receipt_refs)
|
||||
if integration.state is WorkState.UNKNOWN:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if integration.state is WorkState.BLOCKED:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_DETERMINISTIC,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if integration.state in {WorkState.PENDING, WorkState.RUNNING}:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
retry_after_seconds=integration.retry_after_seconds,
|
||||
)
|
||||
if integration.state is WorkState.COMPLETE and not integration.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"INTEGRATION_COMPLETE_WITHOUT_RECEIPT",
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"SOURCE_INTEGRATION_COMPLETE",
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
next_phase=MissionPhase.GATE0,
|
||||
)
|
||||
|
||||
def gate0(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_gate0(observation.mission_id)
|
||||
if view.state is GateState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is GateState.HOLD:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"GATE0_PASS_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.RELEASE_READY
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
)
|
||||
|
||||
def release_ready(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.build_release_ready(observation.mission_id)
|
||||
if view.state is ReadyState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is ReadyState.NOT_READY:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
if not view.material_digest:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"READY_WITHOUT_MATERIAL_DIGEST",
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
)
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"READY_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.PROMOTION
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
|
||||
def promotion(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_promotion(observation.mission_id)
|
||||
if view.state is PromotionState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is PromotionState.IN_PROGRESS:
|
||||
status = StepStatus.IN_PROGRESS
|
||||
elif view.state is PromotionState.VERIFIED:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"PROMOTION_VERIFIED_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
elif view.state is PromotionState.ROLLED_BACK:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.FINAL_ACCEPTANCE
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
|
||||
def final_acceptance(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_final_acceptance(observation.mission_id)
|
||||
if view.state is AcceptanceState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is AcceptanceState.PASS:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"FINAL_ACCEPTANCE_PASS_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
else:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
next_phase=(
|
||||
MissionPhase.COMPLETE
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _admission_receipt_error(
|
||||
lane: TaskLaneSpec,
|
||||
receipt: TaskAdmissionReceipt,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
) -> str | None:
|
||||
if receipt.task_id != lane.task_id:
|
||||
return "TASK_IDENTITY_MISMATCH"
|
||||
if receipt.target_branch != lane.target_branch:
|
||||
return "TASK_BRANCH_MISMATCH"
|
||||
if receipt.generation != observation.generation:
|
||||
return "TASK_GENERATION_MISMATCH"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _refs(*groups: Sequence[str]) -> tuple[str, ...]:
|
||||
seen: set[str] = set()
|
||||
values: list[str] = []
|
||||
for group in groups:
|
||||
for item in group:
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
values.append(item)
|
||||
return tuple(values)
|
||||
|
||||
@staticmethod
|
||||
def _idempotency(*parts: object) -> str:
|
||||
return "aios:auto:" + _digest(
|
||||
{"schema": "aios.unattended-idempotency.v1", "parts": list(parts)}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _phase_fingerprint(
|
||||
phase: str,
|
||||
observation: MissionObservation,
|
||||
details: Mapping[str, object],
|
||||
) -> str:
|
||||
return _digest({
|
||||
"schema": "aios.unattended-phase-condition.v1",
|
||||
"phase": phase,
|
||||
"mission_id": observation.mission_id,
|
||||
"generation": observation.generation,
|
||||
"head_oid": observation.head_oid,
|
||||
"material_digest": observation.material_digest,
|
||||
"details": details,
|
||||
})
|
||||
@@ -0,0 +1,877 @@
|
||||
"""Deterministic unattended mission-resume controller for AIOS.
|
||||
|
||||
The controller is deliberately small and does not create a second scheduler,
|
||||
database, identity system, or release control plane.
|
||||
|
||||
It executes exactly one reconciliation/progress cycle per ``run_once`` call.
|
||||
An existing AIOS scheduler/timer/worker loop should invoke it periodically.
|
||||
|
||||
All consequential effects are delegated through ``AutonomousExecutionPort`` and
|
||||
must use the existing AIOS authorities: durable Owner authorization, task
|
||||
admission, claim/TaskLease/TaskPrincipal, WriterLease/CAS/fencing, Gate0,
|
||||
ReleaseReady, PromotionController and governed rollback.
|
||||
|
||||
The controller's responsibilities are limited to:
|
||||
- one-winner resume lease acquisition;
|
||||
- durable checkpoint progression;
|
||||
- bounded retry/backoff;
|
||||
- unchanged deterministic blocker suppression;
|
||||
- UNKNOWN/ambiguous-effect reconciliation before retry;
|
||||
- non-interactive owner-boundary recording;
|
||||
- crash-safe phase resumption.
|
||||
|
||||
It never manufactures credentials, group membership, principals, leases,
|
||||
signatures, branch OIDs, or release receipts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class MissionPhase(str, Enum):
|
||||
S2_ADMISSION = "S2_ADMISSION"
|
||||
S2_EXECUTION = "S2_EXECUTION"
|
||||
GATE0 = "GATE0"
|
||||
RELEASE_READY = "RELEASE_READY"
|
||||
PROMOTION = "PROMOTION"
|
||||
FINAL_ACCEPTANCE = "FINAL_ACCEPTANCE"
|
||||
OWNER_ACTION_REQUIRED = "OWNER_ACTION_REQUIRED"
|
||||
COMPLETE = "COMPLETE"
|
||||
|
||||
|
||||
class EffectState(str, Enum):
|
||||
ABSENT = "ABSENT"
|
||||
ACTIVE = "ACTIVE"
|
||||
COMPLETED = "COMPLETED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class StepStatus(str, Enum):
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
RETRYABLE = "RETRYABLE"
|
||||
RECONCILE = "RECONCILE"
|
||||
BLOCKED_DETERMINISTIC = "BLOCKED_DETERMINISTIC"
|
||||
BLOCKED_OWNER = "BLOCKED_OWNER"
|
||||
|
||||
|
||||
class OutcomeStatus(str, Enum):
|
||||
COMPLETE = "COMPLETE"
|
||||
PROGRESSED = "PROGRESSED"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
RETRY_SCHEDULED = "RETRY_SCHEDULED"
|
||||
WAIT_CONDITION_CHANGE = "WAIT_CONDITION_CHANGE"
|
||||
ACTIVE_ELSEWHERE = "ACTIVE_ELSEWHERE"
|
||||
OWNER_ACTION_REQUIRED = "OWNER_ACTION_REQUIRED"
|
||||
|
||||
|
||||
_PHASE_NEXT: dict[MissionPhase, MissionPhase] = {
|
||||
MissionPhase.S2_ADMISSION: MissionPhase.S2_EXECUTION,
|
||||
MissionPhase.S2_EXECUTION: MissionPhase.GATE0,
|
||||
MissionPhase.GATE0: MissionPhase.RELEASE_READY,
|
||||
MissionPhase.RELEASE_READY: MissionPhase.PROMOTION,
|
||||
MissionPhase.PROMOTION: MissionPhase.FINAL_ACCEPTANCE,
|
||||
MissionPhase.FINAL_ACCEPTANCE: MissionPhase.COMPLETE,
|
||||
}
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumePolicy:
|
||||
resume_lease_seconds: int = 120
|
||||
default_poll_seconds: int = 30
|
||||
deterministic_block_poll_seconds: int = 300
|
||||
transient_max_attempts: int = 3
|
||||
transient_base_delay_seconds: int = 2
|
||||
transient_max_delay_seconds: int = 60
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"resume_lease_seconds",
|
||||
"default_poll_seconds",
|
||||
"deterministic_block_poll_seconds",
|
||||
"transient_max_attempts",
|
||||
"transient_base_delay_seconds",
|
||||
"transient_max_delay_seconds",
|
||||
):
|
||||
if getattr(self, name) < 1:
|
||||
raise ValueError(f"{name}_must_be_positive")
|
||||
if self.transient_max_delay_seconds < self.transient_base_delay_seconds:
|
||||
raise ValueError("transient_max_delay_seconds_too_small")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionObservation:
|
||||
mission_id: str
|
||||
generation: int
|
||||
head_oid: str
|
||||
material_digest: str
|
||||
condition_fingerprint: str
|
||||
effect_state: EffectState = EffectState.ABSENT
|
||||
requires_reconcile: bool = False
|
||||
owner_action_resolved: bool = False
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if not self.head_oid:
|
||||
raise ValueError("head_oid_required")
|
||||
if not self.material_digest:
|
||||
raise ValueError("material_digest_required")
|
||||
if not self.condition_fingerprint:
|
||||
raise ValueError("condition_fingerprint_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumeLease:
|
||||
lease_id: str
|
||||
mission_id: str
|
||||
generation: int
|
||||
fencing_token: str
|
||||
expires_at: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.lease_id:
|
||||
raise ValueError("lease_id_required")
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if not self.fencing_token:
|
||||
raise ValueError("fencing_token_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepResult:
|
||||
status: StepStatus
|
||||
code: str
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
next_phase: MissionPhase | None = None
|
||||
material_digest: str | None = None
|
||||
owner_action_payload: Mapping[str, Any] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.code:
|
||||
raise ValueError("step_code_required")
|
||||
if not self.condition_fingerprint:
|
||||
raise ValueError("condition_fingerprint_required")
|
||||
if self.retry_after_seconds is not None and self.retry_after_seconds < 0:
|
||||
raise ValueError("retry_after_seconds_must_be_non_negative")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionCheckpoint:
|
||||
schema: str
|
||||
mission_id: str
|
||||
generation: int
|
||||
phase: MissionPhase
|
||||
head_oid: str
|
||||
material_digest: str
|
||||
last_condition_fingerprint: str
|
||||
attempt_count: int
|
||||
blocked_condition_fingerprint: str | None
|
||||
reconcile_required: bool
|
||||
frozen_material_digest: str | None
|
||||
resume_phase_after_owner: MissionPhase | None
|
||||
receipt_refs: tuple[str, ...]
|
||||
updated_at: float
|
||||
revision: int = 1
|
||||
|
||||
@classmethod
|
||||
def initial(
|
||||
cls,
|
||||
observation: MissionObservation,
|
||||
*,
|
||||
observed_at: float,
|
||||
) -> "MissionCheckpoint":
|
||||
return cls(
|
||||
schema="aios.autonomous-resume-checkpoint.v1",
|
||||
mission_id=observation.mission_id,
|
||||
generation=observation.generation,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=None,
|
||||
resume_phase_after_owner=None,
|
||||
receipt_refs=tuple(observation.evidence_refs),
|
||||
updated_at=observed_at,
|
||||
revision=1,
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.schema != "aios.autonomous-resume-checkpoint.v1":
|
||||
raise ValueError("unsupported_checkpoint_schema")
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if self.attempt_count < 0:
|
||||
raise ValueError("attempt_count_must_be_non_negative")
|
||||
if self.revision < 1:
|
||||
raise ValueError("revision_must_be_positive")
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
payload = asdict(self)
|
||||
payload["phase"] = self.phase.value
|
||||
payload["resume_phase_after_owner"] = (
|
||||
None
|
||||
if self.resume_phase_after_owner is None
|
||||
else self.resume_phase_after_owner.value
|
||||
)
|
||||
payload["receipt_refs"] = list(self.receipt_refs)
|
||||
return _sha256(_canonical(payload))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControllerOutcome:
|
||||
status: OutcomeStatus
|
||||
mission_id: str
|
||||
phase: MissionPhase
|
||||
code: str
|
||||
next_wake_after_seconds: int | None
|
||||
checkpoint_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class AutonomousExecutionPort(Protocol):
|
||||
"""Adapter boundary to existing AIOS authorities and executors."""
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation: ...
|
||||
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None: ...
|
||||
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None: ...
|
||||
|
||||
def step(
|
||||
self,
|
||||
*,
|
||||
phase: MissionPhase,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
*,
|
||||
lease: ResumeLease,
|
||||
) -> None: ...
|
||||
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str: ...
|
||||
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
outcome_code: str,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class AutonomousResumeController:
|
||||
"""One-cycle deterministic unattended mission controller."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: AutonomousExecutionPort,
|
||||
*,
|
||||
policy: ResumePolicy | None = None,
|
||||
clock: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
self.port = port
|
||||
self.policy = policy or ResumePolicy()
|
||||
self.clock = clock or time.time
|
||||
|
||||
def run_once(self, mission_id: str) -> ControllerOutcome:
|
||||
if not mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
|
||||
now = float(self.clock())
|
||||
observation = self.port.read_observation(mission_id)
|
||||
checkpoint = self.port.load_checkpoint(mission_id)
|
||||
if checkpoint is None:
|
||||
checkpoint = MissionCheckpoint.initial(observation, observed_at=now)
|
||||
forced_reconcile = False
|
||||
else:
|
||||
checkpoint, forced_reconcile = self._validate_and_refresh_checkpoint(
|
||||
checkpoint,
|
||||
observation,
|
||||
now=now,
|
||||
)
|
||||
|
||||
if checkpoint.phase is MissionPhase.COMPLETE:
|
||||
return self._outcome(
|
||||
OutcomeStatus.COMPLETE,
|
||||
checkpoint,
|
||||
"MISSION_ALREADY_COMPLETE",
|
||||
next_wake=None,
|
||||
)
|
||||
|
||||
if checkpoint.phase is MissionPhase.OWNER_ACTION_REQUIRED:
|
||||
if not observation.owner_action_resolved:
|
||||
return self._outcome(
|
||||
OutcomeStatus.OWNER_ACTION_REQUIRED,
|
||||
checkpoint,
|
||||
"OWNER_ACTION_STILL_REQUIRED",
|
||||
next_wake=None,
|
||||
)
|
||||
resume_phase = (
|
||||
checkpoint.resume_phase_after_owner
|
||||
or MissionPhase.S2_ADMISSION
|
||||
)
|
||||
checkpoint = replace(
|
||||
checkpoint,
|
||||
phase=resume_phase,
|
||||
resume_phase_after_owner=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
checkpoint.blocked_condition_fingerprint is not None
|
||||
and checkpoint.blocked_condition_fingerprint
|
||||
== observation.condition_fingerprint
|
||||
):
|
||||
return self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
checkpoint,
|
||||
"UNCHANGED_DETERMINISTIC_BLOCKER",
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
)
|
||||
|
||||
if (
|
||||
checkpoint.blocked_condition_fingerprint is not None
|
||||
and checkpoint.blocked_condition_fingerprint
|
||||
!= observation.condition_fingerprint
|
||||
):
|
||||
checkpoint = replace(
|
||||
checkpoint,
|
||||
blocked_condition_fingerprint=None,
|
||||
attempt_count=0,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
lease = self.port.acquire_resume_lease(
|
||||
mission_id=observation.mission_id,
|
||||
generation=observation.generation,
|
||||
idempotency_key=self._resume_idempotency_key(observation),
|
||||
lease_seconds=self.policy.resume_lease_seconds,
|
||||
)
|
||||
if lease is None:
|
||||
return self._outcome(
|
||||
OutcomeStatus.ACTIVE_ELSEWHERE,
|
||||
checkpoint,
|
||||
"RESUME_LEASE_HELD_ELSEWHERE",
|
||||
next_wake=self.policy.default_poll_seconds,
|
||||
)
|
||||
|
||||
try:
|
||||
self._validate_resume_lease(
|
||||
lease,
|
||||
observation=observation,
|
||||
now=now,
|
||||
)
|
||||
except Exception:
|
||||
self.port.release_resume_lease(
|
||||
lease,
|
||||
outcome_code="INVALID_RESUME_LEASE",
|
||||
)
|
||||
raise
|
||||
|
||||
outcome_code = "UNSET"
|
||||
try:
|
||||
reconciling = (
|
||||
forced_reconcile
|
||||
or checkpoint.reconcile_required
|
||||
or observation.requires_reconcile
|
||||
or observation.effect_state is EffectState.UNKNOWN
|
||||
)
|
||||
if reconciling:
|
||||
result = self.port.reconcile(
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
else:
|
||||
result = self.port.step(
|
||||
phase=checkpoint.phase,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
|
||||
outcome, new_checkpoint = self._apply_result(
|
||||
result=result,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
now=now,
|
||||
lease=lease,
|
||||
reconciling=reconciling,
|
||||
)
|
||||
outcome_code = outcome.code
|
||||
self.port.persist_checkpoint(new_checkpoint, lease=lease)
|
||||
return replace(
|
||||
outcome,
|
||||
checkpoint_digest=new_checkpoint.digest,
|
||||
)
|
||||
finally:
|
||||
self.port.release_resume_lease(
|
||||
lease,
|
||||
outcome_code=outcome_code,
|
||||
)
|
||||
|
||||
def _validate_and_refresh_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
observation: MissionObservation,
|
||||
*,
|
||||
now: float,
|
||||
) -> tuple[MissionCheckpoint, bool]:
|
||||
if checkpoint.mission_id != observation.mission_id:
|
||||
raise ValueError("checkpoint_mission_mismatch")
|
||||
if checkpoint.generation > observation.generation:
|
||||
raise ValueError("checkpoint_generation_ahead_of_authority")
|
||||
|
||||
refreshed = checkpoint
|
||||
forced_reconcile = False
|
||||
if checkpoint.generation < observation.generation:
|
||||
refreshed = replace(
|
||||
checkpoint,
|
||||
generation=observation.generation,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=None,
|
||||
resume_phase_after_owner=None,
|
||||
receipt_refs=self._merge_refs(
|
||||
checkpoint.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
refreshed.phase in {
|
||||
MissionPhase.RELEASE_READY,
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and refreshed.frozen_material_digest is not None
|
||||
and refreshed.frozen_material_digest
|
||||
!= observation.material_digest
|
||||
):
|
||||
# Material changed after READY. Promotion/final may already have
|
||||
# observable side effects, so reconcile before rebuilding PREPARE.
|
||||
previous_phase = refreshed.phase
|
||||
force_for_effect = (
|
||||
previous_phase in {
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and observation.effect_state in {
|
||||
EffectState.ACTIVE,
|
||||
EffectState.UNKNOWN,
|
||||
}
|
||||
)
|
||||
refreshed = replace(
|
||||
refreshed,
|
||||
phase=MissionPhase.RELEASE_READY,
|
||||
material_digest=observation.material_digest,
|
||||
head_oid=observation.head_oid,
|
||||
frozen_material_digest=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=force_for_effect,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
receipt_refs=self._merge_refs(
|
||||
refreshed.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=refreshed.revision + 1,
|
||||
)
|
||||
forced_reconcile = forced_reconcile or force_for_effect
|
||||
elif (
|
||||
refreshed.phase in {
|
||||
MissionPhase.S2_ADMISSION,
|
||||
MissionPhase.S2_EXECUTION,
|
||||
MissionPhase.GATE0,
|
||||
}
|
||||
and refreshed.head_oid != observation.head_oid
|
||||
):
|
||||
# Source moved before READY. Re-enter S2 admission/replan.
|
||||
refreshed = replace(
|
||||
refreshed,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
frozen_material_digest=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
receipt_refs=self._merge_refs(
|
||||
refreshed.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=refreshed.revision + 1,
|
||||
)
|
||||
|
||||
return refreshed, forced_reconcile
|
||||
|
||||
def _apply_result(
|
||||
self,
|
||||
*,
|
||||
result: StepResult,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
now: float,
|
||||
lease: ResumeLease,
|
||||
reconciling: bool,
|
||||
) -> tuple[ControllerOutcome, MissionCheckpoint]:
|
||||
refs = self._merge_refs(
|
||||
checkpoint.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
result.receipt_refs,
|
||||
)
|
||||
base = replace(
|
||||
checkpoint,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=(
|
||||
result.material_digest
|
||||
or observation.material_digest
|
||||
),
|
||||
last_condition_fingerprint=result.condition_fingerprint,
|
||||
receipt_refs=refs,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
result.status is StepStatus.SUCCEEDED
|
||||
and checkpoint.phase in {
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and checkpoint.frozen_material_digest is not None
|
||||
and result.material_digest is not None
|
||||
and result.material_digest != checkpoint.frozen_material_digest
|
||||
):
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=MissionPhase.RELEASE_READY,
|
||||
material_digest=result.material_digest,
|
||||
frozen_material_digest=None,
|
||||
reconcile_required=True,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
"RECONCILE_REQUIRED:MATERIAL_CHANGED_DURING_PROMOTION",
|
||||
next_wake=0,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.SUCCEEDED:
|
||||
next_phase = (
|
||||
result.next_phase
|
||||
or _PHASE_NEXT.get(checkpoint.phase, checkpoint.phase)
|
||||
)
|
||||
frozen = base.frozen_material_digest
|
||||
if checkpoint.phase is MissionPhase.RELEASE_READY:
|
||||
frozen = result.material_digest or observation.material_digest
|
||||
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=next_phase,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=frozen,
|
||||
)
|
||||
status = (
|
||||
OutcomeStatus.COMPLETE
|
||||
if next_phase is MissionPhase.COMPLETE
|
||||
else OutcomeStatus.PROGRESSED
|
||||
)
|
||||
code = (
|
||||
"MISSION_COMPLETE"
|
||||
if next_phase is MissionPhase.COMPLETE
|
||||
else result.code
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
status,
|
||||
new_checkpoint,
|
||||
code,
|
||||
next_wake=(
|
||||
None
|
||||
if status is OutcomeStatus.COMPLETE
|
||||
else self.policy.default_poll_seconds
|
||||
),
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.IN_PROGRESS:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=(
|
||||
result.retry_after_seconds
|
||||
if result.retry_after_seconds is not None
|
||||
else self.policy.default_poll_seconds
|
||||
),
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.RETRYABLE:
|
||||
attempts = checkpoint.attempt_count + 1
|
||||
if attempts > self.policy.transient_max_attempts:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=attempts,
|
||||
blocked_condition_fingerprint=result.condition_fingerprint,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
new_checkpoint,
|
||||
"RETRY_BUDGET_EXHAUSTED",
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
delay = (
|
||||
result.retry_after_seconds
|
||||
if result.retry_after_seconds is not None
|
||||
else min(
|
||||
self.policy.transient_base_delay_seconds
|
||||
* (2 ** (attempts - 1)),
|
||||
self.policy.transient_max_delay_seconds,
|
||||
)
|
||||
)
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=attempts,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.RETRY_SCHEDULED,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=delay,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.BLOCKED_DETERMINISTIC:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=checkpoint.attempt_count,
|
||||
blocked_condition_fingerprint=result.condition_fingerprint,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.BLOCKED_OWNER:
|
||||
payload = dict(result.owner_action_payload or {})
|
||||
payload.setdefault("mission_id", observation.mission_id)
|
||||
payload.setdefault("generation", observation.generation)
|
||||
payload.setdefault("phase", checkpoint.phase.value)
|
||||
payload.setdefault("code", result.code)
|
||||
record_ref = self.port.persist_owner_action_required(
|
||||
payload,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
if not record_ref:
|
||||
raise ValueError("owner_action_record_ref_required")
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=MissionPhase.OWNER_ACTION_REQUIRED,
|
||||
resume_phase_after_owner=checkpoint.phase,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
receipt_refs=self._merge_refs(refs, (record_ref,)),
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.OWNER_ACTION_REQUIRED,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=None,
|
||||
refs=(record_ref,),
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.RECONCILE:
|
||||
# The next scheduler cycle must reconcile before any retry.
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=True,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
"RECONCILE_REQUIRED:" + result.code,
|
||||
next_wake=0,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
raise AssertionError(f"unsupported_step_status:{result.status}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_resume_lease(
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
now: float,
|
||||
) -> None:
|
||||
if lease.mission_id != observation.mission_id:
|
||||
raise ValueError("resume_lease_mission_mismatch")
|
||||
if lease.generation != observation.generation:
|
||||
raise ValueError("resume_lease_generation_mismatch")
|
||||
if lease.expires_at <= now:
|
||||
raise ValueError("resume_lease_expired")
|
||||
|
||||
def _resume_idempotency_key(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
) -> str:
|
||||
payload = {
|
||||
"schema": "aios.resume-effect.v1",
|
||||
"mission_id": observation.mission_id,
|
||||
"generation": observation.generation,
|
||||
}
|
||||
return "resume:sha256:" + _sha256(_canonical(payload))
|
||||
|
||||
@staticmethod
|
||||
def _merge_refs(*groups: Sequence[str]) -> tuple[str, ...]:
|
||||
merged: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for group in groups:
|
||||
for item in group:
|
||||
value = str(item)
|
||||
if value and value not in seen:
|
||||
seen.add(value)
|
||||
merged.append(value)
|
||||
return tuple(merged)
|
||||
|
||||
@staticmethod
|
||||
def _outcome(
|
||||
status: OutcomeStatus,
|
||||
checkpoint: MissionCheckpoint,
|
||||
code: str,
|
||||
*,
|
||||
next_wake: int | None,
|
||||
refs: Sequence[str] = (),
|
||||
) -> ControllerOutcome:
|
||||
return ControllerOutcome(
|
||||
status=status,
|
||||
mission_id=checkpoint.mission_id,
|
||||
phase=checkpoint.phase,
|
||||
code=code,
|
||||
next_wake_after_seconds=next_wake,
|
||||
checkpoint_digest=None,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Thin callable adapter for :mod:`autonomous_resume_controller`.
|
||||
|
||||
This module contains no transport, daemon, database or scheduler loop. It only
|
||||
binds current AIOS runtime functions to the stable ``AutonomousExecutionPort``
|
||||
contract and exposes one scheduler tick.
|
||||
|
||||
A field integrator should:
|
||||
- resolve the live current APIs;
|
||||
- supply callables that use existing governed authorities;
|
||||
- register ``AutonomousSchedulerTick.run`` with the existing scheduler/timer.
|
||||
|
||||
No additional control plane is created here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from .autonomous_resume_controller import (
|
||||
AutonomousExecutionPort,
|
||||
AutonomousResumeController,
|
||||
ControllerOutcome,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
)
|
||||
|
||||
|
||||
PhaseHandler = Callable[
|
||||
[MissionObservation, MissionCheckpoint, ResumeLease],
|
||||
StepResult,
|
||||
]
|
||||
ReconcileHandler = PhaseHandler
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhaseCallbacks:
|
||||
admission: PhaseHandler
|
||||
source_execution: PhaseHandler
|
||||
gate0: PhaseHandler
|
||||
release_ready: PhaseHandler
|
||||
promotion: PhaseHandler
|
||||
final_acceptance: PhaseHandler
|
||||
|
||||
def for_phase(self, phase: MissionPhase) -> PhaseHandler:
|
||||
mapping = {
|
||||
MissionPhase.S2_ADMISSION: self.admission,
|
||||
MissionPhase.S2_EXECUTION: self.source_execution,
|
||||
MissionPhase.GATE0: self.gate0,
|
||||
MissionPhase.RELEASE_READY: self.release_ready,
|
||||
MissionPhase.PROMOTION: self.promotion,
|
||||
MissionPhase.FINAL_ACCEPTANCE: self.final_acceptance,
|
||||
}
|
||||
try:
|
||||
return mapping[phase]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"phase_has_no_executor:{phase.value}") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorityCallbacks:
|
||||
read_observation: Callable[[str], MissionObservation]
|
||||
load_checkpoint: Callable[[str], MissionCheckpoint | None]
|
||||
acquire_resume_lease: Callable[[str, int, str, int], ResumeLease | None]
|
||||
persist_checkpoint: Callable[[MissionCheckpoint, ResumeLease], None]
|
||||
persist_owner_action_required: Callable[
|
||||
[dict, MissionObservation, MissionCheckpoint, ResumeLease],
|
||||
str,
|
||||
]
|
||||
release_resume_lease: Callable[[ResumeLease, str], None]
|
||||
reconcile: ReconcileHandler
|
||||
|
||||
|
||||
class CallableAutonomousExecutionPort(AutonomousExecutionPort):
|
||||
"""Runtime adapter composed from already-governed AIOS callables."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority: AuthorityCallbacks,
|
||||
phases: PhaseCallbacks,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.phases = phases
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation:
|
||||
value = self.authority.read_observation(mission_id)
|
||||
if not isinstance(value, MissionObservation):
|
||||
raise TypeError("read_observation_must_return_MissionObservation")
|
||||
return value
|
||||
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None:
|
||||
value = self.authority.load_checkpoint(mission_id)
|
||||
if value is not None and not isinstance(value, MissionCheckpoint):
|
||||
raise TypeError("load_checkpoint_must_return_checkpoint_or_none")
|
||||
return value
|
||||
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None:
|
||||
value = self.authority.acquire_resume_lease(
|
||||
mission_id,
|
||||
generation,
|
||||
idempotency_key,
|
||||
lease_seconds,
|
||||
)
|
||||
if value is not None and not isinstance(value, ResumeLease):
|
||||
raise TypeError("acquire_resume_lease_must_return_lease_or_none")
|
||||
return value
|
||||
|
||||
def step(
|
||||
self,
|
||||
*,
|
||||
phase: MissionPhase,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
handler = self.phases.for_phase(phase)
|
||||
value = handler(observation, checkpoint, lease)
|
||||
if not isinstance(value, StepResult):
|
||||
raise TypeError("phase_handler_must_return_StepResult")
|
||||
return value
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
value = self.authority.reconcile(
|
||||
observation,
|
||||
checkpoint,
|
||||
lease,
|
||||
)
|
||||
if not isinstance(value, StepResult):
|
||||
raise TypeError("reconcile_must_return_StepResult")
|
||||
return value
|
||||
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
*,
|
||||
lease: ResumeLease,
|
||||
) -> None:
|
||||
self.authority.persist_checkpoint(checkpoint, lease)
|
||||
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str:
|
||||
value = self.authority.persist_owner_action_required(
|
||||
dict(payload),
|
||||
observation,
|
||||
checkpoint,
|
||||
lease,
|
||||
)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError("owner_action_record_ref_required")
|
||||
return value
|
||||
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
outcome_code: str,
|
||||
) -> None:
|
||||
self.authority.release_resume_lease(lease, outcome_code)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SchedulerTickResult:
|
||||
outcome: ControllerOutcome
|
||||
next_wake_after_seconds: int | None
|
||||
|
||||
|
||||
class AutonomousSchedulerTick:
|
||||
"""One invocation for an existing scheduler/timer.
|
||||
|
||||
This object never sleeps or loops. The host scheduler owns cadence.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: AutonomousResumeController,
|
||||
mission_id: str,
|
||||
) -> None:
|
||||
if not mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
self.controller = controller
|
||||
self.mission_id = mission_id
|
||||
|
||||
def run(self) -> SchedulerTickResult:
|
||||
outcome = self.controller.run_once(self.mission_id)
|
||||
return SchedulerTickResult(
|
||||
outcome=outcome,
|
||||
next_wake_after_seconds=outcome.next_wake_after_seconds,
|
||||
)
|
||||
@@ -31,7 +31,16 @@ class DelegationEngine:
|
||||
current = self.store.parent_of(current)
|
||||
return True
|
||||
|
||||
def delegate(self, parent_id: str, child_plan: TaskPlan, *, caller: str) -> TaskRecord:
|
||||
def delegate(
|
||||
self,
|
||||
parent_id: str,
|
||||
child_plan: TaskPlan,
|
||||
*,
|
||||
caller: str,
|
||||
owner_principal_id: str = "",
|
||||
mission_id: str = "",
|
||||
created_at: float | None = None,
|
||||
) -> TaskRecord:
|
||||
self.can_delegate(parent_id, child_plan)
|
||||
parent = self.store.get_task(parent_id)
|
||||
if child_plan.required_capabilities and not set(child_plan.required_capabilities).issubset(
|
||||
@@ -80,7 +89,12 @@ class DelegationEngine:
|
||||
raise ConflictError("delegation task_id conflict")
|
||||
|
||||
return self.store.delegate_task_with_budget_and_events(
|
||||
parent_id, child_plan, caller=caller
|
||||
parent_id,
|
||||
child_plan,
|
||||
caller=caller,
|
||||
owner_principal_id=owner_principal_id,
|
||||
mission_id=mission_id,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
def _depth(self, task_id: str) -> int:
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""现场(field)组合根:让"Owner 基于授权"真正可接线、可执行。
|
||||
|
||||
本模块是 V2 governed-writer 机制的**现场装配层**,职责:
|
||||
- 实现正式的 `OwnerAuthorizationAuthority`:持有 Owner 登记的授权记录
|
||||
(authorization_ref -> OwnerAuthorizationPolicy),`verify()` 解析引用、
|
||||
核验策略、签发 verification receipt;未知引用 / 策略失效一律 fail-closed。
|
||||
- 提供 `FieldGovernedWriterFactory`(组合根):把 7 个协议适配器(signer /
|
||||
execution / principal_issuer / jobs / git / writer_lease / owner_authority)
|
||||
装配到 `GovernedWriterAutomation.acquire_verified()` 生产入口上。
|
||||
- 提供默认的生产适配器参考实现(`_DefaultOwnerAuthority`),供现场在真实
|
||||
端点未就绪时先闭环;真实端点就绪后替换对应 adapter 即可,组合根接口不变。
|
||||
|
||||
设计原则(与 OWNER_AUTHORIZATION_MODEL.md 的 `aios.owner-authorization.v1` 一致):
|
||||
1. 生产入口唯一授权来源是 Owner 登记的 `authorization_ref`;Agent 本地自造的
|
||||
`OwnerAuthorizationPolicy` 不构成 proof(fail-closed)。
|
||||
2. 长期业务授权(Policy)与短时执行凭证(epoch/lease/principal)分离。
|
||||
3. `legacy_global_writes_enabled=false` 是安全不变式,不是 governed-writer blocker。
|
||||
|
||||
本模块不修改 S6 frozen 9 文件,不触碰 legacy/global 写;只做 governed 写路径的
|
||||
现场装配。适配器接口全部复用 `governed_writer_automation.py` 的 Protocol。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
EpochStatus,
|
||||
GovernedWriterAdapters,
|
||||
GovernedWriterAutomation,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
GovernedWriteReadiness,
|
||||
JobBindingReceipt,
|
||||
OwnerAuthorizationPolicy,
|
||||
RecoveryAuthorization,
|
||||
TaskLeaseReceipt,
|
||||
TaskPrincipalReceipt,
|
||||
TaskState,
|
||||
VerifiedOwnerAuthorization,
|
||||
WriterLeaseReceipt,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 正式 OwnerAuthorizationAuthority 现场实现
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class FieldOwnerAuthorizationAuthority:
|
||||
"""现场 authority:登记 Owner 授权记录并按引用验证,签发核验回执。
|
||||
|
||||
生产语义:
|
||||
- `register(ref, policy)` 由 Owner/管理员在受信侧登记;登记即"一次授权"。
|
||||
- `verify(authorization_ref, request)` 解析引用 -> 核验 -> 返回
|
||||
`VerifiedOwnerAuthorization`(含非空 receipt)。
|
||||
- 任何未知引用、未登记、策略与请求越界,都抛 `AuthorizationDenied`
|
||||
(fail-closed:不猜测、不降级)。
|
||||
"""
|
||||
|
||||
_registry: dict[str, OwnerAuthorizationPolicy] = field(default_factory=dict)
|
||||
_receipt_counter: int = 0
|
||||
|
||||
def register(self, authorization_ref: str, policy: OwnerAuthorizationPolicy) -> None:
|
||||
if authorization_ref != policy.authorization_id:
|
||||
raise AuthorizationDenied(
|
||||
"authorization_ref must equal policy.authorization_id"
|
||||
)
|
||||
self._registry[authorization_ref] = policy
|
||||
|
||||
def contains(self, authorization_ref: str) -> bool:
|
||||
return authorization_ref in self._registry
|
||||
|
||||
def policy(self, authorization_ref: str) -> OwnerAuthorizationPolicy:
|
||||
if authorization_ref not in self._registry:
|
||||
raise AuthorizationDenied(f"unknown authorization reference: {authorization_ref}")
|
||||
return self._registry[authorization_ref]
|
||||
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
authorization_ref: str,
|
||||
request: GovernedWriterRequest,
|
||||
) -> VerifiedOwnerAuthorization:
|
||||
if authorization_ref not in self._registry:
|
||||
raise AuthorizationDenied(
|
||||
f"owner authorization reference not registered: {authorization_ref}"
|
||||
)
|
||||
policy = self._registry[authorization_ref]
|
||||
policy.validate(request, now=time.time())
|
||||
self._receipt_counter += 1
|
||||
receipt = f"receipt:owner-{authorization_ref}:{self._receipt_counter}"
|
||||
return VerifiedOwnerAuthorization(
|
||||
policy=policy,
|
||||
authorization_ref=authorization_ref,
|
||||
verification_receipt=receipt,
|
||||
verified_at=time.time(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 默认生产适配器参考实现(真实端点未就绪时先闭环,接口与协议一一对应)
|
||||
# ---------------------------------------------------------------------------
|
||||
class _DefaultTrustedSigner:
|
||||
"""TrustedSignerAdapter:JIT 重签同 scope 的 epoch(幂等由 ledger 守护)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = 0
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def reissue_epoch(
|
||||
self,
|
||||
*,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
request: GovernedWriterRequest,
|
||||
prior_epoch: EpochStatus | None,
|
||||
minimum_remaining_ttl: float,
|
||||
idempotency_key: str,
|
||||
) -> EpochStatus:
|
||||
self._counter += 1
|
||||
self.calls.append(
|
||||
{
|
||||
"key": idempotency_key,
|
||||
"scope": request.scope_fingerprint,
|
||||
"policy": owner_policy.policy_fingerprint,
|
||||
"prior": None if prior_epoch is None else prior_epoch.epoch_id,
|
||||
}
|
||||
)
|
||||
return EpochStatus(
|
||||
epoch_id=f"epoch-gw-{self._counter}",
|
||||
scope_fingerprint=request.scope_fingerprint,
|
||||
expires_at=time.time() + 7200.0,
|
||||
policy_digest=owner_policy.policy_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultExecutionPlane:
|
||||
"""ExecutionPlane:QUEUED 自动 claim + lease;heartbeat 记录。"""
|
||||
|
||||
def __init__(self, initial: TaskState) -> None:
|
||||
self.task = initial
|
||||
self._lease_counter = 0
|
||||
self.claims: list = []
|
||||
self.heartbeats: list = []
|
||||
|
||||
def read_task(self, task_id: str) -> TaskState:
|
||||
if task_id != self.task.task_id:
|
||||
raise AuthorizationDenied(f"unknown task: {task_id}")
|
||||
return self.task
|
||||
|
||||
def apply_recovery(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
authorization: RecoveryAuthorization,
|
||||
idempotency_key: str,
|
||||
) -> TaskState:
|
||||
raise AuthorizationDenied("terminal recovery requires a real recovery endpoint")
|
||||
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
actor: str,
|
||||
idempotency_key: str,
|
||||
):
|
||||
from app.task_plane.governed_writer_automation import ClaimReceipt
|
||||
|
||||
self.claims.append({"task": task.task_id, "actor": actor, "key": idempotency_key})
|
||||
return ClaimReceipt(
|
||||
claim_id=f"claim-gw-{len(self.claims)}",
|
||||
task_id=task.task_id,
|
||||
actor=actor,
|
||||
fencing=len(self.claims),
|
||||
)
|
||||
|
||||
def heartbeat(self, *, claim) -> None:
|
||||
self.heartbeats.append(claim.claim_id)
|
||||
|
||||
def issue_task_lease(
|
||||
self,
|
||||
*,
|
||||
claim,
|
||||
idempotency_key: str,
|
||||
) -> TaskLeaseReceipt:
|
||||
self._lease_counter += 1
|
||||
return TaskLeaseReceipt(
|
||||
task_lease_id=f"tasklease-gw-{self._lease_counter}",
|
||||
task_id=claim.task_id,
|
||||
token_ref=f"token-gw-{self._lease_counter}",
|
||||
expires_at=time.time() + 3600.0,
|
||||
fencing=claim.fencing,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultPrincipalIssuer:
|
||||
"""TaskPrincipalIssuer:按 lease 派发 scoped TaskPrincipal。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = 0
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
lease: TaskLeaseReceipt,
|
||||
requested_capabilities: frozenset[str],
|
||||
idempotency_key: str,
|
||||
) -> TaskPrincipalReceipt:
|
||||
self._counter += 1
|
||||
return TaskPrincipalReceipt(
|
||||
principal_id=f"principal-gw-{self._counter}",
|
||||
task_id=lease.task_id,
|
||||
capabilities=requested_capabilities,
|
||||
expires_at=time.time() + 1800.0,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultJobs:
|
||||
"""JobAuthority:reconcile 到与请求一致的 job 绑定。"""
|
||||
|
||||
def __init__(self, *, repository: str, target_branch: str, generation: int) -> None:
|
||||
self._repo = repository
|
||||
self._branch = target_branch
|
||||
self._gen = generation
|
||||
self._counter = 0
|
||||
|
||||
def read_or_reconcile(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
principal: TaskPrincipalReceipt,
|
||||
idempotency_key: str,
|
||||
) -> JobBindingReceipt:
|
||||
self._counter += 1
|
||||
return JobBindingReceipt(
|
||||
job_id=f"job-gw-{self._counter}",
|
||||
task_id=task_id,
|
||||
repository=self._repo,
|
||||
target_branch=self._branch,
|
||||
generation=self._gen,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultGit:
|
||||
"""GitAuthority:只读 branch 读回,决定 CAS 模式。"""
|
||||
|
||||
def __init__(self, *, exists: bool, head_oid: str | None = None) -> None:
|
||||
from app.task_plane.governed_writer_automation import BranchReadback
|
||||
|
||||
self._exists = exists
|
||||
self._head = head_oid
|
||||
self.calls: list = []
|
||||
|
||||
def read_branch(self, *, repository: str, target_branch: str):
|
||||
from app.task_plane.governed_writer_automation import BranchReadback
|
||||
|
||||
self.calls.append({"repo": repository, "branch": target_branch})
|
||||
return BranchReadback(exists=self._exists, head_oid=self._head)
|
||||
|
||||
|
||||
class _DefaultWriterLease:
|
||||
"""WriterLeaseAuthority:签发短时 writer lease(CAS 与 base 一致)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = 0
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
job: JobBindingReceipt,
|
||||
principal: TaskPrincipalReceipt,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
branch_mode: str,
|
||||
base_oid: str,
|
||||
expected_branch_oid: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WriterLeaseReceipt:
|
||||
self._counter += 1
|
||||
return WriterLeaseReceipt(
|
||||
writer_lease_id=f"writerlease-gw-{self._counter}",
|
||||
task_id=task_id,
|
||||
job_id=job.job_id,
|
||||
repository=repository,
|
||||
target_branch=target_branch,
|
||||
branch_mode=branch_mode,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
generation=generation,
|
||||
expires_at=time.time() + 900.0,
|
||||
fencing=self._counter,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 组合根:装配 7 适配器 -> 生产入口
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class FieldRuntimeStatus:
|
||||
"""现场运行时状态:由 W-RUNTIME 只读线提供,用于派生 governed 写就绪。"""
|
||||
|
||||
legacy_global_writes_enabled: bool = False
|
||||
task_principal_writes_enabled: bool = True
|
||||
task_principal_verifier_ready: bool = True
|
||||
governed_write_blockers: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldGovernedWriterFactory:
|
||||
"""组合根:把现场配置装配成 `acquire_verified()` 生产入口可直接执行的对象。
|
||||
|
||||
用法(现场接线):
|
||||
factory = FieldGovernedWriterFactory.build(
|
||||
authority=owner_authority,
|
||||
manifest_store=...,
|
||||
action_ledger=...,
|
||||
task_initial=TaskState(...),
|
||||
branch_exists=...,
|
||||
head_oid=...,
|
||||
repo=...,
|
||||
branch=...,
|
||||
generation=...,
|
||||
runtime_status=...,
|
||||
)
|
||||
session = factory.acquire(request, authorization_ref)
|
||||
"""
|
||||
|
||||
automation: GovernedWriterAutomation
|
||||
adapters: GovernedWriterAdapters
|
||||
readiness: GovernedWriteReadiness
|
||||
owner_authority: FieldOwnerAuthorizationAuthority
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
*,
|
||||
authority: FieldOwnerAuthorizationAuthority,
|
||||
manifest_store,
|
||||
action_ledger,
|
||||
task_initial: TaskState,
|
||||
branch_exists: bool,
|
||||
head_oid: str | None,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
generation: int,
|
||||
runtime_status: FieldRuntimeStatus | None = None,
|
||||
decision_engine=None,
|
||||
) -> "FieldGovernedWriterFactory":
|
||||
runtime_status = runtime_status or FieldRuntimeStatus()
|
||||
adapters = GovernedWriterAdapters(
|
||||
signer=_DefaultTrustedSigner(),
|
||||
execution=_DefaultExecutionPlane(task_initial),
|
||||
principal_issuer=_DefaultPrincipalIssuer(),
|
||||
jobs=_DefaultJobs(
|
||||
repository=repository, target_branch=target_branch, generation=generation
|
||||
),
|
||||
git=_DefaultGit(exists=branch_exists, head_oid=head_oid),
|
||||
writer_lease=_DefaultWriterLease(),
|
||||
owner_authority=authority,
|
||||
)
|
||||
automation = GovernedWriterAutomation(
|
||||
manifest_store=manifest_store,
|
||||
action_ledger=action_ledger,
|
||||
decision_engine=decision_engine,
|
||||
)
|
||||
readiness = GovernedWriteReadiness(
|
||||
legacy_global_writes_enabled=runtime_status.legacy_global_writes_enabled,
|
||||
task_principal_writes_enabled=runtime_status.task_principal_writes_enabled,
|
||||
task_principal_verifier_ready=runtime_status.task_principal_verifier_ready,
|
||||
governed_write_blockers=tuple(runtime_status.governed_write_blockers),
|
||||
)
|
||||
return cls(
|
||||
automation=automation,
|
||||
adapters=adapters,
|
||||
readiness=readiness,
|
||||
owner_authority=authority,
|
||||
)
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
request: GovernedWriterRequest,
|
||||
authorization_ref: str,
|
||||
*,
|
||||
epoch: EpochStatus | None = None,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
minimum_remaining_ttl: float = 1800.0,
|
||||
) -> GovernedWriterSession:
|
||||
"""生产入口:先验 Owner 授权,再自动完成受治理写链。"""
|
||||
return self.automation.acquire_verified(
|
||||
request=request,
|
||||
authorization_ref=authorization_ref,
|
||||
readiness=self.readiness,
|
||||
epoch=epoch,
|
||||
adapters=self.adapters,
|
||||
recovery_authorization=recovery_authorization,
|
||||
now=now,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
|
||||
"""Fail-closed candidate-specific governed admission preflight.
|
||||
|
||||
Ordinary/global writes are deliberately NOT a prerequisite. This evaluates the
|
||||
governed Bridge/TaskPrincipal path only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Sequence
|
||||
|
||||
from .governance_resolver import ResolutionResult, ResolutionState
|
||||
|
||||
|
||||
class AdmissionOperation(str, Enum):
|
||||
GOVERNED_COMMIT = "GOVERNED_COMMIT"
|
||||
RECOVER_EXISTING_CHILD = "RECOVER_EXISTING_CHILD"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceFacts:
|
||||
governed_writes_enabled: bool
|
||||
task_principal_writes_enabled: bool
|
||||
recovery_execution_enabled: bool
|
||||
recovery_shadow_mode: bool
|
||||
authenticated_principal_id: str | None
|
||||
target_branch: str | None
|
||||
branch_protection_known: bool
|
||||
expected_branch_oid: str | None
|
||||
generation: int | None
|
||||
task_lease_id: str | None
|
||||
task_principal_id: str | None
|
||||
writer_lease_id: str | None
|
||||
accepted_file_allowlist: tuple[str, ...] = ()
|
||||
acceptance_revision: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdmissionBlocker:
|
||||
code: str
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdmissionResult:
|
||||
ready: bool
|
||||
operation: AdmissionOperation
|
||||
blockers: tuple[AdmissionBlocker, ...]
|
||||
|
||||
|
||||
class AdmissionPreflight:
|
||||
def evaluate(
|
||||
self,
|
||||
*,
|
||||
resolution: ResolutionResult,
|
||||
facts: GovernanceFacts,
|
||||
operation: AdmissionOperation,
|
||||
) -> AdmissionResult:
|
||||
blockers: list[AdmissionBlocker] = []
|
||||
|
||||
if resolution.state is ResolutionState.NOT_FOUND:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_NOT_RESOLVED", "exact candidate task not found"
|
||||
))
|
||||
elif resolution.state is ResolutionState.AMBIGUOUS:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_RESOLUTION_AMBIGUOUS", "multiple exact candidate tasks"
|
||||
))
|
||||
elif resolution.state is not ResolutionState.RESOLVED:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_RESOLUTION_INVALID", resolution.detail or "resolver invalid"
|
||||
))
|
||||
|
||||
if not facts.governed_writes_enabled:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"GOVERNED_WRITES_DISABLED",
|
||||
"governed Bridge write path is disabled",
|
||||
))
|
||||
if not facts.task_principal_writes_enabled:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_PRINCIPAL_WRITES_DISABLED",
|
||||
"TaskPrincipal governed writes are disabled",
|
||||
))
|
||||
if not facts.authenticated_principal_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"CALLER_PRINCIPAL_UNRESOLVED",
|
||||
"authenticated governed caller principal is missing",
|
||||
))
|
||||
if not facts.target_branch:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TARGET_BRANCH_UNRESOLVED", "target branch is missing"
|
||||
))
|
||||
if not facts.branch_protection_known:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"BRANCH_PROTECTION_UNKNOWN",
|
||||
"branch protection has not been read through authority",
|
||||
))
|
||||
if not facts.expected_branch_oid:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"EXPECTED_BRANCH_OID_UNRESOLVED",
|
||||
"expected_branch_oid/CAS preimage is missing",
|
||||
))
|
||||
if facts.generation is None:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"GENERATION_UNRESOLVED", "governed generation is missing"
|
||||
))
|
||||
if not facts.task_lease_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_LEASE_MISSING", "TaskLease not established"
|
||||
))
|
||||
if not facts.task_principal_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_PRINCIPAL_MISSING", "TaskPrincipal not established"
|
||||
))
|
||||
if not facts.writer_lease_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"WRITER_LEASE_MISSING", "WriterLease not established"
|
||||
))
|
||||
if not facts.accepted_file_allowlist:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"FILE_ALLOWLIST_MISSING", "accepted file allowlist is empty"
|
||||
))
|
||||
if not facts.acceptance_revision:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"ACCEPTANCE_REVISION_MISSING",
|
||||
"candidate acceptance revision is missing",
|
||||
))
|
||||
|
||||
if operation is AdmissionOperation.RECOVER_EXISTING_CHILD:
|
||||
if not facts.recovery_execution_enabled:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"RECOVERY_EXECUTION_DISABLED",
|
||||
"recovery execution path is disabled",
|
||||
))
|
||||
if facts.recovery_shadow_mode:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"RECOVERY_SHADOW_MODE",
|
||||
"recovery is still shadow-only",
|
||||
))
|
||||
|
||||
# Ordinary/global write state is intentionally absent from the model.
|
||||
return AdmissionResult(
|
||||
ready=not blockers,
|
||||
operation=operation,
|
||||
blockers=tuple(blockers),
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
"""Canonical recovery observation checkpoint and unchanged-blocker budget.
|
||||
|
||||
The checkpoint is NOT an authority. It records the last reconciled observation
|
||||
so agents do not repeatedly prove the same unchanged blocker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
|
||||
def _canonical(value) -> str:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
)
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryCheckpoint:
|
||||
schema: str
|
||||
goal_id: str
|
||||
repository: str
|
||||
source_oid: str
|
||||
candidate_digest: str
|
||||
task_id: str | None
|
||||
mission_id: str | None
|
||||
root_task_id: str | None
|
||||
parent_task_id: str | None
|
||||
principal_id: str | None
|
||||
blocker_code: str | None
|
||||
blocker_fingerprint: str | None
|
||||
next_legal_action: str
|
||||
last_verified_at: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
unchanged_cycles: int
|
||||
mechanism_repair_required: bool
|
||||
recovery_idempotency_key: str | None = None
|
||||
recovery_receipt_ref: str | None = None
|
||||
recovery_context_digest: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
value = asdict(self)
|
||||
value["evidence_refs"] = list(self.evidence_refs)
|
||||
return value
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
return _digest(self.to_dict())
|
||||
|
||||
|
||||
class CheckpointStore(Protocol):
|
||||
def load(self) -> RecoveryCheckpoint | None: ...
|
||||
def save(self, checkpoint: RecoveryCheckpoint) -> None: ...
|
||||
|
||||
|
||||
class JsonCheckpointStore:
|
||||
"""Atomic local observation cache; never used as authorization evidence."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def load(self) -> RecoveryCheckpoint | None:
|
||||
if not self.path.exists():
|
||||
return None
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
raw["evidence_refs"] = tuple(raw.get("evidence_refs") or ())
|
||||
return RecoveryCheckpoint(**raw)
|
||||
|
||||
def save(self, checkpoint: RecoveryCheckpoint) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
temp.write_text(
|
||||
json.dumps(
|
||||
checkpoint.to_dict(),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temp, self.path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetryBudget:
|
||||
unchanged_threshold: int = 3
|
||||
escalation_eligible_codes: frozenset[str] = frozenset({
|
||||
"TASK_NOT_RESOLVED",
|
||||
"TASK_RESOLUTION_AMBIGUOUS",
|
||||
"CALLER_PRINCIPAL_UNRESOLVED",
|
||||
"CANONICAL_CHANNEL_MISSING",
|
||||
"RESOLVER_UNAVAILABLE",
|
||||
})
|
||||
|
||||
def __post_init__(self):
|
||||
if self.unchanged_threshold < 2 or self.unchanged_threshold > 100:
|
||||
raise ValueError("unchanged_threshold must be in [2,100]")
|
||||
|
||||
@staticmethod
|
||||
def blocker_fingerprint(code: str, details: Sequence[str]) -> str:
|
||||
return _digest({
|
||||
"code": code,
|
||||
"details": sorted(str(item) for item in details),
|
||||
})
|
||||
|
||||
def observe(
|
||||
self,
|
||||
*,
|
||||
previous: RecoveryCheckpoint | None,
|
||||
blocker_code: str,
|
||||
blocker_fingerprint: str,
|
||||
) -> tuple[int, bool]:
|
||||
same = (
|
||||
previous is not None
|
||||
and previous.blocker_code == blocker_code
|
||||
and previous.blocker_fingerprint == blocker_fingerprint
|
||||
)
|
||||
cycles = (previous.unchanged_cycles + 1) if same else 1
|
||||
repair = (
|
||||
blocker_code in self.escalation_eligible_codes
|
||||
and cycles >= self.unchanged_threshold
|
||||
)
|
||||
return cycles, repair
|
||||
@@ -0,0 +1,871 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from contextlib import closing
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
SCHEMA_RUNTIME_MANIFEST = "aios.runtime-manifest.v1"
|
||||
SCHEMA_DECISION = "aios.governance-decision.v1"
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
"""Private control-kernel encoding; not a replacement for published AIOS digests."""
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_hex(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
class GovernanceControlError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class PreflightNotReady(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class StaleManifest(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class IdempotencyConflict(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class RetryBudgetExhausted(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class ConcurrentWriterDenied(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidStateTransition(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveryObservation:
|
||||
worker: str
|
||||
observed_at: float
|
||||
runtime_fingerprint: str
|
||||
process_start_identity: str
|
||||
activation_digest: str
|
||||
epoch_id: str | None
|
||||
policy_digest: str | None
|
||||
plan_provider_root: str | None
|
||||
task_id: str | None
|
||||
grant_id: str | None
|
||||
facts: Mapping[str, Any] = field(default_factory=dict)
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
*,
|
||||
worker: str,
|
||||
runtime_fingerprint: str,
|
||||
process_start_identity: str,
|
||||
activation_digest: str,
|
||||
epoch_id: str | None,
|
||||
policy_digest: str | None,
|
||||
plan_provider_root: str | None,
|
||||
task_id: str | None,
|
||||
grant_id: str | None,
|
||||
facts: Mapping[str, Any] | None = None,
|
||||
evidence_refs: Sequence[str] = (),
|
||||
observed_at: float | None = None,
|
||||
) -> "DiscoveryObservation":
|
||||
return cls(
|
||||
worker=str(worker),
|
||||
observed_at=float(time.time() if observed_at is None else observed_at),
|
||||
runtime_fingerprint=str(runtime_fingerprint),
|
||||
process_start_identity=str(process_start_identity),
|
||||
activation_digest=str(activation_digest),
|
||||
epoch_id=None if epoch_id is None else str(epoch_id),
|
||||
policy_digest=None if policy_digest is None else str(policy_digest),
|
||||
plan_provider_root=None if plan_provider_root is None else str(plan_provider_root),
|
||||
task_id=None if task_id is None else str(task_id),
|
||||
grant_id=None if grant_id is None else str(grant_id),
|
||||
facts=dict(facts or {}),
|
||||
evidence_refs=tuple(str(x) for x in evidence_refs),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeManifest:
|
||||
observed_at: float
|
||||
runtime_fingerprint: str
|
||||
process_start_identity: str
|
||||
activation_digest: str
|
||||
epoch_id: str | None
|
||||
policy_digest: str | None
|
||||
plan_provider_root: str | None
|
||||
task_id: str | None
|
||||
grant_id: str | None
|
||||
worker_facts: Mapping[str, Mapping[str, Any]]
|
||||
evidence_refs: tuple[str, ...]
|
||||
schema: str = SCHEMA_RUNTIME_MANIFEST
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": self.schema,
|
||||
"observed_at": self.observed_at,
|
||||
"runtime_fingerprint": self.runtime_fingerprint,
|
||||
"process_start_identity": self.process_start_identity,
|
||||
"activation_digest": self.activation_digest,
|
||||
"epoch_id": self.epoch_id,
|
||||
"policy_digest": self.policy_digest,
|
||||
"plan_provider_root": self.plan_provider_root,
|
||||
"task_id": self.task_id,
|
||||
"grant_id": self.grant_id,
|
||||
"worker_facts": {k: dict(v) for k, v in sorted(self.worker_facts.items())},
|
||||
"evidence_refs": list(self.evidence_refs),
|
||||
}
|
||||
|
||||
@property
|
||||
def manifest_id(self) -> str:
|
||||
return "sha256:" + sha256_hex(canonical_json_bytes(self.payload()))
|
||||
|
||||
|
||||
class ConsistencyBarrier:
|
||||
_IDENTITY_FIELDS = (
|
||||
"runtime_fingerprint",
|
||||
"process_start_identity",
|
||||
"activation_digest",
|
||||
"epoch_id",
|
||||
"policy_digest",
|
||||
"plan_provider_root",
|
||||
"task_id",
|
||||
"grant_id",
|
||||
)
|
||||
|
||||
def __init__(self, expected_workers: Iterable[str]):
|
||||
self.expected_workers = frozenset(str(x) for x in expected_workers)
|
||||
if not self.expected_workers:
|
||||
raise ValueError("expected_workers must not be empty")
|
||||
self._observations: dict[str, DiscoveryObservation] = {}
|
||||
|
||||
def submit(self, observation: DiscoveryObservation) -> None:
|
||||
if observation.worker not in self.expected_workers:
|
||||
raise PreflightNotReady(f"unexpected discovery worker: {observation.worker}")
|
||||
if observation.worker in self._observations:
|
||||
raise PreflightNotReady(f"duplicate discovery worker: {observation.worker}")
|
||||
self._observations[observation.worker] = observation
|
||||
|
||||
def freeze(self) -> RuntimeManifest:
|
||||
missing = sorted(self.expected_workers - self._observations.keys())
|
||||
if missing:
|
||||
raise PreflightNotReady("missing discovery workers: " + ",".join(missing))
|
||||
ordered = [self._observations[k] for k in sorted(self._observations)]
|
||||
anchor = ordered[0]
|
||||
for other in ordered[1:]:
|
||||
for name in self._IDENTITY_FIELDS:
|
||||
if getattr(anchor, name) != getattr(other, name):
|
||||
raise StaleManifest(
|
||||
f"consistency barrier mismatch field={name} "
|
||||
f"anchor={getattr(anchor, name)!r} "
|
||||
f"worker={other.worker} value={getattr(other, name)!r}"
|
||||
)
|
||||
evidence: list[str] = []
|
||||
facts: dict[str, Mapping[str, Any]] = {}
|
||||
observed_at = 0.0
|
||||
for item in ordered:
|
||||
observed_at = max(observed_at, item.observed_at)
|
||||
evidence.extend(item.evidence_refs)
|
||||
facts[item.worker] = dict(item.facts)
|
||||
return RuntimeManifest(
|
||||
observed_at=observed_at,
|
||||
runtime_fingerprint=anchor.runtime_fingerprint,
|
||||
process_start_identity=anchor.process_start_identity,
|
||||
activation_digest=anchor.activation_digest,
|
||||
epoch_id=anchor.epoch_id,
|
||||
policy_digest=anchor.policy_digest,
|
||||
plan_provider_root=anchor.plan_provider_root,
|
||||
task_id=anchor.task_id,
|
||||
grant_id=anchor.grant_id,
|
||||
worker_facts=facts,
|
||||
evidence_refs=tuple(dict.fromkeys(evidence)),
|
||||
)
|
||||
|
||||
|
||||
class ParallelDiscoveryCoordinator:
|
||||
def __init__(self, workers: Mapping[str, Callable[[], DiscoveryObservation]], *, max_workers: int | None = None):
|
||||
if not workers:
|
||||
raise ValueError("workers must not be empty")
|
||||
self.workers = dict(workers)
|
||||
self.max_workers = max_workers or min(8, len(self.workers))
|
||||
|
||||
def discover(self) -> RuntimeManifest:
|
||||
barrier = ConsistencyBarrier(self.workers.keys())
|
||||
errors: dict[str, str] = {}
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers, thread_name_prefix="aios-discovery") as pool:
|
||||
futures = {pool.submit(fn): name for name, fn in self.workers.items()}
|
||||
for future in as_completed(futures):
|
||||
name = futures[future]
|
||||
try:
|
||||
observation = future.result()
|
||||
if observation.worker != name:
|
||||
raise PreflightNotReady(
|
||||
f"worker name mismatch expected={name!r} got={observation.worker!r}"
|
||||
)
|
||||
barrier.submit(observation)
|
||||
except Exception as exc:
|
||||
errors[name] = f"{type(exc).__name__}:{exc}"
|
||||
if errors:
|
||||
details = ",".join(f"{k}={v}" for k, v in sorted(errors.items()))
|
||||
raise PreflightNotReady(f"discovery failed: {details}")
|
||||
return barrier.freeze()
|
||||
|
||||
|
||||
class ManifestStore:
|
||||
def __init__(self, db_path: str | Path):
|
||||
self.db_path = str(db_path)
|
||||
self._lock = threading.RLock()
|
||||
self._init_schema()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path, timeout=5, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with closing(self._connect()) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS frozen_manifests (
|
||||
manifest_id TEXT PRIMARY KEY,
|
||||
runtime_fingerprint TEXT NOT NULL,
|
||||
process_start_identity TEXT NOT NULL,
|
||||
canonical_json TEXT NOT NULL,
|
||||
frozen_at REAL NOT NULL,
|
||||
superseded_at REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def freeze(self, manifest: RuntimeManifest) -> str:
|
||||
manifest_id = manifest.manifest_id
|
||||
canonical = canonical_json_bytes(manifest.payload()).decode("utf-8")
|
||||
with self._lock, closing(self._connect()) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT canonical_json FROM frozen_manifests WHERE manifest_id=?",
|
||||
(manifest_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO frozen_manifests(
|
||||
manifest_id,runtime_fingerprint,process_start_identity,
|
||||
canonical_json,frozen_at,superseded_at
|
||||
) VALUES(?,?,?,?,?,NULL)
|
||||
""",
|
||||
(
|
||||
manifest_id,
|
||||
manifest.runtime_fingerprint,
|
||||
manifest.process_start_identity,
|
||||
canonical,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
elif row["canonical_json"] != canonical:
|
||||
conn.rollback()
|
||||
raise IdempotencyConflict("manifest_id collision with different canonical bytes")
|
||||
conn.commit()
|
||||
return manifest_id
|
||||
|
||||
def assert_current(self, *, manifest_id: str, runtime_fingerprint: str, process_start_identity: str) -> None:
|
||||
with closing(self._connect()) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT runtime_fingerprint,process_start_identity,superseded_at
|
||||
FROM frozen_manifests WHERE manifest_id=?
|
||||
""",
|
||||
(manifest_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise StaleManifest("unknown manifest_id")
|
||||
if row["superseded_at"] is not None:
|
||||
raise StaleManifest("manifest superseded")
|
||||
if row["runtime_fingerprint"] != runtime_fingerprint:
|
||||
raise StaleManifest("runtime fingerprint drift")
|
||||
if row["process_start_identity"] != process_start_identity:
|
||||
raise StaleManifest("process start identity drift")
|
||||
|
||||
def supersede(self, manifest_id: str) -> None:
|
||||
with self._lock, closing(self._connect()) as conn:
|
||||
conn.execute(
|
||||
"UPDATE frozen_manifests SET superseded_at=? WHERE manifest_id=? AND superseded_at IS NULL",
|
||||
(time.time(), manifest_id),
|
||||
)
|
||||
|
||||
|
||||
class ActionState(str, Enum):
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
EFFECT_UNKNOWN = "EFFECT_UNKNOWN"
|
||||
|
||||
|
||||
class ActionKind(str, Enum):
|
||||
READ_ONLY = "READ_ONLY"
|
||||
MALFORMED_TOOL_REQUEST = "MALFORMED_TOOL_REQUEST"
|
||||
ACTIVATION = "ACTIVATION"
|
||||
RESTART = "RESTART"
|
||||
EPOCH_ISSUANCE = "EPOCH_ISSUANCE"
|
||||
ADMISSION = "ADMISSION"
|
||||
CLAIM = "CLAIM"
|
||||
COMMIT = "COMMIT"
|
||||
GENERIC_MUTATION = "GENERIC_MUTATION"
|
||||
|
||||
|
||||
DEFAULT_RETRY_BUDGETS: Mapping[ActionKind, int] = {
|
||||
ActionKind.READ_ONLY: 2,
|
||||
ActionKind.MALFORMED_TOOL_REQUEST: 1,
|
||||
ActionKind.ACTIVATION: 1,
|
||||
ActionKind.RESTART: 1,
|
||||
ActionKind.EPOCH_ISSUANCE: 1,
|
||||
ActionKind.ADMISSION: 1,
|
||||
ActionKind.CLAIM: 1,
|
||||
ActionKind.COMMIT: 1,
|
||||
ActionKind.GENERIC_MUTATION: 1,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActionTicket:
|
||||
action_id: str
|
||||
idempotency_key: str
|
||||
manifest_id: str
|
||||
phase: str
|
||||
kind: ActionKind
|
||||
target: str
|
||||
precondition_hash: str
|
||||
attempt_count: int
|
||||
max_attempts: int
|
||||
state: ActionState
|
||||
reused: bool = False
|
||||
requires_reconcile: bool = False
|
||||
|
||||
|
||||
class ActionLedger:
|
||||
def __init__(self, db_path: str | Path, *, retry_budgets: Mapping[ActionKind, int] | None = None):
|
||||
self.db_path = str(db_path)
|
||||
self.retry_budgets = dict(DEFAULT_RETRY_BUDGETS)
|
||||
if retry_budgets:
|
||||
self.retry_budgets.update(retry_budgets)
|
||||
self._lock = threading.RLock()
|
||||
self._init_schema()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path, timeout=5, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with closing(self._connect()) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS actions (
|
||||
action_id TEXT PRIMARY KEY,
|
||||
idempotency_key TEXT UNIQUE NOT NULL,
|
||||
manifest_id TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
precondition_hash TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL,
|
||||
max_attempts INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
result_json TEXT,
|
||||
evidence_json TEXT,
|
||||
expected_readback_json TEXT,
|
||||
rollback_ref TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def begin(
|
||||
self,
|
||||
*,
|
||||
idempotency_key: str,
|
||||
manifest_id: str,
|
||||
phase: str,
|
||||
kind: ActionKind,
|
||||
target: str,
|
||||
precondition_hash: str,
|
||||
expected_readback: Mapping[str, Any] | None = None,
|
||||
rollback_ref: str | None = None,
|
||||
) -> ActionTicket:
|
||||
max_attempts = int(self.retry_budgets[kind])
|
||||
now = time.time()
|
||||
with self._lock, closing(self._connect()) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT * FROM actions WHERE idempotency_key=?",
|
||||
(idempotency_key,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
action_id = "act-" + uuid.uuid4().hex
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO actions(
|
||||
action_id,idempotency_key,manifest_id,phase,kind,target,
|
||||
precondition_hash,attempt_count,max_attempts,state,
|
||||
result_json,evidence_json,expected_readback_json,rollback_ref,
|
||||
created_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
action_id,idempotency_key,manifest_id,phase,kind.value,target,
|
||||
precondition_hash,1,max_attempts,ActionState.RUNNING.value,
|
||||
None,"[]",json.dumps(dict(expected_readback or {}), sort_keys=True),
|
||||
rollback_ref,now,now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return ActionTicket(
|
||||
action_id,idempotency_key,manifest_id,phase,kind,target,
|
||||
precondition_hash,1,max_attempts,ActionState.RUNNING
|
||||
)
|
||||
if row["manifest_id"] != manifest_id or row["precondition_hash"] != precondition_hash:
|
||||
conn.rollback()
|
||||
raise IdempotencyConflict(
|
||||
"same idempotency key used with different manifest/precondition"
|
||||
)
|
||||
state = ActionState(row["state"])
|
||||
if state == ActionState.SUCCEEDED:
|
||||
conn.commit()
|
||||
return self._ticket(row, reused=True)
|
||||
if state == ActionState.RUNNING:
|
||||
conn.commit()
|
||||
return self._ticket(row, reused=True)
|
||||
if state == ActionState.EFFECT_UNKNOWN:
|
||||
conn.commit()
|
||||
return self._ticket(row, reused=True, requires_reconcile=True)
|
||||
attempts = int(row["attempt_count"])
|
||||
if attempts >= int(row["max_attempts"]):
|
||||
conn.rollback()
|
||||
raise RetryBudgetExhausted(
|
||||
f"retry budget exhausted key={idempotency_key} attempts={attempts}"
|
||||
)
|
||||
attempts += 1
|
||||
conn.execute(
|
||||
"UPDATE actions SET attempt_count=?,state=?,updated_at=? WHERE action_id=?",
|
||||
(attempts,ActionState.RUNNING.value,now,row["action_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
updated = dict(row)
|
||||
updated["attempt_count"] = attempts
|
||||
updated["state"] = ActionState.RUNNING.value
|
||||
return self._ticket(updated)
|
||||
|
||||
def _ticket(self, row: Mapping[str, Any], *, reused: bool = False, requires_reconcile: bool = False) -> ActionTicket:
|
||||
return ActionTicket(
|
||||
action_id=str(row["action_id"]),
|
||||
idempotency_key=str(row["idempotency_key"]),
|
||||
manifest_id=str(row["manifest_id"]),
|
||||
phase=str(row["phase"]),
|
||||
kind=ActionKind(str(row["kind"])),
|
||||
target=str(row["target"]),
|
||||
precondition_hash=str(row["precondition_hash"]),
|
||||
attempt_count=int(row["attempt_count"]),
|
||||
max_attempts=int(row["max_attempts"]),
|
||||
state=ActionState(str(row["state"])),
|
||||
reused=reused,
|
||||
requires_reconcile=requires_reconcile,
|
||||
)
|
||||
|
||||
def _finish(self, action_id: str, state: ActionState, *, result: Mapping[str, Any] | None = None, evidence_refs: Sequence[str] = ()) -> None:
|
||||
if state == ActionState.RUNNING:
|
||||
raise ValueError("finish state cannot be RUNNING")
|
||||
with self._lock, closing(self._connect()) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute("SELECT state FROM actions WHERE action_id=?", (action_id,)).fetchone()
|
||||
if row is None:
|
||||
conn.rollback()
|
||||
raise GovernanceControlError("unknown action_id")
|
||||
if row["state"] == ActionState.SUCCEEDED.value and state != ActionState.SUCCEEDED:
|
||||
conn.rollback()
|
||||
raise InvalidStateTransition("cannot move succeeded action backwards")
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE actions SET state=?,result_json=?,evidence_json=?,updated_at=?
|
||||
WHERE action_id=?
|
||||
""",
|
||||
(
|
||||
state.value,json.dumps(dict(result or {}), sort_keys=True),
|
||||
json.dumps([str(x) for x in evidence_refs], sort_keys=True),
|
||||
time.time(),action_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def mark_succeeded(self, action_id: str, *, result: Mapping[str, Any], evidence_refs: Sequence[str] = ()) -> None:
|
||||
self._finish(action_id, ActionState.SUCCEEDED, result=result, evidence_refs=evidence_refs)
|
||||
|
||||
def mark_failed(self, action_id: str, *, result: Mapping[str, Any] | None = None, evidence_refs: Sequence[str] = ()) -> None:
|
||||
self._finish(action_id, ActionState.FAILED, result=result, evidence_refs=evidence_refs)
|
||||
|
||||
def mark_effect_unknown(self, action_id: str, *, result: Mapping[str, Any] | None = None, evidence_refs: Sequence[str] = ()) -> None:
|
||||
self._finish(action_id, ActionState.EFFECT_UNKNOWN, result=result, evidence_refs=evidence_refs)
|
||||
|
||||
|
||||
class HypothesisStatus(str, Enum):
|
||||
OPEN = "OPEN"
|
||||
CONFIRMED = "CONFIRMED"
|
||||
REJECTED = "REJECTED"
|
||||
|
||||
|
||||
class HypothesisLedger:
|
||||
def __init__(self, db_path: str | Path):
|
||||
self.db_path = str(db_path)
|
||||
self._lock = threading.RLock()
|
||||
self._init_schema()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path, timeout=5, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with closing(self._connect()) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS hypotheses (
|
||||
hypothesis_id TEXT PRIMARY KEY,
|
||||
claim TEXT NOT NULL,
|
||||
evidence_for_json TEXT NOT NULL,
|
||||
evidence_against_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
next_probe TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def record(
|
||||
self,
|
||||
*,
|
||||
hypothesis_id: str,
|
||||
claim: str,
|
||||
next_probe: str | None = None,
|
||||
evidence_for: Sequence[str] = (),
|
||||
evidence_against: Sequence[str] = (),
|
||||
) -> None:
|
||||
now = time.time()
|
||||
with self._lock, closing(self._connect()) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO hypotheses(
|
||||
hypothesis_id,claim,evidence_for_json,evidence_against_json,
|
||||
status,next_probe,created_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(hypothesis_id) DO UPDATE SET
|
||||
claim=excluded.claim,
|
||||
evidence_for_json=excluded.evidence_for_json,
|
||||
evidence_against_json=excluded.evidence_against_json,
|
||||
next_probe=excluded.next_probe,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
hypothesis_id,claim,json.dumps(list(evidence_for), sort_keys=True),
|
||||
json.dumps(list(evidence_against), sort_keys=True),
|
||||
HypothesisStatus.OPEN.value,next_probe,now,now,
|
||||
),
|
||||
)
|
||||
|
||||
def set_status(self, hypothesis_id: str, status: HypothesisStatus) -> None:
|
||||
with self._lock, closing(self._connect()) as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE hypotheses SET status=?,updated_at=? WHERE hypothesis_id=?",
|
||||
(status.value,time.time(),hypothesis_id),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
raise GovernanceControlError("unknown hypothesis_id")
|
||||
|
||||
def assert_probe_discriminates(self, candidate_hypothesis_ids: Sequence[str]) -> None:
|
||||
ids = tuple(dict.fromkeys(str(x) for x in candidate_hypothesis_ids))
|
||||
if len(ids) < 2:
|
||||
raise GovernanceControlError("probe must discriminate at least two candidate hypotheses")
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
with closing(self._connect()) as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT hypothesis_id,status FROM hypotheses WHERE hypothesis_id IN ({placeholders})",
|
||||
ids,
|
||||
).fetchall()
|
||||
open_ids = {
|
||||
str(r["hypothesis_id"]) for r in rows
|
||||
if r["status"] == HypothesisStatus.OPEN.value
|
||||
}
|
||||
if len(open_ids) < 2:
|
||||
raise GovernanceControlError("probe does not discriminate at least two OPEN hypotheses")
|
||||
|
||||
|
||||
class BlockerClassification(str, Enum):
|
||||
EXTERNAL_DEPENDENCY = "EXTERNAL_DEPENDENCY"
|
||||
MECHANISM_DEFECT = "MECHANISM_DEFECT"
|
||||
TRANSIENT = "TRANSIENT"
|
||||
OWNER_BOUNDARY = "OWNER_BOUNDARY"
|
||||
SELF_ACTIONABLE = "SELF_ACTIONABLE"
|
||||
|
||||
|
||||
class ProducerKind(str, Enum):
|
||||
SELF = "SELF"
|
||||
AUTHORITY_SIDE = "AUTHORITY_SIDE"
|
||||
IMPLEMENTER = "IMPLEMENTER"
|
||||
CONTROL_PLANE_OWNER = "CONTROL_PLANE_OWNER"
|
||||
OWNER = "OWNER"
|
||||
|
||||
|
||||
class NextAction(str, Enum):
|
||||
ROOT_COMPLETE = "ROOT_COMPLETE"
|
||||
CREATE_OR_RESUME_NEXT_CHILD = "CREATE_OR_RESUME_NEXT_CHILD"
|
||||
WAIT_DEPENDENCY = "WAIT_DEPENDENCY"
|
||||
RECOVER_EXISTING_CHILD = "RECOVER_EXISTING_CHILD"
|
||||
OWNER_BOUNDARY = "OWNER_BOUNDARY"
|
||||
REPAIR_MECHANISM = "REPAIR_MECHANISM"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProducerBinding:
|
||||
classification: BlockerClassification
|
||||
producer_kind: ProducerKind
|
||||
producer_id: str
|
||||
action_required: str
|
||||
|
||||
|
||||
DEFAULT_PRODUCER_TABLE: Mapping[str, ProducerBinding] = {
|
||||
"TASK_NOT_RESOLVED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "task-mission-authority", "PROVISION_EXACT_CANDIDATE"),
|
||||
"TASK_RESOLUTION_AMBIGUOUS": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "task-mission-authority", "PROVISION_EXACT_CANDIDATE"),
|
||||
"LIVE_SURFACE_UNAVAILABLE": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "governed-bridge-authority-owner", "EXPOSE_READ_SURFACE"),
|
||||
"LIVE_BINDING_VIOLATION": ProducerBinding(BlockerClassification.MECHANISM_DEFECT, ProducerKind.IMPLEMENTER, "agent-side-adapter-owner", "FIX_BINDING_ADAPTER"),
|
||||
"GOVERNED_WRITES_DISABLED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.CONTROL_PLANE_OWNER, "bridge-runtime-config", "ENABLE_GOVERNED_WRITES"),
|
||||
"TASK_PRINCIPAL_WRITES_DISABLED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.CONTROL_PLANE_OWNER, "bridge-runtime-config", "ENABLE_TASK_PRINCIPAL_WRITES"),
|
||||
"TASK_LEASE_MISSING": ProducerBinding(BlockerClassification.SELF_ACTIONABLE, ProducerKind.SELF, "agent-recovery-executor", "ACQUIRE_LEASE_VIA_GOVERNED_PATH"),
|
||||
"TASK_PRINCIPAL_MISSING": ProducerBinding(BlockerClassification.SELF_ACTIONABLE, ProducerKind.SELF, "agent-recovery-executor", "ISSUE_TASK_PRINCIPAL"),
|
||||
"WRITER_LEASE_MISSING": ProducerBinding(BlockerClassification.SELF_ACTIONABLE, ProducerKind.SELF, "agent-recovery-executor", "ACQUIRE_WRITER_LEASE"),
|
||||
"EXPECTED_BRANCH_OID_UNRESOLVED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "governed-bridge-provisioner", "PROVISION_CANDIDATE_BINDING"),
|
||||
"GENERATION_UNRESOLVED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "governed-bridge-provisioner", "PROVISION_CANDIDATE_BINDING"),
|
||||
"TARGET_BRANCH_UNRESOLVED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "governed-bridge-provisioner", "PROVISION_CANDIDATE_BINDING"),
|
||||
"FILE_ALLOWLIST_MISSING": ProducerBinding(BlockerClassification.OWNER_BOUNDARY, ProducerKind.OWNER, "owner-delegation-epoch", "ISSUE_OWNER_EPOCH"),
|
||||
"ACCEPTANCE_REVISION_MISSING": ProducerBinding(BlockerClassification.OWNER_BOUNDARY, ProducerKind.OWNER, "owner-delegation-epoch", "ISSUE_OWNER_EPOCH"),
|
||||
"RECOVERY_RESULT_UNKNOWN": ProducerBinding(BlockerClassification.SELF_ACTIONABLE, ProducerKind.SELF, "agent-recovery-executor", "RECONCILE_BEFORE_RETRY"),
|
||||
"CAPABILITY_OUT_OF_POLICY": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "admission-policy-authority", "ALIGN_POLICY_AND_EPOCH"),
|
||||
"EXECUTION_DIGEST_INVALID": ProducerBinding(BlockerClassification.MECHANISM_DEFECT, ProducerKind.IMPLEMENTER, "candidate-plan-builder", "REGENERATE_CANONICAL_PLAN"),
|
||||
"TASK_EXPIRED_OWNER_RECOVERY_REQUIRED": ProducerBinding(BlockerClassification.OWNER_BOUNDARY, ProducerKind.OWNER, "agent-task-terminal-recovery", "AUTHORIZE_TERMINAL_RECOVERY_OR_SUCCESSOR"),
|
||||
"EPOCH_EXPIRED": ProducerBinding(BlockerClassification.SELF_ACTIONABLE, ProducerKind.SELF, "trusted-grant-signer-adapter", "JIT_REISSUE_EPOCH_IF_SCOPE_UNCHANGED"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionInput:
|
||||
goal_id: str
|
||||
blocker_codes: tuple[str, ...] = ()
|
||||
owner_boundary_required: bool = False
|
||||
surfaces_ready: bool = True
|
||||
resolution_state: str = "RESOLVED"
|
||||
admission_ready: bool = False
|
||||
acceptance_complete: bool = False
|
||||
descendant_statuses: tuple[str, ...] = ()
|
||||
unchanged_cycles: int = 0
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceDecision:
|
||||
goal_id: str
|
||||
next_valid_action: NextAction
|
||||
owner_boundary: bool
|
||||
blocker_code: str | None
|
||||
classification: BlockerClassification | None
|
||||
producer_kind: ProducerKind | None
|
||||
producer_id: str | None
|
||||
action_required: str | None
|
||||
evidence_refs: tuple[str, ...]
|
||||
schema: str = SCHEMA_DECISION
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = asdict(self)
|
||||
result["next_valid_action"] = self.next_valid_action.value
|
||||
result["classification"] = None if self.classification is None else self.classification.value
|
||||
result["producer_kind"] = None if self.producer_kind is None else self.producer_kind.value
|
||||
return result
|
||||
|
||||
|
||||
class AutonomousDecisionEngine:
|
||||
RECOVERY_STATUSES = frozenset({"FAILED_RECOVERABLE", "EXPIRED", "RECOVERY_REQUIRED"})
|
||||
BLOCKED_STATUSES = frozenset({"BLOCKED", "WAIT_DEPENDENCY", "TERMINAL_UNPROVEN"})
|
||||
|
||||
def __init__(self, producer_table: Mapping[str, ProducerBinding] | None = None):
|
||||
self.producer_table = dict(DEFAULT_PRODUCER_TABLE)
|
||||
if producer_table:
|
||||
self.producer_table.update(producer_table)
|
||||
|
||||
def _binding(self, blocker: str) -> ProducerBinding:
|
||||
return self.producer_table.get(
|
||||
blocker,
|
||||
ProducerBinding(BlockerClassification.MECHANISM_DEFECT, ProducerKind.IMPLEMENTER, "unmapped-blocker-owner", "CLASSIFY_BLOCKER"),
|
||||
)
|
||||
|
||||
def decide(self, inp: DecisionInput) -> GovernanceDecision:
|
||||
blockers = tuple(dict.fromkeys(inp.blocker_codes))
|
||||
for blocker in blockers:
|
||||
binding = self._binding(blocker)
|
||||
if binding.classification == BlockerClassification.MECHANISM_DEFECT:
|
||||
return self._decision(inp, blocker, binding, NextAction.REPAIR_MECHANISM)
|
||||
if inp.owner_boundary_required:
|
||||
blocker = blockers[0] if blockers else "OWNER_BOUNDARY"
|
||||
binding = self._binding(blocker) if blockers else ProducerBinding(
|
||||
BlockerClassification.OWNER_BOUNDARY, ProducerKind.OWNER, "owner", "OWNER_DECISION_REQUIRED"
|
||||
)
|
||||
return self._decision(inp, blocker, binding, NextAction.OWNER_BOUNDARY)
|
||||
for blocker in blockers:
|
||||
binding = self._binding(blocker)
|
||||
if binding.classification == BlockerClassification.OWNER_BOUNDARY:
|
||||
return self._decision(inp, blocker, binding, NextAction.OWNER_BOUNDARY)
|
||||
if not inp.surfaces_ready:
|
||||
blocker = "LIVE_SURFACE_UNAVAILABLE"
|
||||
return self._decision(inp, blocker, self._binding(blocker), NextAction.WAIT_DEPENDENCY)
|
||||
if inp.resolution_state != "RESOLVED":
|
||||
blocker = "TASK_RESOLUTION_AMBIGUOUS" if inp.resolution_state == "AMBIGUOUS" else "TASK_NOT_RESOLVED"
|
||||
return self._decision(inp, blocker, self._binding(blocker), NextAction.WAIT_DEPENDENCY)
|
||||
if not inp.admission_ready:
|
||||
for blocker in blockers:
|
||||
binding = self._binding(blocker)
|
||||
if binding.classification == BlockerClassification.SELF_ACTIONABLE:
|
||||
return self._decision(inp, blocker, binding, NextAction.RECOVER_EXISTING_CHILD)
|
||||
if blockers:
|
||||
blocker = blockers[0]
|
||||
return self._decision(inp, blocker, self._binding(blocker), NextAction.WAIT_DEPENDENCY)
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=NextAction.WAIT_DEPENDENCY,
|
||||
owner_boundary=False,blocker_code=None,
|
||||
classification=BlockerClassification.EXTERNAL_DEPENDENCY,
|
||||
producer_kind=ProducerKind.AUTHORITY_SIDE,producer_id="admission-authority",
|
||||
action_required="MAKE_ADMISSION_READY",evidence_refs=inp.evidence_refs,
|
||||
)
|
||||
if inp.acceptance_complete:
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=NextAction.ROOT_COMPLETE,
|
||||
owner_boundary=False,blocker_code=None,classification=None,
|
||||
producer_kind=None,producer_id=None,action_required=None,evidence_refs=inp.evidence_refs,
|
||||
)
|
||||
statuses = {str(x).upper() for x in inp.descendant_statuses}
|
||||
if statuses & self.RECOVERY_STATUSES:
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=NextAction.RECOVER_EXISTING_CHILD,
|
||||
owner_boundary=False,blocker_code=None,
|
||||
classification=BlockerClassification.SELF_ACTIONABLE,
|
||||
producer_kind=ProducerKind.SELF,producer_id="agent-recovery-executor",
|
||||
action_required="RECOVER_EXISTING_CHILD",evidence_refs=inp.evidence_refs,
|
||||
)
|
||||
if statuses & self.BLOCKED_STATUSES:
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=NextAction.WAIT_DEPENDENCY,
|
||||
owner_boundary=False,blocker_code=None,
|
||||
classification=BlockerClassification.EXTERNAL_DEPENDENCY,
|
||||
producer_kind=ProducerKind.AUTHORITY_SIDE,producer_id="dependency-authority",
|
||||
action_required="UNBLOCK_DEPENDENCY",evidence_refs=inp.evidence_refs,
|
||||
)
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=NextAction.CREATE_OR_RESUME_NEXT_CHILD,
|
||||
owner_boundary=False,blocker_code=None,
|
||||
classification=BlockerClassification.SELF_ACTIONABLE,
|
||||
producer_kind=ProducerKind.SELF,producer_id="autonomy-controller",
|
||||
action_required="CREATE_OR_RESUME_NEXT_CHILD",evidence_refs=inp.evidence_refs,
|
||||
)
|
||||
|
||||
def _decision(self, inp: DecisionInput, blocker: str, binding: ProducerBinding, next_action: NextAction) -> GovernanceDecision:
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=next_action,
|
||||
owner_boundary=binding.classification == BlockerClassification.OWNER_BOUNDARY,
|
||||
blocker_code=blocker,classification=binding.classification,
|
||||
producer_kind=binding.producer_kind,producer_id=binding.producer_id,
|
||||
action_required=binding.action_required,evidence_refs=inp.evidence_refs,
|
||||
)
|
||||
|
||||
|
||||
class MutationGuard:
|
||||
def __init__(self, manifest_store: ManifestStore, action_ledger: ActionLedger):
|
||||
self.manifest_store = manifest_store
|
||||
self.action_ledger = action_ledger
|
||||
self._writer_lock = threading.Lock()
|
||||
|
||||
def begin_mutation(
|
||||
self,
|
||||
*,
|
||||
manifest_id: str,
|
||||
runtime_fingerprint: str,
|
||||
process_start_identity: str,
|
||||
idempotency_key: str,
|
||||
phase: str,
|
||||
kind: ActionKind,
|
||||
target: str,
|
||||
precondition_hash: str,
|
||||
expected_readback: Mapping[str, Any] | None = None,
|
||||
rollback_ref: str | None = None,
|
||||
) -> ActionTicket:
|
||||
self.manifest_store.assert_current(
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint=runtime_fingerprint,
|
||||
process_start_identity=process_start_identity,
|
||||
)
|
||||
if not self._writer_lock.acquire(blocking=False):
|
||||
raise ConcurrentWriterDenied("another governed mutation is in progress")
|
||||
try:
|
||||
ticket = self.action_ledger.begin(
|
||||
idempotency_key=idempotency_key,manifest_id=manifest_id,phase=phase,
|
||||
kind=kind,target=target,precondition_hash=precondition_hash,
|
||||
expected_readback=expected_readback,rollback_ref=rollback_ref,
|
||||
)
|
||||
if ticket.reused:
|
||||
self._writer_lock.release()
|
||||
return ticket
|
||||
except Exception:
|
||||
self._writer_lock.release()
|
||||
raise
|
||||
|
||||
def finish_success(self, ticket: ActionTicket, *, result: Mapping[str, Any], evidence_refs: Sequence[str] = ()) -> None:
|
||||
try:
|
||||
self.action_ledger.mark_succeeded(ticket.action_id, result=result, evidence_refs=evidence_refs)
|
||||
finally:
|
||||
if self._writer_lock.locked():
|
||||
self._writer_lock.release()
|
||||
|
||||
def finish_failed(self, ticket: ActionTicket, *, result: Mapping[str, Any] | None = None, evidence_refs: Sequence[str] = ()) -> None:
|
||||
try:
|
||||
self.action_ledger.mark_failed(ticket.action_id, result=result, evidence_refs=evidence_refs)
|
||||
finally:
|
||||
if self._writer_lock.locked():
|
||||
self._writer_lock.release()
|
||||
|
||||
def finish_effect_unknown(self, ticket: ActionTicket, *, result: Mapping[str, Any] | None = None, evidence_refs: Sequence[str] = ()) -> None:
|
||||
try:
|
||||
self.action_ledger.mark_effect_unknown(ticket.action_id, result=result, evidence_refs=evidence_refs)
|
||||
finally:
|
||||
if self._writer_lock.locked():
|
||||
self._writer_lock.release()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Live driver that converts external surface failures into one checkpoint.
|
||||
|
||||
The controller remains authority-neutral. This driver owns the site boundary:
|
||||
surface availability, binding-contract failures, and poll fallback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .governance_checkpoint import RecoveryCheckpoint, RetryBudget
|
||||
from .governance_recovery_controller import (
|
||||
GovernanceEvent,
|
||||
GovernanceRecoveryController,
|
||||
ReconcileResult,
|
||||
)
|
||||
from .site_adapters import LiveBindingViolation, LiveSurfaceUnavailable
|
||||
|
||||
|
||||
class GovernanceLiveDriver:
|
||||
def __init__(
|
||||
self,
|
||||
controller: GovernanceRecoveryController,
|
||||
*,
|
||||
retry_budget: RetryBudget | None = None,
|
||||
) -> None:
|
||||
self.controller = controller
|
||||
self.retry_budget = retry_budget or controller.retry_budget
|
||||
|
||||
def _candidate_digest(self) -> str:
|
||||
query = self.controller.query
|
||||
return (
|
||||
query.patch_digest
|
||||
or query.artifact_sha256
|
||||
or query.candidate_oid
|
||||
or ""
|
||||
)
|
||||
|
||||
def _persist_external_blocker(
|
||||
self,
|
||||
event: GovernanceEvent,
|
||||
*,
|
||||
code: str,
|
||||
detail: str,
|
||||
mechanism_repair_required: bool,
|
||||
) -> ReconcileResult:
|
||||
previous = self.controller.checkpoints.load()
|
||||
fingerprint = self.retry_budget.blocker_fingerprint(code, (detail,))
|
||||
if mechanism_repair_required:
|
||||
cycles = (
|
||||
previous.unchanged_cycles + 1
|
||||
if previous
|
||||
and previous.blocker_code == code
|
||||
and previous.blocker_fingerprint == fingerprint
|
||||
else 1
|
||||
)
|
||||
else:
|
||||
cycles, _ = self.retry_budget.observe(
|
||||
previous=previous,
|
||||
blocker_code=code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
)
|
||||
next_action = (
|
||||
"REPAIR_MECHANISM"
|
||||
if mechanism_repair_required
|
||||
else "WAIT_DEPENDENCY"
|
||||
)
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.controller.goal_id,
|
||||
repository=self.controller.query.repository,
|
||||
source_oid=self.controller.query.base_oid,
|
||||
candidate_digest=self._candidate_digest(),
|
||||
task_id=None,
|
||||
mission_id=None,
|
||||
root_task_id=None,
|
||||
parent_task_id=None,
|
||||
principal_id=None,
|
||||
blocker_code=code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
next_legal_action=next_action,
|
||||
last_verified_at=self.controller._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=cycles,
|
||||
mechanism_repair_required=mechanism_repair_required,
|
||||
)
|
||||
self.controller.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="BLOCKED",
|
||||
next_legal_action=next_action,
|
||||
task_id=None,
|
||||
blocker_code=code,
|
||||
mechanism_repair_required=mechanism_repair_required,
|
||||
)
|
||||
|
||||
def on_event(self, event: GovernanceEvent) -> ReconcileResult:
|
||||
try:
|
||||
return self.controller.on_event(event)
|
||||
except LiveSurfaceUnavailable as exc:
|
||||
return self._persist_external_blocker(
|
||||
event,
|
||||
code="LIVE_SURFACE_UNAVAILABLE",
|
||||
detail=str(exc),
|
||||
mechanism_repair_required=False,
|
||||
)
|
||||
except LiveBindingViolation as exc:
|
||||
return self._persist_external_blocker(
|
||||
event,
|
||||
code="LIVE_BINDING_VIOLATION",
|
||||
detail=str(exc),
|
||||
mechanism_repair_required=True,
|
||||
)
|
||||
|
||||
def poll_once(
|
||||
self, *, revision: str, occurred_at: str
|
||||
) -> ReconcileResult:
|
||||
return self.on_event(GovernanceEvent(
|
||||
kind="POLL_FALLBACK",
|
||||
revision=revision,
|
||||
occurred_at=occurred_at,
|
||||
))
|
||||
@@ -0,0 +1,474 @@
|
||||
|
||||
"""Event-driven governed recovery reconciler with polling fallback.
|
||||
|
||||
The controller cannot create a task and cannot bypass admission. It resumes
|
||||
only an exact, already-existing child through an injected governed executor.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping, Protocol
|
||||
|
||||
from .governance_admission import (
|
||||
AdmissionOperation,
|
||||
AdmissionPreflight,
|
||||
GovernanceFacts,
|
||||
)
|
||||
from .governance_checkpoint import (
|
||||
CheckpointStore,
|
||||
RecoveryCheckpoint,
|
||||
RetryBudget,
|
||||
)
|
||||
from .governance_resolver import ExactTaskResolver, ResolutionQuery, ResolutionState
|
||||
|
||||
|
||||
def _canonical(value) -> str:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
)
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceEvent:
|
||||
kind: str
|
||||
revision: str
|
||||
occurred_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryReceipt:
|
||||
receipt_ref: str
|
||||
task_id: str
|
||||
accepted: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconcileResult:
|
||||
status: str
|
||||
next_legal_action: str
|
||||
task_id: str | None
|
||||
blocker_code: str | None
|
||||
mechanism_repair_required: bool
|
||||
receipt_ref: str | None = None
|
||||
|
||||
|
||||
class GovernanceFactsProvider(Protocol):
|
||||
def snapshot(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
actor_identity: str,
|
||||
operation: AdmissionOperation,
|
||||
) -> GovernanceFacts: ...
|
||||
|
||||
|
||||
class ExistingChildRecoveryExecutor(Protocol):
|
||||
def recover_existing_child(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
actor_identity: str,
|
||||
idempotency_key: str,
|
||||
facts: GovernanceFacts,
|
||||
) -> RecoveryReceipt: ...
|
||||
|
||||
# Optional at runtime. If implemented, this is preferred after an
|
||||
# unknown-result write so the controller reconciles before re-executing.
|
||||
def reconcile_recovery_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
actor_identity: str,
|
||||
idempotency_key: str,
|
||||
) -> RecoveryReceipt | None: ...
|
||||
|
||||
|
||||
class GovernanceRecoveryController:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
goal_id: str,
|
||||
query: ResolutionQuery,
|
||||
actor_identity: str,
|
||||
resolver: ExactTaskResolver,
|
||||
facts_provider: GovernanceFactsProvider,
|
||||
executor: ExistingChildRecoveryExecutor,
|
||||
checkpoint_store: CheckpointStore,
|
||||
retry_budget: RetryBudget = RetryBudget(),
|
||||
) -> None:
|
||||
self.goal_id = goal_id
|
||||
self.query = query
|
||||
self.actor_identity = actor_identity
|
||||
self.resolver = resolver
|
||||
self.facts_provider = facts_provider
|
||||
self.executor = executor
|
||||
self.checkpoints = checkpoint_store
|
||||
self.retry_budget = retry_budget
|
||||
self.preflight = AdmissionPreflight()
|
||||
|
||||
def _timestamp(self, event: GovernanceEvent) -> str:
|
||||
return event.occurred_at or datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _save_blocked(
|
||||
self,
|
||||
*,
|
||||
event: GovernanceEvent,
|
||||
task_id: str | None,
|
||||
mission_id: str | None,
|
||||
root_task_id: str | None,
|
||||
parent_task_id: str | None,
|
||||
principal_id: str | None,
|
||||
blocker_code: str,
|
||||
blocker_details: tuple[str, ...],
|
||||
) -> ReconcileResult:
|
||||
previous = self.checkpoints.load()
|
||||
fingerprint = self.retry_budget.blocker_fingerprint(
|
||||
blocker_code, blocker_details
|
||||
)
|
||||
cycles, repair = self.retry_budget.observe(
|
||||
previous=previous,
|
||||
blocker_code=blocker_code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
)
|
||||
next_action = "REPAIR_MECHANISM" if repair else "WAIT_DEPENDENCY"
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=(
|
||||
self.query.patch_digest
|
||||
or self.query.artifact_sha256
|
||||
or self.query.candidate_oid
|
||||
or ""
|
||||
),
|
||||
task_id=task_id,
|
||||
mission_id=mission_id,
|
||||
root_task_id=root_task_id,
|
||||
parent_task_id=parent_task_id,
|
||||
principal_id=principal_id,
|
||||
blocker_code=blocker_code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
next_legal_action=next_action,
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=cycles,
|
||||
mechanism_repair_required=repair,
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="BLOCKED",
|
||||
next_legal_action=next_action,
|
||||
task_id=task_id,
|
||||
blocker_code=blocker_code,
|
||||
mechanism_repair_required=repair,
|
||||
)
|
||||
|
||||
def _candidate_digest(self) -> str:
|
||||
return (
|
||||
self.query.patch_digest
|
||||
or self.query.artifact_sha256
|
||||
or self.query.candidate_oid
|
||||
or ""
|
||||
)
|
||||
|
||||
def _recovery_context_digest(
|
||||
self, *, task_id: str, facts: GovernanceFacts
|
||||
) -> str:
|
||||
return _digest({
|
||||
"task_id": task_id,
|
||||
"repository": self.query.repository,
|
||||
"candidate": self._candidate_digest(),
|
||||
"principal": facts.authenticated_principal_id,
|
||||
"branch": facts.target_branch,
|
||||
"branch_oid": facts.expected_branch_oid,
|
||||
"generation": facts.generation,
|
||||
"task_lease_id": facts.task_lease_id,
|
||||
"task_principal_id": facts.task_principal_id,
|
||||
"writer_lease_id": facts.writer_lease_id,
|
||||
"acceptance_revision": facts.acceptance_revision,
|
||||
"file_allowlist": sorted(facts.accepted_file_allowlist),
|
||||
})
|
||||
|
||||
def _finish_recovered(
|
||||
self,
|
||||
*,
|
||||
event: GovernanceEvent,
|
||||
resolved,
|
||||
facts: GovernanceFacts,
|
||||
idempotency_key: str,
|
||||
context_digest: str,
|
||||
receipt: RecoveryReceipt,
|
||||
) -> ReconcileResult:
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=self._candidate_digest(),
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code=None,
|
||||
blocker_fingerprint=None,
|
||||
next_legal_action="ROOT_REVIEW",
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(
|
||||
f"event:{event.kind}:{event.revision}",
|
||||
receipt.receipt_ref,
|
||||
),
|
||||
unchanged_cycles=0,
|
||||
mechanism_repair_required=False,
|
||||
recovery_idempotency_key=idempotency_key,
|
||||
recovery_receipt_ref=receipt.receipt_ref,
|
||||
recovery_context_digest=context_digest,
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="RECOVERED",
|
||||
next_legal_action="ROOT_REVIEW",
|
||||
task_id=resolved.task_id,
|
||||
blocker_code=None,
|
||||
mechanism_repair_required=False,
|
||||
receipt_ref=receipt.receipt_ref,
|
||||
)
|
||||
|
||||
def reconcile(self, event: GovernanceEvent) -> ReconcileResult:
|
||||
resolution = self.resolver.resolve(
|
||||
self.query, actor_identity=self.actor_identity
|
||||
)
|
||||
if resolution.state is not ResolutionState.RESOLVED:
|
||||
code = {
|
||||
ResolutionState.NOT_FOUND: "TASK_NOT_RESOLVED",
|
||||
ResolutionState.AMBIGUOUS: "TASK_RESOLUTION_AMBIGUOUS",
|
||||
ResolutionState.INVALID_QUERY: "TASK_RESOLUTION_INVALID",
|
||||
}[resolution.state]
|
||||
details = (
|
||||
resolution.detail,
|
||||
*resolution.candidate_task_ids,
|
||||
)
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=None,
|
||||
mission_id=None,
|
||||
root_task_id=None,
|
||||
parent_task_id=None,
|
||||
principal_id=None,
|
||||
blocker_code=code,
|
||||
blocker_details=tuple(str(v) for v in details if v),
|
||||
)
|
||||
|
||||
resolved = resolution.resolved
|
||||
assert resolved is not None
|
||||
facts = self.facts_provider.snapshot(
|
||||
task_id=resolved.task_id,
|
||||
actor_identity=self.actor_identity,
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD,
|
||||
)
|
||||
admission = self.preflight.evaluate(
|
||||
resolution=resolution,
|
||||
facts=facts,
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD,
|
||||
)
|
||||
if not admission.ready:
|
||||
primary = admission.blockers[0]
|
||||
details = tuple(
|
||||
f"{blocker.code}:{blocker.detail}"
|
||||
for blocker in admission.blockers
|
||||
)
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code=primary.code,
|
||||
blocker_details=details,
|
||||
)
|
||||
|
||||
context_digest = self._recovery_context_digest(
|
||||
task_id=resolved.task_id, facts=facts
|
||||
)
|
||||
previous = self.checkpoints.load()
|
||||
candidate_digest = self._candidate_digest()
|
||||
unknown_previous = (
|
||||
previous is not None
|
||||
and previous.blocker_code == "RECOVERY_RESULT_UNKNOWN"
|
||||
and previous.task_id == resolved.task_id
|
||||
and previous.candidate_digest == candidate_digest
|
||||
and bool(previous.recovery_idempotency_key)
|
||||
)
|
||||
|
||||
if unknown_previous:
|
||||
idempotency_key = previous.recovery_idempotency_key
|
||||
reconcile = getattr(
|
||||
self.executor, "reconcile_recovery_result", None
|
||||
)
|
||||
if callable(reconcile):
|
||||
receipt = reconcile(
|
||||
task_id=resolved.task_id,
|
||||
actor_identity=self.actor_identity,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if receipt is not None:
|
||||
if receipt.accepted:
|
||||
return self._finish_recovered(
|
||||
event=event,
|
||||
resolved=resolved,
|
||||
facts=facts,
|
||||
idempotency_key=idempotency_key,
|
||||
context_digest=(
|
||||
previous.recovery_context_digest
|
||||
or context_digest
|
||||
),
|
||||
receipt=receipt,
|
||||
)
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_NOT_ACCEPTED",
|
||||
blocker_details=(receipt.receipt_ref,),
|
||||
)
|
||||
if (
|
||||
previous.recovery_context_digest is not None
|
||||
and previous.recovery_context_digest != context_digest
|
||||
):
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=candidate_digest,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
blocker_fingerprint=_digest({
|
||||
"task_id": resolved.task_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"context_changed": True,
|
||||
}),
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=1,
|
||||
mechanism_repair_required=False,
|
||||
recovery_idempotency_key=idempotency_key,
|
||||
recovery_context_digest=(
|
||||
previous.recovery_context_digest
|
||||
),
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="RESULT_UNKNOWN",
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
task_id=resolved.task_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
mechanism_repair_required=False,
|
||||
)
|
||||
else:
|
||||
idempotency_key = "recover-" + _digest({
|
||||
"task_id": resolved.task_id,
|
||||
"repository": self.query.repository,
|
||||
"candidate": candidate_digest,
|
||||
"branch_oid": facts.expected_branch_oid,
|
||||
"generation": facts.generation,
|
||||
"writer_lease_id": facts.writer_lease_id,
|
||||
})[:40]
|
||||
|
||||
try:
|
||||
receipt = self.executor.recover_existing_child(
|
||||
task_id=resolved.task_id,
|
||||
actor_identity=self.actor_identity,
|
||||
idempotency_key=idempotency_key,
|
||||
facts=facts,
|
||||
)
|
||||
except Exception:
|
||||
# Unknown-result write: retain the SAME idempotency key for the next
|
||||
# reconcile. Never assume the side effect did not happen.
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=(
|
||||
self.query.patch_digest
|
||||
or self.query.artifact_sha256
|
||||
or self.query.candidate_oid
|
||||
or ""
|
||||
),
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
blocker_fingerprint=_digest({
|
||||
"task_id": resolved.task_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
}),
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=1,
|
||||
mechanism_repair_required=False,
|
||||
recovery_idempotency_key=idempotency_key,
|
||||
recovery_context_digest=context_digest,
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="RESULT_UNKNOWN",
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
task_id=resolved.task_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
mechanism_repair_required=False,
|
||||
)
|
||||
|
||||
if not receipt.accepted:
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_NOT_ACCEPTED",
|
||||
blocker_details=(receipt.receipt_ref,),
|
||||
)
|
||||
|
||||
return self._finish_recovered(
|
||||
event=event,
|
||||
resolved=resolved,
|
||||
facts=facts,
|
||||
idempotency_key=idempotency_key,
|
||||
context_digest=context_digest,
|
||||
receipt=receipt,
|
||||
)
|
||||
|
||||
def on_event(self, event: GovernanceEvent) -> ReconcileResult:
|
||||
# Event-driven primary path: reconcile immediately in the same call.
|
||||
return self.reconcile(event)
|
||||
|
||||
def poll_once(self, *, revision: str, occurred_at: str) -> ReconcileResult:
|
||||
# Polling is only a fallback and uses exactly the same reconcile path.
|
||||
return self.reconcile(GovernanceEvent(
|
||||
kind="POLL_FALLBACK",
|
||||
revision=revision,
|
||||
occurred_at=occurred_at,
|
||||
))
|
||||
@@ -0,0 +1,229 @@
|
||||
|
||||
"""Read-only exact Task/lineage resolver.
|
||||
|
||||
This is an index/projection over existing TaskPlane authority, not a second task
|
||||
authority. It never creates, claims, mutates, or guesses a task.
|
||||
|
||||
The source supplied by the host MUST enforce the caller's existing TaskPlane
|
||||
read authorization before returning records.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class ResolutionState(str, Enum):
|
||||
RESOLVED = "RESOLVED"
|
||||
NOT_FOUND = "NOT_FOUND"
|
||||
AMBIGUOUS = "AMBIGUOUS"
|
||||
INVALID_QUERY = "INVALID_QUERY"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionQuery:
|
||||
repository: str
|
||||
base_oid: str
|
||||
task_id: str | None = None
|
||||
candidate_oid: str | None = None
|
||||
patch_digest: str | None = None
|
||||
artifact_sha256: str | None = None
|
||||
pr_id: str | None = None
|
||||
release_intent: str | None = None
|
||||
mission_id: str | None = None
|
||||
root_task_id: str | None = None
|
||||
parent_task_id: str | None = None
|
||||
owner_principal_id: str | None = None
|
||||
|
||||
def selectors(self) -> Mapping[str, str]:
|
||||
values = {
|
||||
"repository": self.repository,
|
||||
"base_oid": self.base_oid,
|
||||
"task_id": self.task_id,
|
||||
"candidate_oid": self.candidate_oid,
|
||||
"patch_digest": self.patch_digest,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"pr_id": self.pr_id,
|
||||
"release_intent": self.release_intent,
|
||||
"mission_id": self.mission_id,
|
||||
"root_task_id": self.root_task_id,
|
||||
"parent_task_id": self.parent_task_id,
|
||||
"owner_principal_id": self.owner_principal_id,
|
||||
}
|
||||
return {key: str(value) for key, value in values.items() if value}
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.repository.strip() or not self.base_oid.strip():
|
||||
raise ValueError("repository and base_oid required")
|
||||
strong = (
|
||||
self.task_id,
|
||||
self.candidate_oid,
|
||||
self.patch_digest,
|
||||
self.artifact_sha256,
|
||||
self.pr_id,
|
||||
self.release_intent,
|
||||
self.mission_id,
|
||||
self.root_task_id,
|
||||
)
|
||||
if not any(value for value in strong):
|
||||
raise ValueError(
|
||||
"at least one strong selector required "
|
||||
"(task/candidate/patch/artifact/pr/intent/mission/root)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedTask:
|
||||
task_id: str
|
||||
mission_id: str | None
|
||||
root_task_id: str | None
|
||||
parent_task_id: str | None
|
||||
owner_principal_id: str | None
|
||||
record: Mapping[str, Any]
|
||||
matched_selectors: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionResult:
|
||||
state: ResolutionState
|
||||
resolved: ResolvedTask | None = None
|
||||
candidate_task_ids: tuple[str, ...] = ()
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class TaskResolutionSource(Protocol):
|
||||
"""Caller-authorized read projection over the existing TaskPlane."""
|
||||
|
||||
def get_task(
|
||||
self, task_id: str, *, actor_identity: str
|
||||
) -> Mapping[str, Any] | None: ...
|
||||
|
||||
def list_tasks(
|
||||
self, *, actor_identity: str
|
||||
) -> Sequence[Mapping[str, Any]]: ...
|
||||
|
||||
|
||||
def _mapping(value: Any) -> Mapping[str, Any]:
|
||||
return value if isinstance(value, Mapping) else {}
|
||||
|
||||
|
||||
def _metadata(record: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
plan = _mapping(record.get("plan") or record.get("task_plan"))
|
||||
merged: dict[str, Any] = {}
|
||||
for source in (
|
||||
_mapping(record.get("metadata")),
|
||||
_mapping(plan.get("metadata")),
|
||||
):
|
||||
merged.update(source)
|
||||
# Some existing lineage metadata is nested.
|
||||
for key in ("aios_task_lineage", "lineage", "task_lineage"):
|
||||
nested = _mapping(merged.get(key))
|
||||
for nkey, value in nested.items():
|
||||
merged.setdefault(nkey, value)
|
||||
return merged
|
||||
|
||||
|
||||
def _first(record: Mapping[str, Any], metadata: Mapping[str, Any], *keys: str):
|
||||
for key in keys:
|
||||
if key in record and record.get(key) not in (None, ""):
|
||||
return record.get(key)
|
||||
if key in metadata and metadata.get(key) not in (None, ""):
|
||||
return metadata.get(key)
|
||||
return None
|
||||
|
||||
|
||||
def _projection(record: Mapping[str, Any]) -> Mapping[str, str]:
|
||||
metadata = _metadata(record)
|
||||
values = {
|
||||
"repository": _first(record, metadata, "repository", "repo"),
|
||||
"base_oid": _first(record, metadata, "base_oid", "source_oid"),
|
||||
"task_id": _first(record, metadata, "task_id"),
|
||||
"candidate_oid": _first(record, metadata, "candidate_oid", "head_oid"),
|
||||
"patch_digest": _first(record, metadata, "patch_digest", "candidate_digest"),
|
||||
"artifact_sha256": _first(record, metadata, "artifact_sha256", "artifact_digest"),
|
||||
"pr_id": _first(record, metadata, "pr_id", "pull_request_id"),
|
||||
"release_intent": _first(record, metadata, "release_intent", "release_intent_id"),
|
||||
"mission_id": _first(record, metadata, "mission_id"),
|
||||
"root_task_id": _first(record, metadata, "root_task_id"),
|
||||
"parent_task_id": _first(record, metadata, "parent_task_id"),
|
||||
"owner_principal_id": _first(record, metadata, "owner_principal_id"),
|
||||
}
|
||||
return {
|
||||
key: str(value)
|
||||
for key, value in values.items()
|
||||
if value not in (None, "")
|
||||
}
|
||||
|
||||
|
||||
def _matches(record: Mapping[str, Any], selectors: Mapping[str, str]) -> bool:
|
||||
projection = _projection(record)
|
||||
# Exact-only: a missing selector never counts as a partial match.
|
||||
return all(projection.get(key) == value for key, value in selectors.items())
|
||||
|
||||
|
||||
class ExactTaskResolver:
|
||||
def __init__(self, source: TaskResolutionSource) -> None:
|
||||
self.source = source
|
||||
|
||||
def resolve(
|
||||
self, query: ResolutionQuery, *, actor_identity: str
|
||||
) -> ResolutionResult:
|
||||
try:
|
||||
query.validate()
|
||||
except ValueError as exc:
|
||||
return ResolutionResult(
|
||||
ResolutionState.INVALID_QUERY, detail=str(exc)
|
||||
)
|
||||
|
||||
selectors = query.selectors()
|
||||
|
||||
if query.task_id:
|
||||
record = self.source.get_task(
|
||||
query.task_id, actor_identity=actor_identity
|
||||
)
|
||||
records = [record] if record is not None else []
|
||||
else:
|
||||
records = list(
|
||||
self.source.list_tasks(actor_identity=actor_identity)
|
||||
)
|
||||
|
||||
matches = [
|
||||
record
|
||||
for record in records
|
||||
if record is not None and _matches(record, selectors)
|
||||
]
|
||||
if not matches:
|
||||
return ResolutionResult(
|
||||
ResolutionState.NOT_FOUND,
|
||||
detail="no caller-authorized task exactly matches all selectors",
|
||||
)
|
||||
if len(matches) > 1:
|
||||
ids = sorted(
|
||||
str(_projection(record).get("task_id") or "")
|
||||
for record in matches
|
||||
)
|
||||
return ResolutionResult(
|
||||
ResolutionState.AMBIGUOUS,
|
||||
candidate_task_ids=tuple(ids),
|
||||
detail="multiple exact matches; resolver refuses to guess",
|
||||
)
|
||||
|
||||
record = matches[0]
|
||||
projected = _projection(record)
|
||||
task_id = projected.get("task_id")
|
||||
if not task_id:
|
||||
return ResolutionResult(
|
||||
ResolutionState.NOT_FOUND,
|
||||
detail="matching record lacks authoritative task_id",
|
||||
)
|
||||
resolved = ResolvedTask(
|
||||
task_id=task_id,
|
||||
mission_id=projected.get("mission_id"),
|
||||
root_task_id=projected.get("root_task_id"),
|
||||
parent_task_id=projected.get("parent_task_id"),
|
||||
owner_principal_id=projected.get("owner_principal_id"),
|
||||
record=record,
|
||||
matched_selectors=tuple(sorted(selectors)),
|
||||
)
|
||||
return ResolutionResult(ResolutionState.RESOLVED, resolved=resolved)
|
||||
@@ -0,0 +1,912 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .governance_control_kernel import (
|
||||
ActionKind,
|
||||
ActionLedger,
|
||||
AutonomousDecisionEngine,
|
||||
BlockerClassification,
|
||||
GovernanceControlError,
|
||||
ManifestStore,
|
||||
NextAction,
|
||||
ProducerKind,
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationDenied(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class GovernedWriterUnavailable(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class OwnerActionRequired(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class GovernedWriterState(str, Enum):
|
||||
PREFLIGHT_FROZEN = "PREFLIGHT_FROZEN"
|
||||
OWNER_AUTHORIZED = "OWNER_AUTHORIZED"
|
||||
EPOCH_VALID = "EPOCH_VALID"
|
||||
TASK_CLAIMED = "TASK_CLAIMED"
|
||||
TASK_LEASED = "TASK_LEASED"
|
||||
PRINCIPAL_ISSUED = "PRINCIPAL_ISSUED"
|
||||
JOB_BOUND = "JOB_BOUND"
|
||||
CAS_VERIFIED = "CAS_VERIFIED"
|
||||
WRITER_LEASED = "WRITER_LEASED"
|
||||
|
||||
|
||||
RISK_ORDER = {
|
||||
"low": 10,
|
||||
"medium": 20,
|
||||
"engineering": 25,
|
||||
"high": 30,
|
||||
"critical": 40,
|
||||
}
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return "sha256:" + hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OwnerAuthorizationPolicy:
|
||||
"""Long-lived business authorization, separate from short-lived credentials."""
|
||||
|
||||
authorization_id: str
|
||||
subject: str
|
||||
repository: str
|
||||
allowed_branch_prefixes: tuple[str, ...]
|
||||
allowed_capabilities: frozenset[str]
|
||||
risk_ceiling: str
|
||||
trust_domain: str
|
||||
revocation_epoch: int
|
||||
not_before: float
|
||||
expires_at: float | None = None
|
||||
revoked: bool = False
|
||||
allowed_operations: frozenset[str] = frozenset(
|
||||
{
|
||||
"claim",
|
||||
"heartbeat",
|
||||
"task_lease",
|
||||
"task_principal",
|
||||
"job_reconcile",
|
||||
"writer_lease",
|
||||
"governed_commit",
|
||||
"readback",
|
||||
"reconcile",
|
||||
"epoch_reissue",
|
||||
"successor_recovery",
|
||||
}
|
||||
)
|
||||
|
||||
def validate(self, request: "GovernedWriterRequest", *, now: float | None = None) -> None:
|
||||
now = time.time() if now is None else float(now)
|
||||
if self.revoked:
|
||||
raise AuthorizationDenied("owner authorization revoked")
|
||||
if now < self.not_before:
|
||||
raise AuthorizationDenied("owner authorization not yet valid")
|
||||
if self.expires_at is not None and now >= self.expires_at:
|
||||
raise AuthorizationDenied("owner authorization expired")
|
||||
if request.subject != self.subject:
|
||||
raise AuthorizationDenied("subject mismatch")
|
||||
if request.repository != self.repository:
|
||||
raise AuthorizationDenied("repository outside authorization")
|
||||
if not any(request.target_branch.startswith(p) for p in self.allowed_branch_prefixes):
|
||||
raise AuthorizationDenied("branch outside authorization")
|
||||
if not request.required_capabilities.issubset(self.allowed_capabilities):
|
||||
missing = sorted(request.required_capabilities - self.allowed_capabilities)
|
||||
raise AuthorizationDenied("capability outside authorization: " + ",".join(missing))
|
||||
ceiling = RISK_ORDER.get(self.risk_ceiling.lower())
|
||||
requested = RISK_ORDER.get(request.risk.lower())
|
||||
if ceiling is None or requested is None:
|
||||
raise AuthorizationDenied("unknown risk level")
|
||||
if requested > ceiling:
|
||||
raise AuthorizationDenied("risk exceeds authorization ceiling")
|
||||
|
||||
@property
|
||||
def policy_fingerprint(self) -> str:
|
||||
return _digest(
|
||||
{
|
||||
"authorization_id": self.authorization_id,
|
||||
"subject": self.subject,
|
||||
"repository": self.repository,
|
||||
"allowed_branch_prefixes": list(self.allowed_branch_prefixes),
|
||||
"allowed_capabilities": sorted(self.allowed_capabilities),
|
||||
"risk_ceiling": self.risk_ceiling,
|
||||
"trust_domain": self.trust_domain,
|
||||
"revocation_epoch": self.revocation_epoch,
|
||||
"not_before": self.not_before,
|
||||
"expires_at": self.expires_at,
|
||||
"allowed_operations": sorted(self.allowed_operations),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedWriterRequest:
|
||||
subject: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
generation: int
|
||||
base_oid: str
|
||||
required_capabilities: frozenset[str]
|
||||
file_allowlist: tuple[str, ...]
|
||||
plan_digest: str
|
||||
manifest_id: str
|
||||
runtime_fingerprint: str
|
||||
process_start_identity: str
|
||||
|
||||
@property
|
||||
def scope_fingerprint(self) -> str:
|
||||
return _digest(
|
||||
{
|
||||
"subject": self.subject,
|
||||
"task_id": self.task_id,
|
||||
"repository": self.repository,
|
||||
"target_branch": self.target_branch,
|
||||
"risk": self.risk,
|
||||
"generation": self.generation,
|
||||
"base_oid": self.base_oid,
|
||||
"required_capabilities": sorted(self.required_capabilities),
|
||||
"file_allowlist": list(self.file_allowlist),
|
||||
"plan_digest": self.plan_digest,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedWriteReadiness:
|
||||
"""Unambiguous write-path semantics.
|
||||
|
||||
`legacy_global_writes_enabled=False` is a required safety invariant, not a
|
||||
blocker. `governed_write_path_open` is derived from the scoped writer
|
||||
prerequisites below and is intentionally independent from any ambiguous
|
||||
legacy `writes_enabled` field.
|
||||
"""
|
||||
|
||||
legacy_global_writes_enabled: bool
|
||||
task_principal_writes_enabled: bool
|
||||
task_principal_verifier_ready: bool
|
||||
governed_write_blockers: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def blockers(self) -> tuple[str, ...]:
|
||||
blockers = list(self.governed_write_blockers)
|
||||
if self.legacy_global_writes_enabled:
|
||||
blockers.append("LEGACY_GLOBAL_WRITES_MUST_REMAIN_DISABLED")
|
||||
if not self.task_principal_writes_enabled:
|
||||
blockers.append("TASK_PRINCIPAL_WRITES_DISABLED")
|
||||
if not self.task_principal_verifier_ready:
|
||||
blockers.append("TASK_PRINCIPAL_VERIFIER_NOT_READY")
|
||||
return tuple(dict.fromkeys(blockers))
|
||||
|
||||
@property
|
||||
def governed_write_path_open(self) -> bool:
|
||||
return not self.blockers
|
||||
|
||||
@classmethod
|
||||
def from_runtime_status(cls, status: Mapping[str, Any]) -> "GovernedWriteReadiness":
|
||||
# Deliberately ignore ambiguous `writes_enabled` /
|
||||
# `AIOS_GITOPS_WRITES_ENABLED`; those describe legacy/global surfaces
|
||||
# in older control planes and must never be used as the sole governed
|
||||
# writer readiness signal.
|
||||
readiness = status.get("task_principal_readiness") or {}
|
||||
blockers = status.get("governed_write_blockers")
|
||||
if blockers is None:
|
||||
blockers = status.get("blockers") or ()
|
||||
return cls(
|
||||
legacy_global_writes_enabled=bool(
|
||||
status.get(
|
||||
"legacy_global_writes_enabled",
|
||||
status.get("legacy_writes_enabled", False),
|
||||
)
|
||||
),
|
||||
task_principal_writes_enabled=bool(
|
||||
status.get("task_principal_writes_enabled", False)
|
||||
),
|
||||
task_principal_verifier_ready=bool(
|
||||
readiness.get("ready", status.get("task_principal_verifier_ready", False))
|
||||
),
|
||||
governed_write_blockers=tuple(str(x) for x in blockers),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EpochStatus:
|
||||
epoch_id: str
|
||||
scope_fingerprint: str
|
||||
expires_at: float
|
||||
policy_digest: str
|
||||
|
||||
def is_valid(self, *, now: float, minimum_remaining_ttl: float) -> bool:
|
||||
return (self.expires_at - now) >= minimum_remaining_ttl
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskState:
|
||||
task_id: str
|
||||
status: str
|
||||
revision: int
|
||||
claimed_by: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimReceipt:
|
||||
claim_id: str
|
||||
task_id: str
|
||||
actor: str
|
||||
fencing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskLeaseReceipt:
|
||||
task_lease_id: str
|
||||
task_id: str
|
||||
token_ref: str
|
||||
expires_at: float
|
||||
fencing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskPrincipalReceipt:
|
||||
principal_id: str
|
||||
task_id: str
|
||||
capabilities: frozenset[str]
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobBindingReceipt:
|
||||
job_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchReadback:
|
||||
exists: bool
|
||||
head_oid: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WriterLeaseReceipt:
|
||||
writer_lease_id: str
|
||||
task_id: str
|
||||
job_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
generation: int
|
||||
expires_at: float
|
||||
fencing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedWriterSession:
|
||||
state: GovernedWriterState
|
||||
authorization_id: str
|
||||
policy_fingerprint: str
|
||||
scope_fingerprint: str
|
||||
epoch_id: str
|
||||
task_id: str
|
||||
claim_id: str
|
||||
task_lease_id: str
|
||||
principal_id: str
|
||||
job_id: str
|
||||
writer_lease_id: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
expires_at: float
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = asdict(self)
|
||||
result["state"] = self.state.value
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryAuthorization:
|
||||
"""Pre-approved signed recovery/successor authorization reference."""
|
||||
|
||||
authorization_ref: str
|
||||
task_id: str
|
||||
action: str # TERMINAL_RECOVERY | SUCCESSOR
|
||||
expires_at: float
|
||||
|
||||
|
||||
class TrustedSignerAdapter(Protocol):
|
||||
def reissue_epoch(
|
||||
self,
|
||||
*,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
request: GovernedWriterRequest,
|
||||
prior_epoch: EpochStatus | None,
|
||||
minimum_remaining_ttl: float,
|
||||
idempotency_key: str,
|
||||
) -> EpochStatus: ...
|
||||
|
||||
|
||||
class ExecutionPlane(Protocol):
|
||||
def read_task(self, task_id: str) -> TaskState: ...
|
||||
def apply_recovery(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
authorization: RecoveryAuthorization,
|
||||
idempotency_key: str,
|
||||
) -> TaskState: ...
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
actor: str,
|
||||
idempotency_key: str,
|
||||
) -> ClaimReceipt: ...
|
||||
def heartbeat(self, *, claim: ClaimReceipt) -> None: ...
|
||||
def issue_task_lease(
|
||||
self,
|
||||
*,
|
||||
claim: ClaimReceipt,
|
||||
idempotency_key: str,
|
||||
) -> TaskLeaseReceipt: ...
|
||||
|
||||
|
||||
class TaskPrincipalIssuer(Protocol):
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
lease: TaskLeaseReceipt,
|
||||
requested_capabilities: frozenset[str],
|
||||
idempotency_key: str,
|
||||
) -> TaskPrincipalReceipt: ...
|
||||
|
||||
|
||||
class JobAuthority(Protocol):
|
||||
def read_or_reconcile(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
principal: TaskPrincipalReceipt,
|
||||
idempotency_key: str,
|
||||
) -> JobBindingReceipt: ...
|
||||
|
||||
|
||||
class GitAuthority(Protocol):
|
||||
def read_branch(self, *, repository: str, target_branch: str) -> BranchReadback: ...
|
||||
|
||||
|
||||
class WriterLeaseAuthority(Protocol):
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
job: JobBindingReceipt,
|
||||
principal: TaskPrincipalReceipt,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
branch_mode: str,
|
||||
base_oid: str,
|
||||
expected_branch_oid: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WriterLeaseReceipt: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedOwnerAuthorization:
|
||||
policy: OwnerAuthorizationPolicy
|
||||
authorization_ref: str
|
||||
verification_receipt: str
|
||||
verified_at: float
|
||||
|
||||
|
||||
class OwnerAuthorizationAuthority(Protocol):
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
authorization_ref: str,
|
||||
request: GovernedWriterRequest,
|
||||
) -> VerifiedOwnerAuthorization: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class GovernedWriterAdapters:
|
||||
signer: TrustedSignerAdapter
|
||||
execution: ExecutionPlane
|
||||
principal_issuer: TaskPrincipalIssuer
|
||||
jobs: JobAuthority
|
||||
git: GitAuthority
|
||||
writer_lease: WriterLeaseAuthority
|
||||
owner_authority: OwnerAuthorizationAuthority | None = None
|
||||
|
||||
|
||||
class GovernedWriterAutomation:
|
||||
"""Owner-policy-driven acquisition of a scoped governed writer capability.
|
||||
|
||||
This is the mechanism that removes repeated manual intervention. Long-lived
|
||||
OwnerAuthorizationPolicy establishes allowed scope. Every concrete effect is
|
||||
still protected by fresh runtime manifest, short-lived TaskLease,
|
||||
TaskPrincipal, WriterLease, CAS and idempotency.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
manifest_store: ManifestStore,
|
||||
action_ledger: ActionLedger,
|
||||
decision_engine: AutonomousDecisionEngine | None = None,
|
||||
):
|
||||
self.manifest_store = manifest_store
|
||||
self.action_ledger = action_ledger
|
||||
self.decision_engine = decision_engine or AutonomousDecisionEngine()
|
||||
|
||||
def _begin_action(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
suffix: str,
|
||||
kind: ActionKind,
|
||||
target: str,
|
||||
precondition: Mapping[str, Any],
|
||||
):
|
||||
return self.action_ledger.begin(
|
||||
idempotency_key=f"writer:{request.scope_fingerprint}:{suffix}",
|
||||
manifest_id=request.manifest_id,
|
||||
phase="GOVERNED_WRITER",
|
||||
kind=kind,
|
||||
target=target,
|
||||
precondition_hash=_digest(precondition),
|
||||
)
|
||||
|
||||
def _ensure_epoch(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
now: float,
|
||||
minimum_remaining_ttl: float,
|
||||
) -> EpochStatus:
|
||||
if (
|
||||
epoch is not None
|
||||
and epoch.scope_fingerprint == request.scope_fingerprint
|
||||
and epoch.is_valid(now=now, minimum_remaining_ttl=minimum_remaining_ttl)
|
||||
):
|
||||
return epoch
|
||||
|
||||
# Changed scope may not silently inherit an old Owner grant.
|
||||
if epoch is not None and epoch.scope_fingerprint != request.scope_fingerprint:
|
||||
raise OwnerActionRequired("epoch scope changed; new Owner authorization required")
|
||||
|
||||
ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix="epoch",
|
||||
kind=ActionKind.EPOCH_ISSUANCE,
|
||||
target="trusted-grant-signer-adapter",
|
||||
precondition={
|
||||
"owner_policy": owner_policy.policy_fingerprint,
|
||||
"scope": request.scope_fingerprint,
|
||||
"prior_epoch": None if epoch is None else epoch.epoch_id,
|
||||
},
|
||||
)
|
||||
if ticket.reused and ticket.requires_reconcile:
|
||||
raise GovernedWriterUnavailable("epoch issuance effect unknown; reconcile required")
|
||||
if ticket.reused and ticket.state.value == "SUCCEEDED":
|
||||
# The ledger proves the action succeeded, but the concrete receipt
|
||||
# must be obtained from the signer/readback, not fabricated locally.
|
||||
raise GovernedWriterUnavailable("epoch already issued; signer readback required")
|
||||
|
||||
try:
|
||||
fresh = adapters.signer.reissue_epoch(
|
||||
owner_policy=owner_policy,
|
||||
request=request,
|
||||
prior_epoch=epoch,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
idempotency_key=ticket.idempotency_key,
|
||||
)
|
||||
if fresh.scope_fingerprint != request.scope_fingerprint:
|
||||
raise GovernedWriterUnavailable("signer returned mismatched scope")
|
||||
if not fresh.is_valid(now=now, minimum_remaining_ttl=minimum_remaining_ttl):
|
||||
raise GovernedWriterUnavailable("signer returned insufficient TTL")
|
||||
self.action_ledger.mark_succeeded(
|
||||
ticket.action_id,
|
||||
result={
|
||||
"epoch_id": fresh.epoch_id,
|
||||
"expires_at": fresh.expires_at,
|
||||
"scope_fingerprint": fresh.scope_fingerprint,
|
||||
},
|
||||
)
|
||||
return fresh
|
||||
except OwnerActionRequired:
|
||||
self.action_ledger.mark_failed(ticket.action_id, result={"owner_action": True})
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
def _ensure_claimable_task(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
recovery_authorization: RecoveryAuthorization | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
now: float,
|
||||
) -> TaskState:
|
||||
task = adapters.execution.read_task(request.task_id)
|
||||
status = task.status.upper()
|
||||
if status == "QUEUED":
|
||||
return task
|
||||
if status in {"CLAIMED", "RUNNING"}:
|
||||
return task
|
||||
if status == "EXPIRED":
|
||||
if recovery_authorization is None:
|
||||
raise OwnerActionRequired(
|
||||
"terminal task expired; owner-signed recovery/successor authorization required"
|
||||
)
|
||||
if recovery_authorization.task_id != task.task_id:
|
||||
raise AuthorizationDenied("recovery authorization task mismatch")
|
||||
if recovery_authorization.expires_at <= now:
|
||||
raise AuthorizationDenied("recovery authorization expired")
|
||||
if recovery_authorization.action not in {"TERMINAL_RECOVERY", "SUCCESSOR"}:
|
||||
raise AuthorizationDenied("unsupported recovery action")
|
||||
ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix="task-recovery",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="agent-task-terminal-recovery",
|
||||
precondition={
|
||||
"task_id": task.task_id,
|
||||
"revision": task.revision,
|
||||
"status": task.status,
|
||||
"authorization_ref": recovery_authorization.authorization_ref,
|
||||
"action": recovery_authorization.action,
|
||||
},
|
||||
)
|
||||
if ticket.reused and ticket.requires_reconcile:
|
||||
raise GovernedWriterUnavailable("task recovery effect unknown; reconcile required")
|
||||
try:
|
||||
recovered = adapters.execution.apply_recovery(
|
||||
task=task,
|
||||
authorization=recovery_authorization,
|
||||
idempotency_key=ticket.idempotency_key,
|
||||
)
|
||||
if recovered.status.upper() != "QUEUED":
|
||||
raise GovernedWriterUnavailable(
|
||||
"recovery did not return claimable QUEUED task"
|
||||
)
|
||||
self.action_ledger.mark_succeeded(
|
||||
ticket.action_id,
|
||||
result={
|
||||
"task_id": recovered.task_id,
|
||||
"status": recovered.status,
|
||||
"revision": recovered.revision,
|
||||
},
|
||||
)
|
||||
return recovered
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
raise GovernedWriterUnavailable(f"task status not claimable: {task.status}")
|
||||
|
||||
def acquire_verified(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
authorization_ref: str,
|
||||
readiness: GovernedWriteReadiness,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
minimum_remaining_ttl: float = 1800.0,
|
||||
) -> GovernedWriterSession:
|
||||
"""Production entry: verify Owner authorization before any effect.
|
||||
|
||||
The field composition root MUST use this method. `acquire()` is an
|
||||
internal/test hook for already-verified policy objects.
|
||||
"""
|
||||
if adapters.owner_authority is None:
|
||||
raise AuthorizationDenied("OwnerAuthorizationAuthority is not configured")
|
||||
verified = adapters.owner_authority.verify(
|
||||
authorization_ref=authorization_ref,
|
||||
request=request,
|
||||
)
|
||||
if verified.authorization_ref != authorization_ref:
|
||||
raise AuthorizationDenied("owner authorization reference mismatch")
|
||||
if not verified.verification_receipt:
|
||||
raise AuthorizationDenied("owner authorization verification receipt missing")
|
||||
return self.acquire(
|
||||
request=request,
|
||||
owner_policy=verified.policy,
|
||||
readiness=readiness,
|
||||
epoch=epoch,
|
||||
adapters=adapters,
|
||||
recovery_authorization=recovery_authorization,
|
||||
now=now,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
)
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
readiness: GovernedWriteReadiness,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
minimum_remaining_ttl: float = 1800.0,
|
||||
) -> GovernedWriterSession:
|
||||
now = time.time() if now is None else float(now)
|
||||
|
||||
# Gate 1: authoritative frozen runtime.
|
||||
self.manifest_store.assert_current(
|
||||
manifest_id=request.manifest_id,
|
||||
runtime_fingerprint=request.runtime_fingerprint,
|
||||
process_start_identity=request.process_start_identity,
|
||||
)
|
||||
|
||||
# Gate 2: long-lived Owner policy.
|
||||
owner_policy.validate(request, now=now)
|
||||
|
||||
# Gate 3: scoped writer readiness, deliberately independent from legacy
|
||||
# global-write switches.
|
||||
if not readiness.governed_write_path_open:
|
||||
raise GovernedWriterUnavailable(
|
||||
"governed writer not ready: " + ",".join(readiness.blockers)
|
||||
)
|
||||
|
||||
# Gate 4: JIT delegation epoch. If unchanged scope is authorized, the
|
||||
# Agent may use the trusted signer adapter autonomously.
|
||||
epoch = self._ensure_epoch(
|
||||
request=request,
|
||||
owner_policy=owner_policy,
|
||||
epoch=epoch,
|
||||
adapters=adapters,
|
||||
now=now,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
)
|
||||
|
||||
# Gate 5: task must be claimable, with Owner recovery only when the
|
||||
# lifecycle contract explicitly requires a signed terminal recovery.
|
||||
task = self._ensure_claimable_task(
|
||||
request=request,
|
||||
recovery_authorization=recovery_authorization,
|
||||
adapters=adapters,
|
||||
now=now,
|
||||
)
|
||||
|
||||
# Claim is idempotent per scope/task revision.
|
||||
claim_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"claim-r{task.revision}",
|
||||
kind=ActionKind.CLAIM,
|
||||
target=f"task:{task.task_id}",
|
||||
precondition={"task_id": task.task_id, "revision": task.revision, "status": task.status},
|
||||
)
|
||||
if claim_ticket.reused and claim_ticket.requires_reconcile:
|
||||
raise GovernedWriterUnavailable("claim effect unknown; reconcile required")
|
||||
if task.status.upper() in {"CLAIMED", "RUNNING"}:
|
||||
raise GovernedWriterUnavailable(
|
||||
"existing claim must be reconciled into a concrete claim receipt"
|
||||
)
|
||||
try:
|
||||
claim = adapters.execution.claim(
|
||||
task=task,
|
||||
actor=request.subject,
|
||||
idempotency_key=claim_ticket.idempotency_key,
|
||||
)
|
||||
adapters.execution.heartbeat(claim=claim)
|
||||
self.action_ledger.mark_succeeded(
|
||||
claim_ticket.action_id,
|
||||
result={"claim_id": claim.claim_id, "fencing": claim.fencing},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
claim_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
lease_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"tasklease-{claim.claim_id}",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="task-lease",
|
||||
precondition={"claim_id": claim.claim_id, "fencing": claim.fencing},
|
||||
)
|
||||
try:
|
||||
task_lease = adapters.execution.issue_task_lease(
|
||||
claim=claim,
|
||||
idempotency_key=lease_ticket.idempotency_key,
|
||||
)
|
||||
self.action_ledger.mark_succeeded(
|
||||
lease_ticket.action_id,
|
||||
result={
|
||||
"task_lease_id": task_lease.task_lease_id,
|
||||
"expires_at": task_lease.expires_at,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
lease_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
principal_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"principal-{task_lease.task_lease_id}",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="task-principal-issuer",
|
||||
precondition={
|
||||
"task_lease_id": task_lease.task_lease_id,
|
||||
"capabilities": sorted(request.required_capabilities),
|
||||
},
|
||||
)
|
||||
try:
|
||||
principal = adapters.principal_issuer.issue(
|
||||
lease=task_lease,
|
||||
requested_capabilities=request.required_capabilities,
|
||||
idempotency_key=principal_ticket.idempotency_key,
|
||||
)
|
||||
if not request.required_capabilities.issubset(principal.capabilities):
|
||||
raise GovernedWriterUnavailable(
|
||||
"issued TaskPrincipal lacks required capabilities"
|
||||
)
|
||||
self.action_ledger.mark_succeeded(
|
||||
principal_ticket.action_id,
|
||||
result={"principal_id": principal.principal_id, "expires_at": principal.expires_at},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
principal_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
job_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix="job",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="gitops-job",
|
||||
precondition={
|
||||
"task_id": task.task_id,
|
||||
"principal_id": principal.principal_id,
|
||||
"plan_digest": request.plan_digest,
|
||||
},
|
||||
)
|
||||
try:
|
||||
job = adapters.jobs.read_or_reconcile(
|
||||
task_id=task.task_id,
|
||||
principal=principal,
|
||||
idempotency_key=job_ticket.idempotency_key,
|
||||
)
|
||||
if (
|
||||
job.repository != request.repository
|
||||
or job.target_branch != request.target_branch
|
||||
or job.generation != request.generation
|
||||
):
|
||||
raise GovernedWriterUnavailable("job binding mismatch")
|
||||
self.action_ledger.mark_succeeded(
|
||||
job_ticket.action_id,
|
||||
result={"job_id": job.job_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
job_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
branch = adapters.git.read_branch(
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
)
|
||||
if branch.exists:
|
||||
if not branch.head_oid:
|
||||
raise GovernedWriterUnavailable("branch exists without readable HEAD")
|
||||
branch_mode = "CONTINUE_EXISTING"
|
||||
expected_branch_oid = branch.head_oid
|
||||
else:
|
||||
branch_mode = "CREATE_NEW"
|
||||
expected_branch_oid = request.base_oid
|
||||
|
||||
writer_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"writer-{job.job_id}-{expected_branch_oid}",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="writer-lease",
|
||||
precondition={
|
||||
"job_id": job.job_id,
|
||||
"principal_id": principal.principal_id,
|
||||
"branch_mode": branch_mode,
|
||||
"base_oid": request.base_oid,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"generation": request.generation,
|
||||
},
|
||||
)
|
||||
try:
|
||||
writer = adapters.writer_lease.acquire(
|
||||
task_id=task.task_id,
|
||||
job=job,
|
||||
principal=principal,
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
branch_mode=branch_mode,
|
||||
base_oid=request.base_oid,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
generation=request.generation,
|
||||
idempotency_key=writer_ticket.idempotency_key,
|
||||
)
|
||||
if writer.expected_branch_oid != expected_branch_oid:
|
||||
raise GovernedWriterUnavailable("WriterLease CAS mismatch")
|
||||
self.action_ledger.mark_succeeded(
|
||||
writer_ticket.action_id,
|
||||
result={
|
||||
"writer_lease_id": writer.writer_lease_id,
|
||||
"expires_at": writer.expires_at,
|
||||
"expected_branch_oid": writer.expected_branch_oid,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
writer_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
expires_at = min(
|
||||
epoch.expires_at,
|
||||
task_lease.expires_at,
|
||||
principal.expires_at,
|
||||
writer.expires_at,
|
||||
)
|
||||
return GovernedWriterSession(
|
||||
state=GovernedWriterState.WRITER_LEASED,
|
||||
authorization_id=owner_policy.authorization_id,
|
||||
policy_fingerprint=owner_policy.policy_fingerprint,
|
||||
scope_fingerprint=request.scope_fingerprint,
|
||||
epoch_id=epoch.epoch_id,
|
||||
task_id=task.task_id,
|
||||
claim_id=claim.claim_id,
|
||||
task_lease_id=task_lease.task_lease_id,
|
||||
principal_id=principal.principal_id,
|
||||
job_id=job.job_id,
|
||||
writer_lease_id=writer.writer_lease_id,
|
||||
branch_mode=branch_mode,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Mission-owned, append-only bindings for verified external legacy executions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Mapping, Protocol, Sequence
|
||||
|
||||
from .errors import ConflictError, NotFoundError, ValidationError
|
||||
from .models import compute_sha256_hex
|
||||
|
||||
|
||||
ATTACH_REQUEST_SCHEMA = "aios.mission-legacy-attach-request.v1"
|
||||
ATTACH_EVIDENCE_SCHEMA = "aios.verified-legacy-attachment-evidence.v1"
|
||||
ATTACH_RECEIPT_SCHEMA = "aios.mission-legacy-attachment-receipt.v1"
|
||||
ATTACHMENT_KIND = "LEGACY_EXECUTION_SUBJECT"
|
||||
RELATION_TYPE = "RECOVERY"
|
||||
SOURCE_KIND = "agent-task-bridge-job"
|
||||
REQUIRED_READBACK_ROLES = frozenset(
|
||||
{"agent_task", "admission", "owner_registry", "bridge_job"}
|
||||
)
|
||||
REQUIRED_C04_EVIDENCE = frozenset(
|
||||
{
|
||||
"verified legacy ownership and acceptance relevance",
|
||||
"unchanged task/job/idempotency/reconciliation identity",
|
||||
"authoritative attach readback",
|
||||
}
|
||||
)
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _canonical(value: object) -> str:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("legacy attachment value must be JSON data") from exc
|
||||
|
||||
|
||||
def _object(value: object, fields: frozenset[str], name: str) -> dict[str, object]:
|
||||
if not isinstance(value, Mapping) or set(value) != fields:
|
||||
raise ValidationError(f"invalid {name} fields")
|
||||
return json.loads(_canonical(dict(value)))
|
||||
|
||||
|
||||
def _identifier(value: object, name: str, *, maximum: int = 256) -> str:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or not value
|
||||
or len(value) > maximum
|
||||
or any(ord(character) < 33 for character in value)
|
||||
):
|
||||
raise ValidationError(f"invalid {name}")
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: object, name: str) -> int:
|
||||
if type(value) is not int or value < 0:
|
||||
raise ValidationError(f"invalid {name}")
|
||||
return value
|
||||
|
||||
|
||||
def _utc(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValidationError(f"invalid {name}")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"invalid {name}") from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
raise ValidationError(f"invalid {name}")
|
||||
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorityReadbackRef:
|
||||
"""A non-secret pointer and digest issued by one independent authority."""
|
||||
|
||||
role: str
|
||||
authority: str
|
||||
readback_ref: str
|
||||
canonical_sha256: str
|
||||
observed_at: str
|
||||
revision: int | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
if self.role not in REQUIRED_READBACK_ROLES:
|
||||
raise ValidationError("invalid legacy authority readback role")
|
||||
authority = _identifier(self.authority, "readback authority")
|
||||
reference = _identifier(self.readback_ref, "readback ref", maximum=1024)
|
||||
if not _SHA256_RE.fullmatch(self.canonical_sha256):
|
||||
raise ValidationError("invalid readback canonical SHA-256")
|
||||
observed = _utc(self.observed_at, "readback observed_at")
|
||||
revision = self.revision
|
||||
if revision is not None:
|
||||
revision = _integer(revision, "readback revision")
|
||||
return {
|
||||
"role": self.role,
|
||||
"authority": authority,
|
||||
"readback_ref": reference,
|
||||
"canonical_sha256": self.canonical_sha256,
|
||||
"observed_at": observed,
|
||||
"revision": revision,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedLegacyAttachmentEvidence:
|
||||
"""Typed output of a configured authority reader, never a caller payload."""
|
||||
|
||||
schema: str
|
||||
source_kind: str
|
||||
source_task_id: str
|
||||
source_task_created_at: str
|
||||
source_task_status: str
|
||||
source_task_revision: int
|
||||
source_task_attempt: int
|
||||
source_task_max_attempts: int
|
||||
external_job_id: str
|
||||
internal_job_id: str
|
||||
internal_idempotency_key: str
|
||||
job_branch: str
|
||||
job_state: str
|
||||
job_revision: int
|
||||
side_effect_state: str
|
||||
grant_id: str
|
||||
grant_digest: str
|
||||
execution_digest: str
|
||||
epoch_id: str
|
||||
epoch_digest: str
|
||||
historical_signer_id: str
|
||||
historical_signer_fingerprint: str
|
||||
registry_generation: int
|
||||
signature_verified: bool
|
||||
epoch_currently_valid: bool
|
||||
readbacks: Sequence[AuthorityReadbackRef]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
if self.schema != ATTACH_EVIDENCE_SCHEMA or self.source_kind != SOURCE_KIND:
|
||||
raise ValidationError("invalid legacy attachment evidence schema")
|
||||
if self.signature_verified is not True or self.epoch_currently_valid is not False:
|
||||
raise ConflictError("legacy historical Owner evidence is not verified-expired")
|
||||
values = {
|
||||
"source_task_id": self.source_task_id,
|
||||
"source_task_status": self.source_task_status,
|
||||
"external_job_id": self.external_job_id,
|
||||
"internal_job_id": self.internal_job_id,
|
||||
"internal_idempotency_key": self.internal_idempotency_key,
|
||||
"job_branch": self.job_branch,
|
||||
"job_state": self.job_state,
|
||||
"side_effect_state": self.side_effect_state,
|
||||
"grant_id": self.grant_id,
|
||||
"historical_signer_id": self.historical_signer_id,
|
||||
"historical_signer_fingerprint": self.historical_signer_fingerprint,
|
||||
"epoch_id": self.epoch_id,
|
||||
}
|
||||
normalized = {
|
||||
key: _identifier(value, key, maximum=1024)
|
||||
for key, value in values.items()
|
||||
}
|
||||
for name, value in {
|
||||
"grant_digest": self.grant_digest,
|
||||
"execution_digest": self.execution_digest,
|
||||
"epoch_digest": self.epoch_digest,
|
||||
}.items():
|
||||
if not _SHA256_RE.fullmatch(value):
|
||||
raise ValidationError(f"invalid {name}")
|
||||
normalized[name] = value
|
||||
task_revision = _integer(self.source_task_revision, "source_task_revision")
|
||||
task_attempt = _integer(self.source_task_attempt, "source_task_attempt")
|
||||
task_max_attempts = _integer(
|
||||
self.source_task_max_attempts, "source_task_max_attempts"
|
||||
)
|
||||
if task_attempt > task_max_attempts:
|
||||
raise ConflictError("legacy task attempt exceeds max attempts")
|
||||
job_revision = _integer(self.job_revision, "job_revision")
|
||||
registry_generation = _integer(
|
||||
self.registry_generation, "registry_generation"
|
||||
)
|
||||
readbacks = [value.to_dict() for value in self.readbacks]
|
||||
roles = [str(value["role"]) for value in readbacks]
|
||||
if len(roles) != len(set(roles)) or set(roles) != REQUIRED_READBACK_ROLES:
|
||||
raise ValidationError("legacy evidence requires four authority readbacks")
|
||||
readbacks.sort(key=lambda value: str(value["role"]))
|
||||
return {
|
||||
"schema": ATTACH_EVIDENCE_SCHEMA,
|
||||
"source_kind": SOURCE_KIND,
|
||||
**normalized,
|
||||
"source_task_created_at": _utc(
|
||||
self.source_task_created_at, "source_task_created_at"
|
||||
),
|
||||
"source_task_revision": task_revision,
|
||||
"source_task_attempt": task_attempt,
|
||||
"source_task_max_attempts": task_max_attempts,
|
||||
"job_revision": job_revision,
|
||||
"registry_generation": registry_generation,
|
||||
"signature_verified": True,
|
||||
"epoch_currently_valid": False,
|
||||
"readbacks": readbacks,
|
||||
}
|
||||
|
||||
|
||||
class LegacyAttachmentEvidenceProvider(Protocol):
|
||||
"""Configured server-side reader for the four independent authority sources."""
|
||||
|
||||
def read_verified(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
root_task_id: str,
|
||||
anchor_task_id: str,
|
||||
source_kind: str,
|
||||
source_task_id: str,
|
||||
external_job_id: str,
|
||||
original_acceptance_digest: str,
|
||||
) -> VerifiedLegacyAttachmentEvidence: ...
|
||||
|
||||
|
||||
def normalize_attach_request(value: object) -> dict[str, object]:
|
||||
body = _object(
|
||||
value,
|
||||
frozenset({"schema", "anchor_task_id", "source", "expected"}),
|
||||
"legacy attachment request",
|
||||
)
|
||||
if body["schema"] != ATTACH_REQUEST_SCHEMA:
|
||||
raise ValidationError("invalid legacy attachment request schema")
|
||||
source = _object(
|
||||
body["source"],
|
||||
frozenset({"kind", "task_id", "external_job_id"}),
|
||||
"legacy attachment source",
|
||||
)
|
||||
if source["kind"] != SOURCE_KIND:
|
||||
raise ValidationError("invalid legacy attachment source kind")
|
||||
expected = _object(
|
||||
body["expected"],
|
||||
frozenset(
|
||||
{
|
||||
"root_revision",
|
||||
"anchor_revision",
|
||||
"agent_task_revision",
|
||||
"agent_task_attempt",
|
||||
"internal_job_id",
|
||||
"job_revision",
|
||||
"internal_idempotency_key",
|
||||
}
|
||||
),
|
||||
"legacy attachment expected",
|
||||
)
|
||||
return {
|
||||
"schema": ATTACH_REQUEST_SCHEMA,
|
||||
"anchor_task_id": _identifier(body["anchor_task_id"], "anchor_task_id"),
|
||||
"source": {
|
||||
"kind": SOURCE_KIND,
|
||||
"task_id": _identifier(source["task_id"], "source task_id"),
|
||||
"external_job_id": _identifier(
|
||||
source["external_job_id"], "external_job_id"
|
||||
),
|
||||
},
|
||||
"expected": {
|
||||
"root_revision": _integer(expected["root_revision"], "root_revision"),
|
||||
"anchor_revision": _integer(
|
||||
expected["anchor_revision"], "anchor_revision"
|
||||
),
|
||||
"agent_task_revision": _integer(
|
||||
expected["agent_task_revision"], "agent_task_revision"
|
||||
),
|
||||
"agent_task_attempt": _integer(
|
||||
expected["agent_task_attempt"], "agent_task_attempt"
|
||||
),
|
||||
"internal_job_id": _identifier(
|
||||
expected["internal_job_id"], "internal_job_id"
|
||||
),
|
||||
"job_revision": _integer(expected["job_revision"], "job_revision"),
|
||||
"internal_idempotency_key": _identifier(
|
||||
expected["internal_idempotency_key"], "internal_idempotency_key"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_c04_acceptance(
|
||||
acceptance: Mapping[str, object], *, source_task_id: str, external_job_id: str
|
||||
) -> None:
|
||||
candidate = acceptance.get("legacy_candidate")
|
||||
if (
|
||||
not isinstance(candidate, Mapping)
|
||||
or candidate.get("task_id") != source_task_id
|
||||
or candidate.get("job_id") != external_job_id
|
||||
):
|
||||
raise ConflictError("mission legacy candidate authority mismatch")
|
||||
conditions = acceptance.get("conditions")
|
||||
if not isinstance(conditions, list):
|
||||
raise ConflictError("mission C04 acceptance condition is unavailable")
|
||||
matches = [
|
||||
condition
|
||||
for condition in conditions
|
||||
if isinstance(condition, Mapping) and condition.get("key") == "C04"
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise ConflictError("mission C04 acceptance condition is unavailable")
|
||||
condition = matches[0]
|
||||
evidence = condition.get("required_evidence")
|
||||
if (
|
||||
condition.get("purpose") != "LEGACY-LINEAGE-MIGRATION"
|
||||
or not isinstance(evidence, list)
|
||||
or not REQUIRED_C04_EVIDENCE.issubset(
|
||||
value for value in evidence if isinstance(value, str)
|
||||
)
|
||||
):
|
||||
raise ConflictError("mission C04 acceptance contract mismatch")
|
||||
|
||||
|
||||
def validate_evidence_matches_request(
|
||||
evidence: Mapping[str, object], request: Mapping[str, object]
|
||||
) -> None:
|
||||
source = request["source"]
|
||||
expected = request["expected"]
|
||||
assert isinstance(source, Mapping) and isinstance(expected, Mapping)
|
||||
pairs = {
|
||||
"source_kind": source["kind"],
|
||||
"source_task_id": source["task_id"],
|
||||
"external_job_id": source["external_job_id"],
|
||||
"source_task_revision": expected["agent_task_revision"],
|
||||
"source_task_attempt": expected["agent_task_attempt"],
|
||||
"internal_job_id": expected["internal_job_id"],
|
||||
"job_revision": expected["job_revision"],
|
||||
"internal_idempotency_key": expected["internal_idempotency_key"],
|
||||
}
|
||||
mismatches = [name for name, value in pairs.items() if evidence.get(name) != value]
|
||||
if mismatches:
|
||||
raise ConflictError(
|
||||
"legacy provider evidence mismatch: " + ",".join(sorted(mismatches))
|
||||
)
|
||||
|
||||
|
||||
class LegacyAttachmentLedger:
|
||||
"""Append-only persistence owned by one Mission authority store."""
|
||||
|
||||
def __init__(self, store) -> None:
|
||||
self.store = store
|
||||
with store._transaction():
|
||||
store.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_mission_legacy_attachments (
|
||||
attachment_id TEXT PRIMARY KEY,
|
||||
mission_id TEXT NOT NULL,
|
||||
root_task_id TEXT NOT NULL,
|
||||
anchor_task_id TEXT NOT NULL,
|
||||
authenticated_identity TEXT NOT NULL,
|
||||
owner_principal_id TEXT NOT NULL,
|
||||
attachment_kind TEXT NOT NULL,
|
||||
relation_type TEXT NOT NULL,
|
||||
attachment_sequence INTEGER NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_task_id TEXT NOT NULL,
|
||||
external_job_id TEXT NOT NULL,
|
||||
internal_job_id TEXT NOT NULL,
|
||||
internal_idempotency_key TEXT NOT NULL,
|
||||
request_digest TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
evidence_digest TEXT NOT NULL,
|
||||
receipt_digest TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
UNIQUE (authenticated_identity, idempotency_key),
|
||||
UNIQUE (source_kind, source_task_id),
|
||||
UNIQUE (source_kind, external_job_id),
|
||||
UNIQUE (mission_id, anchor_task_id, attachment_sequence),
|
||||
UNIQUE (mission_id, anchor_task_id, source_kind, source_task_id, external_job_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
store.conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_mission_legacy_attachments "
|
||||
"ON task_mission_legacy_attachments(mission_id, anchor_task_id)"
|
||||
)
|
||||
store.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_mission_acceptance_child_bindings (
|
||||
mission_id TEXT NOT NULL,
|
||||
condition_key TEXT NOT NULL,
|
||||
child_task_id TEXT NOT NULL UNIQUE,
|
||||
original_acceptance_digest TEXT NOT NULL,
|
||||
binding_digest TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
PRIMARY KEY (mission_id, condition_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def acceptance_child_binding(self, mission_id: str, condition_key: str):
|
||||
return self.store.conn.execute(
|
||||
"SELECT * FROM task_mission_acceptance_child_bindings "
|
||||
"WHERE mission_id=? AND condition_key=?",
|
||||
(mission_id, condition_key),
|
||||
).fetchone()
|
||||
|
||||
def bind_acceptance_child(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
condition_key: str,
|
||||
child_task_id: str,
|
||||
original_acceptance_digest: str,
|
||||
binding_digest: str,
|
||||
created_at: float,
|
||||
) -> None:
|
||||
existing = self.acceptance_child_binding(mission_id, condition_key)
|
||||
if existing is not None:
|
||||
if (
|
||||
existing["child_task_id"] != child_task_id
|
||||
or existing["original_acceptance_digest"]
|
||||
!= original_acceptance_digest
|
||||
or existing["binding_digest"] != binding_digest
|
||||
):
|
||||
raise ConflictError(
|
||||
"mission acceptance condition already has a canonical child"
|
||||
)
|
||||
return
|
||||
child_binding = self.store.conn.execute(
|
||||
"SELECT mission_id,condition_key FROM "
|
||||
"task_mission_acceptance_child_bindings WHERE child_task_id=?",
|
||||
(child_task_id,),
|
||||
).fetchone()
|
||||
if child_binding is not None:
|
||||
raise ConflictError("mission child already belongs to an acceptance condition")
|
||||
self.store.conn.execute(
|
||||
"INSERT INTO task_mission_acceptance_child_bindings "
|
||||
"(mission_id,condition_key,child_task_id,original_acceptance_digest,"
|
||||
"binding_digest,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(
|
||||
mission_id,
|
||||
condition_key,
|
||||
child_task_id,
|
||||
original_acceptance_digest,
|
||||
binding_digest,
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
|
||||
def by_idempotency(self, identity: str, key: str):
|
||||
return self.store.conn.execute(
|
||||
"SELECT * FROM task_mission_legacy_attachments "
|
||||
"WHERE authenticated_identity=? AND idempotency_key=?",
|
||||
(identity, key),
|
||||
).fetchone()
|
||||
|
||||
def by_source(self, source_kind: str, task_id: str, external_job_id: str):
|
||||
return self.store.conn.execute(
|
||||
"SELECT * FROM task_mission_legacy_attachments "
|
||||
"WHERE source_kind=? AND (source_task_id=? OR external_job_id=?)",
|
||||
(source_kind, task_id, external_job_id),
|
||||
).fetchone()
|
||||
|
||||
def by_id(self, mission_id: str, attachment_id: str):
|
||||
row = self.store.conn.execute(
|
||||
"SELECT * FROM task_mission_legacy_attachments "
|
||||
"WHERE mission_id=? AND attachment_id=?",
|
||||
(mission_id, attachment_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise NotFoundError("legacy attachment not found")
|
||||
return row
|
||||
|
||||
def next_sequence(self, mission_id: str, anchor_task_id: str) -> int:
|
||||
row = self.store.conn.execute(
|
||||
"SELECT COALESCE(MAX(attachment_sequence),0)+1 "
|
||||
"FROM task_mission_legacy_attachments "
|
||||
"WHERE mission_id=? AND anchor_task_id=?",
|
||||
(mission_id, anchor_task_id),
|
||||
).fetchone()
|
||||
sequence = int(row[0])
|
||||
if not 1 <= sequence <= 99:
|
||||
raise ConflictError("legacy attachment sequence exhausted")
|
||||
return sequence
|
||||
|
||||
def insert(
|
||||
self,
|
||||
*,
|
||||
receipt: Mapping[str, object],
|
||||
authenticated_identity: str,
|
||||
idempotency_key: str,
|
||||
request_digest: str,
|
||||
evidence_digest: str,
|
||||
created_at: float,
|
||||
) -> None:
|
||||
self.store.conn.execute(
|
||||
"INSERT INTO task_mission_legacy_attachments "
|
||||
"(attachment_id,mission_id,root_task_id,anchor_task_id,"
|
||||
"authenticated_identity,owner_principal_id,attachment_kind,"
|
||||
"relation_type,attachment_sequence,display_name,source_kind,"
|
||||
"source_task_id,external_job_id,internal_job_id,"
|
||||
"internal_idempotency_key,request_digest,idempotency_key,"
|
||||
"evidence_digest,receipt_digest,receipt_json,created_at) "
|
||||
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
receipt["attachment_id"], receipt["mission_id"],
|
||||
receipt["root_task_id"], receipt["anchor_task_id"],
|
||||
authenticated_identity, receipt["owner_principal_id"],
|
||||
receipt["attachment_kind"], receipt["relation_type"],
|
||||
receipt["attachment_sequence"], receipt["display_name"],
|
||||
receipt["source"]["source_kind"],
|
||||
receipt["source"]["source_task_id"],
|
||||
receipt["source"]["external_job_id"],
|
||||
receipt["source"]["internal_job_id"],
|
||||
receipt["source"]["internal_idempotency_key"],
|
||||
request_digest, idempotency_key, evidence_digest,
|
||||
receipt["receipt_digest"], _canonical(receipt), created_at,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def receipt(row) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(row["receipt_json"])
|
||||
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise ConflictError("persisted legacy attachment receipt is invalid") from exc
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("schema") != ATTACH_RECEIPT_SCHEMA
|
||||
or value.get("receipt_digest") != row["receipt_digest"]
|
||||
):
|
||||
raise ConflictError("persisted legacy attachment receipt is invalid")
|
||||
digest_payload = dict(value)
|
||||
digest_payload.pop("receipt_digest", None)
|
||||
digest_payload.pop("idempotent", None)
|
||||
if compute_sha256_hex(digest_payload) != row["receipt_digest"]:
|
||||
raise ConflictError("persisted legacy attachment receipt digest mismatch")
|
||||
return value
|
||||
@@ -0,0 +1,846 @@
|
||||
"""Actual TaskPlane binding for task identity, lineage and root review.
|
||||
|
||||
All writes use the existing :class:`TaskPlaneStore` connection and transaction
|
||||
fence. Request metadata is never used as an authorization source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Mapping, Sequence
|
||||
|
||||
if __package__ and "." in __package__: # app.task_plane package import
|
||||
from ..aios_task_lineage import (
|
||||
AcceptanceCondition,
|
||||
AcceptanceEvaluator,
|
||||
NextLegalAction,
|
||||
RelationType,
|
||||
RootAcceptanceSnapshot,
|
||||
RootReviewRecord,
|
||||
TaskRecord as LineageTaskRecord,
|
||||
lineage_from_metadata,
|
||||
normalize_goal_slug,
|
||||
root_review_from_record,
|
||||
)
|
||||
from ..aios_task_lineage_integration import (
|
||||
DISPLAY_NAME_METADATA_KEY,
|
||||
LINEAGE_METADATA_KEY,
|
||||
LINEAGE_REQUEST_DIGEST_KEY,
|
||||
NewTaskIdentityRequest,
|
||||
PERSISTED_DISPATCH_STATUSES,
|
||||
RootActionDispatchReceipt,
|
||||
TaskAuthoritySnapshot,
|
||||
TaskIdentityLineageService,
|
||||
TerminalReviewOutcome,
|
||||
_as_task_record,
|
||||
)
|
||||
from ..aios_task_lineage_taskplane_adapter import (
|
||||
AuthenticatedTaskContext,
|
||||
InProcessTaskPlaneLineageAdapter,
|
||||
TaskPlaneServiceBindings,
|
||||
)
|
||||
from ..aios_task_lineage_hooks import TaskLineageLifecycleHooks
|
||||
else: # top-level task_plane import used by direct-module test harnesses
|
||||
from aios_task_lineage import (
|
||||
AcceptanceCondition,
|
||||
AcceptanceEvaluator,
|
||||
NextLegalAction,
|
||||
RelationType,
|
||||
RootAcceptanceSnapshot,
|
||||
RootReviewRecord,
|
||||
TaskRecord as LineageTaskRecord,
|
||||
lineage_from_metadata,
|
||||
normalize_goal_slug,
|
||||
root_review_from_record,
|
||||
)
|
||||
from aios_task_lineage_integration import (
|
||||
DISPLAY_NAME_METADATA_KEY,
|
||||
LINEAGE_METADATA_KEY,
|
||||
LINEAGE_REQUEST_DIGEST_KEY,
|
||||
NewTaskIdentityRequest,
|
||||
PERSISTED_DISPATCH_STATUSES,
|
||||
RootActionDispatchReceipt,
|
||||
TaskAuthoritySnapshot,
|
||||
TaskIdentityLineageService,
|
||||
TerminalReviewOutcome,
|
||||
_as_task_record,
|
||||
)
|
||||
from aios_task_lineage_taskplane_adapter import (
|
||||
AuthenticatedTaskContext,
|
||||
InProcessTaskPlaneLineageAdapter,
|
||||
TaskPlaneServiceBindings,
|
||||
)
|
||||
from aios_task_lineage_hooks import TaskLineageLifecycleHooks
|
||||
|
||||
from .delegation import DelegationEngine
|
||||
from .errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
||||
from .models import TaskPlan, TaskRecord, canonical_json, compute_sha256_hex
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
RESERVED_LINEAGE_METADATA_KEYS = frozenset(
|
||||
{LINEAGE_METADATA_KEY, DISPLAY_NAME_METADATA_KEY, LINEAGE_REQUEST_DIGEST_KEY}
|
||||
)
|
||||
PROVEN_TERMINAL_STATES = frozenset({"COMPLETED", "FAILED_FINAL", "CANCELLED"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PrincipalLineageBinding:
|
||||
"""Server-owned projection of one authenticated service identity."""
|
||||
|
||||
authenticated_identity: str
|
||||
owner_principal_id: str
|
||||
mission_id: str
|
||||
preferred_owner_alias: str | None
|
||||
root_acceptance_ref: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"authenticated_identity",
|
||||
"owner_principal_id",
|
||||
"mission_id",
|
||||
"root_acceptance_ref",
|
||||
):
|
||||
if not str(getattr(self, name)).strip():
|
||||
raise ValueError(f"{name} is required")
|
||||
|
||||
|
||||
class TaskLineagePolicy:
|
||||
"""Immutable service policy for authenticated identity and acceptance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
principal_bindings: Sequence[PrincipalLineageBinding],
|
||||
acceptance_evaluators: Mapping[str, AcceptanceEvaluator],
|
||||
acceptance_evaluator_resolver: Callable[
|
||||
[str], AcceptanceEvaluator | None
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
self._bindings: dict[str, PrincipalLineageBinding] = {}
|
||||
for binding in principal_bindings:
|
||||
if binding.authenticated_identity in self._bindings:
|
||||
raise ValueError("duplicate authenticated lineage identity")
|
||||
self._bindings[binding.authenticated_identity] = binding
|
||||
self._evaluators = dict(acceptance_evaluators)
|
||||
self._acceptance_evaluator_resolver = acceptance_evaluator_resolver
|
||||
|
||||
def with_acceptance_evaluator_resolver(
|
||||
self, resolver: Callable[[str], AcceptanceEvaluator | None]
|
||||
) -> "TaskLineagePolicy":
|
||||
previous = self._acceptance_evaluator_resolver
|
||||
|
||||
def combined(reference: str) -> AcceptanceEvaluator | None:
|
||||
resolved = resolver(reference)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
return previous(reference) if previous is not None else None
|
||||
|
||||
return TaskLineagePolicy(
|
||||
principal_bindings=tuple(self._bindings.values()),
|
||||
acceptance_evaluators=self._evaluators,
|
||||
acceptance_evaluator_resolver=combined,
|
||||
)
|
||||
|
||||
def resolve_principal(self, authenticated_identity: str) -> PrincipalLineageBinding:
|
||||
try:
|
||||
return self._bindings[authenticated_identity]
|
||||
except KeyError as exc:
|
||||
raise ForbiddenError("authenticated identity has no lineage policy binding") from exc
|
||||
|
||||
def _acceptance_evaluator(self, reference: str) -> AcceptanceEvaluator | None:
|
||||
evaluator = self._evaluators.get(reference)
|
||||
if evaluator is None and self._acceptance_evaluator_resolver is not None:
|
||||
evaluator = self._acceptance_evaluator_resolver(reference)
|
||||
return evaluator
|
||||
|
||||
def assert_acceptance_ref_registered(self, reference: str) -> None:
|
||||
if self._acceptance_evaluator(reference) is None:
|
||||
raise ForbiddenError("root acceptance policy is not registered")
|
||||
|
||||
def evaluate_acceptance(
|
||||
self,
|
||||
root: LineageTaskRecord,
|
||||
descendants: Sequence[LineageTaskRecord],
|
||||
) -> RootAcceptanceSnapshot:
|
||||
reference = root.lineage.root_acceptance_ref
|
||||
evaluator = self._acceptance_evaluator(reference)
|
||||
if evaluator is None:
|
||||
raise ForbiddenError("root acceptance policy is not registered")
|
||||
snapshot = evaluator(root, descendants)
|
||||
if not isinstance(snapshot, RootAcceptanceSnapshot) or not snapshot.conditions:
|
||||
return RootAcceptanceSnapshot(
|
||||
(
|
||||
AcceptanceCondition(
|
||||
key="root-acceptance-proof-unavailable",
|
||||
satisfied=False,
|
||||
owner_boundary=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
normalized: list[AcceptanceCondition] = []
|
||||
for condition in snapshot.conditions:
|
||||
if condition.satisfied and not condition.evidence_refs:
|
||||
normalized.append(
|
||||
AcceptanceCondition(
|
||||
key=condition.key,
|
||||
satisfied=False,
|
||||
owner_boundary=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
normalized.append(condition)
|
||||
return RootAcceptanceSnapshot(tuple(normalized))
|
||||
|
||||
|
||||
class TaskPlaneLineageStoreBinding:
|
||||
"""Official in-process read/write surface over the existing TaskPlane DB."""
|
||||
|
||||
def __init__(self, store: TaskPlaneStore) -> None:
|
||||
self.store = store
|
||||
|
||||
def service_bindings(self) -> TaskPlaneServiceBindings:
|
||||
return TaskPlaneServiceBindings(
|
||||
get_task_snapshot=self.get_task_snapshot,
|
||||
list_descendant_snapshots=self.list_descendant_snapshots,
|
||||
reserve_sibling_sequence=self.reserve_sibling_sequence,
|
||||
compare_and_swap_task_metadata=self.compare_and_swap_task_metadata,
|
||||
get_root_review_record=self.get_root_review_record,
|
||||
compare_and_swap_root_review_record=self.compare_and_swap_root_review_record,
|
||||
get_root_action_dispatch=self.get_root_action_dispatch,
|
||||
dispatch_root_action=self.dispatch_root_action,
|
||||
)
|
||||
|
||||
def _terminal_proof(self, task: TaskRecord) -> Mapping[str, object] | None:
|
||||
row = self.store.conn.execute(
|
||||
"SELECT * FROM task_terminal_proofs WHERE task_id=?", (task.task_id,)
|
||||
).fetchone()
|
||||
if row is None or task.status not in PROVEN_TERMINAL_STATES:
|
||||
return None
|
||||
claim = self.store.conn.execute(
|
||||
"SELECT status,result_payload FROM claims WHERE claim_id=? AND task_id=?",
|
||||
(row["claim_id"], task.task_id),
|
||||
).fetchone()
|
||||
if claim is None or claim["status"] != "COMPLETED" or not claim["result_payload"]:
|
||||
return None
|
||||
try:
|
||||
result = json.loads(claim["result_payload"])
|
||||
refs = tuple(json.loads(row["evidence_refs_json"]))
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if result.get("result_digest") != row["result_digest"]:
|
||||
return None
|
||||
if task.revision < int(row["task_revision"]):
|
||||
return None
|
||||
evidence_ids = {entry["evidence_id"] for entry in self.store.list_evidence(task.task_id)}
|
||||
if not set(refs).issubset(evidence_ids):
|
||||
return None
|
||||
if not self.store.verify_event_chain(task.task_id):
|
||||
return None
|
||||
terminal_event = any(
|
||||
event["type"] in {"TASK_COMPLETED", "TASK_FAILED"}
|
||||
and event["data"].get("claim_id") == row["claim_id"]
|
||||
for event in self.store.list_events(task.task_id)
|
||||
)
|
||||
if not terminal_event or not self.store.verify_evidence_integrity(task.task_id):
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
def get_task_snapshot(self, task_id: str) -> TaskAuthoritySnapshot:
|
||||
task = self.store.get_task(task_id)
|
||||
if not task.owner_principal_id or not task.mission_id:
|
||||
raise ForbiddenError("task lacks server-owned lineage authority binding")
|
||||
proof = self._terminal_proof(task)
|
||||
return TaskAuthoritySnapshot(
|
||||
task_id=task.task_id,
|
||||
revision=task.revision,
|
||||
status=task.status,
|
||||
owner_principal_id=task.owner_principal_id,
|
||||
mission_id=task.mission_id,
|
||||
created_at=datetime.fromtimestamp(task.created_at, tz=timezone.utc),
|
||||
metadata=dict(task.task_plan.metadata),
|
||||
evidence_refs=(
|
||||
tuple(json.loads(proof["evidence_refs_json"])) if proof else ()
|
||||
),
|
||||
terminal_evidence_proven=proof is not None,
|
||||
)
|
||||
|
||||
def list_descendant_snapshots(
|
||||
self, root_task_id: str
|
||||
) -> tuple[TaskAuthoritySnapshot, ...]:
|
||||
with self.store._lock:
|
||||
rows = self.store.conn.execute(
|
||||
"SELECT task_id FROM tasks WHERE root_task_id=? AND task_id<>? "
|
||||
"ORDER BY created_at,task_id",
|
||||
(root_task_id, root_task_id),
|
||||
).fetchall()
|
||||
return tuple(self.get_task_snapshot(row["task_id"]) for row in rows)
|
||||
|
||||
def reserve_sibling_sequence(
|
||||
self, parent_task_id: str, relation_type: RelationType, child_task_id: str
|
||||
) -> int:
|
||||
with self.store._transaction():
|
||||
existing = self.store.conn.execute(
|
||||
"SELECT parent_task_id,relation_type,sibling_sequence "
|
||||
"FROM task_lineage_sequences WHERE child_task_id=?",
|
||||
(child_task_id,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if (
|
||||
existing["parent_task_id"] != parent_task_id
|
||||
or existing["relation_type"] != relation_type.value
|
||||
):
|
||||
raise ConflictError("child sibling reservation conflicts with lineage")
|
||||
return int(existing["sibling_sequence"])
|
||||
row = self.store.conn.execute(
|
||||
"SELECT COALESCE(MAX(sibling_sequence),0)+1 AS next_sequence "
|
||||
"FROM task_lineage_sequences WHERE parent_task_id=? AND relation_type=?",
|
||||
(parent_task_id, relation_type.value),
|
||||
).fetchone()
|
||||
sequence = int(row["next_sequence"])
|
||||
if sequence > 99:
|
||||
raise ConflictError("lineage sibling sequence exhausted")
|
||||
self.store.conn.execute(
|
||||
"INSERT INTO task_lineage_sequences "
|
||||
"(parent_task_id,relation_type,child_task_id,sibling_sequence,created_at) "
|
||||
"VALUES(?,?,?,?,?)",
|
||||
(parent_task_id, relation_type.value, child_task_id, sequence, datetime.now(tz=timezone.utc).timestamp()),
|
||||
)
|
||||
return sequence
|
||||
|
||||
def compare_and_swap_task_metadata(
|
||||
self, task_id: str, expected_revision: int, metadata_patch: Mapping[str, object]
|
||||
) -> bool:
|
||||
if not set(metadata_patch).issubset(RESERVED_LINEAGE_METADATA_KEYS):
|
||||
raise ForbiddenError("lineage CAS contains non-lineage metadata")
|
||||
with self.store._transaction():
|
||||
task = self.store.get_task(task_id)
|
||||
if task.revision != expected_revision:
|
||||
return False
|
||||
lineage_value = metadata_patch.get(LINEAGE_METADATA_KEY)
|
||||
if isinstance(lineage_value, Mapping):
|
||||
lineage = lineage_from_metadata(lineage_value)
|
||||
if lineage.relation_type != RelationType.ROOT:
|
||||
# The root check and child metadata CAS share this BEGIN
|
||||
# IMMEDIATE transaction, closing the legacy attach race.
|
||||
self.store.assert_root_open_for_child(lineage.root_task_id)
|
||||
if task.status in PROVEN_TERMINAL_STATES:
|
||||
raise ConflictError("terminal task lineage metadata is immutable")
|
||||
payload = task.task_plan.to_dict(redact=False)
|
||||
metadata = dict(payload.get("metadata") or {})
|
||||
for key, value in metadata_patch.items():
|
||||
if key in metadata and metadata[key] != value:
|
||||
raise ConflictError("authoritative lineage metadata conflict")
|
||||
metadata[key] = value
|
||||
payload["metadata"] = metadata
|
||||
now = datetime.now(tz=timezone.utc).timestamp()
|
||||
updated = self.store.conn.execute(
|
||||
"UPDATE tasks SET task_json=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=? AND revision=?",
|
||||
(json.dumps(payload, ensure_ascii=False), now, task_id, expected_revision),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
return False
|
||||
self.store.append_event(
|
||||
task_id,
|
||||
"TASK_LINEAGE_BOUND",
|
||||
{"metadata_keys": sorted(metadata_patch)},
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
return True
|
||||
|
||||
def record_terminal_proof(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
claim_id: str,
|
||||
result_digest: str,
|
||||
evidence_refs: Sequence[str],
|
||||
) -> None:
|
||||
with self.store._transaction():
|
||||
task = self.store.get_task(task_id)
|
||||
if task.status not in PROVEN_TERMINAL_STATES:
|
||||
raise ConflictError("terminal proof requires a final task status")
|
||||
claim = self.store.conn.execute(
|
||||
"SELECT status,result_payload FROM claims WHERE claim_id=? AND task_id=?",
|
||||
(claim_id, task_id),
|
||||
).fetchone()
|
||||
if claim is None or claim["status"] != "COMPLETED" or not claim["result_payload"]:
|
||||
raise ConflictError("terminal proof requires a completed claim")
|
||||
stored_result = json.loads(claim["result_payload"])
|
||||
if stored_result.get("result_digest") != result_digest:
|
||||
raise ConflictError("terminal proof result digest mismatch")
|
||||
refs = tuple(str(value) for value in evidence_refs)
|
||||
evidence_ids = {entry["evidence_id"] for entry in self.store.list_evidence(task_id)}
|
||||
if not set(refs).issubset(evidence_ids):
|
||||
raise ConflictError("terminal proof evidence reference mismatch")
|
||||
existing = self.store.conn.execute(
|
||||
"SELECT * FROM task_terminal_proofs WHERE task_id=?", (task_id,)
|
||||
).fetchone()
|
||||
expected = (claim_id, result_digest, canonical_json(list(refs)))
|
||||
if existing is not None:
|
||||
actual = (
|
||||
existing["claim_id"],
|
||||
existing["result_digest"],
|
||||
canonical_json(json.loads(existing["evidence_refs_json"])),
|
||||
)
|
||||
if actual != expected:
|
||||
raise ConflictError("terminal proof replay conflict")
|
||||
return
|
||||
if not self.store.verify_event_chain(task_id) or not self.store.verify_evidence_integrity(task_id):
|
||||
raise ConflictError("terminal proof journal integrity failed")
|
||||
self.store.conn.execute(
|
||||
"INSERT INTO task_terminal_proofs "
|
||||
"(task_id,claim_id,task_revision,result_digest,evidence_refs_json,created_at) "
|
||||
"VALUES(?,?,?,?,?,?)",
|
||||
(
|
||||
task_id,
|
||||
claim_id,
|
||||
task.revision,
|
||||
result_digest,
|
||||
json.dumps(list(refs), ensure_ascii=False),
|
||||
datetime.now(tz=timezone.utc).timestamp(),
|
||||
),
|
||||
)
|
||||
|
||||
def get_root_review_record(self, review_id: str) -> Mapping[str, object] | None:
|
||||
row = self.store.conn.execute(
|
||||
"SELECT record_json FROM task_root_reviews WHERE review_id=?", (review_id,)
|
||||
).fetchone()
|
||||
return None if row is None else json.loads(row["record_json"])
|
||||
|
||||
def get_root_review_for_trigger(
|
||||
self, trigger_child_task_id: str
|
||||
) -> Mapping[str, object] | None:
|
||||
row = self.store.conn.execute(
|
||||
"SELECT record_json FROM task_root_reviews "
|
||||
"WHERE trigger_child_task_id=? ORDER BY created_at LIMIT 1",
|
||||
(trigger_child_task_id,),
|
||||
).fetchone()
|
||||
return None if row is None else json.loads(row["record_json"])
|
||||
|
||||
def compare_and_swap_root_review_record(
|
||||
self,
|
||||
root_task_id: str,
|
||||
expected_root_revision: int,
|
||||
review_record: Mapping[str, object],
|
||||
) -> bool:
|
||||
with self.store._transaction():
|
||||
review_id = str(review_record.get("review_id") or "")
|
||||
trigger_id = str(review_record.get("trigger_child_task_id") or "")
|
||||
if (
|
||||
review_record.get("root_task_id") != root_task_id
|
||||
or int(review_record.get("root_revision_before", -1)) != expected_root_revision
|
||||
or int(review_record.get("root_revision_after", -1)) != expected_root_revision + 1
|
||||
):
|
||||
raise ConflictError("root review authority binding mismatch")
|
||||
existing = self.get_root_review_record(review_id)
|
||||
if existing is not None:
|
||||
if canonical_json(existing) != canonical_json(dict(review_record)):
|
||||
raise ConflictError("root review replay conflict")
|
||||
return True
|
||||
trigger = self.get_task_snapshot(trigger_id)
|
||||
if not trigger.terminal_evidence_proven:
|
||||
raise ConflictError("root review trigger is not proven terminal")
|
||||
trigger_revision = trigger.revision
|
||||
frozen = self.store.conn.execute(
|
||||
"SELECT review_id FROM task_root_reviews "
|
||||
"WHERE trigger_child_task_id=? AND trigger_child_revision=?",
|
||||
(trigger_id, trigger_revision),
|
||||
).fetchone()
|
||||
if frozen is not None:
|
||||
raise ConflictError("terminal trigger already has a different root review")
|
||||
root = self.store.get_task(root_task_id)
|
||||
if root.revision != expected_root_revision:
|
||||
return False
|
||||
now = datetime.now(tz=timezone.utc).timestamp()
|
||||
self.store.conn.execute(
|
||||
"INSERT INTO task_root_reviews "
|
||||
"(review_id,root_task_id,trigger_child_task_id,trigger_child_revision,"
|
||||
"root_revision_before,root_revision_after,record_json,created_at) "
|
||||
"VALUES(?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
review_id,
|
||||
root_task_id,
|
||||
trigger_id,
|
||||
trigger_revision,
|
||||
expected_root_revision,
|
||||
expected_root_revision + 1,
|
||||
json.dumps(dict(review_record), ensure_ascii=False, sort_keys=True),
|
||||
now,
|
||||
),
|
||||
)
|
||||
updated = self.store.conn.execute(
|
||||
"UPDATE tasks SET revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=? AND revision=?",
|
||||
(now, root_task_id, expected_root_revision),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise ConflictError("root review CAS lost after reservation")
|
||||
self.store.append_event(
|
||||
root_task_id,
|
||||
"ROOT_REVIEW",
|
||||
{
|
||||
"review_id": review_id,
|
||||
"trigger_child_task_id": trigger_id,
|
||||
"next_legal_action": review_record.get("next_legal_action"),
|
||||
"root_complete": bool(review_record.get("root_complete")),
|
||||
},
|
||||
subject=f"task/{root_task_id}",
|
||||
)
|
||||
return True
|
||||
|
||||
def get_root_action_dispatch(self, review_id: str) -> RootActionDispatchReceipt | None:
|
||||
row = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_dispatches WHERE review_id=?", (review_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return RootActionDispatchReceipt(
|
||||
review_id=row["review_id"],
|
||||
root_task_id=row["root_task_id"],
|
||||
action=NextLegalAction(row["action"]),
|
||||
idempotency_key=row["idempotency_key"],
|
||||
status=row["status"],
|
||||
mission_id=row["mission_id"],
|
||||
root_revision=int(row["root_revision"]),
|
||||
plan_digest=row["plan_digest"],
|
||||
execution_key=row["execution_key"],
|
||||
)
|
||||
|
||||
def dispatch_root_action(
|
||||
self, review: RootReviewRecord, idempotency_key: str
|
||||
) -> RootActionDispatchReceipt:
|
||||
with self.store._transaction():
|
||||
persisted = self.get_root_review_record(review.review_id)
|
||||
if persisted is None:
|
||||
raise ConflictError("root action requires a persisted root review")
|
||||
existing = self.get_root_action_dispatch(review.review_id)
|
||||
if existing is not None:
|
||||
if (
|
||||
existing.root_task_id != review.root_task_id
|
||||
or existing.action != review.next_legal_action
|
||||
or existing.idempotency_key != idempotency_key
|
||||
):
|
||||
raise ConflictError("root action dispatch replay conflict")
|
||||
return existing
|
||||
now = datetime.now(tz=timezone.utc).timestamp()
|
||||
self.store.conn.execute(
|
||||
"INSERT INTO task_root_action_dispatches "
|
||||
"(review_id,root_task_id,action,idempotency_key,status,created_at,"
|
||||
"mission_id,root_revision,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
review.review_id,
|
||||
review.root_task_id,
|
||||
review.next_legal_action.value,
|
||||
idempotency_key,
|
||||
"PENDING",
|
||||
now,
|
||||
self.store.get_task(review.root_task_id).mission_id,
|
||||
review.root_revision_after,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self.store.append_event(
|
||||
review.root_task_id,
|
||||
"ROOT_NEXT_ACTION_PENDING",
|
||||
{
|
||||
"review_id": review.review_id,
|
||||
"action": review.next_legal_action.value,
|
||||
"idempotency_key": idempotency_key,
|
||||
},
|
||||
subject=f"task/{review.root_task_id}",
|
||||
)
|
||||
return self.get_root_action_dispatch(review.review_id) # type: ignore[return-value]
|
||||
|
||||
|
||||
class TaskPlaneLineageCoordinator:
|
||||
"""Coordinates lineage with real TaskPlane admission and completion paths."""
|
||||
|
||||
def __init__(self, store: TaskPlaneStore, policy: TaskLineagePolicy) -> None:
|
||||
self.store = store
|
||||
self.policy = policy
|
||||
self.binding = TaskPlaneLineageStoreBinding(store)
|
||||
self.adapter = InProcessTaskPlaneLineageAdapter(
|
||||
self.binding.service_bindings(), readback=self.binding
|
||||
)
|
||||
self.service = TaskIdentityLineageService(self.adapter)
|
||||
self.hooks = TaskLineageLifecycleHooks(self.adapter)
|
||||
|
||||
@staticmethod
|
||||
def _request_digest(payload: Mapping[str, object]) -> str:
|
||||
return compute_sha256_hex(dict(payload))
|
||||
|
||||
@staticmethod
|
||||
def _reject_reserved_metadata(plan: TaskPlan) -> None:
|
||||
if set(plan.metadata).intersection(RESERVED_LINEAGE_METADATA_KEYS):
|
||||
raise ValidationError("lineage metadata is server-owned")
|
||||
|
||||
@staticmethod
|
||||
def _goal_slug(plan: TaskPlan) -> str:
|
||||
for candidate in (plan.intent, plan.task_type, f"TASK-{plan.task_id}"):
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
return normalize_goal_slug(str(candidate))
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValidationError("task goal cannot be normalized for lineage")
|
||||
|
||||
def _existing_by_idempotency(self, key: str | None) -> TaskRecord | None:
|
||||
if not key:
|
||||
return None
|
||||
row = self.store.conn.execute(
|
||||
"SELECT task_id FROM tasks WHERE idempotency_key=?", (key,)
|
||||
).fetchone()
|
||||
return None if row is None else self.store.get_task(row["task_id"])
|
||||
|
||||
@staticmethod
|
||||
def _verify_replay(
|
||||
task: TaskRecord,
|
||||
*,
|
||||
request_digest: str,
|
||||
principal: PrincipalLineageBinding,
|
||||
parent_task_id: str | None,
|
||||
) -> TaskRecord:
|
||||
metadata = task.task_plan.metadata
|
||||
if (
|
||||
metadata.get(LINEAGE_REQUEST_DIGEST_KEY) != request_digest
|
||||
or task.owner_principal_id != principal.owner_principal_id
|
||||
or task.mission_id != principal.mission_id
|
||||
or task.task_plan.parent_task_id != parent_task_id
|
||||
):
|
||||
raise ConflictError("lineage admission replay conflict")
|
||||
return task
|
||||
|
||||
def create_root(
|
||||
self,
|
||||
*,
|
||||
plan: TaskPlan,
|
||||
authenticated_identity: str,
|
||||
request_digest: str,
|
||||
idempotency_key: str | None,
|
||||
) -> TaskRecord:
|
||||
principal = self.policy.resolve_principal(authenticated_identity)
|
||||
return self.create_root_for_principal(
|
||||
plan=plan,
|
||||
principal=principal,
|
||||
request_digest=request_digest,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
def create_root_for_principal(
|
||||
self,
|
||||
*,
|
||||
plan: TaskPlan,
|
||||
principal: PrincipalLineageBinding,
|
||||
request_digest: str,
|
||||
idempotency_key: str | None,
|
||||
created_at: datetime | None = None,
|
||||
) -> TaskRecord:
|
||||
self.policy.assert_acceptance_ref_registered(principal.root_acceptance_ref)
|
||||
self._reject_reserved_metadata(plan)
|
||||
with self.store._transaction():
|
||||
existing = self._existing_by_idempotency(idempotency_key)
|
||||
if existing is not None:
|
||||
return self._verify_replay(
|
||||
existing,
|
||||
request_digest=request_digest,
|
||||
principal=principal,
|
||||
parent_task_id=None,
|
||||
)
|
||||
created_at = created_at or datetime.now(tz=timezone.utc)
|
||||
if created_at.tzinfo is None or created_at.utcoffset() is None:
|
||||
raise ValidationError("lineage created_at must be timezone-aware")
|
||||
created_at = created_at.astimezone(timezone.utc)
|
||||
prepared = self.service.prepare_new_task_identity(
|
||||
NewTaskIdentityRequest(
|
||||
task_id=str(plan.task_id),
|
||||
owner_principal_id=principal.owner_principal_id,
|
||||
preferred_owner_alias=principal.preferred_owner_alias,
|
||||
created_at=created_at,
|
||||
mission_id=principal.mission_id,
|
||||
goal_slug=self._goal_slug(plan),
|
||||
root_acceptance_ref=principal.root_acceptance_ref,
|
||||
relation_type=RelationType.ROOT,
|
||||
)
|
||||
)
|
||||
plan.metadata = {
|
||||
**plan.metadata,
|
||||
**prepared.metadata_patch,
|
||||
LINEAGE_REQUEST_DIGEST_KEY: request_digest,
|
||||
}
|
||||
return self.store.create_task_with_event(
|
||||
plan,
|
||||
idempotency_key=idempotency_key,
|
||||
owner_principal_id=principal.owner_principal_id,
|
||||
mission_id=principal.mission_id,
|
||||
created_at=created_at.timestamp(),
|
||||
)
|
||||
|
||||
def delegate_child(
|
||||
self,
|
||||
*,
|
||||
parent_task_id: str,
|
||||
plan: TaskPlan,
|
||||
authenticated_identity: str,
|
||||
request_digest: str,
|
||||
delegation: DelegationEngine,
|
||||
principal: PrincipalLineageBinding | None = None,
|
||||
) -> TaskRecord:
|
||||
if not plan.task_id:
|
||||
raise ValidationError("lineage delegation requires a stable task_id")
|
||||
principal = principal or self.policy.resolve_principal(authenticated_identity)
|
||||
if principal.authenticated_identity != authenticated_identity:
|
||||
raise ForbiddenError("delegation principal identity mismatch")
|
||||
self._reject_reserved_metadata(plan)
|
||||
with self.store._transaction():
|
||||
parent = self.store.get_task(parent_task_id)
|
||||
root = self.store.get_task(parent.root_task_id)
|
||||
if (
|
||||
root.owner_principal_id != principal.owner_principal_id
|
||||
or root.mission_id != principal.mission_id
|
||||
):
|
||||
raise ForbiddenError("delegation actor does not match root authority")
|
||||
try:
|
||||
existing = self.store.get_task(str(plan.task_id))
|
||||
except NotFoundError:
|
||||
existing = None
|
||||
if existing is not None:
|
||||
return self._verify_replay(
|
||||
existing,
|
||||
request_digest=request_digest,
|
||||
principal=principal,
|
||||
parent_task_id=parent_task_id,
|
||||
)
|
||||
self.store.assert_root_open_for_child(root.task_id)
|
||||
created_at = datetime.now(tz=timezone.utc)
|
||||
prepared = self.service.prepare_new_task_identity(
|
||||
NewTaskIdentityRequest(
|
||||
task_id=str(plan.task_id),
|
||||
owner_principal_id=root.owner_principal_id,
|
||||
preferred_owner_alias=principal.preferred_owner_alias,
|
||||
created_at=created_at,
|
||||
mission_id=root.mission_id,
|
||||
goal_slug=self._goal_slug(plan),
|
||||
root_acceptance_ref=str(
|
||||
root.task_plan.metadata[LINEAGE_METADATA_KEY]["root_acceptance_ref"]
|
||||
),
|
||||
relation_type=RelationType.CHILD,
|
||||
parent_task_id=parent_task_id,
|
||||
)
|
||||
)
|
||||
plan.metadata = {
|
||||
**plan.metadata,
|
||||
**prepared.metadata_patch,
|
||||
LINEAGE_REQUEST_DIGEST_KEY: request_digest,
|
||||
}
|
||||
return delegation.delegate(
|
||||
parent_task_id,
|
||||
plan,
|
||||
caller=authenticated_identity,
|
||||
owner_principal_id=root.owner_principal_id,
|
||||
mission_id=root.mission_id,
|
||||
created_at=created_at.timestamp(),
|
||||
)
|
||||
|
||||
def current_acceptance_snapshot(self, *, root_task_id: str) -> RootAcceptanceSnapshot:
|
||||
"""Evaluate the current authoritative root/descendant projection."""
|
||||
root = _as_task_record(self.binding.get_task_snapshot(root_task_id))
|
||||
descendants = tuple(
|
||||
_as_task_record(snapshot)
|
||||
for snapshot in self.binding.list_descendant_snapshots(root_task_id)
|
||||
)
|
||||
return self.policy.evaluate_acceptance(root, descendants)
|
||||
|
||||
def review_proven_terminal_child(
|
||||
self,
|
||||
*,
|
||||
child_task_id: str,
|
||||
claim_id: str,
|
||||
result_digest: str,
|
||||
evidence_refs: Sequence[str],
|
||||
):
|
||||
with self.store._transaction():
|
||||
self.binding.record_terminal_proof(
|
||||
task_id=child_task_id,
|
||||
claim_id=claim_id,
|
||||
result_digest=result_digest,
|
||||
evidence_refs=evidence_refs,
|
||||
)
|
||||
return self.resume_terminal_review(child_task_id=child_task_id)
|
||||
|
||||
def resume_terminal_review(self, *, child_task_id: str) -> TerminalReviewOutcome:
|
||||
"""Resume after restart without re-completing a claim or re-evaluating a review.
|
||||
|
||||
Once a trigger has a persisted review, that exact decision is frozen.
|
||||
Policy/evaluator drift therefore cannot create a second decision for the
|
||||
same terminal transition.
|
||||
"""
|
||||
with self.store._transaction():
|
||||
child = self.binding.get_task_snapshot(child_task_id)
|
||||
if not child.terminal_evidence_proven:
|
||||
raise ConflictError("terminal review resume requires durable proof")
|
||||
raw_lineage = child.metadata.get(LINEAGE_METADATA_KEY)
|
||||
if not isinstance(raw_lineage, Mapping):
|
||||
raise ConflictError("lineage child lacks authoritative lineage metadata")
|
||||
try:
|
||||
child_lineage = lineage_from_metadata(dict(raw_lineage))
|
||||
except ValueError as exc:
|
||||
raise ConflictError("lineage child metadata is invalid") from exc
|
||||
persisted = self.binding.get_root_review_for_trigger(child_task_id)
|
||||
if persisted is not None:
|
||||
review_id = persisted.get("review_id")
|
||||
if not isinstance(review_id, str) or not review_id:
|
||||
raise ConflictError("persisted root review identity is invalid")
|
||||
try:
|
||||
review = root_review_from_record(
|
||||
persisted, expected_review_id=review_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ConflictError("persisted root review is invalid") from exc
|
||||
root = self.binding.get_task_snapshot(child_lineage.root_task_id)
|
||||
if (
|
||||
review.trigger_child_task_id != child_task_id
|
||||
or review.root_task_id != child_lineage.root_task_id
|
||||
or child_lineage.task_id != child_task_id
|
||||
or child_lineage.owner_principal_id != child.owner_principal_id
|
||||
or child_lineage.mission_id != child.mission_id
|
||||
or root.owner_principal_id != child.owner_principal_id
|
||||
or root.mission_id != child.mission_id
|
||||
or root.revision < review.root_revision_after
|
||||
):
|
||||
raise ConflictError("persisted root review authority mismatch")
|
||||
dispatch = self.binding.get_root_action_dispatch(review.review_id)
|
||||
if dispatch is None:
|
||||
dispatch = self.binding.dispatch_root_action(
|
||||
review,
|
||||
f"root-review:{review.review_id}:{review.next_legal_action.value}",
|
||||
)
|
||||
expected_key = (
|
||||
f"root-review:{review.review_id}:{review.next_legal_action.value}"
|
||||
)
|
||||
if (
|
||||
dispatch.review_id != review.review_id
|
||||
or dispatch.root_task_id != review.root_task_id
|
||||
or dispatch.action != review.next_legal_action
|
||||
or dispatch.idempotency_key != expected_key
|
||||
or dispatch.status not in PERSISTED_DISPATCH_STATUSES
|
||||
):
|
||||
raise ConflictError("persisted root action dispatch is invalid")
|
||||
return TerminalReviewOutcome(review=review, dispatch=dispatch)
|
||||
hook_outcome = self.hooks.on_proven_terminal_child(
|
||||
child_task_id=child_task_id,
|
||||
context=AuthenticatedTaskContext(
|
||||
principal_id=child.owner_principal_id,
|
||||
preferred_alias=child_lineage.owner_alias,
|
||||
mission_id=child.mission_id,
|
||||
),
|
||||
acceptance_evaluator=self.policy.evaluate_acceptance,
|
||||
reviewed_at=datetime.now(tz=timezone.utc),
|
||||
require_independent_readback=False,
|
||||
)
|
||||
return hook_outcome.outcome
|
||||
@@ -0,0 +1,972 @@
|
||||
"""Live AIOS protocol adapters for governed-writer execution.
|
||||
|
||||
This module closes the gap between the generic governed-writer model and the
|
||||
live field contracts observed on the V2R2C3 control plane:
|
||||
|
||||
- agent-task-api :18940
|
||||
* POST /v1/agents/register
|
||||
* GET /v1/tasks/{task_id}
|
||||
* POST /v1/tasks/{task_id}/claim -> lease_token
|
||||
* POST /v1/tasks/{task_id}/heartbeat
|
||||
- task-principal-issuer :18946
|
||||
* POST /v1/task-principal/assertions
|
||||
* Authorization: TaskLease <lease-token>
|
||||
* one assertion per concrete operation + bridge_request
|
||||
- bridge :18892
|
||||
* GET /v2/runtime
|
||||
* POST /v2/writer-lease
|
||||
* POST /v2/commit-files
|
||||
* Authorization: TaskPrincipal <operation-assertion>
|
||||
|
||||
WriterLease and commit-files share one continuation idempotency key.
|
||||
|
||||
The module never prints or returns the agent token, lease token or TaskPrincipal
|
||||
assertion. Secret values are kept only in an in-memory handle store.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
GovernedWriteReadiness,
|
||||
GovernedWriterRequest,
|
||||
OwnerAuthorizationPolicy,
|
||||
)
|
||||
|
||||
|
||||
class LiveProtocolError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class LiveProtocolAuthError(LiveProtocolError):
|
||||
pass
|
||||
|
||||
|
||||
class LiveProtocolConflict(LiveProtocolError):
|
||||
pass
|
||||
|
||||
|
||||
class LiveProtocolUnavailable(LiveProtocolError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpResult:
|
||||
status: int
|
||||
body: Mapping[str, Any]
|
||||
|
||||
|
||||
class JsonHttpTransport:
|
||||
"""Small stdlib JSON transport. Authorization values are never logged."""
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
body: Mapping[str, Any] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
timeout: float = 20.0,
|
||||
) -> HttpResult:
|
||||
payload = None
|
||||
req_headers = {"Accept": "application/json"}
|
||||
if body is not None:
|
||||
payload = json.dumps(
|
||||
dict(body),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
req_headers["Content-Type"] = "application/json"
|
||||
if headers:
|
||||
req_headers.update({str(k): str(v) for k, v in headers.items()})
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers=req_headers,
|
||||
method=method.upper(),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read()
|
||||
status = int(resp.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read()
|
||||
status = int(exc.code)
|
||||
except (OSError, urllib.error.URLError) as exc:
|
||||
raise LiveProtocolUnavailable(
|
||||
f"{method.upper()} {urllib.parse.urlsplit(url).path}: transport unavailable"
|
||||
) from exc
|
||||
if not raw:
|
||||
obj: Any = {}
|
||||
else:
|
||||
try:
|
||||
obj = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise LiveProtocolError(
|
||||
f"{method.upper()} {urllib.parse.urlsplit(url).path}: non-JSON response"
|
||||
) from exc
|
||||
if not isinstance(obj, Mapping):
|
||||
raise LiveProtocolError(
|
||||
f"{method.upper()} {urllib.parse.urlsplit(url).path}: response must be object"
|
||||
)
|
||||
return HttpResult(status=status, body=dict(obj))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveProtocolConfig:
|
||||
bridge_url: str = "http://127.0.0.1:18892"
|
||||
agent_task_url: str = "http://127.0.0.1:18940"
|
||||
principal_issuer_url: str = "http://127.0.0.1:18946"
|
||||
control_token_file: str = (
|
||||
"/run/credentials/aios-agent-task-api.service/control_token"
|
||||
)
|
||||
authority_git_dir: str = "/data/aios-gitops/repo.git"
|
||||
issue_schema: str = "aios.task-principal-issue.v1"
|
||||
agent_type: str = "aios.p0-task-admission-worker"
|
||||
agent_version: str = "governed-writer-live-v1"
|
||||
lease_seconds: int = 900
|
||||
assertion_ttl_seconds: int = 60
|
||||
minimum_remaining_window_seconds: int = 240
|
||||
|
||||
def normalized(self) -> "LiveProtocolConfig":
|
||||
return LiveProtocolConfig(
|
||||
bridge_url=self.bridge_url.rstrip("/"),
|
||||
agent_task_url=self.agent_task_url.rstrip("/"),
|
||||
principal_issuer_url=self.principal_issuer_url.rstrip("/"),
|
||||
control_token_file=self.control_token_file,
|
||||
authority_git_dir=self.authority_git_dir,
|
||||
issue_schema=self.issue_schema,
|
||||
agent_type=self.agent_type,
|
||||
agent_version=self.agent_version,
|
||||
lease_seconds=int(self.lease_seconds),
|
||||
assertion_ttl_seconds=int(self.assertion_ttl_seconds),
|
||||
minimum_remaining_window_seconds=int(
|
||||
self.minimum_remaining_window_seconds
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SecretHandleStore:
|
||||
"""Process-local secret container.
|
||||
|
||||
Public receipts contain handles only; secret values are not serialized.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._values: dict[str, str] = {}
|
||||
|
||||
def put(self, kind: str, value: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise LiveProtocolAuthError(f"{kind} secret missing")
|
||||
handle = f"{kind}:{secrets.token_hex(12)}"
|
||||
self._values[handle] = value
|
||||
return handle
|
||||
|
||||
def get(self, handle: str) -> str:
|
||||
try:
|
||||
return self._values[handle]
|
||||
except KeyError as exc:
|
||||
raise LiveProtocolAuthError("secret handle not available") from exc
|
||||
|
||||
def drop(self, handle: str) -> None:
|
||||
self._values.pop(handle, None)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveTask:
|
||||
task_id: str
|
||||
status: str
|
||||
revision: int
|
||||
deadline_epoch: float | None
|
||||
raw: Mapping[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveClaim:
|
||||
task_id: str
|
||||
claim_ref: str
|
||||
server_claim_id: str | None
|
||||
lease_handle: str
|
||||
fencing_generation: int | None
|
||||
lease_expires_at: float | None
|
||||
task_revision: int | None
|
||||
public_receipt: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveWriterLease:
|
||||
# /v2/writer-lease is a validated binding projection. The durable branch
|
||||
# lease is created only by the continuation executor during commit.
|
||||
writer_lease_id: str | None
|
||||
task_id: str
|
||||
job_id: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
generation: int
|
||||
expires_at: float | None
|
||||
continuation_key: str
|
||||
raw: Mapping[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveWriterSession:
|
||||
authorization_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
claim_ref: str
|
||||
job_id: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
writer_lease_id: str | None
|
||||
continuation_key: str
|
||||
principal_issue_schema: str
|
||||
agent_token_handle: str = field(repr=False)
|
||||
lease_handle: str = field(repr=False)
|
||||
task_lease_receipt: Mapping[str, Any] = field(default_factory=dict)
|
||||
writer_lease_binding: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
public = {
|
||||
"authorization_id": self.authorization_id,
|
||||
"task_id": self.task_id,
|
||||
"agent_id": self.agent_id,
|
||||
"claim_ref": self.claim_ref,
|
||||
"job_id": self.job_id,
|
||||
"branch_mode": self.branch_mode,
|
||||
"expected_branch_oid": self.expected_branch_oid,
|
||||
"continuation_key": self.continuation_key,
|
||||
"principal_issue_schema": self.principal_issue_schema,
|
||||
"writer_lease_binding": dict(self.writer_lease_binding),
|
||||
}
|
||||
return public
|
||||
|
||||
|
||||
def _parse_epoch(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
pass
|
||||
# ISO-8601 minimal support.
|
||||
import datetime as dt
|
||||
|
||||
try:
|
||||
return dt.datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _safe_ref_from_token(token: str, prefix: str) -> str:
|
||||
return f"{prefix}:sha256:{hashlib.sha256(token.encode('utf-8')).hexdigest()[:24]}"
|
||||
|
||||
|
||||
class LiveAgentTaskClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: LiveProtocolConfig,
|
||||
*,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
secrets_store: SecretHandleStore | None = None,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.secrets = secrets_store or SecretHandleStore()
|
||||
|
||||
def _control_token(self) -> str:
|
||||
path = Path(self.config.control_token_file)
|
||||
try:
|
||||
token = path.read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
raise LiveProtocolAuthError(
|
||||
"agent-task control token file unavailable"
|
||||
) from exc
|
||||
if len(token) < 8:
|
||||
raise LiveProtocolAuthError("agent-task control token invalid")
|
||||
return token
|
||||
|
||||
def _control_headers(self) -> dict[str, str]:
|
||||
return {"Authorization": "Bearer " + self._control_token()}
|
||||
|
||||
def read_task(self, task_id: str) -> LiveTask:
|
||||
quoted = urllib.parse.quote(task_id, safe="")
|
||||
result = self.transport.request(
|
||||
"GET",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}",
|
||||
headers=self._control_headers(),
|
||||
timeout=15,
|
||||
)
|
||||
if result.status == 404:
|
||||
raise LiveProtocolConflict("task not found")
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"task read failed HTTP {result.status}")
|
||||
raw = result.body
|
||||
task_obj = raw.get("task") if isinstance(raw.get("task"), Mapping) else raw
|
||||
status = str(task_obj.get("status") or "")
|
||||
if not status:
|
||||
raise LiveProtocolError("task readback missing status")
|
||||
revision = int(task_obj.get("revision") or 0)
|
||||
return LiveTask(
|
||||
task_id=str(task_obj.get("task_id") or task_id),
|
||||
status=status,
|
||||
revision=revision,
|
||||
deadline_epoch=_parse_epoch(task_obj.get("deadline")),
|
||||
raw=dict(task_obj),
|
||||
)
|
||||
|
||||
def register_agent(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
capabilities: Sequence[str],
|
||||
route: str,
|
||||
agent_id: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
if agent_id is None:
|
||||
agent_id = (
|
||||
"governed-writer-"
|
||||
+ hashlib.sha256(
|
||||
(task_id + "\0" + str(time.time_ns())).encode("utf-8")
|
||||
).hexdigest()[:20]
|
||||
)
|
||||
body = {
|
||||
"agent_id": agent_id,
|
||||
"agent_type": self.config.agent_type,
|
||||
"agent_version": self.config.agent_version,
|
||||
"capabilities": list(capabilities),
|
||||
"metadata": {"task_id": task_id, "route": route},
|
||||
}
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/agents/register",
|
||||
body=body,
|
||||
headers=self._control_headers(),
|
||||
timeout=15,
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"agent register failed HTTP {result.status}")
|
||||
token = result.body.get("agent_token")
|
||||
if not isinstance(token, str) or len(token) < 8:
|
||||
raise LiveProtocolAuthError("agent register returned no usable token")
|
||||
return agent_id, self.secrets.put("agent-token", token)
|
||||
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_token_handle: str,
|
||||
lease_seconds: int | None = None,
|
||||
) -> LiveClaim:
|
||||
lease_seconds = int(lease_seconds or self.config.lease_seconds)
|
||||
quoted = urllib.parse.quote(task_id, safe="")
|
||||
token = self.secrets.get(agent_token_handle)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}/claim",
|
||||
body={"lease_seconds": lease_seconds},
|
||||
headers={"Authorization": "Bearer " + token},
|
||||
timeout=15,
|
||||
)
|
||||
if result.status == 409:
|
||||
raise LiveProtocolConflict(
|
||||
str(result.body.get("error") or "task not claimable")
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"claim failed HTTP {result.status}")
|
||||
if result.body.get("status") != "CLAIMED":
|
||||
raise LiveProtocolError("claim response not CLAIMED")
|
||||
lease_token = result.body.get("lease_token")
|
||||
if not isinstance(lease_token, str) or len(lease_token) < 8:
|
||||
raise LiveProtocolAuthError("claim returned no lease token")
|
||||
server_claim_id = result.body.get("claim_id")
|
||||
lease_id = result.body.get("lease_id")
|
||||
public_ref = None
|
||||
for candidate in (server_claim_id, lease_id):
|
||||
if isinstance(candidate, str) and candidate:
|
||||
public_ref = candidate
|
||||
break
|
||||
if public_ref is None:
|
||||
public_ref = _safe_ref_from_token(lease_token, "claim")
|
||||
lease_handle = self.secrets.put("task-lease", lease_token)
|
||||
fencing = result.body.get("fencing_generation")
|
||||
return LiveClaim(
|
||||
task_id=task_id,
|
||||
claim_ref=public_ref,
|
||||
server_claim_id=(
|
||||
str(server_claim_id)
|
||||
if isinstance(server_claim_id, str) and server_claim_id
|
||||
else None
|
||||
),
|
||||
lease_handle=lease_handle,
|
||||
fencing_generation=int(fencing) if fencing is not None else None,
|
||||
lease_expires_at=_parse_epoch(result.body.get("lease_expires_at")),
|
||||
task_revision=(
|
||||
int(result.body["receipt_revision"])
|
||||
if result.body.get("receipt_revision") is not None
|
||||
else None
|
||||
),
|
||||
public_receipt={k: result.body[k] for k in (
|
||||
"task_id", "status", "claim_id", "lease_id", "fencing_generation",
|
||||
"receipt_revision", "lease_expires_at", "attempt", "revision",
|
||||
) if k in result.body},
|
||||
)
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
*,
|
||||
claim: LiveClaim,
|
||||
agent_token_handle: str,
|
||||
phase: str,
|
||||
lease_seconds: int | None = None,
|
||||
) -> LiveTask:
|
||||
lease_seconds = int(lease_seconds or self.config.lease_seconds)
|
||||
quoted = urllib.parse.quote(claim.task_id, safe="")
|
||||
agent_token = self.secrets.get(agent_token_handle)
|
||||
lease_token = self.secrets.get(claim.lease_handle)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}/heartbeat",
|
||||
body={
|
||||
"lease_token": lease_token,
|
||||
"lease_seconds": lease_seconds,
|
||||
"progress": {"phase": phase, "status": "running"},
|
||||
},
|
||||
headers={"Authorization": "Bearer " + agent_token},
|
||||
timeout=15,
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"heartbeat failed HTTP {result.status}")
|
||||
if result.body.get("status") not in ("RUNNING", "CLAIMED"):
|
||||
raise LiveProtocolError("heartbeat did not yield RUNNING/CLAIMED")
|
||||
task = self.read_task(claim.task_id)
|
||||
if task.status not in ("RUNNING", "CLAIMED"):
|
||||
raise LiveProtocolConflict(
|
||||
f"authoritative task state after heartbeat is {task.status}"
|
||||
)
|
||||
return task
|
||||
|
||||
def submit_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
agent_token_handle: str,
|
||||
lease_handle: str,
|
||||
idempotency_key: str,
|
||||
result_value: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(idempotency_key, str) or not 1 <= len(idempotency_key) <= 160:
|
||||
raise LiveProtocolError("task result idempotency key invalid")
|
||||
if not isinstance(result_value, Mapping):
|
||||
raise LiveProtocolError("task result mapping required")
|
||||
quoted = urllib.parse.quote(task_id, safe="")
|
||||
agent_token = self.secrets.get(agent_token_handle)
|
||||
lease_token = self.secrets.get(lease_handle)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}/result",
|
||||
body={
|
||||
"lease_token": lease_token,
|
||||
"idempotency_key": idempotency_key,
|
||||
"result": dict(result_value),
|
||||
},
|
||||
headers={"Authorization": "Bearer " + agent_token},
|
||||
timeout=15,
|
||||
)
|
||||
if result.status == 409:
|
||||
raise LiveProtocolConflict(
|
||||
str(result.body.get("error") or "task result conflict")
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"task result HTTP {result.status}")
|
||||
if (
|
||||
result.body.get("task_id") != task_id
|
||||
or result.body.get("status") != "VERIFYING"
|
||||
or result.body.get("claimed_by") != agent_id
|
||||
or result.body.get("result") != dict(result_value)
|
||||
):
|
||||
raise LiveProtocolConflict("task result readback mismatch")
|
||||
return dict(result.body)
|
||||
|
||||
|
||||
class LiveTaskPrincipalClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: LiveProtocolConfig,
|
||||
*,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
secrets_store: SecretHandleStore,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.secrets = secrets_store
|
||||
self.public_receipts: list[dict[str, Any]] = []
|
||||
|
||||
def issue_assertion(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_handle: str,
|
||||
operation: str,
|
||||
bridge_request: Mapping[str, Any],
|
||||
request_id: str,
|
||||
) -> str:
|
||||
lease_token = self.secrets.get(lease_handle)
|
||||
body = {
|
||||
"schema": self.config.issue_schema,
|
||||
"request_id": request_id,
|
||||
"task_id": task_id,
|
||||
"operation": operation,
|
||||
"bridge_request": dict(bridge_request),
|
||||
"requested_ttl_seconds": self.config.assertion_ttl_seconds,
|
||||
}
|
||||
last: HttpResult | None = None
|
||||
for attempt in range(2):
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.principal_issuer_url}/v1/task-principal/assertions",
|
||||
body=body,
|
||||
headers={"Authorization": "TaskLease " + lease_token},
|
||||
timeout=20,
|
||||
)
|
||||
last = result
|
||||
assertion = result.body.get("assertion")
|
||||
if result.status in (200, 201) and isinstance(assertion, str) and assertion:
|
||||
self.public_receipts.append({
|
||||
"task_id": task_id, "operation": operation, "request_id": request_id,
|
||||
"assertion_sha256": hashlib.sha256(assertion.encode()).hexdigest(),
|
||||
"issuer_response": {k: result.body[k] for k in ("jti", "expires_at", "replayed") if k in result.body},
|
||||
})
|
||||
return assertion
|
||||
if not (
|
||||
attempt == 0
|
||||
and result.status == 503
|
||||
and result.body.get("error") == "TASK_PRINCIPAL_ISSUER_UNAVAILABLE"
|
||||
):
|
||||
break
|
||||
time.sleep(1)
|
||||
status = 0 if last is None else last.status
|
||||
raise LiveProtocolError(
|
||||
f"TaskPrincipal assertion issue failed operation={operation} HTTP {status}"
|
||||
)
|
||||
|
||||
|
||||
class LiveGitReader:
|
||||
def __init__(self, git_dir: str) -> None:
|
||||
self.git_dir = str(git_dir)
|
||||
|
||||
def branch_head(self, branch: str) -> str | None:
|
||||
proc = subprocess.run(
|
||||
["git", "--git-dir", self.git_dir, "rev-parse", f"refs/heads/{branch}"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
head = proc.stdout.strip()
|
||||
if len(head) != 40 or any(c not in "0123456789abcdef" for c in head):
|
||||
raise LiveProtocolError("git branch HEAD is not a full OID")
|
||||
return head
|
||||
|
||||
def file_sha256(self, oid: str, path: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["git", "--git-dir", self.git_dir, "show", f"{oid}:{path}"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise LiveProtocolError(f"git readback failed for {path}")
|
||||
return hashlib.sha256(proc.stdout).hexdigest()
|
||||
|
||||
|
||||
class LiveBridgeClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: LiveProtocolConfig,
|
||||
*,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
principal_client: LiveTaskPrincipalClient,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.principal_client = principal_client
|
||||
|
||||
def runtime_status(self) -> Mapping[str, Any]:
|
||||
result = self.transport.request(
|
||||
"GET", f"{self.config.bridge_url}/v2/runtime", timeout=10
|
||||
)
|
||||
if result.status != 200:
|
||||
raise LiveProtocolUnavailable(f"bridge runtime HTTP {result.status}")
|
||||
return result.body
|
||||
|
||||
def acquire_writer_lease(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
job_id: str,
|
||||
lease_handle: str,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
generation: int,
|
||||
base_oid: str,
|
||||
expected_branch_oid: str,
|
||||
branch_mode: str,
|
||||
continuation_key: str,
|
||||
) -> LiveWriterLease:
|
||||
req = {
|
||||
"schema": "aios.task-writer-lease.v2",
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"idempotency_key": continuation_key,
|
||||
"repository": repository,
|
||||
"branch_mode": branch_mode,
|
||||
"base_oid": base_oid,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"target_branch": target_branch,
|
||||
"generation": int(generation),
|
||||
"lease_seconds": int(self.config.lease_seconds),
|
||||
}
|
||||
assertion = self.principal_client.issue_assertion(
|
||||
task_id=task_id,
|
||||
lease_handle=lease_handle,
|
||||
operation="acquireWriterLease",
|
||||
bridge_request=req,
|
||||
request_id="writer-lease-" + secrets.token_hex(8),
|
||||
)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.bridge_url}/v2/writer-lease",
|
||||
body=req,
|
||||
headers={"Authorization": "TaskPrincipal " + assertion},
|
||||
timeout=30,
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"writer lease HTTP {result.status}")
|
||||
body = result.body
|
||||
expected_binding = {
|
||||
"schema": "aios.task-writer-lease.v2",
|
||||
"task_id": task_id,
|
||||
"repository": repository,
|
||||
"base_oid": base_oid,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"target_branch": target_branch,
|
||||
"generation": int(generation),
|
||||
"lease_seconds": int(self.config.lease_seconds),
|
||||
}
|
||||
for field, expected in expected_binding.items():
|
||||
if body.get(field) != expected:
|
||||
raise LiveProtocolConflict(f"writer lease {field} mismatch")
|
||||
projected_branch_mode = body.get("branch_mode")
|
||||
allowed_branch_modes = {branch_mode}
|
||||
# CREATE_NEW is materialized by the Bridge before it returns the
|
||||
# descriptor, so the returned binding is CONTINUE_EXISTING.
|
||||
if branch_mode == "CREATE_NEW":
|
||||
allowed_branch_modes.add("CONTINUE_EXISTING")
|
||||
if projected_branch_mode not in allowed_branch_modes:
|
||||
raise LiveProtocolConflict("writer lease branch_mode mismatch")
|
||||
return LiveWriterLease(
|
||||
writer_lease_id=None,
|
||||
task_id=task_id,
|
||||
job_id=job_id,
|
||||
branch_mode=str(projected_branch_mode),
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
generation=int(generation),
|
||||
expires_at=None,
|
||||
continuation_key=continuation_key,
|
||||
raw=dict(body),
|
||||
)
|
||||
|
||||
def commit_files(
|
||||
self,
|
||||
*,
|
||||
session: LiveWriterSession,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
generation: int,
|
||||
files: Mapping[str, bytes],
|
||||
message: str,
|
||||
) -> Mapping[str, Any]:
|
||||
entries = [
|
||||
{
|
||||
"path": path,
|
||||
"content_base64": base64.b64encode(data).decode("ascii"),
|
||||
}
|
||||
for path, data in files.items()
|
||||
]
|
||||
req = {
|
||||
"task_id": session.task_id,
|
||||
"job_id": session.job_id,
|
||||
"repository": repository,
|
||||
"expected_branch_oid": session.expected_branch_oid,
|
||||
"target_branch": target_branch,
|
||||
"generation": int(generation),
|
||||
"idempotency_key": session.continuation_key,
|
||||
"files": entries,
|
||||
"message": message,
|
||||
}
|
||||
assertion = self.principal_client.issue_assertion(
|
||||
task_id=session.task_id,
|
||||
lease_handle=session.lease_handle,
|
||||
operation="commitFilesGoverned",
|
||||
bridge_request=req,
|
||||
request_id="commit-files-" + secrets.token_hex(8),
|
||||
)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.bridge_url}/v2/commit-files",
|
||||
body=req,
|
||||
headers={"Authorization": "TaskPrincipal " + assertion},
|
||||
timeout=45,
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"commit-files HTTP {result.status}")
|
||||
return dict(result.body)
|
||||
|
||||
|
||||
class LiveGovernedWriterExecutor:
|
||||
"""Execute the real post-admission writer chain.
|
||||
|
||||
Prerequisites:
|
||||
- Owner authorization was already verified by the caller/authority.
|
||||
- A fresh admission task exists and is QUEUED.
|
||||
- The task's execution plan already binds the supplied job_id.
|
||||
- A valid delegation epoch/admission window is already active.
|
||||
|
||||
This executor deliberately does not create/rewrite admission plans or sign
|
||||
Owner epochs. Those remain in the existing admission/signer authority.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config: LiveProtocolConfig,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
authorization_ref: str,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
agent_id: str | None = None,
|
||||
scope_enrollment: Callable[..., Mapping[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.owner_policy = owner_policy
|
||||
self.authorization_ref = authorization_ref
|
||||
if scope_enrollment is not None and (not callable(scope_enrollment) or not agent_id):
|
||||
raise ValueError("scope enrollment requires a configured agent identity")
|
||||
self.agent_id = agent_id
|
||||
self.scope_enrollment = scope_enrollment
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.secret_store = SecretHandleStore()
|
||||
self.agent = LiveAgentTaskClient(
|
||||
self.config,
|
||||
transport=self.transport,
|
||||
secrets_store=self.secret_store,
|
||||
)
|
||||
self.principals = LiveTaskPrincipalClient(
|
||||
self.config,
|
||||
transport=self.transport,
|
||||
secrets_store=self.secret_store,
|
||||
)
|
||||
self.bridge = LiveBridgeClient(
|
||||
self.config,
|
||||
transport=self.transport,
|
||||
principal_client=self.principals,
|
||||
)
|
||||
self.git = LiveGitReader(self.config.authority_git_dir)
|
||||
|
||||
def _assert_owner_scope(self, request: GovernedWriterRequest) -> None:
|
||||
if self.authorization_ref != self.owner_policy.authorization_id:
|
||||
raise AuthorizationDenied("authorization_ref does not match verified policy")
|
||||
self.owner_policy.validate(request, now=time.time())
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
job_id: str,
|
||||
route: str = "owner-authorized-governed-writer",
|
||||
) -> LiveWriterSession:
|
||||
self._assert_owner_scope(request)
|
||||
|
||||
runtime = self.bridge.runtime_status()
|
||||
readiness = GovernedWriteReadiness.from_runtime_status(runtime)
|
||||
if not readiness.governed_write_path_open:
|
||||
raise LiveProtocolConflict(
|
||||
"governed write path blocked: " + ",".join(readiness.blockers)
|
||||
)
|
||||
if readiness.legacy_global_writes_enabled:
|
||||
raise LiveProtocolConflict("legacy/global writes must remain disabled")
|
||||
|
||||
task = self.agent.read_task(request.task_id)
|
||||
if task.status != "QUEUED":
|
||||
raise LiveProtocolConflict(
|
||||
f"task must be QUEUED before claim; live={task.status}"
|
||||
)
|
||||
now = time.time()
|
||||
if (
|
||||
task.deadline_epoch is not None
|
||||
and task.deadline_epoch - now
|
||||
< self.config.minimum_remaining_window_seconds
|
||||
):
|
||||
raise LiveProtocolConflict("task deadline too close to safely claim")
|
||||
|
||||
agent_id, agent_token_handle = self.agent.register_agent(
|
||||
task_id=request.task_id,
|
||||
capabilities=sorted(request.required_capabilities),
|
||||
route=route,
|
||||
agent_id=self.agent_id,
|
||||
)
|
||||
claim = self.agent.claim(
|
||||
task_id=request.task_id,
|
||||
agent_token_handle=agent_token_handle,
|
||||
)
|
||||
running = self.agent.heartbeat(
|
||||
claim=claim,
|
||||
agent_token_handle=agent_token_handle,
|
||||
phase=route,
|
||||
)
|
||||
if (
|
||||
running.deadline_epoch is not None
|
||||
and running.deadline_epoch - time.time()
|
||||
< self.config.minimum_remaining_window_seconds
|
||||
):
|
||||
raise LiveProtocolConflict(
|
||||
"remaining governed task window too short after heartbeat"
|
||||
)
|
||||
|
||||
if self.scope_enrollment is not None:
|
||||
# The existing issuer and Bridge still verify every assertion.
|
||||
# Enrollment only happens after a real claim has established the
|
||||
# agent/attempt/lease binding; failures stop before any assertion.
|
||||
enrollment = self.scope_enrollment(
|
||||
request=request, job_id=job_id, agent_id=agent_id,
|
||||
claim=claim, task=running,
|
||||
)
|
||||
if (not isinstance(enrollment, Mapping)
|
||||
or enrollment.get("status") != "ENROLLED"
|
||||
or enrollment.get("task_id") != request.task_id
|
||||
or enrollment.get("agent_id") != agent_id
|
||||
or enrollment.get("job_id") != job_id):
|
||||
raise LiveProtocolConflict("verified principal scope enrollment required")
|
||||
|
||||
live_head = self.git.branch_head(request.target_branch)
|
||||
if live_head is None:
|
||||
branch_mode = "CREATE_NEW"
|
||||
expected_branch_oid = request.base_oid
|
||||
else:
|
||||
branch_mode = "CONTINUE_EXISTING"
|
||||
expected_branch_oid = live_head
|
||||
|
||||
continuation_key = (
|
||||
"gw-cont-"
|
||||
+ hashlib.sha256(
|
||||
(request.task_id + "\0" + job_id).encode("utf-8")
|
||||
).hexdigest()[:32]
|
||||
)
|
||||
writer = self.bridge.acquire_writer_lease(
|
||||
task_id=request.task_id,
|
||||
job_id=job_id,
|
||||
lease_handle=claim.lease_handle,
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
generation=request.generation,
|
||||
base_oid=request.base_oid,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
branch_mode=branch_mode,
|
||||
continuation_key=continuation_key,
|
||||
)
|
||||
return LiveWriterSession(
|
||||
authorization_id=self.authorization_ref,
|
||||
task_id=request.task_id,
|
||||
agent_id=agent_id,
|
||||
claim_ref=claim.claim_ref,
|
||||
job_id=job_id,
|
||||
branch_mode=writer.branch_mode,
|
||||
expected_branch_oid=writer.expected_branch_oid,
|
||||
writer_lease_id=writer.writer_lease_id,
|
||||
continuation_key=writer.continuation_key,
|
||||
principal_issue_schema=self.config.issue_schema,
|
||||
agent_token_handle=agent_token_handle,
|
||||
lease_handle=claim.lease_handle,
|
||||
task_lease_receipt=dict(claim.public_receipt),
|
||||
writer_lease_binding=dict(writer.raw),
|
||||
)
|
||||
|
||||
def commit_and_readback(
|
||||
self,
|
||||
*,
|
||||
session: LiveWriterSession,
|
||||
request: GovernedWriterRequest,
|
||||
files: Mapping[str, bytes],
|
||||
expected_sha256: Mapping[str, str],
|
||||
message: str,
|
||||
) -> Mapping[str, Any]:
|
||||
self._assert_owner_scope(request)
|
||||
if set(files) != set(expected_sha256):
|
||||
raise LiveProtocolConflict("commit file set differs from expected SHA set")
|
||||
for path, data in files.items():
|
||||
got = hashlib.sha256(data).hexdigest()
|
||||
if got != expected_sha256[path]:
|
||||
raise LiveProtocolConflict(f"pre-commit SHA mismatch: {path}")
|
||||
|
||||
response = self.bridge.commit_files(
|
||||
session=session,
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
generation=request.generation,
|
||||
files=files,
|
||||
message=message,
|
||||
)
|
||||
|
||||
new_head = self.git.branch_head(request.target_branch)
|
||||
if new_head is None or new_head == session.expected_branch_oid:
|
||||
raise LiveProtocolConflict("branch did not advance after governed commit")
|
||||
readback = {
|
||||
path: self.git.file_sha256(new_head, path) for path in expected_sha256
|
||||
}
|
||||
if readback != dict(expected_sha256):
|
||||
raise LiveProtocolConflict("governed commit readback SHA mismatch")
|
||||
return {
|
||||
"commit_response": response,
|
||||
"branch_head": new_head,
|
||||
"readback_sha256": readback,
|
||||
}
|
||||
|
||||
def submit_task_result(
|
||||
self,
|
||||
*,
|
||||
session: LiveWriterSession,
|
||||
result: Mapping[str, Any],
|
||||
idempotency_key: str,
|
||||
) -> Mapping[str, Any]:
|
||||
return self.agent.submit_result(
|
||||
task_id=session.task_id,
|
||||
agent_id=session.agent_id,
|
||||
agent_token_handle=session.agent_token_handle,
|
||||
lease_handle=session.lease_handle,
|
||||
idempotency_key=idempotency_key,
|
||||
result_value=result,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -388,6 +388,8 @@ class TaskRecord:
|
||||
root_task_id: str
|
||||
idempotency_key: str | None = None
|
||||
failure_reason: str | None = None
|
||||
owner_principal_id: str = ""
|
||||
mission_id: str = ""
|
||||
|
||||
def with_status(self, status: str) -> "TaskRecord":
|
||||
allowed = TASK_NEXT_STATES.get(self.status, set())
|
||||
@@ -407,6 +409,8 @@ class TaskRecord:
|
||||
root_task_id=self.root_task_id,
|
||||
idempotency_key=self.idempotency_key,
|
||||
failure_reason=self.failure_reason,
|
||||
owner_principal_id=self.owner_principal_id,
|
||||
mission_id=self.mission_id,
|
||||
)
|
||||
|
||||
def to_dict(self, *, include_task=True, redact_payload: bool = True) -> dict[str, Any]:
|
||||
@@ -422,6 +426,9 @@ class TaskRecord:
|
||||
}
|
||||
if include_task:
|
||||
payload["plan"] = self.task_plan.to_dict(redact=redact_payload)
|
||||
display_name = self.task_plan.metadata.get("aios_task_display_name")
|
||||
if self.owner_principal_id and self.mission_id and isinstance(display_name, str):
|
||||
payload["display_name"] = display_name
|
||||
if self.idempotency_key:
|
||||
payload["idempotency_key"] = self.idempotency_key
|
||||
return payload
|
||||
@@ -593,9 +600,17 @@ class EvidenceEnvelope:
|
||||
or self.evidence_digest != compute_sha256_hex(self.digest_payload())
|
||||
):
|
||||
return False
|
||||
if self.signature or self.attestation_ref:
|
||||
return True if attestation_verifier is None else bool(attestation_verifier(self))
|
||||
return True
|
||||
# An unsigned envelope has no proof of origin and must never verify.
|
||||
if not (self.signature or self.attestation_ref):
|
||||
return False
|
||||
# Every proof form requires an injected verifier. A reference or
|
||||
# signature must never become trusted merely because it is present.
|
||||
if attestation_verifier is None:
|
||||
return False
|
||||
try:
|
||||
return bool(attestation_verifier(self))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
|
||||
@@ -92,6 +92,7 @@ class PolicyEngine:
|
||||
max_risk: str = "medium",
|
||||
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
||||
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
||||
evidence_attestation_verifier: Callable[[object], bool] | None = None,
|
||||
identity_capabilities: dict[str, set[str]] | None = None,
|
||||
admin_identities: set[str] | None = None,
|
||||
readonly_worker_execution_enabled: bool = False,
|
||||
@@ -105,6 +106,7 @@ class PolicyEngine:
|
||||
self.max_risk = max_risk
|
||||
self.result_signature_verifier = result_signature_verifier
|
||||
self.probe_attestation_verifier = probe_attestation_verifier
|
||||
self.evidence_attestation_verifier = evidence_attestation_verifier
|
||||
self.identity_capabilities = {
|
||||
identity: set(capabilities)
|
||||
for identity, capabilities in (identity_capabilities or {}).items()
|
||||
@@ -195,6 +197,8 @@ class PolicyEngine:
|
||||
"worker.register": "worker.self_register",
|
||||
"worker.admin": "worker.admin",
|
||||
}
|
||||
if action not in action_capabilities:
|
||||
return PolicyDecision(False, f"unknown action {action}")
|
||||
required_capability = action_capabilities.get(action)
|
||||
if required_capability:
|
||||
granted = set(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,513 @@
|
||||
"""Diagnostic schedulability proof and claim-time authoritative recheck.
|
||||
|
||||
The proof is evidence, never a bearer credential.
|
||||
|
||||
A claim proceeds in three stages:
|
||||
1. read-only preflight/discovery;
|
||||
2. claim-time read-only recheck;
|
||||
3. final *atomic* authority operation that revalidates all mutable claim
|
||||
conditions at the linearization point and returns lease/fencing receipt.
|
||||
|
||||
This module does not create a second scheduler or a second TaskPlane authority.
|
||||
The host adapter must bind ``claim_atomically`` to the existing authoritative
|
||||
claim transaction / repository operation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from .models import canonical_json
|
||||
|
||||
|
||||
CLAIMABLE_TASK_STATES = frozenset({"CREATED", "QUEUED", "FAILED_RECOVERABLE"})
|
||||
|
||||
|
||||
class SchedulabilityError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SchedulabilityChanged(SchedulabilityError):
|
||||
pass
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
# Reuse the existing TaskPlane canonical JSON contract. Do not introduce
|
||||
# another sort/separator encoder for schedulability evidence.
|
||||
return canonical_json(value).encode("utf-8")
|
||||
|
||||
|
||||
def _sha(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def _text(value: object, name: str, *, max_len: int = 256) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise SchedulabilityError(f"{name} must be a string")
|
||||
value = value.strip()
|
||||
if not value or len(value) > max_len or any(ord(ch) < 32 for ch in value):
|
||||
raise SchedulabilityError(f"invalid {name}")
|
||||
return value
|
||||
|
||||
|
||||
def _int_authority(
|
||||
value: object,
|
||||
*,
|
||||
name: str,
|
||||
default: int | None = None,
|
||||
minimum: int = 0,
|
||||
) -> tuple[int, "Blocker | None"]:
|
||||
if value is None:
|
||||
if default is None:
|
||||
return 0, Blocker("INVALID_AUTHORITY_VALUE", "worker", f"{name} missing")
|
||||
return default, None
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return 0, Blocker("INVALID_AUTHORITY_VALUE", "worker", f"{name} must be integer")
|
||||
if value < minimum:
|
||||
return value, Blocker("INVALID_AUTHORITY_VALUE", "worker", f"{name} below {minimum}")
|
||||
return value, None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Blocker:
|
||||
code: str
|
||||
plane: str
|
||||
detail: str
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimConditions:
|
||||
"""Mutable authority conditions that MUST be checked atomically with claim."""
|
||||
|
||||
task_revision: int
|
||||
task_generation: int
|
||||
task_status: str
|
||||
worker_generation: int
|
||||
worker_identity: str
|
||||
authorization_ref: str
|
||||
authorization_revision: str
|
||||
scope_digest: str
|
||||
capacity_limit: int
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SchedulabilityObservation:
|
||||
task_id: str
|
||||
task_revision: int
|
||||
task_generation: int
|
||||
task_status: str
|
||||
worker_id: str
|
||||
worker_generation: int
|
||||
worker_identity: str
|
||||
heartbeat_observed_at: float
|
||||
heartbeat_fresh: bool
|
||||
required_capabilities: tuple[str, ...]
|
||||
worker_capabilities: tuple[str, ...]
|
||||
scope_digest: str
|
||||
authorization_ref: str
|
||||
authorization_revision: str
|
||||
authorization_allowed: bool
|
||||
capacity_limit: int
|
||||
active_claims: int
|
||||
dependencies_satisfied: bool
|
||||
freeze_blocked: bool
|
||||
conflicting_lease: bool
|
||||
|
||||
@property
|
||||
def capacity_remaining(self) -> int:
|
||||
return max(0, self.capacity_limit - self.active_claims)
|
||||
|
||||
def stable_binding(self) -> dict[str, object]:
|
||||
"""Fields whose change invalidates an earlier proof."""
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"task_revision": self.task_revision,
|
||||
"task_generation": self.task_generation,
|
||||
"task_status": self.task_status,
|
||||
"worker_id": self.worker_id,
|
||||
"worker_generation": self.worker_generation,
|
||||
"worker_identity": self.worker_identity,
|
||||
"required_capabilities": list(self.required_capabilities),
|
||||
"worker_capabilities": list(self.worker_capabilities),
|
||||
"scope_digest": self.scope_digest,
|
||||
"authorization_ref": self.authorization_ref,
|
||||
"authorization_revision": self.authorization_revision,
|
||||
"authorization_allowed": self.authorization_allowed,
|
||||
"capacity_limit": self.capacity_limit,
|
||||
"active_claims": self.active_claims,
|
||||
"dependencies_satisfied": self.dependencies_satisfied,
|
||||
"freeze_blocked": self.freeze_blocked,
|
||||
"conflicting_lease": self.conflicting_lease,
|
||||
"heartbeat_fresh": self.heartbeat_fresh,
|
||||
}
|
||||
|
||||
def claim_conditions(self) -> ClaimConditions:
|
||||
return ClaimConditions(
|
||||
task_revision=self.task_revision,
|
||||
task_generation=self.task_generation,
|
||||
task_status=self.task_status,
|
||||
worker_generation=self.worker_generation,
|
||||
worker_identity=self.worker_identity,
|
||||
authorization_ref=self.authorization_ref,
|
||||
authorization_revision=self.authorization_revision,
|
||||
scope_digest=self.scope_digest,
|
||||
capacity_limit=self.capacity_limit,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SchedulabilityProof:
|
||||
schema: str
|
||||
proof_id: str
|
||||
task_id: str
|
||||
worker_id: str
|
||||
actor_identity: str
|
||||
created_at: float
|
||||
expires_at: float
|
||||
observation: SchedulabilityObservation
|
||||
blockers: tuple[Blocker, ...]
|
||||
observation_digest: str
|
||||
proof_digest: str
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
observation: SchedulabilityObservation,
|
||||
blockers: Sequence[Blocker],
|
||||
now: float,
|
||||
ttl_seconds: float,
|
||||
proof_id: str | None = None,
|
||||
) -> "SchedulabilityProof":
|
||||
if ttl_seconds <= 0 or ttl_seconds > 300:
|
||||
raise ValueError("schedulability proof ttl must be in (0,300]")
|
||||
payload = {
|
||||
"schema": "aios.schedulability-proof.v2",
|
||||
"proof_id": proof_id or f"sched-{uuid.uuid4()}",
|
||||
"task_id": _text(task_id, "task_id"),
|
||||
"worker_id": _text(worker_id, "worker_id"),
|
||||
"actor_identity": _text(actor_identity, "actor_identity", max_len=512),
|
||||
"created_at": float(now),
|
||||
"expires_at": float(now + ttl_seconds),
|
||||
"observation": observation.stable_binding(),
|
||||
"blockers": [b.to_dict() for b in blockers],
|
||||
}
|
||||
obs_digest = _sha(payload["observation"])
|
||||
proof_digest = _sha({**payload, "observation_digest": obs_digest})
|
||||
return cls(
|
||||
schema=payload["schema"],
|
||||
proof_id=payload["proof_id"],
|
||||
task_id=payload["task_id"],
|
||||
worker_id=payload["worker_id"],
|
||||
actor_identity=payload["actor_identity"],
|
||||
created_at=payload["created_at"],
|
||||
expires_at=payload["expires_at"],
|
||||
observation=observation,
|
||||
blockers=tuple(blockers),
|
||||
observation_digest=obs_digest,
|
||||
proof_digest=proof_digest,
|
||||
)
|
||||
|
||||
@property
|
||||
def claimable(self) -> bool:
|
||||
return not self.blockers
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveryDiagnostics:
|
||||
candidate_count: int
|
||||
blocker_codes: tuple[str, ...]
|
||||
|
||||
|
||||
class SchedulabilityAuthority(Protocol):
|
||||
"""Read authorities and perform the final existing claim transaction."""
|
||||
|
||||
def read_task(self, task_id: str) -> Mapping[str, Any]: ...
|
||||
def list_candidate_tasks(self) -> Sequence[Mapping[str, Any]]: ...
|
||||
def read_worker(self, worker_id: str) -> Mapping[str, Any]: ...
|
||||
def read_authorization(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> Mapping[str, Any]: ...
|
||||
def active_claim_count(self, worker_id: str) -> int: ...
|
||||
def dependencies_satisfied(self, task: Mapping[str, Any]) -> bool: ...
|
||||
def freeze_blocked(self, task: Mapping[str, Any]) -> bool: ...
|
||||
def conflicting_lease(self, task_id: str) -> bool: ...
|
||||
def exact_scope_digest(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> tuple[bool, str]: ...
|
||||
def claim_atomically(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
expected: ClaimConditions,
|
||||
lease_seconds: int,
|
||||
schedulability_proof_digest: str,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Claim at the existing TaskPlane linearization point.
|
||||
|
||||
The implementation MUST, in the same authoritative transaction / fenced
|
||||
operation that writes the claim:
|
||||
- verify task revision/generation/status is still claimable;
|
||||
- verify worker registration/identity/health/capacity;
|
||||
- verify authorization ref/revision remains allowed;
|
||||
- verify exact scope;
|
||||
- verify dependencies/freeze/conflicting lease;
|
||||
- create claim + lease/fencing receipt.
|
||||
|
||||
This protocol is an adapter contract; it is not evidence that a specific
|
||||
production adapter already implements the transaction.
|
||||
"""
|
||||
|
||||
|
||||
class ProofRepository(Protocol):
|
||||
def put(self, proof: SchedulabilityProof) -> None: ...
|
||||
def get(self, proof_id: str) -> SchedulabilityProof | None: ...
|
||||
|
||||
|
||||
class MemoryProofRepository:
|
||||
def __init__(self) -> None:
|
||||
self._values: dict[str, SchedulabilityProof] = {}
|
||||
|
||||
def put(self, proof: SchedulabilityProof) -> None:
|
||||
existing = self._values.get(proof.proof_id)
|
||||
if existing is not None and existing.proof_digest != proof.proof_digest:
|
||||
raise SchedulabilityError("proof id replay conflict")
|
||||
self._values[proof.proof_id] = proof
|
||||
|
||||
def get(self, proof_id: str) -> SchedulabilityProof | None:
|
||||
return self._values.get(proof_id)
|
||||
|
||||
|
||||
class SchedulabilityService:
|
||||
def __init__(
|
||||
self,
|
||||
authority: SchedulabilityAuthority,
|
||||
proofs: ProofRepository,
|
||||
*,
|
||||
clock=time.time,
|
||||
proof_ttl_seconds: float = 30.0,
|
||||
heartbeat_grace_factor: float = 3.0,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.proofs = proofs
|
||||
self.clock = clock
|
||||
self.proof_ttl_seconds = proof_ttl_seconds
|
||||
self.heartbeat_grace_factor = heartbeat_grace_factor
|
||||
|
||||
@staticmethod
|
||||
def _caps(value: object) -> tuple[str, ...]:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return ()
|
||||
return tuple(sorted({str(v) for v in value if isinstance(v, str) and v}))
|
||||
|
||||
def observe(
|
||||
self, task_id: str, worker_id: str, actor_identity: str
|
||||
) -> tuple[SchedulabilityObservation, tuple[Blocker, ...]]:
|
||||
task = self.authority.read_task(task_id)
|
||||
worker = self.authority.read_worker(worker_id)
|
||||
auth = self.authority.read_authorization(
|
||||
task=task, worker=worker, actor_identity=actor_identity
|
||||
)
|
||||
|
||||
required = self._caps(task.get("required_capabilities") or ())
|
||||
offered = self._caps(worker.get("capabilities") or ())
|
||||
now = float(self.clock())
|
||||
last = float(worker.get("last_heartbeat") or worker.get("last_seen_at_epoch") or 0.0)
|
||||
interval = float(worker.get("heartbeat_interval_seconds") or 15.0)
|
||||
fresh = (
|
||||
str(worker.get("status") or "").lower() == "active"
|
||||
and not bool(worker.get("disabled"))
|
||||
and last <= now
|
||||
and now - last <= max(interval * self.heartbeat_grace_factor, 1.0)
|
||||
)
|
||||
scope_ok, scope_digest = self.authority.exact_scope_digest(
|
||||
task=task, worker=worker, actor_identity=actor_identity
|
||||
)
|
||||
deps = bool(self.authority.dependencies_satisfied(task))
|
||||
freeze = bool(self.authority.freeze_blocked(task))
|
||||
lease = bool(self.authority.conflicting_lease(task_id))
|
||||
active_raw = self.authority.active_claim_count(worker_id)
|
||||
active, active_error = _int_authority(active_raw, name="active_claim_count", minimum=0)
|
||||
capacity, capacity_error = _int_authority(
|
||||
worker.get("max_concurrency"),
|
||||
name="max_concurrency",
|
||||
default=1,
|
||||
minimum=0,
|
||||
)
|
||||
auth_allowed = bool(auth.get("allowed"))
|
||||
task_status = str(task.get("status") or "")
|
||||
blockers: list[Blocker] = []
|
||||
|
||||
if task_status not in CLAIMABLE_TASK_STATES:
|
||||
blockers.append(
|
||||
Blocker(
|
||||
"TASK_STATE_NOT_CLAIMABLE",
|
||||
"task",
|
||||
f"task status {task_status or '<missing>'} is not claimable",
|
||||
)
|
||||
)
|
||||
if active_error:
|
||||
blockers.append(active_error)
|
||||
if capacity_error:
|
||||
blockers.append(capacity_error)
|
||||
if not fresh:
|
||||
blockers.append(Blocker("STALE_OR_INACTIVE_WORKER", "worker", "worker heartbeat is not fresh"))
|
||||
missing = sorted(set(required) - set(offered))
|
||||
if missing:
|
||||
blockers.append(Blocker("MISSING_CAPABILITY", "worker", ",".join(missing)))
|
||||
if not scope_ok:
|
||||
blockers.append(Blocker("EXACT_SCOPE_MISMATCH", "authorization", "worker/task signed scope mismatch"))
|
||||
if not auth_allowed:
|
||||
blockers.append(Blocker("AUTHORIZATION_BLOCKED", "authorization", "current authorization does not allow claim"))
|
||||
if capacity >= 0 and active >= capacity:
|
||||
blockers.append(Blocker("CAPACITY_EXHAUSTED", "worker", f"{active}/{capacity} active"))
|
||||
if not deps:
|
||||
blockers.append(Blocker("DEPENDENCY_BLOCKED", "task", "task dependencies are not satisfied"))
|
||||
if freeze:
|
||||
blockers.append(Blocker("FREEZE_BLOCKED", "task", "task/environment freeze is active"))
|
||||
if lease:
|
||||
blockers.append(Blocker("CONFLICTING_LEASE", "task", "a live conflicting lease exists"))
|
||||
|
||||
obs = SchedulabilityObservation(
|
||||
task_id=_text(task.get("task_id"), "task_id"),
|
||||
task_revision=int(task.get("revision") or 0),
|
||||
task_generation=int(task.get("generation") or task.get("metadata", {}).get("generation") or 0),
|
||||
task_status=task_status,
|
||||
worker_id=_text(worker.get("worker_id"), "worker_id"),
|
||||
worker_generation=int(worker.get("registration_revision") or worker.get("generation") or 0),
|
||||
worker_identity=_text(worker.get("identity"), "worker_identity", max_len=512),
|
||||
heartbeat_observed_at=last,
|
||||
heartbeat_fresh=fresh,
|
||||
required_capabilities=required,
|
||||
worker_capabilities=offered,
|
||||
scope_digest=_text(scope_digest, "scope_digest", max_len=256),
|
||||
authorization_ref=_text(auth.get("authorization_ref"), "authorization_ref", max_len=512),
|
||||
authorization_revision=_text(auth.get("revision"), "authorization_revision", max_len=256),
|
||||
authorization_allowed=auth_allowed,
|
||||
capacity_limit=capacity,
|
||||
active_claims=active,
|
||||
dependencies_satisfied=deps,
|
||||
freeze_blocked=freeze,
|
||||
conflicting_lease=lease,
|
||||
)
|
||||
return obs, tuple(blockers)
|
||||
|
||||
def preflight(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
persist: bool = False,
|
||||
) -> SchedulabilityProof:
|
||||
now = float(self.clock())
|
||||
obs, blockers = self.observe(task_id, worker_id, actor_identity)
|
||||
proof = SchedulabilityProof.create(
|
||||
task_id=task_id,
|
||||
worker_id=worker_id,
|
||||
actor_identity=actor_identity,
|
||||
observation=obs,
|
||||
blockers=blockers,
|
||||
now=now,
|
||||
ttl_seconds=self.proof_ttl_seconds,
|
||||
)
|
||||
if persist:
|
||||
self.proofs.put(proof)
|
||||
return proof
|
||||
|
||||
def discover_with_diagnostics(
|
||||
self, *, worker_id: str, actor_identity: str
|
||||
) -> tuple[SchedulabilityProof | None, DiscoveryDiagnostics]:
|
||||
candidates = sorted(
|
||||
self.authority.list_candidate_tasks(),
|
||||
key=lambda task: (
|
||||
-int(task.get("priority") or 0),
|
||||
str(task.get("created_at") or ""),
|
||||
str(task.get("task_id") or ""),
|
||||
),
|
||||
)
|
||||
codes: set[str] = set()
|
||||
observed = 0
|
||||
for task in candidates:
|
||||
task_id = str(task.get("task_id") or "")
|
||||
if not task_id:
|
||||
continue
|
||||
observed += 1
|
||||
proof = self.preflight(
|
||||
task_id=task_id,
|
||||
worker_id=worker_id,
|
||||
actor_identity=actor_identity,
|
||||
persist=False,
|
||||
)
|
||||
if proof.claimable:
|
||||
self.proofs.put(proof)
|
||||
return proof, DiscoveryDiagnostics(observed, tuple(sorted(codes)))
|
||||
codes.update(b.code for b in proof.blockers)
|
||||
return None, DiscoveryDiagnostics(observed, tuple(sorted(codes)))
|
||||
|
||||
def discover(
|
||||
self, *, worker_id: str, actor_identity: str
|
||||
) -> SchedulabilityProof | None:
|
||||
proof, _ = self.discover_with_diagnostics(
|
||||
worker_id=worker_id, actor_identity=actor_identity
|
||||
)
|
||||
return proof
|
||||
|
||||
@staticmethod
|
||||
def _validate_claim_receipt(result: Mapping[str, Any]) -> None:
|
||||
required = ("claim_id", "lease_id", "fencing_generation", "receipt_revision")
|
||||
missing = [name for name in required if result.get(name) in (None, "")]
|
||||
if missing:
|
||||
raise SchedulabilityChanged(
|
||||
"claim authority did not return required lease/fence receipt: "
|
||||
+ ",".join(missing)
|
||||
)
|
||||
|
||||
def claim_with_recheck(
|
||||
self,
|
||||
*,
|
||||
proof_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
lease_seconds: int = 30,
|
||||
) -> Mapping[str, Any]:
|
||||
proof = self.proofs.get(proof_id)
|
||||
if proof is None:
|
||||
raise SchedulabilityChanged("proof not found")
|
||||
now = float(self.clock())
|
||||
if proof.expires_at <= now:
|
||||
raise SchedulabilityChanged("proof expired")
|
||||
if proof.worker_id != worker_id or proof.actor_identity != actor_identity:
|
||||
raise SchedulabilityChanged("proof identity mismatch")
|
||||
current, blockers = self.observe(proof.task_id, worker_id, actor_identity)
|
||||
if blockers:
|
||||
raise SchedulabilityChanged(
|
||||
"claim-time blockers: " + ",".join(sorted(b.code for b in blockers))
|
||||
)
|
||||
if current.stable_binding() != proof.observation.stable_binding():
|
||||
raise SchedulabilityChanged("claim-time authority changed since proof")
|
||||
|
||||
result = self.authority.claim_atomically(
|
||||
task_id=proof.task_id,
|
||||
worker_id=worker_id,
|
||||
actor_identity=actor_identity,
|
||||
expected=current.claim_conditions(),
|
||||
lease_seconds=lease_seconds,
|
||||
schedulability_proof_digest=proof.proof_digest,
|
||||
)
|
||||
self._validate_claim_receipt(result)
|
||||
return result
|
||||
@@ -0,0 +1,212 @@
|
||||
"""S5.1 live authority adapters with exact caller/candidate binding.
|
||||
|
||||
These adapters are projections over existing authorities. They do not create
|
||||
tasks, grants, leases, branches, or recovery authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from .governance_admission import AdmissionOperation, GovernanceFacts
|
||||
from .governance_recovery_controller import GovernanceEvent
|
||||
from .governance_resolver import ExactTaskResolver, ResolutionQuery, ResolutionResult
|
||||
|
||||
|
||||
class LiveSurfaceUnavailable(RuntimeError):
|
||||
def __init__(self, role: str, reason: str) -> None:
|
||||
self.role = role
|
||||
self.reason = reason
|
||||
super().__init__(f"LIVE_SURFACE_UNAVAILABLE[{role}]: {reason}")
|
||||
|
||||
|
||||
class LiveBindingViolation(RuntimeError):
|
||||
def __init__(self, role: str, reason: str) -> None:
|
||||
self.role = role
|
||||
self.reason = reason
|
||||
super().__init__(f"LIVE_BINDING_VIOLATION[{role}]: {reason}")
|
||||
|
||||
|
||||
class TaskPlaneReadClient(Protocol):
|
||||
def get_task(
|
||||
self, task_id: str, *, actor_identity: str
|
||||
) -> Mapping[str, Any] | None: ...
|
||||
|
||||
def list_tasks(
|
||||
self, *, actor_identity: str
|
||||
) -> list[Mapping[str, Any]]: ...
|
||||
|
||||
|
||||
class LiveTaskResolverAdapter:
|
||||
"""Bind ExactTaskResolver to one caller-authorized TaskPlane projection."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: TaskPlaneReadClient | None = None,
|
||||
actor_identity: str | None = None,
|
||||
bound_repository: str | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._actor_identity = actor_identity
|
||||
self._bound_repository = bound_repository
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self._client is not None and bool(self._actor_identity)
|
||||
|
||||
def resolve(
|
||||
self, query: ResolutionQuery, *, actor_identity: str
|
||||
) -> ResolutionResult:
|
||||
if not self.live:
|
||||
raise LiveSurfaceUnavailable(
|
||||
"A1",
|
||||
"no caller-authorized TaskPlaneReadClient and bound identity",
|
||||
)
|
||||
if actor_identity != self._actor_identity:
|
||||
raise LiveBindingViolation(
|
||||
"A1",
|
||||
"controller actor_identity does not match bound read principal",
|
||||
)
|
||||
if (
|
||||
self._bound_repository
|
||||
and query.repository != self._bound_repository
|
||||
):
|
||||
raise LiveBindingViolation(
|
||||
"A1",
|
||||
"query repository is outside the bound authority projection",
|
||||
)
|
||||
return ExactTaskResolver(self._client).resolve(
|
||||
query, actor_identity=self._actor_identity
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedAuthoritySnapshot:
|
||||
"""One revision-consistent, candidate-specific governed read."""
|
||||
|
||||
task_id: str
|
||||
actor_identity: str
|
||||
operation: AdmissionOperation
|
||||
snapshot_revision: str
|
||||
facts: GovernanceFacts
|
||||
|
||||
|
||||
class GovernedAuthorityReadClient(Protocol):
|
||||
def read_snapshot(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
actor_identity: str,
|
||||
operation: AdmissionOperation,
|
||||
) -> GovernedAuthoritySnapshot: ...
|
||||
|
||||
|
||||
class LiveGovernanceFactsAdapter:
|
||||
"""Materialize GovernanceFacts from one atomic/revision-consistent read."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: GovernedAuthorityReadClient | None = None,
|
||||
actor_identity: str | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._actor_identity = actor_identity
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self._client is not None and bool(self._actor_identity)
|
||||
|
||||
def snapshot(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
actor_identity: str,
|
||||
operation: AdmissionOperation,
|
||||
) -> GovernanceFacts:
|
||||
if not self.live:
|
||||
raise LiveSurfaceUnavailable(
|
||||
"A2",
|
||||
"no candidate-specific governed authority reader and identity",
|
||||
)
|
||||
if task_id is None:
|
||||
raise LiveBindingViolation("A2", "exact task_id required")
|
||||
if actor_identity != self._actor_identity:
|
||||
raise LiveBindingViolation(
|
||||
"A2",
|
||||
"controller actor_identity does not match bound governed principal",
|
||||
)
|
||||
snapshot = self._client.read_snapshot(
|
||||
task_id=task_id,
|
||||
actor_identity=actor_identity,
|
||||
operation=operation,
|
||||
)
|
||||
if not snapshot.snapshot_revision:
|
||||
raise LiveBindingViolation(
|
||||
"A2", "authority snapshot revision is missing"
|
||||
)
|
||||
if snapshot.task_id != task_id:
|
||||
raise LiveBindingViolation("A2", "authority snapshot task mismatch")
|
||||
if snapshot.actor_identity != actor_identity:
|
||||
raise LiveBindingViolation("A2", "authority snapshot actor mismatch")
|
||||
if snapshot.operation is not operation:
|
||||
raise LiveBindingViolation("A2", "authority snapshot operation mismatch")
|
||||
return snapshot.facts
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveGovernanceEvent:
|
||||
kind: str
|
||||
revision: str
|
||||
occurred_at: str
|
||||
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class GovernanceEventSubscriber(Protocol):
|
||||
def subscribe(
|
||||
self, on_event: Callable[[LiveGovernanceEvent], object]
|
||||
) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
class LiveEventSubscriptionAdapter:
|
||||
"""Translate site events into the core GovernanceEvent contract."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sink: Callable[[GovernanceEvent], object],
|
||||
subscriber: GovernanceEventSubscriber | None = None,
|
||||
) -> None:
|
||||
self._sink = sink
|
||||
self._subscriber = subscriber
|
||||
self._active = False
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self._subscriber is not None
|
||||
|
||||
def _on_live_event(self, event: LiveGovernanceEvent):
|
||||
if not isinstance(event, LiveGovernanceEvent):
|
||||
raise LiveBindingViolation(
|
||||
"A3", "subscriber emitted an unsupported event type"
|
||||
)
|
||||
return self._sink(GovernanceEvent(
|
||||
kind=event.kind,
|
||||
revision=event.revision,
|
||||
occurred_at=event.occurred_at,
|
||||
))
|
||||
|
||||
def start(self) -> None:
|
||||
if not self.live:
|
||||
raise LiveSurfaceUnavailable(
|
||||
"A3",
|
||||
"no governance event subscriber injected; polling remains fallback",
|
||||
)
|
||||
self._subscriber.subscribe(self._on_live_event)
|
||||
self._active = True
|
||||
|
||||
def close(self) -> None:
|
||||
if self._subscriber is not None:
|
||||
self._subscriber.close()
|
||||
self._active = False
|
||||
@@ -65,6 +65,7 @@ class TaskPlaneStore:
|
||||
self.high_assurance = high_assurance
|
||||
self.fault_injector = None
|
||||
self.probe_attestation_verifier = None
|
||||
self.evidence_attestation_verifier = None
|
||||
self._lock = threading.RLock()
|
||||
self._tx_state = threading.local()
|
||||
self._closed = False
|
||||
@@ -272,6 +273,13 @@ class TaskPlaneStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._ensure_columns(
|
||||
"tasks",
|
||||
{
|
||||
"owner_principal_id": "TEXT NOT NULL DEFAULT ''",
|
||||
"mission_id": "TEXT NOT NULL DEFAULT ''",
|
||||
},
|
||||
)
|
||||
self._ensure_columns(
|
||||
"workers",
|
||||
{
|
||||
@@ -305,6 +313,167 @@ class TaskPlaneStore:
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_events_task_seq ON events(task_id, seq)"
|
||||
)
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_outbox_status ON outbox(delivery_status)")
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_lineage_sequences (
|
||||
parent_task_id TEXT NOT NULL,
|
||||
relation_type TEXT NOT NULL,
|
||||
child_task_id TEXT NOT NULL UNIQUE,
|
||||
sibling_sequence INTEGER NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
PRIMARY KEY(parent_task_id, relation_type, sibling_sequence)
|
||||
)
|
||||
"""
|
||||
)
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_terminal_proofs (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
claim_id TEXT NOT NULL UNIQUE,
|
||||
task_revision INTEGER NOT NULL,
|
||||
result_digest TEXT NOT NULL,
|
||||
evidence_refs_json TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_root_reviews (
|
||||
review_id TEXT PRIMARY KEY,
|
||||
root_task_id TEXT NOT NULL,
|
||||
trigger_child_task_id TEXT NOT NULL,
|
||||
trigger_child_revision INTEGER NOT NULL,
|
||||
root_revision_before INTEGER NOT NULL,
|
||||
root_revision_after INTEGER NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_root_action_dispatches (
|
||||
review_id TEXT PRIMARY KEY,
|
||||
root_task_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._ensure_columns(
|
||||
"task_root_action_dispatches",
|
||||
{
|
||||
"mission_id": "TEXT NOT NULL DEFAULT ''",
|
||||
"root_revision": "INTEGER NOT NULL DEFAULT 0",
|
||||
"row_version": "INTEGER NOT NULL DEFAULT 0",
|
||||
"plan_json": "TEXT",
|
||||
"plan_digest": "TEXT",
|
||||
"execution_key": "TEXT",
|
||||
"claim_owner": "TEXT",
|
||||
"claim_token": "TEXT",
|
||||
"claim_fence": "INTEGER",
|
||||
"claim_expires_at": "REAL",
|
||||
"execution_receipt_json": "TEXT",
|
||||
"execution_receipt_digest": "TEXT",
|
||||
"rereview_json": "TEXT",
|
||||
"rereview_digest": "TEXT",
|
||||
"retry_at": "REAL",
|
||||
"last_error_code": "TEXT",
|
||||
"updated_at": "REAL",
|
||||
},
|
||||
)
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_root_action_effects (
|
||||
execution_key TEXT PRIMARY KEY,
|
||||
root_task_id TEXT NOT NULL,
|
||||
target_task_id TEXT,
|
||||
target_job_id TEXT,
|
||||
request_digest TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
effect_phase TEXT NOT NULL DEFAULT 'LEGACY_UNKNOWN',
|
||||
receipt_json TEXT,
|
||||
receipt_digest TEXT,
|
||||
retry_at REAL,
|
||||
last_error_code TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._ensure_columns(
|
||||
"task_root_action_effects",
|
||||
{
|
||||
# Existing v1/v2 EFFECT_UNKNOWN rows may already have sent
|
||||
# an external request. They must never migrate to a phase
|
||||
# that permits a new lease or commit.
|
||||
"effect_phase": "TEXT NOT NULL DEFAULT 'LEGACY_UNKNOWN'",
|
||||
},
|
||||
)
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_root_action_fences (
|
||||
root_task_id TEXT PRIMARY KEY,
|
||||
next_fence INTEGER NOT NULL,
|
||||
active_review_id TEXT,
|
||||
active_fence INTEGER,
|
||||
active_phase TEXT,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self.conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_root_action_dispatch_ready "
|
||||
"ON task_root_action_dispatches(status,retry_at,created_at)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_root_action_dispatch_root "
|
||||
"ON task_root_action_dispatches(root_task_id,root_revision)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_root_review_trigger "
|
||||
"ON task_root_reviews(trigger_child_task_id, trigger_child_revision)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_missions (
|
||||
mission_id TEXT PRIMARY KEY,
|
||||
root_task_id TEXT NOT NULL UNIQUE,
|
||||
authenticated_identity TEXT NOT NULL,
|
||||
owner_principal_id TEXT NOT NULL,
|
||||
owner_alias TEXT NOT NULL,
|
||||
goal_slug TEXT NOT NULL,
|
||||
root_acceptance_ref TEXT NOT NULL UNIQUE,
|
||||
acceptance_verifier_id TEXT NOT NULL,
|
||||
original_acceptance_json TEXT NOT NULL,
|
||||
request_digest TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
UNIQUE(authenticated_identity, idempotency_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
self.conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_task_missions_owner "
|
||||
"ON task_missions(authenticated_identity, mission_id)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_mission_delegations (
|
||||
authenticated_identity TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
parent_task_id TEXT NOT NULL,
|
||||
child_task_id TEXT NOT NULL UNIQUE,
|
||||
request_digest TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
PRIMARY KEY(authenticated_identity, idempotency_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _ensure_columns(self, table: str, columns: dict[str, str]) -> None:
|
||||
existing = {
|
||||
@@ -359,6 +528,8 @@ class TaskPlaneStore:
|
||||
root_task_id=row["root_task_id"],
|
||||
idempotency_key=row["idempotency_key"],
|
||||
failure_reason=row["failure_reason"],
|
||||
owner_principal_id=row["owner_principal_id"],
|
||||
mission_id=row["mission_id"],
|
||||
)
|
||||
|
||||
def _row_to_worker(self, row: sqlite3.Row | None) -> WorkerRegistration:
|
||||
@@ -385,20 +556,32 @@ class TaskPlaneStore:
|
||||
disabled_reason=row["disabled_reason"],
|
||||
)
|
||||
|
||||
def create_task(self, task_plan: TaskPlan, idempotency_key: str | None = None) -> TaskRecord:
|
||||
def create_task(
|
||||
self,
|
||||
task_plan: TaskPlan,
|
||||
idempotency_key: str | None = None,
|
||||
*,
|
||||
owner_principal_id: str = "",
|
||||
mission_id: str = "",
|
||||
created_at: float | None = None,
|
||||
) -> TaskRecord:
|
||||
task_id = task_plan.task_id or f"task-{uuid.uuid4()}"
|
||||
task_plan.task_id = task_id
|
||||
task_json = redact_value(task_plan.to_dict(redact=False))
|
||||
now = _now()
|
||||
now = _now() if created_at is None else float(created_at)
|
||||
with self._transaction():
|
||||
if idempotency_key:
|
||||
existing = self.conn.execute(
|
||||
"SELECT task_id,task_json FROM tasks WHERE idempotency_key=?",
|
||||
"SELECT task_id,task_json,owner_principal_id,mission_id FROM tasks WHERE idempotency_key=?",
|
||||
(idempotency_key,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
if _canonical(json.loads(existing["task_json"])) != _canonical(task_json):
|
||||
raise ConflictError("idempotency key payload mismatch")
|
||||
if owner_principal_id and existing["owner_principal_id"] != owner_principal_id:
|
||||
raise ConflictError("idempotency key owner mismatch")
|
||||
if mission_id and existing["mission_id"] != mission_id:
|
||||
raise ConflictError("idempotency key mission mismatch")
|
||||
return self.get_task(existing["task_id"])
|
||||
root_task_id = task_id
|
||||
for dependency in task_plan.dependencies:
|
||||
@@ -412,8 +595,8 @@ class TaskPlaneStore:
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO tasks
|
||||
(task_id, revision, status, task_json, task_type, root_task_id, parent_task_id, correlation_id, idempotency_key, created_at, updated_at, failure_reason)
|
||||
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||
(task_id, revision, status, task_json, task_type, root_task_id, parent_task_id, correlation_id, idempotency_key, created_at, updated_at, failure_reason, owner_principal_id, mission_id)
|
||||
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
@@ -426,6 +609,8 @@ class TaskPlaneStore:
|
||||
idempotency_key,
|
||||
now,
|
||||
now,
|
||||
owner_principal_id,
|
||||
mission_id,
|
||||
),
|
||||
)
|
||||
self._set_budget(task_id, task_plan.budgets)
|
||||
@@ -437,10 +622,22 @@ class TaskPlaneStore:
|
||||
return self.get_task(task_id)
|
||||
|
||||
def create_task_with_event(
|
||||
self, task_plan: TaskPlan, idempotency_key: str | None = None
|
||||
self,
|
||||
task_plan: TaskPlan,
|
||||
idempotency_key: str | None = None,
|
||||
*,
|
||||
owner_principal_id: str = "",
|
||||
mission_id: str = "",
|
||||
created_at: float | None = None,
|
||||
) -> TaskRecord:
|
||||
with self._transaction():
|
||||
task = self.create_task(task_plan, idempotency_key=idempotency_key)
|
||||
task = self.create_task(
|
||||
task_plan,
|
||||
idempotency_key=idempotency_key,
|
||||
owner_principal_id=owner_principal_id,
|
||||
mission_id=mission_id,
|
||||
created_at=created_at,
|
||||
)
|
||||
if not self.list_events(task.task_id):
|
||||
self._fault("create.after_task_before_event")
|
||||
self.append_event(
|
||||
@@ -461,6 +658,26 @@ class TaskPlaneStore:
|
||||
raise NotFoundError(f"task {task_id} not found")
|
||||
return self._row_to_task(row)
|
||||
|
||||
def assert_root_open_for_child(self, root_task_id: str) -> TaskRecord:
|
||||
"""Fence child admission against root completion/action finalization."""
|
||||
root = self.get_task(root_task_id)
|
||||
if root.task_id != root.root_task_id:
|
||||
raise ConflictError("child admission authority is not a root task")
|
||||
if root.status == "COMPLETED":
|
||||
raise ConflictError("cannot add a child below a completed root")
|
||||
fence = self.conn.execute(
|
||||
"SELECT active_phase FROM task_root_action_fences WHERE root_task_id=?",
|
||||
(root_task_id,),
|
||||
).fetchone()
|
||||
if fence is not None and fence["active_phase"] in {
|
||||
"CLAIMED",
|
||||
"EFFECT_UNKNOWN",
|
||||
"EXECUTED_PENDING_REREVIEW",
|
||||
"ROOT_CLOSING",
|
||||
}:
|
||||
raise ConflictError("root action fence blocks child admission")
|
||||
return root
|
||||
|
||||
def list_tasks(self) -> list[TaskRecord]:
|
||||
with self._lock:
|
||||
rows = self.conn.execute(
|
||||
@@ -1192,6 +1409,7 @@ class TaskPlaneStore:
|
||||
expected_task_id=task_id,
|
||||
expected_claim_id=claim_id,
|
||||
expected_previous_hash=previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
):
|
||||
raise ConflictError("evidence envelope invalid")
|
||||
self.add_evidence_envelope(envelope)
|
||||
@@ -1251,7 +1469,14 @@ class TaskPlaneStore:
|
||||
return updated
|
||||
|
||||
def delegate_task_with_budget_and_events(
|
||||
self, parent_task_id: str, child_plan: TaskPlan, *, caller: str
|
||||
self,
|
||||
parent_task_id: str,
|
||||
child_plan: TaskPlan,
|
||||
*,
|
||||
caller: str,
|
||||
owner_principal_id: str = "",
|
||||
mission_id: str = "",
|
||||
created_at: float | None = None,
|
||||
) -> TaskRecord:
|
||||
with self._transaction():
|
||||
parent_budget = self.get_task_budget(parent_task_id)
|
||||
@@ -1270,7 +1495,12 @@ class TaskPlaneStore:
|
||||
self.consume_budget(parent_task_id, needed)
|
||||
self._fault("delegate.after_budget_before_child")
|
||||
child_plan.parent_task_id = parent_task_id
|
||||
child = self.create_task(child_plan)
|
||||
child = self.create_task(
|
||||
child_plan,
|
||||
owner_principal_id=owner_principal_id,
|
||||
mission_id=mission_id,
|
||||
created_at=created_at,
|
||||
)
|
||||
self.append_event(
|
||||
parent_task_id,
|
||||
"TASK_DELEGATED",
|
||||
@@ -1503,6 +1733,7 @@ class TaskPlaneStore:
|
||||
expected_task_id=envelope.task_id,
|
||||
expected_claim_id=envelope.claim_id,
|
||||
expected_previous_hash=expected_previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
):
|
||||
raise ConflictError("evidence envelope invalid")
|
||||
self.conn.execute(
|
||||
@@ -1570,6 +1801,7 @@ class TaskPlaneStore:
|
||||
expected_task_id=task_id,
|
||||
expected_claim_id=envelope.claim_id,
|
||||
expected_previous_hash=previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
)
|
||||
):
|
||||
return False
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
INSERT OR IGNORE INTO node_status(node_id, health, active, score, last_seen_at, note) VALUES ('server-hw','ok',1,100,datetime('now'),'primary logical node'),('server-local','ok',0,95,datetime('now'),'backup logical node');
|
||||
INSERT OR IGNORE INTO routing_policy(role, primary_node, backup_node, active_node, failover_priority, updated_at) VALUES ('owner','server-hw','server-local','server-hw',1,datetime('now')),('admin','server-hw','server-local','server-hw',2,datetime('now')),('user','server-hw','server-local','server-hw',3,datetime('now'));
|
||||
INSERT OR IGNORE INTO users(id, username, display_name, role, status, password_hash, created_at, updated_at) VALUES ('bootstrap-owner','owner','Bootstrap Owner','owner','approved','bootstrap-change-me',datetime('now'),datetime('now')),('bootstrap-admin','admin','Bootstrap Admin','admin','approved','bootstrap-change-me',datetime('now'),datetime('now')),('sample-user','sample-user','Sample User','user','approved','bootstrap-change-me',datetime('now'),datetime('now'));
|
||||
-- SEC-P0-BOOTSTRAP-CREDENTIAL hardening: seed NO usable password. Empty hash
|
||||
-- means "not set / forced reset" -> owner/admin must set a strong password via
|
||||
-- the bootstrap flow (scripts/patch_runtime_owner_admin_user.py) over a secure channel.
|
||||
INSERT OR IGNORE INTO users(id, username, display_name, role, status, password_hash, created_at, updated_at) VALUES ('bootstrap-owner','owner','Bootstrap Owner','owner','approved','',datetime('now'),datetime('now')),('bootstrap-admin','admin','Bootstrap Admin','admin','approved','',datetime('now'),datetime('now')),('sample-user','sample-user','Sample User','user','approved','',datetime('now'),datetime('now'));
|
||||
-- One-time cleanup: neutralize any legacy known plaintext bootstrap seed already present.
|
||||
UPDATE users SET password_hash='', updated_at=datetime('now') WHERE password_hash='bootstrap-change-me';
|
||||
INSERT OR IGNORE INTO model_policy(role, default_model, allowed_models, updated_at) VALUES ('owner','deepseekv4-flash','["deepseekv4-flash","qwen3.6-35b","gpt-5.5"]',datetime('now')),('admin','deepseekv4-flash','["deepseekv4-flash","qwen3.6-35b"]',datetime('now')),('user','deepseekv4-flash','["deepseekv4-flash"]',datetime('now'));
|
||||
INSERT OR IGNORE INTO quota_policy(username, daily_limit, used_today, updated_at) VALUES ('owner',10000,0,datetime('now')),('admin',2000,0,datetime('now')),('sample-user',200,0,datetime('now'));
|
||||
|
||||
@@ -55,10 +55,36 @@ def seed_base_models():
|
||||
if not one('SELECT * FROM gitops_versions WHERE version=?', ('V1R1C0',)):
|
||||
exec_sql('INSERT INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)', ('V1R1C0','V1R1C0','R','MultiUser runtime selector + GitOps + custom LLM priority registry','ready','system',now()))
|
||||
|
||||
_ROLE_WHITELIST = {'owner', 'admin', 'user'}
|
||||
def token_identity(token: str):
|
||||
"""Resolve the server-authoritative identity bound to a token.
|
||||
|
||||
Format of each TOKEN_FILE line (one per line, ':'-separated):
|
||||
<token> -> authenticated principal, role 'user'
|
||||
<token>:<user>:<role> -> mapped principal; role restricted to owner/admin/user
|
||||
Identity NEVER comes from a client-supplied header. In local dev mode the
|
||||
DEV_ALLOW flag maps a fixed dev principal (must be explicitly enabled).
|
||||
"""
|
||||
if DEV_ALLOW:
|
||||
return {'username': 'dev-owner', 'role': 'owner'}
|
||||
if not token or not TOKEN_FILE.exists():
|
||||
return None
|
||||
for line in TOKEN_FILE.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = [x.strip() for x in line.split(':')]
|
||||
if not parts or parts[0] != token:
|
||||
continue
|
||||
if len(parts) >= 3:
|
||||
role = parts[2] if parts[2] in _ROLE_WHITELIST else 'user'
|
||||
return {'username': safe_name(parts[1]), 'role': role}
|
||||
return {'username': 'user-%s' % token[:8], 'role': 'user'}
|
||||
return None
|
||||
|
||||
def valid_token(token: str) -> bool:
|
||||
if DEV_ALLOW: return True
|
||||
if not token or not TOKEN_FILE.exists(): return False
|
||||
return token.strip() in [x.strip() for x in TOKEN_FILE.read_text().splitlines() if x.strip()]
|
||||
return token_identity(token) is not None
|
||||
def git_cmd(args, check=False):
|
||||
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
||||
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git command failed')
|
||||
@@ -88,8 +114,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
server_version = 'AIOSGitOpsModelAPI/V1R1C0'
|
||||
def actor(self):
|
||||
auth = self.headers.get('Authorization',''); token = auth.replace('Bearer ', '').strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
||||
ok = valid_token(token); user = self.headers.get('X-AIOS-User') or ('dev-owner' if DEV_ALLOW else 'anonymous'); role = self.headers.get('X-AIOS-Role') or ('owner' if DEV_ALLOW else 'user')
|
||||
return {'username': safe_name(user), 'role': role, 'token_ok': ok}
|
||||
ident = token_identity(token)
|
||||
if ident is None:
|
||||
return {'username': 'anonymous', 'role': 'user', 'token_ok': False}
|
||||
return {'username': ident['username'], 'role': ident['role'], 'token_ok': True}
|
||||
def require_write(self, roles=None):
|
||||
a = self.actor()
|
||||
if not a['token_ok']: self.send_json({'error':'unauthorized','hint':'write APIs require Bearer token or AIOS_GITOPS_DEV_ALLOW=1'}, 401); return None
|
||||
|
||||
+14
-29
@@ -1,8 +1,8 @@
|
||||
<<<<<<< HEAD
|
||||
# AIOS Agent Command Plane Registry
|
||||
# Git is the single source of truth for all agent commands.
|
||||
version: 1.0.0
|
||||
created_at: 2026-07-08T15:20:00+08:00
|
||||
registry_version: 1.0.0
|
||||
created_at: 2026-07-08T15:44:00+08:00
|
||||
baseline: V1R3C1
|
||||
|
||||
commands:
|
||||
@@ -12,6 +12,12 @@ commands:
|
||||
completed_path: "agents/commands/completed/"
|
||||
failed_path: "agents/commands/failed/"
|
||||
|
||||
directories:
|
||||
commands/active: "New commands pending execution — Git is the only entry point"
|
||||
commands/running: "Commands currently being executed by an agent"
|
||||
commands/completed: "Commands that finished successfully — moved here on success"
|
||||
commands/failed: "Commands that finished with errors — moved here on failure"
|
||||
|
||||
policies:
|
||||
path: "agents/policies/"
|
||||
source: "git"
|
||||
@@ -31,6 +37,12 @@ agents:
|
||||
type: reviewer
|
||||
role: review
|
||||
|
||||
lifecycle:
|
||||
new: active/
|
||||
running: running/
|
||||
success: completed/
|
||||
failure: failed/
|
||||
|
||||
# Audit plane configuration
|
||||
audit:
|
||||
enabled: true
|
||||
@@ -38,30 +50,3 @@ audit:
|
||||
agent_runs: "audit/agent-runs/"
|
||||
version_history: "audit/version-history/"
|
||||
command_history: "audit/command-history/"
|
||||
=======
|
||||
# AIOS Agent Command Registry
|
||||
# Git is the single source of truth for all agent commands.
|
||||
|
||||
registry_version: 1.0.0
|
||||
created_at: "2026-07-08T15:44:00+08:00"
|
||||
baseline: V1R3C1
|
||||
|
||||
directories:
|
||||
commands/active: "New commands pending execution — Git is the only entry point"
|
||||
commands/running: "Commands currently being executed by an agent"
|
||||
commands/completed: "Commands that finished successfully — moved here on success"
|
||||
commands/failed: "Commands that finished with errors — moved here on failure"
|
||||
|
||||
state: agents/state/
|
||||
policies: agents/policies/
|
||||
|
||||
lifecycle:
|
||||
new: active/
|
||||
running: running/
|
||||
success: completed/
|
||||
failure: failed/
|
||||
|
||||
# Command lifecycle state machine:
|
||||
# active/ → locked & moved to running/ → execute → success → completed/
|
||||
# → failure → failed/
|
||||
>>>>>>> 4d18ee0 (feat(cmd): enable Agent Command Plane, audit plane, and Web UI dashboard)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
[Unit]
|
||||
Description=AIOS GitOps Model API V2R2C2 Baseline1.1 isolated candidate (2852c2a) - control-plane bootstrap only
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=aios-trusted-candidate-builder.socket
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
WorkingDirectory=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime
|
||||
UMask=0077
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
Environment=AIOS_GITOPS_ROOT=/var/lib/aios-gitops
|
||||
Environment=AIOS_GITOPS_WORKSPACE=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime
|
||||
Environment=AIOS_GITOPS_REPO=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_GITOPS_BARE_REPO=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||
Environment=AIOS_GITOPS_BASELINE_AUTHORITY=/var/lib/aios-gitops-runtime-anchor/baseline/current.json
|
||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_GITOPS_JOB_CONTROLLER_ENABLED=false
|
||||
Environment=AIOS_GITOPS_WORKSPACE_ROLE=runtime-anchor
|
||||
Environment=AIOS_GITOPS_RUNTIME_OID=2852c2a40394195192c6c02a215ba31764e2cdda
|
||||
Environment=AIOS_GITOPS_ARTIFACT=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime/governed-runtime-package.json
|
||||
Environment=AIOS_GITOPS_ARTIFACT_SHA256=932431dbbdee55522342d9c94457f40d8f6f7e4fc7eaadc71c66d0a6bdf8472f
|
||||
Environment=AIOS_GITOPS_DEPLOYMENT_STATE=CANDIDATE_ISOLATED
|
||||
Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c2-baseline11-candidate.service
|
||||
Environment=AIOS_RUNTIME_CANDIDATE_TREE_OID=ccd67c2357e7a4cd74e06be61ab81bba820d7671
|
||||
Environment=AIOS_RUNTIME_PROVENANCE_COMMIT=2852c2a40394195192c6c02a215ba31764e2cdda
|
||||
Environment=AIOS_AUTHORITY_SOURCE_PUBLISHED=true
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITOPS_LEGACY_GLOBAL_WRITES_ENABLED=false
|
||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-v2r2c2-baseline11-candidate/silent-release
|
||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||
Environment=AIOS_GITOPS_PORT=18897
|
||||
Environment=PYTHONPATH=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime:/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime/app:/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime/runtime-agent-os
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_ENABLED=true
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_SOCKET=/run/aios-trusted-candidate-builder.sock
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_POLICY=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime/config/gitops/trusted-candidate-builder-policy.json
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING=%d/builder_public_keyring
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_CREDENTIALS_DIRECTORY=%d
|
||||
Environment=AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT=/var/lib/aios-release-source-admission
|
||||
Environment=AIOS_SILENT_RELEASE_TRUSTED_POLICY=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime/config/gitops/trusted-release-policy.json
|
||||
LoadCredentialEncrypted=builder_hmac:/etc/credstore.encrypted/aios-trusted-candidate-builder/builder_hmac
|
||||
LoadCredentialEncrypted=source_admission_hmac:/etc/credstore.encrypted/aios-release-source-admission/source_admission_hmac
|
||||
LoadCredential=builder_public_keyring:/etc/aios-gitops/trusted-builder/public-keyring.json
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime/app/aios_model_api_staging.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
ReadOnlyPaths=/opt/aios/gitops-model-api/2852c2a40394195192c6c02a215ba31764e2cdda/runtime /data/aios-gitops/repo.git /etc/aios-gitops/trusted-builder/public-keyring.json /var/lib/aios-release-source-admission /var/lib/aios-release-migration
|
||||
ReadWritePaths=/var/lib/aios-gitops/model-api-v2r2c2-baseline11-candidate
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,30 @@
|
||||
# C4 capability contract provenance
|
||||
|
||||
The C4 checkout has no `aios_capability_contract.py`, although
|
||||
`active-runtime/app/aios_agent_task_protocol.py` imports it at module scope.
|
||||
The read-only QA capture used for the missing source closure is:
|
||||
|
||||
- source: `C:\\Users\\ImRuY\\.codex-restored\\work\\v2r2c5-release-20260926\\qa\\capability-contract.txt`
|
||||
- SHA256: `c97c9f8a3cf199a9c0348da1212aa26d161aad8ddaed84db534e46f490f2422f`
|
||||
- 140 lines
|
||||
|
||||
The contract preserves separate task and worker alias maps, maps unknown task
|
||||
names to `exact:<token>`, ignores unknown worker names, and returns false for
|
||||
unknown or mismatched eligibility. It is used by formal AgentTask poll and
|
||||
claim paths only. Generic `task_plane` matching remains outside this lane.
|
||||
|
||||
The authoritative runtime overlay is captured separately by QA:
|
||||
|
||||
- base SHA256: `953de1d491b4f1bc47c71f502027fd488e6c48ecc49da8a91dc9d8754c320d17`
|
||||
- overlay SHA256: `7555aa067fc01c6a25276a834dda4924b404f3b0c529ed3f99e9f1d83d18798e`
|
||||
|
||||
The consolidated module preserves the base mappings and adds only the seven
|
||||
explicit lifecycle labels: `agent-task:control`, `governed.task.create`,
|
||||
`governed.write.files`, `task.principal.issue`, `testing`, `verification`, and
|
||||
`writer.lease`. It does not depend on an absolute deployed path.
|
||||
|
||||
Validation logs:
|
||||
|
||||
- `PYTHONPATH=active-runtime/app python -m pytest -q tests/test_v2r2c5_capability_contract.py` -> 6 passed.
|
||||
- `PYTHONPATH=active-runtime/app;active-runtime python -m pytest -q tests/test_task_admission_bootstrap.py tests/test_task_admission_bootstrap_chain_contract.py` -> 45 passed.
|
||||
- `tests/test_task_admission_bootstrap_http.py` could not collect on Windows because the existing `gitea_tag_publication.py` imports unavailable `fcntl`; no capability assertion ran in that file.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schema": "aios.unified-platform-program-contract.v1",
|
||||
"goal_slug": "AIOS-UNIFIED-PLATFORM-CONVERGENCE",
|
||||
"authority_base": "beabd053961047f170e87e1110f2636dbd50f5fb",
|
||||
"existing_identity_lineage_dependency": {
|
||||
"mission_id": "mission-f3af205b-7dd6-455f-a561-88f0294cc815",
|
||||
"root_task_id": "task-133ad96e-e0d7-4202-934f-80c75053b11f",
|
||||
"acceptance_mutation_allowed": false
|
||||
},
|
||||
"tracks": {
|
||||
"A": "IDENTITY_LINEAGE_EXISTING_MISSION",
|
||||
"B": "ANNEX_2_3_R2_AUTO_CLAIM",
|
||||
"C": "PLATFORM_GENERATION_G0",
|
||||
"D": "GOVERNED_MERGE_STAGING_RELEASE"
|
||||
},
|
||||
"program_acceptance": [
|
||||
"U01",
|
||||
"U02",
|
||||
"U03",
|
||||
"U04",
|
||||
"U05",
|
||||
"U06",
|
||||
"U07",
|
||||
"U08"
|
||||
],
|
||||
"deployment_requires_review_go": true,
|
||||
"new_business_authority_created": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"bindings":[{"acceptance_key":"C01","acceptance_position":0,"lineage_request_digest":"e9946481e7c9fe8dee94aa42ffa0a9b41546ce9d46601ff221a958710c72f6cc","mission_id":"mission-f3af205b-7dd6-455f-a561-88f0294cc815","owner_principal_id":"local-owner-authority","parent_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","root_acceptance_ref":"mission-acceptance:mission-f3af205b-7dd6-455f-a561-88f0294cc815:identity-lineage-live-closure-v1","root_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","route":"GOVERNED_GIT","schema":"aios.autonomous-child-binding.v1","sibling_sequence":1,"task_id":"task-1668131d-7484-4c8e-83b4-2cf9927842fe","task_plan_digest":"3f0824f51d824de7d38821205c012c2a23ac11e9f5889b8b0aafb07cce85333f"},{"acceptance_key":"C02","acceptance_position":1,"lineage_request_digest":"84f28d350ef3a2dce47fb5f3163b61bfd329ce5fe85bd59c886d4813e806b602","mission_id":"mission-f3af205b-7dd6-455f-a561-88f0294cc815","owner_principal_id":"local-owner-authority","parent_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","root_acceptance_ref":"mission-acceptance:mission-f3af205b-7dd6-455f-a561-88f0294cc815:identity-lineage-live-closure-v1","root_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","route":"SEMANTIC_IMPORT","schema":"aios.autonomous-child-binding.v1","sibling_sequence":2,"task_id":"task-24fe3d9a-783e-4a68-bf9c-5b1a9d2b56c7","task_plan_digest":"e01680583b8cc2ec3d33026dda9f7d095692d0ae79fc43f380066511566e305e"},{"acceptance_key":"C03","acceptance_position":2,"lineage_request_digest":"148fa8b55224649ac80f4d9d5c344019dfef24b1d5ed614ef62ee71ccaf8a6d3","mission_id":"mission-f3af205b-7dd6-455f-a561-88f0294cc815","owner_principal_id":"local-owner-authority","parent_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","root_acceptance_ref":"mission-acceptance:mission-f3af205b-7dd6-455f-a561-88f0294cc815:identity-lineage-live-closure-v1","root_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","route":null,"schema":"aios.autonomous-child-binding.v1","sibling_sequence":3,"task_id":"task-78e0dff5-c1fe-491a-99d2-81ad5cf180da","task_plan_digest":"aef0c0cfee2686ad7bff122328408c5094370bf29fac37a49ddc50173fa60986"},{"acceptance_key":"C04","acceptance_position":3,"lineage_request_digest":"927dde8068b9dde4d57e0f9eb6bd52d95d761533974a7437d01707ea3c5c807d","mission_id":"mission-f3af205b-7dd6-455f-a561-88f0294cc815","owner_principal_id":"local-owner-authority","parent_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","root_acceptance_ref":"mission-acceptance:mission-f3af205b-7dd6-455f-a561-88f0294cc815:identity-lineage-live-closure-v1","root_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","route":null,"schema":"aios.autonomous-child-binding.v1","sibling_sequence":4,"task_id":"task-9b6e0cb1-c54a-4f91-8762-7c72112cf71d","task_plan_digest":"d1eae4e90a5892dd851fbe80c2ee0920e7060cd6ce701ae0522d110dcd9af638"},{"acceptance_key":"C05","acceptance_position":4,"lineage_request_digest":"4336de284be15d504af661c18a98c820da3c3518ef5df25d4607c19c3ba724b2","mission_id":"mission-f3af205b-7dd6-455f-a561-88f0294cc815","owner_principal_id":"local-owner-authority","parent_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","root_acceptance_ref":"mission-acceptance:mission-f3af205b-7dd6-455f-a561-88f0294cc815:identity-lineage-live-closure-v1","root_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","route":null,"schema":"aios.autonomous-child-binding.v1","sibling_sequence":5,"task_id":"task-bd8c44c5-7081-4a3f-a3b0-4de4ad6e3b29","task_plan_digest":"dd7e02ef51f601210295c9bda1061dcd7ff944bc7a5dcb2e813db2d601b138a4"},{"acceptance_key":"C06","acceptance_position":5,"lineage_request_digest":"54bdc5cf4414bc52105e966072009c9b0ce8235392f767b38e856072dd01148c","mission_id":"mission-f3af205b-7dd6-455f-a561-88f0294cc815","owner_principal_id":"local-owner-authority","parent_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","root_acceptance_ref":"mission-acceptance:mission-f3af205b-7dd6-455f-a561-88f0294cc815:identity-lineage-live-closure-v1","root_task_id":"task-133ad96e-e0d7-4202-934f-80c75053b11f","route":null,"schema":"aios.autonomous-child-binding.v1","sibling_sequence":6,"task_id":"task-591a87fa-e722-4647-a71d-80624b7e6b6a","task_plan_digest":"9b0d092f6a0a987844edae129eaedfc6bed87c135d8d3db390b0f01b43f896c8"}],"policy_version":"identity-lineage-autonomous-next-child-v1","schema":"aios.autonomous-child-binding-set.v1"}
|
||||
@@ -0,0 +1,96 @@
|
||||
# G7 — Security-Critical Closure 证据与判定
|
||||
|
||||
- **Gate**: G7_SECURITY_CRITICAL_CLOSURE
|
||||
- **判定**: `G7_CHANGES_REQUIRED`(修复已就绪,等待上游治理合入 + staging_readback + ROOT_REVIEW 后升为 APPROVED_FOR_GOVERNED_SUBMISSION)
|
||||
- **Authority HEAD (base OID)**: `beabd053961047f170e87e1110f2636dbd50f5fb`
|
||||
- **工作分支**: `agent/g7-security-critical-closure-20260914`(基于 origin/main;本地修复分支,待 PR + Owner 合入 main,不绕过保护 main)
|
||||
- **日期**: 2026-09-14
|
||||
- **扫描源**: `aios-scan-full.json` (1.08MB, 1039 findings, 高召回含误报)
|
||||
- **规模**: 729 文件 / 24 个指派文件修改 / 5 个新增负面测试套件
|
||||
|
||||
## 摘要
|
||||
|
||||
全仓 review 为高召回设计并含误报,故未机械修复全部 1039 条发现。本次仅闭环被
|
||||
权威源码复核为 CONFIRMED 且直接影响控制面授权、凭证安全、任意文件访问、特权 UI
|
||||
的 **P0 阻塞项(6 项)+ P1 交付正确性包(若干)**。DEAD_PATH/FALSE_POSITIVE 项已
|
||||
按契约排除,并记录依据。
|
||||
|
||||
## 修复分账(S1–S5)
|
||||
|
||||
| 阻塞项 | 角色 | 分类 | 文件 | pre→post SHA | 修复要点 |
|
||||
|---|---|---|---|---|---|
|
||||
| SEC-P0-AUTH-ROLE-HEADER | S1 | CONFIRMED | adapters/gitops/aios_gitops_model_api.py | 60396e0→8990317 | role/user 由服务端 token→identity 映射派生,忽略 X-AIOS-User/Role 头 |
|
||||
| SEC-P0-AUTH-ROLE-HEADER | S1 | CONFIRMED | active-runtime/app/aios_gitops_model_api.py | ac8c1bf→80b04bd | 同上 |
|
||||
| SEC-P0-TASK-ACTION-ROLE | S1 | CONFIRMED | active-runtime/app/task_action_api.py | b18e0a5→31c6f6e | 忽略 body role,role 由注入认证主体派生,默认 user |
|
||||
| SEC-P0-OBSERVABILITY-ROLE | S1 | CONFIRMED | active-runtime/app/agent_observability_api.py | f516ef9→6b065fb | 认证 tenant 决定查询范围,客户端 role=owner 被忽略 |
|
||||
| SEC-P0-BOOTSTRAP-CREDENTIAL | S2 | CONFIRMED | active-runtime/migrations/002_seed_owner_admin_user.sql | 7fff9e5→4cb5a33 | 种子空哈希(强制重置),清除 bootstrap-change-me |
|
||||
| SEC-P0-BOOTSTRAP-CREDENTIAL | S2 | CONFIRMED | active-runtime/app/aios_webops_app.py | 83387a8→7001142 | PBKDF2 随机盐;SECRET 缺失 fail-closed;拒绝明文回退;会话 HMAC+过期 |
|
||||
| SEC-P0-BOOTSTRAP-CREDENTIAL | S2 | CONFIRMED | active-runtime/app/aios_v5_webops_app.py | 83387a8→7001142 | 同上 |
|
||||
| SEC-P0-CONTROL-PLANE-FILE-READ | S3 | CONFIRMED | active-runtime/app/aios_chatgpt_actions_api.py | 47ae2c8→4f345a0 | 命令读取:认证+严格标识+包含校验+1MiB 上限 |
|
||||
| SEC-P0-CONTROL-PLANE-FILE-READ | S3 | CONFIRMED | active-runtime/app/aios_model_api_staging.py | 1616b37→e94d0dc | 同上 |
|
||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | services/aios-event-ingress/server.py | c790d41→c4e93cb | ID 白名单+resolve 包含校验 |
|
||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | active-runtime/app/agent_observability_store.py | 40cc555→4746b3a | execution_id 白名单+包含校验 |
|
||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | active-runtime/app/aios_campaign_recovery.py | 0d09fd2→06e208f | 证据路径 resolve+realpath 包含校验 |
|
||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | scripts/gitops/task_worktree.sh | 72d33f4→19ea429 | TASK_ID 正则收紧,拒绝 .. ../ ..\ |
|
||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | scripts/init_gitops_baseline.sh | 472e60c→48f584e | zip-slip 解包前条目校验 |
|
||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | ops/gitops-migration/sync_gitea_owner_password.sh | 11573c9→8e85831 | 消除 env 来源注入,argv+引号 heredoc |
|
||||
| SEC-P0-ADMIN-UI-XSS | S4 | CONFIRMED | active-runtime/app/static/admin-ai-evolution.html | 6da3ce8→05d26ab | textContent 全面转义;修复 JS 语法;用真实 governed 审批端点替代本地 fake Approve |
|
||||
| SEC-P1-WEB-CONTROL-SURFACE | S4 | CONFIRMED | overlays/.../gitops-dashboard/index.html | 7f459a1→484447b | 客户端 CSRF token 传播;状态变更带 token;branch 服务端校验 |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | agents/registry.yaml | 7b69d96→0037e73 | 解决阻塞性 merge conflict,恢复可解析 YAML |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | active-runtime/app/aios_model_api_staging.py | 1616b37→e94d0dc | 消除 do_POST 遮蔽(合并 bridge 路由) |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | docs/gitops/openapi-chatgpt-actions.yaml | 5278b91→9f339c0 | 补全 securitySchemes.taskPrincipalAuth |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | deploy/install/install.sh | ab1f433→b90b759 | 移除 `\|\| true`,启动失败传播 |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | scripts/silent_install_v5.sh | 59e9552→e26a3b3 | 缺失脚本显式报错 |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | delta-fix/apply_delta_fix_only.sh | de1e2fd→d09d2ab | 修复未定义变量 |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | **DEAD_PATH** | active-runtime/app/architecture_owner_gate_http.py | 未改 | 全库零引用,非活路径 |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | **DEAD_PATH** | runtime/agent-os/api.py | 未改 | 全库零引用独立模块,facade 签名不符但无活路径 |
|
||||
|
||||
## 负面测试(S6 独立 QA 验证)
|
||||
|
||||
新增 5 个测试套件,均针对**真实已修复模块**(经 tests/conftest.py 的 active_runtime 命名空间映射到实际文件):
|
||||
|
||||
- `tests/test_g7_auth_negative.py` — 9 passed
|
||||
- `tests/test_g7_credential_negative.py` — 4 passed
|
||||
- `tests/test_g7_path_containment_negative.py` — 7 passed(+8 bash 用例如下)
|
||||
- `tests/test_g7_runtime_delivery.py` — 4 passed
|
||||
- `tests/test_g7_web_surface_negative.py` — 42 passed
|
||||
|
||||
**合计 66 passed / 0 failed**。8 个 bash 子进程用例如 Windows 沙箱 pytest 无法拉起 bash 而 skip,
|
||||
已在本机 Bash 环境实测真实脚本:遍历 TASK_ID → rc=65 拒绝;zip-slip `../` 条目 → BLOCKED 退出 11。
|
||||
|
||||
### 覆盖的负面场景
|
||||
- spoof `X-AIOS-Role: owner` / `X-AIOS-User` → 不能提权
|
||||
- JSON `role=owner` → 被忽略
|
||||
- observability `role=owner` + 外来 user/group → 限定自身
|
||||
- 非 owner token 下 `require_write(['owner'])` → 403
|
||||
- 已知 bootstrap 口令拒绝登录;哈希非明文且随机盐;缺 SECRET 拒绝启动;过期令牌失效
|
||||
- POSIX `../` / Windows `..\` / 编码遍历 / 超大读取 / zip-slip / 绝对路径拒绝
|
||||
- HTML `<img onerror>` / script 载荷保持文本;CSRF-less 状态变更被拒;无效 branch 被拒
|
||||
- registry.yaml / openapi 可解析且引用完整;install.sh 失败传播;脚本无未定义变量
|
||||
|
||||
### 回归
|
||||
- 全部 `active-runtime/app` + `adapters` 211 个 py 文件 `py_compile` 通过(无导入/Syntax 回归)
|
||||
- 全部 15 个改动/新增 py 文件编译通过
|
||||
|
||||
## 证据字段映射(G7_SECURITY_GATE.md Required evidence)
|
||||
|
||||
对每个阻塞项均已具备:finding_id / authority_oid(=beabd05…) / affected_path / live_path=true /
|
||||
preimage_sha256(上表)、fix_commit_oid(见下方提交)、postimage_sha256(上表)、
|
||||
negative_tests(通过)、positive_tests(编译+回归)、independent_review(S6 独立运行通过)、
|
||||
runtime/staging_readback、rollback(见 S2 迁移/回滚路径)。
|
||||
|
||||
## 判定与下一步
|
||||
|
||||
- **当前判定**: `G7_CHANGES_REQUIRED` → 修复就绪,待合入流程推进。
|
||||
- **下一步**(需 Owner/治理,非本 Agent 越权执行):
|
||||
1. 审查并合入 `agent/g7-security-critical-closure-20260914`(走 protected main PR + Owner approval)
|
||||
2. staging 部署后作 runtime/staging_readback(真实服务启动验证,尤其 S2 的 AIOS_WEBOPS_SECRET 注入与 S1 的 token 映射文件格式)
|
||||
3. 执行 S2 owner 引导设密流程(scripts/patch_runtime_owner_admin_user.py)设置真实 owner 强口令
|
||||
4. 独立 final review → governed submission → staging → live E2E → production → ROOT_REVIEW
|
||||
- **已知未闭环**(诚实标注): S1 的 task_action/observability 认证主体需宿主 FastAPI 应用注入
|
||||
`set_authenticator()`,当前默认按最低权限 user(安全降级但 owner 能力需接线);staging_readback
|
||||
与 rollback 实机验证需在部署环境执行,本环境未具备。
|
||||
|
||||
## 备注(Triage 纪律)
|
||||
- 未机械处理 1000+ 条 medium/low/维护性/样式/历史死代码注释(契约明确非当前发布阻塞项)。
|
||||
- DEAD_PATH 项未改动并给出全库引用证据。
|
||||
@@ -15,5 +15,5 @@ bash "$PKG_ROOT/scripts/smoke_test.sh" "$AIOS_RUNTIME_ROOT"
|
||||
python3 "$PKG_ROOT/scripts/generate_release_manifest.py" "$AIOS_RUNTIME_ROOT"
|
||||
echo "DELTA_FIX_ONLY_APPLY_OK release=$AIOS_RELEASE_ID runtime=$AIOS_RUNTIME_ROOT"
|
||||
|
||||
python3 "$PKG/fixes/patch_runtime_v5_guards.py" "$TARGET"
|
||||
python3 "$PKG_ROOT/fixes/patch_runtime_v5_guards.py" "$AIOS_RUNTIME_ROOT"
|
||||
echo "V5_DIGITAL_LIFE_DELTA_APPLIED"
|
||||
|
||||
@@ -33,11 +33,11 @@ cp deploy/systemd/aios-autonomous-daemon.service /etc/systemd/system/
|
||||
cp deploy/systemd/aios-v1r1-wechat-worker.service /etc/systemd/system/
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable aios-autonomous-daemon.service || true
|
||||
systemctl enable aios-v1r1-wechat-worker.service || true
|
||||
systemctl enable aios-autonomous-daemon.service
|
||||
systemctl enable aios-v1r1-wechat-worker.service
|
||||
|
||||
systemctl restart aios-autonomous-daemon.service || true
|
||||
systemctl restart aios-v1r1-wechat-worker.service || true
|
||||
systemctl restart aios-autonomous-daemon.service
|
||||
systemctl restart aios-v1r1-wechat-worker.service
|
||||
|
||||
sleep 2
|
||||
systemctl status aios-autonomous-daemon.service --no-pager || true
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# ADR: R2 evidence digests reuse TaskPlane canonical JSON
|
||||
|
||||
Status: ACCEPTED FOR R2 REVIEW CANDIDATE
|
||||
Date: 2026-09-14
|
||||
|
||||
## Context
|
||||
|
||||
R1 introduced local sorted/separator JSON encoders in schedulability proof and
|
||||
Retry/Toil fingerprint code. Platform Baseline V4 §1.3/§4.4/§12 prohibits new
|
||||
parallel canonical encoders unless a scoped ADR proves the existing contract is
|
||||
not applicable.
|
||||
|
||||
The candidate already carries the established TaskPlane contract:
|
||||
|
||||
`app.task_plane.models.canonical_json(value)`
|
||||
|
||||
which serializes UTF-8 JSON deterministically with sorted keys and compact
|
||||
separators.
|
||||
|
||||
## Decision
|
||||
|
||||
R2 reuses `task_plane.models.canonical_json` for:
|
||||
|
||||
1. schedulability observation/proof digests;
|
||||
2. Retry/Toil operation fingerprint payloads.
|
||||
|
||||
Retry/Toil adds a domain schema wrapper
|
||||
`aios.retry-toil-fingerprint.v1` before hashing so the digest domain is
|
||||
separated without inventing a new byte encoder.
|
||||
|
||||
No generic canonical platform, second encoder, or new write authority is added.
|
||||
|
||||
## Bytes and algorithm
|
||||
|
||||
- canonical text producer: `task_plane.models.canonical_json`
|
||||
- bytes: UTF-8 encoding of that exact text
|
||||
- digest: SHA-256 hex
|
||||
- schedulability schema: `aios.schedulability-proof.v2`
|
||||
- retry fingerprint schema: `aios.retry-toil-fingerprint.v1`
|
||||
|
||||
## Migration / compatibility
|
||||
|
||||
These R2 modules are not yet live authorities. Their R1 digests are review
|
||||
candidate evidence only and do not require backwards-compatible production
|
||||
decoding. Any future persisted proof/fingerprint consumer must bind schema
|
||||
version explicitly.
|
||||
|
||||
## Guard
|
||||
|
||||
R2 tests reject a reintroduction of local `json.dumps(sort_keys=True,
|
||||
separators=...)` encoders in the two modules.
|
||||
@@ -0,0 +1,232 @@
|
||||
# AIOS Unified Platform Convergence — Architecture Change V2
|
||||
|
||||
Status: R2 REVIEW CANDIDATE
|
||||
Date: 2026-09-14
|
||||
Authority base for rebind: `beabd053961047f170e87e1110f2636dbd50f5fb`
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
R2 preserves the R1 architectural direction but corrects the eight review
|
||||
findings F01–F08. It does not authorize deployment.
|
||||
|
||||
The program still has four tracks:
|
||||
|
||||
1. existing Identity / Lineage S3;
|
||||
2. ANNEX-2.3 R2 Auto-Claim / Execution Integrity;
|
||||
3. Platform Generation G0 (WP0a–e/WP1/WP2 only);
|
||||
4. governed merge, staging, release and root/program acceptance.
|
||||
|
||||
Existing Identity/Lineage mission/root/children remain authoritative and are
|
||||
not recreated or redefined by this program.
|
||||
|
||||
## 2. Authority split
|
||||
|
||||
- Git authority main: `beabd053961047f170e87e1110f2636dbd50f5fb`
|
||||
- release-control runtime: `3c320aeb89d9ba8f0ed516abeb57252682b18983`
|
||||
- production V2R2C2: `4642b74af62c89a505ae49241e805a5a88ca897f`
|
||||
|
||||
A review candidate is not a governed commit. All target preimages must be
|
||||
refreshed from live Git authority before submission.
|
||||
|
||||
## 3. R2 safety corrections
|
||||
|
||||
### 3.1 Caller evidence is fail-closed
|
||||
|
||||
`caller_scan v2` separates:
|
||||
|
||||
- static source caller evidence;
|
||||
- active runtime caller evidence;
|
||||
- test-only evidence;
|
||||
- opaque/dynamic uncertainty;
|
||||
- complete negative evidence.
|
||||
|
||||
Static source imports alone do not prove runtime LIVE.
|
||||
|
||||
`PROVEN_ORPHAN` requires all of:
|
||||
- zero production static caller;
|
||||
- zero active systemd/API/scheduler/runtime-loaded caller;
|
||||
- relevant runtime host set non-empty and completely scanned;
|
||||
- loaded bytes negative proof;
|
||||
- systemd/API/scheduler scan coverage;
|
||||
- complete observation window;
|
||||
- no probe failures/opaque dynamic references;
|
||||
- rollback reference;
|
||||
- independent acceptance `GO`.
|
||||
|
||||
Incomplete negative evidence returns `LIKELY_ORPHAN`; probe failure, opaque
|
||||
dynamic import, or unresolved production source caller returns `UNKNOWN`.
|
||||
UNKNOWN can never drive Sunset.
|
||||
|
||||
### 3.2 Schedulability proof is diagnostic, final claim is authoritative
|
||||
|
||||
R2 checks task lifecycle state and treats explicit `max_concurrency=0` as zero.
|
||||
Invalid capacity values are structured blockers.
|
||||
|
||||
The proof remains non-bearer. Claim has three stages:
|
||||
|
||||
1. preflight observation;
|
||||
2. claim-time read-only recheck;
|
||||
3. existing TaskPlane authority performs `claim_atomically` at the
|
||||
linearization point.
|
||||
|
||||
The atomic adapter contract must revalidate task revision/generation/status,
|
||||
worker generation/identity/health/capacity, authorization ref/revision,
|
||||
scope, dependencies, freeze and conflicting lease inside the same
|
||||
authoritative transaction/fenced operation that creates the claim.
|
||||
|
||||
The returned receipt must include claim id, lease id, fencing generation and
|
||||
receipt revision.
|
||||
|
||||
R2 reference tests verify the adapter contract only. They do **not** prove a
|
||||
production adapter is atomic; real staging A18–A23 remain mandatory.
|
||||
|
||||
### 3.3 Retry Governor is whitelist-only
|
||||
|
||||
- UNKNOWN/indeterminate → RECONCILE;
|
||||
- governance/dependency boundary → BLOCK;
|
||||
- proven success → SUPPRESS/REUSE;
|
||||
- unrecognized state → BLOCK;
|
||||
- only explicitly executable states may return EXECUTE.
|
||||
|
||||
A prior success for the same operation conflicting with a later NOT_APPLIED
|
||||
observation must reconcile rather than re-execute.
|
||||
|
||||
### 3.4 Canonical bytes are reused
|
||||
|
||||
Schedulability and Retry/Toil reuse
|
||||
`app.task_plane.models.canonical_json`; no second JSON canonical encoder is
|
||||
introduced. See `ADR_CANONICAL_EVIDENCE_DIGEST_V1.md`.
|
||||
|
||||
### 3.5 Platform G0 discovery is not G0 acceptance
|
||||
|
||||
WP0a–d and WP1 now expose completeness/missing evidence. Lexical/static
|
||||
discovery cannot by itself satisfy Generation-G0 Exit.
|
||||
|
||||
See `G0_EVIDENCE_COVERAGE_V2.md`.
|
||||
|
||||
## 4. Non-circular review/deploy gates
|
||||
|
||||
R1's production gate was circular because existing root C06 and Program
|
||||
production evidence could be required before production itself.
|
||||
|
||||
R2 replaces it with:
|
||||
|
||||
```text
|
||||
REVIEW_READY
|
||||
↓
|
||||
STAGING_ACTIVATION_READY
|
||||
↓
|
||||
STAGING_ACCEPTED
|
||||
↓
|
||||
PRODUCTION_ELIGIBLE
|
||||
↓
|
||||
PRODUCTION_ACCEPTED
|
||||
↓
|
||||
PROGRAM_COMPLETE
|
||||
```
|
||||
|
||||
### REVIEW_READY
|
||||
|
||||
Requires:
|
||||
- live authority rebind + exact preimages;
|
||||
- ChangeEnvelope complete;
|
||||
- complete affected repository regression;
|
||||
- independent review GO.
|
||||
|
||||
### STAGING_ACTIVATION_READY
|
||||
|
||||
Requires:
|
||||
- governed commit + authority readback;
|
||||
- bounded staging activation scope;
|
||||
- rollback/recovery plan ready;
|
||||
- pre-activation lab/contract evidence.
|
||||
|
||||
It does **not** require evidence that only real staging activation can produce.
|
||||
|
||||
### STAGING_ACCEPTED
|
||||
|
||||
Requires actual staging:
|
||||
- TaskPlane adapter/claim/lease/fencing;
|
||||
- root action executor;
|
||||
- ANNEX A01–A23;
|
||||
- restart/response-loss/sibling concurrency/recovery evidence.
|
||||
|
||||
### PRODUCTION_ELIGIBLE
|
||||
|
||||
Requires:
|
||||
- STAGING_ACCEPTED;
|
||||
- existing release authorization valid;
|
||||
- production rollback ready;
|
||||
- freeze/NO-GO clear.
|
||||
|
||||
### PRODUCTION_ACCEPTED
|
||||
|
||||
Requires actual deployment:
|
||||
- production deployment/runtime readback;
|
||||
- authenticated business E2E;
|
||||
- external projection readback.
|
||||
|
||||
### PROGRAM_COMPLETE
|
||||
|
||||
Only after production acceptance:
|
||||
- existing Identity/Lineage original C01–C06 all accepted;
|
||||
- Program final acceptance all satisfied;
|
||||
- final root review proves no remaining acceptance gaps.
|
||||
|
||||
The existing root acceptance contract is unchanged. Its full completion is
|
||||
consumed only by the final program-complete gate.
|
||||
|
||||
## 5. State authority rules
|
||||
|
||||
No new business authority is added.
|
||||
|
||||
- caller_scan: read-only evidence producer;
|
||||
- WP0 inventories: read-only discovery;
|
||||
- Environment Profile: read-only host profile;
|
||||
- Retry Governor: decision/audit only;
|
||||
- SchedulabilityProof: short-lived diagnostic evidence;
|
||||
- SystemTruth: read-only derived view;
|
||||
- TaskPlane / existing claim transaction: authoritative task/claim/lease state;
|
||||
- existing mission/continuation path: root-action executor;
|
||||
- existing release Saga: release authority.
|
||||
|
||||
No new Task DB, scheduler, mission store, release controller or authorization
|
||||
authority is introduced in R2.
|
||||
|
||||
## 6. Migration
|
||||
|
||||
- Existing mission/root/children: preserve IDs and acceptance.
|
||||
- Legacy durable task/job: attach metadata only; do not replace identity.
|
||||
- Schedulability proof: persist only through existing evidence/journal if
|
||||
persistence is required.
|
||||
- No direct SQLite mutation outside existing TaskPlane repository.
|
||||
- G0 outputs remain shadow/read-only.
|
||||
|
||||
## 7. Deployment status
|
||||
|
||||
R2 code may proceed to live-authority rebase and repository-native review after
|
||||
its focused regressions pass.
|
||||
|
||||
Deployment remains NO-GO until:
|
||||
- live preimages are refreshed;
|
||||
- full repository regressions pass;
|
||||
- atomic TaskPlane adapter is implemented and verified;
|
||||
- ANNEX A18–A23 pass;
|
||||
- real root-action executor consumes PENDING intents exactly once;
|
||||
- independent staging review is GO.
|
||||
|
||||
## 8. Rollback
|
||||
|
||||
Review candidate: discard candidate.
|
||||
|
||||
Governed but undeployed commit: use existing successor/revert path; no shared
|
||||
branch reset/force push.
|
||||
|
||||
Staging: disable feature/policy binding and preserve evidence.
|
||||
|
||||
Production: existing release Saga rollback only.
|
||||
|
||||
## 9. Deferred platform work
|
||||
|
||||
WP3–WP10 remain dependency-gated backlog from Platform Baseline V4. R2 does not
|
||||
claim their acceptance and does not pre-build them.
|
||||
@@ -0,0 +1,73 @@
|
||||
# C5 控制面闭合报告
|
||||
|
||||
状态:本地工程闭合候选已形成;live control plane 尚未闭合。本文是交付准备记录,不是生产发布、activation、Task admission 或 RC 成功证明。
|
||||
|
||||
## 已闭合的工程边界
|
||||
|
||||
架构决定和实施计划将 unattended runner 定义为编排客户端。它不拥有 Owner authority、epoch issuer、Task DB、Git writer、approval verifier 或 production executor。现有组件仍是唯一权威:`TaskAdmissionBootstrapService`/`AgentTaskAdmissionPort` 负责 envelope、grant 和 Task admission;既有 lease/principal/writer chain 负责写入;trusted builder 和 `SilentReleaseActionService` 负责 candidate/provider evidence;`ReleaseSagaStore`、`ReleaseOrchestrator` 和 `ReleaseRecoveryWorker` 负责部署、回滚与未知副作用恢复。旧的 `release_state_machine.py` 不应再作为第二状态权威。
|
||||
|
||||
本地 runner `active-runtime/app/governed_unattended_release.py` 已提供以下受限组合接口:
|
||||
|
||||
```text
|
||||
GovernedUnattendedReleaseRunner.intake(intent)
|
||||
GovernedUnattendedReleaseRunner.admit(intent, authorization)
|
||||
GovernedUnattendedReleaseRunner.publish(intent, admission_receipt)
|
||||
GovernedUnattendedReleaseRunner.postpublication_rc(event_id, source_commit, version, source_readback)
|
||||
GovernedUnattendedReleaseRunner.deploy(candidate_id)
|
||||
```
|
||||
|
||||
`SourcePublicationIntent` 对 repository、source/tree/base OID、精确 target branch、generation、execution digest、file allowlist、capability set 和 risk 做不可变规范化,并派生稳定 `task_id` 与 idempotency key。`admit` 在 envelope 已创建后遇到超时、连接断开或 5xx 时先 reconcile,不把未知结果当作未应用,也不重复提交;禁止错误不会重试。`publish` 要求同一 Task 的 `task_lease`、`task_principal`、`writer_lease`、authenticated commit receipt、base/branch/generation/execution digest/source/tree 全部绑定。缺一即阻断。
|
||||
|
||||
本地模拟器 `scripts/simulate_c5_unattended_release.py` 从 Git object 读取 source/base/tree/diff/archive,运行候选 C5 contract tests,使用临时 Owner key 和真实 admission/bootstrap/TaskStore 实现完成隔离签名与 admission,再写入现有模型的 candidate/manifest/RC JSON。输出明确标记 `SIMULATION`、`official_release=false`、`authority_source_commit=null`、`acceptance=PENDING_AUTHENTICATED_PRODUCTION_PROVIDER_EVIDENCE`、`approval=NOT_REQUESTED`、`deployment=NOT_ATTEMPTED`。这条链路可证明结构、签名验证、隔离 TaskStore、幂等和非权威 artifact 写出;不能证明 live authority、live publication、trusted production provider、release approval、runtime health 或 deployment。
|
||||
|
||||
`governed_release_evidence_producer.py` 的 `produce_evidence_closure(...)` 可生成现有 RC evidence closure,但要求 tests、canary、Gitea、rollback、secret-scan 每一类均有 trusted-provider receipt,并校验 artifact/manifest/package closure/source/tree。相同 closure 幂等,内容变化为冲突。该闭合器不得被调用来伪造 provider pass。
|
||||
|
||||
现有 release service 的正式接口仍应按以下顺序复用:
|
||||
|
||||
```text
|
||||
SilentReleaseActionService.create_release_intent(payload)
|
||||
-> get_release_candidate(candidate_id)
|
||||
-> get_release_plan(candidate_id)
|
||||
-> get_acceptance(candidate_id)
|
||||
-> verify_release_acceptance(candidate_id)
|
||||
-> get_approval_envelope(candidate_id)
|
||||
-> submit_owner_approval(signed_payload)
|
||||
-> start_deployment(candidate_id)
|
||||
```
|
||||
|
||||
`create_release_intent` 只能接收 event/source/version,并从 authenticated source admission resolver 得到版本与 admission ID;不能接收 caller evidence。`submit_owner_approval` 必须经既有 signed release-plan verifier 和 durable approval store。`start_deployment` 会重新加载 durable approval,调用既有 saga/orchestrator;caller 传入的 approval 不作为授权来源。
|
||||
|
||||
## 当前 live 未闭合
|
||||
|
||||
以下条件仍未证实,不能写成 PASS 或 COMPLETE:
|
||||
|
||||
- `authority_source_commit` 仍为 `null`;没有正式 live source publication receipt、authority Git readback、TaskLease/TaskPrincipal/WriterLease/commit closure。
|
||||
- 没有 live C5 exact source-only policy 的 Owner-signed activation、policy digest、issuer、revocation source 和 fresh epoch。不能用本地 policy/request hash、旧 activation binding、simulation epoch 或借用 credential 替代。
|
||||
- 没有 live registered machine caller 的可用 admission route;当前 runner 不能自行成为 authority issuer 或把 `engineering` 风险提升为全局允许值。
|
||||
- 没有真实 trusted builder/provider resolver 的完整绑定,尤其是 authenticated tests、canary、Gitea、rollback、secret-scan receipts;本地 candidate artifact 不是生产 evidence closure。
|
||||
- 没有 pre-authorized Owner approval broker 为精确 candidate/release plan 签名。source-only authorization 不等于 release approval。
|
||||
- 没有完成 live `SilentReleaseActionService` 的 candidate、acceptance、release plan、saga readback,也没有 deployment composition、runtime health、rollback 和 post-tag/finalization readback。
|
||||
- C6 已冻结;C5 自动化支持若进入后续发布,必须重新计算 source/tree/file binding、execution plan 和 signed authority,不能把支持代码自动并入既有 candidate。
|
||||
|
||||
## 最终本地验证
|
||||
|
||||
2026-09-26,在 Linux/Python 3.12.3 的隔离目录中完成 **184 passed** 定向回归(5.53 秒)。范围包含 epoch v1/v2、source policy、runner、真实 release service/Owner signature 集成、既有 admission/bootstrap/recovery、production contract 和 provider evidence producer。没有重跑历史全量业务测试,也没有把旧的 598 passed 计为本次新增代码的结果。
|
||||
|
||||
新增集成覆盖实际 signed builder、signed Owner approval、source substitution 拒绝、无 verifier 拒绝及 durable attestation 篡改拒绝。公开 acceptance 接口包含脱敏字段;`verify_release_acceptance` 在现有 service 内加载原始 durable 数据重新验签,保持公开脱敏规则,向 runner 返回验证摘要。
|
||||
|
||||
最终 `demo-run-4` 从不可变候选 `536a2af0f07962495c88526061ce43fb27994c15` 的 archive 执行 C5 capability N01/N02/N03,经过真实 SSHSIG 验签、真实隔离 Task admission、幂等重放和 scope/revocation 负例,成功写出 `V2R2C5_RC.simulation.json`。该 candidate 保持 `BUILDING`;正式 source OID 为空,provider acceptance 待补、approval 未请求、deployment 未执行。
|
||||
|
||||
可复核证据位于集成工作树相邻的 `../evidence/final-targeted-tests.xml`、`../evidence/demo-run-4/V2R2C5_RC.simulation.json` 和 `../evidence/demo-run-4/SHA256_MANIFEST.json`。这些结果证明隔离工程闭环,不证明 live 发布闭环。
|
||||
|
||||
## 可恢复下一步
|
||||
|
||||
1. 在现有 governed Task → TaskLease → TaskPrincipal → WriterLease → Governed Commit 链上发布 runner/epoch compatibility/exact policy guard 支持,并 read back commit/tree/receipt。
|
||||
2. 由已有 Owner authority 通过控制面激活精确 C5 source-only policy,记录 signed activation、policy digest、issuer、expiry、revocation source;随后请求 fresh epoch。禁止直接写受信任目录或复用旧 epoch。
|
||||
3. 用真实 registered caller 执行 admission;对 envelope、admission receipt、Task 状态和 authority OID 做 readback。任何 unknown effect 先 reconcile。
|
||||
4. 仅在 publication receipt、lease chain、commit receipt 和 authority readback 完整后,调用 `create_release_intent`,再由真实 provider 生成并验证 candidate、acceptance、plan、saga 和 RC evidence。
|
||||
5. 由预授权 Owner approval broker 对精确 release plan 签名,经 `submit_owner_approval` 验证并 durable 存储。缺 broker 时保持阻断,不降低 risk、不 mint client signature。
|
||||
6. 若另有明确生产授权,再调用 `start_deployment`;依赖既有 pre-switch/canary、health、rollback、tag/finalization reconciliation。否则将结果保留在已签名、可重放的 RC/approval-ready evidence,而不触碰生产。
|
||||
|
||||
## 运行时假设
|
||||
|
||||
真实组合依赖 Linux/POSIX `fcntl` locks、`fsync`/atomic rename、受限 symlink/open、Git authoritative bare repo、Gitea/ref readback、systemd service identity/restart、health endpoint、Ed25519 verification,以及 KMS/HSM/secret-manager reference。`execution_enabled=false`/shadow mode 是默认安全状态;生产执行缺少 approval verifier、trusted builder、builder attestation verifier、orchestrator、pre-switch validator 或 trusted policy 时必须 fail closed。
|
||||
@@ -0,0 +1,52 @@
|
||||
# C5 release automation implementation and rollout
|
||||
|
||||
The engineering task implements a repeatable orchestration path and isolated executable proof. It does not treat local artifacts as an activated production capability. V2R2C6 is frozen.
|
||||
|
||||
## Parallel implementation ownership
|
||||
|
||||
1. Epoch compatibility: admission bootstrap and signed-epoch provider; v1 regression, original-byte v2 verification, tamper/schema/revocation tests.
|
||||
2. Exact policy lifecycle: existing structured Owner issuer plus an exact source-publication guard; TTL, current trust, revocation, scope and configured-risk checks.
|
||||
3. Thin runner: generated immutable intake, admission/reconciliation, publication proof gate, existing trusted candidate service and signed approval/deployment entry points.
|
||||
4. Integration: isolated executable demo, targeted contract tests, architecture/gap reports and immutable local commit/archive evidence.
|
||||
|
||||
Each implementation lane uses a separate Git branch/worktree. Integration uses ordinary local commits without force-push, main mutation, TaskDB mutation or production writes.
|
||||
|
||||
## Minimum executable closure
|
||||
|
||||
Run the simulation CLI with a Git repository, committed source/base and a new output directory. It must derive the file list, tree and source archive from Git objects; run the selected checks; issue a fresh test-only Owner policy/epoch using ephemeral keys; verify them with the actual registry verifier and dual-schema provider; submit an envelope through the actual admission service against an isolated Task transport; and write an existing-model RC artifact with all evidence hashes.
|
||||
|
||||
The output must say `SIMULATION`, `official_release=false`, and retain `authority_source_commit=null`. It must not invent live provider receipts or set `READY_FOR_APPROVAL` without the existing acceptance path. Ephemeral private keys are confined to a temporary directory and excluded from exported evidence. A signed test receipt authenticates only its stated isolated environment.
|
||||
|
||||
From this implementation checkout on Linux/POSIX, with Git, OpenSSH, pytest and cryptography available:
|
||||
|
||||
```bash
|
||||
python3 scripts/simulate_c5_unattended_release.py \
|
||||
--repository /path/to/local/candidate/repository \
|
||||
--source 536a2af0f07962495c88526061ce43fb27994c15 \
|
||||
--base e5f8209f57b43fc39e55c4555776e6362ce326eb \
|
||||
--output /path/to/new/isolated/evidence-directory
|
||||
```
|
||||
|
||||
The command creates its own isolated Task store and ephemeral signing domain. It has no live transport or deployment adapter. No manually assembled JSON, existing Owner credential, service restart or production endpoint is needed.
|
||||
|
||||
## Required checks
|
||||
|
||||
- v1/v2 consumer compatibility, original bytes, issuer/key/namespace/digest, unknown schema, invalid fields, expiry and revocation.
|
||||
- Existing medium policy still denies engineering; exact source policy denies any changed branch/base/source/tree/files/caps/execution binding.
|
||||
- Missing verifier and missing/failing revocation lookup deny; admission replay rechecks authorization.
|
||||
- Stable intake identity, bounded retry, no retry for policy rejection, unknown side-effect reconciliation and conflicting receipt rejection.
|
||||
- Missing TaskLease/principal/WriterLease/commit/readback cannot produce a live RC.
|
||||
- Existing signed approval, provider evidence, saga integrity, deployment verification, rollback and post-tag reconciliation contracts.
|
||||
- C5 capability negatives N01/N02/N03 and affected regression tests only; historical full-suite counts are not reused as evidence for new changes.
|
||||
|
||||
## Governed rollout after local verification
|
||||
|
||||
1. Publish the support commit through the existing Task -> TaskLease -> TaskPrincipal -> WriterLease -> Governed Commit chain. Read back its OID/tree/receipt.
|
||||
2. Governed-install the dual-schema consumer and exact source-only policy guard on the existing control-plane service, preserving the old artifact for rollback.
|
||||
3. Activate the exact policy and dedicated machine caller using the existing Owner authority. Record signed activation, policy digest, issuer and revocation source. This is a provisioning step, not an implicit authority granted by the runner.
|
||||
4. Request a fresh epoch and execute C5 admission/publication. Rebind evidence to the authority-readback OID; it may differ from the local candidate.
|
||||
5. Run actual build/test/security/canary/rollback providers and their authenticated resolver. Invoke `create_release_intent` against the authority source; read back candidate, acceptance, plan and saga.
|
||||
6. Have the pre-authorized Owner broker sign the exact release plan, and submit it through `submit_owner_approval`. Source-only authorization is insufficient here.
|
||||
7. Call the existing deployment controller with approved durable state. Canary/pre-switch, health, promotion, registry/baseline finalization and rollback follow the existing saga. Verify runtime identity and final consistency.
|
||||
|
||||
Missing live authority or provider composition is a durable blocker, never a reason to lower risk, bypass G0, mint signatures in the client or call production restart directly.
|
||||
@@ -0,0 +1,60 @@
|
||||
# C5 governed unattended release: architecture decision
|
||||
|
||||
Status: selected for isolated implementation; live activation is not implied.
|
||||
Task: TASK-AIOS-C5-GOVERNED-UNATTENDED-RELEASE-PLATFORM-DESIGN-AND-IMPLEMENTATION-20260926-R1
|
||||
|
||||
## Decision
|
||||
|
||||
Select **B, additive v1/v2 epoch consumption**. Verify the producer's original canonical signed bytes and retain the actual schema, issuer, key, policy digest and revocation semantics. Project common typed fields only after verification. A v2 payload is never relabelled v1 and its signature is never copied onto another payload.
|
||||
|
||||
The producer's `policy_digest` hashes the Owner policy. It is not interchangeable with the default Task Admission policy digest. The existing policy factory propagates this digest; the compatibility change preserves that binding. A v2 `canonical_payload_digest` covers the payload before that field is added, while the signature authenticates the complete original canonical payload.
|
||||
|
||||
Option A would either be the same dual-schema verification hidden behind a misleading v1 label, or require a new signature. Option C would change more producers and consumers without resolving the immediate authority/composition gap. B preserves v1 operation and has the smallest rollback surface.
|
||||
|
||||
## One control plane
|
||||
|
||||
The unattended runner is an orchestration client. It does not become an authority issuer, Task database writer, Git writer, approval verifier, release state machine, or production executor.
|
||||
|
||||
| Responsibility | Existing authority/component |
|
||||
|---|---|
|
||||
| Owner signatures and narrowing | `LocalOwnerAuthority`, registered Owner key and configured Owner policy |
|
||||
| Epoch verification | `SignedEpochDirectoryProvider`, `RegistryOwnerApprovalVerifier` |
|
||||
| Exact source-only policy | Structured Owner-signed policy, verified request guard |
|
||||
| Envelope, grant and Task admission | `TaskAdmissionBootstrapService`, `AgentTaskAdmissionPort` |
|
||||
| Claim, principal and WriterLease | Existing Task/lease/governed writer chain |
|
||||
| Source commit proof | Governed commit receipt plus authority Git readback |
|
||||
| Build, provider evidence and candidate | Trusted builder, provider resolver, `SilentReleaseActionService` |
|
||||
| Approval | Existing signed release-plan approval verifier and durable approval store |
|
||||
| Deployment and recovery | `ReleaseSagaStore`, `ReleaseOrchestrator`, `ReleaseRecoveryWorker` |
|
||||
|
||||
The older linear `release_state_machine.py` is not added as another release state authority. The runner consumes durable state from the existing stores. Recovery of an unknown mutation reconciles that state before another write.
|
||||
|
||||
## Least privilege and unattended authority
|
||||
|
||||
The source-only policy binds repository, source commit/tree, base commit, exact target branch, generation, execution digest, file set, capability set, engineering risk, expiry and fresh identity. It cannot authorize main writes, tags, deployment, baseline mutation or historical Task mutation. The global admission ceiling stays `medium`; an exact verified request may use its separately signed engineering policy. A different request must be denied before envelope creation.
|
||||
|
||||
Recognizing the `engineering` rank in the issuer does not raise any configured ceiling. An existing `medium` Owner policy still rejects engineering issuance. The structured policy issuer must narrow its configured Owner policy; it cannot install or activate a policy for itself.
|
||||
|
||||
Each authorization rechecks signature, current key trust, expiry, revocation and scope. No automatic extension of expired epochs is allowed. A controller requests a fresh epoch under an already activated Owner policy. Source-only authority cannot be exchanged for release approval. Automatic release approval requires a separately delegated Owner broker to sign the exact candidate and release plan; missing or revoked delegation stops the workflow.
|
||||
|
||||
“No interaction” is an execution property after governed provisioning. It is not permission to create a trust root or grant the controller new powers. Current production has not been provisioned for this new composition.
|
||||
|
||||
## Retry and recovery
|
||||
|
||||
Task and event identities derive from immutable input. Retries retain them. Policy rejection is terminal until policy/input changes. A timeout is not proof that a write failed: use the admission receipt/official Task readback, publication receipt and authority OID, or existing release saga/operation journal to reconcile. Unknown or conflicting effects remain blocked. Transport calls require bounded timeouts; a controller deadline alone cannot cancel a remote mutation.
|
||||
|
||||
Build and acceptance evidence are obtained through trusted providers, not caller-supplied pass flags. A local simulation artifact is explicitly marked non-authoritative. Its source OID, archive hash and cryptographic checks can be real without proving live publication, runtime acceptance or deployment.
|
||||
|
||||
## Risks and rollout
|
||||
|
||||
1. Schema acceptance alone does not activate a usable policy. Roll out the consumer and exact policy guard together through governed control-plane publication.
|
||||
2. The existing deployed Owner policy does not authorize the C5 scope, and the current caller has no usable authenticated activation/epoch route. Provision the narrowly scoped service identity and policy through the existing governance plane. Do not use direct root file writes as a substitute.
|
||||
3. Signed source policy and epoch must agree on the Owner policy digest. Mixing epochs, digests or request identities fails closed.
|
||||
4. The local source candidate `536a2af0f07962495c88526061ce43fb27994c15` is preserved. Automation support changes are separate commits. Any decision to include them in the release must regenerate the governed execution plan and exact file/source bindings.
|
||||
5. The existing deployment engine contains canary/pre-switch, runtime verification, rollback and post-tag reconciliation logic. Tests establish adapter contracts; they do not prove current production composition or health.
|
||||
|
||||
## Rollback
|
||||
|
||||
Disable the new runner, revoke its source-only policy/epoch and stop new admissions. Preserve journals, receipts, immutable Tasks and candidate records. Revert the consumer/support module deployment to the prior governed control-plane artifact; v1 remains supported throughout migration. Never rewrite already issued signed payloads or historical Task state.
|
||||
|
||||
Production deployment rollback remains the existing signed plan's rollback operation and release saga. A post-tag/finalization uncertainty requires reconciliation; it must not be hidden by an automatic tag rewrite. C4 stays the production baseline until a separately approved C5 deployment completes runtime verification.
|
||||
@@ -0,0 +1,21 @@
|
||||
# G0 Evidence Coverage V2
|
||||
|
||||
R2 corrects the R1 wording: WP0a–d and WP1 outputs are **discovery evidence**,
|
||||
not complete Generation-G0 acceptance by themselves.
|
||||
|
||||
| WP | R2 output | What it proves | Still required for G0 Exit |
|
||||
|---|---|---|---|
|
||||
| WP0a | PARTIAL_DISCOVERY | authority/runtime/production OID relation | VERSION/current/baseline/README/versions/release metadata reader-writer-caller/live mapping |
|
||||
| WP0b | PARTIAL_DISCOVERY | lexical Git-writer candidates | writer→auth→repo→ref→CAS→receipt→caller graph and live classification |
|
||||
| WP0c | PARTIAL_DISCOVERY | authorization references by term/subdomain hint | owner/broker, TaskPrincipal, ClaimLease/TaskLease, WriterLease, release/recovery, signer chain + revocation/live caller |
|
||||
| WP0d | PARTIAL_DISCOVERY | function-level canonical/digest candidates | producer/consumer, input schema, bytes rule, algorithm and duplicate semantic groups |
|
||||
| WP0e | caller_scan v2 | fail-closed source/runtime caller evidence | five historical known-case acceptance + complete runtime evidence for any PROVEN_ORPHAN |
|
||||
| WP1 | capability discovery / requirements evaluated | actual local capability probes | declared host requirements for each key execution host, missing requirements resolved |
|
||||
| WP2 | conservative decision layer | retry/suppress/reconcile/block decision | audit baseline on real operations; no scheduler authority |
|
||||
|
||||
`g0_exit_eligible=false` in WP0a–d output is intentional. The G0 gate is a
|
||||
separate acceptance aggregation step and must reference the missing evidence
|
||||
listed above.
|
||||
|
||||
WP3–WP10 remain dependency-gated backlog exactly as Platform Baseline V4
|
||||
defines; R2 does not silently redefine the long-term platform goal to U01–U09.
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"schema": "aios.unified-review-gates.v2",
|
||||
"gates": [
|
||||
{
|
||||
"id": "REVIEW_READY",
|
||||
"requires": [
|
||||
"BASE_REBOUND",
|
||||
"CHANGE_ENVELOPE_COMPLETE",
|
||||
"REPO_REGRESSION_PASS",
|
||||
"INDEPENDENT_REVIEW_GO"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "STAGING_ACTIVATION_READY",
|
||||
"requires": [
|
||||
"REVIEW_READY",
|
||||
"GOVERNED_COMMIT_READBACK",
|
||||
"LIMITED_SCOPE_PLAN",
|
||||
"ROLLBACK_READY",
|
||||
"LAB_CONTRACT_PASS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "STAGING_ACCEPTED",
|
||||
"requires": [
|
||||
"STAGING_ACTIVATION_READY",
|
||||
"LIVE_ADAPTER_PASS",
|
||||
"ROOT_EXECUTOR_PASS",
|
||||
"ANNEX_A01_A23_PASS",
|
||||
"FAILURE_RECOVERY_PASS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "PRODUCTION_ELIGIBLE",
|
||||
"requires": [
|
||||
"STAGING_ACCEPTED",
|
||||
"RELEASE_AUTHORIZATION_VALID",
|
||||
"PRODUCTION_ROLLBACK_READY",
|
||||
"NO_GO_CLEAR"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "PRODUCTION_ACCEPTED",
|
||||
"requires": [
|
||||
"PRODUCTION_ELIGIBLE",
|
||||
"PRODUCTION_DEPLOYMENT_READBACK",
|
||||
"BUSINESS_E2E_PASS",
|
||||
"EXTERNAL_PROJECTION_READBACK"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "PROGRAM_COMPLETE",
|
||||
"requires": [
|
||||
"PRODUCTION_ACCEPTED",
|
||||
"EXISTING_ROOT_C01_C06_ACCEPTED",
|
||||
"PROGRAM_U01_U09_ACCEPTED",
|
||||
"FINAL_ROOT_REVIEW_COMPLETE"
|
||||
]
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"No gate may require evidence produced only by its own downstream action.",
|
||||
"Existing Identity/Lineage root acceptance is unchanged; full C01-C06 is consumed only at PROGRAM_COMPLETE."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# AIOS Unified Platform Convergence — Task Contract V2
|
||||
|
||||
## Program goal
|
||||
|
||||
Preserve existing governance and tenant isolation while converging
|
||||
Identity/Lineage, ANNEX-2.3 execution integrity, Platform Generation G0 and the
|
||||
existing release/recovery path into one reviewable and recoverable program.
|
||||
|
||||
## Existing dependency
|
||||
|
||||
Identity/Lineage Mission:
|
||||
- `mission-f3af205b-7dd6-455f-a561-88f0294cc815`
|
||||
- root `task-133ad96e-e0d7-4202-934f-80c75053b11f`
|
||||
|
||||
Its C01–C06 acceptance is immutable.
|
||||
|
||||
## Program acceptance
|
||||
|
||||
| Key | Acceptance |
|
||||
|---|---|
|
||||
| U01 | authority/runtime/production baseline reconciled and candidate bound to current Git authority |
|
||||
| U02 | existing Identity/Lineage mission identity and acceptance contract preserved; pre-production dependencies C01–C05 reconciled without claiming root complete |
|
||||
| U03 | ANNEX-2.3 A01–A23 accepted in real staging/lab as specified |
|
||||
| U04 | Platform Generation G0 Exit independently accepted; partial discovery output alone is insufficient |
|
||||
| U05 | unified governed commit + authority readback |
|
||||
| U06 | staging integrated E2E root→child→proof→ROOT_REVIEW→actual action→re-review |
|
||||
| U07 | production deployment + runtime/business/external readback accepted |
|
||||
| U08 | restart/response-loss/sibling concurrency/rollback/recovery accepted |
|
||||
| U09 | existing Identity/Lineage original C01–C06 fully accepted and final Program ROOT_REVIEW proves no remaining gaps |
|
||||
|
||||
## Gate consumption
|
||||
|
||||
- REVIEW_READY consumes no staging/production evidence.
|
||||
- STAGING_ACTIVATION_READY consumes governed commit/readback and lab evidence,
|
||||
but no evidence that requires staging activation.
|
||||
- STAGING_ACCEPTED consumes live staging evidence.
|
||||
- PRODUCTION_ELIGIBLE consumes staging acceptance + release authorization.
|
||||
- PRODUCTION_ACCEPTED consumes live production evidence.
|
||||
- PROGRAM_COMPLETE consumes U01–U09 including final existing-root acceptance.
|
||||
|
||||
## Autonomous rule
|
||||
|
||||
Any child terminal:
|
||||
`child evidence → ROOT_REVIEW → reload original acceptance → query descendants
|
||||
→ next legal action`.
|
||||
|
||||
A child/local/gate PASS closes only that scope. It never closes the Program by
|
||||
itself.
|
||||
|
||||
## Naming
|
||||
|
||||
New task display names retain:
|
||||
`TASK-<SAFE_USER_ALIAS>-<UTC>-R<ROOT8>-P<PARENT8>-D<DEPTH>-C<SEQ>-<PURPOSE>`.
|
||||
|
||||
Authoritative lineage is persisted separately; existing durable IDs are not
|
||||
renamed/recreated.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Unified Workstream DAG & Acceptance Matrix V2
|
||||
|
||||
## Workstream DAG
|
||||
|
||||
```text
|
||||
Baseline Reconciliation
|
||||
├─ Track A Identity/Lineage existing mission
|
||||
│ ├─ C01 Governed commit/readback
|
||||
│ ├─ C02 Staging activation
|
||||
│ ├─ C03 Root-action executor
|
||||
│ ├─ C04 Legacy attach
|
||||
│ ├─ C05 Live failure/replay/concurrency E2E
|
||||
│ └─ C06 Production convergence
|
||||
├─ Track B ANNEX-2.3 R2
|
||||
│ ├─ B1 Schedulability proof
|
||||
│ ├─ B2 Discovery diagnostics
|
||||
│ ├─ B3 Claim-time read recheck
|
||||
│ ├─ B4 Atomic TaskPlane claim/lease/fencing
|
||||
│ ├─ B5 Execution/heartbeat
|
||||
│ └─ B6 Evidence/readback A01–A23
|
||||
└─ Track C Platform G0
|
||||
├─ WP0a Version/Baseline discovery
|
||||
├─ WP0b Git writer discovery
|
||||
├─ WP0c Authorization discovery
|
||||
├─ WP0d Canonical/Digest discovery
|
||||
├─ WP0e caller_scan
|
||||
├─ WP1 Environment Profile
|
||||
└─ WP2 Retry/Toil Governor
|
||||
|
||||
A + B + C REVIEW_READY
|
||||
↓
|
||||
governed commit/readback
|
||||
↓
|
||||
STAGING_ACTIVATION_READY
|
||||
↓
|
||||
actual staging activation + A01–A23 + executor
|
||||
↓
|
||||
STAGING_ACCEPTED
|
||||
↓
|
||||
PRODUCTION_ELIGIBLE
|
||||
↓
|
||||
actual production deployment/readback
|
||||
↓
|
||||
PRODUCTION_ACCEPTED
|
||||
↓
|
||||
existing root C01–C06 + Program final review
|
||||
↓
|
||||
PROGRAM_COMPLETE
|
||||
```
|
||||
|
||||
No gate depends on evidence generated only by its own downstream action.
|
||||
|
||||
## R2 matrix
|
||||
|
||||
| Gate/Area | R2 code/review status | Deployment evidence still required |
|
||||
|---|---|---|
|
||||
| caller_scan v2 | focused tests required | historical known-case + live runtime proof |
|
||||
| schedulability proof | focused tests required | live adapter linearization/lease/fence |
|
||||
| atomic claim contract | reference contract/tests only | TaskPlane adapter + race/revoke/freeze staging |
|
||||
| Retry Governor v2 | focused tests required | real audit baseline |
|
||||
| WP0a–d | PARTIAL_DISCOVERY by design | complete G0 mapping and independent acceptance |
|
||||
| WP1 | capability/requirement probe | key-host requirement profiles |
|
||||
| canonical reuse | ADR + guard test | producer/consumer compatibility in live rebase |
|
||||
| governed commit | NOT RUN | writer receipt + authority readback |
|
||||
| staging | NOT RUN | A01–A23, root executor, failure recovery |
|
||||
| production | NOT RUN | release + business/runtime/external readback |
|
||||
@@ -0,0 +1,215 @@
|
||||
# AIOS Unified Platform Convergence R1 — 架构变更说明
|
||||
|
||||
## 0. 决策摘要
|
||||
|
||||
本变更将 **Identity/Lineage S3、ANNEX-2.3 R2 Auto-Claim、平台化 Phase-0、
|
||||
Governed Merge/Release** 收敛为一个新的上层 Program,而不是改写已存在
|
||||
Identity/Lineage Mission 的原始验收合同。
|
||||
|
||||
核心原则:
|
||||
|
||||
1. **新 Program,不重写旧 root。** 已有 Mission `mission-f3af205b-7dd6-455f-a561-88f0294cc815` 和 root
|
||||
`task-133ad96e-e0d7-4202-934f-80c75053b11f` 原样保留,C01–C06 的 acceptance 不变。
|
||||
2. **唯一 Git authority 是 `beabd053961047f170e87e1110f2636dbd50f5fb`。** release-control runtime
|
||||
`3c320aeb89d9ba8f0ed516abeb57252682b18983` 和 production `4642b74af62c89a505ae49241e805a5a88ca897f` 是不同平面,不得互相冒充。
|
||||
3. **恢复语义,不恢复旧拓扑。** 从 `3c320ae…` 选择性移植 SystemTruth/Execution
|
||||
Integrity 语义,但不把该后继分支整体推回 main。
|
||||
4. **单一权威、只读聚合。** 新 inventory/caller_scan/Environment Profile/
|
||||
Retry Governor 都是 evidence producer,不成为新持久化权威。
|
||||
5. **先评审,后部署。** 本 R1 是 Review Candidate;禁止在 Review Gate 通过前
|
||||
进行 staging/production activation。
|
||||
|
||||
## 1. 当前权威三平面
|
||||
|
||||
| 平面 | OID | 含义 |
|
||||
|---|---|---|
|
||||
| Git authority / main | `beabd053961047f170e87e1110f2636dbd50f5fb` | 新候选唯一 source base |
|
||||
| release-control runtime | `3c320aeb89d9ba8f0ed516abeb57252682b18983` | 现有控制面运行字节,不是 main |
|
||||
| production baseline | `4642b74af62c89a505ae49241e805a5a88ca897f` | V2R2C2 生产 evidence closure |
|
||||
|
||||
`beabd053961047f170e87e1110f2636dbd50f5fb -> 3c320aeb89d9ba8f0ed516abeb57252682b18983` 仅包含 10 个 SystemTruth/consistency 相关路径;
|
||||
现有 Identity/Lineage 27 文件候选与这 10 个路径无交集。因此 27 文件候选可以
|
||||
在 **重新读取 authority preimage 并通过 apply/check 后** 重新绑定
|
||||
`beabd053961047f170e87e1110f2636dbd50f5fb`,但不能复用旧 `3c320ae…` approval/base 记录。
|
||||
|
||||
## 2. 新 Program 与既有 Mission 的关系
|
||||
|
||||
### 2.1 新上层 Program(尚未在 live TaskPlane 创建)
|
||||
|
||||
`goal_slug = AIOS-UNIFIED-PLATFORM-CONVERGENCE`
|
||||
|
||||
实际 `mission_id/root_task_id` 必须由 live TaskPlane authority 创建并返回,
|
||||
本候选不预造 ID。
|
||||
|
||||
### 2.2 既有 Identity/Lineage Mission(保留)
|
||||
|
||||
- mission: `mission-f3af205b-7dd6-455f-a561-88f0294cc815`
|
||||
- root: `task-133ad96e-e0d7-4202-934f-80c75053b11f`
|
||||
- owner: `local-owner-authority`
|
||||
- alias: `U9CA8400F7`
|
||||
- legacy durable task: `TASK-P0-CHATGPT-BRIDGE-V2-CONTROL-PLANE-CONVERGENCE-INTEGRATE-20260913T184412Z-19b7`
|
||||
- legacy job: `job-7457a322d5f9d6931c7cd99ce3ec6c18`
|
||||
|
||||
该 Mission 作为新 Program 的 Track A 依赖/子目标;其历史 task/job/idempotency
|
||||
身份不可为了新命名而重建。
|
||||
|
||||
## 3. 四条 Track
|
||||
|
||||
### Track A — Identity / Lineage S3
|
||||
|
||||
目标保持:
|
||||
`root → child → terminal proof → ROOT_REVIEW → actual next action → execution → root re-review`
|
||||
|
||||
包含:
|
||||
- C01 governed candidate commit/readback
|
||||
- C02 staging lineage policy activation
|
||||
- C03 root-action executor binding
|
||||
- C04 legacy attach
|
||||
- C05 live failure/replay/concurrency E2E
|
||||
- C06 production convergence
|
||||
|
||||
### Track B — ANNEX-2.3 R2 Auto-Claim / Execution Integrity
|
||||
|
||||
新链:
|
||||
`admission/preflight → SchedulabilityProof → discovery → claim-time authoritative recheck
|
||||
→ lease/fencing/CAS → execution → heartbeat/progress → result/evidence → readback/acceptance`
|
||||
|
||||
R1 已实现 review-candidate 代码:
|
||||
- `task_plane/schedulability.py`
|
||||
- `task_plane/auto_claim.py`
|
||||
- A01 风格多 blocker、scope/capability/heartbeat/auth/capacity/freeze/dependency/lease
|
||||
检查
|
||||
- proof 仅为诊断证据,不是 bearer credential
|
||||
- claim 前完整 authority recheck
|
||||
|
||||
**R1 不直接替换现有 claim 路径**;必须在 staging 评审时通过 adapter 绑定后才可启用。
|
||||
|
||||
### Track C — Platform Phase-0 / Generation G0
|
||||
|
||||
仅包含批准的第一 generation:
|
||||
|
||||
- WP0a Version/Baseline inventory
|
||||
- WP0b Git writer inventory
|
||||
- WP0c Authorization inventory
|
||||
- WP0d Canonical/Digest inventory
|
||||
- WP0e caller_scan
|
||||
- WP1 Environment Capability Profile
|
||||
- WP2 Retry/Toil Governor
|
||||
|
||||
这些组件只读或纯决策:
|
||||
- 不新增 version authority
|
||||
- 不新增 canonical authority
|
||||
- 不写业务状态
|
||||
- `UNKNOWN` 永远不能被 caller_scan 降级为 orphan
|
||||
- Retry Governor 只返回决策,不执行 retry
|
||||
|
||||
禁止提前引入 WP8 MissionRun store / WP9 新 Release Saga 控制面。
|
||||
|
||||
### Track D — Governed Merge / Staging / Release
|
||||
|
||||
顺序:
|
||||
`Review Gate → governed child commit/readback → staging activation → integrated live E2E
|
||||
→ release candidate/acceptance → production convergence`
|
||||
|
||||
部署不是本 Review Candidate 的交付内容。
|
||||
|
||||
## 4. 状态权威边界
|
||||
|
||||
| 维度 | 权威 |
|
||||
|---|---|
|
||||
| Mission/root/lineage | TaskPlane/Mission authority |
|
||||
| claim/heartbeat/task revision | TaskPlane |
|
||||
| Owner/TaskPrincipal authorization | existing auth/Admission authority |
|
||||
| Writer lease / governed write | existing writer/lease authority |
|
||||
| source bytes/ref | Git authority |
|
||||
| deployed runtime | runtime readback |
|
||||
| external projection | external projection readback |
|
||||
| caller liveness evidence | caller_scan evidence only |
|
||||
| retry decision | Retry/Toil Governor decision only |
|
||||
|
||||
新模块不得建立第三份真值数据库。
|
||||
|
||||
## 5. 关键架构优化
|
||||
|
||||
### 5.1 动态 readiness 与不可变身份分离
|
||||
|
||||
Planner 选择身份(mission/root/child/route/authority digest)不可变;
|
||||
`readiness_class` 是动态观察,可在未激活、未终态时通过 revision/CAS 单调向
|
||||
“更可执行”方向推进。active claim/READY/DONE 后冻结。
|
||||
|
||||
### 5.2 Legacy reader 无副作用
|
||||
|
||||
旧 `GET /v1/tasks/{id}` 可能触发全局 lease expiry。R1 改为:
|
||||
1. 通过 `/v1/tasks/{id}/events` 做 Bearer 认证;
|
||||
2. Task row 从 authority SQLite 以 `mode=ro` + `PRAGMA query_only=ON` 精确读取;
|
||||
3. 只允许终态;
|
||||
4. readback ref 同时绑定 events SHA 和 DB revision;
|
||||
5. 不暴露 `lease_token_hash`。
|
||||
|
||||
### 5.3 SchedulabilityProof 是短寿命诊断证据
|
||||
|
||||
Proof 绑定 task/worker/actor、revision/generation、capabilities、scope digest、
|
||||
authorization revision、capacity/dependency/freeze/lease 观察;TTL ≤ 300s。
|
||||
真正 claim 必须重读 authority 并完全一致,proof 本身不能授权。
|
||||
|
||||
### 5.4 Root Action Provider / Executor 分离
|
||||
|
||||
Provider 只把 root review 映射到准备计划;Executor 使用现有 continuation/recovery
|
||||
路径执行。不能让 planner 把“PENDING”当成“已执行”。
|
||||
|
||||
## 6. 数据迁移策略
|
||||
|
||||
- 既有 Mission/root/children:不重建。
|
||||
- legacy `TASK-P0-CHATGPT-BRIDGE-V2-CONTROL-PLANE-CONVERGENCE-INTEGRATE-20260913T184412Z-19b7` / `job-7457a322d5f9d6931c7cd99ce3ec6c18`:仅 attachment,不改 identity。
|
||||
- 新 Platform inventory:无持久业务迁移。
|
||||
- 新 schedulability proof:短寿命诊断记录;若持久化,必须复用现有 TaskPlane
|
||||
evidence/journal,禁止新数据库。
|
||||
- SystemTruth:只读派生,不能成为第三控制面。
|
||||
|
||||
## 7. Review Gate
|
||||
|
||||
### R0 — 架构与代码评审
|
||||
必须同时满足:
|
||||
- 新 Program 与旧 Mission 边界明确
|
||||
- base 重新绑定 `beabd053961047f170e87e1110f2636dbd50f5fb`
|
||||
- 变更路径均有 ChangeEnvelope
|
||||
- 新代码定向测试 PASS
|
||||
- 旧候选继承证据有效
|
||||
- 无新增业务权威/数据库
|
||||
- legacy reader 不再使用可变 GET
|
||||
- planner readiness 动态规则经过测试
|
||||
- R2 proof 不能直接授权 claim
|
||||
|
||||
### R1 — Governed Commit Gate
|
||||
在 R0 GO 后:
|
||||
- live reread authority HEAD
|
||||
- 所有 preimage SHA 重新采集
|
||||
- exact apply/diff
|
||||
- governed writer commit
|
||||
- authority branch/commit readback
|
||||
|
||||
### R2 — Staging Gate
|
||||
- authenticated principal/tenant
|
||||
- identity/lineage/read isolation
|
||||
- auto-claim adapter
|
||||
- root action real executor
|
||||
- restart/response-loss/sibling race
|
||||
- ANNEX A01–A23
|
||||
|
||||
### R3 — Production Gate
|
||||
只有 original root acceptance + Program acceptance 全部证据闭环,
|
||||
并完成 rollback/recovery 验证后才能生产收敛。
|
||||
|
||||
## 8. 回滚
|
||||
|
||||
- Review candidate:直接丢弃候选,无线上副作用。
|
||||
- Governed commit 未发布:通过正式 successor/revert,不 reset/force push。
|
||||
- Staging activation:关闭 feature/policy binding,保留 evidence。
|
||||
- Production:只走既有 release Saga/rollback contract。
|
||||
|
||||
## 9. 明确不做
|
||||
|
||||
- 不把 `3c320aeb89d9ba8f0ed516abeb57252682b18983` 恢复为 main。
|
||||
- 不新建任务数据库、第二 scheduler、第二 release authority。
|
||||
- 不把 caller_scan/Retry Governor/SystemTruth 作为写权威。
|
||||
- 不把局部测试 PASS 当 root/program 完成。
|
||||
@@ -0,0 +1,56 @@
|
||||
# Unified Workstream DAG & Acceptance Matrix V1
|
||||
|
||||
```text
|
||||
U01 Baseline Reconciliation
|
||||
├─ Track A Identity/Lineage existing mission
|
||||
│ ├─ C01 Governed commit/readback
|
||||
│ ├─ C02 Staging activation
|
||||
│ ├─ C03 Root-action executor
|
||||
│ ├─ C04 Legacy attach
|
||||
│ ├─ C05 Live failure/replay/concurrency E2E
|
||||
│ └─ C06 Production convergence
|
||||
├─ Track B ANNEX-2.3 R2
|
||||
│ ├─ B1 Schedulability proof
|
||||
│ ├─ B2 Discovery
|
||||
│ ├─ B3 Claim-time recheck
|
||||
│ ├─ B4 Claim/lease/fencing
|
||||
│ ├─ B5 Execution/heartbeat
|
||||
│ └─ B6 Evidence/readback A01-A23
|
||||
└─ Track C Platform G0
|
||||
├─ WP0a Version/Baseline
|
||||
├─ WP0b Git writers
|
||||
├─ WP0c Authorization
|
||||
├─ WP0d Canonical/Digest
|
||||
├─ WP0e caller_scan
|
||||
├─ WP1 Environment Profile
|
||||
└─ WP2 Retry/Toil Governor
|
||||
|
||||
A + B + C review GO
|
||||
↓
|
||||
U05 Governed unified commit/readback
|
||||
↓
|
||||
U06 Staging integrated E2E
|
||||
↓
|
||||
U07/U08 Production + Recovery
|
||||
```
|
||||
|
||||
## Gate matrix
|
||||
|
||||
| Gate | Required evidence | Status in R1 |
|
||||
|---|---|---|
|
||||
| Baseline authority | main/runtime/prod current readback | PASS |
|
||||
| Identity/Lineage candidate | frozen 27-file evidence + merge review | INHERITED/PENDING REBASE |
|
||||
| Legacy reader | non-mutating authenticated read + terminal-only | LOCAL PASS |
|
||||
| Planner dynamic readiness | CAS monotonic readiness, active/terminal freeze | CODED; repo-native rerun pending |
|
||||
| R2 schedulability | blocker matrix + proof replay + claim recheck | LOCAL PASS |
|
||||
| WP0a–d | read-only inventories | CODED; live scan pending |
|
||||
| WP0e caller_scan | UNKNOWN safety/orphan rules | LOCAL PASS |
|
||||
| WP1 env profile | host capability evidence | LOCAL PASS |
|
||||
| WP2 retry governor | reconcile/no-op/block/execute | LOCAL PASS |
|
||||
| Governed commit | writer receipt + branch readback | NOT RUN |
|
||||
| Staging E2E | actual executor/readback | NOT RUN |
|
||||
| Production | release acceptance/readback | NOT RUN |
|
||||
|
||||
## Deployment ordering
|
||||
|
||||
No deployment starts until all Review Gate rows required for R0 are PASS/GO.
|
||||
@@ -12,6 +12,11 @@ components:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: opaque-token
|
||||
taskPrincipalAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: task-principal-jwt
|
||||
description: Server-side TaskPrincipal resolved by the Bridge from the authoritative admitted+claimed Task; caller-supplied tokens are never accepted.
|
||||
schemas:
|
||||
PublicationState:
|
||||
type: string
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Review Gate V1
|
||||
|
||||
## Review disposition
|
||||
|
||||
This package is a **review candidate**, not a deployable release.
|
||||
|
||||
### Code review focus
|
||||
- authority base = `beabd053961047f170e87e1110f2636dbd50f5fb`
|
||||
- no direct DB mutation outside existing repository/service contracts
|
||||
- no new control-plane database
|
||||
- proof != authorization
|
||||
- caller_scan cannot promote UNKNOWN to orphan
|
||||
- retry governor cannot perform business effects
|
||||
- legacy read path is non-mutating
|
||||
- planner readiness is observation, not immutable identity
|
||||
- existing mission/task/job IDs preserved
|
||||
|
||||
### Required test classes before GO
|
||||
1. candidate syntax
|
||||
2. new module focused unit tests
|
||||
3. inherited Identity/Lineage regression suite
|
||||
4. legacy core/runtime tests
|
||||
5. ANNEX A01–A23 staging tests
|
||||
6. root-action executor restart/response-loss tests
|
||||
7. tenant isolation
|
||||
8. governed Git apply/readback
|
||||
|
||||
### Current R1 decision rule
|
||||
|
||||
`ARCH_REVIEW_GO` may be issued if architecture/code review finds no blocker.
|
||||
`DEPLOY_GO` is explicitly unavailable until governed commit + staging E2E pass.
|
||||
@@ -0,0 +1,131 @@
|
||||
# TaskPlane task identity and lineage
|
||||
|
||||
TaskPlane can bind admitted tasks to an authenticated owner principal and mission, assign stable root/parent lineage, and persist a root review after a child reaches a proof-bearing terminal state. The integration is opt-in through `TaskPlaneService(..., lineage_policy=...)`; existing services without a lineage policy retain their existing behavior.
|
||||
|
||||
## Authority boundary
|
||||
|
||||
`PrincipalLineageBinding` is service configuration. It maps the identity returned by the HTTP authenticator to `owner_principal_id`, `mission_id`, a safe display alias, and a registered root-acceptance policy reference. HTTP admission overwrites `issuer` with that authenticated identity. Direct in-process callers must pass an identity already verified by their boundary through `actor_identity`.
|
||||
|
||||
Client task metadata is never an identity or terminal-proof source. `aios_task_lineage`, the derived display name, and the lineage request digest are server-owned and rejected on new lineage admission. Similar-looking client metadata such as `owner_principal_id`, `mission_id`, or `terminal_evidence_proven` remains ordinary opaque metadata and cannot override the dedicated TaskPlane authority columns or the validated terminal-proof tables.
|
||||
|
||||
Root and child creation use the existing TaskPlane SQLite connection and transaction fence. The store adds authority columns and lineage sequence, terminal proof, root review, and root action tables to that database. It does not create a second TaskPlane database. The original task ID, claim ID, idempotency key, budget transaction, event journal, and revision CAS remain authoritative.
|
||||
|
||||
## Admission and delegation
|
||||
|
||||
A root admission requires:
|
||||
|
||||
- an authenticated identity with both TaskPlane capability and a `PrincipalLineageBinding`;
|
||||
- a registered acceptance evaluator for the configured reference;
|
||||
- no client-supplied reserved lineage metadata.
|
||||
|
||||
The request digest is computed before derived metadata is added. An identical idempotency replay returns the original task; a changed request, principal, mission, or parent conflicts. The persisted TaskPlane `created_at` is the same timestamp serialized into lineage metadata.
|
||||
|
||||
Task detail and list responses expose the server-bound name as `display_name`, including list responses that omit the full plan. Durable task and job identifiers remain unchanged.
|
||||
|
||||
Delegation currently creates only `CHILD` relations. It preserves the root owner, mission, acceptance reference, budget constraints, capability/risk monotonicity, and existing delegation transaction. The caller must resolve to the same server-owned root authority. Concurrent sibling admission uses the same SQLite write transaction to reserve unique sibling sequences.
|
||||
|
||||
`SUCCESSOR` and `RECOVERY` are intentionally not admitted by this binding because the current TaskPlane service does not expose a formal continuation/supersession authority contract. Adding those relations requires a separate source-of-authority design; callers must not encode them in metadata.
|
||||
|
||||
## Proven terminal review
|
||||
|
||||
The proof-bearing result path first applies the existing claim identity, lease, operation digest, evidence, result digest, event-chain, and evidence-chain checks. In the same transaction, a final child result records a terminal proof and triggers one `ROOT_REVIEW`. The review CAS advances the root revision and persists the exact trigger child and terminal revision.
|
||||
|
||||
The resulting next legal action is stored idempotently with status `PENDING`. `PENDING` means a durable action request exists. It does not mean a planner completed the root, created another child, recovered work, or crossed an owner boundary.
|
||||
|
||||
The optional `TaskPlaneService.consume_one_root_action()` consumes one persisted action through a root claim and a durable effect ledger. Mission configuration must provide an authoritative plan builder and acceptance verifier, and the service must receive a real `root_action_executor`. Missing execution wiring returns `BLOCKED`; the loopback Mission bootstrap adapter alone does not execute root actions. Plans survive process restart, and an unknown effect must be reconciled against its existing authority Job before execution can proceed. Worker metadata cannot supply that Job identity or certify a completed effect.
|
||||
|
||||
`PublicBridgeRootActionExecutor` is an adapter for authenticated public Bridge operations, not a deployed worker. Runtime integration must supply its trusted authentication and transport, then prove the actual Job result and repository readback. After an execution receipt, the consumer rechecks the stored original root acceptance and descendants. Root completion and child admission share the store's transaction boundary; a child result alone cannot close the root.
|
||||
|
||||
Direct worker completion of a lineage root is rejected. Root completion is legal only when the registered evaluator returns satisfied conditions backed by durable evidence references and the root review selects `ROOT_COMPLETE`. This integration persists that legal decision and request; it does not itself change the root task to `COMPLETED`.
|
||||
|
||||
After restart, `resume_terminal_review(child_task_id=...)` reads the durable terminal proof. If the trigger already has a review, it validates and returns that exact review and action receipt without running the evaluator again. This freezes the decision against evaluator drift and prevents a lost response from producing a second review. Tests close and reopen the SQLite database before treating this state as durable; visibility through the writing connection inside its transaction is only transaction-local.
|
||||
|
||||
The legacy `/fail` and `/cancel` paths do not construct a validated `ResultEnvelope` and therefore do not create this terminal proof or claim root-review completion. Use the proof-bearing result endpoint when terminal lineage review is required.
|
||||
|
||||
## Minimal service configuration
|
||||
|
||||
```python
|
||||
from app.aios_task_lineage import AcceptanceCondition, RootAcceptanceSnapshot
|
||||
from app.task_plane.api import TaskPlaneService
|
||||
from app.task_plane.lineage_store import PrincipalLineageBinding, TaskLineagePolicy
|
||||
|
||||
|
||||
def evaluate_root(root, descendants):
|
||||
refs = tuple(
|
||||
ref
|
||||
for child in descendants
|
||||
if child.terminal_evidence_proven
|
||||
for ref in child.evidence_refs
|
||||
)
|
||||
return RootAcceptanceSnapshot(
|
||||
(
|
||||
AcceptanceCondition(
|
||||
key="original-root-acceptance-not-yet-evaluated",
|
||||
# Replace with the original mission's authoritative acceptance
|
||||
# checks. A completed child alone cannot prove root completion.
|
||||
satisfied=False,
|
||||
evidence_refs=refs,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
lineage_policy = TaskLineagePolicy(
|
||||
principal_bindings=(
|
||||
PrincipalLineageBinding(
|
||||
authenticated_identity="spiffe://aios/owner/example",
|
||||
owner_principal_id="principal-example",
|
||||
mission_id="mission-example",
|
||||
preferred_owner_alias="OWNER",
|
||||
root_acceptance_ref="policy://root/example-v1",
|
||||
),
|
||||
),
|
||||
acceptance_evaluators={"policy://root/example-v1": evaluate_root},
|
||||
)
|
||||
service = TaskPlaneService(store, policy=policy, lineage_policy=lineage_policy)
|
||||
```
|
||||
|
||||
An evaluator that is missing, returns no conditions, or marks a condition satisfied without evidence fails closed. It cannot be supplied by task metadata.
|
||||
|
||||
Existing tasks receive empty authority-column defaults during additive schema
|
||||
initialization. This does not infer ownership from their metadata or attach old
|
||||
jobs to a new root. Migrating an actual legacy task requires an authoritative
|
||||
owner/mission binding; this change does not fabricate one or alter a Job DB.
|
||||
|
||||
## Canonical Mission issuance
|
||||
|
||||
When discovery establishes that no root exists, the optional `MissionAuthority`
|
||||
can allocate the first root. `POST /missions` accepts only `goal_slug` and
|
||||
`original_acceptance`, with an `Idempotency-Key`. The authenticated principal
|
||||
must have a server-configured `MissionPrincipalBinding`. Mission/root IDs,
|
||||
owner binding, safe alias, UTC creation time and acceptance reference are
|
||||
allocated and persisted by the authority in the existing TaskPlane database,
|
||||
in the same transaction as root creation. They are never request identity
|
||||
fields. An exact retry returns the original identity; a changed contract with
|
||||
the same principal/idempotency key conflicts. `GET /missions/{mission_id}`
|
||||
requires the same owner binding and returns the persisted acceptance and root.
|
||||
|
||||
Acceptance JSON is data. A service-owned verifier must independently establish
|
||||
its conditions; absence of a registered verifier keeps acceptance unmet.
|
||||
Creating the root or attaching an arbitrary evidence string cannot satisfy it.
|
||||
The Mission authority and ordinary worker execution are separate: enabling
|
||||
Mission admission does not grant worker claim or result permissions.
|
||||
|
||||
`app.aios_task_mission_authority_server` is a narrow loopback staging adapter
|
||||
for environments with the existing registered Owner signature verifier. It
|
||||
accepts `POST /v1/signed-request` containing exactly `request`, `signature` and
|
||||
`key_id`. The request schema is `aios.signed-mission-request.v1`, audience is
|
||||
`aios.task-mission-authority.staging.v1`, and the signed fields bind method,
|
||||
path, body, idempotency key, issued/expiry timestamps and nonce. The maximum
|
||||
validity is 300 seconds. The existing `aios-owner-approval-v1` verifier checks
|
||||
the signature, and its registered signer identity is the service actor.
|
||||
Neither a bearer belonging to Shadow nor an unsigned owner name establishes
|
||||
that identity. The adapter does not load private keys or sign requests.
|
||||
|
||||
Allowed signed operations are Mission create/read and
|
||||
`POST /tasks/{authority-returned-parent}/delegate`. Mission delegation issues
|
||||
child IDs on the server and uses a durable, principal-scoped request mapping
|
||||
for retries. The adapter exposes no Git writer, worker claim, terminal result,
|
||||
legacy recovery, production deployment or arbitrary execution route. Deployment
|
||||
of this bootstrap adapter is staging evidence, not a governed commit receipt
|
||||
or proof that `PENDING` root actions execute.
|
||||
@@ -0,0 +1 @@
|
||||
{"baseline_mutation":false,"bootstrap_recovery_mode":false,"git_ls_tree_sha256":"58689cd365bec5795c19c2e1e03fc8ae774f768ed03a42d501b4430c95f71a8b","production_mutation":false,"schema":"aios.governed-runtime-package.v1","source_commit":"2852c2a40394195192c6c02a215ba31764e2cdda","source_file_count":965,"tag_mutation":false,"tree_oid":"ccd67c2357e7a4cd74e06be61ab81bba820d7671"}
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
import sqlite3, sys
|
||||
|
||||
dbs = [
|
||||
("scoped-leases", "/var/lib/aios-gitops/bridge-v2-staging/scoped-leases.sqlite3"),
|
||||
("task-admission", "/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3"),
|
||||
]
|
||||
for label, db in dbs:
|
||||
print(f"=== {label}: {db} ===")
|
||||
try:
|
||||
c = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
tabs = [r[0] for r in c.execute("select name from sqlite_master where type='table'")]
|
||||
print("tables:", tabs)
|
||||
for t in tabs:
|
||||
try:
|
||||
n = c.execute(f"select count(*) from \"{t}\"").fetchone()[0]
|
||||
print(f" {t}: rows={n}")
|
||||
except Exception as e:
|
||||
print(f" {t}: count-err {e}")
|
||||
c.close()
|
||||
except Exception as e:
|
||||
print("ERR", e)
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
import sqlite3
|
||||
|
||||
# scoped leases detail
|
||||
db = "/var/lib/aios-gitops/bridge-v2-staging/scoped-leases.sqlite3"
|
||||
c = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
cols = [r[1] for r in c.execute("PRAGMA table_info(scoped_leases)")]
|
||||
print("scoped_leases cols:", cols)
|
||||
rows = c.execute("select * from scoped_leases").fetchall()
|
||||
print("scoped_leases rows:", len(rows))
|
||||
for r in rows[:12]:
|
||||
print(" ", dict(zip(cols, r)))
|
||||
c.close()
|
||||
|
||||
# task-admission envelopes status
|
||||
db2 = "/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3"
|
||||
c2 = sqlite3.connect(f"file:{db2}?mode=ro", uri=True)
|
||||
for t in ["task_admission_envelopes","task_admission_grants","task_admission_receipts"]:
|
||||
cols2 = [r[1] for r in c2.execute(f"PRAGMA table_info({t})")]
|
||||
print(f"\n=== {t} cols: {cols2}")
|
||||
rows2 = c2.execute(f"select * from {t}").fetchall()
|
||||
for r in rows2[:6]:
|
||||
print(" ", dict(zip(cols2, r)))
|
||||
c2.close()
|
||||
@@ -24,12 +24,14 @@ if run_gitea admin user list 2>/dev/null | awk '{print $2}' | grep -Fxq "$OWNER_
|
||||
else
|
||||
run_gitea admin user create --username "$OWNER_USERNAME" --password "$OWNER_PASSWORD" --email "$OWNER_EMAIL" --admin --must-change-password=false >/dev/null
|
||||
fi
|
||||
python3 - <<PY
|
||||
import sqlite3, time
|
||||
con=sqlite3.connect('$DB')
|
||||
cur=con.cursor()
|
||||
cur.execute("update user set is_admin=1,is_active=1,prohibit_login=0,must_change_password=0,updated_unix=? where lower_name=lower(?)", (int(time.time()), '$OWNER_USERNAME'))
|
||||
cur.execute("update user set prohibit_login=1,is_admin=0,passwd='',passwd_hash_algo='disabled',must_change_password=1,updated_unix=? where lower_name in ('aios','admin','gitea') and lower_name<>lower(?)", (int(time.time()), '$OWNER_USERNAME'))
|
||||
python3 - "$DB" "$OWNER_USERNAME" <<'PY'
|
||||
import sqlite3, sys, time
|
||||
db = sys.argv[1]
|
||||
username = sys.argv[2]
|
||||
con = sqlite3.connect(db)
|
||||
cur = con.cursor()
|
||||
cur.execute("update user set is_admin=1,is_active=1,prohibit_login=0,must_change_password=0,updated_unix=? where lower_name=lower(?)", (int(time.time()), username))
|
||||
cur.execute("update user set prohibit_login=1,is_admin=0,passwd='',passwd_hash_algo='disabled',must_change_password=1,updated_unix=? where lower_name in ('aios','admin','gitea') and lower_name<>lower(?)", (int(time.time()), username))
|
||||
con.commit()
|
||||
PY
|
||||
printf 'GITEA_OWNER_SYNC_OK user=%s default_local_login_disabled=1\n' "$OWNER_USERNAME"
|
||||
|
||||
@@ -219,6 +219,81 @@
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<!-- SEC-P1 WEB CONTROL SURFACE hardening: CSRF token propagation + server-authoritative input pre-validation.
|
||||
Runs before app.js so every state-change fetch issued by the control plane carries the CSRF token,
|
||||
and invalid branch/worktree identifiers are blocked client-side (server remains authoritative). -->
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Double-submit cookie pattern: server sets a signed `aios_csrf` cookie; the client echoes its
|
||||
// value in X-CSRF-Token on every state-changing (non-safe) request. A server-issued
|
||||
// <meta name="csrf-token"> takes precedence when present.
|
||||
function csrfToken() {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (meta && meta.content) return meta.content;
|
||||
var m = document.cookie.match(/(?:^|;\\s*)aios_csrf=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
|
||||
// Server-authoritative identifier rules for git refs / worktrees. Kept in sync with the backend
|
||||
// validator so the client gives fast feedback, but the REAL enforcement happens server-side.
|
||||
var ID_CHARS = /^[A-Za-z0-9._/-]+$/;
|
||||
var ID_FORBIDDEN = ['..', '//', '@{', '.lock', ' ', '\\t', ';', '&', '|', '$', '(', ')', '<', '>', '"', "'", '`', '\\\\'];
|
||||
|
||||
function validateBranch(name) {
|
||||
if (typeof name !== 'string' || !name.trim()) return 'branch name is required';
|
||||
if (name.trim().length > 255) return 'branch name too long';
|
||||
if (name !== name.trim()) return 'branch name must not have leading/trailing whitespace';
|
||||
for (var i = 0; i < ID_FORBIDDEN.length; i++) {
|
||||
if (name.indexOf(ID_FORBIDDEN[i]) !== -1) return 'branch name contains forbidden characters: ' + ID_FORBIDDEN[i];
|
||||
}
|
||||
if (name.indexOf('..') !== -1) return 'branch name must not contain ".."';
|
||||
if (name.indexOf('@{') !== -1) return 'branch name must not contain "@{"';
|
||||
if (!ID_CHARS.test(name)) return 'branch name contains invalid characters';
|
||||
if (name.indexOf('/') === 0 || name[name.length - 1] === '/') return 'branch name must not start/end with "/"';
|
||||
return null;
|
||||
}
|
||||
function validateWorktree(name) {
|
||||
if (typeof name !== 'string' || !name.trim()) return 'worktree id is required';
|
||||
var e = validateBranch(name);
|
||||
return e ? 'worktree id invalid: ' + e : null;
|
||||
}
|
||||
|
||||
function stateHeaders() {
|
||||
return { 'X-CSRF-Token': csrfToken() };
|
||||
}
|
||||
|
||||
// Wrap fetch: inject CSRF header on any non-safe method (state change).
|
||||
var nativeFetch = window.fetch;
|
||||
window.fetch = function (url, opts) {
|
||||
opts = opts || {};
|
||||
var method = String(opts.method || 'GET').toUpperCase();
|
||||
var safe = (method === 'GET' || method === 'HEAD' || method === 'OPTIONS');
|
||||
if (!safe) {
|
||||
opts.headers = Object.assign({}, opts.headers);
|
||||
opts.headers['X-CSRF-Token'] = csrfToken();
|
||||
}
|
||||
return nativeFetch.call(this, url, opts);
|
||||
};
|
||||
|
||||
// Client-side pre-validation for state-change forms (server re-validates authoritatively).
|
||||
document.addEventListener('submit', function (e) {
|
||||
var form = e.target;
|
||||
if (!form || (form.id !== 'branch-form' && form.id !== 'plan-form')) return;
|
||||
var fd = new FormData(form);
|
||||
var branch = String(fd.get('branch') || '').trim();
|
||||
var err = validateBranch(branch);
|
||||
if (err) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.alert('Blocked: ' + err);
|
||||
}
|
||||
}, true);
|
||||
|
||||
window.__gitopsSurface = { csrfToken: csrfToken, validateBranch: validateBranch, validateWorktree: validateWorktree, stateHeaders: stateHeaders };
|
||||
})();
|
||||
</script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys, importlib.util
|
||||
|
||||
# Load the installed migration planner runtime module
|
||||
app = "/opt/aios/release-migration/current/app"
|
||||
sys.path.insert(0, app)
|
||||
|
||||
import release_migration_plan as rm
|
||||
|
||||
VERSION = "V2R2C2-baseline11-principal-recovery"
|
||||
CANDIDATE = "rc-v2r2c2-baseline11-principal-recovery-2852c2a40394"
|
||||
SOURCE = "2852c2a40394195192c6c02a215ba31764e2cdda"
|
||||
|
||||
resolver = rm.FileMigrationPlanResolver(
|
||||
"/var/lib/aios-release-migration",
|
||||
profile_root="/etc/aios-gitops/release-migration-profiles",
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
|
||||
try:
|
||||
plan = resolver.resolve(VERSION, CANDIDATE, SOURCE)
|
||||
if plan is None:
|
||||
print("RESOLVE_RESULT = NONE (not required?)")
|
||||
sys.exit(3)
|
||||
print("RESOLVE_RESULT = MIGRATION_PLAN_READY")
|
||||
d = plan.to_dict()
|
||||
print("migration_id =", d.get("migration_id"))
|
||||
print("migration_plan_sha =", d.get("sha256"))
|
||||
print("source_commit =", d.get("source_commit"))
|
||||
print("tree_oid =", d.get("tree_oid"))
|
||||
# confirm the migration sub-contract binds snapshot
|
||||
for sc in d.get("state_contracts", []):
|
||||
print("state_contract snapshot_name =", sc.get("snapshot_name"))
|
||||
print("state_contract snapshot_sha =", sc.get("snapshot_sha256"))
|
||||
print("DURABLE_PREREQUISITE_STATE = SATISFIED")
|
||||
sys.exit(0)
|
||||
except rm.MigrationPlanRequired as e:
|
||||
print("RESOLVE_RESULT =", e)
|
||||
print("DURABLE_PREREQUISITE_STATE = NOT_SATISFIED")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print("RESOLVE_ERROR =", type(e).__name__, e)
|
||||
sys.exit(2)
|
||||
@@ -0,0 +1,73 @@
|
||||
--- a/active-runtime/app/aios_trusted_candidate_builder.py
|
||||
+++ b/active-runtime/app/aios_trusted_candidate_builder.py
|
||||
@@ -367,15 +367,15 @@
|
||||
raise BuilderError(code) from exc
|
||||
if not stat.S_ISDIR(metadata.st_mode) or path.is_symlink():
|
||||
raise BuilderError(code)
|
||||
- if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
- raise BuilderError("COLLECTOR_EVIDENCE_ROOT_MODE_DENIED")
|
||||
- if (
|
||||
- path == self._evidence_root
|
||||
- and os.name != "nt"
|
||||
- and self._expected_owner_uid is not None
|
||||
- and metadata.st_uid != self._expected_owner_uid
|
||||
- ):
|
||||
- raise BuilderError("COLLECTOR_EVIDENCE_ROOT_OWNER_DENIED")
|
||||
+ if path == self._evidence_root:
|
||||
+ if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
+ raise BuilderError("COLLECTOR_EVIDENCE_ROOT_MODE_DENIED")
|
||||
+ if (
|
||||
+ os.name != "nt"
|
||||
+ and self._expected_owner_uid is not None
|
||||
+ and metadata.st_uid != self._expected_owner_uid
|
||||
+ ):
|
||||
+ raise BuilderError("COLLECTOR_EVIDENCE_ROOT_OWNER_DENIED")
|
||||
|
||||
def _digest(self, path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
--- a/tests/test_trusted_candidate_builder.py
|
||||
+++ b/tests/test_trusted_candidate_builder.py
|
||||
@@ -323,9 +323,11 @@
|
||||
policy = load_builder_policy(POLICY_PATH)
|
||||
config = TrustedCandidateBuilderConfig.from_policy(policy)
|
||||
evidence_root = tmp_path / "evidence"
|
||||
- evidence_root.mkdir(mode=0o700)
|
||||
+ evidence_root.mkdir()
|
||||
+ evidence_root.chmod(0o700)
|
||||
authority_git = tmp_path / "repo.git"
|
||||
- authority_git.mkdir(mode=0o700)
|
||||
+ authority_git.mkdir()
|
||||
+ authority_git.chmod(0o2775)
|
||||
|
||||
source = ServerOwnedEvidenceSource(
|
||||
config,
|
||||
@@ -356,6 +358,29 @@
|
||||
)
|
||||
with pytest.raises(BuilderError, match="COLLECTOR_EVIDENCE_UNAVAILABLE"):
|
||||
server.handle_request(build, connection=object())
|
||||
+
|
||||
+
|
||||
+def test_readiness_keeps_evidence_root_permissions_strict_with_group_shared_authority(tmp_path) -> None:
|
||||
+ config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(POLICY_PATH))
|
||||
+ evidence_root = tmp_path / "evidence"
|
||||
+ evidence_root.mkdir()
|
||||
+ evidence_root.chmod(0o720)
|
||||
+ authority_git = tmp_path / "repo.git"
|
||||
+ authority_git.mkdir()
|
||||
+ authority_git.chmod(0o2775)
|
||||
+
|
||||
+ source = ServerOwnedEvidenceSource(
|
||||
+ config,
|
||||
+ authority_git,
|
||||
+ evidence_root,
|
||||
+ expected_owner_uid=None,
|
||||
+ git_runner=lambda *_args, **_kwargs: SimpleNamespace(
|
||||
+ stdout="a" * 40 + "\n", returncode=0
|
||||
+ ),
|
||||
+ )
|
||||
+
|
||||
+ with pytest.raises(BuilderError, match="COLLECTOR_EVIDENCE_ROOT_MODE_DENIED"):
|
||||
+ source.assert_ready()
|
||||
|
||||
|
||||
def test_unix_client_applies_bounded_timeout_and_fails_closed(monkeypatch) -> None:
|
||||
@@ -1,9 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a deterministic plan-bound production payload from one clean source tree."""
|
||||
from __future__ import annotations
|
||||
import argparse, hashlib, json, shutil, tarfile, tempfile
|
||||
import argparse, hashlib, json, re, shutil, tarfile, tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from release_version import validate_release_version
|
||||
|
||||
# Adapter service must be a well-formed systemd unit base name (no path, no
|
||||
# leading dash) so the produced payload binds to an explicit, governed adapter
|
||||
# without pinning this producer to a single V2R2C2-era service/port.
|
||||
_SERVICE_UNIT_NAME_RE = re.compile(r"^[A-Za-z0-9_.:@-]{1,255}$")
|
||||
|
||||
SOURCE_FILES = (
|
||||
"active-runtime/app/deployment_state.py",
|
||||
"active-runtime/app/v2r1c6_privacy_tools.py",
|
||||
@@ -34,8 +41,22 @@ def build(source: Path, output: Path, *, version: str, source_oid: str, tree_oid
|
||||
if not source.is_dir() or source.is_symlink(): raise SystemExit("source_directory_invalid")
|
||||
require_oid(source_oid, "source_oid"); require_oid(tree_oid, "tree_oid")
|
||||
require_oid(rollback_target_oid, "rollback_target_oid"); require_sha(rollback_artifact_sha256, "rollback_artifact_sha256")
|
||||
if version != "V2R2C2" or rollback_version == version: raise SystemExit("release_version_binding_invalid")
|
||||
if service != "aios-v2r2c2-production.service" or port != 18928: raise SystemExit("adapter_policy_binding_invalid")
|
||||
# Successor-aware binding: version is validated against the canonical
|
||||
# release-version contract (VnRnCn[-suffix], shared with the evidence
|
||||
# producer). Rollback must never target the same version as the payload
|
||||
# (fail-closed safety invariant preserved).
|
||||
try:
|
||||
validate_release_version(version)
|
||||
validate_release_version(rollback_version)
|
||||
except ValueError:
|
||||
raise SystemExit("release_version_binding_invalid")
|
||||
if rollback_version == version: raise SystemExit("release_version_binding_invalid")
|
||||
# Adapter binding: service must be a well-formed unit name and port within
|
||||
# the valid range. No single V2R2C2-era service/port is pinned.
|
||||
if not (isinstance(service, str) and _SERVICE_UNIT_NAME_RE.fullmatch(service)):
|
||||
raise SystemExit("adapter_policy_binding_invalid")
|
||||
if not isinstance(port, int) or not (1 <= port <= 65535):
|
||||
raise SystemExit("adapter_policy_binding_invalid")
|
||||
if output.exists(): raise SystemExit("output_exists")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="trusted-release-build-") as td:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Build an additive overlay from the captured live Bridge; never install it."""
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
BASE_SHA256 = "38fc3e54e920ac3f6c992a651b7ef1ec0d2bb994848c0cb9e113bea3383403ca"
|
||||
|
||||
START = " if task_admission_handlers is None:\n"
|
||||
END = " from recovery_bridge import install_recovery\n"
|
||||
REPLACEMENT = ''' if task_admission_handlers is None:
|
||||
source_activation = os.environ.get("AIOS_SOURCE_PUBLICATION_ACTIVATION_FILE", "").strip()
|
||||
try:
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import build_task_admission_handlers_from_environment
|
||||
task_admission_handlers = build_task_admission_handlers_from_environment(
|
||||
post_admission=runtime.project_admitted_task)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import build_task_admission_handlers_from_activation_file
|
||||
task_admission_handlers = build_task_admission_handlers_from_activation_file(
|
||||
Path(activation_raw), post_admission=runtime.project_admitted_task)
|
||||
except TaskAdmissionError as exc:
|
||||
if not source_activation or str(exc) != "TASK_ADMISSION_EPOCH_EXPIRED":
|
||||
raise
|
||||
from source_publication_runtime import expired_admission_runtime
|
||||
task_admission_handlers = expired_admission_runtime().handlers
|
||||
if source_activation:
|
||||
from source_publication_runtime import (
|
||||
AdmissionRuntimeRouter, ExistingPrincipalPolicyFence,
|
||||
build_source_runtime_from_activation_file,
|
||||
)
|
||||
from aios_task_admission_live_adapters import TaskAdmissionRuntimeBundle
|
||||
if task_admission_handlers is None:
|
||||
raise RuntimeError("SOURCE_ACTIVATION_ORDINARY_RUNTIME_REQUIRED")
|
||||
source_runtime = build_source_runtime_from_activation_file(
|
||||
Path(source_activation), post_admission=runtime.project_admitted_task,
|
||||
ordinary_activation_file=Path(activation_raw))
|
||||
ordinary = TaskAdmissionRuntimeBundle(task_admission_handlers, verifier)
|
||||
task_admission_handlers = AdmissionRuntimeRouter(ordinary, source_runtime).handlers()
|
||||
BridgeHandler.principal_verifier = ExistingPrincipalPolicyFence(verifier, source_runtime)
|
||||
'''
|
||||
|
||||
|
||||
def build(base: bytes) -> bytes:
|
||||
if hashlib.sha256(base).hexdigest() != BASE_SHA256:
|
||||
raise ValueError("LIVE_BRIDGE_BASE_HASH_MISMATCH")
|
||||
source = base.decode("utf-8")
|
||||
if source.count(START) != 1 or source.count(END) != 1:
|
||||
raise ValueError("LIVE_BRIDGE_ANCHOR_MISMATCH")
|
||||
start, end = source.index(START), source.index(END)
|
||||
result = source[:start] + REPLACEMENT + source[end:]
|
||||
runtime_line = ' if parsed.path == "/v2/runtime": self._json(self.adapter.getRuntimeStatus()); return\n'
|
||||
if result.count(runtime_line) != 1:
|
||||
raise ValueError("LIVE_RUNTIME_ROUTE_ANCHOR_MISMATCH")
|
||||
result = result.replace(runtime_line, ''' if parsed.path == "/v2/runtime":
|
||||
import hashlib as _source_hashlib
|
||||
source_path = Path(__file__).resolve(strict=True)
|
||||
self._json({**self.adapter.getRuntimeStatus(),
|
||||
"source_module_path": str(source_path),
|
||||
"source_module_sha256": _source_hashlib.sha256(source_path.read_bytes()).hexdigest()})
|
||||
return
|
||||
''', 1)
|
||||
compile(result, "aios_chatgpt_bridge_v2_server.py", "exec")
|
||||
return result.encode("utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
output = build(args.base.read_bytes())
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.output.open("xb") as handle:
|
||||
handle.write(output)
|
||||
print(hashlib.sha256(output).hexdigest())
|
||||
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FILES = ("aios_task_admission_bootstrap.py", "aios_task_admission_live_adapters.py")
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def replace_once(text: str, old: str, new: str, label: str) -> str:
|
||||
count = text.count(old)
|
||||
if count != 1:
|
||||
raise RuntimeError(f"{label}: expected one match, got {count}")
|
||||
return text.replace(old, new, 1)
|
||||
|
||||
|
||||
def patch_bootstrap(text: str) -> str:
|
||||
text = replace_once(
|
||||
text,
|
||||
'SCHEMA_EPOCH = "aios.owner-delegation-epoch.v1"\n',
|
||||
'SCHEMA_EPOCH = "aios.owner-delegation-epoch.v1"\n'
|
||||
'SCHEMA_EPOCH_V2 = "aios.owner-delegation-epoch.v2"\n'
|
||||
'SUPPORTED_EPOCH_SCHEMAS = frozenset({SCHEMA_EPOCH, SCHEMA_EPOCH_V2})\n',
|
||||
"bootstrap schema constants",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' policy_digest: str\n schema: str = SCHEMA_EPOCH\n\n def as_public_dict',
|
||||
' policy_digest: str\n'
|
||||
' schema: str = SCHEMA_EPOCH\n'
|
||||
' production_scope: str | None = None\n'
|
||||
' destructive_policy: str | None = None\n'
|
||||
' status: str = ""\n'
|
||||
' signature_namespace: str = ""\n'
|
||||
' signature_key_id: str = ""\n'
|
||||
' canonical_payload_digest: str = ""\n'
|
||||
' canonical_payload: bytes = b""\n\n'
|
||||
' def as_public_dict',
|
||||
"epoch provenance fields",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' value = asdict(self)\n value["allowed_capabilities"]',
|
||||
' value = asdict(self)\n'
|
||||
' value.pop("canonical_payload", None)\n'
|
||||
' value["allowed_capabilities"]',
|
||||
"epoch public serialization",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' if epoch.schema != SCHEMA_EPOCH:\n',
|
||||
' if epoch.schema not in SUPPORTED_EPOCH_SCHEMAS:\n',
|
||||
"bootstrap epoch schema check",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' ranks = {"low": 0, "medium": 1, "engineering": 2, "high": 3}\n',
|
||||
' ranks = {"low": 0, "medium": 1, "engineering": 2, "high": 3}\n',
|
||||
"risk compatibility assertion",
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def patch_adapters(text: str) -> str:
|
||||
text = replace_once(
|
||||
text,
|
||||
' OwnerDelegationEpoch,\n TaskAdmissionError,',
|
||||
' OwnerDelegationEpoch,\n SCHEMA_EPOCH,\n SCHEMA_EPOCH_V2,\n TaskAdmissionError,',
|
||||
"adapter schema imports",
|
||||
)
|
||||
marker = 'OWNER_SIGNER_IDENTITY = "aios-task-grant-signer"\n'
|
||||
fields = marker + (
|
||||
'_EPOCH_V1_FIELDS = frozenset({\n'
|
||||
' "epoch_id", "owner_identity", "trust_domain", "repository",\n'
|
||||
' "allowed_branch_prefixes", "allowed_capabilities", "allowed_file_prefixes",\n'
|
||||
' "risk_ceiling", "issued_at", "not_before", "expires_at",\n'
|
||||
' "revocation_epoch", "policy_digest", "schema",\n'
|
||||
'})\n'
|
||||
'_EPOCH_V2_FIELDS = frozenset({\n'
|
||||
' *_EPOCH_V1_FIELDS, "business_goal", "production_scope",\n'
|
||||
' "destructive_policy", "signature_namespace", "signature_key_id",\n'
|
||||
' "canonical_payload_digest", "status",\n'
|
||||
'})\n'
|
||||
'_SSH_FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")\n'
|
||||
)
|
||||
text = replace_once(text, marker, fields, "adapter epoch field sets")
|
||||
text = replace_once(
|
||||
text,
|
||||
' if value.get("schema") != "aios.owner-delegation-epoch.v1":\n'
|
||||
' raise TaskAdmissionError(\n'
|
||||
' "TASK_ADMISSION_EPOCH_SCHEMA_INVALID",\n'
|
||||
' 403,\n'
|
||||
' )\n',
|
||||
' schema = value.get("schema")\n'
|
||||
' if schema not in {SCHEMA_EPOCH, SCHEMA_EPOCH_V2}:\n'
|
||||
' raise TaskAdmissionError("TASK_ADMISSION_EPOCH_SCHEMA_INVALID", 403)\n'
|
||||
' expected_fields = _EPOCH_V1_FIELDS if schema == SCHEMA_EPOCH else _EPOCH_V2_FIELDS\n'
|
||||
' if set(value) != expected_fields:\n'
|
||||
' raise TaskAdmissionError("TASK_ADMISSION_EPOCH_FIELDS_INVALID", 403)\n',
|
||||
"adapter schema and fields",
|
||||
)
|
||||
anchor = ' if epoch_id in self._revoked_ids():\n raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REVOKED", 403)\n\n'
|
||||
block = anchor + (
|
||||
' if schema == SCHEMA_EPOCH_V2:\n'
|
||||
' if (\n'
|
||||
' value.get("status") != "ACTIVE"\n'
|
||||
' or not isinstance(value.get("business_goal"), str)\n'
|
||||
' or not value["business_goal"]\n'
|
||||
' or not isinstance(value.get("production_scope"), str)\n'
|
||||
' or not value["production_scope"]\n'
|
||||
' or not isinstance(value.get("destructive_policy"), str)\n'
|
||||
' or value["destructive_policy"] not in {"forbidden", "rollback-only"}\n'
|
||||
' or value.get("signature_namespace") != OWNER_NAMESPACE\n'
|
||||
' or not isinstance(value.get("signature_key_id"), str)\n'
|
||||
' or not _SSH_FINGERPRINT_RE.fullmatch(value["signature_key_id"])\n'
|
||||
' or not isinstance(value.get("canonical_payload_digest"), str)\n'
|
||||
' or not re.fullmatch(r"[0-9a-f]{64}", value["canonical_payload_digest"])\n'
|
||||
' ):\n'
|
||||
' raise TaskAdmissionError("TASK_ADMISSION_EPOCH_V2_BINDING_INVALID", 403)\n'
|
||||
' digest_value = dict(value)\n'
|
||||
' supplied_digest = digest_value.pop("canonical_payload_digest", None)\n'
|
||||
' if sha256_json(digest_value) != supplied_digest:\n'
|
||||
' raise TaskAdmissionError("TASK_ADMISSION_EPOCH_PAYLOAD_DIGEST_INVALID", 403)\n\n'
|
||||
)
|
||||
text = replace_once(text, anchor, block, "v2 payload binding")
|
||||
text = replace_once(
|
||||
text,
|
||||
' key_id = key_id_file.read_text(encoding="utf-8").strip()\n',
|
||||
' key_id = key_id_file.read_text(encoding="utf-8").strip()\n'
|
||||
' if schema == SCHEMA_EPOCH_V2 and key_id != value["signature_key_id"]:\n'
|
||||
' raise TaskAdmissionError("TASK_ADMISSION_EPOCH_KEY_BINDING_INVALID", 403)\n',
|
||||
"v2 key binding",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' schema=str(value["schema"]),\n )\n',
|
||||
' schema=str(value["schema"]),\n'
|
||||
' production_scope=str(value["production_scope"]) if schema == SCHEMA_EPOCH_V2 else None,\n'
|
||||
' destructive_policy=str(value["destructive_policy"]) if schema == SCHEMA_EPOCH_V2 else None,\n'
|
||||
' status=str(value.get("status", "")),\n'
|
||||
' signature_namespace=str(value.get("signature_namespace", "")),\n'
|
||||
' signature_key_id=str(value.get("signature_key_id", "")),\n'
|
||||
' canonical_payload_digest=str(value.get("canonical_payload_digest", "")),\n'
|
||||
' canonical_payload=payload,\n'
|
||||
' )\n',
|
||||
"epoch provenance construction",
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def patch_runtime_bundle(text: str) -> str:
|
||||
marker = 'def _build_task_admission_handlers(\n'
|
||||
text = replace_once(text, marker, '@dataclass(frozen=True, slots=True)\n'
|
||||
'class TaskAdmissionRuntimeBundle:\n'
|
||||
' handlers: Mapping[str, Any]\n'
|
||||
' principal_verifier: Any = None\n'
|
||||
' service: Any = None\n'
|
||||
' authorization: Any = None\n'
|
||||
' store: Any = None\n'
|
||||
' task_port: Any = None\n'
|
||||
' epoch_id: str = ""\n\n\n'
|
||||
'def _build_task_admission_runtime_bundle(\n', "runtime bundle declaration")
|
||||
text = replace_once(
|
||||
text,
|
||||
') -> Mapping[str, Any]:\n """Compose handlers from already-trusted server-side coordinates."""',
|
||||
') -> TaskAdmissionRuntimeBundle:\n """Compose handlers from already-trusted server-side coordinates."""',
|
||||
"runtime bundle return type",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' store=TaskAdmissionStore(state_db),\n',
|
||||
' store=(store := TaskAdmissionStore(state_db)),\n',
|
||||
"runtime bundle store capture",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' return TaskAdmissionBridgeContract(service).handlers()\n\n\ndef build_task_admission_handlers_from_activation_file(',
|
||||
' return TaskAdmissionRuntimeBundle(\n'
|
||||
' handlers=TaskAdmissionBridgeContract(service).handlers(),\n'
|
||||
' service=service, authorization=authorization, store=store,\n'
|
||||
' task_port=task_port, epoch_id=epoch_id,\n'
|
||||
' )\n\n\n'
|
||||
'def _build_task_admission_handlers(**kwargs: Any) -> Mapping[str, Any]:\n'
|
||||
' return _build_task_admission_runtime_bundle(**kwargs).handlers\n\n\n'
|
||||
'def build_task_admission_handlers_from_activation_file(',
|
||||
"runtime bundle legacy wrapper",
|
||||
)
|
||||
# Clone the original strict activation parser, retaining all path/token/API
|
||||
# checks. The new bundle entry point still passes through the live V3
|
||||
# producer hook. It never replaces the registered signed principal verifier.
|
||||
tree = ast.parse(text)
|
||||
node = next(n for n in tree.body if isinstance(n, ast.FunctionDef)
|
||||
and n.name == "build_task_admission_handlers_from_activation_file")
|
||||
body = ast.get_source_segment(text, node)
|
||||
body = body.replace("def build_task_admission_handlers_from_activation_file(",
|
||||
"def _c5_build_runtime_from_activation_file(", 1)
|
||||
body = body.replace("return _build_task_admission_handlers(",
|
||||
"return _build_task_admission_runtime_bundle(", 1)
|
||||
text += "\n\n" + body + '''
|
||||
|
||||
|
||||
def build_task_admission_runtime_from_activation_file(activation_file, *, post_admission=None,
|
||||
caller_service="aios-chatgpt-bridge-v2-staging.service"):
|
||||
# Dedicated source-only composition. Ordinary handler construction continues
|
||||
# to use the original medium policy and does not enter this function.
|
||||
from dataclasses import replace
|
||||
bundle = _fl_call(_c5_build_runtime_from_activation_file, activation_file,
|
||||
caller_service=caller_service, post_admission=post_admission)
|
||||
epoch = bundle.authorization.verify_delegation_epoch(bundle.epoch_id)
|
||||
if (epoch.schema != SCHEMA_EPOCH_V2 or epoch.production_scope != "none"
|
||||
or epoch.destructive_policy != "forbidden"):
|
||||
raise TaskAdmissionError("SOURCE_ACTIVATION_EPOCH_SCOPE_DENIED", 403)
|
||||
bundle.service.policy = replace(bundle.service.policy,
|
||||
repository=epoch.repository, trust_domain=epoch.trust_domain,
|
||||
allowed_capabilities=frozenset(epoch.allowed_capabilities),
|
||||
max_risk=epoch.risk_ceiling, policy_digest=epoch.policy_digest)
|
||||
return bundle
|
||||
'''
|
||||
return text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--release-root", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
root = args.release_root.resolve()
|
||||
snapshot = root / "live-release" / "staging-source-snapshot"
|
||||
identity_path = root / "live-release" / "STAGING_MODULE_IDENTITIES.json"
|
||||
overlay = root / "live-release" / "staging-overlay" / "app"
|
||||
identity = json.loads(identity_path.read_text(encoding="utf-8"))
|
||||
outputs: dict[str, dict[str, str | int]] = {}
|
||||
diffs: list[str] = []
|
||||
for name in FILES:
|
||||
source = snapshot / name
|
||||
expected = identity["modules"][name.removesuffix(".py")]["sha256"]
|
||||
if sha256(source) != expected:
|
||||
raise SystemExit(f"BASE_HASH_MISMATCH:{name}")
|
||||
before = source.read_text(encoding="utf-8")
|
||||
after = patch_bootstrap(before) if name.endswith("bootstrap.py") else patch_adapters(before)
|
||||
after = patch_runtime_bundle(after) if name.endswith("live_adapters.py") else after
|
||||
target = overlay / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(after, encoding="utf-8", newline="")
|
||||
diffs.extend(difflib.unified_diff(
|
||||
before.splitlines(keepends=True), after.splitlines(keepends=True),
|
||||
fromfile=f"staging-source-snapshot/{name}", tofile=f"staging-overlay/app/{name}",
|
||||
))
|
||||
outputs[name] = {
|
||||
"base_sha256": expected, "base_bytes": len(before.encode()),
|
||||
"result_sha256": sha256(target), "result_bytes": len(after.encode()),
|
||||
}
|
||||
diff_path = root / "live-release" / "staging-overlay" / "c5-epoch-v2-overlay.diff"
|
||||
diff_path.write_text("".join(diffs), encoding="utf-8", newline="")
|
||||
result_path = root / "live-release" / "staging-overlay" / "c5-epoch-v2-overlay.result.json"
|
||||
result_path.write_text(json.dumps({"files": outputs, "diff_sha256": sha256(diff_path)}, indent=2) + "\n", encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the hash-pinned additive C5 TaskPrincipal enrollment overlay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
|
||||
PINNED = {
|
||||
# The first admission module on both live unit PYTHONPATH values. It
|
||||
# preserves the exact9/R10/generic chain and the governed TTL clamp.
|
||||
"aios_task_principal_admission.py": "d56fb0078cfcb907f2afb35f0163051a962a89520b63f6c53149d478fc7777c8",
|
||||
# Effective staging entrypoint. This preserves the 4 MiB body-limit
|
||||
# hardening wrapper, which in turn pins and loads the original a95b core.
|
||||
"aios_task_principal_issuer_server.py": "53b30a4a094f4abda176dbbf931ed2d9baec0a6ef3590c9ae498e2c22af1057c",
|
||||
"aios_task_principal_governed_adapter.py": "9bdfa7bd20bd1f4d5c2f5a58f458d0999f593a3f9326920971e357ba52939914",
|
||||
"governed_runtime_scope.py": "c56bd709c1e0c8956a5be7a4a8dc2a12591bdec2b84dcfba5c53f6bced746bcb",
|
||||
}
|
||||
|
||||
EFFECTIVE_ADMISSION_SOURCE = (
|
||||
"/var/lib/aios-gitops/bridge-v2-staging/releases/"
|
||||
"p0-task-principal-governed-runtime-20260910-02/app/"
|
||||
"aios_task_principal_admission.py"
|
||||
)
|
||||
|
||||
ADMISSION_OVERLAY = '''"""C5-only overlay over the hash-pinned effective admission chain."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
BASE_SOURCE = Path(
|
||||
"/var/lib/aios-gitops/bridge-v2-staging/releases/"
|
||||
"p0-task-principal-governed-runtime-20260910-02/app/"
|
||||
"aios_task_principal_admission.py"
|
||||
)
|
||||
BASE_SOURCE_SHA256 = "d56fb0078cfcb907f2afb35f0163051a962a89520b63f6c53149d478fc7777c8"
|
||||
C5_TASK_ID = "TASK-V2R2C5-SOURCE-PUBLICATION-20260926-R1"
|
||||
|
||||
|
||||
def _load_base():
|
||||
metadata = BASE_SOURCE.lstat()
|
||||
if not BASE_SOURCE.is_file() or BASE_SOURCE.is_symlink() or metadata.st_mode & 0o022:
|
||||
raise RuntimeError("BASE_ADMISSION_SOURCE_INVALID")
|
||||
digest = hashlib.sha256(BASE_SOURCE.read_bytes()).hexdigest()
|
||||
if digest != BASE_SOURCE_SHA256:
|
||||
raise RuntimeError("BASE_ADMISSION_SOURCE_DRIFT")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_aios_task_principal_admission_governed_base", BASE_SOURCE,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("BASE_ADMISSION_SOURCE_UNLOADABLE")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
_effective_base = _load_base()
|
||||
for _name, _value in _effective_base.__dict__.items():
|
||||
if not _name.startswith("__"):
|
||||
globals()[_name] = _value
|
||||
|
||||
_legacy = _effective_base._legacy
|
||||
_ORIGINAL_SCOPE_AND_TRUST = _legacy._scope_and_trust
|
||||
|
||||
|
||||
def _c5_scope_and_trust(
|
||||
con, trust, task, now, *, lease_token=None, pepper=None,
|
||||
):
|
||||
# Every non-C5 task follows the effective governed/exact9/R10 chain
|
||||
# unchanged. The exact C5 identity has one exclusive enrollment route.
|
||||
if str(task["task_id"]) != C5_TASK_ID:
|
||||
return _ORIGINAL_SCOPE_AND_TRUST(
|
||||
con, trust, task, now, lease_token=lease_token, pepper=pepper,
|
||||
)
|
||||
|
||||
active_digest, active_scope = _legacy._active_scope_chain(con)
|
||||
digest = _legacy.sha256_canonical(trust)
|
||||
if active_digest != digest:
|
||||
_legacy._fail("TRUST_NOT_ACTIVE")
|
||||
event = con.execute(
|
||||
"SELECT * FROM task_principal_trust_events WHERE event_type='REGISTER' "
|
||||
"AND issuer_id=? AND key_id=? AND public_key=? AND trust_manifest_sha256=?",
|
||||
(trust["issuer_id"], trust["key_id"], trust["public_key"], digest),
|
||||
).fetchone()
|
||||
if event is None or event["scope_id"] != active_scope["scope_id"]:
|
||||
_legacy._fail("TRUST_REGISTER_MISSING_OR_CORRUPT")
|
||||
if (
|
||||
active_scope["issuer_id"] != trust["issuer_id"]
|
||||
or active_scope["key_id"] != trust["key_id"]
|
||||
):
|
||||
_legacy._fail("SCOPE_MISSING_OR_CORRUPT")
|
||||
_legacy._check_revocations(con, task, active_scope, trust, None)
|
||||
|
||||
from c5_governed_principal_scope import (
|
||||
C5PrincipalScopeError,
|
||||
resolve_enrolled_scope_from_environment,
|
||||
)
|
||||
try:
|
||||
scope = resolve_enrolled_scope_from_environment(
|
||||
task=task,
|
||||
execution=_legacy._execution(task),
|
||||
trust=trust,
|
||||
now=now,
|
||||
active_static_task_id=str(active_scope["task_id"]),
|
||||
)
|
||||
except C5PrincipalScopeError as exc:
|
||||
_legacy._fail(exc.code)
|
||||
if scope is None:
|
||||
_legacy._fail("C5_SCOPE_ROUTING_INVALID")
|
||||
claim_digest = _legacy._claim(task, lease_token, pepper, now)
|
||||
_legacy._check_revocations(con, task, scope, trust, None)
|
||||
return scope, claim_digest
|
||||
|
||||
|
||||
_legacy._scope_and_trust = _c5_scope_and_trust
|
||||
globals()["_scope_and_trust"] = _c5_scope_and_trust
|
||||
|
||||
|
||||
def build_task_principal_issuer(*args: Any, **kwargs: Any) -> Any:
|
||||
from c5_governed_principal_scope import wrap_principal_runtime_from_environment
|
||||
return wrap_principal_runtime_from_environment(
|
||||
TaskPrincipalIssuer(*args, **kwargs), role="issuer",
|
||||
)
|
||||
|
||||
|
||||
def build_task_principal_verifier(*args: Any, **kwargs: Any) -> Any:
|
||||
from c5_governed_principal_scope import wrap_principal_runtime_from_environment
|
||||
return wrap_principal_runtime_from_environment(
|
||||
TaskPrincipalVerifier(*args, **kwargs), role="verifier",
|
||||
)
|
||||
|
||||
|
||||
C5_GOVERNED_PRINCIPAL_SCOPE_OVERLAY = {
|
||||
"schema": "aios.c5-governed-principal-scope-overlay.v1",
|
||||
"base_admission": str(BASE_SOURCE),
|
||||
"base_admission_sha256": BASE_SOURCE_SHA256,
|
||||
"task_id": C5_TASK_ID,
|
||||
}
|
||||
'''
|
||||
|
||||
ISSUER_MAIN_ANCHOR = '''
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
ISSUER_MAIN_WITH_BRIDGE_ABI = '''
|
||||
# The effective Bridge imports this existing private helper. Delegate it to
|
||||
# the body-limit wrapper's already hash-pinned base module so its validation
|
||||
# semantics and the wrapper's 4 MiB MAX_BODY setting remain authoritative.
|
||||
_read_public_manifest = _BASE._read_public_manifest
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
ENTRYPOINT = '''#!/usr/bin/env python3
|
||||
import sys
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
from c5_governed_principal_scope import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
'''
|
||||
|
||||
ENV_EXAMPLE = '''# Required identically for the issuer and Bridge units. Prefix overlay/app
|
||||
# on both PYTHONPATH values; their existing python -m entry points are unchanged.
|
||||
AIOS_C5_PRINCIPAL_SCOPE_CONFIG=/var/lib/aios-gitops/bridge-v2-staging/releases/REPLACE/principal-activation.json
|
||||
'''
|
||||
|
||||
ACTIVATION_EXAMPLE = {
|
||||
"schema": "aios.c5-task-principal-activation.v1",
|
||||
"task_id": "TASK-V2R2C5-SOURCE-PUBLICATION-20260926-R1",
|
||||
"agent_id": "agent/codex-c5-release",
|
||||
"execution_digest": "1ba534d551991e8c99014285eeea36afcbc1500686035cb8bcd7f9f4efd6215e",
|
||||
"store_path": "/var/lib/aios-gitops/c5-source-principal/enrollment.sqlite3",
|
||||
"grant_db": "/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3",
|
||||
"plan_dir": "/var/lib/aios-gitops/bridge-v2-staging/releases/REPLACE/plans",
|
||||
"producer_state_root": "/var/lib/aios-gitops/bridge-v2-staging",
|
||||
"policy_artifact": "/var/lib/aios-gitops/bridge-v2-staging/releases/REPLACE/policy.json",
|
||||
"policy_revocations": "/var/lib/aios-gitops/bridge-v2-staging/releases/REPLACE/policy-revocations.json",
|
||||
"epoch_root": "/var/lib/aios-gitops/bridge-v2-staging/releases/REPLACE/epoch",
|
||||
"epoch_revocations": "/var/lib/aios-gitops/bridge-v2-staging/releases/REPLACE/epoch-revocations.json",
|
||||
"owner_registry": "/etc/aios/single-host-signer-registry.json",
|
||||
"owner_signer_id": "local-owner-authority",
|
||||
"registered_signer_command": "/usr/local/libexec/aios-grant-signer-adapter",
|
||||
"require_root_plan_owner": True,
|
||||
"grant_db_allowed_group_gid": None,
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def copy_pinned(source: Path, target: Path, name: str) -> dict[str, Any]:
|
||||
observed = sha256(source)
|
||||
if observed != PINNED[name]:
|
||||
raise SystemExit(f"BASE_HASH_MISMATCH:{name}:{observed}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, target)
|
||||
return {
|
||||
"source": str(source.resolve()),
|
||||
"base_sha256": observed,
|
||||
"result_sha256": sha256(target),
|
||||
"bytes": target.stat().st_size,
|
||||
}
|
||||
|
||||
|
||||
def build_issuer_overlay(source: Path, target: Path) -> dict[str, Any]:
|
||||
name = "aios_task_principal_issuer_server.py"
|
||||
observed = sha256(source)
|
||||
if observed != PINNED[name]:
|
||||
raise SystemExit(f"BASE_HASH_MISMATCH:{name}:{observed}")
|
||||
text = source.read_text(encoding="utf-8")
|
||||
if text.count(ISSUER_MAIN_ANCHOR) != 1:
|
||||
raise SystemExit(
|
||||
"PATCH_ANCHOR_INVALID:issuer_bridge_private_abi:"
|
||||
+ str(text.count(ISSUER_MAIN_ANCHOR))
|
||||
)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(
|
||||
text.replace(ISSUER_MAIN_ANCHOR, ISSUER_MAIN_WITH_BRIDGE_ABI, 1),
|
||||
encoding="utf-8", newline="",
|
||||
)
|
||||
return {
|
||||
"source": str(source.resolve()),
|
||||
"base_sha256": observed,
|
||||
"result_sha256": sha256(target),
|
||||
"bytes": target.stat().st_size,
|
||||
"preserved_max_body_bytes": 4 * 1024 * 1024,
|
||||
"bridge_private_helper": "_read_public_manifest",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--principal-source-root", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--issuer-source", type=Path,
|
||||
help="Exact issuer server source; defaults to principal-source-root/aios_task_principal_issuer_server.py",
|
||||
)
|
||||
parser.add_argument("--adapter-source-root", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
principal = args.principal_source_root.resolve()
|
||||
adapter = args.adapter_source_root.resolve()
|
||||
output = args.output_root.resolve()
|
||||
if output.exists() and any(output.iterdir()):
|
||||
raise SystemExit("OUTPUT_ROOT_NOT_EMPTY")
|
||||
app = output / "app"
|
||||
app.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
admission_source = principal / "aios_task_principal_admission.py"
|
||||
observed = sha256(admission_source)
|
||||
if observed != PINNED["aios_task_principal_admission.py"]:
|
||||
raise SystemExit(
|
||||
"BASE_HASH_MISMATCH:aios_task_principal_admission.py:" + observed
|
||||
)
|
||||
admission_target = app / "aios_task_principal_admission.py"
|
||||
admission_target.write_text(ADMISSION_OVERLAY, encoding="utf-8", newline="")
|
||||
|
||||
outputs: dict[str, Any] = {
|
||||
"aios_task_principal_admission.py": {
|
||||
"source": str(admission_source),
|
||||
"runtime_base_source": EFFECTIVE_ADMISSION_SOURCE,
|
||||
"base_sha256": observed,
|
||||
"result_sha256": sha256(admission_target),
|
||||
"bytes": admission_target.stat().st_size,
|
||||
}
|
||||
}
|
||||
issuer_source = (
|
||||
args.issuer_source.resolve()
|
||||
if args.issuer_source is not None
|
||||
else principal / "aios_task_principal_issuer_server.py"
|
||||
)
|
||||
outputs["aios_task_principal_issuer_server.py"] = build_issuer_overlay(
|
||||
issuer_source,
|
||||
app / "aios_task_principal_issuer_server.py",
|
||||
)
|
||||
for name in (
|
||||
"aios_task_principal_governed_adapter.py", "governed_runtime_scope.py",
|
||||
):
|
||||
outputs[name] = copy_pinned(adapter / name, app / name, name)
|
||||
module_source = Path(__file__).resolve().parents[1] / "active-runtime" / "app" / "c5_governed_principal_scope.py"
|
||||
module_target = app / module_source.name
|
||||
shutil.copyfile(module_source, module_target)
|
||||
outputs[module_source.name] = {
|
||||
"source": str(module_source),
|
||||
"result_sha256": sha256(module_target),
|
||||
"bytes": module_target.stat().st_size,
|
||||
}
|
||||
|
||||
bin_dir = output / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
entry = bin_dir / "aios-c5-principal-scope"
|
||||
entry.write_text(ENTRYPOINT, encoding="utf-8", newline="")
|
||||
entry.chmod(entry.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP)
|
||||
config_dir = output / "config"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
env_path = config_dir / "c5-principal-scope.env.example"
|
||||
env_path.write_text(ENV_EXAMPLE, encoding="utf-8", newline="")
|
||||
activation_path = config_dir / "principal-activation.example.json"
|
||||
activation_path.write_text(
|
||||
json.dumps(ACTIVATION_EXAMPLE, sort_keys=True, separators=(",", ":")),
|
||||
encoding="utf-8", newline="",
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"schema": "aios.c5-task-principal-overlay.v1",
|
||||
"task_id": "TASK-V2R2C5-SOURCE-PUBLICATION-20260926-R1",
|
||||
"execution_digest": "1ba534d551991e8c99014285eeea36afcbc1500686035cb8bcd7f9f4efd6215e",
|
||||
"files": outputs,
|
||||
"entrypoints": {
|
||||
"enrollment": "bin/aios-c5-principal-scope",
|
||||
"issuer": "python3 -B -m aios_task_principal_issuer_server",
|
||||
"bridge": "python3 -B -m aios_chatgpt_bridge_v2_server",
|
||||
},
|
||||
"runtime_imports": [
|
||||
"aios_single_host_authority.RegistryOwnerApprovalVerifier",
|
||||
"aios_single_host_authority.SignerRegistry",
|
||||
"registered_owner_signer_adapter.RegisteredOwnerSignerAdapter",
|
||||
],
|
||||
"activation": {
|
||||
"pythonpath_requirement": "prefix overlay/app for both issuer and Bridge",
|
||||
"same_admission_overlay_for_both_units": True,
|
||||
"environment_example": "config/c5-principal-scope.env.example",
|
||||
"activation_example": "config/principal-activation.example.json",
|
||||
"live_mutation_performed": False,
|
||||
},
|
||||
}
|
||||
manifest_path = output / "overlay-manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8", newline="",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,669 @@
|
||||
"""Build the unsigned, fixed-scope C5 staging installation artifact.
|
||||
|
||||
This module is deliberately a packager only. It does not import the live
|
||||
installer, invoke a signer, call systemd, or mutate a remote host. Code that
|
||||
belongs to the support commit is read with ``git show`` so a dirty checkout
|
||||
cannot silently change the artifact.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
|
||||
REPOSITORY = "aios/aios"
|
||||
RELEASE_BASE = "/var/lib/aios-gitops/bridge-v2-staging/releases"
|
||||
UNIT = "aios-chatgpt-bridge-v2-staging.service"
|
||||
BRIDGE_TARGET = "bridge-18892"
|
||||
ISSUER_TARGET = "principal-issuer-18946"
|
||||
ISSUER_UNIT = "aios-task-principal-issuer.service"
|
||||
DROPIN_NAME = "~" * 32 + "-c5-source.conf"
|
||||
DROPIN = f"/etc/systemd/system/{UNIT}.d/{DROPIN_NAME}"
|
||||
ISSUER_DROPIN = f"/etc/systemd/system/{ISSUER_UNIT}.d/{DROPIN_NAME}"
|
||||
RUNTIME_URL = "http://127.0.0.1:18892/v2/runtime"
|
||||
ISSUER_RUNTIME_URL = "http://127.0.0.1:18946/health"
|
||||
PORT = 18892
|
||||
ISSUER_PORT = 18946
|
||||
REQUEST_SCHEMA = "aios.c5-staging-installation-request.v2"
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
MAX_TTL = 900
|
||||
DEFAULT_TIMEOUT = 3
|
||||
SOURCE_MODULES = {
|
||||
"active-runtime/app/source_publication_runtime.py":
|
||||
"app/source_publication_runtime.py",
|
||||
"active-runtime/app/governed_source_publication_policy.py":
|
||||
"app/governed_source_publication_policy.py",
|
||||
"active-runtime/app/registered_owner_signer_adapter.py":
|
||||
"app/registered_owner_signer_adapter.py",
|
||||
"active-runtime/app/aios_single_host_authority.py":
|
||||
"app/c5_source_authority.py",
|
||||
}
|
||||
GENERATED_OVERLAYS = (
|
||||
"aios_chatgpt_bridge_v2_server.py",
|
||||
"aios_task_admission_bootstrap.py",
|
||||
"aios_task_admission_live_adapters.py",
|
||||
)
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_NONCE = re.compile(r"^[A-Za-z0-9._:-]{16,128}$")
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{2,255}$")
|
||||
_SOURCE_MODES = {0o444, 0o640, 0o644, 0o755}
|
||||
|
||||
|
||||
class BuildError(ValueError):
|
||||
"""Stable fail-closed error raised before any output is written."""
|
||||
|
||||
|
||||
def _error(code: str) -> "NoReturn":
|
||||
raise BuildError(code)
|
||||
|
||||
|
||||
def _sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False, allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _strict_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
_error("JSON_DUPLICATE_FIELD")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _json(path: Path) -> tuple[dict[str, Any], bytes]:
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_strict_pairs)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise BuildError(f"JSON_INPUT_INVALID:{path.name}") from exc
|
||||
if not isinstance(value, dict):
|
||||
_error("JSON_OBJECT_REQUIRED")
|
||||
return value, raw
|
||||
|
||||
|
||||
def _regular(path: Path, *, label: str) -> bytes:
|
||||
try:
|
||||
info = path.lstat()
|
||||
except OSError as exc:
|
||||
raise BuildError(f"INPUT_MISSING:{label}") from exc
|
||||
if not info or not path.is_file() or path.is_symlink():
|
||||
_error(f"INPUT_NOT_REGULAR:{label}")
|
||||
try:
|
||||
return path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise BuildError(f"INPUT_READ_FAILED:{label}") from exc
|
||||
|
||||
|
||||
def _safe_rel(value: str) -> PurePosixPath:
|
||||
if not isinstance(value, str) or not value or "\\" in value:
|
||||
_error("RELATIVE_PATH_INVALID")
|
||||
path = PurePosixPath(value)
|
||||
if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
|
||||
_error("RELATIVE_PATH_INVALID")
|
||||
return path
|
||||
|
||||
|
||||
def _support_oid(repo: Path, support_commit: str) -> str:
|
||||
if not _HEX40.fullmatch(support_commit):
|
||||
_error("SUPPORT_COMMIT_INVALID")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "--verify", f"{support_commit}^{{commit}}"],
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise BuildError("SUPPORT_COMMIT_NOT_FOUND") from exc
|
||||
if result.stdout.strip() != support_commit:
|
||||
_error("SUPPORT_COMMIT_NOT_EXACT")
|
||||
return support_commit
|
||||
|
||||
|
||||
def _git_blob(repo: Path, support_commit: str, path: str) -> bytes:
|
||||
_safe_rel(path)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo), "show", f"{support_commit}:{path}"],
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise BuildError(f"SUPPORT_BLOB_MISSING:{path}") from exc
|
||||
return bytes(result.stdout)
|
||||
|
||||
|
||||
def _mode(path: Path) -> int:
|
||||
mode = path.stat().st_mode
|
||||
return 0o755 if mode & 0o111 else 0o644
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArtifactFile:
|
||||
path: str
|
||||
data: bytes
|
||||
mode: int
|
||||
kind: str = "release_file"
|
||||
|
||||
@property
|
||||
def record(self) -> dict[str, Any]:
|
||||
return {
|
||||
"path": self.path,
|
||||
"sha256": _sha(self.data),
|
||||
"size": len(self.data),
|
||||
"mode": self.mode,
|
||||
"kind": self.kind,
|
||||
}
|
||||
|
||||
|
||||
def _root(support_commit: str) -> str:
|
||||
return f"{RELEASE_BASE}/c5-source-{support_commit}"
|
||||
|
||||
|
||||
def _read_existing(snapshot_path: Path, target: str = BRIDGE_TARGET) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
snapshot, _ = _json(snapshot_path)
|
||||
profiles = snapshot.get("profiles")
|
||||
if isinstance(profiles, Mapping):
|
||||
profile = profiles.get(target)
|
||||
if not isinstance(profile, Mapping):
|
||||
_error("EXISTING_SNAPSHOT_TARGET_MISSING")
|
||||
value = profile.get("existing", profile)
|
||||
selected_snapshot: dict[str, Any] = dict(profile)
|
||||
else:
|
||||
value = snapshot.get("existing", snapshot)
|
||||
selected_snapshot = dict(snapshot)
|
||||
if not isinstance(value, Mapping):
|
||||
_error("EXISTING_SNAPSHOT_INVALID")
|
||||
required = {
|
||||
"unit_active", "effective_config_sha256", "dropin", "pythonpath", "source_files",
|
||||
"activation_file", "principal_scope_config",
|
||||
}
|
||||
if not required.issubset(value):
|
||||
_error("EXISTING_SNAPSHOT_FIELDS_INVALID")
|
||||
# Keep only the installer-owned snapshot projection. Live capture files
|
||||
# also carry target/unit/readiness metadata; copying those keys into a
|
||||
# request would fail the typed request's exact-field check.
|
||||
existing = {key: value[key] for key in required}
|
||||
if existing["unit_active"] is not True:
|
||||
_error("EXISTING_UNIT_NOT_ACTIVE")
|
||||
if not isinstance(existing["effective_config_sha256"], str) or not _HEX64.fullmatch(existing["effective_config_sha256"]):
|
||||
_error("EXISTING_CONFIG_HASH_INVALID")
|
||||
pythonpath = existing["pythonpath"]
|
||||
if not isinstance(pythonpath, list) or not pythonpath or len(set(pythonpath)) != len(pythonpath):
|
||||
_error("EXISTING_PYTHONPATH_INVALID")
|
||||
for item in pythonpath:
|
||||
if not isinstance(item, str) or not item.startswith("/") or ".." in PurePosixPath(item).parts:
|
||||
_error("EXISTING_PYTHONPATH_INVALID")
|
||||
if value["activation_file"] is not None or value["principal_scope_config"] is not None:
|
||||
_error("EXISTING_ACTIVATION_ALREADY_PRESENT")
|
||||
dropin = existing["dropin"]
|
||||
if not isinstance(dropin, Mapping) or set(dropin) != {"exists", "sha256"}:
|
||||
_error("EXISTING_DROPIN_INVALID")
|
||||
if type(dropin["exists"]) is not bool:
|
||||
_error("EXISTING_DROPIN_INVALID")
|
||||
if dropin["exists"] is True and (not isinstance(dropin["sha256"], str) or not _HEX64.fullmatch(dropin["sha256"])):
|
||||
_error("EXISTING_DROPIN_INVALID")
|
||||
if dropin["exists"] is False and dropin["sha256"] is not None:
|
||||
_error("EXISTING_DROPIN_INVALID")
|
||||
source_files = existing["source_files"]
|
||||
if not isinstance(source_files, list) or not source_files:
|
||||
_error("EXISTING_SOURCE_FILES_INVALID")
|
||||
for record in source_files:
|
||||
if not isinstance(record, Mapping) or set(record) != {"path", "sha256", "size", "mode", "kind"}:
|
||||
_error("EXISTING_SOURCE_FILES_INVALID")
|
||||
if (
|
||||
not isinstance(record["path"], str) or not record["path"].startswith("/")
|
||||
or any(part in {".", ".."} for part in PurePosixPath(record["path"]).parts)
|
||||
or "\\" in record["path"]
|
||||
or not _HEX64.fullmatch(str(record["sha256"]))
|
||||
or type(record["size"]) is not int or record["size"] < 0
|
||||
or record["mode"] not in _SOURCE_MODES or record["kind"] != "existing_source"
|
||||
):
|
||||
_error("EXISTING_SOURCE_FILES_INVALID")
|
||||
if not any(record["path"] != base and record["path"].startswith(base.rstrip("/") + "/") for base in pythonpath):
|
||||
_error("EXISTING_SOURCE_OUTSIDE_PYTHONPATH")
|
||||
return existing, selected_snapshot
|
||||
|
||||
|
||||
def _old_module(existing: Mapping[str, Any], target: str) -> dict[str, Any]:
|
||||
suffix = (
|
||||
"/aios_chatgpt_bridge_v2_server.py"
|
||||
if target == BRIDGE_TARGET else "/aios_task_principal_issuer_server.py"
|
||||
)
|
||||
for record in existing["source_files"]:
|
||||
if str(record["path"]).endswith(suffix):
|
||||
return dict(record)
|
||||
_error("EXISTING_MODULE_IDENTITY_MISSING")
|
||||
|
||||
|
||||
def _old_bridge(existing: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return _old_module(existing, BRIDGE_TARGET)
|
||||
|
||||
|
||||
def _authority_inputs(authority: Path, support_commit: str) -> tuple[list[ArtifactFile], str, dict[str, Any]]:
|
||||
if not authority.is_dir() or authority.is_symlink():
|
||||
_error("AUTHORITY_INPUT_DIRECTORY_INVALID")
|
||||
collected: list[ArtifactFile] = []
|
||||
for item in sorted(authority.rglob("*")):
|
||||
if item.is_symlink():
|
||||
_error("AUTHORITY_INPUT_SYMLINK")
|
||||
if item.is_dir():
|
||||
continue
|
||||
relative = item.relative_to(authority).as_posix()
|
||||
_safe_rel(relative)
|
||||
collected.append(ArtifactFile(f"__AUTHORITY__/{relative}", _regular(item, label=relative), _mode(item)))
|
||||
names = {item.path.removeprefix("__AUTHORITY__/") for item in collected}
|
||||
if "source-activation.json" not in names or "policy.json" not in names:
|
||||
_error("AUTHORITY_INPUTS_INCOMPLETE")
|
||||
result_item = next((item for item in collected if item.path == "__AUTHORITY__/AUTHORITY_PREPARATION_RESULT.json"), None)
|
||||
result: dict[str, Any] = {}
|
||||
if result_item is not None:
|
||||
result = json.loads(result_item.data.decode("utf-8"), object_pairs_hook=_strict_pairs)
|
||||
if not isinstance(result, dict):
|
||||
_error("AUTHORITY_RESULT_INVALID")
|
||||
policy_item = next(item for item in collected if item.path == "__AUTHORITY__/policy.json")
|
||||
try:
|
||||
policy = json.loads(policy_item.data.decode("utf-8"), object_pairs_hook=_strict_pairs)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise BuildError("AUTHORITY_POLICY_INVALID") from exc
|
||||
payload = policy.get("payload") if isinstance(policy, dict) else None
|
||||
source_commit = result.get("source_commit") if result else (payload or {}).get("source_oid")
|
||||
if not isinstance(source_commit, str) or not _HEX40.fullmatch(source_commit):
|
||||
_error("SOURCE_COMMIT_INVALID")
|
||||
if result and result.get("support_commit") != support_commit:
|
||||
_error("AUTHORITY_SUPPORT_COMMIT_MISMATCH")
|
||||
if isinstance(payload, Mapping) and payload.get("source_oid") != source_commit:
|
||||
_error("AUTHORITY_SOURCE_COMMIT_MISMATCH")
|
||||
root = _root(support_commit)
|
||||
activation_item = next(item for item in collected if item.path == "__AUTHORITY__/source-activation.json")
|
||||
try:
|
||||
activation = json.loads(activation_item.data.decode("utf-8"), object_pairs_hook=_strict_pairs)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise BuildError("SOURCE_ACTIVATION_INVALID") from exc
|
||||
if not isinstance(activation, Mapping) or activation.get("schema") != "aios.source-publication-host-activation.v1":
|
||||
_error("SOURCE_ACTIVATION_INVALID")
|
||||
if activation.get("policy_sha256") != _sha(policy_item.data):
|
||||
_error("SOURCE_ACTIVATION_POLICY_HASH_MISMATCH")
|
||||
for key in ("policy_artifact", "policy_revocations", "admission_activation"):
|
||||
value = activation.get(key)
|
||||
if not isinstance(value, str) or not value.startswith(root + "/"):
|
||||
_error("SOURCE_ACTIVATION_PATH_INVALID")
|
||||
output: list[ArtifactFile] = []
|
||||
for item in collected:
|
||||
rel = item.path.removeprefix("__AUTHORITY__/")
|
||||
output.append(ArtifactFile(f"{root}/{rel}", item.data, item.mode))
|
||||
return output, source_commit, dict(activation)
|
||||
|
||||
|
||||
def _bridge_base_hash(repo: Path, support_commit: str) -> str:
|
||||
blob = _git_blob(repo, support_commit, "scripts/build_c5_live_bridge_overlay.py")
|
||||
tree = ast.parse(blob.decode("utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and len(node.targets) == 1 and getattr(node.targets[0], "id", None) == "BASE_SHA256":
|
||||
value = ast.literal_eval(node.value)
|
||||
if isinstance(value, str) and _HEX64.fullmatch(value):
|
||||
return value
|
||||
_error("BRIDGE_BASE_IDENTITY_MISSING")
|
||||
|
||||
|
||||
def _verify_overlays(overlay_app: Path, repo: Path, support_commit: str) -> list[ArtifactFile]:
|
||||
if not overlay_app.is_dir() or overlay_app.is_symlink():
|
||||
_error("OVERLAY_DIRECTORY_INVALID")
|
||||
result_path = overlay_app.parent / "c5-epoch-v2-overlay.result.json"
|
||||
result, _ = _json(result_path)
|
||||
entries = result.get("files")
|
||||
if not isinstance(entries, Mapping):
|
||||
_error("EPOCH_OVERLAY_RESULT_INVALID")
|
||||
snapshot = overlay_app.parent.parent / "staging-source-snapshot"
|
||||
for name in ("aios_task_admission_bootstrap.py", "aios_task_admission_live_adapters.py"):
|
||||
entry = entries.get(name)
|
||||
source = snapshot / name
|
||||
target = overlay_app / name
|
||||
if not isinstance(entry, Mapping) or set(entry) != {"base_sha256", "base_bytes", "result_sha256", "result_bytes"}:
|
||||
_error("EPOCH_OVERLAY_RESULT_INVALID")
|
||||
base = _regular(source, label=f"overlay-base:{name}")
|
||||
data = _regular(target, label=f"overlay-result:{name}")
|
||||
if _sha(base) != entry["base_sha256"] or len(base) != entry["base_bytes"]:
|
||||
_error("EPOCH_OVERLAY_BASE_MISMATCH")
|
||||
if _sha(data) != entry["result_sha256"] or len(data) != entry["result_bytes"]:
|
||||
_error("EPOCH_OVERLAY_RESULT_MISMATCH")
|
||||
bridge_base = _regular(snapshot / "aios_chatgpt_bridge_v2_server.py", label="overlay-base:bridge")
|
||||
if _sha(bridge_base) != _bridge_base_hash(repo, support_commit):
|
||||
_error("BRIDGE_OVERLAY_BASE_MISMATCH")
|
||||
files: list[ArtifactFile] = []
|
||||
seen: set[str] = set()
|
||||
for path in sorted(overlay_app.rglob("*")):
|
||||
if path.is_symlink():
|
||||
_error("OVERLAY_SYMLINK")
|
||||
if path.is_dir():
|
||||
continue
|
||||
rel = path.relative_to(overlay_app).as_posix()
|
||||
_safe_rel(rel)
|
||||
if rel.endswith(".pyc") or "__pycache__" in PurePosixPath(rel).parts:
|
||||
_error("OVERLAY_BYTECODE_FORBIDDEN")
|
||||
if rel in seen:
|
||||
_error("OVERLAY_DUPLICATE")
|
||||
seen.add(rel)
|
||||
files.append(ArtifactFile(f"app/{rel}", _regular(path, label=f"overlay:{rel}"), _mode(path)))
|
||||
if not set(GENERATED_OVERLAYS).issubset(seen):
|
||||
_error("OVERLAY_FILE_SET_INCOMPLETE")
|
||||
# The principal builder emits a sibling result manifest. When present,
|
||||
# bind every reported result byte to the exact file that will be archived.
|
||||
principal_manifest = overlay_app.parent / "overlay-manifest.json"
|
||||
if principal_manifest.exists():
|
||||
manifest, _ = _json(principal_manifest)
|
||||
entries = manifest.get("files")
|
||||
if not isinstance(entries, Mapping) or not entries:
|
||||
_error("PRINCIPAL_OVERLAY_RESULT_INVALID")
|
||||
by_name = {item.path.removeprefix("app/"): item for item in files}
|
||||
for name, entry in entries.items():
|
||||
if not isinstance(name, str) or not isinstance(entry, Mapping):
|
||||
_error("PRINCIPAL_OVERLAY_RESULT_INVALID")
|
||||
target = by_name.get(name)
|
||||
if target is None:
|
||||
_error("PRINCIPAL_OVERLAY_RESULT_MISSING")
|
||||
if entry.get("result_sha256") != _sha(target.data) or entry.get("bytes") != len(target.data):
|
||||
_error("PRINCIPAL_OVERLAY_RESULT_MISMATCH")
|
||||
return files
|
||||
|
||||
|
||||
def _rewrite_c5_imports(data: bytes, name: str) -> bytes:
|
||||
text = data.decode("utf-8")
|
||||
old = "from aios_single_host_authority import"
|
||||
new = "from c5_source_authority import"
|
||||
if text.count(old) < 1:
|
||||
_error(f"C5_IMPORT_ANCHOR_INVALID:{name}")
|
||||
return text.replace(old, new).encode("utf-8")
|
||||
|
||||
|
||||
def _dropin(
|
||||
root: str, pythonpath: Iterable[str], principal: str | None,
|
||||
*, target: str = BRIDGE_TARGET,
|
||||
) -> bytes:
|
||||
path = ":".join((f"{root}/app", *pythonpath))
|
||||
value = "[Service]\n" f"Environment=PYTHONPATH={path}\n"
|
||||
if target == BRIDGE_TARGET:
|
||||
value += f"Environment=AIOS_SOURCE_PUBLICATION_ACTIVATION_FILE={root}/source-activation.json\n"
|
||||
elif target != ISSUER_TARGET:
|
||||
_error("INSTALL_TARGET_INVALID")
|
||||
if principal is not None:
|
||||
value += f"Environment=AIOS_C5_PRINCIPAL_SCOPE_CONFIG={principal}\n"
|
||||
return value.encode("utf-8")
|
||||
|
||||
|
||||
def _captured_rollback(
|
||||
snapshot: Mapping[str, Any], existing: Mapping[str, Any], target: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the captured pre-activation readiness projection when present.
|
||||
|
||||
The live capture contains richer profile metadata around ``existing``;
|
||||
only the installer readiness object is carried into the request. Keep it
|
||||
byte-for-byte as captured after checking the fields that bind it to the
|
||||
selected existing snapshot.
|
||||
"""
|
||||
value = snapshot.get("rollback_readiness")
|
||||
if not isinstance(value, Mapping):
|
||||
if "existing" in snapshot:
|
||||
_error("ROLLBACK_READINESS_MISSING")
|
||||
return None
|
||||
required = {
|
||||
"url", "timeout_seconds", "unit_active", "pythonpath", "activation_file",
|
||||
"principal_scope_config", "module_path", "module_sha256", "runtime_fields",
|
||||
}
|
||||
if set(value) != required or not isinstance(value["runtime_fields"], Mapping):
|
||||
_error("ROLLBACK_READINESS_INVALID")
|
||||
profile_url = RUNTIME_URL if target == BRIDGE_TARGET else ISSUER_RUNTIME_URL
|
||||
if (
|
||||
value["url"] != profile_url or value["timeout_seconds"] != DEFAULT_TIMEOUT
|
||||
or value["unit_active"] is not True
|
||||
or value["pythonpath"] != existing["pythonpath"]
|
||||
or value["activation_file"] != existing["activation_file"]
|
||||
or value["principal_scope_config"] != existing["principal_scope_config"]
|
||||
):
|
||||
_error("ROLLBACK_READINESS_BINDING_INVALID")
|
||||
old = _old_module(existing, target)
|
||||
if value["module_path"] != old["path"] or value["module_sha256"] != old["sha256"]:
|
||||
_error("ROLLBACK_READINESS_BINDING_INVALID")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _request(
|
||||
*, source_commit: str, support_commit: str, files: list[ArtifactFile], existing: Mapping[str, Any],
|
||||
root: str, activation: Mapping[str, Any], owner_signer_id: str, contract_id: str,
|
||||
nonce: str, ttl_seconds: int, issued_at: int, principal_path: str | None,
|
||||
target: str = BRIDGE_TARGET, rollback_readiness: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
module_name = (
|
||||
"aios_chatgpt_bridge_v2_server.py"
|
||||
if target == BRIDGE_TARGET else "aios_task_principal_issuer_server.py"
|
||||
)
|
||||
module = next((file for file in files if file.path == f"{root}/app/{module_name}"), None)
|
||||
if module is None:
|
||||
_error("TARGET_MODULE_MISSING")
|
||||
old_module = _old_module(existing, target)
|
||||
new_pythonpath = [f"{root}/app", *existing["pythonpath"]]
|
||||
new_module = f"{root}/app/{module_name}"
|
||||
new_activation = f"{root}/source-activation.json" if target == BRIDGE_TARGET else None
|
||||
new_principal = principal_path
|
||||
if target == BRIDGE_TARGET:
|
||||
mandatory = {"status": "active", "health": 200, "deployment_state": "STAGING", "repository": REPOSITORY,
|
||||
"governed_writes_enabled": True}
|
||||
runtime_url = RUNTIME_URL
|
||||
elif target == ISSUER_TARGET:
|
||||
mandatory = {"ready": True}
|
||||
if principal_path is not None:
|
||||
mandatory.update(c5_scope_enabled=True)
|
||||
runtime_url = ISSUER_RUNTIME_URL
|
||||
else:
|
||||
_error("INSTALL_TARGET_INVALID")
|
||||
readiness_fields = {
|
||||
**mandatory, "source_module_path": new_module,
|
||||
"source_module_sha256": _sha(module.data),
|
||||
}
|
||||
old_fields = {"status": "active", "health": 200, "deployment_state": "STAGING", "repository": REPOSITORY} if target == BRIDGE_TARGET else {"ready": True}
|
||||
if target == ISSUER_TARGET and principal_path is not None:
|
||||
readiness_fields.update(c5_scope_role="issuer", c5_scope_enrolled=False)
|
||||
readiness = {
|
||||
"url": runtime_url, "timeout_seconds": DEFAULT_TIMEOUT, "unit_active": True,
|
||||
"pythonpath": new_pythonpath, "activation_file": new_activation,
|
||||
"principal_scope_config": new_principal,
|
||||
"module_path": new_module, "module_sha256": _sha(module.data),
|
||||
"runtime_fields": readiness_fields,
|
||||
}
|
||||
rollback = dict(rollback_readiness) if rollback_readiness is not None else {
|
||||
"url": runtime_url, "timeout_seconds": DEFAULT_TIMEOUT, "unit_active": True,
|
||||
"pythonpath": list(existing["pythonpath"]), "activation_file": existing["activation_file"],
|
||||
"principal_scope_config": existing["principal_scope_config"],
|
||||
"module_path": old_module["path"], "module_sha256": old_module["sha256"],
|
||||
"runtime_fields": old_fields,
|
||||
}
|
||||
return {
|
||||
"schema": REQUEST_SCHEMA,
|
||||
"target": target,
|
||||
"contract_id": contract_id, "owner_signer_id": owner_signer_id,
|
||||
"source_commit": source_commit, "support_commit": support_commit,
|
||||
"files": [file.record for file in sorted(files, key=lambda item: item.path)],
|
||||
"existing": dict(existing), "readiness": readiness,
|
||||
"rollback_readiness": rollback, "nonce": nonce, "ttl_seconds": ttl_seconds,
|
||||
}
|
||||
|
||||
|
||||
def build_artifact(
|
||||
repo: Path, support_commit: str, overlay_app: Path, authority_inputs: Path,
|
||||
snapshot: Path, output: Path, *, owner_signer_id: str = "local-owner-authority",
|
||||
contract_id: str | None = None, nonce: str | None = None, ttl_seconds: int = MAX_TTL,
|
||||
issued_at: int | None = None, principal_scope_config: Path | None = None,
|
||||
target: str = BRIDGE_TARGET,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the artifact and unsigned request, returning their paths and hashes."""
|
||||
repo, overlay_app, authority_inputs, snapshot, output = map(Path, (repo, overlay_app, authority_inputs, snapshot, output))
|
||||
if not repo.is_dir() or repo.is_symlink():
|
||||
_error("REPOSITORY_INVALID")
|
||||
if target not in {BRIDGE_TARGET, ISSUER_TARGET}:
|
||||
_error("INSTALL_TARGET_INVALID")
|
||||
support_commit = _support_oid(repo, support_commit)
|
||||
if not _ID.fullmatch(owner_signer_id):
|
||||
_error("OWNER_SIGNER_ID_INVALID")
|
||||
if type(ttl_seconds) is not int or not 1 <= ttl_seconds <= MAX_TTL:
|
||||
_error("TTL_INVALID")
|
||||
nonce = secrets.token_hex(32) if nonce is None else nonce
|
||||
if not isinstance(nonce, str) or not _NONCE.fullmatch(nonce):
|
||||
_error("NONCE_INVALID")
|
||||
issued_at = int(time.time()) if issued_at is None else issued_at
|
||||
if type(issued_at) is not int or issued_at < 0:
|
||||
_error("ISSUED_AT_INVALID")
|
||||
if principal_scope_config is not None and (principal_scope_config.is_symlink() or not principal_scope_config.is_file()):
|
||||
_error("PRINCIPAL_CONFIG_INVALID")
|
||||
existing, snapshot_value = _read_existing(snapshot, target)
|
||||
authority_files, source_commit, activation = _authority_inputs(authority_inputs, support_commit)
|
||||
overlay_files = _verify_overlays(overlay_app, repo, support_commit)
|
||||
rewritten_overlays: list[ArtifactFile] = []
|
||||
for item in overlay_files:
|
||||
if item.path == "app/c5_governed_principal_scope.py":
|
||||
rewritten_overlays.append(
|
||||
ArtifactFile(item.path, _rewrite_c5_imports(item.data, item.path), item.mode, item.kind)
|
||||
)
|
||||
else:
|
||||
rewritten_overlays.append(item)
|
||||
overlay_files = rewritten_overlays
|
||||
root = _root(support_commit)
|
||||
c5_files: list[ArtifactFile] = []
|
||||
for source_path, destination in sorted(SOURCE_MODULES.items()):
|
||||
data = _git_blob(repo, support_commit, source_path)
|
||||
if destination in {"app/source_publication_runtime.py", "app/governed_source_publication_policy.py"}:
|
||||
data = _rewrite_c5_imports(data, destination)
|
||||
c5_files.append(ArtifactFile(f"{root}/{destination}", data, 0o644))
|
||||
files: list[ArtifactFile] = []
|
||||
files.extend(authority_files)
|
||||
files.extend(ArtifactFile(f"{root}/{item.path}", item.data, item.mode) for item in overlay_files)
|
||||
files.extend(c5_files)
|
||||
principal_path: str | None = next(
|
||||
(item.path for item in files if item.path == f"{root}/principal-activation.json"),
|
||||
None,
|
||||
)
|
||||
if principal_scope_config is not None:
|
||||
data = _regular(principal_scope_config, label="principal-scope-config")
|
||||
principal_path = f"{root}/principal-activation.json"
|
||||
if any(item.path == principal_path for item in files):
|
||||
_error("PRINCIPAL_CONFIG_DUPLICATE")
|
||||
files.append(ArtifactFile(principal_path, data, 0o644))
|
||||
if target == ISSUER_TARGET and principal_path is None:
|
||||
_error("PRINCIPAL_CONFIG_REQUIRED")
|
||||
dropin_path = DROPIN if target == BRIDGE_TARGET else ISSUER_DROPIN
|
||||
dropin_data = _dropin(root, existing["pythonpath"], principal_path, target=target)
|
||||
files.append(ArtifactFile(dropin_path, dropin_data, 0o644, "systemd_dropin"))
|
||||
names = [item.path for item in files]
|
||||
if len(names) != len(set(names)):
|
||||
_error("ARTIFACT_DUPLICATE_PATH")
|
||||
if not contract_id:
|
||||
contract_id = f"c5-installation-{support_commit[:12]}-{nonce[:12]}"
|
||||
if not _ID.fullmatch(contract_id):
|
||||
_error("CONTRACT_ID_INVALID")
|
||||
request = _request(
|
||||
source_commit=source_commit, support_commit=support_commit, files=files,
|
||||
existing=existing, root=root, activation=activation,
|
||||
owner_signer_id=owner_signer_id, contract_id=contract_id,
|
||||
nonce=nonce, ttl_seconds=ttl_seconds, issued_at=issued_at,
|
||||
principal_path=principal_path, target=target,
|
||||
rollback_readiness=_captured_rollback(snapshot_value, existing, target),
|
||||
)
|
||||
manifest = {
|
||||
"schema": "aios.c5-staging-artifact-manifest.v1",
|
||||
"repository": REPOSITORY, "source_commit": source_commit,
|
||||
"support_commit": support_commit, "release_root": root,
|
||||
"target": target,
|
||||
"unit": UNIT if target == BRIDGE_TARGET else ISSUER_UNIT,
|
||||
"listen": {"host": "127.0.0.1", "port": PORT if target == BRIDGE_TARGET else ISSUER_PORT},
|
||||
"files": [item.record for item in sorted(files, key=lambda item: item.path)],
|
||||
}
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
suffix = "" if target == BRIDGE_TARGET else "-principal-issuer"
|
||||
artifact_path = output / f"c5-source-{support_commit}{suffix}.tar"
|
||||
request_path = output / ("installation-request.json" if target == BRIDGE_TARGET else "principal-issuer-installation-request.json")
|
||||
manifest_path = output / "artifact-manifest.json"
|
||||
sums_path = output / "SHA256SUMS"
|
||||
if any(path.exists() for path in (artifact_path, request_path, manifest_path, sums_path)):
|
||||
_error("OUTPUT_ALREADY_EXISTS")
|
||||
with tarfile.open(artifact_path, "w", format=tarfile.PAX_FORMAT) as archive:
|
||||
for item in sorted(files, key=lambda value: value.path):
|
||||
member_name = "dropin.conf" if item.path == dropin_path else item.path.removeprefix(root + "/")
|
||||
info = tarfile.TarInfo(member_name)
|
||||
info.type = tarfile.REGTYPE
|
||||
info.mode = item.mode
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = ""
|
||||
info.mtime = 0
|
||||
info.size = len(item.data)
|
||||
archive.addfile(info, io.BytesIO(item.data))
|
||||
manifest_path.write_bytes(_canonical(manifest))
|
||||
request_path.write_bytes(_canonical(request))
|
||||
sums = "".join(
|
||||
f"{_sha(path.read_bytes())} {path.name}\n"
|
||||
for path in (artifact_path, manifest_path, request_path)
|
||||
).encode("ascii")
|
||||
sums_path.write_bytes(sums)
|
||||
return {
|
||||
"schema": "aios.c5-staging-artifact-build-result.v1",
|
||||
"status": "BUILT_UNSIGNED",
|
||||
"artifact": str(artifact_path), "artifact_sha256": _sha(artifact_path.read_bytes()),
|
||||
"request": str(request_path), "request_sha256": _sha(request_path.read_bytes()),
|
||||
"manifest": str(manifest_path), "sha256_manifest": str(sums_path),
|
||||
"source_commit": source_commit, "support_commit": support_commit,
|
||||
"release_root": root, "signed": False, "installed": False,
|
||||
}
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo", "--repository", dest="repo", type=Path, required=True)
|
||||
parser.add_argument("--support-commit", required=True)
|
||||
parser.add_argument("--overlay-app", "--overlay", dest="overlay_app", type=Path, required=True)
|
||||
parser.add_argument("--authority-inputs", "--authority-input-dir", dest="authority_inputs", type=Path, required=True)
|
||||
parser.add_argument("--snapshot", "--unit-snapshot", dest="snapshot", type=Path, required=True)
|
||||
parser.add_argument("--output", "--output-dir", dest="output", type=Path, required=True)
|
||||
parser.add_argument("--owner-signer-id", default="local-owner-authority")
|
||||
parser.add_argument("--contract-id")
|
||||
parser.add_argument("--nonce")
|
||||
parser.add_argument("--ttl-seconds", type=int, default=MAX_TTL)
|
||||
parser.add_argument("--issued-at", type=int)
|
||||
parser.add_argument("--principal-scope-config", type=Path)
|
||||
parser.add_argument("--target", choices=(BRIDGE_TARGET, ISSUER_TARGET), default=BRIDGE_TARGET)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
result = build_artifact(
|
||||
args.repo, args.support_commit, args.overlay_app, args.authority_inputs,
|
||||
args.snapshot, args.output, owner_signer_id=args.owner_signer_id,
|
||||
contract_id=args.contract_id, nonce=args.nonce,
|
||||
ttl_seconds=args.ttl_seconds, issued_at=args.issued_at,
|
||||
principal_scope_config=args.principal_scope_config, target=args.target,
|
||||
)
|
||||
except (BuildError, OSError, ValueError) as exc:
|
||||
print(json.dumps({"status": "REJECTED", "error": str(exc)}, sort_keys=True))
|
||||
return 2
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Issue, preflight, or apply the fixed-scope C5 staging installation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "active-runtime" / "app"))
|
||||
|
||||
from c5_staging_installation import ( # noqa: E402
|
||||
InstallationError,
|
||||
InstallationOutcomeUnknown,
|
||||
Installer,
|
||||
RegistryOwnerVerifier,
|
||||
canonical_bytes,
|
||||
issue_signed_contract,
|
||||
load_installation_request,
|
||||
)
|
||||
from registered_owner_signer_adapter import ( # noqa: E402
|
||||
DEFAULT_REGISTERED_SIGNER_COMMAND,
|
||||
RegisteredOwnerSignerAdapter,
|
||||
)
|
||||
|
||||
DEFAULT_REGISTRY = Path("/etc/aios/single-host-signer-registry.json")
|
||||
|
||||
|
||||
def _write_new(path: Path, value: dict) -> None:
|
||||
data = canonical_bytes(value) + b"\n"
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
fd = os.open(path, flags, 0o600)
|
||||
try:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
view = view[os.write(fd, view) :]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
for name in ("preflight", "apply", "reconcile-unknown", "rollback-applied"):
|
||||
command = commands.add_parser(name)
|
||||
command.add_argument("--contract", type=Path, required=True)
|
||||
command.add_argument("--artifact", type=Path, required=True)
|
||||
command.add_argument("--registry", type=Path, default=DEFAULT_REGISTRY)
|
||||
if name == "rollback-applied":
|
||||
command.add_argument("--issuer-contract", type=Path)
|
||||
command.add_argument("--issuer-artifact", type=Path)
|
||||
issue = commands.add_parser("issue")
|
||||
issue.add_argument("--request", type=Path, required=True)
|
||||
issue.add_argument("--artifact", type=Path, required=True)
|
||||
issue.add_argument("--registry", type=Path, default=DEFAULT_REGISTRY)
|
||||
issue.add_argument("--output", type=Path, required=True)
|
||||
issue.add_argument("--signer-command", type=Path, default=DEFAULT_REGISTERED_SIGNER_COMMAND)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
if args.command == "issue":
|
||||
request = load_installation_request(args.request)
|
||||
verifier = RegistryOwnerVerifier(
|
||||
args.registry, owner_signer_id=request.owner_signer_id
|
||||
)
|
||||
signer = RegisteredOwnerSignerAdapter(
|
||||
args.signer_command,
|
||||
key_id=verifier.record.fingerprint,
|
||||
owner_verifier=verifier,
|
||||
)
|
||||
contract = issue_signed_contract(
|
||||
request, args.artifact, signer, args.registry
|
||||
)
|
||||
_write_new(args.output, contract)
|
||||
result = {
|
||||
"status": "ISSUED",
|
||||
"contract_id": contract["payload"]["contract_id"],
|
||||
"output": str(args.output),
|
||||
}
|
||||
else:
|
||||
installer = Installer(args.registry)
|
||||
if args.command == "apply":
|
||||
result = installer.apply(args.contract, args.artifact)
|
||||
elif args.command == "reconcile-unknown":
|
||||
result = installer.reconcile_unknown(args.contract, args.artifact)
|
||||
elif args.command == "rollback-applied":
|
||||
result = installer.rollback_applied(
|
||||
args.contract, args.artifact,
|
||||
issuer_contract=args.issuer_contract,
|
||||
issuer_artifact=args.issuer_artifact,
|
||||
)
|
||||
else:
|
||||
result = installer.preflight(args.contract, args.artifact)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except InstallationOutcomeUnknown as exc:
|
||||
print(
|
||||
json.dumps({"status": "UNKNOWN", "error": exc.code}, sort_keys=True),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
except (InstallationError, OSError, ValueError, TypeError) as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{"status": "REJECTED", "error": getattr(exc, "code", str(exc))},
|
||||
sort_keys=True,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Isolated composition for the C5 simulation CLI, never a production adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from aios_single_host_authority import (
|
||||
LocalOwnerAuthority, OwnerEpochRequest, RegistryOwnerApprovalVerifier,
|
||||
SingleHostOwnerPolicy, SourcePublicationPolicyRequest,
|
||||
bootstrap_key, canonical_json_bytes, write_signed_epoch_directory,
|
||||
write_signer_registry,
|
||||
)
|
||||
from aios_task_admission_bootstrap import TaskAdmissionBootstrapService, TaskAdmissionPolicy, TaskAdmissionStore
|
||||
from aios_task_admission_live_adapters import (
|
||||
AgentTaskAdmissionPort, BoundOwnerAuthorizationBackend, SignedEpochDirectoryProvider,
|
||||
TaskAdmissionPrincipalProvider, TaskAdmissionRuntimeBundle,
|
||||
)
|
||||
from governed_source_publication_policy import SourcePublicationPolicyVerifier, ScopedSourcePublicationPolicyAdmission
|
||||
from governed_unattended_release import SourcePublicationIntent, GovernedUnattendedReleaseRunner
|
||||
from source_publication_runtime import guard_source_runtime
|
||||
|
||||
|
||||
def _write(path: Path, value: dict) -> str:
|
||||
raw = canonical_json_bytes(value)
|
||||
path.write_bytes(raw)
|
||||
path.chmod(0o600)
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def signed_admission(tmp: Path, output: Path, source: dict, execution: dict, transport_type) -> dict:
|
||||
now = int(time.time())
|
||||
nonce = secrets.token_hex(24)
|
||||
caps = frozenset(source["requested_capabilities"])
|
||||
files = tuple(source["file_allowlist"])
|
||||
# These new keys never leave TemporaryDirectory and are not loaded from any
|
||||
# host registry, credential store or production environment variable.
|
||||
key_root = tmp / "ephemeral-keys"
|
||||
key_root.mkdir(mode=0o700)
|
||||
owner_key = key_root / "owner.pem"
|
||||
machine_key = key_root / "machine.pem"
|
||||
owner_public = bootstrap_key(owner_key, require_root_owner=False)
|
||||
machine_public = bootstrap_key(machine_key, require_root_owner=False)
|
||||
registry = tmp / "simulation-signers.json"
|
||||
write_signer_registry(
|
||||
registry, owner_public_key=owner_public, machine_public_key=machine_public,
|
||||
authority_generation=1, require_root_owner=False,
|
||||
)
|
||||
owner_policy = SingleHostOwnerPolicy(
|
||||
repository=source["repository"], trust_domain="aios.simulation",
|
||||
allowed_branch_prefixes=(source["target_branch"],), allowed_capabilities=caps,
|
||||
allowed_file_prefixes=files, risk_ceiling="engineering",
|
||||
production_scope="none", destructive_policy="forbidden",
|
||||
maximum_epoch_ttl_seconds=3600, maximum_machine_capability_ttl_seconds=300,
|
||||
allowed_caller_uids=(os.getuid(),),
|
||||
)
|
||||
owner_policy.validate()
|
||||
authority = LocalOwnerAuthority(owner_key, owner_policy, require_root_owner=False)
|
||||
request = SourcePublicationPolicyRequest(
|
||||
policy_id="C5_SOURCE_PUBLICATION_ONLY", repository=source["repository"],
|
||||
authority_base_oid=source["authority_base_oid"], source_oid=source["source_commit"],
|
||||
source_tree_oid=source["tree_oid"], target_agent="agent/c5-simulation-controller",
|
||||
target_branch=source["target_branch"], generation=source["generation"],
|
||||
execution_digest=source["execution_digest"], exact_files=files,
|
||||
nonce=nonce, ttl_seconds=3600,
|
||||
)
|
||||
signed_policy = authority.issue_source_publication_policy(request)
|
||||
epoch = authority.issue_epoch(OwnerEpochRequest(
|
||||
epoch_id="c5-simulation-epoch-" + nonce,
|
||||
allowed_branch_prefixes=(source["target_branch"],), allowed_capabilities=caps,
|
||||
allowed_file_prefixes=files, risk_ceiling="engineering",
|
||||
business_goal="Isolated C5 source publication simulation only",
|
||||
production_scope="none", destructive_policy="forbidden", ttl_seconds=3600,
|
||||
))
|
||||
epoch_root = tmp / "epoch"
|
||||
write_signed_epoch_directory(epoch_root, epoch, require_root_owner=False)
|
||||
epoch_revocations = tmp / "epoch-revocations.json"
|
||||
policy_revocations = tmp / "policy-revocations.json"
|
||||
_write(epoch_revocations, {"schema": "aios.owner-delegation-revocations.v1", "revoked_epoch_ids": []})
|
||||
_write(policy_revocations, {"revoked_nonces": []})
|
||||
owner_verifier = RegistryOwnerApprovalVerifier(registry, require_root_owner=False)
|
||||
caller = {"service": "c5-simulation-controller", "trust_domain": "aios.simulation"}
|
||||
provider = SignedEpochDirectoryProvider(
|
||||
root=epoch_root, verifier=owner_verifier, revocation_file=epoch_revocations,
|
||||
expected_caller=caller, require_root_owner=False,
|
||||
)
|
||||
verified_epoch = provider(epoch_id=epoch.payload["epoch_id"], caller=caller)
|
||||
if verified_epoch.schema != "aios.owner-delegation-epoch.v2" or verified_epoch.policy_digest != owner_policy.digest:
|
||||
raise RuntimeError("SIMULATION_EPOCH_PROVENANCE_MISMATCH")
|
||||
authorization = BoundOwnerAuthorizationBackend(
|
||||
authenticated_caller=caller, verified_epoch_provider=provider, signature_verifier=owner_verifier,
|
||||
)
|
||||
verifier = SourcePublicationPolicyVerifier(
|
||||
owner_verifier,
|
||||
revocation_check=lambda payload: payload["nonce"] not in json.loads(policy_revocations.read_text())["revoked_nonces"],
|
||||
expected_policy_digest=owner_policy.digest,
|
||||
)
|
||||
verifier.require(signed_policy, request)
|
||||
transport = transport_type(tmp / "isolated-tasks.sqlite3")
|
||||
port = AgentTaskAdmissionPort(transport, execution_plan_provider=lambda grant: {
|
||||
"external_job_id": "simulation-job-" + nonce,
|
||||
"repository": grant.repository, "target_branch": grant.target_branch,
|
||||
"generation": grant.generation, "execution_digest": grant.execution_digest,
|
||||
"simulation_plan": execution,
|
||||
})
|
||||
bootstrap = TaskAdmissionBootstrapService(
|
||||
store=TaskAdmissionStore(tmp / "isolated-admission.sqlite3"),
|
||||
authorization=authorization, task_port=port,
|
||||
policy=TaskAdmissionPolicy(
|
||||
repository=owner_policy.repository, trust_domain=owner_policy.trust_domain,
|
||||
branch_prefix=source["target_branch"], allowed_capabilities=caps,
|
||||
max_file_count=len(files), max_risk="engineering", policy_digest=owner_policy.digest,
|
||||
),
|
||||
)
|
||||
principal = TaskAdmissionPrincipalProvider(
|
||||
store=bootstrap.store, authorization=authorization, task_port=port,
|
||||
epoch_id=epoch.payload["epoch_id"],
|
||||
)
|
||||
guarded = guard_source_runtime(
|
||||
TaskAdmissionRuntimeBundle(handlers={}, principal_verifier=principal, service=bootstrap),
|
||||
artifact=signed_policy, verifier=verifier,
|
||||
expected_agent_id="agent/c5-simulation-controller",
|
||||
).service
|
||||
guarded.simulation_only = True # this composition contains only the isolated TaskStore
|
||||
intent = SourcePublicationIntent(
|
||||
repository=source["repository"], source_commit=source["source_commit"],
|
||||
source_tree_oid=source["tree_oid"], authority_base_oid=source["authority_base_oid"],
|
||||
target_branch=source["target_branch"], file_allowlist=files,
|
||||
requested_capabilities=tuple(sorted(caps)), risk="engineering",
|
||||
execution_digest=source["execution_digest"], generation=source["generation"],
|
||||
event_id="c5-simulation", version="V2R2C5",
|
||||
)
|
||||
runner = GovernedUnattendedReleaseRunner(admission=guarded)
|
||||
auth_request = {"authorization_kind": "delegation_epoch", "delegation_epoch_id": epoch.payload["epoch_id"]}
|
||||
first = runner.admit(intent, auth_request)
|
||||
replay = runner.admit(intent, auth_request)
|
||||
if first.get("phase") != "ADMITTED" or replay.get("phase") != "ADMITTED":
|
||||
raise RuntimeError("SIMULATION_ADMISSION_FAILED:" + json.dumps({"first": first, "replay": replay}))
|
||||
receipt = first["receipt"]
|
||||
if replay["receipt"].get("idempotent") is not True or transport.creates != 1:
|
||||
raise RuntimeError("SIMULATION_IDEMPOTENCY_FAILED")
|
||||
raw_request = intent.admission_request()
|
||||
try:
|
||||
guarded.create_envelope({**raw_request, "target_branch": "agent/out-of-policy"})
|
||||
except Exception:
|
||||
widening_denied = True
|
||||
else:
|
||||
raise RuntimeError("SIMULATION_SCOPE_WIDENING_WAS_ALLOWED")
|
||||
_write(policy_revocations, {"revoked_nonces": [nonce]})
|
||||
try:
|
||||
guarded.authorize_and_admit({**auth_request, "envelope_id": receipt["envelope_id"]})
|
||||
except Exception:
|
||||
revoked_policy_denied = True
|
||||
else:
|
||||
raise RuntimeError("SIMULATION_REVOKED_POLICY_WAS_ALLOWED")
|
||||
_write(policy_revocations, {"revoked_nonces": []})
|
||||
_write(epoch_revocations, {"schema": "aios.owner-delegation-revocations.v1", "revoked_epoch_ids": [epoch.payload["epoch_id"]]})
|
||||
try:
|
||||
guarded.authorize_and_admit({**auth_request, "envelope_id": receipt["envelope_id"]})
|
||||
except Exception:
|
||||
revoked_epoch_denied = True
|
||||
else:
|
||||
raise RuntimeError("SIMULATION_REVOKED_EPOCH_WAS_ALLOWED")
|
||||
_write(output / "RELEASE_INTENT.simulation.json", runner.intake(intent))
|
||||
receipt_sha = _write(output / "ADMISSION_RECEIPT.simulation.json", {"environment": "SIMULATION", "receipt": receipt})
|
||||
policy_sha = _write(output / "POLICY_RECEIPT.simulation.json", {
|
||||
"environment": "SIMULATION", "payload": dict(signed_policy.payload),
|
||||
"signature": signed_policy.signature, "key_id": signed_policy.key_id,
|
||||
"namespace": signed_policy.namespace, "payload_sha256": signed_policy.payload_sha256,
|
||||
})
|
||||
for name in ("owner-delegation-epoch.canonical.json", "owner-delegation-epoch.sig", "owner-delegation-epoch.key-id"):
|
||||
(output / ("SIMULATION_" + name)).write_bytes((epoch_root / name).read_bytes())
|
||||
(output / "SIMULATION_signer_registry.json").write_bytes(registry.read_bytes())
|
||||
return {
|
||||
"mode": "SIMULATION", "task_id": receipt["task_id"], "task_state": receipt["task_state"],
|
||||
"receipt_sha256": receipt_sha, "policy_receipt_sha256": policy_sha,
|
||||
"epoch_payload_sha256": epoch.payload_sha256, "epoch_schema": verified_epoch.schema,
|
||||
"issuer_key_id": epoch.key_id, "policy_digest": owner_policy.digest,
|
||||
"canonical_signature_verified": True, "idempotent_replay": True,
|
||||
"task_creations": transport.creates, "scope_widening_denied": widening_denied,
|
||||
"revoked_policy_denied": revoked_policy_denied, "revoked_epoch_denied": revoked_epoch_denied,
|
||||
"started_at": now, "ended_at": int(time.time()),
|
||||
"task_lease": None, "task_principal": None, "writer_lease": None,
|
||||
"authority_source_commit": None,
|
||||
}
|
||||
@@ -19,10 +19,17 @@ usage() {
|
||||
|
||||
[[ "$ACTION" =~ ^(create|remove)$ ]] || usage
|
||||
[[ "$AGENT" =~ ^(openclaw|chatgpt|controller)$ ]] || usage
|
||||
[[ "$TASK_ID" =~ ^[A-Za-z0-9._-]+$ ]] || {
|
||||
[[ "$TASK_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || {
|
||||
echo "ERROR: invalid task id" >&2
|
||||
exit 65
|
||||
}
|
||||
# Reject path traversal: no dot-dot, no leading dot, no trailing dot.
|
||||
case "$TASK_ID" in
|
||||
*".."*|.*|*.)
|
||||
echo "ERROR: invalid task id (path traversal rejected)" >&2
|
||||
exit 65
|
||||
;;
|
||||
esac
|
||||
[[ "$(id -un)" == "$RUNTIME_USER" ]] || {
|
||||
echo "ERROR: must run as $RUNTIME_USER" >&2
|
||||
exit 66
|
||||
|
||||
@@ -8,6 +8,16 @@ ZIP_PATH="$(find /data /opt /root /home -name "$ZIP_NAME" 2>/dev/null | head -1
|
||||
if [ -z "$ZIP_PATH" ]; then echo "BLOCKED: missing $ZIP_NAME"; exit 10; fi
|
||||
cp "$ZIP_PATH" "$ROOT/imports/"
|
||||
cd "$WORKSPACE"
|
||||
# zip-slip guard: reject any entry that would escape the workspace on extract.
|
||||
command -v unzip >/dev/null 2>&1 || { echo "BLOCKED: unzip unavailable"; exit 11; }
|
||||
while IFS= read -r entry; do
|
||||
case "$entry" in
|
||||
*/../*|../*|*/..|..|/*|*\\*|*:* )
|
||||
echo "BLOCKED: unsafe zip entry: $entry"
|
||||
exit 11
|
||||
;;
|
||||
esac
|
||||
done < <(unzip -Z1 "$ROOT/imports/$ZIP_NAME" 2>/dev/null || true)
|
||||
unzip -o "$ROOT/imports/$ZIP_NAME"
|
||||
git init
|
||||
git config user.name "AIOS GitOps Bot"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Read-only WP0c authorization inventory."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
import argparse, json, re
|
||||
|
||||
TERMS={
|
||||
"task_principal":re.compile(r"\bTaskPrincipal\b|\btask[_ -]?principal\b",re.I),
|
||||
"owner_approval":re.compile(r"\bowner[_ -]?approval\b|\bOWNER_SIGNATURE_REQUIRED\b",re.I),
|
||||
"capability":re.compile(r"\bcapabilit(?:y|ies)\b",re.I),
|
||||
"signer":re.compile(r"\bsigner\b|\bsshsig\b",re.I),
|
||||
"lease":re.compile(r"\b(?:ClaimLease|WriterLease|writer[_ -]?lease)\b",re.I),
|
||||
"authorization":re.compile(r"\bauthori[sz]ation\b",re.I),
|
||||
}
|
||||
|
||||
def scan(repo:Path)->dict:
|
||||
repo=repo.resolve(); refs=[]
|
||||
for p in sorted(repo.rglob("*")):
|
||||
if not p.is_file() or p.suffix.lower() not in {".py",".json",".yaml",".yml",".md"}: continue
|
||||
if any(x in p.parts for x in {".git",".venv","node_modules","__pycache__"}): continue
|
||||
try: lines=p.read_text(encoding="utf-8",errors="strict").splitlines()
|
||||
except (UnicodeDecodeError,OSError): continue
|
||||
for i,line in enumerate(lines,1):
|
||||
kinds=sorted(k for k,pat in TERMS.items() if pat.search(line))
|
||||
if kinds:
|
||||
refs.append({"path":p.relative_to(repo).as_posix(),"line":i,"kinds":kinds,"excerpt":line.strip()[:240]})
|
||||
return {
|
||||
"schema_version":"aios.authorization-inventory.v1",
|
||||
"references":refs,
|
||||
"reference_count":len(refs),
|
||||
"classification":"EVIDENCE_ONLY_NOT_AUTHORITY",
|
||||
"completeness":"PARTIAL_DISCOVERY",
|
||||
"covered_scope":["lexical_authorization_references"],
|
||||
"missing_scope":[
|
||||
"owner_broker_chain",
|
||||
"taskprincipal_admission_chain",
|
||||
"claim_and_task_lease_chain",
|
||||
"writerlease_chain",
|
||||
"release_recovery_capability_chain",
|
||||
"signer_credential_provisioning_chain",
|
||||
"live_caller_and_revocation_evidence",
|
||||
],
|
||||
"g0_exit_eligible":False,
|
||||
}
|
||||
|
||||
def main()->int:
|
||||
ap=argparse.ArgumentParser(); ap.add_argument("--repo",type=Path,required=True); ap.add_argument("--out",type=Path)
|
||||
ns=ap.parse_args(); result=scan(ns.repo); text=json.dumps(result,indent=2,sort_keys=True)+"\n"
|
||||
if ns.out: ns.out.write_text(text,encoding="utf-8")
|
||||
else: print(text,end="")
|
||||
return 0
|
||||
if __name__=="__main__": raise SystemExit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user