Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bf363143e | ||
|
|
2852c2a403 | ||
|
|
9adec87c4b | ||
|
|
a320ea391e | ||
|
|
6ffd7fe19c |
+3
-10
@@ -4,14 +4,7 @@ title = "AIOS repository secret scan policy"
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Exact non-secret fixtures: Task Admission idempotency test fixture and release-freeze git commit OIDs (generic-api-key false positive on 40-hex git hashes)"
|
||||
description = "Exact non-secret Task Admission test idempotency fixture in the historical PR commit"
|
||||
condition = "AND"
|
||||
paths = [
|
||||
'''tests/test_task_admission_bootstrap_http\.py$''',
|
||||
'''docs/V2R2C2-P0-convergence-20260909\.md$''',
|
||||
'''artifacts/round3-provider-auth/ledger\.json$''',
|
||||
]
|
||||
regexes = [
|
||||
'''idempotency-1''',
|
||||
'''f7f53d0568767d6deec2c47008a89f4d8413f93d''',
|
||||
]
|
||||
paths = ['''tests/test_task_admission_bootstrap_http\.py$''']
|
||||
regexes = ['''idempotency-1''']
|
||||
|
||||
@@ -1,79 +1,40 @@
|
||||
"""API adapter for agent context routing and visibility."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = HTTPException = None
|
||||
APIRouter = None
|
||||
|
||||
from .agent_context_dashboard import AgentContextDashboard
|
||||
from .agent_context_rbac import AgentContextRBAC
|
||||
from .agent_context_resolver import AgentContextResolver
|
||||
from .agent_observability_api import ApiSecurityError, authenticated_principal, set_authenticator
|
||||
|
||||
resolver = AgentContextResolver()
|
||||
rbac = AgentContextRBAC()
|
||||
dashboard = AgentContextDashboard()
|
||||
_AUTHORITY_KEYS = frozenset({"user_id", "group_id", "role", "owner", "owner_policy", "group_policy", "agent_default"})
|
||||
|
||||
|
||||
def resolve_agent_context(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
principal = authenticated_principal()
|
||||
user_id = principal["user_id"]
|
||||
group_id = principal.get("group_id")
|
||||
role = principal["role"]
|
||||
if role == "group_admin" and (not isinstance(group_id, str) or not group_id):
|
||||
raise ApiSecurityError(403, "GROUP_SCOPE_REQUIRED")
|
||||
route = rbac.route(user_id, group_id, role)
|
||||
|
||||
def server_policy(name: str) -> dict[str, Any] | None:
|
||||
value = principal.get(name)
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
raise ApiSecurityError(403, "INVALID_AUTHENTICATED_POLICY")
|
||||
return dict(value)
|
||||
|
||||
def client_overlay(name: str) -> dict[str, Any] | None:
|
||||
value = payload.get(name)
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
raise ApiSecurityError(403, "INVALID_CONTEXT_OVERLAY")
|
||||
return {key: item for key, item in value.items() if key not in _AUTHORITY_KEYS}
|
||||
|
||||
defaults = server_policy("agent_default") or {}
|
||||
defaults.update(user_id=user_id, group_id=group_id or "", role=role)
|
||||
|
||||
route = rbac.route(payload["user_id"], payload.get("group_id"), payload.get("role", "user"))
|
||||
context = resolver.resolve(
|
||||
user_id=user_id,
|
||||
group_id=group_id or "",
|
||||
session=client_overlay("session"),
|
||||
user_profile=client_overlay("user_profile"),
|
||||
group_policy=server_policy("group_policy"),
|
||||
agent_default=defaults,
|
||||
owner_policy=server_policy("owner_policy") if role == "owner" else None,
|
||||
owner=role == "owner",
|
||||
user_id=payload["user_id"],
|
||||
group_id=payload.get("group_id", ""),
|
||||
session=payload.get("session"),
|
||||
user_profile=payload.get("user_profile"),
|
||||
group_policy=payload.get("group_policy"),
|
||||
agent_default=payload.get("agent_default"),
|
||||
owner_policy=payload.get("owner_policy"),
|
||||
owner=payload.get("role") == "owner",
|
||||
)
|
||||
return {"routing": route.to_dict(), "context": context.to_dict()}
|
||||
|
||||
|
||||
def get_context_dashboard() -> dict[str, Any]:
|
||||
authenticated_principal()
|
||||
return dashboard.overview()
|
||||
|
||||
|
||||
def _http(call):
|
||||
try:
|
||||
return call()
|
||||
except ApiSecurityError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.code) from exc
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
@@ -81,15 +42,15 @@ def build_router():
|
||||
|
||||
@router.post("/resolve", operation_id="resolveAgentContext")
|
||||
def resolve(payload: dict[str, Any]):
|
||||
return _http(lambda: resolve_agent_context(payload))
|
||||
return resolve_agent_context(payload)
|
||||
|
||||
@router.get("/overview", operation_id="getAgentContextDashboard")
|
||||
def overview():
|
||||
return _http(get_context_dashboard)
|
||||
return get_context_dashboard()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "set_authenticator", "resolve_agent_context", "get_context_dashboard"]
|
||||
__all__ = ["router", "resolve_agent_context", "get_context_dashboard"]
|
||||
|
||||
@@ -1,129 +1,47 @@
|
||||
"""Authenticated loopback API for the unified Agent Event Bus."""
|
||||
"""Actions API for unified Agent Event Bus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import ipaddress
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
except Exception: # pragma: no cover
|
||||
APIRouter = HTTPException = Request = None
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .agent_event_bus import AgentEventBus
|
||||
from .agent_observability_api import ApiSecurityError, authenticated_principal, set_authenticator
|
||||
|
||||
bus = AgentEventBus()
|
||||
_CREATE_FIELDS = frozenset({"kind", "source", "target", "summary", "payload", "correlation_id"})
|
||||
|
||||
|
||||
def _authorize(client_host: str | None) -> dict[str, Any]:
|
||||
try:
|
||||
address = ipaddress.ip_address(client_host or "")
|
||||
except ValueError as exc:
|
||||
raise ApiSecurityError(403, "LOOPBACK_REQUIRED") from exc
|
||||
if not address.is_loopback:
|
||||
raise ApiSecurityError(403, "LOOPBACK_REQUIRED")
|
||||
return authenticated_principal()
|
||||
def create_agent_event(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.publish(**payload)}
|
||||
|
||||
|
||||
def _event_scope(principal: Mapping[str, Any]) -> tuple[str, frozenset[str]]:
|
||||
tenant_id = principal.get("tenant_id")
|
||||
targets = principal.get("allowed_event_targets")
|
||||
if not isinstance(tenant_id, str) or not tenant_id:
|
||||
raise ApiSecurityError(403, "EVENT_TENANT_SCOPE_REQUIRED")
|
||||
if (not isinstance(targets, (list, tuple, set, frozenset)) or not targets
|
||||
or any(not isinstance(target, str) or not target for target in targets)):
|
||||
raise ApiSecurityError(403, "EVENT_TARGET_SCOPE_REQUIRED")
|
||||
return tenant_id, frozenset(targets)
|
||||
def list_agent_events(kind: str | None = None, status: str = "pending") -> dict[str, Any]:
|
||||
return {"status": "ok", "events": bus.list_events(kind, status)}
|
||||
|
||||
|
||||
def _authorized_event(
|
||||
event: Mapping[str, Any] | None, tenant_id: str, targets: frozenset[str],
|
||||
) -> Mapping[str, Any] | None:
|
||||
if event is None:
|
||||
return None
|
||||
if event.get("tenant_id") != tenant_id or event.get("target") not in targets:
|
||||
raise ApiSecurityError(403, "EVENT_SCOPE_DENIED")
|
||||
return event
|
||||
def get_agent_event(event_id: str) -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.get(event_id)}
|
||||
|
||||
|
||||
def create_agent_event(payload: Mapping[str, Any], *, client_host: str | None = None) -> dict[str, Any]:
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
if set(payload) - _CREATE_FIELDS:
|
||||
raise ApiSecurityError(403, "EVENT_FIELDS_FORBIDDEN")
|
||||
required = ("kind", "target", "summary", "payload")
|
||||
if any(name not in payload for name in required):
|
||||
raise ApiSecurityError(403, "INVALID_EVENT")
|
||||
normalized = {name: payload[name] for name in required}
|
||||
if not isinstance(normalized["target"], str) or normalized["target"] not in targets:
|
||||
raise ApiSecurityError(403, "EVENT_TARGET_SCOPE_DENIED")
|
||||
normalized["source"] = principal["user_id"]
|
||||
normalized["correlation_id"] = payload.get("correlation_id")
|
||||
normalized["tenant_id"] = tenant_id
|
||||
return {"status": "ok", "event": bus.publish(**normalized)}
|
||||
def transition_agent_event(event_id: str, status: str, actor: str = "system") -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.transition(event_id, status, actor)}
|
||||
|
||||
|
||||
def list_agent_events(
|
||||
kind: str | None = None, status: str = "pending", *, client_host: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
return {"status": "ok", "events": bus.list_events(
|
||||
kind, status, tenant_id=tenant_id, targets=targets,
|
||||
)}
|
||||
def agent_event_stats() -> dict[str, Any]:
|
||||
return {"status": "ok", "stats": bus.stats()}
|
||||
|
||||
|
||||
def get_agent_event(event_id: str, *, client_host: str | None = None) -> dict[str, Any]:
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
return {"status": "ok", "event": _authorized_event(bus.get(event_id), tenant_id, targets)}
|
||||
# Compatibility adapters
|
||||
|
||||
def list_gpt_inbox_messages():
|
||||
return list_agent_events(kind="gpt_inbox")
|
||||
|
||||
|
||||
def transition_agent_event(
|
||||
event_id: str, status: str, actor: str = "system", *, client_host: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
del actor
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
_authorized_event(bus.get(event_id), tenant_id, targets)
|
||||
return {"status": "ok", "event": bus.transition(
|
||||
event_id, status, principal["user_id"], expected_tenant_id=tenant_id,
|
||||
expected_targets=targets,
|
||||
)}
|
||||
|
||||
|
||||
def agent_event_stats(*, client_host: str | None = None) -> dict[str, Any]:
|
||||
principal = _authorize(client_host)
|
||||
tenant_id, targets = _event_scope(principal)
|
||||
return {"status": "ok", "stats": bus.stats(tenant_id=tenant_id, targets=targets)}
|
||||
|
||||
|
||||
def list_gpt_inbox_messages(*, client_host: str | None = None):
|
||||
return list_agent_events(kind="gpt_inbox", client_host=client_host)
|
||||
|
||||
|
||||
def ack_gpt_inbox_message(event_id: str, *, client_host: str | None = None):
|
||||
return transition_agent_event(event_id, "completed", client_host=client_host)
|
||||
|
||||
|
||||
def _host(request: Any) -> str | None:
|
||||
client = getattr(request, "client", None)
|
||||
return getattr(client, "host", None)
|
||||
|
||||
|
||||
def _http(call: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
return call()
|
||||
except ApiSecurityError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.code) from exc
|
||||
except ValueError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
def ack_gpt_inbox_message(event_id: str):
|
||||
return transition_agent_event(event_id, "completed", "custom-gpt")
|
||||
|
||||
|
||||
def build_router():
|
||||
@@ -132,32 +50,28 @@ def build_router():
|
||||
router = APIRouter(prefix="/agent-events", tags=["agent-events"])
|
||||
|
||||
@router.post("", operation_id="createAgentEvent")
|
||||
def create(payload: dict[str, Any], request: Request):
|
||||
return _http(lambda: create_agent_event(payload, client_host=_host(request)))
|
||||
def create(payload: dict[str, Any]):
|
||||
return create_agent_event(payload)
|
||||
|
||||
@router.get("", operation_id="listAgentEvents")
|
||||
def listing(request: Request, kind: str | None = None, status: str = "pending"):
|
||||
return _http(lambda: list_agent_events(kind, status, client_host=_host(request)))
|
||||
|
||||
@router.get("/stats/overview", operation_id="getAgentEventStats")
|
||||
def stats(request: Request):
|
||||
return _http(lambda: agent_event_stats(client_host=_host(request)))
|
||||
def listing(kind: str | None = None, status: str = "pending"):
|
||||
return list_agent_events(kind, status)
|
||||
|
||||
@router.get("/{event_id}", operation_id="getAgentEvent")
|
||||
def get(event_id: str, request: Request):
|
||||
return _http(lambda: get_agent_event(event_id, client_host=_host(request)))
|
||||
def get(event_id: str):
|
||||
return get_agent_event(event_id)
|
||||
|
||||
@router.post("/{event_id}/transition", operation_id="transitionAgentEvent")
|
||||
def transition(event_id: str, payload: dict[str, Any], request: Request):
|
||||
return _http(lambda: transition_agent_event(
|
||||
event_id, payload.get("status", ""), payload.get("actor", "system"),
|
||||
client_host=_host(request),
|
||||
))
|
||||
def transition(event_id: str, payload: dict[str, Any]):
|
||||
return transition_agent_event(event_id, payload["status"], payload.get("actor", "system"))
|
||||
|
||||
@router.get("/stats/overview", operation_id="getAgentEventStats")
|
||||
def stats():
|
||||
return agent_event_stats()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
__all__ = [
|
||||
"router", "set_authenticator", "create_agent_event", "list_agent_events",
|
||||
"get_agent_event", "transition_agent_event", "agent_event_stats",
|
||||
]
|
||||
|
||||
__all__ = ["router", "create_agent_event", "list_agent_events", "get_agent_event", "transition_agent_event"]
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""Filesystem-backed Agent Event Bus with strict path contracts."""
|
||||
"""Unified Agent Event Bus.
|
||||
|
||||
Unifies commands, GPT messages, replies, approvals, audit and reports into one
|
||||
state machine. Compatibility layers can filter events by kind instead of keeping
|
||||
separate queues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
@@ -6,12 +12,8 @@ from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
from .security_path_guards import contained_path, safe_segment, write_text_no_follow
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -25,7 +27,6 @@ class AgentEvent:
|
||||
target: str
|
||||
summary: str
|
||||
payload: dict[str, Any]
|
||||
tenant_id: str | None = None
|
||||
status: str = "pending"
|
||||
correlation_id: str | None = None
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
@@ -38,223 +39,87 @@ class AgentEvent:
|
||||
|
||||
class AgentEventBus:
|
||||
STATES = ("pending", "running", "completed", "failed")
|
||||
_DIR_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
|
||||
def __init__(self, root: str = "agents/events"):
|
||||
self.root = Path(root).absolute()
|
||||
if self.root.is_symlink():
|
||||
raise ValueError("symlink event root forbidden")
|
||||
self.root = Path(root)
|
||||
for state in self.STATES:
|
||||
(self.root / state).mkdir(parents=True, exist_ok=True)
|
||||
(self.root / "audit").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def publish(
|
||||
self, kind: str, source: str, target: str, summary: str,
|
||||
payload: dict[str, Any], correlation_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
self,
|
||||
kind: str,
|
||||
source: str,
|
||||
target: str,
|
||||
summary: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not all(isinstance(value, str) and value for value in (kind, source, target, summary)):
|
||||
raise ValueError("event text fields must be nonempty strings")
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("event payload must be an object")
|
||||
if tenant_id is not None and (not isinstance(tenant_id, str) or not tenant_id):
|
||||
raise ValueError("invalid event tenant")
|
||||
identity = json.dumps(
|
||||
{"kind": kind, "source": source, "target": target, "summary": summary,
|
||||
"payload": payload, "correlation_id": correlation_id, "tenant_id": tenant_id},
|
||||
sort_keys=True, ensure_ascii=False, allow_nan=False, separators=(",", ":"),
|
||||
)
|
||||
event_id = "evt_" + sha256(identity.encode("utf-8")).hexdigest()[:16]
|
||||
safe_segment(event_id)
|
||||
event = AgentEvent(
|
||||
event_id, kind, source, target, summary, payload,
|
||||
tenant_id=tenant_id, correlation_id=correlation_id,
|
||||
)
|
||||
event_id = "evt_" + sha256(
|
||||
json.dumps(
|
||||
{"kind": kind, "source": source, "summary": summary, "payload": payload},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
||||
self._write("pending", event.to_dict())
|
||||
self._audit("created", event.to_dict())
|
||||
return event.to_dict()
|
||||
|
||||
def list_events(
|
||||
self, kind: str | None = None, status: str = "pending", limit: int = 100,
|
||||
*, tenant_id: str | None = None, targets: frozenset[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
self._state(status)
|
||||
if not isinstance(limit, int) or isinstance(limit, bool) or limit < 0 or limit > 1000:
|
||||
raise ValueError("invalid event limit")
|
||||
if limit == 0:
|
||||
return []
|
||||
result: list[dict[str, Any]] = []
|
||||
for name in sorted(self._names(status)):
|
||||
if not name.endswith(".json"):
|
||||
continue
|
||||
event_id = safe_segment(name[:-5])
|
||||
event = self._read(status, event_id)
|
||||
def list_events(self, kind: str | None = None, status: str = "pending", limit: int = 100) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for path in sorted((self.root / status).glob("*.json")):
|
||||
event = self._read(path)
|
||||
if kind and event.get("kind") != kind:
|
||||
continue
|
||||
if tenant_id is not None and event.get("tenant_id") != tenant_id:
|
||||
continue
|
||||
if targets is not None and event.get("target") not in targets:
|
||||
continue
|
||||
result.append(event)
|
||||
if len(result) == limit:
|
||||
break
|
||||
return result
|
||||
return result[:limit]
|
||||
|
||||
def get(self, event_id: str) -> dict[str, Any] | None:
|
||||
safe_segment(event_id)
|
||||
for state in self.STATES:
|
||||
if f"{event_id}.json" in self._names(state):
|
||||
return self._read(state, event_id)
|
||||
path = self.root / state / f"{event_id}.json"
|
||||
if path.exists():
|
||||
return self._read(path)
|
||||
return None
|
||||
|
||||
def transition(
|
||||
self, event_id: str, status: str, actor: str | None = None,
|
||||
*, expected_tenant_id: str | None = None,
|
||||
expected_targets: frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
safe_segment(event_id)
|
||||
self._state(status) # validate before any lookup or mutation
|
||||
def transition(self, event_id: str, status: str, actor: str | None = None) -> dict[str, Any]:
|
||||
event = self.get(event_id)
|
||||
if not event:
|
||||
return {"status": "not_found", "id": event_id}
|
||||
if expected_tenant_id is not None and event.get("tenant_id") != expected_tenant_id:
|
||||
raise ValueError("event tenant mismatch")
|
||||
if expected_targets is not None and event.get("target") not in expected_targets:
|
||||
raise ValueError("event target mismatch")
|
||||
old = event.get("status")
|
||||
self._state(old)
|
||||
event.update(status=status, actor=actor, updated_at=utc_now())
|
||||
if status not in self.STATES:
|
||||
return {"status": "invalid_state"}
|
||||
old = event.get("status", "pending")
|
||||
old_path = self.root / old / f"{event_id}.json"
|
||||
event["status"] = status
|
||||
event["actor"] = actor
|
||||
event["updated_at"] = utc_now()
|
||||
self._write(status, event)
|
||||
if old != status:
|
||||
self._unlink(old, event_id)
|
||||
if old_path.exists() and old != status:
|
||||
old_path.unlink()
|
||||
self._audit("transition", event, {"from": old, "to": status})
|
||||
return event
|
||||
|
||||
def stats(
|
||||
self, *, tenant_id: str | None = None, targets: frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if tenant_id is not None:
|
||||
events = {
|
||||
state: len(self.list_events(status=state, limit=1000, tenant_id=tenant_id, targets=targets))
|
||||
for state in self.STATES
|
||||
}
|
||||
audit_events = 0
|
||||
for name in self._names("audit"):
|
||||
if not name.endswith(".json"):
|
||||
continue
|
||||
audit = self._read("audit", safe_segment(name[:-5]))
|
||||
event = audit.get("event") if isinstance(audit, dict) else None
|
||||
if (isinstance(event, dict) and event.get("tenant_id") == tenant_id
|
||||
and (targets is None or event.get("target") in targets)):
|
||||
audit_events += 1
|
||||
return {"events": events, "audit_events": audit_events, "status": "healthy"}
|
||||
def stats(self) -> dict[str, Any]:
|
||||
return {
|
||||
"events": {state: sum(name.endswith(".json") for name in self._names(state)) for state in self.STATES},
|
||||
"audit_events": sum(name.endswith(".json") for name in self._names("audit")),
|
||||
"events": {state: len(list((self.root / state).glob("*.json"))) for state in self.STATES},
|
||||
"audit_events": len(list((self.root / "audit").glob("*.json"))),
|
||||
"status": "healthy",
|
||||
}
|
||||
|
||||
def _state(self, state: Any) -> str:
|
||||
if state not in self.STATES:
|
||||
raise ValueError("invalid event state")
|
||||
return str(state)
|
||||
|
||||
def _ensure_root(self) -> None:
|
||||
if os.name != "posix" or not hasattr(os, "O_NOFOLLOW"):
|
||||
raise OSError("event storage requires POSIX no-follow support")
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
if self.root.is_symlink() or not self.root.is_dir():
|
||||
raise ValueError("invalid event root")
|
||||
|
||||
def _write(self, state: str, data: dict[str, Any]) -> None:
|
||||
if state != "audit":
|
||||
self._state(state)
|
||||
if not isinstance(data.get("id"), str):
|
||||
raise ValueError("event id must be a string")
|
||||
event_id = safe_segment(data["id"])
|
||||
self._ensure_root()
|
||||
write_text_no_follow(
|
||||
self.root, f"{state}/{event_id}.json",
|
||||
json.dumps(data, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
|
||||
def _write(self, state: str, data: dict[str, Any]):
|
||||
(self.root / state / f"{data['id']}.json").write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
def _open_state(self, state: str, *, create: bool = False) -> tuple[int, int] | None:
|
||||
if state != "audit":
|
||||
self._state(state)
|
||||
if not self.root.exists():
|
||||
if not create:
|
||||
return None
|
||||
self._ensure_root()
|
||||
contained_path(self.root, state)
|
||||
root_fd = os.open(self.root, self._DIR_FLAGS)
|
||||
try:
|
||||
try:
|
||||
state_fd = os.open(state, self._DIR_FLAGS, dir_fd=root_fd)
|
||||
except FileNotFoundError:
|
||||
if not create:
|
||||
os.close(root_fd)
|
||||
return None
|
||||
os.mkdir(state, mode=0o750, dir_fd=root_fd)
|
||||
state_fd = os.open(state, self._DIR_FLAGS, dir_fd=root_fd)
|
||||
return root_fd, state_fd
|
||||
except Exception:
|
||||
try:
|
||||
os.close(root_fd)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
def _read(self, path: Path):
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
def _names(self, state: str) -> set[str]:
|
||||
if os.name != "posix" or not hasattr(os, "O_NOFOLLOW"):
|
||||
if not self.root.exists():
|
||||
return set()
|
||||
raise OSError("event storage requires POSIX no-follow support")
|
||||
opened = self._open_state(state)
|
||||
if opened is None:
|
||||
return set()
|
||||
root_fd, state_fd = opened
|
||||
try:
|
||||
return set(os.listdir(state_fd))
|
||||
finally:
|
||||
os.close(state_fd); os.close(root_fd)
|
||||
|
||||
def _read(self, state: str, event_id: str) -> dict[str, Any]:
|
||||
safe_segment(event_id)
|
||||
opened = self._open_state(state)
|
||||
if opened is None:
|
||||
raise FileNotFoundError(event_id)
|
||||
root_fd, state_fd = opened
|
||||
try:
|
||||
fd = os.open(f"{event_id}.json", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=state_fd)
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
||||
os.close(fd)
|
||||
raise ValueError("unsafe event file")
|
||||
with os.fdopen(fd, "r", encoding="utf-8") as stream:
|
||||
value = json.load(stream)
|
||||
if (not isinstance(value, dict) or value.get("id") != event_id
|
||||
or (state != "audit" and value.get("status") != state)):
|
||||
raise ValueError("invalid event record")
|
||||
return value
|
||||
finally:
|
||||
os.close(state_fd); os.close(root_fd)
|
||||
|
||||
def _unlink(self, state: str, event_id: str) -> None:
|
||||
opened = self._open_state(state)
|
||||
if opened is None:
|
||||
return
|
||||
root_fd, state_fd = opened
|
||||
try:
|
||||
info = os.stat(f"{event_id}.json", dir_fd=state_fd, follow_symlinks=False)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
||||
raise ValueError("unsafe event file")
|
||||
os.unlink(f"{event_id}.json", dir_fd=state_fd)
|
||||
os.fsync(state_fd)
|
||||
finally:
|
||||
os.close(state_fd); os.close(root_fd)
|
||||
|
||||
def _audit(self, action: str, event: dict[str, Any], extra: dict[str, Any] | None = None) -> None:
|
||||
safe_segment(action)
|
||||
audit = {"id": f"{safe_segment(event['id'])}-{action}", "action": action,
|
||||
"event": event, "extra": extra or {}, "time": utc_now()}
|
||||
self._write("audit", audit)
|
||||
def _audit(self, action: str, event: dict[str, Any], extra: dict[str, Any] | None = None):
|
||||
data = {"action": action, "event": event, "extra": extra or {}, "time": utc_now()}
|
||||
(self.root / "audit" / f"{event['id']}-{action}.json").write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AgentEvent", "AgentEventBus"]
|
||||
|
||||
@@ -1,178 +1,36 @@
|
||||
"""Authenticated API for agent orchestration observability."""
|
||||
"""API for agent orchestration observability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter, HTTPException
|
||||
except Exception: # pragma: no cover
|
||||
APIRouter = HTTPException = None
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .agent_observability import AgentExecutionRecord
|
||||
from .agent_observability_dashboard import AgentObservabilityDashboard
|
||||
from .agent_observability_store import AgentObservabilityStore
|
||||
|
||||
store: AgentObservabilityStore | None = None
|
||||
dashboard = AgentObservabilityDashboard()
|
||||
_ROLE_WHITELIST = frozenset({"user", "group_admin", "owner"})
|
||||
_RECORD_FIELDS = frozenset(AgentExecutionRecord.__dataclass_fields__)
|
||||
_authenticator: Callable[[], Mapping[str, Any] | None] | None = None
|
||||
store = AgentObservabilityStore()
|
||||
dashboard = AgentObservabilityDashboard(store.load())
|
||||
|
||||
|
||||
class ApiSecurityError(PermissionError):
|
||||
def __init__(self, status_code: int, code: str):
|
||||
super().__init__(code)
|
||||
self.status_code, self.code = status_code, code
|
||||
def record_agent_execution(payload: dict[str, Any]):
|
||||
record = AgentExecutionRecord(**payload)
|
||||
return {"status": "ok", "record": store.append(record)}
|
||||
|
||||
|
||||
def _storage() -> AgentObservabilityStore:
|
||||
global store
|
||||
if store is None:
|
||||
store = AgentObservabilityStore()
|
||||
return store
|
||||
|
||||
|
||||
def set_authenticator(fn: Callable[[], Mapping[str, Any] | None] | None) -> None:
|
||||
"""Install the host authenticator; its result is fetched for every call."""
|
||||
if fn is not None and not callable(fn):
|
||||
raise TypeError("authenticator must be callable or None")
|
||||
global _authenticator
|
||||
_authenticator = fn
|
||||
|
||||
|
||||
def _group_id(principal: Mapping[str, Any]) -> str | None:
|
||||
value = principal.get("group_id")
|
||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def authenticated_principal() -> dict[str, Any]:
|
||||
"""Return a request-scoped copy of the server principal, or fail closed."""
|
||||
if _authenticator is None:
|
||||
raise ApiSecurityError(401, "AUTHENTICATION_REQUIRED")
|
||||
try:
|
||||
raw = _authenticator()
|
||||
except ApiSecurityError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ApiSecurityError(401, "AUTHENTICATION_FAILED") from exc
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ApiSecurityError(401, "AUTHENTICATION_REQUIRED")
|
||||
principal = dict(raw)
|
||||
user_id, role = principal.get("user_id"), principal.get("role")
|
||||
if not isinstance(user_id, str) or not user_id.strip() or role not in _ROLE_WHITELIST:
|
||||
raise ApiSecurityError(403, "INVALID_AUTHENTICATED_PRINCIPAL")
|
||||
principal["user_id"] = user_id.strip()
|
||||
if principal.get("group_id") is not None and _group_id(principal) is None:
|
||||
raise ApiSecurityError(403, "INVALID_AUTHENTICATED_PRINCIPAL")
|
||||
if role == "group_admin" and not _group_id(principal):
|
||||
raise ApiSecurityError(403, "GROUP_SCOPE_REQUIRED")
|
||||
return principal
|
||||
|
||||
|
||||
def _scope_set(principal: Mapping[str, Any], name: str) -> frozenset[str]:
|
||||
raw = principal.get(name, ())
|
||||
if not isinstance(raw, (list, tuple, set, frozenset)) or any(
|
||||
not isinstance(item, str) or not item for item in raw
|
||||
):
|
||||
raise ApiSecurityError(403, "INVALID_AUTHENTICATED_SCOPE")
|
||||
return frozenset(raw)
|
||||
|
||||
|
||||
def _owner_user(principal: Mapping[str, Any], requested: str | None) -> str:
|
||||
own, target = str(principal["user_id"]), requested or str(principal["user_id"])
|
||||
if target != own and target not in _scope_set(principal, "allowed_user_ids"):
|
||||
raise ApiSecurityError(403, "OWNER_USER_SCOPE_DENIED")
|
||||
return target
|
||||
|
||||
|
||||
def _owner_group(principal: Mapping[str, Any], requested: str | None) -> str | None:
|
||||
if requested is None:
|
||||
return None
|
||||
if requested != _group_id(principal) and requested not in _scope_set(principal, "allowed_group_ids"):
|
||||
raise ApiSecurityError(403, "OWNER_GROUP_SCOPE_DENIED")
|
||||
return requested
|
||||
|
||||
|
||||
def _summary(records: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
count = len(records)
|
||||
hermes = sum(1 for row in records if row.get("used_hermes"))
|
||||
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)
|
||||
return {
|
||||
"total_executions": count,
|
||||
"openclaw_independent_loops": count - hermes,
|
||||
"hermes_orchestrated_loops": hermes,
|
||||
"average_duration_ms": (
|
||||
sum(int(row.get("duration_ms") or 0) for row in records) / count if count else 0
|
||||
),
|
||||
"token_usage": {
|
||||
"input": sum(int(row.get("input_tokens") or 0) for row in records),
|
||||
"output": sum(int(row.get("output_tokens") or 0) for row in records),
|
||||
"total": sum(int(row.get("total_tokens") or 0) for row in records),
|
||||
},
|
||||
"status": "ok",
|
||||
"records": records,
|
||||
"summary": store.load().summary(user_id if role != "owner" else None),
|
||||
}
|
||||
|
||||
|
||||
def record_agent_execution(payload: Mapping[str, Any]):
|
||||
principal = authenticated_principal()
|
||||
if set(payload) - _RECORD_FIELDS:
|
||||
raise ApiSecurityError(403, "EXECUTION_FIELDS_FORBIDDEN")
|
||||
normalized, role = dict(payload), principal["role"]
|
||||
if role == "owner":
|
||||
requested_user = payload.get("user_id")
|
||||
if requested_user is not None and not isinstance(requested_user, str):
|
||||
raise ApiSecurityError(403, "INVALID_EXECUTION_SCOPE")
|
||||
target_user = _owner_user(principal, requested_user)
|
||||
requested_scope = payload.get("visible_scope")
|
||||
if requested_scope is not None and not isinstance(requested_scope, str):
|
||||
raise ApiSecurityError(403, "INVALID_EXECUTION_SCOPE")
|
||||
allowed_group = _owner_group(principal, requested_scope) if requested_scope and requested_scope != target_user else None
|
||||
normalized["user_id"], normalized["visible_scope"] = target_user, allowed_group or target_user
|
||||
else:
|
||||
normalized["user_id"] = principal["user_id"]
|
||||
normalized["visible_scope"] = _group_id(principal) or principal["user_id"]
|
||||
try:
|
||||
record = AgentExecutionRecord(**normalized)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ApiSecurityError(403, "INVALID_EXECUTION_RECORD") from exc
|
||||
return {"status": "ok", "record": _storage().append(record)}
|
||||
|
||||
|
||||
def get_agent_observability(
|
||||
user_id: str | None = None, group_id: str | None = None,
|
||||
role: str = "user",
|
||||
):
|
||||
del role # wire compatibility only; server principal is authoritative
|
||||
principal, pr_role = authenticated_principal(), None
|
||||
pr_role = principal["role"]
|
||||
if pr_role == "user":
|
||||
if user_id not in (None, principal["user_id"]) or group_id is not None:
|
||||
raise ApiSecurityError(403, "TENANT_SCOPE_DENIED")
|
||||
q_user, q_group, q_role = principal["user_id"], None, "user"
|
||||
elif pr_role == "group_admin":
|
||||
own_group = _group_id(principal)
|
||||
if group_id not in (None, own_group) or user_id not in (None, principal["user_id"]):
|
||||
raise ApiSecurityError(403, "TENANT_SCOPE_DENIED")
|
||||
q_user, q_group, q_role = principal["user_id"], own_group, "group_admin"
|
||||
elif user_id is None and group_id is None:
|
||||
q_user, q_group, q_role = principal["user_id"], None, "user"
|
||||
elif group_id is not None:
|
||||
q_user = _owner_user(principal, user_id) if user_id is not None else principal["user_id"]
|
||||
q_group, q_role = _owner_group(principal, group_id), "group_admin"
|
||||
else:
|
||||
q_user, q_group, q_role = _owner_user(principal, user_id), None, "user"
|
||||
records = _storage().query(q_user, q_group, q_role)
|
||||
return {"status": "ok", "records": records, "summary": _summary(records)}
|
||||
|
||||
|
||||
def _http(call: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
return call()
|
||||
except ApiSecurityError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.code) from exc
|
||||
|
||||
|
||||
def build_router():
|
||||
if APIRouter is None:
|
||||
return None
|
||||
@@ -180,13 +38,15 @@ def build_router():
|
||||
|
||||
@router.post("/execution", operation_id="recordAgentExecution")
|
||||
def record(payload: dict[str, Any]):
|
||||
return _http(lambda: record_agent_execution(payload))
|
||||
return record_agent_execution(payload)
|
||||
|
||||
@router.get("/overview", operation_id="getAgentObservabilityOverview")
|
||||
def overview(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||
return _http(lambda: get_agent_observability(user_id, group_id, role))
|
||||
return get_agent_observability(user_id, group_id, role)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
__all__ = ["ApiSecurityError", "authenticated_principal", "set_authenticator", "router", "record_agent_execution", "get_agent_observability"]
|
||||
|
||||
__all__ = ["router", "record_agent_execution", "get_agent_observability"]
|
||||
|
||||
@@ -8,15 +8,11 @@ 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"):
|
||||
@@ -25,17 +21,9 @@ class AgentObservabilityStore:
|
||||
self.records_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
|
||||
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}")
|
||||
path = self.records_dir / f"{record.execution_id}.json"
|
||||
data = record.to_dict()
|
||||
target.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return data
|
||||
|
||||
def load(self) -> AgentObservability:
|
||||
|
||||
@@ -1,940 +0,0 @@
|
||||
#!/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 (
|
||||
CAPABILITY_VOCABULARY_DIGEST,
|
||||
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,
|
||||
capability_vocabulary_digest TEXT,
|
||||
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,
|
||||
capability_vocabulary_digest TEXT,
|
||||
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);
|
||||
"""
|
||||
)
|
||||
self._ensure_additive_column(
|
||||
connection, "agents", "capability_vocabulary_digest", "TEXT"
|
||||
)
|
||||
self._ensure_additive_column(
|
||||
connection, "tasks", "capability_vocabulary_digest", "TEXT"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_additive_column(
|
||||
connection: sqlite3.Connection, table: str, column: str, definition: str
|
||||
) -> None:
|
||||
"""Add C6 handshake storage without rewriting historical task rows."""
|
||||
columns = {
|
||||
str(row["name"])
|
||||
for row in connection.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
}
|
||||
if column not in columns:
|
||||
connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_capability_vocabulary_digest(value: object | None) -> str | None:
|
||||
"""Validate an opt-in formal vocabulary handshake.
|
||||
|
||||
An omitted digest is the explicit C5-compatible legacy mode. A
|
||||
supplied digest has no downgrade path: it must name this exact formal
|
||||
vocabulary before any durable state is changed.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or value.strip().lower() != CAPABILITY_VOCABULARY_DIGEST:
|
||||
raise TaskProtocolError("CAPABILITY_VOCABULARY_MISMATCH", 409)
|
||||
return CAPABILITY_VOCABULARY_DIGEST
|
||||
|
||||
@staticmethod
|
||||
def _negotiated_capability_vocabulary_matches(
|
||||
*,
|
||||
agent_digest: str | None,
|
||||
task_digest: str | None,
|
||||
requested_digest: str | None,
|
||||
) -> bool:
|
||||
"""Match only an explicitly negotiated formal task vocabulary.
|
||||
|
||||
Historical agents and tasks carry ``NULL`` and keep their C5 behavior.
|
||||
A task admitted with a digest is never offered to or claimed by an
|
||||
agent that did not register the same current vocabulary.
|
||||
"""
|
||||
if task_digest is None:
|
||||
return True
|
||||
return (
|
||||
task_digest == CAPABILITY_VOCABULARY_DIGEST
|
||||
and agent_digest == task_digest
|
||||
and (requested_digest is None or requested_digest == task_digest)
|
||||
)
|
||||
|
||||
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"]),
|
||||
"capability_vocabulary_digest": row["capability_vocabulary_digest"],
|
||||
"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,
|
||||
capability_vocabulary_digest: str | 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")
|
||||
vocabulary_digest = self._normalize_capability_vocabulary_digest(
|
||||
capability_vocabulary_digest
|
||||
)
|
||||
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,"
|
||||
"capability_vocabulary_digest,metadata_json,token_hash,status,created_at,last_seen_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
agent_id,
|
||||
agent_type,
|
||||
agent_version,
|
||||
canonical_json(caps),
|
||||
vocabulary_digest,
|
||||
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,
|
||||
"capability_vocabulary_digest": vocabulary_digest,
|
||||
"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"]),
|
||||
"capability_vocabulary_digest": row["capability_vocabulary_digest"],
|
||||
"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,
|
||||
capability_vocabulary_digest: str | 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)
|
||||
vocabulary_digest = self._normalize_capability_vocabulary_digest(
|
||||
capability_vocabulary_digest
|
||||
)
|
||||
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,
|
||||
"capability_vocabulary_digest": vocabulary_digest,
|
||||
"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,capability_vocabulary_digest,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),
|
||||
vocabulary_digest,
|
||||
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,
|
||||
capability_vocabulary_digest: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not 1 <= int(limit) <= 100:
|
||||
raise TaskProtocolError("LIMIT_INVALID", 400)
|
||||
requested_digest = self._normalize_capability_vocabulary_digest(
|
||||
capability_vocabulary_digest
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
agent = connection.execute(
|
||||
"SELECT capabilities_json,capability_vocabulary_digest FROM agents "
|
||||
"WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if agent is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
self._expire_locked(connection)
|
||||
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 (
|
||||
self._negotiated_capability_vocabulary_matches(
|
||||
agent_digest=agent["capability_vocabulary_digest"],
|
||||
task_digest=row["capability_vocabulary_digest"],
|
||||
requested_digest=requested_digest,
|
||||
)
|
||||
and 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,
|
||||
capability_vocabulary_digest: str | None = None,
|
||||
) -> 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)
|
||||
requested_digest = self._normalize_capability_vocabulary_digest(
|
||||
capability_vocabulary_digest
|
||||
)
|
||||
lease_token = "lease_" + secrets.token_urlsafe(32)
|
||||
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)
|
||||
agent = connection.execute(
|
||||
"SELECT capabilities_json,capability_vocabulary_digest FROM agents "
|
||||
"WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if agent is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
if not self._negotiated_capability_vocabulary_matches(
|
||||
agent_digest=agent["capability_vocabulary_digest"],
|
||||
task_digest=row["capability_vocabulary_digest"],
|
||||
requested_digest=requested_digest,
|
||||
):
|
||||
raise TaskProtocolError("CAPABILITY_VOCABULARY_MISMATCH", 409)
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row["status"] != "QUEUED":
|
||||
raise TaskProtocolError("TASK_NOT_CLAIMABLE", 409)
|
||||
capabilities = set(json.loads(agent["capabilities_json"]))
|
||||
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, replace
|
||||
from dataclasses import dataclass
|
||||
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,EvidenceEnvelope
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget
|
||||
from task_plane.errors import TaskPlaneHTTPError
|
||||
from aios_gitops_job_controller import GitOpsJobController,JobStore,JobState,JobError
|
||||
from .evidence import ReceiptSigner,canonical,digest
|
||||
@@ -69,7 +69,6 @@ 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'}:
|
||||
@@ -380,39 +379,11 @@ class BaselineService:
|
||||
|
||||
def _close_task(self,task,claim,envelope):
|
||||
if not self._receipt_valid(task,envelope): raise ServiceError('RESULT_ATTESTATION_INVALID',403)
|
||||
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
|
||||
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)])
|
||||
|
||||
def cancel_task(self,identity,task_id):
|
||||
self._user(identity)
|
||||
|
||||
@@ -4,8 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any
|
||||
@@ -15,29 +13,6 @@ 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:
|
||||
@@ -106,11 +81,10 @@ 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 = _contained_evidence(Path(item["path"]), base)
|
||||
target = Path(item["path"])
|
||||
if _digest(target) != item["sha256"]:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_DIGEST_MISMATCH")
|
||||
return {
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
"""Canonical fail-closed capability semantics for formal AgentTask eligibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
|
||||
# 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:"
|
||||
CAPABILITY_VOCABULARY_SCHEMA = "aios.agent-task.capability-vocabulary.v1"
|
||||
|
||||
|
||||
def capability_vocabulary_descriptor() -> dict[str, object]:
|
||||
"""Return the immutable inputs to the formal capability vocabulary digest.
|
||||
|
||||
The generic Task Plane has deliberately different wildcard semantics. This
|
||||
descriptor names only the formal AgentTask vocabulary used in this module.
|
||||
"""
|
||||
return {
|
||||
"schema": CAPABILITY_VOCABULARY_SCHEMA,
|
||||
"task_aliases": {
|
||||
key: sorted(value) for key, value in sorted(_TASK_ALIASES.items())
|
||||
},
|
||||
"worker_aliases": {
|
||||
key: sorted(value) for key, value in sorted(_WORKER_ALIASES.items())
|
||||
},
|
||||
"unknown_required_prefix": _UNKNOWN_PREFIX,
|
||||
"unknown_required": "exact-and-deny",
|
||||
"unknown_worker": "never-granted",
|
||||
"wildcard": "formal-deny",
|
||||
}
|
||||
|
||||
|
||||
def capability_vocabulary_digest() -> str:
|
||||
"""Return a deterministic digest for the formal capability vocabulary."""
|
||||
encoded = json.dumps(
|
||||
capability_vocabulary_descriptor(),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
# A constant lets registration, admission, poll, and claim compare one exact
|
||||
# formal vocabulary without importing or interpreting the generic Task Plane.
|
||||
CAPABILITY_VOCABULARY_DIGEST = capability_vocabulary_digest()
|
||||
|
||||
|
||||
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,11 +110,6 @@ 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',
|
||||
@@ -158,10 +153,7 @@ def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
|
||||
def now() -> str: return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
def uid(p='id') -> str: return f'{p}-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}'
|
||||
from security_path_guards import safe_segment, relative_parts, write_text_no_follow
|
||||
|
||||
def safe(s: str, n=160) -> str:
|
||||
return safe_segment(s, max_length=n)
|
||||
def safe(s: str, n=160) -> str: return re.sub(r'[^A-Za-z0-9_./@-]+', '-', str(s or 'unknown')).strip('/-')[:n] or 'unknown'
|
||||
def sh(args, check=False):
|
||||
p = subprocess.run(['git'] + args, cwd=str(WS), text=True, capture_output=True)
|
||||
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git failed')
|
||||
@@ -209,10 +201,7 @@ def relpath(path: str) -> str:
|
||||
if not x or x.startswith('/') or '..' in Path(x).parts or Path(x).parts[0] == '.git': raise ValueError('invalid path')
|
||||
return str(Path(x))
|
||||
def branch_name(name: str | None, prefix='patch/chatgpt') -> str:
|
||||
b = name or f'{prefix}-{uid("br")}'
|
||||
if len(b) > 180: raise ValueError('branch name too long')
|
||||
relative_parts(b)
|
||||
if '..' in b or '@{' in b or b.endswith('.lock'): raise ValueError('invalid branch name')
|
||||
b = safe(name or f'{prefix}-{uid("br")}', 180)
|
||||
if b in ('main','master') or b.startswith('main/') or b.startswith('master/'): raise ValueError('direct main/master writes are forbidden')
|
||||
return b
|
||||
def _json_has_plain_secret(obj: Any) -> bool:
|
||||
@@ -260,12 +249,8 @@ def token_records():
|
||||
else:
|
||||
out.append({'account':'legacy-token','role':'owner','scopes':['*'],'token_plain':line})
|
||||
return out
|
||||
from security_path_guards import require_isolated_dev_profile
|
||||
|
||||
def authenticate(token: str):
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1':
|
||||
require_isolated_dev_profile(HOST)
|
||||
return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1': return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if not token: return None
|
||||
digest=hashlib.sha256(token.encode()).hexdigest()
|
||||
for r in token_records():
|
||||
@@ -294,7 +279,7 @@ def checkout(branch, base='main', create=False):
|
||||
return b
|
||||
def write_file(path, content):
|
||||
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
||||
r=relpath(path); write_text_no_follow(WS, str(r).replace('\\','/'), content); return r
|
||||
r=relpath(path); t=WS/r; t.parent.mkdir(parents=True, exist_ok=True); t.write_text(content, encoding='utf-8'); return r
|
||||
def write_json(path, data): return write_file(path, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
||||
def sync_branch(branch: str) -> dict:
|
||||
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
|
||||
@@ -845,7 +830,7 @@ class H(BaseHTTPRequestHandler):
|
||||
a=self.need('branch:create');
|
||||
if not a: return
|
||||
b=checkout(data.get('branch'), data.get('base','main'), True); meta={'type':'branch','branch':b,'base':data.get('base','main'),'created_by':a['account'],'created_at':now(),'direct_main_push':False,'owner_approval_required':True}
|
||||
r=write_json(f'patches/{safe(b.replace("/","_"))}.branch.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create {b}'); audit(a['account'],'branch',b,meta); self.sendj({'status':'ok','branch':b,'commit':c})
|
||||
r=write_json(f'patches/{safe(b).replace("/","_")}.branch.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create {b}'); audit(a['account'],'branch',b,meta); self.sendj({'status':'ok','branch':b,'commit':c})
|
||||
def file(self,data):
|
||||
a=self.need('file:write');
|
||||
if not a: return
|
||||
@@ -952,7 +937,7 @@ class H(BaseHTTPRequestHandler):
|
||||
if not name or not base: return self.sendj({'error':'provider_name/base_url/model_name required'},400)
|
||||
owner=safe(data.get('username') or a['account']); secret_ref=data.get('api_key_secret_ref') or data.get('secret_ref')
|
||||
if data.get('api_key'):
|
||||
sid=uid('llmkey'); write_text_no_follow(SECRETS, f'llm/{owner}/{sid}.secret', str(data['api_key'])); secret_ref=f'secret://llm/{owner}/{sid}'
|
||||
d=SECRETS/'llm'/owner; d.mkdir(parents=True, exist_ok=True); sid=uid('llmkey'); p=d/f'{sid}.secret'; p.write_text(str(data['api_key']), encoding='utf-8'); os.chmod(p,0o600); secret_ref=f'secret://llm/{owner}/{sid}'
|
||||
if not secret_ref: return self.sendj({'error':'api_key_secret_ref or api_key required'},400)
|
||||
mid=data.get('model_id') or f'custom.{owner}.{hashlib.sha1((prov+base+name).encode()).hexdigest()[:10]}'; b=checkout(data.get('branch') or f'patch/model-{safe(mid)}','main',True)
|
||||
doc=sanitize({k:v for k,v in data.items() if k!='api_key'}); doc.update({'model_id':mid,'provider_name':prov,'base_url':base,'model_name':name,'api_key_secret_ref':secret_ref,'created_by':a['account'],'created_at':now()})
|
||||
@@ -1057,25 +1042,12 @@ 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 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 d.exists():
|
||||
fp=d/cmd_filter
|
||||
if fp.exists():
|
||||
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)
|
||||
@@ -1090,7 +1062,6 @@ def api_spec():
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/repository/tree','GET /api/repository/search','GET /api/repository/diff-index','GET /api/repository/file','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
||||
|
||||
def main():
|
||||
require_isolated_dev_profile(HOST)
|
||||
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
|
||||
@@ -216,7 +216,6 @@ class ControlPlaneReleaseManifest:
|
||||
repository_identity: str
|
||||
authoritative_git_dir: Path
|
||||
deployment_state: str
|
||||
trusted_runtime_identity: Mapping[str, object] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -230,35 +229,6 @@ class ControlPlaneReleaseManifest:
|
||||
"deployment_state": self.deployment_state,
|
||||
}
|
||||
|
||||
def runtime_identity(self) -> dict[str, object]:
|
||||
"""Return only a host-injected, already trusted comparison result."""
|
||||
identity = self.trusted_runtime_identity
|
||||
required = {
|
||||
"status", "declared_identity", "observed_identity", "manifest_digest",
|
||||
"config_digest", "observed_at", "expires_at", "evidence_refs", "reason",
|
||||
"mismatch_fields",
|
||||
}
|
||||
if (
|
||||
not isinstance(identity, Mapping)
|
||||
or identity.get("status") != "PASS"
|
||||
or not required.issubset(identity)
|
||||
or not isinstance(identity.get("declared_identity"), Mapping)
|
||||
or not isinstance(identity.get("observed_identity"), Mapping)
|
||||
or not isinstance(identity.get("evidence_refs"), list)
|
||||
or not identity["evidence_refs"]
|
||||
):
|
||||
return {
|
||||
"status": "UNKNOWN",
|
||||
"declared_identity": None,
|
||||
"observed_identity": None,
|
||||
"observed_at": None,
|
||||
"expires_at": None,
|
||||
"evidence_refs": [],
|
||||
"reason": "TRUSTED_OBSERVATION_UNAVAILABLE",
|
||||
"mismatch_fields": [],
|
||||
}
|
||||
return dict(identity)
|
||||
|
||||
def validate(self, artifact: Path, *, observed_oid: str, approved_root: Path = Path("/opt/aios/gitops-control-plane")) -> dict[str, object]:
|
||||
try:
|
||||
runtime = self.runtime_path.resolve(strict=True)
|
||||
@@ -284,12 +254,7 @@ class ControlPlaneReleaseManifest:
|
||||
)
|
||||
if not valid:
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT")
|
||||
# Artifact and path validation proves only a local declaration. The
|
||||
# composition root may inject the output of an already trusted runtime
|
||||
# comparison, but this manifest must never promote its own checks to
|
||||
# runtime-observation PASS.
|
||||
identity = self.runtime_identity()
|
||||
return {**self.as_dict(), "drift_status": identity["status"], "component_runtime_identity": identity}
|
||||
return {**self.as_dict(), "drift_status": "PASS"}
|
||||
|
||||
|
||||
class BridgeV2Adapter:
|
||||
|
||||
@@ -39,7 +39,6 @@ from aios_governed_task_bootstrap import (
|
||||
)
|
||||
from governed_production_release import build_executor_config
|
||||
from aios_baseline.runtime_contract import evaluate_deployment
|
||||
from component_runtime_identity import evaluate_component_runtime_identity
|
||||
|
||||
MAX_BODY = 2 * 1024 * 1024
|
||||
TaskPrincipalVerifier = Callable[
|
||||
@@ -105,10 +104,6 @@ class RuntimeConfig:
|
||||
health_url: str = "http://127.0.0.1:18927/health"
|
||||
release_plan_path: Path | None = None
|
||||
repository_discovery_cursor_secret: str | bytes | None = None
|
||||
component_manifest_path: Path | None = None
|
||||
component_receipt_path: Path | None = None
|
||||
component_id: str | None = None
|
||||
component_identity_comparison: Callable[[Mapping[str, Any]], Mapping[str, Any] | None] | None = None
|
||||
|
||||
|
||||
def _safe(value: str) -> str:
|
||||
@@ -241,12 +236,6 @@ class GovernedRuntime:
|
||||
blockers.append("LEGACY_GLOBAL_WRITES_MUST_REMAIN_DISABLED")
|
||||
if not task_principal_writes_enabled:
|
||||
blockers.append("TASK_PRINCIPAL_WRITES_DISABLED")
|
||||
identity = evaluate_component_runtime_identity(
|
||||
component_id=self.config.component_id,
|
||||
manifest_path=self.config.component_manifest_path,
|
||||
receipt_path=self.config.component_receipt_path,
|
||||
trusted_comparison=self.config.component_identity_comparison,
|
||||
)
|
||||
return {
|
||||
"status": "active",
|
||||
"health": 200,
|
||||
@@ -257,8 +246,7 @@ class GovernedRuntime:
|
||||
"runtime_path": str(self.config.state_root),
|
||||
"artifact_sha256": os.environ.get("AIOS_CONTROL_PLANE_ARTIFACT_SHA256"),
|
||||
"deployment_state": "STAGING",
|
||||
"drift_status": identity["status"],
|
||||
"component_runtime_identity": identity,
|
||||
"drift_status": "PASS",
|
||||
"repository": self.config.repository,
|
||||
"repository_identity": self.config.repository,
|
||||
"authoritative_git_dir": str(self.config.authoritative_git_dir),
|
||||
@@ -1344,17 +1332,6 @@ def main(
|
||||
"/var/lib/aios-gitops-runtime-anchor/baseline/current.json",
|
||||
)),
|
||||
repository_discovery_cursor_secret=discovery_cursor_secret,
|
||||
component_manifest_path=(
|
||||
Path(os.environ["AIOS_COMPONENT_MANIFEST_PATH"])
|
||||
if os.environ.get("AIOS_COMPONENT_MANIFEST_PATH", "").strip()
|
||||
else None
|
||||
),
|
||||
component_receipt_path=(
|
||||
Path(os.environ["AIOS_COMPONENT_ARTIFACT_RECEIPT_PATH"])
|
||||
if os.environ.get("AIOS_COMPONENT_ARTIFACT_RECEIPT_PATH", "").strip()
|
||||
else None
|
||||
),
|
||||
component_id=os.environ.get("AIOS_COMPONENT_ID", "").strip() or None,
|
||||
)
|
||||
runtime = GovernedRuntime(config)
|
||||
# Prefer a server-side TaskPrincipal provider recovered from the same
|
||||
@@ -1365,39 +1342,21 @@ def main(
|
||||
BridgeHandler.principal_verifier = task_principal_verifier
|
||||
if task_admission_handlers is None:
|
||||
admission_runtime = 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_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,
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_environment,
|
||||
)
|
||||
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 = 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,
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -11,32 +11,10 @@ class RuntimeStatus:
|
||||
read_plane: Mapping[str, bool]
|
||||
write_plane: Mapping[str, bool]
|
||||
release_plane: Mapping[str, bool]
|
||||
required_facts: Mapping[str, frozenset[str]] | None = None
|
||||
|
||||
def _readiness_reason(self) -> str:
|
||||
if self.required_facts is None:
|
||||
return "REQUIRED_FACT_INVENTORY_MISSING"
|
||||
expected_groups = {"READ_PLANE", "WRITE_PLANE", "RELEASE_PLANE"}
|
||||
if set(self.required_facts) != expected_groups:
|
||||
return "REQUIRED_FACT_INVENTORY_INVALID"
|
||||
planes = {
|
||||
"READ_PLANE": self.read_plane,
|
||||
"WRITE_PLANE": self.write_plane,
|
||||
"RELEASE_PLANE": self.release_plane,
|
||||
}
|
||||
for group, facts in self.required_facts.items():
|
||||
if not isinstance(facts, frozenset) or not facts or any(not isinstance(name, str) or not name for name in facts):
|
||||
return "REQUIRED_FACT_INVENTORY_INVALID"
|
||||
plane = planes[group]
|
||||
if set(plane) != set(facts):
|
||||
return "REQUIRED_FACT_INVENTORY_MISMATCH"
|
||||
if any(type(value) is not bool or value is not True for value in plane.values()):
|
||||
return "REQUIRED_FACT_NOT_TRUE"
|
||||
return "READY"
|
||||
|
||||
@property
|
||||
def release_control_plane_ready(self) -> bool:
|
||||
return self._readiness_reason() == "READY"
|
||||
return all(self.read_plane.values()) and all(self.write_plane.values()) and all(self.release_plane.values())
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -44,5 +22,4 @@ class RuntimeStatus:
|
||||
"WRITE_PLANE": dict(self.write_plane),
|
||||
"RELEASE_PLANE": dict(self.release_plane),
|
||||
"release_control_plane_ready": self.release_control_plane_ready,
|
||||
"readiness_reason": self._readiness_reason(),
|
||||
}
|
||||
|
||||
@@ -24,25 +24,25 @@ def _safe_oid(value: str, field: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _safe_branch(value: str) -> str:
|
||||
branch = str(value or "")
|
||||
if (
|
||||
not branch
|
||||
or len(branch) > 240
|
||||
or branch != branch.strip()
|
||||
or branch.startswith(("/", "-", "."))
|
||||
or branch.endswith(("/", ".", ".lock"))
|
||||
or ".." in branch
|
||||
or "//" in branch
|
||||
or "@{" in branch
|
||||
or "\\" in branch
|
||||
or any(part in {"", ".", ".."} for part in branch.split("/"))
|
||||
or any(ch not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._/-" for ch in branch)
|
||||
):
|
||||
raise GiteaTransportError("BRANCH_INVALID")
|
||||
return branch
|
||||
|
||||
|
||||
def _safe_branch(value: str) -> str:
|
||||
branch = str(value or "")
|
||||
if (
|
||||
not branch
|
||||
or len(branch) > 240
|
||||
or branch != branch.strip()
|
||||
or branch.startswith(("/", "-", "."))
|
||||
or branch.endswith(("/", ".", ".lock"))
|
||||
or ".." in branch
|
||||
or "//" in branch
|
||||
or "@{" in branch
|
||||
or "\\" in branch
|
||||
or any(part in {"", ".", ".."} for part in branch.split("/"))
|
||||
or any(ch not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._/-" for ch in branch)
|
||||
):
|
||||
raise GiteaTransportError("BRANCH_INVALID")
|
||||
return branch
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GiteaMachineTransport:
|
||||
api_base: str
|
||||
@@ -110,7 +110,7 @@ class GiteaMachineTransport:
|
||||
return self.repository_info()
|
||||
|
||||
def read_branch(self, branch: str) -> str | None:
|
||||
branch = _safe_branch(branch)
|
||||
branch = _safe_branch(branch)
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
try:
|
||||
value = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/branches/{quote(branch, safe='/')}")[1]
|
||||
@@ -156,99 +156,99 @@ class GiteaMachineTransport:
|
||||
finally:
|
||||
askpass.unlink(missing_ok=True)
|
||||
|
||||
def fetch_branch_exact(self, branch: str, expected_oid: str) -> str:
|
||||
"""Fetch one exact remote branch tip into a quarantine ref.
|
||||
|
||||
This never updates ``main`` or any caller-visible branch. The remote
|
||||
ref is read before and after the fetch so a concurrent branch move
|
||||
cannot be silently admitted.
|
||||
"""
|
||||
|
||||
branch = _safe_branch(branch)
|
||||
expected_oid = _safe_oid(expected_oid, "expected_oid")
|
||||
remote_before = self.read_branch(branch)
|
||||
if remote_before != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MISMATCH")
|
||||
target_ref = f"refs/aios/release-sources/{expected_oid}"
|
||||
|
||||
current = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"{target_ref}^{{commit}}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if current.returncode == 0 and current.stdout.strip().lower() == expected_oid:
|
||||
remote_after = self.read_branch(branch)
|
||||
if remote_after != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MOVED")
|
||||
return expected_oid
|
||||
|
||||
askpass, additions = self._askpass()
|
||||
environment = os.environ.copy()
|
||||
environment.update(additions)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--no-write-fetch-head",
|
||||
self.remote_url,
|
||||
f"refs/heads/{branch}:{target_ref}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise GiteaTransportError("GITEA_SOURCE_FETCH_FAILED")
|
||||
finally:
|
||||
askpass.unlink(missing_ok=True)
|
||||
|
||||
local = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"{target_ref}^{{commit}}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
local_oid = local.stdout.strip().lower()
|
||||
if local.returncode != 0 or local_oid != expected_oid:
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"update-ref",
|
||||
"-d",
|
||||
target_ref,
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
raise GiteaTransportError("GITEA_SOURCE_FETCH_MISMATCH")
|
||||
remote_after = self.read_branch(branch)
|
||||
if remote_after != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MOVED")
|
||||
return expected_oid
|
||||
|
||||
def fetch_branch_exact(self, branch: str, expected_oid: str) -> str:
|
||||
"""Fetch one exact remote branch tip into a quarantine ref.
|
||||
|
||||
This never updates ``main`` or any caller-visible branch. The remote
|
||||
ref is read before and after the fetch so a concurrent branch move
|
||||
cannot be silently admitted.
|
||||
"""
|
||||
|
||||
branch = _safe_branch(branch)
|
||||
expected_oid = _safe_oid(expected_oid, "expected_oid")
|
||||
remote_before = self.read_branch(branch)
|
||||
if remote_before != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MISMATCH")
|
||||
target_ref = f"refs/aios/release-sources/{expected_oid}"
|
||||
|
||||
current = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"{target_ref}^{{commit}}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if current.returncode == 0 and current.stdout.strip().lower() == expected_oid:
|
||||
remote_after = self.read_branch(branch)
|
||||
if remote_after != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MOVED")
|
||||
return expected_oid
|
||||
|
||||
askpass, additions = self._askpass()
|
||||
environment = os.environ.copy()
|
||||
environment.update(additions)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--no-write-fetch-head",
|
||||
self.remote_url,
|
||||
f"refs/heads/{branch}:{target_ref}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise GiteaTransportError("GITEA_SOURCE_FETCH_FAILED")
|
||||
finally:
|
||||
askpass.unlink(missing_ok=True)
|
||||
|
||||
local = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"{target_ref}^{{commit}}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
local_oid = local.stdout.strip().lower()
|
||||
if local.returncode != 0 or local_oid != expected_oid:
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"update-ref",
|
||||
"-d",
|
||||
target_ref,
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
raise GiteaTransportError("GITEA_SOURCE_FETCH_MISMATCH")
|
||||
remote_after = self.read_branch(branch)
|
||||
if remote_after != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MOVED")
|
||||
return expected_oid
|
||||
|
||||
def compare_and_swap_branch(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||
_safe_oid(expected_oid, "expected_oid")
|
||||
_safe_oid(new_oid, "new_oid")
|
||||
|
||||
@@ -664,23 +664,6 @@ 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":
|
||||
@@ -784,12 +767,7 @@ class GitOpsJobController:
|
||||
key = sha256_json({"client_idempotency_key": client_idempotency_key})
|
||||
else:
|
||||
key = sha256_json(binding)
|
||||
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]
|
||||
)
|
||||
job_id = "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)})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html, ipaddress
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -16,25 +16,6 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||
ENTRY = os.getenv('AIOS_ENTRY', 'https://cnnetmkt.top')
|
||||
DEV_ALLOW = os.getenv('AIOS_GITOPS_DEV_ALLOW', '0') == '1'
|
||||
|
||||
def _loopback(value):
|
||||
try: return ipaddress.ip_address(value).is_loopback
|
||||
except (ValueError, TypeError): return False
|
||||
|
||||
def validate_dev_profile():
|
||||
if DEV_ALLOW and not (os.getenv('AIOS_RUNTIME_PROFILE') == 'isolated-staging' and _loopback(HOST)):
|
||||
raise RuntimeError('DEV_ALLOW requires isolated-staging and loopback')
|
||||
|
||||
def runtime_selection(principal, data):
|
||||
if not principal.get('token_ok'): raise PermissionError('authenticated principal required')
|
||||
if any(key in data for key in ('username', 'user_id', 'role', 'tier')):
|
||||
raise ValueError('runtime identity must come from authenticated principal')
|
||||
username=principal['username']; role=principal['role']
|
||||
if role not in _ROLE_WHITELIST or re.fullmatch(r'[A-Za-z0-9_@.-]{1,120}', username) is None or username in ('.','..'):
|
||||
raise ValueError('invalid authenticated principal')
|
||||
tier='ultra' if role=='owner' else 'regular'
|
||||
return username, role, tier
|
||||
|
||||
BASE_MODELS = [
|
||||
{'id':'base.deepseekv4-flash','provider':'one-api','model':'deepseekv4-flash','display':'DeepSeek V4 Flash','tier':'regular','reasoning':1,'vision':0,'tools':1,'context':128000},
|
||||
{'id':'base.qwen3.6-35b','provider':'one-api','model':'qwen3.6-35b','display':'Qwen 3.6 35B','tier':'pro','reasoning':1,'vision':0,'tools':1,'context':262144},
|
||||
@@ -76,39 +57,10 @@ 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:
|
||||
validate_dev_profile()
|
||||
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:
|
||||
validate_dev_profile()
|
||||
return True
|
||||
return token_identity(token) is not None
|
||||
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()]
|
||||
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')
|
||||
@@ -138,10 +90,8 @@ 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','')
|
||||
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}
|
||||
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}
|
||||
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
|
||||
@@ -185,19 +135,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({'error':'not_found','path':path},404)
|
||||
except Exception as e: self.send_json({'error':str(e)},500)
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path == '/api/runtime/select':
|
||||
if not (_loopback(HOST) and _loopback(self.client_address[0])):
|
||||
self.send_json({'error':'loopback ingress required'},403); return
|
||||
a = self.require_write()
|
||||
if not a: return
|
||||
data = parse_body(self)
|
||||
try: runtime_selection(a, data)
|
||||
except (ValueError, PermissionError):
|
||||
self.send_json({'error':'invalid runtime selection identity'},403); return
|
||||
else:
|
||||
data = parse_body(self)
|
||||
init_db()
|
||||
init_db(); path = urllib.parse.urlparse(self.path).path; data = parse_body(self)
|
||||
try:
|
||||
if path == '/api/model/custom/add':
|
||||
a = self.require_write();
|
||||
@@ -251,7 +189,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not m: self.send_json({'error':'model not found'},404); return
|
||||
self.send_json({'status':'ok' if m['base_url'] and m['model_name'] else 'failed','live_test':os.getenv('AIOS_ENABLE_MODEL_LIVE_TEST','0')=='1','model':m['model_name'],'base_url':m['base_url'],'note':'Network live call is disabled unless AIOS_ENABLE_MODEL_LIVE_TEST=1'}); return
|
||||
if path == '/api/runtime/select':
|
||||
username,role,tier=runtime_selection(a, data); risk=data.get('risk','normal')
|
||||
a=self.actor(); username=safe_name(data.get('username') or a['username']); role=data.get('role') or a['role']; tier=data.get('tier') or ('ultra' if role=='owner' else 'regular'); risk=data.get('risk','normal')
|
||||
if risk in ('high_tool','shell','docker','write_prod'): runtime,backend='ephemeral_sandbox','openclaw_executor'
|
||||
elif role in ('owner','admin') or tier=='ultra': runtime,backend='dedicated','hermes'
|
||||
elif tier=='pro': runtime,backend='dedicated_or_shared','hermes'
|
||||
@@ -326,5 +264,5 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as e: self.send_json({'error':str(e)},500)
|
||||
|
||||
def api_spec(): return {'version':'V1R1C0','short_links':['/base','/ver','/diff','/patch','/run','/model','/api'],'gitops_get':['/api/status','/api/base','/api/ver','/api/diff','/api/patch'],'gitops_post':['/api/branch','/api/file','/api/commit','/api/tag','/api/rollback','/api/nl'],'model_get':['/api/model/base','/api/model/custom','/api/model/priority'],'model_post':['/api/model/custom/add','/api/model/custom/update','/api/model/delete','/api/model/priority/set','/api/model/route/resolve','/api/model/test'],'runtime':['/api/runtime/status','/api/runtime/select','/api/runtime/register','/api/runtime/heartbeat'],'auth':'write APIs require Bearer token unless AIOS_GITOPS_DEV_ALLOW=1 for local acceptance tests'}
|
||||
def main(): validate_dev_profile(); init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
def main(): init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
@@ -1,890 +0,0 @@
|
||||
"""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,11 +135,6 @@ 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',
|
||||
@@ -160,7 +155,6 @@ SILENT_RELEASE_POST_PATHS = frozenset({
|
||||
'/submitSignedApproval',
|
||||
'/submitOwnerApproval',
|
||||
'/startDeployment',
|
||||
'/reopenOwnerApproval',
|
||||
})
|
||||
|
||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
@@ -486,12 +480,8 @@ def token_records():
|
||||
else:
|
||||
out.append({'account':'legacy-token','role':'owner','scopes':['*'],'token_plain':line})
|
||||
return out
|
||||
from security_path_guards import require_isolated_dev_profile
|
||||
|
||||
def authenticate(token: str):
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1':
|
||||
require_isolated_dev_profile(HOST)
|
||||
return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1': return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if not token: return None
|
||||
digest=hashlib.sha256(token.encode()).hexdigest()
|
||||
for r in token_records():
|
||||
@@ -699,6 +689,20 @@ 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)
|
||||
@@ -944,17 +948,6 @@ 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')
|
||||
@@ -981,17 +974,6 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj(
|
||||
silent_release_service().submit_owner_approval(data)
|
||||
)
|
||||
if path == "/reopenOwnerApproval":
|
||||
a = self.need("release:approve")
|
||||
if not a:
|
||||
return
|
||||
from release_approval_reopen import ApprovalReopenError, reopen_owner_approval
|
||||
try:
|
||||
result = reopen_owner_approval(silent_release_service(), data)
|
||||
except ApprovalReopenError as exc:
|
||||
return self.sendj({"error": exc.code, "message": str(exc)}, 409)
|
||||
return self.sendj(result)
|
||||
|
||||
if path == '/startDeployment':
|
||||
a=self.need('release:deploy')
|
||||
if not a: return
|
||||
@@ -1254,25 +1236,12 @@ 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 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 d.exists():
|
||||
fp=d/cmd_filter
|
||||
if fp.exists():
|
||||
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)
|
||||
@@ -1307,7 +1276,6 @@ def api_spec():
|
||||
|
||||
|
||||
def main():
|
||||
require_isolated_dev_profile(HOST)
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
"""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,30 +51,15 @@ 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}$")
|
||||
# ``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}
|
||||
_RISK = {"low": 0, "medium": 1, "high": 2}
|
||||
_MAX_JSON = 128 * 1024
|
||||
_MAX_KEY = 64 * 1024
|
||||
_MAGIC = b"SSHSIG"
|
||||
@@ -706,30 +691,6 @@ 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
|
||||
@@ -794,28 +755,20 @@ class LocalOwnerAuthority(LocalFileSigner):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_path: Path | None,
|
||||
key_path: Path,
|
||||
policy: SingleHostOwnerPolicy,
|
||||
*,
|
||||
require_root_owner: bool = True,
|
||||
clock: Any = time.time,
|
||||
registered_signer: Any | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
key_path or Path("/dev/null"),
|
||||
key_path,
|
||||
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:
|
||||
@@ -878,111 +831,6 @@ 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,8 +35,6 @@ 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}$")
|
||||
@@ -144,30 +142,9 @@ 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)
|
||||
@@ -332,7 +309,7 @@ def _normalize_capabilities(
|
||||
|
||||
|
||||
def _risk_rank(value: str) -> int:
|
||||
ranks = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
ranks = {"low": 0, "medium": 1, "high": 2}
|
||||
try:
|
||||
return ranks[value]
|
||||
except KeyError:
|
||||
@@ -861,7 +838,7 @@ class TaskAdmissionBootstrapService:
|
||||
now: int,
|
||||
) -> OwnerDelegationEpoch:
|
||||
epoch = self.authorization.verify_delegation_epoch(epoch_id)
|
||||
if epoch.schema not in SUPPORTED_EPOCH_SCHEMAS:
|
||||
if epoch.schema != SCHEMA_EPOCH:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_SCHEMA_INVALID", 403)
|
||||
if epoch.epoch_id != epoch_id:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_ID_MISMATCH", 403)
|
||||
@@ -952,23 +929,10 @@ class TaskAdmissionBootstrapService:
|
||||
trust_domain=envelope.trust_domain, policy_digest=envelope.policy_digest,
|
||||
authorization_kind=kind, owner_identity=owner_identity,
|
||||
parent_epoch_id=parent_epoch_id, issued_at=now,
|
||||
# V2R2C2 TTL contract: the admission envelope is a *signing
|
||||
# window* only. Once authorization succeeds (epoch verified or
|
||||
# Owner signature verified) the envelope is consumed, and the
|
||||
# derived grant's lifecycle is bounded by the task/grant policy
|
||||
# ceiling (max_task_ttl_seconds) and, for a delegation epoch,
|
||||
# by the parent epoch's own expiry. It must NOT be truncated by
|
||||
# the already-consumed envelope's remaining window, otherwise a
|
||||
# nominal 3600s policy collapses to the ~900s envelope window
|
||||
# (dual-semantic TTL defect). The envelope's temporal validity
|
||||
# still gates authorization (now < envelope.expires_at above).
|
||||
expires_at=(
|
||||
now + self.policy.max_task_ttl_seconds
|
||||
if parent_epoch is None
|
||||
else min(
|
||||
now + self.policy.max_task_ttl_seconds,
|
||||
parent_epoch.expires_at,
|
||||
)
|
||||
expires_at=min(
|
||||
envelope.expires_at,
|
||||
now + self.policy.max_task_ttl_seconds,
|
||||
parent_epoch.expires_at if parent_epoch is not None else envelope.expires_at,
|
||||
),
|
||||
)
|
||||
persisted_grant, _ = self.store.claim_grant(
|
||||
|
||||
@@ -11,12 +11,10 @@ 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
|
||||
@@ -26,39 +24,15 @@ 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):
|
||||
@@ -478,7 +452,6 @@ class TaskAdmissionPrincipalProvider:
|
||||
class TaskAdmissionRuntimeBundle:
|
||||
handlers: Mapping[str, Any]
|
||||
principal_verifier: TaskAdmissionPrincipalProvider
|
||||
service: Any | None = None
|
||||
|
||||
|
||||
class TaskAdmissionHostError(RuntimeError):
|
||||
@@ -638,15 +611,11 @@ class SignedEpochDirectoryProvider:
|
||||
"TASK_ADMISSION_EPOCH_CANONICAL_MISMATCH",
|
||||
403,
|
||||
)
|
||||
schema = value.get("schema")
|
||||
if schema not in {SCHEMA_EPOCH, SCHEMA_EPOCH_V2}:
|
||||
if value.get("schema") != "aios.owner-delegation-epoch.v1":
|
||||
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",
|
||||
@@ -662,34 +631,7 @@ 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,
|
||||
@@ -706,23 +648,6 @@ 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)
|
||||
@@ -736,16 +661,13 @@ 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(
|
||||
@@ -768,19 +690,6 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -995,7 +904,6 @@ def _build_task_admission_runtime(
|
||||
task_port=task_port,
|
||||
epoch_id=epoch.epoch_id,
|
||||
),
|
||||
service=service,
|
||||
)
|
||||
|
||||
|
||||
@@ -1161,8 +1069,6 @@ 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):
|
||||
@@ -1224,38 +1130,6 @@ 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")
|
||||
@@ -1278,7 +1152,7 @@ def _unique_strings(value:object,code:str)->list[str]:
|
||||
|
||||
|
||||
def validate_v3_plan(*,grant:object,raw:bytes)->dict[str,Any]:
|
||||
require_reservable_grant(grant)
|
||||
require_target_grant(grant)
|
||||
plan=strict_json_object(raw)
|
||||
if set(plan)!=PLAN_FIELDS_V3:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FIELDS_INVALID")
|
||||
@@ -1321,17 +1195,14 @@ def validate_v3_plan(*,grant:object,raw:bytes)->dict[str,Any]:
|
||||
|
||||
|
||||
def load_v3_plan(root:Path,grant:object)->dict[str,Any]:
|
||||
require_reservable_grant(grant)
|
||||
require_target_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 (
|
||||
_host_posix_metadata_enforced()
|
||||
and (rst.st_uid!=0 or rst.st_mode&0o022)
|
||||
)
|
||||
or rst.st_uid!=0 or rst.st_mode&0o022
|
||||
):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_DIRECTORY_INVALID")
|
||||
path=root/(str(_gv(grant,"execution_digest"))+".json")
|
||||
@@ -1341,10 +1212,7 @@ 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 (
|
||||
_host_posix_metadata_enforced()
|
||||
and (st.st_uid!=0 or st.st_mode&0o022)
|
||||
)
|
||||
or st.st_uid!=0 or st.st_mode&0o022
|
||||
):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_INVALID")
|
||||
try:
|
||||
@@ -1355,30 +1223,25 @@ def load_v3_plan(root:Path,grant:object)->dict[str,Any]:
|
||||
|
||||
|
||||
def reservation_idempotency_key(grant:object)->str:
|
||||
require_reservable_grant(grant)
|
||||
require_target_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,*,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")),
|
||||
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,
|
||||
"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]:
|
||||
@@ -1393,22 +1256,21 @@ def validate_bootstrap_response(
|
||||
request:Mapping[str,Any],
|
||||
response:object,
|
||||
expected_side_effect_state:str,
|
||||
expected_branch_mode:str="CONTINUE_EXISTING",
|
||||
)->str:
|
||||
require_reservable_grant(grant)
|
||||
require_target_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":_gv(grant,"task_id"),
|
||||
"task_id":TARGET_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",_gv(grant,"authority_base_oid")),
|
||||
"target_branch":_gv(grant,"target_branch"),
|
||||
"branch_mode":expected_branch_mode,
|
||||
"generation":_gv(grant,"generation"),
|
||||
"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,
|
||||
"state":"BOUND",
|
||||
}
|
||||
for key,value in expected.items():
|
||||
@@ -1471,20 +1333,13 @@ 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=first_side_effect_state,
|
||||
expected_branch_mode=expected_branch_mode,
|
||||
grant=grant,request=request,response=first,expected_side_effect_state="QUEUED"
|
||||
)
|
||||
replay_id=validate_bootstrap_response(
|
||||
grant=grant,request=request,response=replay,
|
||||
expected_side_effect_state=replay_side_effect_state,
|
||||
expected_branch_mode=expected_branch_mode,
|
||||
expected_side_effect_state=replay_side_effect_state
|
||||
)
|
||||
if first_id!=replay_id:
|
||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_REPLAY_JOB_CONFLICT")
|
||||
@@ -1500,12 +1355,6 @@ 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"
|
||||
|
||||
@@ -1546,428 +1395,38 @@ 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)
|
||||
|
||||
main_before,branch_before,branch_mode,expected_branch_oid=_fl_reservation_refs(self.runtime,grant)
|
||||
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)
|
||||
|
||||
request=build_reservation_request(grant,expected_branch_oid=expected_branch_oid)
|
||||
request=build_reservation_request(grant)
|
||||
request_before=dict(request)
|
||||
try:
|
||||
first=bootstrap.create(request)
|
||||
if request!=request_before:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_REQUEST_MUTATED",503)
|
||||
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,
|
||||
)
|
||||
validate_bootstrap_response(grant=grant,request=request,response=first,expected_side_effect_state="QUEUED")
|
||||
|
||||
replay=bootstrap.create(request)
|
||||
if request!=request_before:
|
||||
@@ -1975,12 +1434,6 @@ 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
|
||||
@@ -1995,12 +1448,11 @@ 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[str(_gv(grant,"task_id"))]=dict(replay)
|
||||
self.readbacks.pop(str(_gv(grant,"task_id")),None)
|
||||
bindings[TARGET_TASK_ID]=dict(replay)
|
||||
|
||||
try:
|
||||
main_after=authority.read_ref("refs/heads/main")
|
||||
branch_after=authority.read_ref("refs/heads/"+str(_gv(grant,"target_branch")))
|
||||
branch_after=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_after!=main_before or branch_after!=branch_before:
|
||||
@@ -2015,33 +1467,29 @@ 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.existing_v3_job(grant,plan)
|
||||
if job_id is None:
|
||||
job_id=self._reserver(grant,plan)
|
||||
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,
|
||||
"execution_digest":_gv(grant,"execution_digest"),
|
||||
}
|
||||
return {**plan,"external_job_id":job_id}
|
||||
|
||||
def __call__(self,grant:object)->Mapping[str,Any]:
|
||||
try:
|
||||
return self._target(grant)
|
||||
except Exception as v3_error:
|
||||
if getattr(grant,"task_id",None)!=TARGET_TASK_ID:
|
||||
parent=getattr(super(),"__call__",None)
|
||||
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
|
||||
if callable(parent):
|
||||
return parent(grant)
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_PROVIDER_CONTRACT_INVALID",503)
|
||||
return self._target(grant)
|
||||
|
||||
def load(self,grant:object)->Mapping[str,Any]:
|
||||
return self(grant)
|
||||
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)
|
||||
|
||||
def _fl_runtime_from_post_admission(post_admission:object)->object:
|
||||
runtime=getattr(post_admission,"__self__",None)
|
||||
@@ -2061,93 +1509,31 @@ def _fl_call(builder,*args,post_admission=None,**kwargs):
|
||||
previous=ExecutionPlanDirectoryProvider
|
||||
ExecutionPlanDirectoryProvider=BoundProvider
|
||||
try:
|
||||
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)
|
||||
return builder(*args,post_admission=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_RUNTIME_FROM_ENVIRONMENT,
|
||||
_FL_BUILD_FROM_ACTIVATION,activation_file,
|
||||
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,
|
||||
):
|
||||
return build_task_admission_runtime_from_environment(
|
||||
if not callable(_FL_BUILD_FROM_ENVIRONMENT):
|
||||
raise RuntimeError("TASK_ADMISSION_ENVIRONMENT_BUILDER_MISSING")
|
||||
return _fl_call(
|
||||
_FL_BUILD_FROM_ENVIRONMENT,
|
||||
caller_service=caller_service,post_admission=post_admission,
|
||||
).handlers
|
||||
)
|
||||
|
||||
# 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
|
||||
ExecutionPlanDirectoryProvider=V3ProducerReceiptExecutionPlanDirectoryProvider
|
||||
# ===== AIOS FORMAL LINEAGE POST-OWNER PRODUCER NATIVE V12 — END =====
|
||||
|
||||
@@ -1,779 +0,0 @@
|
||||
|
||||
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")
|
||||
@@ -1,285 +0,0 @@
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -1,539 +0,0 @@
|
||||
|
||||
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)
|
||||
@@ -27,7 +27,6 @@ CANONICAL_PROFILE = "aios.task-initial-execution.v1"
|
||||
CANONICAL_PROFILE_VERSION = 1
|
||||
SUPERSESSION_REASON = "formal-lineage-supersession-after-expired-unclaimed-source"
|
||||
MAX_TTL = timedelta(minutes=5)
|
||||
PARAMETERIZED_RECOVERY_NONCE_SCOPE = "aios.task-parameterized-recovery.v1:owner-nonce"
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
|
||||
@@ -200,31 +199,6 @@ def _verify_source(row: sqlite3.Row, d: Mapping[str, Any], identity_digest: Call
|
||||
_fail("SOURCE_GENERATION_CONSTRAINT_MISMATCH")
|
||||
|
||||
|
||||
def _parameterized_recovery_nonce_reserved(
|
||||
connection: sqlite3.Connection, nonce: str
|
||||
) -> bool:
|
||||
"""Share the existing authority nonce domain when C6 NOOP support exists.
|
||||
|
||||
Historical lineage databases can predate TaskStore's idempotency table;
|
||||
their established lineage-only operation remains readable. The C6 NOOP
|
||||
adapter refuses to activate on those schemas, so an absent table cannot
|
||||
create a cross-operation uniqueness gap.
|
||||
"""
|
||||
tables = {
|
||||
str(row[0])
|
||||
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
if "idempotency" not in tables:
|
||||
return False
|
||||
columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(idempotency)")}
|
||||
if not {"scope", "key", "payload_digest", "response_json", "created_at"}.issubset(columns):
|
||||
_fail("FORMAL_IDEMPOTENCY_SCHEMA_UNAVAILABLE", 503)
|
||||
return connection.execute(
|
||||
"SELECT 1 FROM idempotency WHERE scope=? AND key=?",
|
||||
(PARAMETERIZED_RECOVERY_NONCE_SCOPE, nonce),
|
||||
).fetchone() is not None
|
||||
|
||||
|
||||
class TaskLineageSupersessionController:
|
||||
def __init__(self, db_path: str | Path, verifier: SignatureVerifier, *, clock=None, identity_digest=None):
|
||||
self.db_path = Path(db_path)
|
||||
@@ -277,8 +251,6 @@ class TaskLineageSupersessionController:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._assert_schema(connection)
|
||||
if _parameterized_recovery_nonce_reserved(connection, decision["nonce"]):
|
||||
_fail("PARAMETERIZED_RECOVERY_NONCE_CONSUMED")
|
||||
replay = connection.execute(
|
||||
"SELECT owner_decision_sha256,receipt_json FROM task_lineage_supersession_receipts WHERE decision_id=? OR nonce=?",
|
||||
(decision["decision_id"], decision["nonce"]),
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
|
||||
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")
|
||||
@@ -1,242 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,317 +0,0 @@
|
||||
"""Narrow, terminal-only parameterized recovery over the formal Task store.
|
||||
|
||||
This adapter deliberately does *not* create a generic recovery authority. It
|
||||
can only record an Owner-signed, exact-CAS terminal NOOP. A non-terminal
|
||||
recovery remains the existing formal lineage/supersession operation.
|
||||
|
||||
The receipt uses the existing TaskStore ``idempotency`` uniqueness constraint
|
||||
in the same authoritative database. Two fixed owner-authority scopes bind a
|
||||
decision id and a nonce atomically; this is not a new nonce database or table.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from aios_agent_task_protocol import TaskStore, canonical_json
|
||||
from aios_task_lineage_supersession import (
|
||||
OWNER_KEY_ID,
|
||||
OWNER_NAMESPACE,
|
||||
OWNER_PRINCIPAL,
|
||||
REPOSITORY,
|
||||
)
|
||||
|
||||
|
||||
DECISION_SCHEMA = "aios.task-parameterized-recovery-decision.v1"
|
||||
RECEIPT_SCHEMA = "aios.task-parameterized-recovery-receipt.v1"
|
||||
ACTION_TERMINAL_NOOP = "TERMINAL_NOOP"
|
||||
TERMINAL_STATUSES = frozenset({"COMPLETED", "CANCELLED", "FAILED_FINAL"})
|
||||
MAX_TTL = timedelta(minutes=5)
|
||||
NONCE_SCOPE = "aios.task-parameterized-recovery.v1:owner-nonce"
|
||||
DECISION_SCOPE = "aios.task-parameterized-recovery.v1:owner-decision"
|
||||
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
_BRANCH = re.compile(r"^agent/[A-Za-z0-9._/-]{1,220}$")
|
||||
_FIELDS = frozenset(
|
||||
{
|
||||
"schema", "decision_id", "nonce", "action", "task_id",
|
||||
"task_identity_sha256", "expected_status", "expected_attempt",
|
||||
"expected_generation", "expected_revision", "expected_claimed_by",
|
||||
"expected_lease_token_hash", "expected_lease_expires_at", "repository",
|
||||
"target_branch", "owner_identity", "signature_namespace",
|
||||
"signature_principal", "key_id", "issued_at", "not_before",
|
||||
"expires_at", "one_time_use", "signature",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ParameterizedRecoveryError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = int(http_status)
|
||||
|
||||
|
||||
class SignatureVerifier(Protocol):
|
||||
def verify(self, *, namespace: str, payload: bytes, signature: str, key_id: str) -> bool: ...
|
||||
|
||||
|
||||
def _fail(code: str, status: int = 409) -> None:
|
||||
raise ParameterizedRecoveryError(code, status)
|
||||
|
||||
|
||||
def _utc(value: object, field: str) -> datetime:
|
||||
if not isinstance(value, str) or not value or len(value) > 80:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ParameterizedRecoveryError(f"{field.upper()}_INVALID", 400) from exc
|
||||
if parsed.tzinfo is None:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _text(value: object, field: str, *, maximum: int = 1024) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
return value
|
||||
|
||||
|
||||
def canonical_decision_payload(raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: raw[key] for key in raw if key != "signature"}
|
||||
|
||||
|
||||
def _identity_digest(row: sqlite3.Row) -> str:
|
||||
value = {
|
||||
"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"]),
|
||||
"priority": row["priority"],
|
||||
"max_attempts": row["max_attempts"],
|
||||
}
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def validate_decision(raw: Mapping[str, Any], *, now: datetime) -> dict[str, Any]:
|
||||
if not isinstance(raw, Mapping) or set(raw) != _FIELDS:
|
||||
_fail("DECISION_FIELDS_INVALID", 400)
|
||||
exact = {
|
||||
"schema": DECISION_SCHEMA,
|
||||
"action": ACTION_TERMINAL_NOOP,
|
||||
"repository": REPOSITORY,
|
||||
"owner_identity": OWNER_PRINCIPAL,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_principal": OWNER_PRINCIPAL,
|
||||
"key_id": OWNER_KEY_ID,
|
||||
"one_time_use": True,
|
||||
}
|
||||
for field, expected in exact.items():
|
||||
if raw[field] != expected:
|
||||
_fail(f"{field.upper()}_MISMATCH", 403)
|
||||
for field in ("task_id", "decision_id", "nonce"):
|
||||
if _SAFE_ID.fullmatch(_text(raw[field], field, maximum=512)) is None:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
if not isinstance(raw["task_identity_sha256"], str) or not _HEX64.fullmatch(raw["task_identity_sha256"]):
|
||||
_fail("TASK_IDENTITY_SHA256_INVALID", 400)
|
||||
if not isinstance(raw["target_branch"], str) or not _BRANCH.fullmatch(raw["target_branch"]):
|
||||
_fail("TARGET_BRANCH_INVALID", 400)
|
||||
for field, minimum in (("expected_attempt", 0), ("expected_generation", 1), ("expected_revision", 1)):
|
||||
if isinstance(raw[field], bool) or not isinstance(raw[field], int) or raw[field] < minimum:
|
||||
_fail(f"{field.upper()}_INVALID", 400)
|
||||
for field in ("expected_claimed_by", "expected_lease_token_hash", "expected_lease_expires_at"):
|
||||
if raw[field] is not None:
|
||||
_text(raw[field], field, maximum=512)
|
||||
if raw["expected_status"] not in TERMINAL_STATUSES:
|
||||
_fail("EXPECTED_STATUS_NOT_TERMINAL", 409)
|
||||
_text(raw["signature"], "signature", maximum=32768)
|
||||
issued_at = _utc(raw["issued_at"], "issued_at")
|
||||
not_before = _utc(raw["not_before"], "not_before")
|
||||
expires_at = _utc(raw["expires_at"], "expires_at")
|
||||
if issued_at > not_before or not_before > expires_at:
|
||||
_fail("DECISION_TIME_ORDER_INVALID", 403)
|
||||
if expires_at <= issued_at or expires_at - issued_at > MAX_TTL:
|
||||
_fail("DECISION_TTL_INVALID", 403)
|
||||
if now < not_before or now >= expires_at or issued_at > now + timedelta(seconds=5) or now - issued_at > MAX_TTL:
|
||||
_fail("DECISION_EXPIRED_OR_NOT_YET_VALID", 403)
|
||||
return dict(raw)
|
||||
|
||||
|
||||
class ParameterizedRecoveryController:
|
||||
"""Consumes a validated terminal-NOOP decision once, in the TaskStore DB."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: TaskStore,
|
||||
verifier: SignatureVerifier,
|
||||
*,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
identity_digest: Callable[[sqlite3.Row], str] = _identity_digest,
|
||||
) -> None:
|
||||
if not isinstance(store, TaskStore) or verifier is None:
|
||||
_fail("FORMAL_TASK_AUTHORITY_REQUIRED", 503)
|
||||
self.store = store
|
||||
self.verifier = verifier
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.identity_digest = identity_digest
|
||||
|
||||
def _now(self) -> datetime:
|
||||
value = self.clock()
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _assert_schema(connection: sqlite3.Connection) -> None:
|
||||
tables = {str(row[0]) for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if not {"tasks", "events", "idempotency", "task_lineage_supersession_receipts"}.issubset(tables):
|
||||
_fail("FORMAL_LINEAGE_SCHEMA_UNAVAILABLE", 503)
|
||||
columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(idempotency)")}
|
||||
if not {"scope", "key", "payload_digest", "response_json", "created_at"}.issubset(columns):
|
||||
_fail("FORMAL_IDEMPOTENCY_SCHEMA_UNAVAILABLE", 503)
|
||||
|
||||
def _verify_task(self, row: sqlite3.Row, decision: Mapping[str, Any]) -> None:
|
||||
if self.identity_digest(row) != decision["task_identity_sha256"]:
|
||||
_fail("TASK_IDENTITY_DIGEST_MISMATCH")
|
||||
if (
|
||||
row["task_id"] != decision["task_id"]
|
||||
or row["status"] != decision["expected_status"]
|
||||
or int(row["revision"]) != decision["expected_revision"]
|
||||
or int(row["attempt"]) != decision["expected_attempt"]
|
||||
or row["claimed_by"] != decision["expected_claimed_by"]
|
||||
or row["lease_token_hash"] != decision["expected_lease_token_hash"]
|
||||
or row["lease_expires_at"] != decision["expected_lease_expires_at"]
|
||||
):
|
||||
_fail("TASK_EXACT_CAS_MISMATCH")
|
||||
try:
|
||||
metadata = json.loads(row["metadata_json"])
|
||||
constraints = json.loads(row["constraints_json"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ParameterizedRecoveryError("TASK_BINDING_JSON_INVALID") from exc
|
||||
execution = metadata.get("execution") if isinstance(metadata, Mapping) else None
|
||||
if not isinstance(execution, Mapping) or constraints.get("generation") != decision["expected_generation"]:
|
||||
_fail("TASK_EXECUTION_BINDING_INVALID")
|
||||
if (
|
||||
execution.get("task_id") != decision["task_id"]
|
||||
or execution.get("repository") != decision["repository"]
|
||||
or execution.get("target_branch") != decision["target_branch"]
|
||||
):
|
||||
_fail("TASK_EXECUTION_BINDING_MISMATCH")
|
||||
|
||||
@staticmethod
|
||||
def _receipt(decision: Mapping[str, Any], decision_sha: str, row: sqlite3.Row, now: datetime) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": RECEIPT_SCHEMA,
|
||||
"state": "NOOP",
|
||||
"noop_reason": "TERMINAL_STATUS",
|
||||
"decision_id": decision["decision_id"],
|
||||
"nonce": decision["nonce"],
|
||||
"task_id": decision["task_id"],
|
||||
"status": row["status"],
|
||||
"revision": int(row["revision"]),
|
||||
"owner_decision_sha256": decision_sha,
|
||||
"owner_signature_namespace": OWNER_NAMESPACE,
|
||||
"owner_signature_principal": OWNER_PRINCIPAL,
|
||||
"owner_signature_key_id": OWNER_KEY_ID,
|
||||
"owner_signature_sha256": hashlib.sha256(decision["signature"].encode()).hexdigest(),
|
||||
"exact_identity_cas": True,
|
||||
"one_time_use": True,
|
||||
"observed_at": now.isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _read_exact_replay(
|
||||
nonce_row: sqlite3.Row | None, decision_row: sqlite3.Row | None, decision: Mapping[str, Any], decision_sha: str
|
||||
) -> dict[str, Any] | None:
|
||||
if nonce_row is None and decision_row is None:
|
||||
return None
|
||||
if nonce_row is None or decision_row is None:
|
||||
present = nonce_row if nonce_row is not None else decision_row
|
||||
if present["payload_digest"] != decision_sha:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
_fail("RECOVERY_RECEIPT_INCOMPLETE", 500)
|
||||
if nonce_row["payload_digest"] != decision_sha or decision_row["payload_digest"] != decision_sha:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
try:
|
||||
nonce_receipt = json.loads(nonce_row["response_json"])
|
||||
decision_receipt = json.loads(decision_row["response_json"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ParameterizedRecoveryError("RECOVERY_RECEIPT_INVALID", 500) from exc
|
||||
if nonce_receipt != decision_receipt or (
|
||||
nonce_receipt.get("schema") != RECEIPT_SCHEMA
|
||||
or nonce_receipt.get("decision_id") != decision["decision_id"]
|
||||
or nonce_receipt.get("nonce") != decision["nonce"]
|
||||
or nonce_receipt.get("task_id") != decision["task_id"]
|
||||
or nonce_receipt.get("owner_decision_sha256") != decision_sha
|
||||
):
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
return dict(nonce_receipt)
|
||||
|
||||
def recover_terminal_noop(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
now = self._now()
|
||||
decision = validate_decision(raw, now=now)
|
||||
payload = canonical_decision_payload(raw)
|
||||
payload_bytes = canonical_json(payload).encode("utf-8")
|
||||
if not self.verifier.verify(
|
||||
namespace=OWNER_NAMESPACE,
|
||||
payload=payload_bytes,
|
||||
signature=decision["signature"],
|
||||
key_id=OWNER_KEY_ID,
|
||||
):
|
||||
_fail("DECISION_SIGNATURE_INVALID", 403)
|
||||
decision_sha = hashlib.sha256(payload_bytes).hexdigest()
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._assert_schema(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (decision["task_id"],)).fetchone()
|
||||
if row is None:
|
||||
_fail("TASK_NOT_FOUND", 404)
|
||||
self._verify_task(row, decision)
|
||||
nonce_row = connection.execute(
|
||||
"SELECT payload_digest,response_json FROM idempotency WHERE scope=? AND key=?",
|
||||
(NONCE_SCOPE, decision["nonce"]),
|
||||
).fetchone()
|
||||
decision_row = connection.execute(
|
||||
"SELECT payload_digest,response_json FROM idempotency WHERE scope=? AND key=?",
|
||||
(DECISION_SCOPE, decision["decision_id"]),
|
||||
).fetchone()
|
||||
lineage_row = connection.execute(
|
||||
"SELECT 1 FROM task_lineage_supersession_receipts WHERE decision_id=? OR nonce=?",
|
||||
(decision["decision_id"], decision["nonce"]),
|
||||
).fetchone()
|
||||
if lineage_row is not None:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
replay = self._read_exact_replay(nonce_row, decision_row, decision, decision_sha)
|
||||
if replay is not None:
|
||||
connection.commit()
|
||||
return {**replay, "idempotent": True}
|
||||
receipt = self._receipt(decision, decision_sha, row, now)
|
||||
receipt_json = canonical_json(receipt)
|
||||
for scope, key in ((NONCE_SCOPE, decision["nonce"]), (DECISION_SCOPE, decision["decision_id"])):
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency(scope,key,payload_digest,response_json,created_at) VALUES(?,?,?,?,?)",
|
||||
(scope, key, decision_sha, receipt_json, now.isoformat()),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES(?,?,?,?,?)",
|
||||
(decision["task_id"], None, "TASK_PARAMETERIZED_RECOVERY_NOOP", receipt_json, now.isoformat()),
|
||||
)
|
||||
connection.commit()
|
||||
return {**receipt, "idempotent": False}
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
# There is intentionally no generic resume/reclaim path. The existing
|
||||
# lineage controller remains the only predecessor/successor authority.
|
||||
recover = recover_terminal_noop
|
||||
@@ -1,167 +0,0 @@
|
||||
"""Read-only V3 issuer scope over the admitted producer's durable receipt.
|
||||
|
||||
The V1/V2 provider remains authoritative for those schemas. V3 validation
|
||||
and Job readback are delegated to the existing admission producer contract;
|
||||
this module never reserves a Job or changes the canonical plan digest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_governed_task_bootstrap import GovernedTaskBootstrap
|
||||
from aios_task_admission_bootstrap import TaskAdmissionError
|
||||
from aios_task_admission_live_adapters import (
|
||||
ProducerReceiptError,
|
||||
_FLProducerReceiptReserver,
|
||||
load_v3_plan,
|
||||
validate_v3_plan,
|
||||
)
|
||||
from governed_runtime_scope import (
|
||||
GovernedScopeError,
|
||||
RuntimeScope,
|
||||
_branch,
|
||||
_generation,
|
||||
_hex64,
|
||||
_job_id,
|
||||
_normalize_caps,
|
||||
_normalize_files,
|
||||
_oid40,
|
||||
_task_id,
|
||||
_trust_digest,
|
||||
)
|
||||
|
||||
|
||||
def _deny_mutation(*_args: Any, **_kwargs: Any) -> None:
|
||||
raise GovernedScopeError("ISSUER_PRODUCER_MUTATION_FORBIDDEN")
|
||||
|
||||
|
||||
def _read_only_bootstrap(root: Path) -> GovernedTaskBootstrap:
|
||||
"""Construct only the read-only binding view; __init__ creates directories."""
|
||||
root = Path(root)
|
||||
for path in (root, root / "task-bindings", root / "jobs"):
|
||||
metadata = path.lstat()
|
||||
if (stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode)
|
||||
or stat.S_IMODE(metadata.st_mode) & 0o022):
|
||||
raise GovernedScopeError("PRODUCER_STATE_ROOT_INVALID")
|
||||
view = object.__new__(GovernedTaskBootstrap)
|
||||
view.root = root
|
||||
view.task_meta_root = root / "task-bindings"
|
||||
view.job_root = root / "jobs"
|
||||
view.create = _deny_mutation
|
||||
view._store = _deny_mutation
|
||||
view._write = _deny_mutation
|
||||
return view
|
||||
|
||||
|
||||
class IssuerV3ReceiptScopeProvider:
|
||||
"""Delegate legacy plans, and resolve V3 with an existing Job only."""
|
||||
|
||||
def __init__(
|
||||
self, *, legacy: Any, producer_state_root: Path, plan_root: Path,
|
||||
grant_reader: Callable[..., Mapping[str, Any]],
|
||||
plan_loader: Callable[[Path, Mapping[str, Any]], Mapping[str, Any]] = load_v3_plan,
|
||||
) -> None:
|
||||
self.legacy = legacy
|
||||
self.producer_state_root = Path(producer_state_root)
|
||||
self.plan_root = Path(plan_root)
|
||||
self.grant_reader = grant_reader
|
||||
self.plan_loader = plan_loader
|
||||
|
||||
def inspect_v3_binding(
|
||||
self, *, grant_raw: Mapping[str, Any], plan: Mapping[str, Any],
|
||||
) -> tuple[str, Mapping[str, Any]]:
|
||||
"""Validate original bytes and durable receipt without Task mutation.
|
||||
|
||||
This method can inspect an expired historical fixture. ``resolve``
|
||||
separately enforces live Task and grant state before issuing scope.
|
||||
"""
|
||||
try:
|
||||
original = self.plan_loader(self.plan_root, grant_raw)
|
||||
if dict(original) != dict(plan):
|
||||
raise GovernedScopeError("EXECUTION_PLAN_READBACK_CONFLICT")
|
||||
# Keep the main V3 validator in the issuer contract even when a
|
||||
# test supplies an isolated loader. It binds the original plan,
|
||||
# including risk, hashes, capabilities and execution digest.
|
||||
from aios_task_admission_live_adapters import canonical_json_bytes
|
||||
validate_v3_plan(grant=grant_raw, raw=canonical_json_bytes(original))
|
||||
bootstrap = _read_only_bootstrap(self.producer_state_root)
|
||||
runtime = type("ReadOnlyIssuerRuntime", (), {
|
||||
"bootstrap": bootstrap, "bindings": {},
|
||||
})()
|
||||
reserver = _FLProducerReceiptReserver(runtime)
|
||||
job_id = reserver.existing_v3_job(grant_raw, original)
|
||||
if job_id is None:
|
||||
raise GovernedScopeError("PRODUCER_RECEIPT_MISSING")
|
||||
readback = reserver.readbacks.get(str(grant_raw["task_id"]))
|
||||
proof = readback.get("reservation_readback") if isinstance(readback, Mapping) else None
|
||||
if not isinstance(proof, Mapping) or proof.get("job_id") != job_id:
|
||||
raise GovernedScopeError("PRODUCER_RECEIPT_BINDING_INVALID")
|
||||
return job_id, dict(proof)
|
||||
except (TaskAdmissionError, ProducerReceiptError, OSError, KeyError, TypeError, ValueError) as exc:
|
||||
raise GovernedScopeError(str(exc) or "PRODUCER_RECEIPT_INVALID") from exc
|
||||
|
||||
def resolve(
|
||||
self, *, task: Any, execution: Any, grant: Any,
|
||||
plan: Mapping[str, Any], trust: Any, now: int,
|
||||
) -> RuntimeScope:
|
||||
if plan.get("schema") != "aios.task-admission-execution-plan.v3":
|
||||
return self.legacy.resolve(
|
||||
task=task, execution=execution, grant=grant,
|
||||
plan=plan, trust=trust, now=now,
|
||||
)
|
||||
|
||||
task_id = _task_id(task.task_id)
|
||||
exec_job = _job_id(execution.external_job_id)
|
||||
exec_digest = _hex64(execution.execution_digest, "execution_digest")
|
||||
exec_branch = _branch(execution.target_branch)
|
||||
exec_generation = _generation(execution.generation)
|
||||
grant_raw = self.grant_reader(
|
||||
task_id=task_id, execution_digest=exec_digest,
|
||||
repository=execution.repository, target_branch=exec_branch,
|
||||
generation=exec_generation, now=int(now),
|
||||
)
|
||||
producer_job, _proof = self.inspect_v3_binding(
|
||||
grant_raw=grant_raw, plan=plan,
|
||||
)
|
||||
if exec_job != producer_job:
|
||||
raise GovernedScopeError("EXECUTION_PRODUCER_JOB_MISMATCH")
|
||||
if str(task.status).upper() not in {"CLAIMED", "RUNNING"}:
|
||||
raise GovernedScopeError("TASK_NOT_CLAIMED")
|
||||
if grant.status != "ADMITTED" or int(grant.expires_at) <= int(now):
|
||||
raise GovernedScopeError("GRANT_EXPIRED_OR_NOT_ADMITTED")
|
||||
expected = {
|
||||
"task_id": task_id,
|
||||
"repository": execution.repository,
|
||||
"target_branch": exec_branch,
|
||||
"generation": exec_generation,
|
||||
"execution_digest": exec_digest,
|
||||
"authority_base_oid": _oid40(grant.authority_base_oid, "authority_base_oid"),
|
||||
"grant_id": grant.grant_id,
|
||||
}
|
||||
if (execution.repository != "aios/aios"
|
||||
or execution.task_id != task_id
|
||||
or any(grant_raw.get(key) != value for key, value in expected.items())):
|
||||
raise GovernedScopeError("V3_GRANT_EXECUTION_BINDING_MISMATCH")
|
||||
caps = _normalize_caps(grant.requested_capabilities)
|
||||
files = _normalize_files(grant.file_allowlist)
|
||||
if (caps != _normalize_caps(grant_raw["capabilities"])
|
||||
or files != _normalize_files(grant_raw["file_allowlist"])):
|
||||
raise GovernedScopeError("V3_GRANT_SCOPE_MISMATCH")
|
||||
if (not trust.issuer_id or not trust.key_id or not trust.public_key
|
||||
or "TASK_PRINCIPAL" not in str(trust.purpose).upper().replace("-", "_")
|
||||
or not str(trust.bridge_id).strip()):
|
||||
raise GovernedScopeError("TRUST_IDENTITY_INVALID")
|
||||
trust_digest = _trust_digest(trust)
|
||||
if (trust.scope_manifest_sha256 is not None
|
||||
and _hex64(trust.scope_manifest_sha256, "scope_manifest_sha256") != trust_digest):
|
||||
raise GovernedScopeError("TRUST_MANIFEST_DIGEST_MISMATCH")
|
||||
return RuntimeScope(
|
||||
scope_id=f"governed-runtime:{task_id}:g{exec_generation}:{exec_digest[:16]}",
|
||||
task_id=task_id, external_job_id=producer_job,
|
||||
repository=execution.repository, target_branch=exec_branch,
|
||||
generation=exec_generation, execution_digest=exec_digest,
|
||||
capabilities=caps, trust_manifest_sha256=trust_digest,
|
||||
grant_id=grant.grant_id, grant_expires_at=int(grant.expires_at),
|
||||
)
|
||||
@@ -1,511 +0,0 @@
|
||||
"""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
|
||||
@@ -24,19 +24,6 @@ class BuilderError(RuntimeError):
|
||||
"""A stable, non-sensitive error code returned by the builder boundary."""
|
||||
|
||||
|
||||
C5_INTEGRATION_CONTRACT_FIELDS = frozenset({
|
||||
"integration_source_commit", "integration_tree_oid",
|
||||
"publication_source_commit", "publication_tree_oid",
|
||||
"publication_plan_sha256", "repair_source_commit", "repair_tree_oid",
|
||||
})
|
||||
C5_INTEGRATION_FILES = {
|
||||
"active-runtime/app/aios_capability_contract.py": ("publication_source_commit", "67d8a3a296110e20a4ea59863cd7982b6bdada71abd457c0f7cec480b06d278a"),
|
||||
"tests/test_v2r2c5_capability_contract.py": ("publication_source_commit", "f91af641528ea9a625ca45a46b6c26f5dba568e6b69cba819ca33ab5cba28de3"),
|
||||
"active-runtime/app/aios_baseline/service.py": ("repair_source_commit", "3c3918aec5503947b48ef4c73e34cac8f0d33fdfb99cfcd5ca8af699b663da72"),
|
||||
"tests/baseline_v1_1/test_loop.py": ("repair_source_commit", "f867e92b9ec9b4dff248e6ca38280848458a535c610dc6d9fea2b270c29b664a"),
|
||||
}
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||
|
||||
@@ -148,7 +135,6 @@ class TrustedCandidateBuilderConfig:
|
||||
policy_sha256: str
|
||||
source_admission_resolver: Callable[[str, str | None, str], Mapping[str, str]] | None = None
|
||||
migration_plan_resolver: Callable[[str, str, str], Any] | None = None
|
||||
c5_contract: Mapping[str, str] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
||||
@@ -159,52 +145,16 @@ class TrustedCandidateBuilderConfig:
|
||||
"allowed_peer_runtime_prefix", "allowed_peer_runtime_suffix",
|
||||
"required_checks", "evidence_layout", "recipes", "governed_recipes",
|
||||
}
|
||||
is_integration = policy.get("schema") == "aios.trusted-candidate-builder-policy.c5-integration.v1"
|
||||
is_c5 = is_integration or policy.get("schema") == "aios.trusted-candidate-builder-policy.c5.v1"
|
||||
if set(policy) != (required | {"c5_contract"} if is_c5 else required) or policy.get("schema") not in {"aios.trusted-candidate-builder-policy.v1", "aios.trusted-candidate-builder-policy.c5.v1", "aios.trusted-candidate-builder-policy.c5-integration.v1"}:
|
||||
if set(policy) != required or policy.get("schema") != "aios.trusted-candidate-builder-policy.v1":
|
||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
||||
recipes = policy.get("recipes")
|
||||
expected_recipes = {"V2R2C2", "V2R2C3", "V2R2C5"} if is_c5 else {"V2R2C2", "V2R2C3"}
|
||||
if not isinstance(recipes, Mapping) or set(recipes) != expected_recipes:
|
||||
if not isinstance(recipes, Mapping) or set(recipes) != {"V2R2C2", "V2R2C3"}:
|
||||
raise BuilderError("POLICY_RECIPES_INVALID")
|
||||
c2, c3 = recipes["V2R2C2"], recipes["V2R2C3"]
|
||||
if not isinstance(c2, Mapping) or set(c2) != {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose"} or c2.get("mode") != "fixed":
|
||||
raise BuilderError("POLICY_V2R2C2_INVALID")
|
||||
if not isinstance(c3, Mapping) or set(c3) != {"mode", "derives_from", "version", "purpose"} or c3.get("mode") != "governed-derived-only" or c3.get("derives_from") != "V2R2C2":
|
||||
raise BuilderError("POLICY_V2R2C3_INVALID")
|
||||
c5_contract = None
|
||||
if is_c5:
|
||||
c5 = recipes["V2R2C5"]
|
||||
expected_mode = "governed-source-integration-only" if is_integration else "governed-source-admission-only"
|
||||
if not isinstance(c5, Mapping) or set(c5) != {"mode", "version", "purpose"} or c5.get("mode") != expected_mode or c5.get("version") != "V2R2C5" or c5.get("purpose") != "AIOS_TRUSTED_RELEASE_BUILDER":
|
||||
raise BuilderError("POLICY_V2R2C5_INVALID")
|
||||
required_c5 = {"task_id", "authority_base_oid", "accepted_base_oid", "rollback_version", "rollback_target_oid", "rollback_artifact_sha256"}
|
||||
if is_integration:
|
||||
required_c5 |= C5_INTEGRATION_CONTRACT_FIELDS
|
||||
value = policy.get("c5_contract")
|
||||
if not isinstance(value, Mapping) or set(value) != required_c5:
|
||||
raise BuilderError("POLICY_V2R2C5_CONTRACT_INVALID")
|
||||
if any(not isinstance(v, str) for v in value.values()):
|
||||
raise BuilderError("POLICY_V2R2C5_CONTRACT_INVALID")
|
||||
c5_contract = dict(value)
|
||||
oid_fields = {"authority_base_oid", "accepted_base_oid", "rollback_target_oid"}
|
||||
if is_integration:
|
||||
oid_fields |= {
|
||||
"integration_source_commit", "integration_tree_oid",
|
||||
"publication_source_commit", "publication_tree_oid",
|
||||
"repair_source_commit", "repair_tree_oid",
|
||||
}
|
||||
sha_fields = {"rollback_artifact_sha256"}
|
||||
if is_integration:
|
||||
sha_fields.add("publication_plan_sha256")
|
||||
if (
|
||||
not c5_contract["task_id"]
|
||||
or c5_contract["rollback_version"] != "V2R2C4"
|
||||
or c5_contract["accepted_base_oid"] != c5_contract["rollback_target_oid"]
|
||||
or any(len(c5_contract[name]) != 40 or any(ch not in "0123456789abcdef" for ch in c5_contract[name]) for name in oid_fields)
|
||||
or any(len(c5_contract[name]) != 64 or any(ch not in "0123456789abcdef" for ch in c5_contract[name]) for name in sha_fields)
|
||||
):
|
||||
raise BuilderError("POLICY_V2R2C5_CONTRACT_INVALID")
|
||||
if (
|
||||
policy["repository"] != "aios/aios"
|
||||
or policy["operation"] != "BuildTrustedCandidateV1"
|
||||
@@ -260,7 +210,6 @@ class TrustedCandidateBuilderConfig:
|
||||
recipes={name: dict(value) for name, value in recipes.items()},
|
||||
governed_recipes={name: dict(value) for name, value in policy["governed_recipes"].items()} if isinstance(policy["governed_recipes"], Mapping) else _invalid_governed_recipes(),
|
||||
policy_sha256=hashlib.sha256(_canonical_json(dict(policy))).hexdigest(),
|
||||
c5_contract=c5_contract,
|
||||
)
|
||||
|
||||
def fixed_recipe(self, name: str) -> Mapping[str, str]:
|
||||
@@ -282,36 +231,6 @@ class TrustedCandidateBuilderConfig:
|
||||
def resolve_recipe(self, version: str, candidate_id: str | None, source_commit: str) -> Mapping[str, str]:
|
||||
recipe = self.recipes.get(version)
|
||||
if recipe is not None:
|
||||
if recipe.get("mode") in {"governed-source-admission-only", "governed-source-integration-only"}:
|
||||
if version != "V2R2C5" or self.c5_contract is None:
|
||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED")
|
||||
resolver = self.source_admission_resolver
|
||||
if resolver is None:
|
||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED")
|
||||
try:
|
||||
admitted = dict(resolver(version, candidate_id, source_commit))
|
||||
except Exception as exc:
|
||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED") from exc
|
||||
required = {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "tree_oid"}
|
||||
if set(admitted) != required or admitted.get("mode") != "governed-source-admission" or admitted.get("version") != version or admitted.get("source_commit") != source_commit or admitted.get("purpose") != "AIOS_TRUSTED_RELEASE_BUILDER":
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
if (not isinstance(admitted.get("candidate_id"), str) or not admitted.get("candidate_id")
|
||||
or admitted.get("task_id") != self.c5_contract["task_id"]
|
||||
or admitted.get("base_oid") != self.c5_contract["authority_base_oid"]):
|
||||
raise BuilderError("SOURCE_ADMISSION_BINDING_INVALID")
|
||||
effective = str(candidate_id or admitted["candidate_id"])
|
||||
if effective != admitted["candidate_id"]:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
for name in ("base_oid", "source_commit", "tree_oid"):
|
||||
value = admitted.get(name)
|
||||
if not isinstance(value, str) or len(value) != 40 or any(char not in "0123456789abcdef" for char in value.lower()):
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
if recipe.get("mode") == "governed-source-integration-only" and (
|
||||
source_commit != self.c5_contract["integration_source_commit"]
|
||||
or admitted["tree_oid"] != self.c5_contract["integration_tree_oid"]
|
||||
):
|
||||
raise BuilderError("SOURCE_INTEGRATION_BINDING_INVALID")
|
||||
return {**admitted, **self.c5_contract}
|
||||
resolved = self.fixed_recipe(version)
|
||||
# Fixed/governed-derived policy recipes remain immutable. A
|
||||
# source admission can never override one of these identities.
|
||||
@@ -328,8 +247,6 @@ class TrustedCandidateBuilderConfig:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
return resolved
|
||||
|
||||
if self.c5_contract is not None:
|
||||
raise BuilderError("RECIPE_UNKNOWN")
|
||||
resolver = self.source_admission_resolver
|
||||
if resolver is None:
|
||||
raise BuilderError("RECIPE_UNKNOWN")
|
||||
@@ -346,9 +263,8 @@ class TrustedCandidateBuilderConfig:
|
||||
"version",
|
||||
"purpose",
|
||||
}
|
||||
allowed_fields = (required, required | {"tree_oid"}) if version == "V2R2C5" else (required,)
|
||||
if (
|
||||
set(admitted) not in allowed_fields
|
||||
set(admitted) != required
|
||||
or admitted.get("mode") != "governed-source-admission"
|
||||
or admitted.get("version") != version
|
||||
or admitted.get("source_commit") != source_commit
|
||||
@@ -359,7 +275,7 @@ class TrustedCandidateBuilderConfig:
|
||||
effective_candidate_id = str(candidate_id or admitted["candidate_id"])
|
||||
if effective_candidate_id != admitted["candidate_id"]:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
for name in ("base_oid", "source_commit", *(["tree_oid"] if "tree_oid" in admitted else [])):
|
||||
for name in ("base_oid", "source_commit"):
|
||||
value = admitted.get(name)
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
@@ -451,15 +367,15 @@ class ServerOwnedEvidenceSource:
|
||||
raise BuilderError(code) from exc
|
||||
if not stat.S_ISDIR(metadata.st_mode) or path.is_symlink():
|
||||
raise BuilderError(code)
|
||||
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")
|
||||
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")
|
||||
|
||||
def _digest(self, path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
@@ -479,44 +395,7 @@ class ServerOwnedEvidenceSource:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
return value
|
||||
|
||||
def _validate_integration_source(self, recipe: Mapping[str, str]) -> None:
|
||||
if "integration_source_commit" not in recipe:
|
||||
return
|
||||
|
||||
def read(*arguments: str, binary: bool = False):
|
||||
try:
|
||||
result = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", *arguments],
|
||||
check=True, capture_output=True, text=not binary, timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
return result.stdout if binary else result.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_UNAVAILABLE") from exc
|
||||
|
||||
source = recipe["source_commit"]
|
||||
if source != recipe["integration_source_commit"]:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_MISMATCH")
|
||||
for commit_field, tree_field in (
|
||||
("integration_source_commit", "integration_tree_oid"),
|
||||
("publication_source_commit", "publication_tree_oid"),
|
||||
("repair_source_commit", "repair_tree_oid"),
|
||||
):
|
||||
if read("rev-parse", f"{recipe[commit_field]}^{{tree}}") != recipe[tree_field]:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_MISMATCH")
|
||||
if read("show", "-s", "--format=%P", source) != recipe["authority_base_oid"]:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_MISMATCH")
|
||||
changed = read("diff", "--name-only", recipe["authority_base_oid"], source).splitlines()
|
||||
if sorted(changed) != sorted(C5_INTEGRATION_FILES):
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_FILE_SET_MISMATCH")
|
||||
for path, (component, digest) in C5_INTEGRATION_FILES.items():
|
||||
integrated = read("show", f"{source}:{path}", binary=True)
|
||||
original = read("show", f"{recipe[component]}:{path}", binary=True)
|
||||
if integrated != original or hashlib.sha256(integrated).hexdigest() != digest:
|
||||
raise BuilderError("COLLECTOR_INTEGRATION_BYTES_MISMATCH")
|
||||
|
||||
def __call__(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
self._validate_integration_source(recipe)
|
||||
try:
|
||||
tree_result = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", "rev-parse", f"{recipe['source_commit']}^{{tree}}"],
|
||||
@@ -533,16 +412,6 @@ class ServerOwnedEvidenceSource:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
||||
if getattr(ancestry_result, "returncode", 1) != 0:
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
if recipe.get("tree_oid") is not None and recipe.get("tree_oid") != tree_oid:
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
if recipe.get("accepted_base_oid") is not None:
|
||||
accepted_ancestry = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", "merge-base", "--is-ancestor", recipe["accepted_base_oid"], recipe["source_commit"]],
|
||||
check=False, capture_output=True, text=True, timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if getattr(accepted_ancestry, "returncode", 1) != 0:
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
|
||||
receipt = self._json(self._regular_path(f"{recipe_name}/receipt.json"))
|
||||
if set(receipt) != {"schema", "version", "candidate", "release_plan"}:
|
||||
@@ -619,13 +488,6 @@ class ServerOwnedEvidenceSource:
|
||||
or rollback.get("rollback_sha256") != deployment.get("rollback_sha256")
|
||||
):
|
||||
raise BuilderError("COLLECTOR_ROLLBACK_MISMATCH")
|
||||
if recipe.get("rollback_version") is not None:
|
||||
if deployment.get("rollback_target") != {
|
||||
"version": recipe["rollback_version"],
|
||||
"target_oid": recipe["rollback_target_oid"],
|
||||
"artifact_sha256": recipe["rollback_artifact_sha256"],
|
||||
}:
|
||||
raise BuilderError("COLLECTOR_ROLLBACK_MISMATCH")
|
||||
if (
|
||||
digests["gitea"] != hashlib.sha256(_canonical_json(dict(gitea))).hexdigest()
|
||||
or digests["rollback"] != hashlib.sha256(_canonical_json(dict(rollback))).hexdigest()
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'))
|
||||
@@ -14,11 +13,7 @@ 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') 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'))
|
||||
SECRET = os.getenv('AIOS_WEBOPS_SECRET','change-me-before-production')
|
||||
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')]
|
||||
@@ -48,46 +43,19 @@ 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()))
|
||||
_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 hash_pw(p): return hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
||||
def check_pw(stored,p): return stored in (p, hash_pw(p))
|
||||
def token(username,role):
|
||||
exp=int(time.time())+SESSION_TTL
|
||||
data=f'{username}|{role}|{exp}'
|
||||
sig=hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
|
||||
return data+'|'+sig
|
||||
data=f'{username}|{role}|{int(time.time())}'; sig=hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]; 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
|
||||
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'))
|
||||
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'))
|
||||
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>'
|
||||
@@ -247,7 +215,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=Strict"]); 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=Lax"]); 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,7 +4,6 @@ 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'))
|
||||
@@ -14,11 +13,7 @@ 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') 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'))
|
||||
SECRET = os.getenv('AIOS_WEBOPS_SECRET','change-me-before-production')
|
||||
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')]
|
||||
@@ -48,46 +43,19 @@ 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()))
|
||||
_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 hash_pw(p): return hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
||||
def check_pw(stored,p): return stored in (p, hash_pw(p))
|
||||
def token(username,role):
|
||||
exp=int(time.time())+SESSION_TTL
|
||||
data=f'{username}|{role}|{exp}'
|
||||
sig=hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
|
||||
return data+'|'+sig
|
||||
data=f'{username}|{role}|{int(time.time())}'; sig=hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]; 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
|
||||
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'))
|
||||
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'))
|
||||
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>'
|
||||
@@ -247,7 +215,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=Strict"]); 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=Lax"]); 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())
|
||||
|
||||
@@ -18,10 +18,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
try:
|
||||
import fcntl
|
||||
except ModuleNotFoundError: # pragma: no cover - production host is POSIX
|
||||
fcntl = None
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -379,8 +376,6 @@ class FileLock:
|
||||
self.handle = None
|
||||
|
||||
def __enter__(self) -> "FileLock":
|
||||
if fcntl is None:
|
||||
raise ReleaseBlocked("production executor file locking requires POSIX")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.handle = self.path.open("a+", encoding="utf-8")
|
||||
os.chmod(self.path, 0o600)
|
||||
@@ -393,7 +388,7 @@ class FileLock:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if self.handle is not None and fcntl is not None:
|
||||
if self.handle is not None:
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
self.handle.close()
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Fixed registered composition of the existing shadow API and renewal store."""
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
ORIGINAL_SHA = 'f6393fe614239036a1d8235c98c1f455dc32b700107d88f48190c4f61dae617e'
|
||||
|
||||
|
||||
def service_type(types, renewal, public_key):
|
||||
class RegisteredService(types.SilentReleaseActionService):
|
||||
def __init__(self, root, **kwargs):
|
||||
if 'completion_approvals' in kwargs:
|
||||
raise renewal.RenewalRejected('FIXED_COMPLETION_DEPENDENCY_REQUIRED')
|
||||
original = types.SignedApprovalStore(Path(root) / 'approvals')
|
||||
completion = renewal.RegisteredCompletionApprovalStore(
|
||||
original, public_key, types.SignedReleasePlanApproval,
|
||||
)
|
||||
super().__init__(root, completion_approvals=completion, **kwargs)
|
||||
return RegisteredService
|
||||
|
||||
|
||||
def main():
|
||||
# Original Round3 startup and verifier remain loaded before the new root
|
||||
# is added. The C5 types are a separate registered module as in R2.
|
||||
import silent_release_hardening as original
|
||||
import hashlib
|
||||
if hashlib.sha256(Path(original.__file__).read_bytes()).hexdigest() != ORIGINAL_SHA:
|
||||
raise RuntimeError('ROUND3_HARDENING_DRIFT')
|
||||
if not getattr(original.BuilderAttestationVerifier.verify,
|
||||
'__aios_round3_rollback_contract_patch__', False):
|
||||
raise RuntimeError('ROUND3_VERIFIER_NOT_ACTIVE')
|
||||
import c5_completion_renewal as renewal
|
||||
renewal.configure_registered_bindings()
|
||||
root = renewal.AUTHORITY_ROOT
|
||||
sys.path.insert(0, str(root))
|
||||
if Path(__file__) != root / 'c5_completion_api.py':
|
||||
raise renewal.RenewalRejected('REGISTERED_API_WRAPPER_REQUIRED')
|
||||
policy = renewal.RegisteredCompletionAuthority()()
|
||||
parent = json.loads(renewal.secure_bytes(Path(policy['predecessor_snapshot'])))
|
||||
keyring = json.loads(renewal.secure_bytes(root / 'approval-public-keyring.json'))
|
||||
if keyring.get('algorithm') != 'Ed25519' or parent['key_id'] in keyring.get('revoked', []):
|
||||
raise renewal.RenewalRejected('REGISTERED_APPROVAL_KEY_REQUIRED')
|
||||
public_key = base64.b64decode(keyring['keys'][parent['key_id']], validate=True)
|
||||
renewal.verify_signature(parent, public_key)
|
||||
spec = importlib.util.spec_from_file_location('_registered_c5_completion_types',
|
||||
root / 'silent_release_hardening.py')
|
||||
types = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = types
|
||||
spec.loader.exec_module(types)
|
||||
loaded = runpy.run_path(str(root / 'original_api.py'), run_name='_registered_original_api')
|
||||
g = loaded['main'].__globals__
|
||||
if g['SILENT_RELEASE_EXECUTION_ENABLED'] is not False or Path(g['SILENT_RELEASE_STATE_ROOT']) != renewal.STATE_ROOT:
|
||||
raise renewal.RenewalRejected('EXISTING_SHADOW_PLANE_REQUIRED')
|
||||
g['ReleasePlan'] = types.ReleasePlan
|
||||
g['TrustedCandidateBundle'] = types.TrustedCandidateBundle
|
||||
g['SilentReleaseActionService'] = service_type(types, renewal, public_key)
|
||||
g['TRUSTED_RELEASE_POLICY'] = renewal.TRUSTED_RELEASE_POLICY_PATH
|
||||
service = g['_build_silent_release_service']()
|
||||
if service.execution_enabled or service.policy_sha256 != renewal.TRUSTED_RELEASE_POLICY_SHA256:
|
||||
raise renewal.RenewalRejected('EXACT_API_POLICY_REQUIRED')
|
||||
# Preserve the exact candidate boundary from the existing registered R2.
|
||||
def reject_candidate_creation(payload):
|
||||
raise original.CapabilityAuthorizationError('completion mode cannot create or replace candidates')
|
||||
service.create_release_candidate = reject_candidate_creation
|
||||
g['_SILENT_RELEASE_SERVICE'] = service
|
||||
g['main']()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Completion-only fixed tag and metadata adapter launcher, no runtime effects."""
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
UNIT = 'aios-c5-exact-production-host-r1.service'
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('adapter')
|
||||
parser.add_argument('mode', choices=('tag', 'registry', 'baseline'))
|
||||
args = parser.parse_args()
|
||||
import c5_completion_renewal as renewal
|
||||
renewal.configure_registered_bindings()
|
||||
root = renewal.AUTHORITY_ROOT
|
||||
sys.path.insert(0, str(root))
|
||||
from silent_release_hardening import SignedApprovalStore, SignedReleasePlanApproval
|
||||
if Path(__file__) != root / 'c5_completion_phase.py' or os.geteuid() != 0:
|
||||
raise renewal.RenewalRejected('REGISTERED_COMPLETION_PHASE_REQUIRED')
|
||||
policy = renewal.RegisteredCompletionAuthority()()
|
||||
credentials = Path(os.environ.get('CREDENTIALS_DIRECTORY', ''))
|
||||
if credentials != Path('/run/credentials') / UNIT:
|
||||
raise renewal.RenewalRejected('FIXED_COMPLETION_PURPOSE_UNIT_REQUIRED')
|
||||
if not any(line.split(':', 2)[-1].rstrip('/').endswith('/' + UNIT)
|
||||
for line in Path('/proc/self/cgroup').read_text().splitlines()):
|
||||
raise renewal.RenewalRejected('FIXED_COMPLETION_CGROUP_REQUIRED')
|
||||
module = 'governed_tag_adapter.py' if args.mode == 'tag' else 'release_metadata_adapter.py'
|
||||
if Path(args.adapter) != ROOT / module:
|
||||
raise renewal.RenewalRejected('FIXED_COMPLETION_ADAPTER_REQUIRED')
|
||||
value = json.load(sys.stdin)
|
||||
fixed = {'schema': 'aios.production-executor-request.v1', 'version': 'V2R2C5',
|
||||
'target_oid': renewal.SOURCE, 'evidence_closure_oid': renewal.SOURCE,
|
||||
'artifact_sha256': renewal.ARTIFACT,
|
||||
'approval_plan_id': 'sha256:d8f64b8b92f9fdb17f5c6053b76a68ee5bcbc2f266d91438c4db7893463079f8',
|
||||
'repository_identity': 'aios/aios', 'production_service': 'aios-v2r2c2-production.service',
|
||||
'production_port': 18928}
|
||||
if args.mode != 'tag':
|
||||
fixed['tag_oid'] = renewal.TAG_OID
|
||||
if (set(value) != set(fixed) | {'operation'} or value['operation'] not in {'probe', 'apply'}
|
||||
or any(value[k] != v for k, v in fixed.items())):
|
||||
raise renewal.RenewalRejected('EXACT_COMPLETION_REQUEST_REQUIRED')
|
||||
parent = json.loads(renewal.secure_bytes(Path(policy['predecessor_snapshot'])))
|
||||
ring = json.loads(renewal.secure_bytes(root / 'approval-public-keyring.json'))
|
||||
if parent['key_id'] in ring.get('revoked', []):
|
||||
raise renewal.RenewalRejected('APPROVAL_KEY_REVOKED')
|
||||
public_key = base64.b64decode(ring['keys'][parent['key_id']], validate=True)
|
||||
approval = renewal.RegisteredCompletionApprovalStore(
|
||||
SignedApprovalStore(renewal.STATE_ROOT / 'approvals'), public_key, SignedReleasePlanApproval,
|
||||
).load(renewal.CID)
|
||||
if approval.fingerprint == renewal.OLD_FP:
|
||||
raise renewal.RenewalRejected('FRESH_COMPLETION_SUCCESSOR_REQUIRED')
|
||||
# Loading the store verifies both actual signatures, registered fixed nonce,
|
||||
# append-only chain and current successor lifetime. The historical proof
|
||||
# intentionally remains bound to the original approval, never rewritten.
|
||||
runtime = json.load(urllib.request.urlopen('http://127.0.0.1:18928/health', timeout=10))
|
||||
if any(runtime.get(k) != v for k, v in {
|
||||
'version': 'V2R2C5', 'commit_oid': renewal.SOURCE, 'artifact_sha256': renewal.ARTIFACT,
|
||||
'deployment_state': 'ACTIVE', 'production_deployed': True}.items()):
|
||||
raise renewal.RenewalRejected('CURRENT_C5_RUNTIME_REQUIRED')
|
||||
# Exact signed copies of these historical helpers are read-only here.
|
||||
# Never call their activation, run(), or verification-phase effect methods.
|
||||
import production_verification_phase_r1 as business
|
||||
import public_business_acceptance_probe_r1 as public
|
||||
business.RegisteredVerificationPhase.business_proof(None, True)
|
||||
public.reconcile()
|
||||
os.environ.clear()
|
||||
os.environ.update(PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
PYTHONDONTWRITEBYTECODE='1', AIOS_GITOPS_WRITES_ENABLED='false',
|
||||
AIOS_GITOPS_LEGACY_GLOBAL_WRITES_ENABLED='false',
|
||||
AIOS_GITOPS_CONTROLLER_WORKSPACE=str(renewal.CONTROLLER_WORKSPACE),
|
||||
AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=str(renewal.AUTHORITATIVE_GIT_DIR),
|
||||
AIOS_GITOPS_REPOSITORY_IDENTITY='aios/aios',
|
||||
AIOS_RUNTIME_REGISTRY_ROOT=str(renewal.RUNTIME_REGISTRY_ROOT),
|
||||
AIOS_RUNTIME_BASELINE_PATH=str(renewal.RUNTIME_BASELINE_PATH))
|
||||
if args.mode == 'tag':
|
||||
os.environ.update(GIT_COMMITTER_NAME='owner', GIT_COMMITTER_EMAIL='owner@cnnetmkt.top',
|
||||
GIT_COMMITTER_DATE='1790826063 +0000', GIT_TERMINAL_PROMPT='0',
|
||||
GIT_ASKPASS=str(root / 'publisher-askpass.py'))
|
||||
sys.argv = [module, '--workspace', str(renewal.CONTROLLER_WORKSPACE),
|
||||
'--repository', str(renewal.AUTHORITATIVE_GIT_DIR), '--remote', 'gitea-http',
|
||||
'--gitea-api', 'http://127.0.0.1:3000/api/v1', '--gitea-repo', 'aios/aios',
|
||||
'--gitea-token-file', str(credentials / 'gitea-release-publisher'),
|
||||
'--state-root', str(renewal.HISTORICAL_ROOT / 'tag-publication')]
|
||||
else:
|
||||
sys.argv = [module, '--mode', args.mode, '--expected-previous-baseline', 'V2R2C4',
|
||||
'--expected-previous-baseline-sha256', 'de0c7008facc44c5061239ee490135b58e521989952918f84a57cecb3358483c',
|
||||
'--health-url', 'http://127.0.0.1:18928/health']
|
||||
sys.stdin = io.StringIO(json.dumps(value))
|
||||
runpy.run_path(str(root / module), run_name='__main__')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,630 +0,0 @@
|
||||
"""Append-only C5 completion renewal boundary; activation requires registration.
|
||||
|
||||
The exact Owner-registered API, signer and completion host must call this
|
||||
boundary. This module does not grant authority or perform deployment effects.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
CID = 'rc-v2r2c5-5f65e458957d'
|
||||
# Immutable release content remains compiled into the validator. Renewable
|
||||
# authority identities and filesystem locations are installed only from the
|
||||
# digest-bound, root-owned registration file (see configure_registered_bindings).
|
||||
OLD_FP = '0' * 64
|
||||
SOURCE = '5f65e458957dd51c9973cfa956a624f3cbf54990'
|
||||
TREE = 'ddd239d43d63a7fbd3cff46119c71466c9443737'
|
||||
ARTIFACT = '7f9d8409f07a5e0d36f5db7c55eb82ea70935fa731e057998f29cd1a5ca0a9ad'
|
||||
PLAN = '370521afcf9b2288f6ad3803ca3554a46ee05d2042d53b19b4f4cb80e37a9684'
|
||||
MUTABLE = {'issued_at', 'not_before', 'expires_at', 'nonce', 'fingerprint', 'signature'}
|
||||
CAPABILITIES = ['baseline_registry_finalize', 'runtime_switch', 'tag_publish']
|
||||
AUTHORITY_ROOT = Path('/unconfigured/c5-completion-authority')
|
||||
STATE_ROOT = Path('/unconfigured/c5-completion-state')
|
||||
OWNER_KEY = 'UNCONFIGURED'
|
||||
TAG_OID = '6d1745ff496103db7e721a2b2bc624fda52c5dc8'
|
||||
SAGA_HEAD = '0' * 64
|
||||
HISTORICAL_ROOT = Path('/unconfigured/c5-completion-history')
|
||||
CONTROLLER_WORKSPACE = Path('/unconfigured/c5-controller-workspace')
|
||||
AUTHORITATIVE_GIT_DIR = Path('/unconfigured/c5-authoritative-git')
|
||||
RUNTIME_REGISTRY_ROOT = Path('/unconfigured/c5-runtime-registry')
|
||||
RUNTIME_BASELINE_PATH = Path('/unconfigured/c5-runtime-baseline.json')
|
||||
TRUSTED_RELEASE_POLICY_PATH = Path('/unconfigured/c5-trusted-release-policy.json')
|
||||
TRUSTED_RELEASE_POLICY_SHA256 = '0' * 64
|
||||
BINDINGS_PATH_ENV = 'AIOS_C5_COMPLETION_BINDINGS_PATH'
|
||||
BINDINGS_SHA256_ENV = 'AIOS_C5_COMPLETION_BINDINGS_SHA256'
|
||||
_BINDINGS_DIGEST = None
|
||||
RENEWAL_SOURCE_FILES = ('c5_completion_renewal.py', 'c5_release_execution_host.py',
|
||||
'silent_release_hardening.py', 'c5_completion_signer.py',
|
||||
'c5_completion_api.py', 'c5_completion_phase.py') # gitleaks:allow -- filenames only
|
||||
PHASE_DEPENDENCIES = ('governed_tag_adapter.py', 'release_metadata_adapter.py',
|
||||
'production_verification_phase_r1.py', 'public_business_acceptance_probe_r1.py',
|
||||
'publisher-askpass.py', 'c5_staging_installation.py',
|
||||
'aios_single_host_authority.py', 'registered_owner_signer_adapter.py')
|
||||
|
||||
|
||||
class RenewalRejected(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical(value):
|
||||
return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode()
|
||||
|
||||
|
||||
def digest(raw):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def validate_completion_lineage(gate):
|
||||
"""Bind a current completion-only journal suffix to the frozen Saga15 head.
|
||||
|
||||
The suffix is authenticated by the enclosing registered Owner contract;
|
||||
these checks preserve its origin and exclude deployment/source transitions.
|
||||
"""
|
||||
revision = gate.get('saga_revision')
|
||||
suffix = gate.get('completion_journal_suffix', [])
|
||||
observed = gate.get('observed_at')
|
||||
if (type(revision) is not int or revision < 15 or not isinstance(suffix, list)
|
||||
or type(observed) not in {int, float} or not math.isfinite(observed)):
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
if revision == 15:
|
||||
if gate.get('saga_head') != SAGA_HEAD or suffix:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
return
|
||||
if not 0 < len(suffix) <= 64 or len(suffix) != revision - 15:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
allowed = {
|
||||
'owner_registered_completion_successor_tag_reconciliation': ('TAG_PENDING', {'RECONCILIATION_REQUIRED'}),
|
||||
'owner_registered_tag_capability_successor_verified': ('TAG_VERIFIED', {'RECONCILIATION_REQUIRED', 'TAG_PENDING'}),
|
||||
'baseline_registry_finalization_pending': ('BASELINE_REGISTRY_PENDING', {'TAG_VERIFIED'}),
|
||||
'owner_registered_completion_successor_metadata_reconciliation': ('BASELINE_REGISTRY_PENDING', {'RECONCILIATION_REQUIRED'}),
|
||||
'release_reconciliation_required': ('RECONCILIATION_REQUIRED', {'TAG_PENDING', 'TAG_VERIFIED', 'BASELINE_REGISTRY_PENDING'}),
|
||||
}
|
||||
previous, state = SAGA_HEAD, 'RECONCILIATION_REQUIRED'
|
||||
for offset, event in enumerate(suffix, 16):
|
||||
if (not isinstance(event, dict)
|
||||
or set(event) != {'schema_version', 'sequence', 'previous_hash', 'payload', 'event_hash'}
|
||||
or type(event['schema_version']) is not int or event['schema_version'] != 1
|
||||
or type(event['sequence']) is not int or event['sequence'] != offset + 1
|
||||
or event['previous_hash'] != previous):
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
payload = event['payload']
|
||||
if (not isinstance(payload, dict)
|
||||
or set(payload) != {'candidate_id', 'release_plan_id', 'revision', 'state', 'event', 'timestamp', 'details'}
|
||||
or payload['candidate_id'] != CID
|
||||
or payload['release_plan_id'] != 'pending-' + CID
|
||||
or type(payload['revision']) is not int or payload['revision'] != offset
|
||||
or not isinstance(payload['details'], dict)
|
||||
or not isinstance(payload['event'], str) or payload['event'] not in allowed):
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
target, origins = allowed[payload['event']]
|
||||
if payload['state'] != target or state not in origins:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
if ('release_plan_sha256' in payload['details']
|
||||
and payload['details']['release_plan_sha256'] != PLAN):
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
try:
|
||||
if timestamp(payload['timestamp']) > gate['observed_at']:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
actual = digest(canonical({key: event[key] for key in ('sequence', 'previous_hash', 'payload')}))
|
||||
except (TypeError, ValueError, AttributeError, KeyError) as exc:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID') from exc
|
||||
if event['event_hash'] != actual:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
previous, state = actual, target
|
||||
if gate.get('saga_head') != previous:
|
||||
raise RenewalRejected('COMPLETION_LINEAGE_INVALID')
|
||||
|
||||
|
||||
def timestamp(value):
|
||||
parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
if parsed.tzinfo is None or parsed.utcoffset().total_seconds() != 0:
|
||||
raise RenewalRejected('UTC_REQUIRED')
|
||||
return parsed.timestamp()
|
||||
|
||||
|
||||
def verify_signature(approval, public_key):
|
||||
payload = {k: v for k, v in approval.items() if k not in {'signature', 'fingerprint'}}
|
||||
raw = canonical(payload)
|
||||
if approval.get('fingerprint') != digest(raw):
|
||||
raise RenewalRejected('APPROVAL_DIGEST_INVALID')
|
||||
Ed25519PublicKey.from_public_bytes(public_key).verify(
|
||||
base64.b64decode(approval['signature'], validate=True), raw
|
||||
)
|
||||
|
||||
|
||||
def validate_successor(old, new, public_key, *, now):
|
||||
"""Validate historical predecessor and fresh identical-binding successor.
|
||||
|
||||
Historical signature verification does not authorize an expired effect.
|
||||
The caller must separately verify its fresh registered Owner capability.
|
||||
"""
|
||||
verify_signature(old, public_key)
|
||||
verify_signature(new, public_key)
|
||||
exact = {'candidate_id': CID, 'source_commit': SOURCE, 'tree_oid': TREE,
|
||||
'artifact_sha256': ARTIFACT, 'release_plan_sha256': PLAN,
|
||||
'fingerprint': OLD_FP, 'capabilities': CAPABILITIES}
|
||||
if any(old.get(k) != v for k, v in exact.items()):
|
||||
raise RenewalRejected('EXACT_PREDECESSOR_REQUIRED')
|
||||
if set(old) != set(new) or any(new[k] != old[k] for k in set(old) - MUTABLE):
|
||||
raise RenewalRejected('IMMUTABLE_APPROVAL_BINDING_DRIFT')
|
||||
if timestamp(old['expires_at']) > now:
|
||||
raise RenewalRejected('PREDECESSOR_NOT_EXPIRED')
|
||||
issued, not_before, expires = (timestamp(new[k]) for k in ['issued_at', 'not_before', 'expires_at'])
|
||||
if not issued <= not_before <= now < expires or not 0 < expires - issued <= 900:
|
||||
raise RenewalRejected('FRESH_BOUNDED_APPROVAL_REQUIRED')
|
||||
if new['nonce'] == old['nonce'] or new['fingerprint'] == old['fingerprint']:
|
||||
raise RenewalRejected('FRESH_APPROVAL_IDENTITY_REQUIRED')
|
||||
return new['fingerprint']
|
||||
|
||||
|
||||
def secure_bytes(path, owner_uid=0):
|
||||
path = Path(path)
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
with os.fdopen(descriptor, 'rb') as stream:
|
||||
metadata = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != owner_uid or metadata.st_mode & 0o022:
|
||||
raise RenewalRejected('UNSAFE_RENEWAL_FILE')
|
||||
return stream.read()
|
||||
|
||||
|
||||
def _safe_parents(path):
|
||||
"""Reject writable or symlinked ancestors before opening authority files."""
|
||||
path = Path(path)
|
||||
if not path.is_absolute() or '..' in path.parts:
|
||||
raise RenewalRejected('ABSOLUTE_CANONICAL_PATH_REQUIRED')
|
||||
for parent in path.parents:
|
||||
info = parent.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise RenewalRejected('UNSAFE_AUTHORITY_ANCESTOR')
|
||||
|
||||
|
||||
def _sha(value):
|
||||
return isinstance(value, str) and len(value) == 64 and all(c in '0123456789abcdef' for c in value)
|
||||
|
||||
|
||||
def _oid(value):
|
||||
return isinstance(value, str) and len(value) == 40 and all(c in '0123456789abcdef' for c in value)
|
||||
|
||||
|
||||
def configure_registered_bindings(path=None, expected_sha256=None, *, owner_uid=0):
|
||||
"""Install renewable completion bindings from one authenticated file.
|
||||
|
||||
No historical approval fingerprint, Saga head, Owner key, or authority/state
|
||||
path is a runtime default. The service registration must provide both the
|
||||
absolute file path and its SHA-256 digest.
|
||||
"""
|
||||
global _BINDINGS_DIGEST
|
||||
path = Path(path or os.environ.get(BINDINGS_PATH_ENV, ''))
|
||||
expected_sha256 = expected_sha256 or os.environ.get(BINDINGS_SHA256_ENV, '')
|
||||
if not path.is_absolute() or not _sha(expected_sha256):
|
||||
raise RenewalRejected('REGISTERED_COMPLETION_BINDINGS_REQUIRED')
|
||||
_safe_parents(path)
|
||||
raw = secure_bytes(path, owner_uid=owner_uid)
|
||||
if digest(raw) != expected_sha256:
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_DIGEST_MISMATCH')
|
||||
if _BINDINGS_DIGEST is not None and _BINDINGS_DIGEST != expected_sha256:
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_REBIND_FORBIDDEN')
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_INVALID') from exc
|
||||
fields = {
|
||||
'schema', 'authority_root', 'state_root', 'owner_key_id',
|
||||
'predecessor_fingerprint', 'saga_head', 'historical_root',
|
||||
'controller_workspace', 'authoritative_git_dir',
|
||||
'runtime_registry_root', 'runtime_baseline_path',
|
||||
'trusted_release_policy_path', 'trusted_release_policy_sha256',
|
||||
}
|
||||
if not isinstance(value, dict) or set(value) != fields or value.get('schema') != 'aios.c5.completion-bindings.v1':
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_SCHEMA_INVALID')
|
||||
authority_root = Path(value['authority_root'])
|
||||
state_root = Path(value['state_root'])
|
||||
governed_paths = {
|
||||
name: Path(value[name]) for name in (
|
||||
'historical_root', 'controller_workspace', 'authoritative_git_dir',
|
||||
'runtime_registry_root', 'runtime_baseline_path', 'trusted_release_policy_path',
|
||||
)
|
||||
}
|
||||
owner_key_id = value['owner_key_id']
|
||||
try:
|
||||
owner_key_valid = (isinstance(owner_key_id, str)
|
||||
and owner_key_id.startswith('SHA256:')
|
||||
and len(base64.b64decode(owner_key_id[7:], validate=True)) == 32)
|
||||
except (ValueError, TypeError):
|
||||
owner_key_valid = False
|
||||
all_paths = (authority_root, state_root, *governed_paths.values())
|
||||
if (any(not item.is_absolute() or '..' in item.parts for item in all_paths)
|
||||
or not _sha(value['predecessor_fingerprint']) or not _sha(value['saga_head'])
|
||||
or not _sha(value['trusted_release_policy_sha256'])
|
||||
or not owner_key_valid):
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_VALUE_INVALID')
|
||||
if path.parent != authority_root:
|
||||
raise RenewalRejected('COMPLETION_BINDINGS_ROOT_MISMATCH')
|
||||
global AUTHORITY_ROOT, STATE_ROOT, OWNER_KEY, OLD_FP, SAGA_HEAD
|
||||
global HISTORICAL_ROOT, CONTROLLER_WORKSPACE, AUTHORITATIVE_GIT_DIR
|
||||
global RUNTIME_REGISTRY_ROOT, RUNTIME_BASELINE_PATH
|
||||
global TRUSTED_RELEASE_POLICY_PATH, TRUSTED_RELEASE_POLICY_SHA256
|
||||
AUTHORITY_ROOT = authority_root
|
||||
STATE_ROOT = state_root
|
||||
OWNER_KEY = value['owner_key_id']
|
||||
OLD_FP = value['predecessor_fingerprint']
|
||||
SAGA_HEAD = value['saga_head']
|
||||
HISTORICAL_ROOT = governed_paths['historical_root']
|
||||
CONTROLLER_WORKSPACE = governed_paths['controller_workspace']
|
||||
AUTHORITATIVE_GIT_DIR = governed_paths['authoritative_git_dir']
|
||||
RUNTIME_REGISTRY_ROOT = governed_paths['runtime_registry_root']
|
||||
RUNTIME_BASELINE_PATH = governed_paths['runtime_baseline_path']
|
||||
TRUSTED_RELEASE_POLICY_PATH = governed_paths['trusted_release_policy_path']
|
||||
TRUSTED_RELEASE_POLICY_SHA256 = value['trusted_release_policy_sha256']
|
||||
_BINDINGS_DIGEST = expected_sha256
|
||||
return dict(value)
|
||||
|
||||
|
||||
class RegisteredCompletionAuthority:
|
||||
"""Fixed root registration, Owner signature and immutable evidence verifier.
|
||||
|
||||
The completed production proofs are historical facts. This fresh authority
|
||||
permits only approval renewal and remaining metadata effects; it cannot
|
||||
authorize another runtime switch or public acceptance Task.
|
||||
"""
|
||||
|
||||
@property
|
||||
def root(self):
|
||||
return AUTHORITY_ROOT
|
||||
|
||||
@property
|
||||
def state_root(self):
|
||||
return STATE_ROOT
|
||||
|
||||
def __call__(self):
|
||||
from c5_staging_installation import RegistryOwnerVerifier
|
||||
root = AUTHORITY_ROOT
|
||||
_safe_parents(root / 'contract.json')
|
||||
envelope = json.loads(secure_bytes(root / 'contract.json'))
|
||||
if set(envelope) != {'payload', 'signature'}:
|
||||
raise RenewalRejected('EXACT_AUTHORITY_ENVELOPE_REQUIRED')
|
||||
p = envelope['payload']
|
||||
fields = {'schema', 'host', 'repository', 'candidate_id', 'source_commit', 'tree_oid',
|
||||
'artifact_sha256', 'release_plan_sha256', 'state_root', 'predecessor_fingerprint',
|
||||
'predecessor_sha256', 'predecessor_uid', 'predecessor_snapshot', 'approval_nonce',
|
||||
'issued_at', 'expires_at', 'owner_key_id', 'owner_namespace', 'allowed_effects',
|
||||
'runtime_effects_allowed', 'files', 'gate_path', 'gate_observed_at',
|
||||
'governed_source_commit', 'governed_source_tree'}
|
||||
if not isinstance(p, dict) or set(p) != fields:
|
||||
raise RenewalRejected('EXACT_AUTHORITY_SCHEMA_REQUIRED')
|
||||
exact = {'schema': 'aios.c5.registered-completion-authority.v1', 'host': 'cn-network-mkt',
|
||||
'repository': 'aios/aios', 'candidate_id': CID, 'source_commit': SOURCE,
|
||||
'tree_oid': TREE, 'artifact_sha256': ARTIFACT, 'release_plan_sha256': PLAN,
|
||||
'state_root': str(STATE_ROOT), 'predecessor_fingerprint': OLD_FP,
|
||||
'owner_key_id': OWNER_KEY, 'owner_namespace': 'aios-owner-approval-v1',
|
||||
'allowed_effects': ['approval_successor_append', 'baseline_finalize', 'registry_finalize', 'tag_publish'],
|
||||
'runtime_effects_allowed': False}
|
||||
if any(p[k] != v for k, v in exact.items()) or type(p['runtime_effects_allowed']) is not bool:
|
||||
raise RenewalRejected('EXACT_COMPLETION_AUTHORITY_REQUIRED')
|
||||
now = time.time()
|
||||
if (type(p['issued_at']) is not int or type(p['expires_at']) is not int
|
||||
or not p['issued_at'] <= now < p['expires_at']
|
||||
or not 0 < p['expires_at'] - p['issued_at'] <= 1800
|
||||
or type(p['gate_observed_at']) not in {int, float}
|
||||
or not math.isfinite(p['gate_observed_at'])
|
||||
or not p['issued_at'] - 60 <= p['gate_observed_at'] <= p['issued_at']):
|
||||
raise RenewalRejected('FRESH_OWNER_AUTHORITY_REQUIRED')
|
||||
if (not _sha(p['approval_nonce']) or not _sha(p['predecessor_sha256'])
|
||||
or type(p['predecessor_uid']) is not int or p['predecessor_uid'] not in {0, 1000}
|
||||
or any(not isinstance(p[k], str) or len(p[k]) != 40
|
||||
or any(c not in '0123456789abcdef' for c in p[k])
|
||||
for k in ('governed_source_commit', 'governed_source_tree'))):
|
||||
raise RenewalRejected('AUTHORITY_IDENTITIES_INVALID')
|
||||
verifier = RegistryOwnerVerifier(Path('/etc/aios/single-host-signer-registry.json'),
|
||||
owner_signer_id='local-owner-authority')
|
||||
if not verifier.verify(namespace=p['owner_namespace'], payload=canonical(p),
|
||||
signature=envelope['signature'], key_id=p['owner_key_id']):
|
||||
raise RenewalRejected('OWNER_SIGNATURE_REJECTED')
|
||||
payload_sha = digest(canonical(p))
|
||||
registered = json.loads(secure_bytes(root / 'registration.json'))
|
||||
if (set(registered) != {'schema', 'status', 'payload_sha256', 'approval_nonce'}
|
||||
or registered != {'schema': 'aios.c5.completion-registration.v1', 'status': 'REGISTERED',
|
||||
'payload_sha256': payload_sha, 'approval_nonce': p['approval_nonce']}):
|
||||
raise RenewalRejected('ROOT_REGISTRATION_REQUIRED')
|
||||
if (root / 'rollback.json').exists():
|
||||
raise RenewalRejected('REGISTRATION_ROLLED_BACK')
|
||||
pins = p['files']
|
||||
if not isinstance(pins, list) or not pins:
|
||||
raise RenewalRejected('SIGNED_FILE_CLOSURE_REQUIRED')
|
||||
checked = {}
|
||||
for item in pins:
|
||||
if (not isinstance(item, dict) or set(item) != {'path', 'sha256', 'uid'}
|
||||
or not _sha(item['sha256']) or type(item['uid']) is not int or item['uid'] != 0):
|
||||
raise RenewalRejected('SIGNED_FILE_PIN_INVALID')
|
||||
path = Path(item['path'])
|
||||
if path.parent != root or str(path) in checked:
|
||||
raise RenewalRejected('SIGNED_FILE_PATH_INVALID')
|
||||
_safe_parents(path)
|
||||
raw = secure_bytes(path)
|
||||
if digest(raw) != item['sha256']:
|
||||
raise RenewalRejected('SIGNED_FILE_BYTES_DRIFT')
|
||||
checked[str(path)] = raw
|
||||
required = {str(root / name) for name in (*RENEWAL_SOURCE_FILES, *PHASE_DEPENDENCIES, 'source-material.json',
|
||||
'historical-production-proof.json', 'predecessor-approval.json', 'current-gate.json',
|
||||
'approval-public-keyring.json', 'original_api.py')}
|
||||
if not required <= set(checked):
|
||||
raise RenewalRejected('INCOMPLETE_REGISTERED_CLOSURE')
|
||||
if Path(__file__) != root / 'c5_completion_renewal.py':
|
||||
raise RenewalRejected('UNREGISTERED_AUTHORITY_MODULE')
|
||||
if (p['predecessor_snapshot'] != str(root / 'predecessor-approval.json')
|
||||
or p['gate_path'] != str(root / 'current-gate.json')
|
||||
or digest(checked[p['predecessor_snapshot']]) != p['predecessor_sha256']):
|
||||
raise RenewalRejected('REGISTERED_SNAPSHOT_DRIFT')
|
||||
material = json.loads(checked[str(root / 'source-material.json')])
|
||||
if (material.get('status') != 'MATERIALIZED' or material.get('task_status') != 'SUCCEEDED'
|
||||
or material.get('remote_verified') is not True
|
||||
or material.get('source_commit') != p['governed_source_commit']
|
||||
or material.get('source_tree') != p['governed_source_tree']
|
||||
or material.get('file_sha256') != {
|
||||
name: digest(checked[str(root / name)])
|
||||
for name in RENEWAL_SOURCE_FILES}):
|
||||
raise RenewalRejected('GOVERNED_CODE_PROVENANCE_REQUIRED')
|
||||
historical = json.loads(checked[str(root / 'historical-production-proof.json')])
|
||||
if (historical.get('candidate_id') != CID or historical.get('approval_fingerprint') != OLD_FP
|
||||
or historical.get('release_plan_sha256') != PLAN
|
||||
or historical.get('status') != 'COMPLETED'
|
||||
or any(historical.get(k) is not True for k in (
|
||||
'public_business_verified', 'real_c4_rollback_verified', 'c5_restore_verified'))):
|
||||
raise RenewalRejected('COMPLETED_PRODUCTION_PROOF_REQUIRED')
|
||||
gate = json.loads(checked[p['gate_path']])
|
||||
expected_gate = {'candidate_id': CID, 'source_commit': SOURCE, 'artifact_sha256': ARTIFACT,
|
||||
'release_plan_sha256': PLAN,
|
||||
'local_tag_oid': TAG_OID, 'runtime_verified': True, 'business_verified': True,
|
||||
'global_writes_enabled': False, 'observed_at': p['gate_observed_at']}
|
||||
if (any(gate.get(k) != v for k, v in expected_gate.items())
|
||||
or any(type(gate.get(k)) is not bool for k in (
|
||||
'runtime_verified', 'business_verified', 'global_writes_enabled'))
|
||||
or gate.get('remote_tag_oid') not in {None, TAG_OID}):
|
||||
raise RenewalRejected('EXACT_POSTVERIFICATION_GATE_REQUIRED')
|
||||
validate_completion_lineage(gate)
|
||||
return {**p, 'payload_sha256': payload_sha}
|
||||
|
||||
def validate_host(self, *, state_root, candidate_id, source_commit, tree_oid,
|
||||
artifact_sha256, release_plan_sha256):
|
||||
self()
|
||||
if (Path(state_root), candidate_id, source_commit, tree_oid, artifact_sha256, release_plan_sha256) != (
|
||||
STATE_ROOT, CID, SOURCE, TREE, ARTIFACT, PLAN):
|
||||
raise RenewalRejected('COMPLETION_HOST_BINDING_DRIFT')
|
||||
|
||||
def deny_runtime_effect(self, **kwargs):
|
||||
self()
|
||||
raise RenewalRejected('COMPLETION_RUNTIME_EFFECT_FORBIDDEN')
|
||||
|
||||
|
||||
class CompletionRuntimeGuard:
|
||||
def __init__(self, authority):
|
||||
if type(authority) is not RegisteredCompletionAuthority:
|
||||
raise RenewalRejected('REGISTERED_COMPLETION_AUTHORITY_REQUIRED')
|
||||
self.authority = authority
|
||||
|
||||
def execute(self, **kwargs):
|
||||
return self.authority.deny_runtime_effect(**kwargs)
|
||||
|
||||
|
||||
def append_file(path, raw):
|
||||
"""An existing record must be identical; it is never replaced."""
|
||||
path = Path(path)
|
||||
try:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
except FileExistsError:
|
||||
if secure_bytes(path, owner_uid=os.geteuid()) != raw:
|
||||
raise RenewalRejected('APPEND_ONLY_RECORD_CONFLICT')
|
||||
return False
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(raw)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
fd = os.open(path.parent, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return True
|
||||
|
||||
|
||||
def publish_successor(directory, old, new, public_key, *, now, registration_digest, predecessor_bytes):
|
||||
"""Append records inside the SAME plane under a verified caller lock.
|
||||
|
||||
Registration validation, current Saga CAS, production/business readbacks,
|
||||
and permission to call this function remain mandatory caller gates.
|
||||
"""
|
||||
fp = validate_successor(old, new, public_key, now=now)
|
||||
if json.loads(predecessor_bytes) != old:
|
||||
raise RenewalRejected('PREDECESSOR_BYTES_DRIFT')
|
||||
predecessor_bytes_digest = digest(predecessor_bytes)
|
||||
if len(registration_digest) != 64 or any(c not in '0123456789abcdef' for c in registration_digest):
|
||||
raise RenewalRejected('REGISTERED_DIGEST_REQUIRED')
|
||||
directory = Path(directory)
|
||||
metadata = directory.lstat()
|
||||
if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.geteuid() or metadata.st_mode & 0o022:
|
||||
raise RenewalRejected('UNSAFE_APPEND_DIRECTORY')
|
||||
receipt = {'schema': 'aios.c5.completion-approval-successor.v1', 'candidate_id': CID,
|
||||
'predecessor_fingerprint': OLD_FP, 'predecessor_bytes_sha256': predecessor_bytes_digest,
|
||||
'successor_fingerprint': fp, 'successor_sha256': digest(canonical(new)),
|
||||
'owner_registration_payload_sha256': registration_digest,
|
||||
'completion_only': True, 'runtime_effects_allowed': False}
|
||||
# A crash after the first append is unresolved until its exact receipt is
|
||||
# reconciled. A loader must require BOTH files; no mutable current pointer.
|
||||
append_file(directory / (fp + '.approval.json'), canonical(new))
|
||||
append_file(directory / (fp + '.receipt.json'), canonical(receipt))
|
||||
return receipt
|
||||
|
||||
|
||||
class CompletionApprovalStore:
|
||||
"""Explicit store dependency for a registered completion host/API.
|
||||
|
||||
The original store and original candidate approval remain untouched.
|
||||
No global class replacement or expired approval verification is used.
|
||||
An authenticated registration fixes ONE successor nonce and immutable
|
||||
receipt path before signing. The host must separately enforce TAG_PENDING
|
||||
and forbid runtime effects; this store does not execute a Saga.
|
||||
"""
|
||||
|
||||
def __init__(self, original, directory, public_key, authority, approval_type):
|
||||
self.original = original
|
||||
self.directory = Path(directory)
|
||||
self.public_key = public_key
|
||||
self.authority = authority
|
||||
self.approval_type = approval_type
|
||||
|
||||
def _policy(self):
|
||||
# authority must be the fixed registered verifier, not a caller-supplied
|
||||
# HTTP callback. Wiring it is outside this unregistered draft.
|
||||
policy = self.authority()
|
||||
if policy.get('candidate_id') != CID or policy.get('predecessor_fingerprint') != OLD_FP:
|
||||
raise RenewalRejected('REGISTERED_EXACT_POLICY_REQUIRED')
|
||||
nonce = policy.get('approval_nonce', '')
|
||||
if not isinstance(nonce, str) or len(nonce) != 64 or any(c not in '0123456789abcdef' for c in nonce):
|
||||
raise RenewalRejected('REGISTERED_NONCE_REQUIRED')
|
||||
return policy, self.directory / (nonce + '.activation.json')
|
||||
|
||||
def _parent(self):
|
||||
old = self.original.load(CID)
|
||||
policy, _ = self._policy()
|
||||
owner = policy.get('predecessor_uid')
|
||||
if type(owner) is not int or owner not in {0, 1000}:
|
||||
raise RenewalRejected('REGISTERED_PREDECESSOR_OWNER_REQUIRED')
|
||||
raw = secure_bytes(self.original.root / (CID + '.json'), owner_uid=owner)
|
||||
if json.loads(raw) != old.to_dict() or digest(raw) != policy.get('predecessor_sha256'):
|
||||
raise RenewalRejected('PREDECESSOR_FILE_DRIFT')
|
||||
return old.to_dict(), raw
|
||||
|
||||
def load(self, candidate_id):
|
||||
if candidate_id != CID:
|
||||
return self.original.load(candidate_id)
|
||||
policy, activation = self._policy()
|
||||
if not activation.exists():
|
||||
return self.original.load(candidate_id)
|
||||
receipt = json.loads(secure_bytes(activation, owner_uid=policy['predecessor_uid']))
|
||||
expected = {'schema', 'candidate_id', 'predecessor_fingerprint', 'predecessor_bytes_sha256',
|
||||
'successor_fingerprint', 'successor_sha256', 'owner_registration_payload_sha256',
|
||||
'completion_only', 'runtime_effects_allowed'}
|
||||
if set(receipt) != expected or receipt.get('owner_registration_payload_sha256') != policy['payload_sha256']:
|
||||
raise RenewalRejected('ACTIVATION_RECEIPT_INVALID')
|
||||
fp = receipt.get('successor_fingerprint', '')
|
||||
if len(fp) != 64 or any(c not in '0123456789abcdef' for c in fp):
|
||||
raise RenewalRejected('SUCCESSOR_FINGERPRINT_INVALID')
|
||||
raw = secure_bytes(self.directory / (fp + '.approval.json'), owner_uid=policy['predecessor_uid'])
|
||||
new = json.loads(raw)
|
||||
parent, parent_raw = self._parent()
|
||||
valid = validate_successor(parent, new, self.public_key, now=datetime.now(timezone.utc).timestamp())
|
||||
exact = {'candidate_id': CID, 'predecessor_fingerprint': OLD_FP,
|
||||
'predecessor_bytes_sha256': digest(parent_raw), 'successor_fingerprint': valid,
|
||||
'successor_sha256': digest(raw), 'completion_only': True, 'runtime_effects_allowed': False}
|
||||
if any(receipt.get(k) != v for k, v in exact.items()) or new['nonce'] != policy['approval_nonce']:
|
||||
raise RenewalRejected('SUCCESSOR_ACTIVATION_DRIFT')
|
||||
durable = json.loads(secure_bytes(self.directory / (fp + '.receipt.json'), owner_uid=policy['predecessor_uid']))
|
||||
if durable != receipt:
|
||||
raise RenewalRejected('DURABLE_RECEIPT_DRIFT')
|
||||
return self.approval_type.from_dict(new)
|
||||
|
||||
def save(self, candidate_id, approval):
|
||||
if candidate_id != CID:
|
||||
return self.original.save(candidate_id, approval)
|
||||
policy, activation = self._policy()
|
||||
parent, raw = self._parent()
|
||||
if approval.fingerprint == parent['fingerprint']:
|
||||
return self.original.save(candidate_id, approval)
|
||||
new = approval.to_dict()
|
||||
if new.get('nonce') != policy['approval_nonce']:
|
||||
raise RenewalRejected('REGISTERED_NONCE_MISMATCH')
|
||||
# The caller supplies its authenticated registered effect lock and
|
||||
# invokes the existing ApprovalVerifier before this store boundary.
|
||||
receipt = publish_successor(self.directory, parent, new, self.public_key,
|
||||
now=datetime.now(timezone.utc).timestamp(),
|
||||
registration_digest=policy['payload_sha256'], predecessor_bytes=raw)
|
||||
created = append_file(activation, canonical(receipt))
|
||||
if self.load(candidate_id).fingerprint != approval.fingerprint:
|
||||
raise RenewalRejected('SUCCESSOR_READBACK_FAILED')
|
||||
return activation, created
|
||||
|
||||
|
||||
class RegisteredCompletionApprovalStore(CompletionApprovalStore):
|
||||
"""Production dependency; no request-selected root or authority callback."""
|
||||
|
||||
def __init__(self, original, public_key, approval_type):
|
||||
if Path(original.root) != STATE_ROOT / 'approvals':
|
||||
raise RenewalRejected('SAME_APPROVAL_PLANE_REQUIRED')
|
||||
super().__init__(original, STATE_ROOT / 'approvals' / 'c5-completion-successors',
|
||||
public_key, RegisteredCompletionAuthority(), approval_type)
|
||||
|
||||
|
||||
class CompletionSignerMixin:
|
||||
"""Narrow additional method of the EXISTING credential-bound signer.
|
||||
|
||||
Activation must register this exact mixin, verifier and namespace snapshot
|
||||
pins. The encrypted private key stays in the existing signer's unit. This
|
||||
draft supplies no activation, credential access or HTTP route.
|
||||
"""
|
||||
|
||||
def sign_exact_completion_successor(self, request):
|
||||
expected = {'schema', 'operation', 'candidate_id', 'predecessor_fingerprint'}
|
||||
if (set(request) != expected or request.get('operation') != 'renew_exact_completion_approval'
|
||||
or request.get('candidate_id') != CID or request.get('predecessor_fingerprint') != OLD_FP
|
||||
or request.get('schema') != 'aios.exact-release-signer.request.v1'):
|
||||
raise RenewalRejected('EXACT_SIGNER_RENEWAL_REQUEST_REQUIRED')
|
||||
policy = self.completion_authority()
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
if (policy.get('candidate_id') != CID or policy.get('predecessor_fingerprint') != OLD_FP
|
||||
or not policy['issued_at'] <= now < policy['expires_at']
|
||||
or not 0 <= now - policy['gate_observed_at'] <= 60):
|
||||
raise RenewalRejected('FRESH_REGISTERED_COMPLETION_GATE_REQUIRED')
|
||||
old_raw = secure_bytes(policy['predecessor_snapshot'], owner_uid=0)
|
||||
if digest(old_raw) != policy['predecessor_sha256']:
|
||||
raise RenewalRejected('REGISTERED_SIGNER_PREIMAGE_DRIFT')
|
||||
old = json.loads(old_raw)
|
||||
public_key = self.private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
verify_signature(old, public_key)
|
||||
exact = {'candidate_id': CID, 'source_commit': SOURCE, 'tree_oid': TREE,
|
||||
'artifact_sha256': ARTIFACT, 'release_plan_sha256': PLAN,
|
||||
'fingerprint': OLD_FP, 'capabilities': CAPABILITIES,
|
||||
'key_id': self.policy.key_id, 'owner_identity': self.policy.owner_identity,
|
||||
'trust_domain': self.policy.trust_domain,
|
||||
'owner_approval_record_oid': self.authorization_oid}
|
||||
if any(old.get(k) != v for k, v in exact.items()) or timestamp(old['expires_at']) > now:
|
||||
raise RenewalRejected('EXACT_EXPIRED_SIGNER_PREDECESSOR_REQUIRED')
|
||||
nonce = policy['approval_nonce']
|
||||
if len(nonce) != 64 or any(c not in '0123456789abcdef' for c in nonce) or nonce == old['nonce']:
|
||||
raise RenewalRejected('FRESH_REGISTERED_SIGNER_NONCE_REQUIRED')
|
||||
output = self.state_root / 'completion-renewals' / (nonce + '.json')
|
||||
if output.exists():
|
||||
result = json.loads(secure_bytes(output, owner_uid=0))
|
||||
if (result.get('registration_payload_sha256') != policy['payload_sha256']
|
||||
or result['approval']['nonce'] != nonce):
|
||||
raise RenewalRejected('SIGNER_REPLAY_CONFLICT')
|
||||
validate_successor(old, result['approval'], public_key, now=now)
|
||||
return {**result, 'status': 'SIGNED_IDEMPOTENT'}
|
||||
expiry = min(now + 600, policy['expires_at'] - 5)
|
||||
if expiry - now < 120:
|
||||
raise RenewalRejected('INSUFFICIENT_SIGNER_WINDOW')
|
||||
def utc(value):
|
||||
return datetime.fromtimestamp(value, timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
||||
payload = {k:v for k,v in old.items() if k not in {'fingerprint', 'signature'}}
|
||||
payload.update(issued_at=utc(now), not_before=utc(now), expires_at=utc(expiry), nonce=nonce)
|
||||
raw = canonical(payload)
|
||||
# All candidate, plan, historical signature and registered freshness
|
||||
# checks precede this only signing operation.
|
||||
approval = {**payload, 'fingerprint': digest(raw),
|
||||
'signature': base64.b64encode(self.private_key.sign(raw)).decode()}
|
||||
validate_successor(old, approval, public_key, now=now)
|
||||
result = {'schema': 'aios.exact-release-signer.response.v1', 'status': 'SIGNED',
|
||||
'approval': approval, 'registration_payload_sha256': policy['payload_sha256'],
|
||||
'predecessor_fingerprint': OLD_FP, 'completion_only': True, 'arbitrary_signing': False}
|
||||
# This registered directory must be created by the activation consumer
|
||||
# inside the existing signer state root, never a request path.
|
||||
append_file(output, canonical(result))
|
||||
return result
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Registered fixed extension of the existing credential-bound signer unit."""
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from c5_completion_renewal import (
|
||||
CompletionSignerMixin, RegisteredCompletionAuthority,
|
||||
RenewalRejected, secure_bytes, digest,
|
||||
)
|
||||
|
||||
ORIGINAL = Path('/usr/local/libexec/aios_exact_release_signer.py')
|
||||
ORIGINAL_SHA = '6e2a5a9df0edfe566175e5b556d15901b2c8fe4484388ad74af2ac64f810c930'
|
||||
|
||||
|
||||
def signer_class(original):
|
||||
class CompletionSigner(CompletionSignerMixin, original.ExactReleaseSigner):
|
||||
def completion_authority(self):
|
||||
return RegisteredCompletionAuthority()()
|
||||
|
||||
def handle(self, request):
|
||||
if request.get('operation') == 'renew_exact_completion_approval':
|
||||
return self.sign_exact_completion_successor(request)
|
||||
return super().handle(request)
|
||||
return CompletionSigner
|
||||
|
||||
|
||||
def main():
|
||||
import c5_completion_renewal as renewal
|
||||
renewal.configure_registered_bindings()
|
||||
if Path(__file__) != renewal.AUTHORITY_ROOT / 'c5_completion_signer.py':
|
||||
raise RenewalRejected('REGISTERED_SIGNER_WRAPPER_REQUIRED')
|
||||
RegisteredCompletionAuthority()()
|
||||
if digest(secure_bytes(ORIGINAL)) != ORIGINAL_SHA:
|
||||
raise RenewalRejected('ORIGINAL_SIGNER_PREIMAGE_DRIFT')
|
||||
spec = importlib.util.spec_from_file_location('c5_original_exact_release_signer', ORIGINAL)
|
||||
original = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = original
|
||||
spec.loader.exec_module(original)
|
||||
credentials = Path(os.environ.get('CREDENTIALS_DIRECTORY', ''))
|
||||
if not credentials.is_absolute() or credentials.parent != Path('/run/credentials'):
|
||||
raise RenewalRejected('EXISTING_SIGNER_PURPOSE_CREDENTIAL_REQUIRED')
|
||||
if credentials.name != 'aios-exact-release-signer.service':
|
||||
raise RenewalRejected('EXISTING_SIGNER_UNIT_REQUIRED')
|
||||
cgroup = Path('/proc/self/cgroup').read_text()
|
||||
if not any(line.split(':', 2)[-1].rstrip('/').endswith('/aios-exact-release-signer.service')
|
||||
for line in cgroup.splitlines()):
|
||||
raise RenewalRejected('EXISTING_SIGNER_CGROUP_REQUIRED')
|
||||
signer = signer_class(original)(
|
||||
Path('/etc/aios-gitops/exact-release-signer-policy.json'),
|
||||
credentials / 'owner_release_private_key',
|
||||
)
|
||||
original.serve(Path('/run/aios-exact-release-signer/signer.sock'), signer)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -1,503 +0,0 @@
|
||||
"""Exact C5 runtime-switch adapter for the silent-release Saga.
|
||||
|
||||
The adapter owns only the production deployment phase. Tag publication and
|
||||
Registry/Baseline finalization remain separate ``ReleaseOrchestrator`` Saga
|
||||
steps. A host must register immutable executable bytes and provide the real
|
||||
HMAC-authenticated source-admission store plus the signed Owner approval
|
||||
verifier before this adapter can be constructed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
from typing import Any, Mapping
|
||||
|
||||
from release_source_admission import ReleaseSourceAdmissionStore
|
||||
from silent_release_controller import ReleaseCandidate
|
||||
from silent_release_hardening import (
|
||||
CapabilityAuthorizationError,
|
||||
CapabilityLedger,
|
||||
ClaimProbeResult,
|
||||
ReleasePlan,
|
||||
RuntimeSwitchController,
|
||||
SignedApprovalVerifier,
|
||||
SignedReleasePlanApproval,
|
||||
_canonical_json,
|
||||
)
|
||||
|
||||
|
||||
C5_VERSION = "V2R2C5"
|
||||
C5_PREPUBLICATION_SOURCE_COMMIT = "396ea9811124736aa140d2810ab53eaefb6b5c1c"
|
||||
C4_ROLLBACK_TARGET = {
|
||||
"version": "V2R2C4",
|
||||
"target_oid": "e5f8209f57b43fc39e55c4555776e6362ce326eb",
|
||||
"artifact_sha256": "0dfc0461f7f9aa25cfbfc37008dcbd2d48d5341383574163b7bba102852062c4",
|
||||
}
|
||||
C4_ROLLBACK_ARTIFACT_PATH = Path(
|
||||
"/var/lib/aios-gitops/release-only/"
|
||||
"v2r2c4-e5f8209f57b4/artifact.tar.gz"
|
||||
)
|
||||
C5_PRODUCTION_ENVIRONMENT_FILE = Path("/etc/aios/v2r2c2.env")
|
||||
C5_PRODUCTION_SERVICE = "aios-v2r2c2-production.service"
|
||||
C5_PRODUCTION_PORT = 18928
|
||||
REQUEST_SCHEMA = "aios.c5.exact-production-deployment-request.v1"
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class C5DeploymentBindingError(CapabilityAuthorizationError):
|
||||
"""C5 source, plan, approval, executor, or receipt binding is invalid."""
|
||||
|
||||
|
||||
class C5DeploymentOutcomeUnknown(RuntimeError):
|
||||
"""The deployment effect must be reconciled before any retry."""
|
||||
|
||||
|
||||
class C5DeploymentConflict(RuntimeError):
|
||||
"""The observed runtime is bound to a different release."""
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _run_registered_adapter(
|
||||
command: tuple[str, ...], request: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Invoke only the code-pinned process and accept one JSON object."""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
list(command),
|
||||
input=_canonical_json(dict(request)),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=180,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise C5DeploymentOutcomeUnknown(
|
||||
"C5_DEPLOYMENT_EXECUTOR_UNAVAILABLE"
|
||||
) from exc
|
||||
if completed.returncode != 0:
|
||||
raise C5DeploymentOutcomeUnknown(
|
||||
"C5_DEPLOYMENT_EXECUTOR_FAILED"
|
||||
)
|
||||
try:
|
||||
lines = [
|
||||
line
|
||||
for line in completed.stdout.decode("utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
value = json.loads(lines[-1]) if lines else None
|
||||
except (UnicodeError, ValueError) as exc:
|
||||
raise C5DeploymentOutcomeUnknown(
|
||||
"C5_DEPLOYMENT_EXECUTOR_RESPONSE_INVALID"
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise C5DeploymentOutcomeUnknown(
|
||||
"C5_DEPLOYMENT_EXECUTOR_RESPONSE_INVALID"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _secure_registered_file(
|
||||
path: Path,
|
||||
expected_sha256: str,
|
||||
*,
|
||||
require_root_owner: bool,
|
||||
) -> None:
|
||||
path = Path(path)
|
||||
if (
|
||||
not path.is_absolute()
|
||||
or path.is_symlink()
|
||||
or path.resolve(strict=False) != path
|
||||
):
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_PATH_INVALID")
|
||||
try:
|
||||
info = path.stat()
|
||||
except OSError as exc:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_PATH_INVALID") from exc
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_PATH_INVALID")
|
||||
if os.name != "nt" and stat.S_IMODE(info.st_mode) & 0o022:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_PATH_WRITABLE")
|
||||
if require_root_owner:
|
||||
if not hasattr(os, "geteuid") or info.st_uid != 0:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_OWNER_INVALID")
|
||||
parent = path.parent
|
||||
while True:
|
||||
parent_info = parent.stat()
|
||||
if (
|
||||
parent.is_symlink()
|
||||
or parent_info.st_uid != 0
|
||||
or stat.S_IMODE(parent_info.st_mode) & 0o022
|
||||
):
|
||||
raise C5DeploymentBindingError(
|
||||
"C5_EXECUTOR_PARENT_SECURITY_INVALID"
|
||||
)
|
||||
if parent == parent.parent:
|
||||
break
|
||||
parent = parent.parent
|
||||
if _sha256_file(path) != expected_sha256:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_CODE_MISMATCH")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegisteredC5DeploymentExecutor:
|
||||
"""Pinned host configuration for one concrete process adapter.
|
||||
|
||||
``adapter_path`` is optional for a native executable. When present the
|
||||
command is exactly ``executable adapter_path``; arbitrary argv and Python
|
||||
callables are intentionally unsupported.
|
||||
"""
|
||||
|
||||
executor_id: str
|
||||
executable: Path
|
||||
executable_sha256: str
|
||||
artifact_path: Path
|
||||
rollback_artifact_path: Path
|
||||
production_environment_file: Path
|
||||
adapter_path: Path | None = None
|
||||
adapter_sha256: str | None = None
|
||||
require_root_owner: bool = True
|
||||
execution_scope: str = "PRODUCTION"
|
||||
isolation_root: Path | None = None
|
||||
rollback_target: Mapping[str, str] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.executor_id or len(self.executor_id) > 255:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_ID_INVALID")
|
||||
if HEX64.fullmatch(self.executable_sha256) is None:
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_DIGEST_INVALID")
|
||||
if (self.adapter_path is None) != (self.adapter_sha256 is None):
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_ADAPTER_BINDING_INVALID")
|
||||
if (
|
||||
self.adapter_sha256 is not None
|
||||
and HEX64.fullmatch(self.adapter_sha256) is None
|
||||
):
|
||||
raise C5DeploymentBindingError("C5_EXECUTOR_DIGEST_INVALID")
|
||||
if not Path(self.artifact_path).is_absolute():
|
||||
raise C5DeploymentBindingError("C5_ARTIFACT_PATH_INVALID")
|
||||
if self.execution_scope not in {"PRODUCTION", "ISOLATED_REHEARSAL"}:
|
||||
raise C5DeploymentBindingError("C5_EXECUTION_SCOPE_INVALID")
|
||||
target = dict(self.rollback_target or C4_ROLLBACK_TARGET)
|
||||
if target.keys() != C4_ROLLBACK_TARGET.keys() or target["version"] != C4_ROLLBACK_TARGET["version"] or target["target_oid"] != C4_ROLLBACK_TARGET["target_oid"] or HEX64.fullmatch(str(target["artifact_sha256"])) is None:
|
||||
raise C5DeploymentBindingError("C5_ROLLBACK_TARGET_INVALID")
|
||||
if self.execution_scope == "PRODUCTION":
|
||||
if target != C4_ROLLBACK_TARGET:
|
||||
raise C5DeploymentBindingError("C5_ROLLBACK_TARGET_INVALID")
|
||||
if Path(self.rollback_artifact_path) != C4_ROLLBACK_ARTIFACT_PATH:
|
||||
raise C5DeploymentBindingError("C5_ROLLBACK_ARTIFACT_PATH_INVALID")
|
||||
if Path(self.production_environment_file) != C5_PRODUCTION_ENVIRONMENT_FILE:
|
||||
raise C5DeploymentBindingError("C5_PRODUCTION_ENVIRONMENT_FILE_INVALID")
|
||||
if self.isolation_root is not None:
|
||||
raise C5DeploymentBindingError("C5_ISOLATION_ROOT_INVALID")
|
||||
else:
|
||||
if self.isolation_root is None or not Path(self.isolation_root).is_absolute():
|
||||
raise C5DeploymentBindingError("C5_ISOLATION_ROOT_INVALID")
|
||||
root = Path(self.isolation_root).resolve(strict=False)
|
||||
for bound_path in (self.rollback_artifact_path, self.production_environment_file):
|
||||
path = Path(bound_path)
|
||||
if not path.is_absolute() or path.resolve(strict=False) == root:
|
||||
raise C5DeploymentBindingError("C5_REHEARSAL_BINDING_INVALID")
|
||||
try:
|
||||
path.resolve(strict=False).relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise C5DeploymentBindingError("C5_REHEARSAL_BINDING_INVALID") from exc
|
||||
_secure_registered_file(
|
||||
Path(self.rollback_artifact_path),
|
||||
target["artifact_sha256"],
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
object.__setattr__(self, "rollback_target", target)
|
||||
self.verify_code()
|
||||
|
||||
def verify_code(self) -> None:
|
||||
_secure_registered_file(
|
||||
Path(self.executable),
|
||||
self.executable_sha256,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
if self.adapter_path is not None:
|
||||
_secure_registered_file(
|
||||
Path(self.adapter_path),
|
||||
str(self.adapter_sha256),
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
|
||||
@property
|
||||
def command(self) -> tuple[str, ...]:
|
||||
values = [str(Path(self.executable))]
|
||||
if self.adapter_path is not None:
|
||||
values.append(str(Path(self.adapter_path)))
|
||||
return tuple(values)
|
||||
|
||||
|
||||
class C5ExactDeploymentAdapter:
|
||||
"""Callable runtime-switch phase with exact C5 admission and approval."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
candidate: ReleaseCandidate,
|
||||
release_plan: ReleasePlan,
|
||||
approval: SignedReleasePlanApproval,
|
||||
approval_verifier: SignedApprovalVerifier,
|
||||
source_admissions: ReleaseSourceAdmissionStore,
|
||||
executor: RegisteredC5DeploymentExecutor,
|
||||
) -> None:
|
||||
if not isinstance(approval_verifier, SignedApprovalVerifier):
|
||||
raise C5DeploymentBindingError("C5_APPROVAL_VERIFIER_REQUIRED")
|
||||
if not isinstance(source_admissions, ReleaseSourceAdmissionStore):
|
||||
raise C5DeploymentBindingError("C5_SOURCE_ADMISSION_STORE_REQUIRED")
|
||||
self.candidate = candidate
|
||||
self.release_plan = release_plan
|
||||
self.approval = approval
|
||||
self.approval_verifier = approval_verifier
|
||||
self.source_admissions = source_admissions
|
||||
self.executor = executor
|
||||
self._binding = self._verify_binding(candidate, release_plan)
|
||||
|
||||
@staticmethod
|
||||
def _global_writes_disabled() -> bool:
|
||||
return os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() == "false"
|
||||
|
||||
def _verify_binding(
|
||||
self,
|
||||
candidate: ReleaseCandidate,
|
||||
plan: ReleasePlan,
|
||||
) -> dict[str, Any]:
|
||||
if candidate.fingerprint != self.candidate.fingerprint or plan.sha256 != self.release_plan.sha256:
|
||||
raise C5DeploymentBindingError("C5_RUNTIME_INPUT_BINDING_MISMATCH")
|
||||
plan.validate()
|
||||
if plan.tag.version != C5_VERSION or plan.finalization.version != C5_VERSION:
|
||||
raise C5DeploymentBindingError("C5_VERSION_BINDING_INVALID")
|
||||
if candidate.source_commit == C5_PREPUBLICATION_SOURCE_COMMIT:
|
||||
raise C5DeploymentBindingError("C5_PREPUBLICATION_SOURCE_FORBIDDEN")
|
||||
rollback_target = dict(self.executor.rollback_target or C4_ROLLBACK_TARGET)
|
||||
if plan.deployment.rollback_target != rollback_target:
|
||||
raise C5DeploymentBindingError("C5_ROLLBACK_TARGET_INVALID")
|
||||
if plan.deployment.service != C5_PRODUCTION_SERVICE:
|
||||
raise C5DeploymentBindingError("C5_PRODUCTION_SERVICE_INVALID")
|
||||
if plan.deployment.writer_expected is not False:
|
||||
raise C5DeploymentBindingError("C5_LEGACY_WRITES_MUST_REMAIN_DISABLED")
|
||||
if not self._global_writes_disabled():
|
||||
raise C5DeploymentBindingError("C5_LEGACY_WRITES_MUST_REMAIN_DISABLED")
|
||||
|
||||
admitted = self.source_admissions.resolve_recipe(
|
||||
C5_VERSION,
|
||||
candidate.candidate_id,
|
||||
candidate.source_commit,
|
||||
)
|
||||
if (
|
||||
admitted.get("source_commit") != candidate.source_commit
|
||||
or admitted.get("tree_oid") != candidate.tree_oid
|
||||
):
|
||||
raise C5DeploymentBindingError("C5_ADMITTED_SOURCE_BINDING_INVALID")
|
||||
if (
|
||||
plan.deployment.source_commit != candidate.source_commit
|
||||
or plan.deployment.tree_oid != candidate.tree_oid
|
||||
or plan.deployment.artifact_sha256 != candidate.artifact_sha256
|
||||
or plan.deployment.manifest_sha256 != candidate.manifest_sha256
|
||||
):
|
||||
raise C5DeploymentBindingError("C5_CANDIDATE_PLAN_BINDING_INVALID")
|
||||
self.approval_verifier.verify_release_plan(
|
||||
self.approval,
|
||||
candidate,
|
||||
plan,
|
||||
plan.policy_sha256,
|
||||
)
|
||||
|
||||
artifact = Path(self.executor.artifact_path)
|
||||
_secure_registered_file(
|
||||
artifact,
|
||||
candidate.artifact_sha256,
|
||||
require_root_owner=self.executor.require_root_owner,
|
||||
)
|
||||
artifact_size = artifact.stat().st_size
|
||||
if artifact_size <= 0:
|
||||
raise C5DeploymentBindingError("C5_ARTIFACT_BYTES_MISMATCH")
|
||||
self.executor.verify_code()
|
||||
return {
|
||||
"version": C5_VERSION,
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"source_commit": candidate.source_commit,
|
||||
"target_oid": candidate.source_commit,
|
||||
"tree_oid": candidate.tree_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"artifact_path": str(artifact),
|
||||
"artifact_size": artifact_size,
|
||||
"manifest_sha256": candidate.manifest_sha256,
|
||||
"deployment_plan_sha256": plan.deployment.sha256,
|
||||
"release_plan_sha256": plan.sha256,
|
||||
"policy_sha256": plan.policy_sha256,
|
||||
"approval_plan_id": self.approval.approval_plan_id,
|
||||
"approval_fingerprint": self.approval.fingerprint,
|
||||
"owner_approval_record_oid": self.approval.owner_approval_record_oid,
|
||||
"evidence_closure_oid": self.approval.evidence_closure_oid,
|
||||
"repository_identity": plan.deployment.repository_identity,
|
||||
"production_service": plan.deployment.service,
|
||||
"production_port": C5_PRODUCTION_PORT,
|
||||
"systemd_unit_sha256": plan.deployment.systemd_unit_sha256,
|
||||
"dropin_sha256": plan.deployment.dropin_sha256,
|
||||
"rollback_target": rollback_target,
|
||||
"rollback_artifact_path": str(
|
||||
self.executor.rollback_artifact_path
|
||||
),
|
||||
"production_environment_file": str(
|
||||
self.executor.production_environment_file
|
||||
),
|
||||
"writer_expected": False,
|
||||
"global_legacy_writes_enabled": False,
|
||||
"registered_executor_id": self.executor.executor_id,
|
||||
"executor_executable_sha256": self.executor.executable_sha256,
|
||||
"executor_adapter_sha256": self.executor.adapter_sha256,
|
||||
}
|
||||
|
||||
def _request(self, operation: str) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": REQUEST_SCHEMA,
|
||||
"operation": operation,
|
||||
**self._binding,
|
||||
}
|
||||
|
||||
def _invoke(self, operation: str) -> dict[str, Any]:
|
||||
if not self._global_writes_disabled():
|
||||
raise C5DeploymentBindingError("C5_LEGACY_WRITES_MUST_REMAIN_DISABLED")
|
||||
self.executor.verify_code()
|
||||
value = _run_registered_adapter(
|
||||
self.executor.command, self._request(operation)
|
||||
)
|
||||
for key, expected in self._binding.items():
|
||||
if value.get(key) != expected:
|
||||
raise C5DeploymentBindingError(f"C5_DEPLOYMENT_RECEIPT_MISMATCH:{key}")
|
||||
if value.get("writer_disabled") is not True:
|
||||
raise C5DeploymentBindingError("C5_DEPLOYMENT_WRITER_STATE_INVALID")
|
||||
receipt = value.get("deployment_receipt")
|
||||
if not isinstance(receipt, Mapping) or not receipt:
|
||||
raise C5DeploymentBindingError("C5_DEPLOYMENT_RECEIPT_REQUIRED")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _classify(value: Mapping[str, Any]) -> ClaimProbeResult:
|
||||
status = value.get("status")
|
||||
if (
|
||||
status == "ACTIVE_VERIFIED"
|
||||
and value.get("verified") is True
|
||||
and value.get("deployment_state") == "ACTIVE"
|
||||
and value.get("production_deployed") is True
|
||||
and value.get("writer_disabled") is True
|
||||
and value.get("global_legacy_writes_enabled") is False
|
||||
):
|
||||
return ClaimProbeResult.APPLIED_VERIFIED
|
||||
if status == "NOT_APPLIED":
|
||||
return ClaimProbeResult.NOT_APPLIED
|
||||
if status == "CONFLICT":
|
||||
return ClaimProbeResult.CONFLICT
|
||||
return ClaimProbeResult.INDETERMINATE
|
||||
|
||||
def recovery_probe(self) -> ClaimProbeResult:
|
||||
"""Read-only exact probe used by ``CapabilityLedger`` after UNKNOWN."""
|
||||
try:
|
||||
self._verify_binding(self.candidate, self.release_plan)
|
||||
return self._classify(self._invoke("probe"))
|
||||
except C5DeploymentConflict:
|
||||
return ClaimProbeResult.CONFLICT
|
||||
except Exception:
|
||||
return ClaimProbeResult.INDETERMINATE
|
||||
|
||||
def rollback(
|
||||
self,
|
||||
*,
|
||||
candidate: ReleaseCandidate,
|
||||
plan: ReleasePlan,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute only the explicit Saga rollback phase and verify exact C4."""
|
||||
self._verify_binding(candidate, plan)
|
||||
value = self._invoke("rollback")
|
||||
if (
|
||||
value.get("status") != "ROLLED_BACK_VERIFIED"
|
||||
or value.get("verified") is not True
|
||||
or value.get("rollback_verified") is not True
|
||||
or value.get("rollback_version") != self.executor.rollback_target["version"]
|
||||
or value.get("rollback_target_oid")
|
||||
!= self.executor.rollback_target["target_oid"]
|
||||
or value.get("rollback_artifact_sha256")
|
||||
!= self.executor.rollback_target["artifact_sha256"]
|
||||
):
|
||||
raise C5DeploymentOutcomeUnknown("C5_ROLLBACK_OUTCOME_UNKNOWN")
|
||||
return {
|
||||
"verified": True,
|
||||
"rollback_verified": True,
|
||||
"rollback_version": self.executor.rollback_target["version"],
|
||||
"rollback_target_oid": self.executor.rollback_target["target_oid"],
|
||||
"rollback_artifact_sha256": self.executor.rollback_target["artifact_sha256"],
|
||||
"writer_disabled": True,
|
||||
}
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
candidate: ReleaseCandidate,
|
||||
plan: ReleasePlan,
|
||||
) -> dict[str, Any]:
|
||||
self._verify_binding(candidate, plan)
|
||||
probe = self._invoke("probe")
|
||||
classification = self._classify(probe)
|
||||
if classification == ClaimProbeResult.CONFLICT:
|
||||
raise C5DeploymentConflict("C5_RUNTIME_BINDING_CONFLICT")
|
||||
if classification == ClaimProbeResult.INDETERMINATE:
|
||||
raise C5DeploymentOutcomeUnknown("C5_DEPLOYMENT_OUTCOME_UNKNOWN")
|
||||
if classification == ClaimProbeResult.NOT_APPLIED:
|
||||
applied = self._invoke("apply")
|
||||
if applied.get("status") == "CONFLICT":
|
||||
raise C5DeploymentConflict("C5_RUNTIME_BINDING_CONFLICT")
|
||||
if applied.get("status") not in {"APPLIED", "ACTIVE_VERIFIED"}:
|
||||
raise C5DeploymentOutcomeUnknown("C5_DEPLOYMENT_OUTCOME_UNKNOWN")
|
||||
probe = self._invoke("probe")
|
||||
classification = self._classify(probe)
|
||||
if classification == ClaimProbeResult.CONFLICT:
|
||||
raise C5DeploymentConflict("C5_RUNTIME_BINDING_CONFLICT")
|
||||
if classification != ClaimProbeResult.APPLIED_VERIFIED:
|
||||
raise C5DeploymentOutcomeUnknown("C5_DEPLOYMENT_OUTCOME_UNKNOWN")
|
||||
return {
|
||||
"verified": True,
|
||||
"version": C5_VERSION,
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"source_commit": candidate.source_commit,
|
||||
"tree_oid": candidate.tree_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"release_plan_sha256": plan.sha256,
|
||||
"approval_fingerprint": self.approval.fingerprint,
|
||||
"rollback_target": dict(self.executor.rollback_target),
|
||||
"writer_disabled": True,
|
||||
"global_legacy_writes_enabled": False,
|
||||
"registered_executor_id": self.executor.executor_id,
|
||||
"deployment_receipt_sha256": hashlib.sha256(
|
||||
_canonical_json(dict(probe))
|
||||
).hexdigest(),
|
||||
}
|
||||
|
||||
def controller(
|
||||
self,
|
||||
ledger: CapabilityLedger,
|
||||
*,
|
||||
lease_seconds: float = 300,
|
||||
) -> RuntimeSwitchController:
|
||||
"""Bind this exact adapter to the existing capability controller."""
|
||||
return RuntimeSwitchController(
|
||||
ledger,
|
||||
self,
|
||||
recovery_probe=self.recovery_probe,
|
||||
executor_id=self.executor.executor_id,
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,688 +0,0 @@
|
||||
"""Owner-registered dispatch boundary for the fixed-scope C5 installer.
|
||||
|
||||
The registration authorizes one exact signed installation contract and one
|
||||
exact set of installed executor bytes. It is not an execution receipt; the
|
||||
wrapped :class:`Installer` remains the component that durably records and
|
||||
reconciles installation state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
import c5_staging_installation as _installer_module
|
||||
from c5_staging_installation import (
|
||||
BRIDGE_EFFECTS,
|
||||
BRIDGE_TARGET,
|
||||
HOST,
|
||||
InstallationError,
|
||||
InstallationOutcomeUnknown,
|
||||
ISSUER_EFFECTS,
|
||||
ISSUER_TARGET,
|
||||
OWNER_NAMESPACE,
|
||||
R14_SOURCE_COMMIT,
|
||||
REPOSITORY,
|
||||
SUCCESSOR_SCHEMA,
|
||||
Installer,
|
||||
RegistryOwnerVerifier,
|
||||
canonical_bytes,
|
||||
verify_signed_contract,
|
||||
)
|
||||
from registered_owner_signer_adapter import RegisteredOwnerSignerAdapter
|
||||
|
||||
|
||||
SCHEMA = "aios.c5.registered-installer-executor.v1"
|
||||
PROVENANCE_SCHEMA = "aios.c5.executor-provenance.v1"
|
||||
EXECUTION_DIGEST = "0779fa615e8e7971a8dcfccf0f295b1323cd9b64787e174aea4fd2affc414339"
|
||||
BRIDGE_PORT = 18892
|
||||
ISSUER_PORT = 18946
|
||||
CAPABILITIES = frozenset({"install", "rollback", "reconcile"})
|
||||
TARGET_EFFECTS = {BRIDGE_TARGET: BRIDGE_EFFECTS, ISSUER_TARGET: ISSUER_EFFECTS}
|
||||
MAX_TTL_SECONDS = 900
|
||||
MAX_CODE_BYTES = 16 * 1024 * 1024
|
||||
DEFAULT_REGISTRY = Path("/etc/aios/single-host-signer-registry.json")
|
||||
DEFAULT_PROVENANCE_MANIFEST = Path("/etc/aios/c5-executor-provenance.json")
|
||||
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}$")
|
||||
SCOPE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:\-]{2,127}$")
|
||||
|
||||
|
||||
class ExecutorRegistrationError(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise ExecutorRegistrationError(code)
|
||||
|
||||
|
||||
def _sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _require_digest(value: Any, pattern: re.Pattern[str], code: str) -> str:
|
||||
if not isinstance(value, str) or pattern.fullmatch(value) is None:
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
_fail("EXECUTOR_JSON_DUPLICATE_FIELD")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _secure_bytes(
|
||||
path: Path, *, require_root_owner: bool = True, maximum: int = MAX_CODE_BYTES,
|
||||
) -> bytes:
|
||||
path = Path(path)
|
||||
if not path.is_absolute():
|
||||
_fail("EXECUTOR_CODE_PATH_INVALID")
|
||||
try:
|
||||
if path.is_symlink() or path.resolve(strict=True) != path:
|
||||
_fail("EXECUTOR_CODE_PATH_INVALID")
|
||||
before = path.stat()
|
||||
except OSError as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_CODE_PATH_INVALID") from exc
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd = os.open(path, flags)
|
||||
except OSError as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_CODE_PATH_INVALID") from exc
|
||||
try:
|
||||
current = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(current.st_mode)
|
||||
or current.st_dev != before.st_dev
|
||||
or current.st_ino != before.st_ino
|
||||
or current.st_size < 1
|
||||
or current.st_size > maximum
|
||||
):
|
||||
_fail("EXECUTOR_CODE_FILE_INVALID")
|
||||
if require_root_owner:
|
||||
if os.name != "posix" or current.st_uid != 0:
|
||||
_fail("EXECUTOR_CODE_PATH_OWNER_INVALID")
|
||||
if stat.S_IMODE(current.st_mode) & 0o022:
|
||||
_fail("EXECUTOR_CODE_PATH_WRITABLE")
|
||||
parent = path.parent
|
||||
while True:
|
||||
parent_stat = parent.stat()
|
||||
if parent.is_symlink() or parent_stat.st_uid != 0:
|
||||
_fail("EXECUTOR_CODE_PATH_OWNER_INVALID")
|
||||
if stat.S_IMODE(parent_stat.st_mode) & 0o022:
|
||||
_fail("EXECUTOR_CODE_PATH_WRITABLE")
|
||||
if parent == parent.parent:
|
||||
break
|
||||
parent = parent.parent
|
||||
chunks: list[bytes] = []
|
||||
remaining = current.st_size
|
||||
while remaining:
|
||||
chunk = os.read(fd, min(remaining, 1024 * 1024))
|
||||
if not chunk:
|
||||
_fail("EXECUTOR_CODE_FILE_INVALID")
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
if os.read(fd, 1):
|
||||
_fail("EXECUTOR_CODE_FILE_INVALID")
|
||||
return b"".join(chunks)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CodeProvenance:
|
||||
environment: str
|
||||
instance_id: str
|
||||
integration_commit: str
|
||||
integration_tree: str
|
||||
installer_module_path: Path
|
||||
installer_script_path: Path
|
||||
executor_module_path: Path
|
||||
installer_module_sha256: str
|
||||
installer_script_sha256: str
|
||||
executor_module_sha256: str
|
||||
authority_manifest_sha256: str
|
||||
|
||||
|
||||
def _actual_installer_module_path() -> Path:
|
||||
value = getattr(_installer_module, "__file__", None)
|
||||
if not isinstance(value, str):
|
||||
_fail("EXECUTOR_LOADED_INSTALLER_PATH_INVALID")
|
||||
return Path(value).resolve(strict=True)
|
||||
|
||||
|
||||
def _actual_executor_module_path() -> Path:
|
||||
return Path(__file__).resolve(strict=True)
|
||||
|
||||
|
||||
def load_code_provenance(
|
||||
manifest: Path, *, require_root_owner: bool = True,
|
||||
) -> CodeProvenance:
|
||||
"""Load identity from an authority manifest and actual loaded paths."""
|
||||
manifest = Path(manifest)
|
||||
raw = _secure_bytes(manifest, require_root_owner=require_root_owner)
|
||||
try:
|
||||
value = json.loads(raw, object_pairs_hook=_strict_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_PROVENANCE_INVALID") from exc
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or set(value) != {
|
||||
"schema", "environment", "instance_id", "integration_commit",
|
||||
"integration_tree", "entries",
|
||||
}
|
||||
or value["schema"] != PROVENANCE_SCHEMA
|
||||
or not isinstance(value["entries"], dict)
|
||||
or set(value["entries"]) != {
|
||||
"installer_module", "installer_script", "executor_module",
|
||||
}
|
||||
):
|
||||
_fail("EXECUTOR_PROVENANCE_INVALID")
|
||||
expected_paths = {
|
||||
"installer_module": _actual_installer_module_path(),
|
||||
"executor_module": _actual_executor_module_path(),
|
||||
}
|
||||
resolved: dict[str, Path] = {}
|
||||
hashes: dict[str, str] = {}
|
||||
for label, entry in value["entries"].items():
|
||||
if not isinstance(entry, dict) or set(entry) != {"path", "sha256"}:
|
||||
_fail("EXECUTOR_PROVENANCE_INVALID")
|
||||
if not isinstance(entry["path"], str):
|
||||
_fail("EXECUTOR_PROVENANCE_INVALID")
|
||||
path = Path(entry["path"])
|
||||
if not path.is_absolute():
|
||||
_fail("EXECUTOR_CODE_PATH_INVALID")
|
||||
try:
|
||||
canonical = path.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_CODE_PATH_INVALID") from exc
|
||||
if path != canonical:
|
||||
_fail("EXECUTOR_CODE_PATH_INVALID")
|
||||
if label in expected_paths and canonical != expected_paths[label]:
|
||||
_fail("EXECUTOR_PROVENANCE_LOADED_PATH_MISMATCH")
|
||||
if label == "installer_script" and canonical.name != "c5_staging_installation.py":
|
||||
_fail("EXECUTOR_INSTALLER_SCRIPT_PATH_INVALID")
|
||||
digest = _sha256_bytes(
|
||||
_secure_bytes(canonical, require_root_owner=require_root_owner)
|
||||
)
|
||||
if entry["sha256"] != digest:
|
||||
_fail("EXECUTOR_PROVENANCE_CODE_MISMATCH")
|
||||
resolved[label] = canonical
|
||||
hashes[label] = digest
|
||||
if (
|
||||
not isinstance(value["environment"], str)
|
||||
or SCOPE_ID.fullmatch(value["environment"]) is None
|
||||
or not isinstance(value["instance_id"], str)
|
||||
or SCOPE_ID.fullmatch(value["instance_id"]) is None
|
||||
):
|
||||
_fail("EXECUTOR_PROVENANCE_SCOPE_INVALID")
|
||||
return CodeProvenance(
|
||||
environment=value["environment"],
|
||||
instance_id=value["instance_id"],
|
||||
integration_commit=_require_digest(
|
||||
value["integration_commit"], HEX40,
|
||||
"EXECUTOR_INTEGRATION_COMMIT_INVALID",
|
||||
),
|
||||
integration_tree=_require_digest(
|
||||
value["integration_tree"], HEX40,
|
||||
"EXECUTOR_INTEGRATION_TREE_INVALID",
|
||||
),
|
||||
installer_module_path=resolved["installer_module"],
|
||||
installer_script_path=resolved["installer_script"],
|
||||
executor_module_path=resolved["executor_module"],
|
||||
installer_module_sha256=hashes["installer_module"],
|
||||
installer_script_sha256=hashes["installer_script"],
|
||||
executor_module_sha256=hashes["executor_module"],
|
||||
authority_manifest_sha256=_sha256_bytes(raw),
|
||||
)
|
||||
|
||||
|
||||
def _contract_binding(payload: Mapping[str, Any], digest: str) -> dict[str, Any]:
|
||||
return {
|
||||
"target": payload["target"],
|
||||
"contract_digest": digest,
|
||||
"old_snapshot_sha256": _sha256_bytes(canonical_bytes(payload["existing"])),
|
||||
"new_manifest_sha256": _sha256_bytes(canonical_bytes({
|
||||
"release_root": payload["release_root"],
|
||||
"files": payload["files"],
|
||||
"readiness": payload["readiness"],
|
||||
})),
|
||||
"rollback_sha256": _sha256_bytes(canonical_bytes(payload["rollback"])),
|
||||
"contract_issued_at": payload["issued_at"],
|
||||
"contract_expires_at": payload["expires_at"],
|
||||
"contract_nonce": payload["nonce"],
|
||||
}
|
||||
|
||||
|
||||
def _issuer_dependency(payload: Mapping[str, Any], digest: str) -> dict[str, Any]:
|
||||
return {
|
||||
"target": payload["target"],
|
||||
"contract_digest": digest,
|
||||
"source_commit": payload["source_commit"],
|
||||
"support_commit": payload["support_commit"],
|
||||
"owner_signer_id": payload["owner_signer_id"],
|
||||
"owner_key_id": payload["owner_key_id"],
|
||||
"contract_nonce": payload["nonce"],
|
||||
"contract_issued_at": payload["issued_at"],
|
||||
"contract_expires_at": payload["expires_at"],
|
||||
"rollback_sha256": _sha256_bytes(canonical_bytes(payload["rollback"])),
|
||||
}
|
||||
|
||||
|
||||
def _registration_payload(
|
||||
*, executor_id: Any, nonce: Any, issued_at: Any, expires_at: Any,
|
||||
contract_payload: Mapping[str, Any], contract_digest: str,
|
||||
provenance: CodeProvenance,
|
||||
issuer_dependency: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": SCHEMA,
|
||||
"executor_id": executor_id,
|
||||
"owner_signer_id": contract_payload["owner_signer_id"],
|
||||
"owner_key_id": contract_payload["owner_key_id"],
|
||||
"host": HOST,
|
||||
"repository": REPOSITORY,
|
||||
"environment": provenance.environment,
|
||||
"instance_id": provenance.instance_id,
|
||||
"source_commit": R14_SOURCE_COMMIT,
|
||||
"support_commit": contract_payload["support_commit"],
|
||||
"integration_commit": provenance.integration_commit,
|
||||
"integration_tree": provenance.integration_tree,
|
||||
"installer_module_sha256": provenance.installer_module_sha256,
|
||||
"installer_script_sha256": provenance.installer_script_sha256,
|
||||
"executor_module_sha256": provenance.executor_module_sha256,
|
||||
"authority_manifest_sha256": provenance.authority_manifest_sha256,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
"bridge_port": BRIDGE_PORT,
|
||||
"issuer_port": ISSUER_PORT,
|
||||
"capabilities": sorted(CAPABILITIES),
|
||||
"effects": sorted(contract_payload["effects"]),
|
||||
"issuer_dependency": dict(issuer_dependency) if issuer_dependency is not None else None,
|
||||
"issued_at": issued_at,
|
||||
"expires_at": expires_at,
|
||||
"nonce": nonce,
|
||||
**_contract_binding(contract_payload, contract_digest),
|
||||
}
|
||||
|
||||
|
||||
def _validate_issuer_dependency(
|
||||
dependency: Any, contract_payload: Mapping[str, Any],
|
||||
) -> None:
|
||||
if contract_payload["target"] == ISSUER_TARGET:
|
||||
if dependency is not None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_INVALID")
|
||||
return
|
||||
if (
|
||||
not isinstance(dependency, Mapping)
|
||||
or set(dependency) != {
|
||||
"target", "contract_digest", "source_commit", "support_commit",
|
||||
"owner_signer_id", "owner_key_id", "contract_nonce",
|
||||
"contract_issued_at", "contract_expires_at", "rollback_sha256",
|
||||
}
|
||||
or dependency["target"] != ISSUER_TARGET
|
||||
or dependency["source_commit"] != contract_payload["source_commit"]
|
||||
or dependency["support_commit"] != contract_payload["support_commit"]
|
||||
or dependency["owner_signer_id"] != contract_payload["owner_signer_id"]
|
||||
or dependency["owner_key_id"] != contract_payload["owner_key_id"]
|
||||
or type(dependency["contract_issued_at"]) is not int
|
||||
or type(dependency["contract_expires_at"]) is not int
|
||||
):
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_INVALID")
|
||||
_require_digest(
|
||||
dependency["contract_digest"], HEX64,
|
||||
"EXECUTOR_ISSUER_DEPENDENCY_INVALID",
|
||||
)
|
||||
_require_digest(
|
||||
dependency["rollback_sha256"], HEX64,
|
||||
"EXECUTOR_ISSUER_DEPENDENCY_INVALID",
|
||||
)
|
||||
|
||||
|
||||
def _validate_payload(
|
||||
payload: Mapping[str, Any], *, now: int, provenance: CodeProvenance,
|
||||
contract_payload: Mapping[str, Any], contract_digest: str,
|
||||
issuer_dependency: Mapping[str, Any] | None,
|
||||
) -> None:
|
||||
expected = _registration_payload(
|
||||
executor_id=payload.get("executor_id"), nonce=payload.get("nonce"),
|
||||
issued_at=payload.get("issued_at"), expires_at=payload.get("expires_at"),
|
||||
contract_payload=contract_payload, contract_digest=contract_digest,
|
||||
provenance=provenance, issuer_dependency=issuer_dependency,
|
||||
)
|
||||
if set(payload) != set(expected) or any(
|
||||
payload.get(key) != value for key, value in expected.items()
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_BINDING_INVALID")
|
||||
if (
|
||||
not isinstance(payload["executor_id"], str)
|
||||
or not payload["executor_id"]
|
||||
or not isinstance(payload["nonce"], str)
|
||||
or NONCE.fullmatch(payload["nonce"]) is None
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_IDENTITY_INVALID")
|
||||
if (
|
||||
type(payload["issued_at"]) is not int
|
||||
or type(payload["expires_at"]) is not int
|
||||
or payload["issued_at"] > now
|
||||
or now >= payload["expires_at"]
|
||||
or not 1 <= payload["expires_at"] - payload["issued_at"] <= MAX_TTL_SECONDS
|
||||
or payload["issued_at"] < contract_payload["issued_at"]
|
||||
or payload["expires_at"] > contract_payload["expires_at"]
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_EXPIRED")
|
||||
if payload["support_commit"] != payload["integration_commit"]:
|
||||
_fail("EXECUTOR_COMMIT_BINDING_INVALID")
|
||||
if (
|
||||
issuer_dependency is not None
|
||||
and payload["expires_at"] > issuer_dependency["contract_expires_at"]
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_EXPIRED")
|
||||
if (
|
||||
not isinstance(payload["environment"], str)
|
||||
or SCOPE_ID.fullmatch(payload["environment"]) is None
|
||||
or not isinstance(payload["instance_id"], str)
|
||||
or SCOPE_ID.fullmatch(payload["instance_id"]) is None
|
||||
):
|
||||
_fail("EXECUTOR_ENVIRONMENT_BINDING_INVALID")
|
||||
if payload["source_commit"] != R14_SOURCE_COMMIT:
|
||||
_fail("EXECUTOR_SOURCE_COMMIT_INVALID")
|
||||
if (
|
||||
contract_payload["schema"] != SUCCESSOR_SCHEMA
|
||||
or contract_payload["source_commit"] != R14_SOURCE_COMMIT
|
||||
):
|
||||
_fail("EXECUTOR_CONTRACT_GENERATION_INVALID")
|
||||
effects = TARGET_EFFECTS.get(payload["target"])
|
||||
if (
|
||||
effects is None
|
||||
or not isinstance(payload["effects"], list)
|
||||
or frozenset(payload["effects"]) != effects
|
||||
or len(payload["effects"]) != len(effects)
|
||||
):
|
||||
_fail("EXECUTOR_EFFECTS_INVALID")
|
||||
if (
|
||||
not isinstance(payload["capabilities"], list)
|
||||
or frozenset(payload["capabilities"]) != CAPABILITIES
|
||||
or len(payload["capabilities"]) != len(CAPABILITIES)
|
||||
):
|
||||
_fail("EXECUTOR_CAPABILITIES_INVALID")
|
||||
_validate_issuer_dependency(issuer_dependency, contract_payload)
|
||||
for name in (
|
||||
"installer_module_sha256", "installer_script_sha256",
|
||||
"executor_module_sha256", "authority_manifest_sha256",
|
||||
"execution_digest", "contract_digest", "old_snapshot_sha256",
|
||||
"new_manifest_sha256", "rollback_sha256",
|
||||
):
|
||||
_require_digest(payload[name], HEX64, f"EXECUTOR_{name.upper()}_INVALID")
|
||||
|
||||
|
||||
def _verified_contract(
|
||||
contract: Path, installer: Installer, *, now: int,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
return verify_signed_contract(
|
||||
contract, installer.registry_path, now=now,
|
||||
require_root_owner=installer.require_root_owner,
|
||||
_layout=installer.layout,
|
||||
)
|
||||
|
||||
|
||||
def _verified_issuer_dependency(
|
||||
issuer_contract: Path | None, installer: Installer, *, now: int,
|
||||
contract_payload: Mapping[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
if contract_payload["target"] == ISSUER_TARGET:
|
||||
if issuer_contract is not None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_INVALID")
|
||||
return None
|
||||
if issuer_contract is None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_REQUIRED")
|
||||
payload, digest = _verified_contract(issuer_contract, installer, now=now)
|
||||
if payload["schema"] != SUCCESSOR_SCHEMA or payload["source_commit"] != R14_SOURCE_COMMIT:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_INVALID")
|
||||
dependency = _issuer_dependency(payload, digest)
|
||||
_validate_issuer_dependency(dependency, contract_payload)
|
||||
return dependency
|
||||
|
||||
|
||||
def issue_registration(
|
||||
*, executor_id: str, nonce: str, ttl_seconds: int, contract: Path,
|
||||
installer: Installer, provenance_manifest: Path,
|
||||
registered_signer: RegisteredOwnerSignerAdapter,
|
||||
clock: Callable[[], int], issuer_contract: Path | None = None,
|
||||
require_root_owner: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Issue authorization after deriving every scope field from trusted input."""
|
||||
if type(installer) is not Installer:
|
||||
_fail("EXECUTOR_INSTALLER_TYPE_INVALID")
|
||||
if not isinstance(registered_signer, RegisteredOwnerSignerAdapter):
|
||||
_fail("REGISTERED_SIGNER_ADAPTER_REQUIRED")
|
||||
if type(ttl_seconds) is not int or not 1 <= ttl_seconds <= MAX_TTL_SECONDS:
|
||||
_fail("EXECUTOR_REGISTRATION_TTL_INVALID")
|
||||
now = int(clock())
|
||||
provenance = load_code_provenance(
|
||||
provenance_manifest, require_root_owner=require_root_owner,
|
||||
)
|
||||
contract_payload, contract_digest = _verified_contract(contract, installer, now=now)
|
||||
dependency = _verified_issuer_dependency(
|
||||
issuer_contract, installer, now=now, contract_payload=contract_payload,
|
||||
)
|
||||
payload = _registration_payload(
|
||||
executor_id=executor_id, nonce=nonce, issued_at=now,
|
||||
expires_at=now + ttl_seconds, contract_payload=contract_payload,
|
||||
contract_digest=contract_digest, provenance=provenance,
|
||||
issuer_dependency=dependency,
|
||||
)
|
||||
_validate_payload(
|
||||
payload, now=now, provenance=provenance,
|
||||
contract_payload=contract_payload, contract_digest=contract_digest,
|
||||
issuer_dependency=dependency,
|
||||
)
|
||||
if registered_signer.fingerprint != payload["owner_key_id"]:
|
||||
_fail("EXECUTOR_SIGNER_BINDING_INVALID")
|
||||
signature = registered_signer._sign_canonical_payload(
|
||||
OWNER_NAMESPACE, canonical_bytes(payload),
|
||||
)
|
||||
return {"payload": payload, "signature": signature}
|
||||
|
||||
|
||||
def load_registration(path: Path, *, require_root_owner: bool = True) -> dict[str, Any]:
|
||||
raw = _secure_bytes(
|
||||
Path(path), require_root_owner=require_root_owner, maximum=1024 * 1024,
|
||||
)
|
||||
try:
|
||||
value = json.loads(raw, object_pairs_hook=_strict_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ExecutorRegistrationError("EXECUTOR_REGISTRATION_INVALID") from exc
|
||||
if not isinstance(value, dict) or set(value) != {"payload", "signature"}:
|
||||
_fail("EXECUTOR_REGISTRATION_FIELDS_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
class RegisteredInstallerExecutor:
|
||||
def __init__(
|
||||
self, *, owner_verifier: RegistryOwnerVerifier, installer: Installer,
|
||||
provenance_manifest: Path, clock: Callable[[], int],
|
||||
host: Callable[[], str] = socket.gethostname,
|
||||
require_root_owner: bool = True,
|
||||
) -> None:
|
||||
if type(installer) is not Installer:
|
||||
_fail("EXECUTOR_INSTALLER_TYPE_INVALID")
|
||||
if not isinstance(owner_verifier, RegistryOwnerVerifier):
|
||||
_fail("EXECUTOR_OWNER_VERIFIER_INVALID")
|
||||
self.owner_verifier = owner_verifier
|
||||
self.installer = installer
|
||||
self.provenance_manifest = Path(provenance_manifest)
|
||||
self.clock = clock
|
||||
self.host = host
|
||||
self.require_root_owner = require_root_owner
|
||||
self.provenance = load_code_provenance(
|
||||
self.provenance_manifest, require_root_owner=require_root_owner,
|
||||
)
|
||||
|
||||
def _check_code_provenance(self) -> None:
|
||||
current = load_code_provenance(
|
||||
self.provenance_manifest, require_root_owner=self.require_root_owner,
|
||||
)
|
||||
if current != self.provenance:
|
||||
_fail("EXECUTOR_CODE_PROVENANCE_CHANGED")
|
||||
|
||||
def _verify(
|
||||
self, registration: Mapping[str, Any], contract: Path,
|
||||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||||
self._check_code_provenance()
|
||||
if (
|
||||
not isinstance(registration, Mapping)
|
||||
or set(registration) != {"payload", "signature"}
|
||||
or not isinstance(registration["payload"], Mapping)
|
||||
):
|
||||
_fail("EXECUTOR_REGISTRATION_FIELDS_INVALID")
|
||||
if self.host() != HOST:
|
||||
_fail("EXECUTOR_HOST_MISMATCH")
|
||||
now = int(self.clock())
|
||||
contract_payload, contract_digest = _verified_contract(
|
||||
contract, self.installer, now=now,
|
||||
)
|
||||
payload = dict(registration["payload"])
|
||||
dependency = payload.get("issuer_dependency")
|
||||
_validate_payload(
|
||||
payload, now=now, provenance=self.provenance,
|
||||
contract_payload=contract_payload, contract_digest=contract_digest,
|
||||
issuer_dependency=dependency,
|
||||
)
|
||||
if payload["owner_key_id"] != self.owner_verifier.record.fingerprint:
|
||||
_fail("EXECUTOR_OWNER_KEY_BINDING_INVALID")
|
||||
signature = registration["signature"]
|
||||
if (
|
||||
not isinstance(signature, str)
|
||||
or not self.owner_verifier.verify(
|
||||
namespace=OWNER_NAMESPACE, payload=canonical_bytes(payload),
|
||||
signature=signature, key_id=payload["owner_key_id"],
|
||||
)
|
||||
):
|
||||
_fail("EXECUTOR_OWNER_SIGNATURE_INVALID")
|
||||
return payload, contract_digest, contract_payload
|
||||
|
||||
def dispatch(
|
||||
self, registration: Mapping[str, Any], contract: Path,
|
||||
artifact: Path, operation: str, *,
|
||||
issuer_contract: Path | None = None,
|
||||
issuer_artifact: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload, contract_digest, contract_payload = self._verify(registration, contract)
|
||||
capability = "install" if operation in {"preflight", "install"} else operation
|
||||
if capability not in CAPABILITIES:
|
||||
_fail("EXECUTOR_OPERATION_INVALID")
|
||||
if capability not in payload["capabilities"]:
|
||||
_fail("EXECUTOR_CAPABILITY_INVALID")
|
||||
if operation == "rollback" and contract_payload["target"] == BRIDGE_TARGET:
|
||||
if issuer_artifact is None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_REQUIRED")
|
||||
actual_dependency = _verified_issuer_dependency(
|
||||
issuer_contract, self.installer, now=int(self.clock()),
|
||||
contract_payload=contract_payload,
|
||||
)
|
||||
if actual_dependency != payload["issuer_dependency"]:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_MISMATCH")
|
||||
elif issuer_contract is not None or issuer_artifact is not None:
|
||||
_fail("EXECUTOR_ISSUER_DEPENDENCY_UNEXPECTED")
|
||||
if operation == "preflight":
|
||||
result = self.installer.preflight(contract, artifact)
|
||||
elif operation == "install":
|
||||
result = self.installer.apply(contract, artifact)
|
||||
elif operation == "rollback":
|
||||
result = self.installer.rollback_applied(
|
||||
contract, artifact, issuer_contract=issuer_contract,
|
||||
issuer_artifact=issuer_artifact,
|
||||
)
|
||||
else:
|
||||
result = self.installer.reconcile_unknown(contract, artifact)
|
||||
return {
|
||||
**result,
|
||||
"operation": operation,
|
||||
"executor_id": payload["executor_id"],
|
||||
"contract_digest": contract_digest,
|
||||
"code_binding": {
|
||||
"integration_commit": payload["integration_commit"],
|
||||
"integration_tree": payload["integration_tree"],
|
||||
"installer_module_sha256": payload["installer_module_sha256"],
|
||||
"installer_script_sha256": payload["installer_script_sha256"],
|
||||
"executor_module_sha256": payload["executor_module_sha256"],
|
||||
},
|
||||
"registration_policy_digest": _sha256_bytes(canonical_bytes(payload)),
|
||||
}
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run one Owner-registered fixed-scope C5 installer operation.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"operation", choices=("preflight", "install", "rollback", "reconcile"),
|
||||
)
|
||||
parser.add_argument("--registration", type=Path, required=True)
|
||||
parser.add_argument("--contract", type=Path, required=True)
|
||||
parser.add_argument("--artifact", type=Path, required=True)
|
||||
parser.add_argument("--issuer-contract", type=Path)
|
||||
parser.add_argument("--issuer-artifact", type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Production CLI with fixed registry and provenance authority paths."""
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
registration = load_registration(args.registration)
|
||||
payload = registration.get("payload")
|
||||
if not isinstance(payload, Mapping) or not isinstance(payload.get("owner_signer_id"), str):
|
||||
_fail("EXECUTOR_REGISTRATION_FIELDS_INVALID")
|
||||
installer = Installer(DEFAULT_REGISTRY)
|
||||
verifier = RegistryOwnerVerifier(
|
||||
DEFAULT_REGISTRY, owner_signer_id=payload["owner_signer_id"],
|
||||
)
|
||||
executor = RegisteredInstallerExecutor(
|
||||
owner_verifier=verifier, installer=installer,
|
||||
provenance_manifest=DEFAULT_PROVENANCE_MANIFEST,
|
||||
clock=lambda: int(time.time()),
|
||||
)
|
||||
result = executor.dispatch(
|
||||
registration, args.contract, args.artifact, args.operation,
|
||||
issuer_contract=args.issuer_contract,
|
||||
issuer_artifact=args.issuer_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 (ExecutorRegistrationError, 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())
|
||||
@@ -1,977 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Root-owned one-shot assembly for the exact V2R2C5 release Saga.
|
||||
|
||||
This module only assembles existing hardened Saga phases. Each mutating phase
|
||||
is a SHA-pinned process adapter; no caller-provided Python callable is accepted.
|
||||
The staging HTTP service remains shadow-only and does not import this launcher.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import approved_release_executor as approved_executor_module
|
||||
from approved_release_executor import run_adapter
|
||||
import c5_exact_deployment_adapter as exact_deployment_module
|
||||
from c5_exact_deployment_adapter import (
|
||||
C4_ROLLBACK_ARTIFACT_PATH,
|
||||
C4_ROLLBACK_TARGET,
|
||||
C5ExactDeploymentAdapter,
|
||||
C5_PRODUCTION_ENVIRONMENT_FILE,
|
||||
C5_PRODUCTION_PORT,
|
||||
C5_PRODUCTION_SERVICE,
|
||||
C5_VERSION,
|
||||
RegisteredC5DeploymentExecutor,
|
||||
)
|
||||
from release_source_admission import open_source_admission_store
|
||||
from silent_release_controller import ReleaseCandidate, ReleaseCandidateStore
|
||||
from silent_release_hardening import (
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
BaselineController,
|
||||
CapabilityAuthorizationError,
|
||||
CapabilityLedger,
|
||||
ClaimProbeResult,
|
||||
GovernedFinalizationCoordinator,
|
||||
HardenedTagController,
|
||||
PreSwitchValidator,
|
||||
RegistryController,
|
||||
ReleaseOrchestrator,
|
||||
RecoverableReleaseError,
|
||||
ReleasePlan,
|
||||
ReleasePlanStore,
|
||||
ReleaseSagaStore,
|
||||
SignedApprovalStore,
|
||||
SignedApprovalVerifier,
|
||||
TagPlan,
|
||||
TagPublicationConflict,
|
||||
TagPublicationProbeEvidence,
|
||||
TrustedReleasePolicy,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA = "aios.c5.release-execution-host.v1"
|
||||
PROVENANCE_SCHEMA = "aios.c5.release-execution-provenance.v1"
|
||||
REQUEST_SCHEMA = "aios.production-executor-request.v1"
|
||||
REPOSITORY = "aios/aios"
|
||||
SCOPES = frozenset({"PRODUCTION", "ISOLATED_REHEARSAL"})
|
||||
SHA256 = __import__("re").compile(r"^[0-9a-f]{64}$")
|
||||
OID = __import__("re").compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class C5ExecutionHostBlocked(RuntimeError):
|
||||
"""Exact host assembly or phase verification failed closed."""
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _secure_file(
|
||||
path: Path,
|
||||
expected_sha256: str,
|
||||
*,
|
||||
expected_owner_uid: int | None,
|
||||
) -> Path:
|
||||
path = Path(path)
|
||||
if (
|
||||
not path.is_absolute()
|
||||
or path.is_symlink()
|
||||
or path.resolve(strict=False) != path
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_PATH_INVALID")
|
||||
try:
|
||||
info = path.stat()
|
||||
except OSError as exc:
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_UNAVAILABLE") from exc
|
||||
if not stat.S_ISREG(info.st_mode) or (
|
||||
os.name != "nt" and stat.S_IMODE(info.st_mode) & 0o022
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_SECURITY_INVALID")
|
||||
if expected_owner_uid is not None and info.st_uid != expected_owner_uid:
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_OWNER_INVALID")
|
||||
if expected_owner_uid is not None:
|
||||
parent = path.parent
|
||||
while True:
|
||||
parent_info = parent.stat()
|
||||
if (
|
||||
parent.is_symlink()
|
||||
or parent_info.st_uid != expected_owner_uid
|
||||
or stat.S_IMODE(parent_info.st_mode) & 0o022
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_PARENT_SECURITY_INVALID")
|
||||
if parent == parent.parent:
|
||||
break
|
||||
parent = parent.parent
|
||||
if not SHA256.fullmatch(expected_sha256) or _sha256(path) != expected_sha256:
|
||||
raise C5ExecutionHostBlocked("HOST_FILE_DIGEST_MISMATCH")
|
||||
return path
|
||||
|
||||
|
||||
def _absolute(value: Any, name: str) -> Path:
|
||||
path = Path(str(value or ""))
|
||||
if not path.is_absolute():
|
||||
raise C5ExecutionHostBlocked(f"{name}_PATH_INVALID")
|
||||
return path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegisteredPhaseCommand:
|
||||
executable: Path
|
||||
executable_sha256: str
|
||||
adapter_path: Path
|
||||
adapter_sha256: str
|
||||
arguments: tuple[str, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, raw: Any) -> "RegisteredPhaseCommand":
|
||||
if not isinstance(raw, Mapping) or set(raw) != {
|
||||
"executable",
|
||||
"executable_sha256",
|
||||
"adapter_path",
|
||||
"adapter_sha256",
|
||||
"arguments",
|
||||
}:
|
||||
raise C5ExecutionHostBlocked("PHASE_COMMAND_SCHEMA_INVALID")
|
||||
arguments = raw["arguments"]
|
||||
if not isinstance(arguments, list) or not all(
|
||||
isinstance(item, str) and item and "\0" not in item
|
||||
for item in arguments
|
||||
):
|
||||
raise C5ExecutionHostBlocked("PHASE_COMMAND_ARGUMENTS_INVALID")
|
||||
return cls(
|
||||
executable=_absolute(raw["executable"], "PHASE_EXECUTABLE"),
|
||||
executable_sha256=str(raw["executable_sha256"]),
|
||||
adapter_path=_absolute(raw["adapter_path"], "PHASE_ADAPTER"),
|
||||
adapter_sha256=str(raw["adapter_sha256"]),
|
||||
arguments=tuple(arguments),
|
||||
)
|
||||
|
||||
def verify(self, *, expected_owner_uid: int | None) -> None:
|
||||
_secure_file(
|
||||
self.executable,
|
||||
self.executable_sha256,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
_secure_file(
|
||||
self.adapter_path,
|
||||
self.adapter_sha256,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
|
||||
@property
|
||||
def command(self) -> tuple[str, ...]:
|
||||
return (str(self.executable), str(self.adapter_path), *self.arguments)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class C5HostProvenance:
|
||||
environment: str
|
||||
instance_id: str
|
||||
authority_commit: str
|
||||
authority_tree: str
|
||||
entries: Mapping[str, tuple[Path, str]]
|
||||
manifest_sha256: str
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
path: Path,
|
||||
*,
|
||||
expected_sha256: str,
|
||||
expected_owner_uid: int | None = 0,
|
||||
) -> "C5HostProvenance":
|
||||
path = _secure_file(
|
||||
Path(path), expected_sha256,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_INVALID") from exc
|
||||
labels = {
|
||||
"host", "exact_deployment", "deployment", "approved_executor",
|
||||
"tag", "registry", "baseline",
|
||||
}
|
||||
if (
|
||||
not isinstance(raw, Mapping)
|
||||
or set(raw) != {
|
||||
"schema", "environment", "instance_id",
|
||||
"authority_commit", "authority_tree", "entries",
|
||||
}
|
||||
or raw["schema"] != PROVENANCE_SCHEMA
|
||||
or raw["environment"] not in SCOPES
|
||||
or not isinstance(raw["entries"], Mapping)
|
||||
or set(raw["entries"]) != labels
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_INVALID")
|
||||
instance_id = str(raw["instance_id"] or "")
|
||||
if not instance_id or len(instance_id) > 128:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_INSTANCE_INVALID")
|
||||
authority_commit = str(raw["authority_commit"])
|
||||
authority_tree = str(raw["authority_tree"])
|
||||
if not OID.fullmatch(authority_commit) or not OID.fullmatch(authority_tree):
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_AUTHORITY_INVALID")
|
||||
actual_loaded = {
|
||||
"host": Path(__file__).resolve(strict=True),
|
||||
"exact_deployment": Path(exact_deployment_module.__file__).resolve(strict=True),
|
||||
"approved_executor": Path(approved_executor_module.__file__).resolve(strict=True),
|
||||
}
|
||||
entries: dict[str, tuple[Path, str]] = {}
|
||||
for label, item in raw["entries"].items():
|
||||
if not isinstance(item, Mapping) or set(item) != {"path", "sha256"}:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_ENTRY_INVALID")
|
||||
entry_path = _absolute(item["path"], "HOST_PROVENANCE_ENTRY")
|
||||
digest = str(item["sha256"])
|
||||
_secure_file(
|
||||
entry_path, digest, expected_owner_uid=expected_owner_uid
|
||||
)
|
||||
if label in actual_loaded and entry_path != actual_loaded[label]:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_LOADED_PATH_MISMATCH")
|
||||
entries[label] = (entry_path, digest)
|
||||
return cls(
|
||||
environment=str(raw["environment"]),
|
||||
instance_id=instance_id,
|
||||
authority_commit=authority_commit,
|
||||
authority_tree=authority_tree,
|
||||
entries=entries,
|
||||
manifest_sha256=expected_sha256,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class C5ExecutionHostConfig:
|
||||
host_provenance: C5HostProvenance
|
||||
executor_authority_commit: str
|
||||
executor_authority_tree: str
|
||||
execution_scope: str
|
||||
instance_id: str
|
||||
provenance: Mapping[str, Any]
|
||||
candidate_id: str
|
||||
source_commit: str
|
||||
tree_oid: str
|
||||
artifact_sha256: str
|
||||
release_plan_sha256: str
|
||||
approval_fingerprint: str
|
||||
state_root: Path
|
||||
trusted_policy_path: Path
|
||||
trusted_policy_sha256: str
|
||||
approval_keyring_path: Path
|
||||
approval_keyring_sha256: str
|
||||
source_admission_root: Path
|
||||
source_admission_policy_path: Path
|
||||
source_admission_policy_sha256: str
|
||||
source_admission_task_id: str
|
||||
source_admission_hmac_path: Path
|
||||
source_admission_hmac_sha256: str
|
||||
deployment_executable: Path
|
||||
deployment_executable_sha256: str
|
||||
deployment_adapter_path: Path
|
||||
deployment_adapter_sha256: str
|
||||
artifact_path: Path
|
||||
rollback_target: Mapping[str, str]
|
||||
rollback_artifact_path: Path
|
||||
production_environment_file: Path
|
||||
authoritative_git_dir: Path
|
||||
git_executable: Path
|
||||
git_executable_sha256: str
|
||||
tag_command: RegisteredPhaseCommand
|
||||
registry_command: RegisteredPhaseCommand
|
||||
baseline_command: RegisteredPhaseCommand
|
||||
registry_record_path: Path
|
||||
baseline_record_path: Path
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
path: Path,
|
||||
*,
|
||||
expected_sha256: str,
|
||||
host_provenance: C5HostProvenance,
|
||||
expected_owner_uid: int | None = 0,
|
||||
) -> "C5ExecutionHostConfig":
|
||||
path = _secure_file(
|
||||
Path(path),
|
||||
expected_sha256,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise C5ExecutionHostBlocked("HOST_CONFIG_INVALID") from exc
|
||||
keys = {
|
||||
"schema", "version", "execution_scope", "instance_id",
|
||||
"executor_authority_commit", "executor_authority_tree",
|
||||
"provenance", "candidate_id",
|
||||
"source_commit", "tree_oid", "artifact_sha256",
|
||||
"release_plan_sha256", "approval_fingerprint", "state_root",
|
||||
"trusted_policy_path", "trusted_policy_sha256",
|
||||
"approval_keyring_path", "approval_keyring_sha256",
|
||||
"source_admission_root", "source_admission_policy_path",
|
||||
"source_admission_policy_sha256", "source_admission_task_id", "source_admission_hmac_path",
|
||||
"source_admission_hmac_sha256", "deployment_executable",
|
||||
"deployment_executable_sha256", "deployment_adapter_path",
|
||||
"deployment_adapter_sha256", "artifact_path",
|
||||
"rollback_target", "rollback_artifact_path",
|
||||
"production_environment_file", "production_service",
|
||||
"production_port", "repository_identity", "global_legacy_writes_enabled",
|
||||
"authoritative_git_dir", "git_executable", "git_executable_sha256",
|
||||
"tag_command", "registry_command", "baseline_command",
|
||||
"registry_record_path", "baseline_record_path",
|
||||
}
|
||||
if not isinstance(raw, Mapping) or set(raw) != keys:
|
||||
raise C5ExecutionHostBlocked("HOST_CONFIG_SCHEMA_INVALID")
|
||||
rollback_artifact_path = _absolute(raw["rollback_artifact_path"], "ROLLBACK_ARTIFACT")
|
||||
production_environment_file = _absolute(raw["production_environment_file"], "PRODUCTION_ENVIRONMENT_FILE")
|
||||
exact = (
|
||||
raw["schema"] == SCHEMA
|
||||
and raw["version"] == C5_VERSION
|
||||
and raw["execution_scope"] in SCOPES
|
||||
and isinstance(raw["rollback_target"], Mapping)
|
||||
and raw["production_service"] == C5_PRODUCTION_SERVICE
|
||||
and raw["production_port"] == C5_PRODUCTION_PORT
|
||||
and raw["repository_identity"] == REPOSITORY
|
||||
and raw["global_legacy_writes_enabled"] is False
|
||||
)
|
||||
if not exact:
|
||||
raise C5ExecutionHostBlocked("HOST_CONFIG_BINDING_INVALID")
|
||||
provenance = raw["provenance"]
|
||||
expected_provenance = (
|
||||
{
|
||||
"environment": "ISOLATED_REHEARSAL",
|
||||
"fixture": True,
|
||||
"r14_source_receipt": False,
|
||||
}
|
||||
if raw["execution_scope"] == "ISOLATED_REHEARSAL"
|
||||
else {
|
||||
"environment": "PRODUCTION",
|
||||
"fixture": False,
|
||||
"r14_source_receipt": True,
|
||||
}
|
||||
)
|
||||
if provenance != expected_provenance:
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_INVALID")
|
||||
instance_id = str(raw["instance_id"] or "")
|
||||
if not instance_id or len(instance_id) > 128 or any(
|
||||
character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-"
|
||||
for character in instance_id
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_INSTANCE_ID_INVALID")
|
||||
for name, pattern in (
|
||||
("executor_authority_commit", OID),
|
||||
("executor_authority_tree", OID),
|
||||
("source_commit", OID), ("tree_oid", OID),
|
||||
("artifact_sha256", SHA256),
|
||||
("release_plan_sha256", SHA256),
|
||||
("approval_fingerprint", SHA256),
|
||||
):
|
||||
if pattern.fullmatch(str(raw[name])) is None:
|
||||
raise C5ExecutionHostBlocked(f"{name.upper()}_INVALID")
|
||||
value = cls(
|
||||
host_provenance=host_provenance,
|
||||
executor_authority_commit=str(raw["executor_authority_commit"]),
|
||||
executor_authority_tree=str(raw["executor_authority_tree"]),
|
||||
execution_scope=str(raw["execution_scope"]),
|
||||
instance_id=instance_id,
|
||||
provenance=dict(provenance),
|
||||
candidate_id=str(raw["candidate_id"]),
|
||||
source_commit=str(raw["source_commit"]),
|
||||
tree_oid=str(raw["tree_oid"]),
|
||||
artifact_sha256=str(raw["artifact_sha256"]),
|
||||
release_plan_sha256=str(raw["release_plan_sha256"]),
|
||||
approval_fingerprint=str(raw["approval_fingerprint"]),
|
||||
state_root=_absolute(raw["state_root"], "STATE_ROOT"),
|
||||
trusted_policy_path=_absolute(raw["trusted_policy_path"], "TRUSTED_POLICY"),
|
||||
trusted_policy_sha256=str(raw["trusted_policy_sha256"]),
|
||||
approval_keyring_path=_absolute(raw["approval_keyring_path"], "APPROVAL_KEYRING"),
|
||||
approval_keyring_sha256=str(raw["approval_keyring_sha256"]),
|
||||
source_admission_root=_absolute(raw["source_admission_root"], "SOURCE_ADMISSION_ROOT"),
|
||||
source_admission_policy_path=_absolute(raw["source_admission_policy_path"], "SOURCE_ADMISSION_POLICY"),
|
||||
source_admission_policy_sha256=str(raw["source_admission_policy_sha256"]),
|
||||
source_admission_task_id=str(raw["source_admission_task_id"]),
|
||||
source_admission_hmac_path=_absolute(raw["source_admission_hmac_path"], "SOURCE_ADMISSION_HMAC"),
|
||||
source_admission_hmac_sha256=str(raw["source_admission_hmac_sha256"]),
|
||||
deployment_executable=_absolute(raw["deployment_executable"], "DEPLOYMENT_EXECUTABLE"),
|
||||
deployment_executable_sha256=str(raw["deployment_executable_sha256"]),
|
||||
deployment_adapter_path=_absolute(raw["deployment_adapter_path"], "DEPLOYMENT_ADAPTER"),
|
||||
deployment_adapter_sha256=str(raw["deployment_adapter_sha256"]),
|
||||
artifact_path=_absolute(raw["artifact_path"], "ARTIFACT"),
|
||||
rollback_target=dict(raw["rollback_target"]),
|
||||
rollback_artifact_path=rollback_artifact_path,
|
||||
production_environment_file=production_environment_file,
|
||||
authoritative_git_dir=_absolute(raw["authoritative_git_dir"], "AUTHORITATIVE_GIT_DIR"),
|
||||
git_executable=_absolute(raw["git_executable"], "GIT_EXECUTABLE"),
|
||||
git_executable_sha256=str(raw["git_executable_sha256"]),
|
||||
tag_command=RegisteredPhaseCommand.from_mapping(raw["tag_command"]),
|
||||
registry_command=RegisteredPhaseCommand.from_mapping(raw["registry_command"]),
|
||||
baseline_command=RegisteredPhaseCommand.from_mapping(raw["baseline_command"]),
|
||||
registry_record_path=_absolute(raw["registry_record_path"], "REGISTRY_RECORD"),
|
||||
baseline_record_path=_absolute(raw["baseline_record_path"], "BASELINE_RECORD"),
|
||||
)
|
||||
if (not value.source_admission_task_id
|
||||
or len(value.source_admission_task_id) > 160
|
||||
or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.:-"
|
||||
for character in value.source_admission_task_id)):
|
||||
raise C5ExecutionHostBlocked("SOURCE_ADMISSION_TASK_ID_INVALID")
|
||||
if value.execution_scope == "ISOLATED_REHEARSAL":
|
||||
rehearsal_root = value.state_root.resolve(strict=False)
|
||||
lowered_parts = {part.lower() for part in rehearsal_root.parts}
|
||||
if (
|
||||
"isolated-rehearsal" not in lowered_parts
|
||||
or value.instance_id not in rehearsal_root.parts
|
||||
):
|
||||
raise C5ExecutionHostBlocked("REHEARSAL_ROOT_NOT_ISOLATED")
|
||||
for fixture_path in (
|
||||
value.source_admission_root,
|
||||
value.artifact_path,
|
||||
value.rollback_artifact_path,
|
||||
value.production_environment_file,
|
||||
value.registry_record_path,
|
||||
value.baseline_record_path,
|
||||
):
|
||||
try:
|
||||
fixture_path.resolve(strict=False).relative_to(rehearsal_root)
|
||||
except ValueError as exc:
|
||||
raise C5ExecutionHostBlocked(
|
||||
"REHEARSAL_FIXTURE_OUTSIDE_ISOLATION_ROOT"
|
||||
) from exc
|
||||
if value.execution_scope == "ISOLATED_REHEARSAL":
|
||||
if set(value.rollback_target) != set(C4_ROLLBACK_TARGET) or value.rollback_target.get("version") != C4_ROLLBACK_TARGET["version"] or value.rollback_target.get("target_oid") != C4_ROLLBACK_TARGET["target_oid"]:
|
||||
raise C5ExecutionHostBlocked("REHEARSAL_ROLLBACK_TARGET_INVALID")
|
||||
if SHA256.fullmatch(str(value.rollback_target.get("artifact_sha256", ""))) is None:
|
||||
raise C5ExecutionHostBlocked("REHEARSAL_ROLLBACK_TARGET_INVALID")
|
||||
if (
|
||||
host_provenance.environment != value.execution_scope
|
||||
or host_provenance.instance_id != value.instance_id
|
||||
or host_provenance.authority_commit
|
||||
!= value.executor_authority_commit
|
||||
or host_provenance.authority_tree != value.executor_authority_tree
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_BINDING_MISMATCH")
|
||||
if value.execution_scope == "PRODUCTION" and (
|
||||
value.rollback_target != C4_ROLLBACK_TARGET
|
||||
or value.rollback_artifact_path != C4_ROLLBACK_ARTIFACT_PATH
|
||||
or value.production_environment_file != C5_PRODUCTION_ENVIRONMENT_FILE
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_CONFIG_BINDING_INVALID")
|
||||
expected_adapter_paths = {
|
||||
"deployment": value.deployment_adapter_path,
|
||||
"tag": value.tag_command.adapter_path,
|
||||
"registry": value.registry_command.adapter_path,
|
||||
"baseline": value.baseline_command.adapter_path,
|
||||
}
|
||||
if any(
|
||||
host_provenance.entries[label][0] != path
|
||||
for label, path in expected_adapter_paths.items()
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_PROVENANCE_ADAPTER_MISMATCH")
|
||||
production_names = {
|
||||
"deployment": "c5_production_deployment_adapter.py",
|
||||
"tag": "governed_tag_adapter.py",
|
||||
"registry": "release_metadata_adapter.py",
|
||||
"baseline": "release_metadata_adapter.py",
|
||||
}
|
||||
observed_names = {
|
||||
label: path.name for label, path in expected_adapter_paths.items()
|
||||
}
|
||||
if value.execution_scope == "PRODUCTION":
|
||||
if observed_names != production_names:
|
||||
raise C5ExecutionHostBlocked("PRODUCTION_ADAPTER_IDENTITY_INVALID")
|
||||
elif any(
|
||||
observed_names[label] == production_names[label]
|
||||
for label in production_names
|
||||
):
|
||||
raise C5ExecutionHostBlocked("REHEARSAL_PRODUCTION_ADAPTER_FORBIDDEN")
|
||||
value.verify_files(expected_owner_uid=expected_owner_uid)
|
||||
return value
|
||||
|
||||
def verify_files(self, *, expected_owner_uid: int | None) -> None:
|
||||
for path, digest in (
|
||||
(self.trusted_policy_path, self.trusted_policy_sha256),
|
||||
(self.approval_keyring_path, self.approval_keyring_sha256),
|
||||
(self.source_admission_policy_path, self.source_admission_policy_sha256),
|
||||
(self.source_admission_hmac_path, self.source_admission_hmac_sha256),
|
||||
(self.git_executable, self.git_executable_sha256),
|
||||
):
|
||||
_secure_file(path, digest, expected_owner_uid=expected_owner_uid)
|
||||
for command in (
|
||||
self.tag_command, self.registry_command, self.baseline_command
|
||||
):
|
||||
command.verify(expected_owner_uid=expected_owner_uid)
|
||||
|
||||
|
||||
def _load_policy(path: Path) -> TrustedReleasePolicy:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
required = {
|
||||
"services", "release_roots", "cutover_links", "dropin_paths",
|
||||
"authoritative_git_dirs", "repository_identities",
|
||||
}
|
||||
if not isinstance(raw, Mapping) or set(raw) != required:
|
||||
raise C5ExecutionHostBlocked("TRUSTED_POLICY_SCHEMA_INVALID")
|
||||
values: dict[str, frozenset[str]] = {}
|
||||
for name in required:
|
||||
items = raw[name]
|
||||
if not isinstance(items, list) or not items or not all(
|
||||
isinstance(item, str) and item for item in items
|
||||
):
|
||||
raise C5ExecutionHostBlocked("TRUSTED_POLICY_VALUES_INVALID")
|
||||
values[name] = frozenset(items)
|
||||
return TrustedReleasePolicy(**values)
|
||||
|
||||
|
||||
def _phase_request(candidate: ReleaseCandidate, plan: ReleasePlan, approval, operation: str) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": REQUEST_SCHEMA,
|
||||
"operation": operation,
|
||||
"version": C5_VERSION,
|
||||
"target_oid": candidate.source_commit,
|
||||
"evidence_closure_oid": approval.evidence_closure_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"approval_plan_id": approval.approval_plan_id,
|
||||
"repository_identity": plan.deployment.repository_identity,
|
||||
"production_service": plan.deployment.service,
|
||||
"production_port": C5_PRODUCTION_PORT,
|
||||
}
|
||||
|
||||
|
||||
def _checked_phase_response(
|
||||
value: Mapping[str, Any], request: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
for name in (
|
||||
"version", "target_oid", "evidence_closure_oid",
|
||||
"artifact_sha256", "approval_plan_id",
|
||||
):
|
||||
if value.get(name) != request[name]:
|
||||
raise C5ExecutionHostBlocked(f"PHASE_RECEIPT_MISMATCH:{name}")
|
||||
return dict(value)
|
||||
|
||||
|
||||
class _PreSwitchProbe:
|
||||
def __init__(self, config: C5ExecutionHostConfig, source_admissions):
|
||||
self.config = config
|
||||
self.source_admissions = source_admissions
|
||||
|
||||
def _git(self, *args: str) -> str:
|
||||
completed = subprocess.run(
|
||||
[str(self.config.git_executable), f"--git-dir={self.config.authoritative_git_dir}", *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise C5ExecutionHostBlocked("PRE_SWITCH_GIT_UNAVAILABLE")
|
||||
return completed.stdout.strip()
|
||||
|
||||
def collect(self, candidate: ReleaseCandidate, plan) -> Mapping[str, Any]:
|
||||
admitted = self.source_admissions.resolve_recipe(
|
||||
C5_VERSION, candidate.candidate_id, candidate.source_commit
|
||||
)
|
||||
tree = self._git("rev-parse", f"{candidate.source_commit}^{{tree}}")
|
||||
artifact_ok = (
|
||||
self.config.artifact_path.is_file()
|
||||
and _sha256(self.config.artifact_path) == candidate.artifact_sha256
|
||||
)
|
||||
rollback_path = (
|
||||
C4_ROLLBACK_ARTIFACT_PATH
|
||||
if self.config.execution_scope == "PRODUCTION"
|
||||
else self.config.rollback_artifact_path
|
||||
)
|
||||
rollback_target = (
|
||||
C4_ROLLBACK_TARGET
|
||||
if self.config.execution_scope == "PRODUCTION"
|
||||
else self.config.rollback_target
|
||||
)
|
||||
rollback_ok = (
|
||||
rollback_path.is_file()
|
||||
and _sha256(rollback_path) == rollback_target["artifact_sha256"]
|
||||
)
|
||||
return {
|
||||
"source_commit": candidate.source_commit,
|
||||
"tree_oid": tree,
|
||||
"artifact_sha256": candidate.artifact_sha256 if artifact_ok else "",
|
||||
"manifest_sha256": candidate.manifest_sha256,
|
||||
"deployment_plan_sha256": plan.sha256,
|
||||
"policy_sha256": plan.policy_sha256,
|
||||
"rollback_ready": rollback_ok,
|
||||
"git_refs_consistent": (
|
||||
admitted.get("source_commit") == candidate.source_commit
|
||||
and admitted.get("tree_oid") == tree
|
||||
),
|
||||
"repository_identity": plan.repository_identity,
|
||||
"writer_disabled": os.environ.get(
|
||||
"AIOS_GITOPS_WRITES_ENABLED", "false"
|
||||
).lower() == "false",
|
||||
}
|
||||
|
||||
|
||||
class _TagPhase:
|
||||
def __init__(self, command: RegisteredPhaseCommand, candidate, plan, approval, owner_uid):
|
||||
self.command_config = command
|
||||
self.candidate = candidate
|
||||
self.plan = plan
|
||||
self.approval = approval
|
||||
self.owner_uid = owner_uid
|
||||
|
||||
def _call(self, operation: str) -> dict[str, Any]:
|
||||
self.command_config.verify(expected_owner_uid=self.owner_uid)
|
||||
request = _phase_request(self.candidate, self.plan, self.approval, operation)
|
||||
return _checked_phase_response(
|
||||
run_adapter(self.command_config.command, request), request
|
||||
)
|
||||
|
||||
def probe_evidence(self, tag_plan: TagPlan) -> TagPublicationProbeEvidence:
|
||||
value = self._call("probe")
|
||||
status = value.get("status")
|
||||
listing = value.get("gitea_listing_visible")
|
||||
if status == "NOT_APPLIED":
|
||||
listing = False
|
||||
return TagPublicationProbeEvidence(
|
||||
repository_identity=tag_plan.repository_identity,
|
||||
remote_reachable=True,
|
||||
gitea_api_reachable=True,
|
||||
gitea_listing_reachable=True,
|
||||
remote_tag_oid=value.get("remote_tag_oid"),
|
||||
annotated_tag_object_oid=value.get("tag_oid"),
|
||||
remote_peeled_target_oid=value.get("remote_peeled_target_oid"),
|
||||
gitea_api_tag_oid=value.get("gitea_tag_oid"),
|
||||
gitea_api_peeled_target_oid=value.get("gitea_peeled_target_oid"),
|
||||
gitea_listing_visible=listing,
|
||||
gitea_listing_tag_oid=(value.get("gitea_tag_oid") if listing else None),
|
||||
gitea_listing_peeled_target_oid=(value.get("gitea_peeled_target_oid") if listing else None),
|
||||
observed_at=time.time(),
|
||||
)
|
||||
|
||||
def __call__(self, *, tag_plan: TagPlan) -> Mapping[str, Any]:
|
||||
value = self._call("probe")
|
||||
if value.get("status") in {"NOT_APPLIED", "PARTIAL_LOCAL"}:
|
||||
value = self._call("apply")
|
||||
value = self._call("probe")
|
||||
if value.get("status") == "CONFLICT":
|
||||
raise TagPublicationConflict(
|
||||
"tag publication conflicts with the exact C5 plan"
|
||||
)
|
||||
if value.get("status") != "APPLIED_VERIFIED":
|
||||
raise RecoverableReleaseError(
|
||||
"tag publication outcome is indeterminate"
|
||||
)
|
||||
proof = self.probe_evidence(tag_plan)
|
||||
if proof.remote_tag_oid != tag_plan.expected_tag_oid:
|
||||
raise C5ExecutionHostBlocked("TAG_OBJECT_BINDING_MISMATCH")
|
||||
return {
|
||||
"verified": True,
|
||||
"evidence_closure_oid": self.approval.evidence_closure_oid,
|
||||
"approval_plan_id": self.approval.approval_plan_id,
|
||||
"repository_identity": tag_plan.repository_identity,
|
||||
"remote_tag_oid": proof.remote_tag_oid,
|
||||
"remote_peeled_target_oid": proof.remote_peeled_target_oid,
|
||||
"gitea_api_tag_oid": proof.gitea_api_tag_oid,
|
||||
"gitea_api_peeled_target_oid": proof.gitea_api_peeled_target_oid,
|
||||
"gitea_listing_visible": proof.gitea_listing_visible,
|
||||
}
|
||||
|
||||
|
||||
class _MetadataPhase:
|
||||
def __init__(self, *, mode: str, command: RegisteredPhaseCommand, record_path: Path,
|
||||
candidate, plan, approval, tag_phase: _TagPhase, owner_uid):
|
||||
self.mode = mode
|
||||
self.command_config = command
|
||||
self.record_path = record_path
|
||||
self.candidate = candidate
|
||||
self.plan = plan
|
||||
self.approval = approval
|
||||
self.tag_phase = tag_phase
|
||||
self.owner_uid = owner_uid
|
||||
|
||||
def _call(self, operation: str) -> dict[str, Any]:
|
||||
self.command_config.verify(expected_owner_uid=self.owner_uid)
|
||||
request = _phase_request(self.candidate, self.plan, self.approval, operation)
|
||||
request["tag_oid"] = self.plan.tag.expected_tag_oid
|
||||
return _checked_phase_response(run_adapter(self.command_config.command, request), request)
|
||||
|
||||
def __call__(self, *, finalization_plan) -> Mapping[str, Any]:
|
||||
value = self._call("probe")
|
||||
if value.get("status") == "NOT_APPLIED":
|
||||
self._call("apply")
|
||||
value = self._call("probe")
|
||||
if value.get("status") != "VERIFIED":
|
||||
raise C5ExecutionHostBlocked(f"{self.mode.upper()}_FINALIZATION_NOT_VERIFIED")
|
||||
try:
|
||||
record = json.loads(self.record_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise C5ExecutionHostBlocked(f"{self.mode.upper()}_RECORD_INVALID") from exc
|
||||
expected_record = (
|
||||
finalization_plan.registry_expected_record
|
||||
if self.mode == "registry"
|
||||
else finalization_plan.baseline_expected_record
|
||||
)
|
||||
if any(record.get(key) != item for key, item in expected_record.items()):
|
||||
raise C5ExecutionHostBlocked(f"{self.mode.upper()}_RECORD_BINDING_MISMATCH")
|
||||
tag = self.tag_phase.probe_evidence(self.plan.tag)
|
||||
if (
|
||||
tag.remote_tag_oid != self.plan.tag.expected_tag_oid
|
||||
or tag.remote_peeled_target_oid != self.candidate.source_commit
|
||||
or tag.gitea_api_peeled_target_oid != self.candidate.source_commit
|
||||
or tag.gitea_listing_visible is not True
|
||||
):
|
||||
raise C5ExecutionHostBlocked("FINALIZATION_TAG_PROOF_INVALID")
|
||||
proof = {
|
||||
"verified": True,
|
||||
"tag_verified": True,
|
||||
"remote_tag_oid": tag.remote_tag_oid,
|
||||
"remote_peeled_target_oid": tag.remote_peeled_target_oid,
|
||||
"gitea_api_peeled_target_oid": tag.gitea_api_peeled_target_oid,
|
||||
"gitea_listing_visible": True,
|
||||
"evidence_closure_oid": self.approval.evidence_closure_oid,
|
||||
"approval_plan_id": self.approval.approval_plan_id,
|
||||
f"{self.mode}_target_oid": self.candidate.source_commit,
|
||||
}
|
||||
if self.mode == "baseline":
|
||||
proof.update(
|
||||
{
|
||||
"registry_record": finalization_plan.registry_expected_record,
|
||||
"baseline_record": finalization_plan.baseline_expected_record,
|
||||
"baseline_written_last": True,
|
||||
}
|
||||
)
|
||||
return proof
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class C5ExecutionHost:
|
||||
config: C5ExecutionHostConfig
|
||||
orchestrator: ReleaseOrchestrator
|
||||
|
||||
def execute(self) -> dict[str, Any]:
|
||||
result = dict(self.orchestrator.execute(self.config.candidate_id))
|
||||
production_receipt = (
|
||||
self.config.execution_scope == "PRODUCTION"
|
||||
and result.get("state") == "COMPLETED"
|
||||
)
|
||||
return {
|
||||
**result,
|
||||
"execution_scope": self.config.execution_scope,
|
||||
"instance_id": self.config.instance_id,
|
||||
"provenance": dict(self.config.provenance),
|
||||
"host_authority_commit": self.config.host_provenance.authority_commit,
|
||||
"host_authority_tree": self.config.host_provenance.authority_tree,
|
||||
"host_provenance_sha256": self.config.host_provenance.manifest_sha256,
|
||||
"fixture_evidence": self.config.execution_scope == "ISOLATED_REHEARSAL",
|
||||
"production_receipt": production_receipt,
|
||||
"public_business_e2e_verified": False,
|
||||
"production_acceptance": False,
|
||||
}
|
||||
|
||||
|
||||
def _validate_source_admission_task(config, candidate, admitted) -> None:
|
||||
if admitted.get("task_id") != config.source_admission_task_id:
|
||||
raise C5ExecutionHostBlocked("SOURCE_ADMISSION_TASK_BINDING_INVALID")
|
||||
|
||||
|
||||
def build_execution_host(
|
||||
config: C5ExecutionHostConfig,
|
||||
*,
|
||||
expected_owner_uid: int | None = 0,
|
||||
completion_approvals: Any = None,
|
||||
) -> C5ExecutionHost:
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() != "false":
|
||||
raise C5ExecutionHostBlocked("LEGACY_GLOBAL_WRITES_ENABLED")
|
||||
config.verify_files(expected_owner_uid=expected_owner_uid)
|
||||
candidates = ReleaseCandidateStore(config.state_root / "candidates")
|
||||
plans = ReleasePlanStore(config.state_root / "release-plans")
|
||||
approvals = SignedApprovalStore(config.state_root / "approvals")
|
||||
completion_authority = None
|
||||
if completion_approvals is not None:
|
||||
from c5_completion_renewal import (
|
||||
RegisteredCompletionApprovalStore, RegisteredCompletionAuthority,
|
||||
CompletionRuntimeGuard,
|
||||
)
|
||||
if (type(completion_approvals) is not RegisteredCompletionApprovalStore
|
||||
or type(completion_approvals.authority) is not RegisteredCompletionAuthority
|
||||
or expected_owner_uid != 0 or config.execution_scope != "PRODUCTION"
|
||||
or Path(__file__) != completion_approvals.authority.root / "c5_release_execution_host.py"
|
||||
or Path(completion_approvals.original.root) != approvals.root):
|
||||
raise C5ExecutionHostBlocked("REGISTERED_COMPLETION_DEPENDENCY_REQUIRED")
|
||||
completion_authority = completion_approvals.authority
|
||||
completion_authority.validate_host(
|
||||
state_root=config.state_root, candidate_id=config.candidate_id,
|
||||
source_commit=config.source_commit, tree_oid=config.tree_oid,
|
||||
artifact_sha256=config.artifact_sha256,
|
||||
release_plan_sha256=config.release_plan_sha256,
|
||||
)
|
||||
approvals = completion_approvals
|
||||
sagas = ReleaseSagaStore(config.state_root / "sagas")
|
||||
candidate = candidates.load(config.candidate_id)
|
||||
plan = plans.load(config.candidate_id).validate()
|
||||
approval = approvals.load(config.candidate_id)
|
||||
observed = (
|
||||
candidate.source_commit, candidate.tree_oid, candidate.artifact_sha256,
|
||||
plan.sha256, approval.fingerprint,
|
||||
)
|
||||
expected = (
|
||||
config.source_commit, config.tree_oid, config.artifact_sha256,
|
||||
config.release_plan_sha256, config.approval_fingerprint,
|
||||
)
|
||||
if observed != expected or plan.tag.version != C5_VERSION:
|
||||
raise C5ExecutionHostBlocked("HOST_DURABLE_BINDING_MISMATCH")
|
||||
if (
|
||||
plan.policy_sha256 != config.trusted_policy_sha256
|
||||
or Path(plan.deployment.authoritative_git_dir)
|
||||
!= config.authoritative_git_dir
|
||||
):
|
||||
raise C5ExecutionHostBlocked("HOST_ACTUAL_PLAN_BINDING_MISMATCH")
|
||||
policy = _load_policy(config.trusted_policy_path)
|
||||
policy.validate_deployment(plan.deployment)
|
||||
verifier = SignedApprovalVerifier(
|
||||
ApprovalKeyRing.from_file(config.approval_keyring_path),
|
||||
ApprovalRevocationStore(config.state_root / "revocations"),
|
||||
)
|
||||
verifier.verify_release_plan(approval, candidate, plan, plan.policy_sha256)
|
||||
hmac_key = config.source_admission_hmac_path.read_bytes().strip()
|
||||
source_admissions = open_source_admission_store(
|
||||
config.source_admission_root,
|
||||
repository=REPOSITORY,
|
||||
policy=config.source_admission_policy_path,
|
||||
hmac_key=hmac_key,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
writable=False,
|
||||
)
|
||||
admitted = source_admissions.resolve_recipe(
|
||||
C5_VERSION, candidate.candidate_id, candidate.source_commit
|
||||
)
|
||||
_validate_source_admission_task(config, candidate, admitted)
|
||||
deployment_executor = RegisteredC5DeploymentExecutor(
|
||||
executor_id="c5-production-runtime-switch-v1",
|
||||
executable=config.deployment_executable,
|
||||
executable_sha256=config.deployment_executable_sha256,
|
||||
adapter_path=config.deployment_adapter_path,
|
||||
adapter_sha256=config.deployment_adapter_sha256,
|
||||
artifact_path=config.artifact_path,
|
||||
rollback_artifact_path=config.rollback_artifact_path,
|
||||
production_environment_file=config.production_environment_file,
|
||||
execution_scope=config.execution_scope,
|
||||
isolation_root=(config.state_root if config.execution_scope == "ISOLATED_REHEARSAL" else None),
|
||||
rollback_target=config.rollback_target,
|
||||
require_root_owner=expected_owner_uid == 0,
|
||||
)
|
||||
runtime = C5ExactDeploymentAdapter(
|
||||
candidate=candidate,
|
||||
release_plan=plan,
|
||||
approval=approval,
|
||||
approval_verifier=verifier,
|
||||
source_admissions=source_admissions,
|
||||
executor=deployment_executor,
|
||||
)
|
||||
ledger = CapabilityLedger(config.state_root / "capabilities")
|
||||
tag_phase = _TagPhase(
|
||||
config.tag_command, candidate, plan, approval, expected_owner_uid
|
||||
)
|
||||
tag = HardenedTagController(
|
||||
ledger,
|
||||
tag_phase,
|
||||
expected_repository=REPOSITORY,
|
||||
publication_probe=tag_phase.probe_evidence,
|
||||
executor_id="c5-production-tag-v1",
|
||||
)
|
||||
registry_adapter = _MetadataPhase(
|
||||
mode="registry", command=config.registry_command,
|
||||
record_path=config.registry_record_path, candidate=candidate,
|
||||
plan=plan, approval=approval, tag_phase=tag_phase,
|
||||
owner_uid=expected_owner_uid,
|
||||
)
|
||||
baseline_adapter = _MetadataPhase(
|
||||
mode="baseline", command=config.baseline_command,
|
||||
record_path=config.baseline_record_path, candidate=candidate,
|
||||
plan=plan, approval=approval, tag_phase=tag_phase,
|
||||
owner_uid=expected_owner_uid,
|
||||
)
|
||||
finalizer = GovernedFinalizationCoordinator(
|
||||
RegistryController(ledger, registry_adapter, executor_id="c5-production-registry-v1"),
|
||||
BaselineController(ledger, baseline_adapter, executor_id="c5-production-baseline-v1"),
|
||||
)
|
||||
|
||||
def actual_plan_factory(actual_candidate: ReleaseCandidate) -> ReleasePlan:
|
||||
actual = plans.load(actual_candidate.candidate_id).validate()
|
||||
runtime._verify_binding(actual_candidate, actual)
|
||||
return actual
|
||||
|
||||
def runtime_verify(*, candidate: ReleaseCandidate, plan: ReleasePlan) -> Mapping[str, Any]:
|
||||
runtime._verify_binding(candidate, plan)
|
||||
result = runtime._invoke("probe")
|
||||
if runtime._classify(result) != ClaimProbeResult.APPLIED_VERIFIED:
|
||||
raise C5ExecutionHostBlocked("RUNTIME_VERIFY_INDETERMINATE")
|
||||
return {
|
||||
"verified": True,
|
||||
"source_commit": candidate.source_commit,
|
||||
"tree_oid": candidate.tree_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"manifest_sha256": candidate.manifest_sha256,
|
||||
"repository_identity": plan.deployment.repository_identity,
|
||||
"authoritative_git_dir": plan.deployment.authoritative_git_dir,
|
||||
"writer_disabled": True,
|
||||
}
|
||||
|
||||
orchestrator = ReleaseOrchestrator(
|
||||
candidates=candidates,
|
||||
plans=plans,
|
||||
approvals=approvals,
|
||||
sagas=sagas,
|
||||
approval_verifier=verifier,
|
||||
trusted_policy=policy,
|
||||
actual_plan_factory=actual_plan_factory,
|
||||
pre_switch_validator=PreSwitchValidator(
|
||||
_PreSwitchProbe(config, source_admissions)
|
||||
),
|
||||
runtime_switch=(CompletionRuntimeGuard(completion_authority) if completion_authority
|
||||
else runtime.controller(ledger)),
|
||||
runtime_verify=runtime_verify,
|
||||
runtime_rollback=(completion_authority.deny_runtime_effect if completion_authority
|
||||
else runtime.rollback),
|
||||
tag_publish=tag,
|
||||
finalizer=finalizer,
|
||||
)
|
||||
return C5ExecutionHost(config=config, orchestrator=orchestrator)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
parser.add_argument("--config-sha256", required=True)
|
||||
parser.add_argument("--provenance", type=Path, required=True)
|
||||
parser.add_argument("--provenance-sha256", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
provenance = C5HostProvenance.load(
|
||||
args.provenance,
|
||||
expected_sha256=args.provenance_sha256,
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
config = C5ExecutionHostConfig.load(
|
||||
args.config,
|
||||
expected_sha256=args.config_sha256,
|
||||
host_provenance=provenance,
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
result = build_execution_host(config, expected_owner_uid=0).execute()
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (C5ExecutionHostBlocked, CapabilityAuthorizationError) as exc:
|
||||
print(json.dumps({"status": "FAIL_CLOSED", "error": type(exc).__name__}))
|
||||
raise SystemExit(2)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,326 +0,0 @@
|
||||
"""Fail-closed component deployment identity projection.
|
||||
|
||||
This module deliberately does not discover a trust root or treat process
|
||||
environment/health responses as runtime evidence. A host supplies a trusted
|
||||
comparison callback after it has performed its own authenticated observation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_COMPONENT_SCHEMA = "aios.component-manifest.v1"
|
||||
_RECEIPT_SCHEMA = "aios.component-artifact-receipt.v1"
|
||||
|
||||
|
||||
class RuntimeIdentityError(ValueError):
|
||||
"""A declared or observed identity has an invalid shape."""
|
||||
|
||||
|
||||
def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise RuntimeIdentityError("DUPLICATE_FIELD")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _load_json(path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
if not path.is_file() or path.is_symlink() or metadata.st_size > 2 * 1024 * 1024:
|
||||
raise RuntimeIdentityError("IDENTITY_FILE_INVALID")
|
||||
value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_strict_object)
|
||||
except (OSError, UnicodeError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeIdentityError("IDENTITY_FILE_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeIdentityError("IDENTITY_OBJECT_REQUIRED")
|
||||
return value
|
||||
|
||||
|
||||
def _require_sha(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or _SHA256.fullmatch(value) is None:
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _require_oid(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or _OID.fullmatch(value) is None:
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _require_text(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: object) -> str:
|
||||
try:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeIdentityError("IDENTITY_VALUE_INVALID") from exc
|
||||
|
||||
|
||||
def _validate_modules(value: object) -> dict[str, str]:
|
||||
if not isinstance(value, Mapping) or not value:
|
||||
raise RuntimeIdentityError("MODULES_INVALID")
|
||||
modules: dict[str, str] = {}
|
||||
for path, digest in value.items():
|
||||
name = _require_text(path, "module_path")
|
||||
if name.startswith("/") or ".." in Path(name).parts:
|
||||
raise RuntimeIdentityError("MODULE_PATH_INVALID")
|
||||
modules[name] = _require_sha(digest, "module_sha256")
|
||||
return dict(sorted(modules.items()))
|
||||
|
||||
|
||||
def _validate_extra_files(value: object) -> dict[str, str]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeIdentityError("EXTRA_FILES_INVALID")
|
||||
files: dict[str, str] = {}
|
||||
for path, digest in value.items():
|
||||
name = _require_text(path, "extra_file_path")
|
||||
if name.startswith("/") or ".." in Path(name).parts:
|
||||
raise RuntimeIdentityError("EXTRA_FILE_PATH_INVALID")
|
||||
files[name] = _require_sha(digest, "extra_file_sha256")
|
||||
return dict(sorted(files.items()))
|
||||
|
||||
|
||||
def _relative_artifact_path(value: object, field: str, *, allow_current: bool = False) -> str:
|
||||
name = _require_text(value, field)
|
||||
path = Path(name)
|
||||
if "\\" in name or path.is_absolute() or ".." in path.parts or (name == "." and not allow_current):
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return name
|
||||
|
||||
|
||||
def _validate_start_identity(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping) or set(value) != {"entrypoint", "config_path", "python_path"}:
|
||||
raise RuntimeIdentityError("START_IDENTITY_INVALID")
|
||||
entrypoint = _require_text(value.get("entrypoint"), "entrypoint")
|
||||
config_path = _relative_artifact_path(value.get("config_path"), "config_path")
|
||||
python_path = value.get("python_path")
|
||||
if not isinstance(python_path, list) or not python_path:
|
||||
raise RuntimeIdentityError("START_IDENTITY_INVALID")
|
||||
result = {
|
||||
"entrypoint": entrypoint,
|
||||
"config_path": config_path,
|
||||
"python_path": [
|
||||
_relative_artifact_path(path, "python_path", allow_current=True)
|
||||
for path in python_path
|
||||
],
|
||||
}
|
||||
_canonical(result)
|
||||
return result
|
||||
|
||||
|
||||
def _validate_component(value: object) -> dict[str, Any]:
|
||||
required = {
|
||||
"component_id", "source_oid", "tree_oid", "modules", "extra_files", "config_sha256",
|
||||
"start_identity", "dependency_contract_version", "rollback_artifact_sha256",
|
||||
}
|
||||
if not isinstance(value, Mapping) or set(value) != required:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_FIELDS_INVALID")
|
||||
rollback = value.get("rollback_artifact_sha256")
|
||||
if rollback is not None:
|
||||
rollback = _require_sha(rollback, "rollback_artifact_sha256")
|
||||
return {
|
||||
"component_id": _require_text(value.get("component_id"), "component_id"),
|
||||
"source_oid": _require_oid(value.get("source_oid"), "source_oid"),
|
||||
"tree_oid": _require_oid(value.get("tree_oid"), "tree_oid"),
|
||||
"modules": _validate_modules(value.get("modules")),
|
||||
"extra_files": _validate_extra_files(value.get("extra_files")),
|
||||
"config_sha256": _require_sha(value.get("config_sha256"), "config_sha256"),
|
||||
"start_identity": _validate_start_identity(value.get("start_identity")),
|
||||
"dependency_contract_version": _require_text(value.get("dependency_contract_version"), "dependency_contract_version"),
|
||||
"rollback_artifact_sha256": rollback,
|
||||
}
|
||||
|
||||
|
||||
def validate_component_manifest(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping) or set(value) != {"schema", "schema_version", "components"}:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_FIELDS_INVALID")
|
||||
if value.get("schema") != _COMPONENT_SCHEMA or value.get("schema_version") != 1:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_SCHEMA_INVALID")
|
||||
components = value.get("components")
|
||||
if not isinstance(components, list) or not components:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_COMPONENTS_INVALID")
|
||||
normalized = [_validate_component(component) for component in components]
|
||||
ids = [component["component_id"] for component in normalized]
|
||||
if len(set(ids)) != len(ids):
|
||||
raise RuntimeIdentityError("COMPONENT_ID_DUPLICATE")
|
||||
return {"schema": _COMPONENT_SCHEMA, "schema_version": 1, "components": normalized}
|
||||
|
||||
|
||||
def load_component_manifest(path: Path) -> tuple[dict[str, Any], str]:
|
||||
raw = path.read_bytes()
|
||||
return validate_component_manifest(_load_json(path)), hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def validate_artifact_receipt(value: object) -> dict[str, Any]:
|
||||
required = {"schema", "schema_version", "artifact_sha256", "component_manifest_sha256", "manifest_sha256", "evidence_refs"}
|
||||
optional = {"version", "source_commit", "tree_oid", "artifact_size", "production_deployed"}
|
||||
if not isinstance(value, Mapping) or not required.issubset(value) or set(value) - required - optional:
|
||||
raise RuntimeIdentityError("ARTIFACT_RECEIPT_FIELDS_INVALID")
|
||||
if value.get("schema") != _RECEIPT_SCHEMA or value.get("schema_version") != 1:
|
||||
raise RuntimeIdentityError("ARTIFACT_RECEIPT_SCHEMA_INVALID")
|
||||
references = value.get("evidence_refs")
|
||||
if not isinstance(references, list) or not references or any(not isinstance(ref, str) or not ref.strip() for ref in references):
|
||||
raise RuntimeIdentityError("EVIDENCE_REFS_INVALID")
|
||||
result = {
|
||||
"schema": _RECEIPT_SCHEMA,
|
||||
"schema_version": 1,
|
||||
"artifact_sha256": _require_sha(value.get("artifact_sha256"), "artifact_sha256"),
|
||||
"component_manifest_sha256": _require_sha(value.get("component_manifest_sha256"), "component_manifest_sha256"),
|
||||
"manifest_sha256": _require_sha(value.get("manifest_sha256"), "manifest_sha256"),
|
||||
"evidence_refs": list(references),
|
||||
}
|
||||
if "version" in value:
|
||||
result["version"] = _require_text(value["version"], "version")
|
||||
if "source_commit" in value:
|
||||
result["source_commit"] = _require_oid(value["source_commit"], "source_commit")
|
||||
if "tree_oid" in value:
|
||||
result["tree_oid"] = _require_oid(value["tree_oid"], "tree_oid")
|
||||
if "artifact_size" in value:
|
||||
size = value["artifact_size"]
|
||||
if type(size) is not int or size < 1:
|
||||
raise RuntimeIdentityError("ARTIFACT_SIZE_INVALID")
|
||||
result["artifact_size"] = size
|
||||
if "production_deployed" in value:
|
||||
deployed = value["production_deployed"]
|
||||
if type(deployed) is not bool:
|
||||
raise RuntimeIdentityError("PRODUCTION_DEPLOYED_INVALID")
|
||||
result["production_deployed"] = deployed
|
||||
return result
|
||||
|
||||
|
||||
def load_artifact_receipt(path: Path) -> dict[str, Any]:
|
||||
return validate_artifact_receipt(_load_json(path))
|
||||
|
||||
|
||||
def _identity_result(
|
||||
status: str,
|
||||
*,
|
||||
component_id: str | None,
|
||||
declared: Mapping[str, Any] | None = None,
|
||||
observed: Mapping[str, Any] | None = None,
|
||||
reason: str,
|
||||
evidence_refs: list[str] | None = None,
|
||||
mismatches: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"status": status,
|
||||
"component_id": component_id,
|
||||
"declared_identity": dict(declared) if declared is not None else None,
|
||||
"observed_identity": dict(observed) if observed is not None else None,
|
||||
"manifest_digest": declared.get("component_manifest_sha256") if declared else None,
|
||||
"config_digest": declared.get("config_sha256") if declared else None,
|
||||
"observed_at": observed.get("observed_at") if observed else None,
|
||||
"expires_at": observed.get("expires_at") if observed else None,
|
||||
"evidence_refs": list(evidence_refs or ()),
|
||||
"reason": reason,
|
||||
"mismatch_fields": list(mismatches or ()),
|
||||
}
|
||||
|
||||
|
||||
def declared_component_identity(manifest_path: Path | None, receipt_path: Path | None, component_id: str | None) -> dict[str, Any] | None:
|
||||
if manifest_path is None or receipt_path is None or not component_id:
|
||||
return None
|
||||
manifest, manifest_digest = load_component_manifest(manifest_path)
|
||||
receipt = load_artifact_receipt(receipt_path)
|
||||
if receipt["component_manifest_sha256"] != manifest_digest:
|
||||
raise RuntimeIdentityError("COMPONENT_MANIFEST_DIGEST_MISMATCH")
|
||||
component = next((item for item in manifest["components"] if item["component_id"] == component_id), None)
|
||||
if component is None:
|
||||
raise RuntimeIdentityError("COMPONENT_ID_NOT_DECLARED")
|
||||
return {
|
||||
**component,
|
||||
"artifact_sha256": receipt["artifact_sha256"],
|
||||
"component_manifest_sha256": receipt["component_manifest_sha256"],
|
||||
"manifest_sha256": receipt["manifest_sha256"],
|
||||
}
|
||||
|
||||
|
||||
def _timestamp(value: object, field: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)) or float(value) < 0:
|
||||
raise RuntimeIdentityError(f"{field.upper()}_INVALID")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _validate_observed(value: object) -> dict[str, Any]:
|
||||
required = {
|
||||
"component_id", "source_oid", "tree_oid", "modules", "extra_files", "config_sha256", "start_identity",
|
||||
"artifact_sha256", "observed_at", "expires_at", "evidence_refs",
|
||||
}
|
||||
if not isinstance(value, Mapping) or set(value) != required:
|
||||
raise RuntimeIdentityError("OBSERVED_IDENTITY_FIELDS_INVALID")
|
||||
observed_at = _timestamp(value.get("observed_at"), "observed_at")
|
||||
expires_at = _timestamp(value.get("expires_at"), "expires_at")
|
||||
if expires_at <= observed_at:
|
||||
raise RuntimeIdentityError("OBSERVATION_EXPIRY_INVALID")
|
||||
refs = value.get("evidence_refs")
|
||||
if not isinstance(refs, list) or not refs or any(not isinstance(ref, str) or not ref.strip() for ref in refs):
|
||||
raise RuntimeIdentityError("EVIDENCE_REFS_INVALID")
|
||||
return {
|
||||
"component_id": _require_text(value.get("component_id"), "component_id"),
|
||||
"source_oid": _require_oid(value.get("source_oid"), "source_oid"),
|
||||
"tree_oid": _require_oid(value.get("tree_oid"), "tree_oid"),
|
||||
"modules": _validate_modules(value.get("modules")),
|
||||
"extra_files": _validate_extra_files(value.get("extra_files")),
|
||||
"config_sha256": _require_sha(value.get("config_sha256"), "config_sha256"),
|
||||
"start_identity": _validate_start_identity(value.get("start_identity")),
|
||||
"artifact_sha256": _require_sha(value.get("artifact_sha256"), "artifact_sha256"),
|
||||
"observed_at": observed_at,
|
||||
"expires_at": expires_at,
|
||||
"evidence_refs": list(refs),
|
||||
}
|
||||
|
||||
|
||||
TrustedComparison = Callable[[Mapping[str, Any]], Mapping[str, Any] | None]
|
||||
|
||||
|
||||
def evaluate_component_runtime_identity(
|
||||
*,
|
||||
component_id: str | None,
|
||||
manifest_path: Path | None,
|
||||
receipt_path: Path | None,
|
||||
trusted_comparison: TrustedComparison | None = None,
|
||||
now: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Project identity without granting trust to a declaration or self-report."""
|
||||
try:
|
||||
declared = declared_component_identity(manifest_path, receipt_path, component_id)
|
||||
except RuntimeIdentityError as exc:
|
||||
return _identity_result("FAIL", component_id=component_id, reason=str(exc))
|
||||
if declared is None:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, reason="DECLARED_IDENTITY_UNAVAILABLE")
|
||||
if trusted_comparison is None:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, declared=declared, reason="TRUSTED_OBSERVATION_UNAVAILABLE")
|
||||
try:
|
||||
raw_observed = trusted_comparison(dict(declared))
|
||||
except Exception:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, declared=declared, reason="TRUSTED_COMPARISON_UNAVAILABLE")
|
||||
if raw_observed is None:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, declared=declared, reason="OBSERVATION_MISSING")
|
||||
try:
|
||||
observed = _validate_observed(raw_observed)
|
||||
except RuntimeIdentityError as exc:
|
||||
return _identity_result("FAIL", component_id=component_id, declared=declared, reason=str(exc))
|
||||
current = time.time() if now is None else now
|
||||
if observed["expires_at"] <= current:
|
||||
return _identity_result("UNKNOWN", component_id=component_id, declared=declared, observed=observed, reason="OBSERVATION_EXPIRED", evidence_refs=observed["evidence_refs"])
|
||||
fields = ("component_id", "source_oid", "tree_oid", "modules", "extra_files", "config_sha256", "start_identity", "artifact_sha256")
|
||||
mismatches = [field for field in fields if _canonical(declared[field]) != _canonical(observed[field])]
|
||||
if mismatches:
|
||||
return _identity_result("FAIL", component_id=component_id, declared=declared, observed=observed, reason="DECLARED_OBSERVED_DRIFT", evidence_refs=observed["evidence_refs"], mismatches=mismatches)
|
||||
return _identity_result("PASS", component_id=component_id, declared=declared, observed=observed, reason="TRUSTED_COMPARISON_MATCH", evidence_refs=observed["evidence_refs"])
|
||||
@@ -9,8 +9,6 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from component_runtime_identity import evaluate_component_runtime_identity
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA = re.compile(r"^[0-9a-f]{64}$")
|
||||
_STATES = {"STAGED", "ACTIVE", "ROLLED_BACK"}
|
||||
@@ -27,13 +25,6 @@ class RuntimeDeploymentState:
|
||||
return self.deployment_state == "ACTIVE"
|
||||
|
||||
def health_payload(self) -> dict[str, Any]:
|
||||
# This listener owns liveness only. Its environment-derived release
|
||||
# declaration must never become runtime identity evidence by itself.
|
||||
identity = evaluate_component_runtime_identity(
|
||||
component_id=None,
|
||||
manifest_path=None,
|
||||
receipt_path=None,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": self.version,
|
||||
@@ -42,8 +33,6 @@ class RuntimeDeploymentState:
|
||||
"deployment_state": self.deployment_state,
|
||||
"production_deployed": self.production_deployed,
|
||||
"health_timestamp": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"drift_status": identity["status"],
|
||||
"component_runtime_identity": identity,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Authenticated Gitea consistency provider using the real governed Git transport.
|
||||
|
||||
The provider proves Gitea consistency without inventing a REST API:
|
||||
|
||||
* local HEAD == source_oid,
|
||||
* local source_oid^{tree} == tree_oid,
|
||||
* origin identifies the governed repository,
|
||||
* exact checked-out branch exists remotely, and
|
||||
* git ls-remote reports exactly source_oid for that branch.
|
||||
|
||||
It then writes an append-only root-owned v2 provider receipt using only governed
|
||||
policy for provider identity, operation, store root, store id and modes.
|
||||
|
||||
The result explicitly records ``transport=git-ls-remote`` and
|
||||
``api_verified=false``. It must never be described as REST/API evidence.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
from typing import Callable, Mapping
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
RECEIPT_SCHEMA = "aios.trusted-evidence-provider-receipt.v2"
|
||||
RECEIPT_VERSION = 2
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_REF_RE = re.compile(r"^refs/heads/[A-Za-z0-9._/-]+$")
|
||||
_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
class GiteaProviderError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitCommandResult:
|
||||
returncode: int
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
GitRunner = Callable[[tuple[str, ...], Path], GitCommandResult]
|
||||
|
||||
|
||||
def canonical_json_bytes(payload: Mapping) -> bytes:
|
||||
return json.dumps(
|
||||
dict(payload),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def canonical_sha256(payload: Mapping) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(payload)).hexdigest()
|
||||
|
||||
|
||||
def _runner(args: tuple[str, ...], cwd: Path) -> GitCommandResult:
|
||||
proc = subprocess.run(
|
||||
list(args),
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=45,
|
||||
check=False,
|
||||
)
|
||||
return GitCommandResult(proc.returncode, proc.stdout, proc.stderr)
|
||||
|
||||
|
||||
def _git(run: GitRunner, cwd: Path, *args: str, error: str) -> str:
|
||||
result = run(("git", *args), cwd)
|
||||
if result.returncode != 0:
|
||||
raise GiteaProviderError(f"{error}: {result.stderr.strip()[:300]}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _oid(value: str, field: str) -> str:
|
||||
value = str(value).strip().lower()
|
||||
if not _OID_RE.fullmatch(value):
|
||||
raise GiteaProviderError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _mode(value) -> int:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
if text.isdigit():
|
||||
return int(text, 8)
|
||||
raise GiteaProviderError("PROVIDER_MODE_INVALID")
|
||||
|
||||
|
||||
def _safe_ref(value: str) -> Path:
|
||||
raw = str(value).strip()
|
||||
p = Path(raw)
|
||||
if not raw or p.is_absolute() or any(part in ("", ".", "..") for part in p.parts):
|
||||
raise GiteaProviderError("PROVIDER_RECEIPT_REF_INVALID")
|
||||
return p
|
||||
|
||||
|
||||
def _safe_remote_url(url: str) -> str:
|
||||
url = str(url).strip()
|
||||
if "://" in url:
|
||||
parts = urlsplit(url)
|
||||
host = parts.hostname or ""
|
||||
port = f":{parts.port}" if parts.port else ""
|
||||
return urlunsplit((parts.scheme, host + port, parts.path, "", ""))
|
||||
if ":" in url and not url.startswith(("/", "./", "../")):
|
||||
left, right = url.split(":", 1)
|
||||
return f"{left.rsplit('@', 1)[-1]}:{right}"
|
||||
return url
|
||||
|
||||
|
||||
def repository_identity_from_remote(url: str) -> str:
|
||||
safe = _safe_remote_url(url)
|
||||
if "://" in safe:
|
||||
path = urlsplit(safe).path
|
||||
elif ":" in safe and not safe.startswith("/"):
|
||||
path = safe.split(":", 1)[1]
|
||||
else:
|
||||
path = safe
|
||||
parts = [x for x in path.strip("/").split("/") if x]
|
||||
if parts and parts[0] == "git":
|
||||
parts = parts[1:]
|
||||
if len(parts) < 2:
|
||||
raise GiteaProviderError("GITEA_REPOSITORY_IDENTITY_UNAVAILABLE")
|
||||
owner, repo = parts[-2], parts[-1]
|
||||
if repo.endswith(".git"):
|
||||
repo = repo[:-4]
|
||||
identity = f"{owner}/{repo}".lower()
|
||||
if not _REPO_RE.fullmatch(identity):
|
||||
raise GiteaProviderError("GITEA_REPOSITORY_IDENTITY_INVALID")
|
||||
return identity
|
||||
|
||||
|
||||
def load_gitea_policy(policy_path: str | Path) -> dict:
|
||||
raw = json.loads(Path(policy_path).read_text(encoding="utf-8"))
|
||||
defaults = dict(raw.get("defaults") or {})
|
||||
providers = raw.get("providers")
|
||||
if not isinstance(providers, dict) or not isinstance(providers.get("gitea"), dict):
|
||||
raise GiteaProviderError("GITEA_PROVIDER_POLICY_MISSING")
|
||||
cfg = dict(providers["gitea"])
|
||||
return {
|
||||
"provider_identity": str(cfg["provider_identity"]),
|
||||
"store_id": str(cfg["store_id"]),
|
||||
"store_root": Path(str(cfg["store_root"])),
|
||||
"provider_operation": str(cfg["allowed_operations"][0]),
|
||||
"expected_owner_uid": int(cfg.get("expected_owner_uid", defaults.get("expected_owner_uid", 0))),
|
||||
"directory_mode": min(
|
||||
_mode(x) for x in cfg.get(
|
||||
"allowed_directory_modes",
|
||||
defaults.get("allowed_directory_modes", ["0700", "0750"]),
|
||||
)
|
||||
),
|
||||
"receipt_mode": min(
|
||||
_mode(x) for x in cfg.get(
|
||||
"allowed_receipt_modes",
|
||||
defaults.get("allowed_receipt_modes", ["0600", "0640"]),
|
||||
)
|
||||
),
|
||||
"authentication_kind": str(
|
||||
cfg.get(
|
||||
"authentication_kind",
|
||||
defaults.get("authentication_kind", "root-owned-store-v1"),
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def collect_gitea_transport_consistency(
|
||||
*,
|
||||
repo_root: str | Path,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
expected_repository: str = "aios/aios",
|
||||
remote: str = "origin",
|
||||
git_runner: GitRunner | None = None,
|
||||
) -> dict:
|
||||
repo_root = Path(repo_root).resolve()
|
||||
if not repo_root.is_dir():
|
||||
raise GiteaProviderError("GITEA_CHECKOUT_UNAVAILABLE")
|
||||
source_oid = _oid(source_oid, "source_oid")
|
||||
tree_oid = _oid(tree_oid, "tree_oid")
|
||||
expected_repository = str(expected_repository).strip().lower()
|
||||
if not _REPO_RE.fullmatch(expected_repository):
|
||||
raise GiteaProviderError("GITEA_EXPECTED_REPOSITORY_INVALID")
|
||||
if not re.fullmatch(r"[A-Za-z0-9._/-]+", remote) or remote.startswith(("/", "-")):
|
||||
raise GiteaProviderError("GITEA_REMOTE_NAME_INVALID")
|
||||
|
||||
run = git_runner or _runner
|
||||
|
||||
if _git(run, repo_root, "rev-parse", "--is-inside-work-tree",
|
||||
error="GITEA_LOCAL_REPOSITORY_REQUIRED").lower() != "true":
|
||||
raise GiteaProviderError("GITEA_LOCAL_REPOSITORY_REQUIRED")
|
||||
|
||||
actual_source = _oid(
|
||||
_git(run, repo_root, "rev-parse", "HEAD^{commit}",
|
||||
error="GITEA_LOCAL_SOURCE_UNAVAILABLE"),
|
||||
"actual_source_oid",
|
||||
)
|
||||
if actual_source != source_oid:
|
||||
raise GiteaProviderError("GITEA_LOCAL_SOURCE_OID_MISMATCH")
|
||||
|
||||
actual_tree = _oid(
|
||||
_git(run, repo_root, "rev-parse", f"{source_oid}^{{tree}}",
|
||||
error="GITEA_LOCAL_TREE_UNAVAILABLE"),
|
||||
"actual_tree_oid",
|
||||
)
|
||||
if actual_tree != tree_oid:
|
||||
raise GiteaProviderError("GITEA_LOCAL_TREE_OID_MISMATCH")
|
||||
|
||||
branch = _git(
|
||||
run, repo_root, "symbolic-ref", "--quiet", "HEAD",
|
||||
error="GITEA_GOVERNED_BRANCH_REQUIRED",
|
||||
)
|
||||
if not _REF_RE.fullmatch(branch):
|
||||
raise GiteaProviderError("GITEA_GOVERNED_BRANCH_INVALID")
|
||||
|
||||
remote_url = _git(
|
||||
run, repo_root, "remote", "get-url", remote,
|
||||
error="GITEA_REMOTE_UNAVAILABLE",
|
||||
)
|
||||
identity = repository_identity_from_remote(remote_url)
|
||||
if identity != expected_repository:
|
||||
raise GiteaProviderError("GITEA_REPOSITORY_IDENTITY_MISMATCH")
|
||||
|
||||
output = _git(
|
||||
run, repo_root, "ls-remote", "--exit-code", remote, branch,
|
||||
error="GITEA_REMOTE_REF_UNAVAILABLE",
|
||||
)
|
||||
rows = []
|
||||
for line in output.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1] == branch:
|
||||
rows.append((parts[0].lower(), parts[1]))
|
||||
if len(rows) != 1:
|
||||
raise GiteaProviderError("GITEA_REMOTE_REF_AMBIGUOUS")
|
||||
remote_oid = _oid(rows[0][0], "remote_ref_oid")
|
||||
if remote_oid != source_oid:
|
||||
raise GiteaProviderError("GITEA_REMOTE_REF_OID_MISMATCH")
|
||||
|
||||
return {
|
||||
"consistent": True,
|
||||
"repository": identity,
|
||||
"source_commit": source_oid,
|
||||
"remote_ref_oid": remote_oid,
|
||||
"tree_oid": tree_oid,
|
||||
"remote_ref": branch,
|
||||
"remote_url": _safe_remote_url(remote_url),
|
||||
"transport": "git-ls-remote",
|
||||
"api_verified": False,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_root_directory(path: Path, mode: int, expected_uid: int) -> None:
|
||||
if os.geteuid() != expected_uid:
|
||||
raise GiteaProviderError("GITEA_PROVIDER_ROOT_REQUIRED")
|
||||
path.mkdir(parents=True, exist_ok=True, mode=mode)
|
||||
st = os.lstat(path)
|
||||
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
||||
raise GiteaProviderError("GITEA_PROVIDER_STORE_INVALID")
|
||||
if st.st_uid != expected_uid:
|
||||
raise GiteaProviderError("GITEA_PROVIDER_STORE_OWNER_INVALID")
|
||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
||||
raise GiteaProviderError("GITEA_PROVIDER_STORE_MODE_INVALID")
|
||||
|
||||
|
||||
def _write_receipt_exclusive(path: Path, payload: Mapping, mode: int) -> str:
|
||||
data = canonical_json_bytes(payload)
|
||||
if path.exists() or path.is_symlink():
|
||||
raise GiteaProviderError("GITEA_PROVIDER_RECEIPT_EXISTS")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
flags |= getattr(os, "O_BINARY", 0)
|
||||
fd = os.open(path, flags, mode)
|
||||
try:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
written = os.write(fd, view)
|
||||
if written <= 0:
|
||||
raise GiteaProviderError("GITEA_PROVIDER_RECEIPT_WRITE_FAILED")
|
||||
view = view[written:]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.chmod(path, mode)
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def produce_gitea_consistency_receipt(
|
||||
*,
|
||||
repo_root: str | Path,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
policy_path: str | Path,
|
||||
receipt_ref: str,
|
||||
expected_repository: str = "aios/aios",
|
||||
) -> tuple[str, dict]:
|
||||
cfg = load_gitea_policy(policy_path)
|
||||
if cfg["authentication_kind"] != "root-owned-store-v1":
|
||||
raise GiteaProviderError("GITEA_PROVIDER_AUTH_KIND_UNSUPPORTED")
|
||||
result = collect_gitea_transport_consistency(
|
||||
repo_root=repo_root,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
expected_repository=expected_repository,
|
||||
)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
run_id = f"gitea-git-transport-{source_oid[:12]}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
|
||||
rel = _safe_ref(receipt_ref)
|
||||
target = cfg["store_root"].joinpath(rel)
|
||||
root = cfg["store_root"].resolve()
|
||||
if not str(target.resolve(strict=False)).startswith(str(root) + os.sep):
|
||||
raise GiteaProviderError("GITEA_PROVIDER_RECEIPT_REF_ESCAPE")
|
||||
_ensure_root_directory(root, cfg["directory_mode"], cfg["expected_owner_uid"])
|
||||
parent = root
|
||||
for part in rel.parts[:-1]:
|
||||
parent = parent / part
|
||||
_ensure_root_directory(parent, cfg["directory_mode"], cfg["expected_owner_uid"])
|
||||
|
||||
payload = {
|
||||
"schema": RECEIPT_SCHEMA,
|
||||
"version": RECEIPT_VERSION,
|
||||
"provider_identity": cfg["provider_identity"],
|
||||
"provider_operation": cfg["provider_operation"],
|
||||
"operation_run_id": run_id,
|
||||
"result_reference": f"git:{result['remote_url']}#{result['remote_ref']}",
|
||||
"content_sha256": canonical_sha256(result),
|
||||
"source_oid": _oid(source_oid, "source_oid"),
|
||||
"tree_oid": _oid(tree_oid, "tree_oid"),
|
||||
"environment": "hw-isolated-linux-git-transport",
|
||||
"issued_at": now,
|
||||
"result": result,
|
||||
"authentication": {
|
||||
"kind": "root-owned-store-v1",
|
||||
"store_id": cfg["store_id"],
|
||||
},
|
||||
}
|
||||
sha = _write_receipt_exclusive(target, payload, cfg["receipt_mode"])
|
||||
return sha, payload
|
||||
@@ -1,58 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from governed_provider_auth_adapter import (
|
||||
ALL_RELEASE_EVIDENCE_KINDS,
|
||||
authenticate_build_artifacts,
|
||||
gate_w_authentication_status,
|
||||
)
|
||||
from trusted_provider_receipts import (
|
||||
ProviderAuthenticationError,
|
||||
TrustedProviderReceiptResolver,
|
||||
)
|
||||
|
||||
|
||||
def produce_authenticated_evidence_closure(
|
||||
request: Any,
|
||||
*,
|
||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
||||
source_root: Path,
|
||||
builder_policy: Path,
|
||||
legacy_producer=None,
|
||||
):
|
||||
"""Production wrapper for the existing evidence producer.
|
||||
|
||||
This is the governed operation entrypoint. It refuses to call the legacy
|
||||
closure assembler until *all* final-release provider receipts are resolved
|
||||
from authenticated stores. For Gate-W, use `validate_gate_w_provider_auth`
|
||||
instead; canary/rollback remain pending until Gate-R.
|
||||
"""
|
||||
if provider_receipt_resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
||||
if legacy_producer is None:
|
||||
from governed_release_evidence_producer import produce_evidence_closure as legacy_producer
|
||||
|
||||
authenticated_request = authenticate_build_artifacts(
|
||||
request,
|
||||
resolver=provider_receipt_resolver,
|
||||
required_kinds=ALL_RELEASE_EVIDENCE_KINDS,
|
||||
)
|
||||
return legacy_producer(
|
||||
authenticated_request,
|
||||
source_root=source_root,
|
||||
builder_policy=builder_policy,
|
||||
)
|
||||
|
||||
|
||||
def validate_gate_w_provider_auth(
|
||||
request: Any,
|
||||
*,
|
||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
||||
) -> dict[str, str]:
|
||||
"""Gate-W authentication check without pretending Gate-R evidence exists."""
|
||||
return gate_w_authentication_status(
|
||||
request,
|
||||
resolver=provider_receipt_resolver,
|
||||
)
|
||||
@@ -1,323 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed evidence runtime registration chain (V2R2C2 P0).
|
||||
|
||||
Establishes the REAL runtime chain for the governed release evidence producer:
|
||||
|
||||
Inventory -> Resolver -> operation -> Producer -> closure
|
||||
|
||||
The Producer alone (governed_release_evidence_producer) is not "runtime
|
||||
registered" just because its OperationDefinition exists — registration only
|
||||
holds when a runtime can actually discover the operation in an inventory,
|
||||
resolve its ``handler_ref`` to the real callable, and invoke it to produce a
|
||||
closure. That full path is what this module wires and what the
|
||||
``PRODUCER_RUNTIME_REGISTERED_PASS`` verdict proves.
|
||||
|
||||
- Inventory : EvidenceOperationInventory — the set of governed evidence
|
||||
operations the authority can execute (unions the producer
|
||||
registry; extensible to future governed operations).
|
||||
- Resolver : resolve_handler(handler_ref) maps the operation's declared
|
||||
handler reference to the actual ``produce_evidence_closure``
|
||||
callable. Resolution is exact and restrictive — it never
|
||||
imports by arbitrary string eval.
|
||||
- Producer : the resolved callable executes with validated, receipt-bound
|
||||
build artifacts and materializes the evidence closure.
|
||||
- closure : the produced source_root/<version>/ closure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
|
||||
from aios_baseline.operations import OperationDefinition, OperationRegistry
|
||||
from governed_authenticated_evidence_operation import (
|
||||
produce_authenticated_evidence_closure,
|
||||
)
|
||||
from governed_provider_auth_adapter import gate_w_authentication_status
|
||||
from governed_release_evidence_producer import (
|
||||
EvidenceProduceError,
|
||||
GovernedBuildArtifacts,
|
||||
ProduceResult,
|
||||
TRUSTED_PROVIDER_EVIDENCE_TYPES,
|
||||
AUTHENTICATED_EVIDENCE_HANDLER_REF,
|
||||
produce_evidence_closure,
|
||||
producer_operation_definition,
|
||||
producer_registry,
|
||||
)
|
||||
from trusted_provider_receipts import (
|
||||
ProviderAuthenticationError,
|
||||
TrustedProviderPolicy,
|
||||
TrustedProviderReceiptResolver,
|
||||
)
|
||||
|
||||
|
||||
# Exact, restrictive handler_ref -> callable map. This is the ONLY way
|
||||
# handler_refs are resolved; no arbitrary import/eval.
|
||||
_HANDLERS: Mapping[str, Callable[..., ProduceResult]] = {
|
||||
"governed_release_evidence_producer.produce_evidence_closure": produce_evidence_closure,
|
||||
AUTHENTICATED_EVIDENCE_HANDLER_REF: produce_authenticated_evidence_closure,
|
||||
}
|
||||
|
||||
|
||||
class EvidenceOperationInventory:
|
||||
"""Inventory of governed evidence operations available to the authority."""
|
||||
|
||||
def __init__(self, *registries: OperationRegistry) -> None:
|
||||
definitions: list[OperationDefinition] = []
|
||||
seen: set[str] = set()
|
||||
for reg in registries:
|
||||
for definition in reg.definitions:
|
||||
if definition.operation_id in seen:
|
||||
continue
|
||||
seen.add(definition.operation_id)
|
||||
definitions.append(definition)
|
||||
self._registry = OperationRegistry(definitions)
|
||||
self._by_id = {d.operation_id: d for d in definitions}
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> "EvidenceOperationInventory":
|
||||
"""The standard inventory: the governed release evidence producer."""
|
||||
return cls(producer_registry())
|
||||
|
||||
def contains(self, operation_id: str) -> bool:
|
||||
return operation_id in self._by_id
|
||||
|
||||
def operation_ids(self) -> tuple[str, ...]:
|
||||
return tuple(self._by_id)
|
||||
|
||||
def get(self, operation_id: str) -> OperationDefinition | None:
|
||||
return self._by_id.get(operation_id)
|
||||
|
||||
|
||||
def resolve_handler(handler_ref: str) -> Callable[..., ProduceResult]:
|
||||
"""Resolver: turn an OperationDefinition.handler_ref into the real callable.
|
||||
|
||||
Exact lookup in a fixed allowlist. Unknown or non-producer references raise.
|
||||
"""
|
||||
handler = _HANDLERS.get(handler_ref)
|
||||
if handler is None:
|
||||
raise EvidenceProduceError("HANDLER_REF_UNRESOLVED")
|
||||
return handler
|
||||
|
||||
|
||||
def _payload_to_artifacts(payload: Mapping[str, Any]) -> GovernedBuildArtifacts:
|
||||
try:
|
||||
return GovernedBuildArtifacts(
|
||||
version=payload["version"],
|
||||
candidate_id=payload["candidate_id"],
|
||||
source_commit=payload["source_commit"],
|
||||
tree_oid=payload["tree_oid"],
|
||||
artifact=bytes.fromhex(payload["artifact_hex"]),
|
||||
manifest=payload["manifest"],
|
||||
package_closure=bytes.fromhex(payload["package_closure_hex"]),
|
||||
policy=payload["policy"],
|
||||
tests=payload["tests"],
|
||||
canary=payload["canary"],
|
||||
gitea=payload["gitea"],
|
||||
rollback=payload["rollback"],
|
||||
secret_scan=payload["secret_scan"],
|
||||
deployment=payload["deployment"],
|
||||
tag=payload["tag"],
|
||||
finalization=payload["finalization"],
|
||||
rollback_sha256=payload.get("rollback_sha256", ""),
|
||||
issued_at=payload.get("issued_at", ""),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise EvidenceProduceError("PAYLOAD_SCHEMA_INVALID") from exc
|
||||
|
||||
|
||||
def invoke_governed_produce(
|
||||
*,
|
||||
builder_policy: Path,
|
||||
source_root: Path,
|
||||
payload: Mapping[str, Any],
|
||||
expected_owner_uid: int | None = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Real runtime chain: Inventory -> Resolver -> operation -> Producer -> closure.
|
||||
|
||||
Returns a verdict dict. Only a genuine end-to-end invocation that reaches
|
||||
evidence production reports ``status == "PRODUCER_RUNTIME_REGISTERED_PASS"``.
|
||||
"""
|
||||
inventory = EvidenceOperationInventory.default()
|
||||
operation_id = "governed.release.evidence.produce"
|
||||
if not inventory.contains(operation_id):
|
||||
raise EvidenceProduceError("OPERATION_NOT_IN_INVENTORY")
|
||||
|
||||
definition = inventory.get(operation_id)
|
||||
assert definition is not None # guaranteed by contains()
|
||||
handler = resolve_handler(definition.handler_ref)
|
||||
|
||||
request = _payload_to_artifacts(payload)
|
||||
result = handler(
|
||||
builder_policy=builder_policy,
|
||||
source_root=source_root,
|
||||
request=request,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
# The resolved handler returned a real ProduceResult from a real closure;
|
||||
# the registered operation genuinely produced evidence.
|
||||
return {
|
||||
"status": "PRODUCER_RUNTIME_REGISTERED_PASS",
|
||||
"operation_id": operation_id,
|
||||
"handler_ref": definition.handler_ref,
|
||||
"handler_resolved": handler.__module__ + "." + handler.__name__,
|
||||
"evidence_types_bound": list(TRUSTED_PROVIDER_EVIDENCE_TYPES),
|
||||
"result": {
|
||||
"produce_status": result.status,
|
||||
"idempotent": result.idempotent,
|
||||
"receipt_sha256": result.receipt_sha256,
|
||||
"closure_dir": result.closure_dir,
|
||||
"evidence_digests": dict(result.evidence_digests),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V2R2C2 Round-3: Provider-Authenticated runtime chain.
|
||||
#
|
||||
# Inventory -> Resolver -> authenticated operation -> TrustedProviderReceiptResolver
|
||||
# -> Producer assembler (second-layer bound)
|
||||
#
|
||||
# The Round-3 runtime builds TrustedProviderReceiptResolver from the governed
|
||||
# evidence-provider policy (never from request-supplied store paths, provider
|
||||
# identities, key ids, or HMAC material). Gate-W authenticates only the
|
||||
# provider classes that can exist before staging (tests/Gitea/secret-scan);
|
||||
# canary/rollback remain pending until Gate-R.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Sequence used for a full final release closure (Gate-R).
|
||||
ALL_RELEASE_EVIDENCE_KINDS = ("tests", "canary", "gitea", "rollback", "secret_scan")
|
||||
# Provider classes that are real before staging (Gate-W).
|
||||
GATE_W_EVIDENCE_KINDS = ("tests", "gitea", "secret_scan")
|
||||
|
||||
_DEFAULT_PROVIDER_POLICY = Path(__file__).resolve().parents[2] / "config" / "gitops" / "trusted-evidence-providers.json"
|
||||
|
||||
|
||||
def _load_provider_policy(path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
decoded = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_UNAVAILABLE") from exc
|
||||
if not isinstance(decoded, Mapping) or decoded.get("schema") != "aios.trusted-evidence-providers.v1":
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
||||
return decoded
|
||||
|
||||
|
||||
def _policy_from_config(name: str, raw: Mapping[str, Any], defaults: Mapping[str, Any]) -> TrustedProviderPolicy:
|
||||
def _mode(value: object, field: str) -> int:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str) and value.isdigit():
|
||||
return int(value, 8)
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
||||
|
||||
try:
|
||||
store_root = Path(str(raw["store_root"])).expanduser()
|
||||
allowed_ops = frozenset(str(op) for op in raw["allowed_operations"])
|
||||
return TrustedProviderPolicy(
|
||||
evidence_kind=name,
|
||||
provider_identity=str(raw["provider_identity"]),
|
||||
store_id=str(raw["store_id"]),
|
||||
store_root=store_root,
|
||||
allowed_operations=allowed_ops,
|
||||
expected_owner_uid=_mode(raw.get("expected_owner_uid", defaults.get("expected_owner_uid", 0)), "expected_owner_uid"),
|
||||
allowed_directory_modes=frozenset(
|
||||
_mode(v, "allowed_directory_modes")
|
||||
for v in raw.get("allowed_directory_modes", defaults.get("allowed_directory_modes", ["0700", "0750"]))
|
||||
),
|
||||
allowed_receipt_modes=frozenset(
|
||||
_mode(v, "allowed_receipt_modes")
|
||||
for v in raw.get("allowed_receipt_modes", defaults.get("allowed_receipt_modes", ["0600", "0640"]))
|
||||
),
|
||||
authentication_kind=str(raw.get("authentication_kind", defaults.get("authentication_kind", "root-owned-store-v1"))),
|
||||
max_age_seconds=raw.get("max_age_seconds", defaults.get("max_age_seconds")),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID") from exc
|
||||
|
||||
|
||||
def build_provider_receipt_resolver(
|
||||
provider_policy_path: Path = _DEFAULT_PROVIDER_POLICY,
|
||||
*,
|
||||
hmac_key_resolver: Callable[[str], bytes] | None = None,
|
||||
now_fn: Callable[[], Any] | None = None,
|
||||
) -> TrustedProviderReceiptResolver:
|
||||
"""Build TrustedProviderReceiptResolver from the governed evidence-provider policy.
|
||||
|
||||
Store paths, provider identities, key ids and HMAC material come only from
|
||||
the governed policy/secret resolver — never from a request payload.
|
||||
"""
|
||||
policy = _load_provider_policy(provider_policy_path)
|
||||
defaults = dict(policy.get("defaults") or {})
|
||||
providers = policy.get("providers")
|
||||
if not isinstance(providers, Mapping) or not providers:
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
||||
policies: dict[str, TrustedProviderPolicy] = {}
|
||||
for name, raw in providers.items():
|
||||
if not isinstance(raw, Mapping):
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
||||
policies[name] = _policy_from_config(name, raw, defaults)
|
||||
return TrustedProviderReceiptResolver(policies, hmac_key_resolver=hmac_key_resolver, now_fn=now_fn)
|
||||
|
||||
|
||||
def _resolver_guard(resolver: TrustedProviderReceiptResolver | None) -> TrustedProviderReceiptResolver:
|
||||
if resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
||||
return resolver
|
||||
|
||||
|
||||
def validate_gate_w_provider_auth(
|
||||
request: GovernedBuildArtifacts,
|
||||
*,
|
||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
||||
) -> dict[str, str]:
|
||||
"""Gate-W: authenticate only the provider classes real before staging.
|
||||
|
||||
Returns PRODUCER_RUNTIME_REGISTRATION_PASS / PROVIDER_AUTH_ENFORCEMENT_PASS /
|
||||
FINAL_RELEASE_CLOSURE_PENDING_GATE_R. Canary/rollback are intentionally not
|
||||
required here — they remain pending until Gate-R.
|
||||
"""
|
||||
resolver = _resolver_guard(provider_receipt_resolver)
|
||||
gate_w_authentication_status(request, resolver=resolver)
|
||||
return {
|
||||
"producer_runtime": "PRODUCER_RUNTIME_REGISTRATION_PASS",
|
||||
"provider_auth": "PROVIDER_AUTH_ENFORCEMENT_PASS",
|
||||
"final_release_closure": "FINAL_RELEASE_CLOSURE_PENDING_GATE_R",
|
||||
}
|
||||
|
||||
|
||||
def _legacy_closure_adapter(authenticated_request: GovernedBuildArtifacts, *, source_root: Path, builder_policy: Path) -> ProduceResult:
|
||||
"""Invoke the legacy closure assembler on the resolver-authenticated request.
|
||||
|
||||
The classified evidence fields now carry ``provider_receipt`` provenance;
|
||||
the producer's second-layer validator re-asserts content/OID binding on it.
|
||||
"""
|
||||
return produce_evidence_closure(
|
||||
builder_policy=builder_policy,
|
||||
source_root=source_root,
|
||||
request=authenticated_request,
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
|
||||
|
||||
def produce_authenticated_evidence(
|
||||
request: GovernedBuildArtifacts,
|
||||
*,
|
||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
||||
source_root: Path,
|
||||
builder_policy: Path,
|
||||
required_kinds: Iterable[str] = ALL_RELEASE_EVIDENCE_KINDS,
|
||||
) -> ProduceResult:
|
||||
"""Gate-R production path: authenticate all five final-release provider
|
||||
receipts, then invoke the legacy assembler only after authentication."""
|
||||
resolver = _resolver_guard(provider_receipt_resolver)
|
||||
try:
|
||||
return produce_authenticated_evidence_closure(
|
||||
request,
|
||||
provider_receipt_resolver=resolver,
|
||||
source_root=source_root,
|
||||
builder_policy=builder_policy,
|
||||
legacy_producer=_legacy_closure_adapter,
|
||||
)
|
||||
except ProviderAuthenticationError as exc:
|
||||
raise EvidenceProduceError(str(exc)) from exc
|
||||
@@ -1,87 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import is_dataclass, replace
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from trusted_provider_receipts import (
|
||||
ProviderAuthenticationError,
|
||||
ResolvedEvidence,
|
||||
TrustedProviderReceiptResolver,
|
||||
)
|
||||
|
||||
ALL_RELEASE_EVIDENCE_KINDS = ("tests", "canary", "gitea", "rollback", "secret_scan")
|
||||
GATE_W_EVIDENCE_KINDS = ("tests", "gitea", "secret_scan")
|
||||
|
||||
|
||||
def authenticate_evidence_request(
|
||||
evidence_kind: str,
|
||||
evidence_request: Mapping[str, Any],
|
||||
*,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
resolver: TrustedProviderReceiptResolver | None,
|
||||
) -> dict[str, Any]:
|
||||
if resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
||||
resolved: ResolvedEvidence = resolver.resolve_evidence(
|
||||
evidence_kind,
|
||||
evidence_request,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
)
|
||||
return {
|
||||
**dict(resolved.result),
|
||||
"provider_receipt": dict(resolved.provider_receipt),
|
||||
}
|
||||
|
||||
|
||||
def authenticate_build_artifacts(
|
||||
request: Any,
|
||||
*,
|
||||
resolver: TrustedProviderReceiptResolver | None,
|
||||
required_kinds: Iterable[str] = ALL_RELEASE_EVIDENCE_KINDS,
|
||||
) -> Any:
|
||||
"""Authenticate receipt_ref-only evidence and return a replaced request.
|
||||
|
||||
The input is expected to be the existing GovernedBuildArtifacts dataclass.
|
||||
The function deliberately relies only on field names already used by the
|
||||
producer (`source_commit`, `tree_oid`, and the evidence kind fields), so it
|
||||
can be integrated without changing the deployment/release-plan contract.
|
||||
"""
|
||||
if resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
||||
if not is_dataclass(request):
|
||||
raise TypeError("request must be a dataclass instance")
|
||||
source_oid = getattr(request, "source_commit")
|
||||
tree_oid = getattr(request, "tree_oid")
|
||||
replacements: dict[str, Any] = {}
|
||||
for kind in tuple(required_kinds):
|
||||
evidence_request = getattr(request, kind, None)
|
||||
if evidence_request is None:
|
||||
raise ProviderAuthenticationError(f"EVIDENCE_{kind.upper()}_REQUIRED")
|
||||
replacements[kind] = authenticate_evidence_request(
|
||||
kind,
|
||||
evidence_request,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
resolver=resolver,
|
||||
)
|
||||
return replace(request, **replacements)
|
||||
|
||||
|
||||
def gate_w_authentication_status(
|
||||
request: Any,
|
||||
*,
|
||||
resolver: TrustedProviderReceiptResolver | None,
|
||||
) -> dict[str, str]:
|
||||
"""Authenticate Gate-W evidence without pretending final closure exists."""
|
||||
authenticate_build_artifacts(
|
||||
request,
|
||||
resolver=resolver,
|
||||
required_kinds=GATE_W_EVIDENCE_KINDS,
|
||||
)
|
||||
return {
|
||||
"producer_runtime": "PRODUCER_RUNTIME_REGISTRATION_PASS",
|
||||
"provider_auth": "PROVIDER_AUTH_ENFORCEMENT_PASS",
|
||||
"final_release_closure": "FINAL_RELEASE_CLOSURE_PENDING_GATE_R",
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
"""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}$")
|
||||
_SAFE_CODE = re.compile(r"^(?:TASK_PRINCIPAL|EXECUTION_PLAN|GOVERNED)_[A-Z0-9_]{3,120}$")
|
||||
_SAFE_OPERATION = re.compile(r"^[A-Za-z][A-Za-z0-9]{1,63}$")
|
||||
|
||||
|
||||
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",
|
||||
diagnostic_sink: Callable[[Mapping[str, Any]], None] | None = None,
|
||||
) -> 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
|
||||
if diagnostic_sink is not None and not callable(diagnostic_sink):
|
||||
raise ValueError("diagnostic_sink_invalid")
|
||||
self.diagnostic_sink = diagnostic_sink
|
||||
|
||||
def _record_uncertain_effect(self, *, exc: Exception, task_id: str,
|
||||
stage: str) -> None:
|
||||
"""Emit only allowlisted facts; never serialize an exception or body."""
|
||||
if self.diagnostic_sink is None:
|
||||
return
|
||||
raw_status = getattr(exc, "status", None)
|
||||
status = raw_status if type(raw_status) is int and 100 <= raw_status <= 599 else None
|
||||
raw_code = getattr(exc, "error_code", None)
|
||||
code = raw_code if isinstance(raw_code, str) and _SAFE_CODE.fullmatch(raw_code) else None
|
||||
raw_operation = getattr(exc, "operation", None)
|
||||
operation = (
|
||||
raw_operation if isinstance(raw_operation, str)
|
||||
and _SAFE_OPERATION.fullmatch(raw_operation) else stage
|
||||
)
|
||||
diagnostic = {
|
||||
"task_id": task_id, "operation": operation,
|
||||
"http_status": status,
|
||||
"error_code": (
|
||||
"transport_unknown" if status is None else (code or "issuer_error_unknown")
|
||||
),
|
||||
}
|
||||
try:
|
||||
self.diagnostic_sink(diagnostic)
|
||||
except Exception:
|
||||
# Diagnostic observation must never change the uncertainty policy.
|
||||
pass
|
||||
|
||||
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,
|
||||
)
|
||||
stage = "acquireWriterLease"
|
||||
try:
|
||||
session = self.writer_executor.acquire(request=request, job_id=job_id)
|
||||
stage = "commitFiles"
|
||||
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.
|
||||
self._record_uncertain_effect(exc=exc, task_id=intent.task_id, stage=stage)
|
||||
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.
|
||||
self._record_uncertain_effect(
|
||||
exc=exc, task_id=intent.task_id, stage="submitTaskResult",
|
||||
)
|
||||
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
|
||||
@@ -1,634 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed release evidence producer (V2R2C2 P0, operation governed.release.evidence.produce).
|
||||
|
||||
This module is the *producer* half of the governed evidence pipeline. It never
|
||||
invents candidate/release evidence: every digest and pass/fail flag in the closure
|
||||
is derived from real build artifacts and real verification executions supplied as
|
||||
input. It assembles a self-consistent, root-owned evidence closure below
|
||||
``source_root/<version>`` that ``TrustedBuilderEvidenceMaterializer`` (the projection
|
||||
half) can re-validate and project byte-for-byte.
|
||||
|
||||
Contract (see docs/V2R2C2-P0-convergence-20260909.md section 1):
|
||||
- operation_id : governed.release.evidence.produce
|
||||
- input : governed build artifacts + deployment conventions (no hand-write)
|
||||
- output : source_root/<version>/ full evidence_layout closure + receipt.json
|
||||
- fail-closed : mismatched secret-scan digest, tampered artifact, non-passing
|
||||
tests/canary/rollback/secret-scan, or inconsistent gitea all abort
|
||||
without writing a closure.
|
||||
- idempotent : an existing identical closure is accepted; a conflicting one fails.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_baseline.operations import OperationDefinition, OperationRegistry
|
||||
from aios_trusted_candidate_builder import (
|
||||
load_builder_policy,
|
||||
TrustedCandidateBuilderConfig,
|
||||
)
|
||||
from release_evidence_materializer import EvidenceMaterializationError
|
||||
from release_version import validate_release_version
|
||||
|
||||
|
||||
PRODUCE_SCHEMA = "aios.governed-release-evidence-producer.v1"
|
||||
EVIDENCE_RECEIPT_SCHEMA = "aios.trusted-candidate-builder.evidence.v1"
|
||||
# Each governed evidence type (tests/canary/gitea/rollback/secret_scan) must be
|
||||
# bound to a trusted-provider receipt. A plain caller-supplied {"passed": true}
|
||||
# is never accepted on its own: the producer requires the receipt that proves a
|
||||
# trusted provider actually ran the check and produced the referenced result.
|
||||
TRUSTED_PROVIDER_RECEIPT_SCHEMA = "aios.trusted-provider.evidence.v1"
|
||||
TRUSTED_PROVIDER_EVIDENCE_TYPES = ("tests", "canary", "gitea", "rollback", "secret_scan")
|
||||
|
||||
|
||||
class EvidenceProduceError(RuntimeError):
|
||||
"""Stable, non-sensitive producer failure."""
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def _validate_oid(value: object, name: str) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if (
|
||||
len(normalized) != 40
|
||||
or any(char not in "0123456789abcdef" for char in normalized)
|
||||
):
|
||||
raise EvidenceProduceError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_identifier(value: object, name: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if (
|
||||
not normalized
|
||||
or len(normalized) > 255
|
||||
or normalized[0] in ".-"
|
||||
or any(
|
||||
char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:-"
|
||||
for char in normalized
|
||||
)
|
||||
):
|
||||
raise EvidenceProduceError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_sha256(value: object, name: str, *, allow_empty: bool = False) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if allow_empty and not normalized:
|
||||
return ""
|
||||
if (
|
||||
len(normalized) != 64
|
||||
or any(char not in "0123456789abcdef" for char in normalized)
|
||||
):
|
||||
raise EvidenceProduceError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedBuildArtifacts:
|
||||
"""Real build artifacts + real verification results. Producer never invents these."""
|
||||
|
||||
version: str
|
||||
candidate_id: str
|
||||
source_commit: str
|
||||
tree_oid: str
|
||||
artifact: bytes
|
||||
manifest: Mapping[str, Any]
|
||||
package_closure: bytes
|
||||
policy: Mapping[str, Any]
|
||||
tests: Mapping[str, Any]
|
||||
canary: Mapping[str, Any]
|
||||
gitea: Mapping[str, Any]
|
||||
rollback: Mapping[str, Any]
|
||||
secret_scan: Mapping[str, Any]
|
||||
# Deployment conventions (supplied by release planning, not evidence invention).
|
||||
deployment: Mapping[str, Any]
|
||||
tag: Mapping[str, Any]
|
||||
finalization: Mapping[str, Any]
|
||||
rollback_sha256: str = ""
|
||||
issued_at: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProduceResult:
|
||||
status: str
|
||||
idempotent: bool
|
||||
receipt_sha256: str
|
||||
closure_dir: str
|
||||
evidence_digests: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _require_trusted_provider_receipt(
|
||||
name: str,
|
||||
evidence: Mapping[str, Any],
|
||||
source_commit: str,
|
||||
tree_oid: str,
|
||||
) -> None:
|
||||
"""Require a trusted-provider binding for `evidence` (second-layer check).
|
||||
|
||||
A caller may never pass a bare ``{"passed": true}``: each governed evidence
|
||||
type must be bound to a trusted provider. Two accepted forms, both
|
||||
fail-closed on any missing/structurally invalid/digest-mismatched binding:
|
||||
|
||||
1. Round-2 native ``receipt`` (schema ``aios.trusted-provider.evidence.v1``)
|
||||
— a caller-carried receipt.
|
||||
2. Round-3 resolver-authenticated ``provider_receipt`` provenance (schema
|
||||
``aios.trusted-evidence-provider-receipt.v2``) — produced by
|
||||
``TrustedProviderReceiptResolver`` from an authenticated store. Its input
|
||||
is the resolver-authenticated provenance, never a caller-supplied body.
|
||||
|
||||
This helper is a *second-layer* binding validator: for the Round-3 path the
|
||||
authoritative proof is the resolver's authenticated provenance embedded as
|
||||
``provider_receipt``, and this check only re-asserts content/OID binding on
|
||||
top of it.
|
||||
"""
|
||||
receipt = evidence.get("receipt")
|
||||
if isinstance(receipt, Mapping) and receipt:
|
||||
body = {k: v for k, v in dict(evidence).items() if k != "receipt"}
|
||||
expected = _sha256_bytes(_canonical_json(body))
|
||||
if receipt.get("schema") != TRUSTED_PROVIDER_RECEIPT_SCHEMA:
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
||||
if receipt.get("content_sha256") != expected:
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_CONTENT_MISMATCH")
|
||||
for f in ("provider_identity", "operation_run_id", "result_reference",
|
||||
"environment", "issued_at"):
|
||||
if not str(receipt.get(f, "") or "").strip():
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
||||
if receipt.get("source_oid") not in (None, "", source_commit):
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_SOURCE_OID_MISMATCH")
|
||||
if receipt.get("tree_oid") not in (None, "", tree_oid):
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_TREE_OID_MISMATCH")
|
||||
return
|
||||
|
||||
provider_receipt = evidence.get("provider_receipt")
|
||||
if isinstance(provider_receipt, Mapping) and provider_receipt:
|
||||
if (
|
||||
provider_receipt.get("schema")
|
||||
!= "aios.trusted-evidence-provider-receipt.v2"
|
||||
):
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
||||
for f in ("provider_identity", "provider_operation", "operation_run_id",
|
||||
"result_reference", "content_sha256", "source_oid", "tree_oid",
|
||||
"issued_at"):
|
||||
if not str(provider_receipt.get(f, "") or "").strip():
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
||||
if provider_receipt.get("source_oid") != source_commit:
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_SOURCE_OID_MISMATCH")
|
||||
if provider_receipt.get("tree_oid") != tree_oid:
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_TREE_OID_MISMATCH")
|
||||
return
|
||||
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_MISSING")
|
||||
|
||||
|
||||
def _verify_artifacts(
|
||||
config: TrustedCandidateBuilderConfig,
|
||||
request: GovernedBuildArtifacts,
|
||||
) -> dict[str, str]:
|
||||
"""Validate input consistency (fail-closed) and return the evidence digests."""
|
||||
try:
|
||||
version = validate_release_version(request.version)
|
||||
except ValueError as exc:
|
||||
raise EvidenceProduceError("VERSION_INVALID") from exc
|
||||
if version != request.version:
|
||||
raise EvidenceProduceError("VERSION_INVALID")
|
||||
|
||||
candidate_id = _validate_identifier(request.candidate_id, "candidate_id")
|
||||
source_commit = _validate_oid(request.source_commit, "source_commit")
|
||||
tree_oid = _validate_oid(request.tree_oid, "tree_oid")
|
||||
|
||||
if (
|
||||
set(request.manifest) != {
|
||||
"artifact_sha256", "package_closure_sha256",
|
||||
}
|
||||
):
|
||||
raise EvidenceProduceError("MANIFEST_SCHEMA_INVALID")
|
||||
closure_sha = _validate_sha256(
|
||||
request.manifest.get("package_closure_sha256"), "package_closure_sha256"
|
||||
)
|
||||
artifact_sha = _sha256_bytes(request.artifact)
|
||||
if request.manifest["artifact_sha256"] != artifact_sha:
|
||||
raise EvidenceProduceError("MANIFEST_ARTIFACT_DIGEST_MISMATCH")
|
||||
|
||||
# manifest_sha256 is the digest of the manifest evidence file itself. It is
|
||||
# derived (never invented) from the manifest content; the manifest does not
|
||||
# embed its own digest to avoid a self-referential fixed point.
|
||||
manifest_sha = _sha256_bytes(_canonical_json(dict(request.manifest)))
|
||||
if _sha256_bytes(request.package_closure) != closure_sha:
|
||||
raise EvidenceProduceError("PACKAGE_CLOSURE_DIGEST_MISMATCH")
|
||||
|
||||
# policy_sha256 is the digest of the policy evidence file content; derived
|
||||
# here (never invented, never self-referential).
|
||||
policy_sha = _sha256_bytes(_canonical_json(dict(request.policy)))
|
||||
|
||||
# Secret scan must cover the exact artifact bytes.
|
||||
secret_scan_artifact = _validate_sha256(
|
||||
request.secret_scan.get("artifact_sha256"), "secret_scan.artifact_sha256"
|
||||
)
|
||||
if secret_scan_artifact != artifact_sha:
|
||||
raise EvidenceProduceError("SECRET_SCAN_ARTIFACT_DIGEST_MISMATCH")
|
||||
|
||||
# Every governed evidence type must be bound to a trusted-provider receipt.
|
||||
# A bare caller-supplied {"passed": true} (no receipt) fails closed here;
|
||||
# the producer never accepts an invented pass flag.
|
||||
for etype in TRUSTED_PROVIDER_EVIDENCE_TYPES:
|
||||
_require_trusted_provider_receipt(
|
||||
etype, getattr(request, etype), source_commit, tree_oid
|
||||
)
|
||||
|
||||
if request.secret_scan.get("passed") is not True:
|
||||
raise EvidenceProduceError("SECRET_SCAN_NOT_PASSED")
|
||||
|
||||
if request.tests.get("passed") is not True:
|
||||
raise EvidenceProduceError("TESTS_NOT_PASSED")
|
||||
if request.canary.get("passed") is not True:
|
||||
raise EvidenceProduceError("CANARY_NOT_PASSED")
|
||||
if request.rollback.get("ready") is not True:
|
||||
raise EvidenceProduceError("ROLLBACK_NOT_READY")
|
||||
if (
|
||||
request.gitea.get("consistent") is not True
|
||||
or request.gitea.get("repository") != config.repository
|
||||
or request.gitea.get("source_commit") != source_commit
|
||||
or request.gitea.get("remote_ref_oid") != source_commit
|
||||
or request.gitea.get("tree_oid") != tree_oid
|
||||
):
|
||||
raise EvidenceProduceError("GITEA_INCONSISTENT")
|
||||
|
||||
digests = {
|
||||
"artifact": artifact_sha,
|
||||
"manifest": manifest_sha,
|
||||
"package_closure": closure_sha,
|
||||
"policy": policy_sha,
|
||||
"tests": _sha256_bytes(_canonical_json(dict(request.tests))),
|
||||
"canary": _sha256_bytes(_canonical_json(dict(request.canary))),
|
||||
"gitea": _sha256_bytes(_canonical_json(dict(request.gitea))),
|
||||
"rollback": _sha256_bytes(_canonical_json(dict(request.rollback))),
|
||||
"secret_scan": _sha256_bytes(_canonical_json(dict(request.secret_scan))),
|
||||
}
|
||||
return digests
|
||||
|
||||
|
||||
def _build_receipt(
|
||||
config: TrustedCandidateBuilderConfig,
|
||||
request: GovernedBuildArtifacts,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Assemble the self-consistent receipt from verified artifacts."""
|
||||
from silent_release_controller import ReleaseCandidate, ReleaseState
|
||||
|
||||
artifact_sha = _sha256_bytes(request.artifact)
|
||||
manifest_sha = _sha256_bytes(_canonical_json(dict(request.manifest)))
|
||||
closure_sha = _validate_sha256(
|
||||
request.manifest.get("package_closure_sha256"), "package_closure_sha256"
|
||||
)
|
||||
policy_sha = _sha256_bytes(_canonical_json(dict(request.policy)))
|
||||
|
||||
candidate_kwargs: dict[str, Any] = {}
|
||||
issued_at = str(request.issued_at or "").strip()
|
||||
if issued_at:
|
||||
# The evidence was produced at the build execution instant; bind it so the
|
||||
# same verified artifacts reproduce an identical, idempotent closure.
|
||||
candidate_kwargs["created_at"] = issued_at
|
||||
candidate_kwargs["updated_at"] = issued_at
|
||||
candidate = ReleaseCandidate(
|
||||
candidate_id=request.candidate_id,
|
||||
source_commit=request.source_commit,
|
||||
tree_oid=request.tree_oid,
|
||||
artifact_sha256=artifact_sha,
|
||||
manifest_sha256=manifest_sha,
|
||||
tests=dict(request.tests),
|
||||
canary=dict(request.canary),
|
||||
rollback_ready=request.rollback.get("ready") is True,
|
||||
git_consistency={
|
||||
"consistent": True,
|
||||
"repository": config.repository,
|
||||
"source_commit": request.source_commit,
|
||||
"tree_oid": request.tree_oid,
|
||||
},
|
||||
gitea_consistency=dict(request.gitea),
|
||||
state=ReleaseState.READY_FOR_APPROVAL,
|
||||
**candidate_kwargs,
|
||||
)
|
||||
|
||||
deployment_payload = dict(request.deployment)
|
||||
deployment_payload.update(
|
||||
{
|
||||
"service": request.deployment["service"],
|
||||
"source_commit": request.source_commit,
|
||||
"tree_oid": request.tree_oid,
|
||||
"artifact_sha256": artifact_sha,
|
||||
"manifest_sha256": manifest_sha,
|
||||
"package_closure_sha256": closure_sha,
|
||||
"policy_sha256": policy_sha,
|
||||
"rollback_sha256": _validate_sha256(
|
||||
request.rollback_sha256, "rollback_sha256"
|
||||
)
|
||||
or request.rollback.get("rollback_sha256", ""),
|
||||
"repository_identity": config.repository,
|
||||
"authoritative_git_dir": request.deployment["authoritative_git_dir"],
|
||||
}
|
||||
)
|
||||
if not deployment_payload["rollback_sha256"]:
|
||||
raise EvidenceProduceError("ROLLBACK_SHA256_REQUIRED")
|
||||
|
||||
from silent_release_hardening import (
|
||||
DeploymentPlan,
|
||||
FinalizationPlan,
|
||||
ReleasePlan,
|
||||
TagPlan,
|
||||
TrustedCandidateBundle,
|
||||
)
|
||||
|
||||
release_plan = ReleasePlan(
|
||||
deployment=DeploymentPlan.from_dict(deployment_payload),
|
||||
tag=TagPlan.from_dict(dict(request.tag)),
|
||||
finalization=FinalizationPlan.from_dict(dict(request.finalization)),
|
||||
policy_sha256=policy_sha,
|
||||
)
|
||||
checks = {name: True for name in config.required_checks}
|
||||
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
||||
|
||||
return {
|
||||
"schema": EVIDENCE_RECEIPT_SCHEMA,
|
||||
"version": request.version,
|
||||
"candidate": candidate.to_dict(),
|
||||
"release_plan": release_plan.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
def _safe_target(source_root: Path, version: str) -> Path:
|
||||
relative = Path(version)
|
||||
if relative.is_absolute() or any(
|
||||
part in {"", ".", ".."} for part in relative.parts
|
||||
):
|
||||
raise EvidenceProduceError("CLOSURE_PATH_INVALID")
|
||||
candidate = Path(source_root) / version
|
||||
try:
|
||||
metadata = Path(source_root).stat(follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise EvidenceProduceError("SOURCE_ROOT_UNAVAILABLE") from exc
|
||||
if not stat.S_ISDIR(metadata.st_mode) or Path(source_root).is_symlink():
|
||||
raise EvidenceProduceError("SOURCE_ROOT_INVALID")
|
||||
return candidate
|
||||
|
||||
|
||||
def _write_durable_json(path: Path, payload: Mapping[str, Any], mode: int = 0o600) -> str:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
# The deterministic collector compares file digests against the canonical
|
||||
# payload hash (no trailing newline); write the exact canonical bytes so the
|
||||
# producer's closure re-validates under TrustedBuilderEvidenceMaterializer.
|
||||
raw = _canonical_json(payload)
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
mode,
|
||||
)
|
||||
try:
|
||||
os.write(descriptor, raw)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.chmod(path, mode)
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _write_durable_bytes(path: Path, value: bytes, mode: int = 0o600) -> str:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
mode,
|
||||
)
|
||||
try:
|
||||
os.write(descriptor, value)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.chmod(path, mode)
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def produce_evidence_closure(
|
||||
*,
|
||||
builder_policy: Path,
|
||||
source_root: Path,
|
||||
request: GovernedBuildArtifacts,
|
||||
expected_owner_uid: int | None = 0,
|
||||
) -> ProduceResult:
|
||||
"""Produce a self-consistent, root-owned evidence closure, fail-closed and idempotent."""
|
||||
try:
|
||||
config = TrustedCandidateBuilderConfig.from_policy(
|
||||
load_builder_policy(builder_policy)
|
||||
)
|
||||
except Exception as exc:
|
||||
raise EvidenceProduceError(str(exc)) from exc
|
||||
|
||||
try:
|
||||
version = validate_release_version(request.version)
|
||||
except ValueError as exc:
|
||||
raise EvidenceProduceError("VERSION_INVALID") from exc
|
||||
|
||||
digests = _verify_artifacts(config, request)
|
||||
receipt = _build_receipt(config, request)
|
||||
|
||||
# Resolve layout paths from policy (aligned with evidence_layout; never invented).
|
||||
target = _safe_target(source_root, version)
|
||||
layout_paths = {
|
||||
name: target / Path(template.format(version=version)).relative_to(version)
|
||||
for name, template in config.evidence_layout.items()
|
||||
}
|
||||
|
||||
if target.exists() or target.is_symlink():
|
||||
# Idempotency: an existing identical closure (same receipt digest + same
|
||||
# artifact bytes) is accepted; anything else is a conflict.
|
||||
existing_receipt = target / "receipt.json"
|
||||
if not existing_receipt.is_file():
|
||||
raise EvidenceProduceError("CLOSURE_CONFLICT")
|
||||
try:
|
||||
existing = json.loads(existing_receipt.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise EvidenceProduceError("CLOSURE_CONFLICT") from exc
|
||||
if _canonical_json(dict(existing)) != _canonical_json(dict(receipt)):
|
||||
raise EvidenceProduceError("CLOSURE_CONFLICT")
|
||||
for name, path in layout_paths.items():
|
||||
if not (path.is_file() and _file_sha256(path) == digests[name]):
|
||||
raise EvidenceProduceError("CLOSURE_CONFLICT")
|
||||
return ProduceResult(
|
||||
status="EVIDENCE_PRODUCED",
|
||||
idempotent=True,
|
||||
receipt_sha256=_sha256_bytes(_canonical_json(dict(receipt))),
|
||||
closure_dir=str(target),
|
||||
evidence_digests=dict(digests),
|
||||
)
|
||||
|
||||
temporary = source_root / (
|
||||
f".{version}.{os.getpid()}.{secrets.token_hex(8)}.prod.tmp"
|
||||
)
|
||||
if temporary.exists() or temporary.is_symlink():
|
||||
raise EvidenceProduceError("PRODUCE_TEMP_CONFLICT")
|
||||
temporary.mkdir(mode=0o700)
|
||||
# The closure directory mirrors source_root/<version>/; every written file uses
|
||||
# the policy-declared basename so the layout is aligned with evidence_layout.
|
||||
try:
|
||||
_write_durable_json(temporary / "receipt.json", receipt)
|
||||
_write_durable_bytes(temporary / "artifact.tar.gz", request.artifact)
|
||||
_write_durable_json(temporary / "manifest.json", request.manifest)
|
||||
_write_durable_bytes(temporary / "checksums.sha256", request.package_closure)
|
||||
_write_durable_json(temporary / "trusted-release-policy.json", request.policy)
|
||||
_write_durable_json(temporary / "tests.json", request.tests)
|
||||
_write_durable_json(temporary / "canary.json", request.canary)
|
||||
_write_durable_json(temporary / "gitea.json", request.gitea)
|
||||
_write_durable_json(temporary / "rollback.json", request.rollback)
|
||||
_write_durable_json(temporary / "secret-scan.json", request.secret_scan)
|
||||
os.replace(temporary, target)
|
||||
except Exception:
|
||||
shutil.rmtree(temporary, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return ProduceResult(
|
||||
status="EVIDENCE_PRODUCED",
|
||||
idempotent=False,
|
||||
receipt_sha256=_sha256_bytes(_canonical_json(dict(receipt))),
|
||||
closure_dir=str(target),
|
||||
evidence_digests=dict(digests),
|
||||
)
|
||||
|
||||
|
||||
def producer_operation_definition() -> OperationDefinition:
|
||||
"""Register the governed.release.evidence.produce operation (lookup-only registry).
|
||||
|
||||
This is the *legacy closure assembler* entrypoint. V2R2C2 Round-3 keeps it
|
||||
registered for backward compatibility with the existing runtime chain; the
|
||||
governed production path now resolves
|
||||
``governed_authenticated_evidence_operation.produce_authenticated_evidence_closure``
|
||||
(see ``authenticated_producer_operation_definition``).
|
||||
"""
|
||||
return OperationDefinition.from_dict(
|
||||
{
|
||||
"operation_id": "governed.release.evidence.produce",
|
||||
"version": 1,
|
||||
"effect_class": "RECONCILABLE",
|
||||
"handler_ref": "governed_release_evidence_producer.produce_evidence_closure",
|
||||
"input_schema": PRODUCE_SCHEMA,
|
||||
"required_permissions": ["governed.release.evidence.produce"],
|
||||
"timeout_seconds": 60.0,
|
||||
"readback": {"mode": "STATE", "required": True},
|
||||
"retry": {"mode": "NEVER"},
|
||||
"reconcile": {"mode": "EXACT_READBACK"},
|
||||
"rollback": {"mode": "COMPENSATE"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
AUTHENTICATED_EVIDENCE_HANDLER_REF = (
|
||||
"governed_authenticated_evidence_operation.produce_authenticated_evidence_closure"
|
||||
)
|
||||
|
||||
|
||||
def authenticated_producer_operation_definition() -> OperationDefinition:
|
||||
"""Register the governed, provider-authenticated release evidence operation.
|
||||
|
||||
V2R2C2 Round-3 production entrypoint. Its ``handler_ref`` points at the
|
||||
authenticated wrapper, which refuses to invoke the legacy closure assembler
|
||||
until every final-release provider receipt has been resolved from an
|
||||
authenticated store (Gate-W only authenticates tests/Gitea/secret-scan;
|
||||
canary/rollback require Gate-R). See Round-3 Provider Authentication.
|
||||
"""
|
||||
return OperationDefinition.from_dict(
|
||||
{
|
||||
"operation_id": "governed.authenticated.release.evidence.produce",
|
||||
"version": 1,
|
||||
"effect_class": "RECONCILABLE",
|
||||
"handler_ref": AUTHENTICATED_EVIDENCE_HANDLER_REF,
|
||||
"input_schema": PRODUCE_SCHEMA,
|
||||
"required_permissions": ["governed.release.evidence.produce"],
|
||||
"timeout_seconds": 60.0,
|
||||
"readback": {"mode": "STATE", "required": True},
|
||||
"retry": {"mode": "NEVER"},
|
||||
"reconcile": {"mode": "EXACT_READBACK"},
|
||||
"rollback": {"mode": "COMPENSATE"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def producer_registry() -> OperationRegistry:
|
||||
"""Union of the legacy assembler and the authenticated production operation."""
|
||||
return OperationRegistry(
|
||||
[producer_operation_definition(), authenticated_producer_operation_definition()]
|
||||
)
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("operation", choices=("produce",))
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--builder-policy", required=True, type=Path)
|
||||
parser.add_argument("--source-root", required=True, type=Path)
|
||||
parser.add_argument("--artifacts-json", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
try:
|
||||
payload = json.loads(arguments.artifacts_json.read_text(encoding="utf-8"))
|
||||
request = GovernedBuildArtifacts(
|
||||
version=arguments.version,
|
||||
candidate_id=payload["candidate_id"],
|
||||
source_commit=payload["source_commit"],
|
||||
tree_oid=payload["tree_oid"],
|
||||
artifact=bytes.fromhex(payload["artifact_hex"]),
|
||||
manifest=payload["manifest"],
|
||||
package_closure=bytes.fromhex(payload["package_closure_hex"]),
|
||||
policy=payload["policy"],
|
||||
tests=payload["tests"],
|
||||
canary=payload["canary"],
|
||||
gitea=payload["gitea"],
|
||||
rollback=payload["rollback"],
|
||||
secret_scan=payload["secret_scan"],
|
||||
deployment=payload["deployment"],
|
||||
tag=payload["tag"],
|
||||
finalization=payload["finalization"],
|
||||
rollback_sha256=payload.get("rollback_sha256", ""),
|
||||
)
|
||||
result = produce_evidence_closure(
|
||||
builder_policy=arguments.builder_policy,
|
||||
source_root=arguments.source_root,
|
||||
request=request,
|
||||
)
|
||||
except (EvidenceProduceError, KeyError, ValueError, OSError, json.JSONDecodeError) as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
from dataclasses import asdict
|
||||
|
||||
print(json.dumps(asdict(result), sort_keys=True, separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -1,405 +0,0 @@
|
||||
"""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
|
||||
@@ -1,372 +0,0 @@
|
||||
"""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)}
|
||||
@@ -1,136 +0,0 @@
|
||||
"""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
|
||||
@@ -1,359 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed, append-only reopen of one expired Owner approval cycle."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
REQUEST_SCHEMA = "aios.owner-approval-reopen-request.v1"
|
||||
EVENT_NAME = "owner_approval_reopen_authorized"
|
||||
_IDEMPOTENCY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$")
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class ApprovalReopenError(RuntimeError):
|
||||
def __init__(self, code: str, detail: str | None = None):
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
super().__init__(code if not detail else f"{code}:{detail}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReopenRequest:
|
||||
candidate_id: str
|
||||
expected_approval_fingerprint: str
|
||||
expected_source_commit: str
|
||||
expected_tree_oid: str
|
||||
expected_artifact_sha256: str
|
||||
expected_manifest_sha256: str
|
||||
expected_release_plan_sha256: str
|
||||
expected_policy_sha256: str
|
||||
reason: str
|
||||
idempotency_key: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, raw: Mapping[str, Any]) -> "ReopenRequest":
|
||||
if raw.get("schema") != REQUEST_SCHEMA:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SCHEMA_INVALID")
|
||||
allowed = {
|
||||
"schema", "candidate_id", "expected_approval_fingerprint",
|
||||
"expected_source_commit", "expected_tree_oid",
|
||||
"expected_artifact_sha256", "expected_manifest_sha256",
|
||||
"expected_release_plan_sha256", "expected_policy_sha256",
|
||||
"reason", "idempotency_key",
|
||||
}
|
||||
if set(raw) != allowed:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_REQUEST_FIELDS_INVALID")
|
||||
req = cls(
|
||||
candidate_id=str(raw["candidate_id"]),
|
||||
expected_approval_fingerprint=str(raw["expected_approval_fingerprint"]),
|
||||
expected_source_commit=str(raw["expected_source_commit"]),
|
||||
expected_tree_oid=str(raw["expected_tree_oid"]),
|
||||
expected_artifact_sha256=str(raw["expected_artifact_sha256"]),
|
||||
expected_manifest_sha256=str(raw["expected_manifest_sha256"]),
|
||||
expected_release_plan_sha256=str(raw["expected_release_plan_sha256"]),
|
||||
expected_policy_sha256=str(raw["expected_policy_sha256"]),
|
||||
reason=str(raw["reason"]),
|
||||
idempotency_key=str(raw["idempotency_key"]),
|
||||
)
|
||||
if not req.candidate_id or "/" in req.candidate_id or ".." in req.candidate_id:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_ID_INVALID")
|
||||
if not _HEX64.fullmatch(req.expected_approval_fingerprint):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_FINGERPRINT_INVALID")
|
||||
if not _HEX40.fullmatch(req.expected_source_commit):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SOURCE_INVALID")
|
||||
if not _HEX40.fullmatch(req.expected_tree_oid):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_TREE_INVALID")
|
||||
for value, code in (
|
||||
(req.expected_artifact_sha256, "APPROVAL_REOPEN_ARTIFACT_INVALID"),
|
||||
(req.expected_manifest_sha256, "APPROVAL_REOPEN_MANIFEST_INVALID"),
|
||||
(req.expected_release_plan_sha256, "APPROVAL_REOPEN_RELEASE_PLAN_INVALID"),
|
||||
(req.expected_policy_sha256, "APPROVAL_REOPEN_POLICY_INVALID"),
|
||||
):
|
||||
if not _HEX64.fullmatch(value):
|
||||
raise ApprovalReopenError(code)
|
||||
if not (1 <= len(req.reason.strip()) <= 512):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_REASON_INVALID")
|
||||
if not _IDEMPOTENCY_RE.fullmatch(req.idempotency_key):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_IDEMPOTENCY_KEY_INVALID")
|
||||
return req
|
||||
|
||||
|
||||
def _canon(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
||||
|
||||
|
||||
def _sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _time(value: Any, code: str) -> datetime:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ApprovalReopenError(code)
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ApprovalReopenError(code) from exc
|
||||
if dt.tzinfo is None:
|
||||
raise ApprovalReopenError(code)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _secure_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_INVALID")
|
||||
st = path.stat()
|
||||
if st.st_mode & 0o022:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_WRITABLE")
|
||||
if st.st_uid not in {0, os.geteuid()}:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_OWNER_INVALID")
|
||||
try:
|
||||
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_INVALID") from exc
|
||||
if not isinstance(obj, dict):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_INVALID")
|
||||
return obj
|
||||
|
||||
|
||||
def _fsync_dir(path: Path) -> None:
|
||||
fd = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _paths(root: Path, cid: str) -> dict[str, Path]:
|
||||
return {
|
||||
"candidate": root / "candidates" / f"{cid}.json",
|
||||
"saga": root / "sagas" / f"{cid}.json",
|
||||
"plan": root / "release-plans" / f"{cid}.json",
|
||||
"acceptance": root / "acceptance" / f"{cid}.json",
|
||||
"approval": root / "approvals" / f"{cid}.json",
|
||||
}
|
||||
|
||||
|
||||
def _history(root: Path, cid: str, fp: str) -> Path:
|
||||
return root / "approval-history" / cid / f"{fp}.json"
|
||||
|
||||
|
||||
def _archive(active: Path, history: Path) -> None:
|
||||
history.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
try:
|
||||
os.chmod(history.parent, 0o750)
|
||||
except PermissionError:
|
||||
pass
|
||||
original = active.read_bytes()
|
||||
if history.exists():
|
||||
if history.is_symlink() or not history.is_file() or history.read_bytes() != original:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_HISTORY_COLLISION")
|
||||
return
|
||||
try:
|
||||
os.link(active, history)
|
||||
except OSError:
|
||||
fd = os.open(history, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
active.stat().st_mode & 0o777)
|
||||
try:
|
||||
view = memoryview(original)
|
||||
while view:
|
||||
n = os.write(fd, view)
|
||||
view = view[n:]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
if history.read_bytes() != original:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_HISTORY_COPY_FAILED")
|
||||
_fsync_dir(history.parent)
|
||||
|
||||
|
||||
def _details(event: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
val = event.get("details")
|
||||
return val if isinstance(val, Mapping) else {}
|
||||
|
||||
|
||||
def _cycle_event(saga: Mapping[str, Any], cycle_id: str) -> Mapping[str, Any] | None:
|
||||
for event in saga.get("events") or []:
|
||||
if isinstance(event, Mapping) and event.get("event") == EVENT_NAME \
|
||||
and _details(event).get("approval_cycle_id") == cycle_id:
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
def _state(name: str) -> Any:
|
||||
from silent_release_hardening import SagaState # lazy: avoids import cycle
|
||||
return getattr(SagaState, name)
|
||||
|
||||
|
||||
def _cycle_id(req: ReopenRequest) -> str:
|
||||
return "approval-reopen-" + _sha(_canon({
|
||||
"schema": REQUEST_SCHEMA,
|
||||
"candidate_id": req.candidate_id,
|
||||
"old_fingerprint": req.expected_approval_fingerprint,
|
||||
"source_commit": req.expected_source_commit,
|
||||
"tree_oid": req.expected_tree_oid,
|
||||
"artifact_sha256": req.expected_artifact_sha256,
|
||||
"manifest_sha256": req.expected_manifest_sha256,
|
||||
"release_plan_sha256": req.expected_release_plan_sha256,
|
||||
"policy_sha256": req.expected_policy_sha256,
|
||||
"idempotency_key": req.idempotency_key,
|
||||
}))[:24]
|
||||
|
||||
|
||||
def _validate(req: ReopenRequest, candidate: Mapping[str, Any],
|
||||
saga: Mapping[str, Any], plan: Mapping[str, Any],
|
||||
acceptance: Mapping[str, Any], approval: Mapping[str, Any],
|
||||
now: datetime) -> None:
|
||||
if saga.get("journal_integrity") is False:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_JOURNAL_INTEGRITY_INVALID")
|
||||
if saga.get("state") != "APPROVED":
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SAGA_STATE_INVALID")
|
||||
if candidate.get("candidate_id") != req.candidate_id:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_MISMATCH")
|
||||
if candidate.get("state") not in {"READY_FOR_APPROVAL", "APPROVED"}:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_STATE_INVALID")
|
||||
if candidate.get("approval_state") not in {None, "PENDING"}:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_APPROVAL_STATE_INVALID")
|
||||
# ReleaseCandidate v1 durable records do not persist side_effects_executed.
|
||||
# Compatibility rule is intentionally fail-closed:
|
||||
# - an absent key is accepted only together with the independent Saga
|
||||
# invariant below that the accepted Owner-approval event is the final
|
||||
# Saga event;
|
||||
# - if a future durable schema persists the key, it must be exactly False;
|
||||
# - explicit True/None/number/string values are rejected.
|
||||
if "side_effects_executed" in candidate and candidate.get("side_effects_executed") is not False:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SIDE_EFFECTS_ALREADY_EXECUTED")
|
||||
|
||||
checks = {
|
||||
"source": candidate.get("source_commit") == req.expected_source_commit,
|
||||
"tree": candidate.get("tree_oid") == req.expected_tree_oid,
|
||||
"artifact": candidate.get("artifact_sha256") == req.expected_artifact_sha256,
|
||||
"manifest": candidate.get("manifest_sha256") == req.expected_manifest_sha256,
|
||||
"approval_fingerprint": approval.get("fingerprint") == req.expected_approval_fingerprint,
|
||||
"approval_source": approval.get("source_commit") == req.expected_source_commit,
|
||||
"approval_tree": approval.get("tree_oid") == req.expected_tree_oid,
|
||||
"approval_artifact": approval.get("artifact_sha256") == req.expected_artifact_sha256,
|
||||
"approval_manifest": approval.get("manifest_sha256") == req.expected_manifest_sha256,
|
||||
"approval_release_plan": approval.get("release_plan_sha256") == req.expected_release_plan_sha256,
|
||||
"approval_policy": approval.get("policy_sha256") == req.expected_policy_sha256,
|
||||
"acceptance": acceptance.get("status") == "ACCEPTED",
|
||||
"acceptance_release_plan": acceptance.get("release_plan_sha256") == req.expected_release_plan_sha256,
|
||||
"plan_sha": (plan.get("sha256") or plan.get("release_plan_sha256")) == req.expected_release_plan_sha256,
|
||||
"plan_policy": plan.get("policy_sha256") == req.expected_policy_sha256,
|
||||
}
|
||||
bad = sorted(k for k, ok in checks.items() if not ok)
|
||||
if bad:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_BINDING_MISMATCH", ",".join(bad))
|
||||
|
||||
if now <= _time(approval.get("expires_at"), "APPROVAL_REOPEN_APPROVAL_EXPIRY_INVALID"):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_APPROVAL_NOT_EXPIRED")
|
||||
|
||||
events = [e for e in saga.get("events") or [] if isinstance(e, Mapping)]
|
||||
accepted = None
|
||||
for i, event in enumerate(events):
|
||||
if event.get("event") == "signed_owner_approval_accepted" \
|
||||
and _details(event).get("approval_fingerprint") == req.expected_approval_fingerprint:
|
||||
accepted = i
|
||||
if accepted is None:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACCEPTED_EVENT_MISSING")
|
||||
if accepted != len(events) - 1:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SAGA_ALREADY_ADVANCED")
|
||||
|
||||
|
||||
def reopen_owner_approval(service: Any, raw: Mapping[str, Any], *,
|
||||
state_root: Path | None = None,
|
||||
now: datetime | None = None) -> dict[str, Any]:
|
||||
req = ReopenRequest.from_mapping(raw)
|
||||
raw_root = state_root or os.environ.get("AIOS_SILENT_RELEASE_STATE_ROOT", "")
|
||||
if not str(raw_root):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_ROOT_MISSING")
|
||||
root = Path(raw_root).expanduser().resolve()
|
||||
paths = _paths(root, req.candidate_id)
|
||||
cycle = _cycle_id(req)
|
||||
history = _history(root, req.candidate_id, req.expected_approval_fingerprint)
|
||||
saga = _secure_json(paths["saga"])
|
||||
|
||||
prior = _cycle_event(saga, cycle)
|
||||
if prior is not None:
|
||||
if saga.get("state") != "READY_FOR_APPROVAL":
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CYCLE_ALREADY_CONSUMED")
|
||||
if not history.is_file():
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_HISTORY_MISSING")
|
||||
if paths["approval"].exists():
|
||||
active = _secure_json(paths["approval"])
|
||||
if active.get("fingerprint") != req.expected_approval_fingerprint:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACTIVE_APPROVAL_CHANGED")
|
||||
paths["approval"].unlink()
|
||||
_fsync_dir(paths["approval"].parent)
|
||||
return {
|
||||
"schema": "aios.owner-approval-reopen-result.v1",
|
||||
"status": "IDEMPOTENT",
|
||||
"candidate_id": req.candidate_id,
|
||||
"approval_cycle_id": cycle,
|
||||
"supersedes_fingerprint": req.expected_approval_fingerprint,
|
||||
"saga_state": "READY_FOR_APPROVAL",
|
||||
"history_ref": str(history.relative_to(root)),
|
||||
}
|
||||
|
||||
candidate = _secure_json(paths["candidate"])
|
||||
plan = _secure_json(paths["plan"])
|
||||
acceptance = _secure_json(paths["acceptance"])
|
||||
approval = _secure_json(paths["approval"])
|
||||
_validate(req, candidate, saga, plan, acceptance, approval,
|
||||
(now or datetime.now(timezone.utc)).astimezone(timezone.utc))
|
||||
|
||||
_archive(paths["approval"], history)
|
||||
event_details = {
|
||||
"schema": "aios.owner-approval-reopen-event.v1",
|
||||
"approval_cycle_id": cycle,
|
||||
"supersedes_approval_fingerprint": req.expected_approval_fingerprint,
|
||||
"expired_at": approval.get("expires_at"),
|
||||
"release_plan_sha256": req.expected_release_plan_sha256,
|
||||
"source_commit": req.expected_source_commit,
|
||||
"tree_oid": req.expected_tree_oid,
|
||||
"artifact_sha256": req.expected_artifact_sha256,
|
||||
"reason": req.reason.strip(),
|
||||
"idempotency_digest": _sha(req.idempotency_key.encode()),
|
||||
}
|
||||
try:
|
||||
service.sagas.compare_and_set(
|
||||
req.candidate_id,
|
||||
expected_revision=saga.get("revision"),
|
||||
expected_states={_state("APPROVED")},
|
||||
target=_state("READY_FOR_APPROVAL"),
|
||||
event=EVENT_NAME,
|
||||
details=event_details,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SAGA_CAS_FAILED") from exc
|
||||
|
||||
try:
|
||||
active = _secure_json(paths["approval"])
|
||||
if active.get("fingerprint") != req.expected_approval_fingerprint:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACTIVE_APPROVAL_CHANGED")
|
||||
paths["approval"].unlink()
|
||||
_fsync_dir(paths["approval"].parent)
|
||||
except ApprovalReopenError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACTIVE_APPROVAL_REMOVE_FAILED") from exc
|
||||
|
||||
return {
|
||||
"schema": "aios.owner-approval-reopen-result.v1",
|
||||
"status": "REOPENED",
|
||||
"candidate_id": req.candidate_id,
|
||||
"approval_cycle_id": cycle,
|
||||
"supersedes_fingerprint": req.expected_approval_fingerprint,
|
||||
"saga_state": "READY_FOR_APPROVAL",
|
||||
"history_ref": str(history.relative_to(root)),
|
||||
"release_plan_sha256": req.expected_release_plan_sha256,
|
||||
"source_commit": req.expected_source_commit,
|
||||
"tree_oid": req.expected_tree_oid,
|
||||
}
|
||||
@@ -162,8 +162,7 @@ def _copy_exact(source: Path, target: Path, *, mode: int = 0o640) -> None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
descriptor = os.open(
|
||||
target,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
mode,
|
||||
)
|
||||
try:
|
||||
@@ -183,8 +182,7 @@ def _write_json_exclusive(path: Path, payload: Mapping[str, Any]) -> str:
|
||||
raw = _canonical_json(payload) + b"\n"
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o640,
|
||||
)
|
||||
try:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -553,7 +553,7 @@ class ReleaseSourceAdmissionStore:
|
||||
supplied_candidate = str(candidate_id or "")
|
||||
if supplied_candidate and supplied_candidate != record["candidate_id"]:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_CANDIDATE_MISMATCH")
|
||||
result = {
|
||||
return {
|
||||
"mode": RECIPE_MODE,
|
||||
"task_id": record["task_id"],
|
||||
"candidate_id": record["candidate_id"],
|
||||
@@ -562,9 +562,6 @@ class ReleaseSourceAdmissionStore:
|
||||
"version": record["version"],
|
||||
"purpose": record["purpose"],
|
||||
}
|
||||
if version == "V2R2C5":
|
||||
result["tree_oid"] = record["tree_oid"]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -1,716 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Host-injected broker for short release approvals under standing authority.
|
||||
|
||||
The broker deliberately owns no business-authority ledger. Its readers must
|
||||
return verified current snapshots from the host's durable authority and
|
||||
release-lineage stores. It only derives, verifies, and persists short-lived
|
||||
``SignedReleasePlanApproval`` children for the exact local ``ReleasePlan``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from silent_release_controller import CANDIDATE_ID_RE, OID_RE, SHA256_RE, _parse_utc
|
||||
from silent_release_hardening import (
|
||||
ALLOWED_CAPABILITIES,
|
||||
CapabilityAuthorizationError,
|
||||
ReleasePlan,
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
_canonical_json,
|
||||
release_file_lock,
|
||||
)
|
||||
from task_plane.governance_control_kernel import (
|
||||
AuthorityDecisionContext,
|
||||
AuthorityLifecycleState,
|
||||
ExactScopeState,
|
||||
OwnerBoundaryReason,
|
||||
)
|
||||
|
||||
|
||||
_EFFECT_STATES = frozenset(
|
||||
{"KNOWN_NOT_APPLIED", "KNOWN_IN_PROGRESS", "APPLIED_VERIFIED", "UNKNOWN"}
|
||||
)
|
||||
_LINEAGE_KINDS = frozenset({"ROOT", "SUCCESSOR"})
|
||||
_BINDING_KEYS = frozenset(
|
||||
{
|
||||
"authorization_id",
|
||||
"generation",
|
||||
"authorization_sha256",
|
||||
"version",
|
||||
"repository",
|
||||
"production_scope",
|
||||
"authority_base_oid",
|
||||
"trust_root_sha256",
|
||||
"actor_id",
|
||||
"mission_id",
|
||||
"run_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StandingAuthorityError(CapabilityAuthorizationError):
|
||||
"""A current standing-authority or exact-scope check failed closed."""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _text(name: str, value: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{name} must be a non-empty canonical string")
|
||||
return value
|
||||
|
||||
|
||||
def _sha(name: str, value: str) -> str:
|
||||
value = _text(name, value)
|
||||
if not SHA256_RE.fullmatch(value):
|
||||
raise ValueError(f"{name} must be a SHA-256 digest")
|
||||
return value
|
||||
|
||||
|
||||
def _oid(name: str, value: str) -> str:
|
||||
value = _text(name, value)
|
||||
if not OID_RE.fullmatch(value):
|
||||
raise ValueError(f"{name} must be a Git object id")
|
||||
return value
|
||||
|
||||
|
||||
def _tuple_of_text(name: str, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if not isinstance(value, tuple) or not value:
|
||||
raise ValueError(f"{name} must be a non-empty tuple")
|
||||
normalized = tuple(_text(name, item) for item in value)
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise ValueError(f"{name} contains duplicates")
|
||||
return normalized
|
||||
|
||||
|
||||
def _utc(value: datetime) -> datetime:
|
||||
if not isinstance(value, datetime) or value.tzinfo is None:
|
||||
raise ValueError("clock must return a timezone-aware datetime")
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _format_utc(value: datetime) -> str:
|
||||
return _utc(value).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanonicalPlanScope:
|
||||
version: str
|
||||
repository: str
|
||||
service: str
|
||||
release_root: str
|
||||
cutover_link: str
|
||||
authoritative_git_dir: str
|
||||
dropin_relative_path: str
|
||||
rollback_target: str
|
||||
required_capabilities: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"version",
|
||||
"repository",
|
||||
"service",
|
||||
"release_root",
|
||||
"cutover_link",
|
||||
"authoritative_git_dir",
|
||||
"dropin_relative_path",
|
||||
"rollback_target",
|
||||
):
|
||||
_text(name, getattr(self, name))
|
||||
capabilities = _tuple_of_text(
|
||||
"required_capabilities", self.required_capabilities
|
||||
)
|
||||
if tuple(sorted(capabilities)) != capabilities:
|
||||
raise ValueError("required_capabilities must be sorted")
|
||||
object.__setattr__(self, "required_capabilities", capabilities)
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"cutover_link": self.cutover_link,
|
||||
"authoritative_git_dir": self.authoritative_git_dir,
|
||||
"dropin_relative_path": self.dropin_relative_path,
|
||||
"release_root": self.release_root,
|
||||
"repository": self.repository,
|
||||
"required_capabilities": list(self.required_capabilities),
|
||||
"rollback_target": self.rollback_target,
|
||||
"service": self.service,
|
||||
"version": self.version,
|
||||
}
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
return hashlib.sha256(_canonical_json(self.canonical_payload)).hexdigest()
|
||||
|
||||
|
||||
def canonical_plan_scope(
|
||||
plan: ReleasePlan, required_capabilities: tuple[str, ...]
|
||||
) -> CanonicalPlanScope:
|
||||
"""Derive production scope only from a validated local ReleasePlan."""
|
||||
|
||||
plan.validate()
|
||||
release_path = PurePosixPath(plan.deployment.release_path)
|
||||
dropin_path = PurePosixPath(plan.deployment.dropin_path)
|
||||
try:
|
||||
dropin_relative = dropin_path.relative_to(release_path).as_posix()
|
||||
except ValueError as exc:
|
||||
raise ValueError("drop-in path is outside the candidate release path") from exc
|
||||
if dropin_relative in {"", "."}:
|
||||
raise ValueError("drop-in path must be a child of the release path")
|
||||
return CanonicalPlanScope(
|
||||
version=plan.tag.version,
|
||||
repository=plan.deployment.repository_identity,
|
||||
service=plan.deployment.service,
|
||||
release_root=plan.deployment.release_root,
|
||||
cutover_link=plan.deployment.cutover_link,
|
||||
authoritative_git_dir=plan.deployment.authoritative_git_dir,
|
||||
dropin_relative_path=dropin_relative,
|
||||
rollback_target=plan.deployment.rollback_path,
|
||||
required_capabilities=tuple(sorted(required_capabilities)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StandingReleaseAuthorization:
|
||||
authorization_id: str
|
||||
generation: int
|
||||
authorization_sha256: str
|
||||
version: str
|
||||
repository: str
|
||||
production_scope: str
|
||||
authority_base_oid: str
|
||||
trust_root_sha256: str
|
||||
trust_domain: str
|
||||
lineage_root_candidate_fingerprint: str
|
||||
allowed_capabilities: tuple[str, ...]
|
||||
rollback_target: str
|
||||
owner_identity: str
|
||||
allowed_actor_ids: tuple[str, ...]
|
||||
mission_id: str
|
||||
run_id: str
|
||||
lifecycle_state: AuthorityLifecycleState
|
||||
acceptance_complete: bool
|
||||
acceptance_evidence_ref: str | None = None
|
||||
expires_at: str | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"authorization_id",
|
||||
"version",
|
||||
"repository",
|
||||
"trust_domain",
|
||||
"rollback_target",
|
||||
"owner_identity",
|
||||
"mission_id",
|
||||
"run_id",
|
||||
):
|
||||
_text(name, getattr(self, name))
|
||||
if (
|
||||
not isinstance(self.generation, int)
|
||||
or isinstance(self.generation, bool)
|
||||
or self.generation < 1
|
||||
):
|
||||
raise ValueError("generation must be a positive integer")
|
||||
_sha("authorization_sha256", self.authorization_sha256)
|
||||
_sha("production_scope", self.production_scope)
|
||||
_oid("authority_base_oid", self.authority_base_oid)
|
||||
_sha("trust_root_sha256", self.trust_root_sha256)
|
||||
_sha(
|
||||
"lineage_root_candidate_fingerprint",
|
||||
self.lineage_root_candidate_fingerprint,
|
||||
)
|
||||
capabilities = _tuple_of_text(
|
||||
"allowed_capabilities", self.allowed_capabilities
|
||||
)
|
||||
if not set(capabilities).issubset(ALLOWED_CAPABILITIES):
|
||||
raise ValueError("allowed_capabilities contains an unsupported capability")
|
||||
object.__setattr__(self, "allowed_capabilities", capabilities)
|
||||
object.__setattr__(
|
||||
self, "allowed_actor_ids", _tuple_of_text("allowed_actor_ids", self.allowed_actor_ids)
|
||||
)
|
||||
if not isinstance(self.lifecycle_state, AuthorityLifecycleState):
|
||||
raise ValueError("lifecycle_state must be AuthorityLifecycleState")
|
||||
if type(self.acceptance_complete) is not bool:
|
||||
raise ValueError("acceptance_complete must be a boolean")
|
||||
if self.acceptance_complete:
|
||||
_text("acceptance_evidence_ref", self.acceptance_evidence_ref or "")
|
||||
elif self.acceptance_evidence_ref is not None:
|
||||
raise ValueError("acceptance evidence is only valid for completed acceptance")
|
||||
if self.expires_at is not None:
|
||||
_parse_utc(self.expires_at)
|
||||
if not isinstance(self.evidence_refs, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for value in self.evidence_refs:
|
||||
_text("evidence_ref", value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseExecutionScope:
|
||||
release_plan_id: str
|
||||
candidate_id: str
|
||||
candidate_fingerprint: str
|
||||
source_commit: str
|
||||
tree_oid: str
|
||||
artifact_sha256: str
|
||||
manifest_sha256: str
|
||||
deployment_plan_sha256: str
|
||||
release_plan_sha256: str
|
||||
approval_plan_id: str
|
||||
evidence_closure_oid: str
|
||||
owner_approval_record_oid: str
|
||||
authority_base_oid: str
|
||||
trust_root_sha256: str
|
||||
lineage_root_candidate_fingerprint: str
|
||||
actor_id: str
|
||||
mission_id: str
|
||||
run_id: str
|
||||
effect_state: str
|
||||
lineage_kind: str
|
||||
predecessor_candidate_fingerprint: str | None = None
|
||||
successor_lineage_sha256: str | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"release_plan_id",
|
||||
"candidate_id",
|
||||
"approval_plan_id",
|
||||
"actor_id",
|
||||
"mission_id",
|
||||
"run_id",
|
||||
):
|
||||
_text(name, getattr(self, name))
|
||||
if not CANDIDATE_ID_RE.fullmatch(self.candidate_id):
|
||||
raise ValueError("candidate_id is invalid")
|
||||
for name in ("source_commit", "tree_oid", "evidence_closure_oid", "owner_approval_record_oid", "authority_base_oid"):
|
||||
_oid(name, getattr(self, name))
|
||||
for name in (
|
||||
"candidate_fingerprint",
|
||||
"artifact_sha256",
|
||||
"manifest_sha256",
|
||||
"deployment_plan_sha256",
|
||||
"release_plan_sha256",
|
||||
"trust_root_sha256",
|
||||
"lineage_root_candidate_fingerprint",
|
||||
):
|
||||
_sha(name, getattr(self, name))
|
||||
if self.effect_state not in _EFFECT_STATES:
|
||||
raise ValueError("effect_state is invalid")
|
||||
if self.lineage_kind not in _LINEAGE_KINDS:
|
||||
raise ValueError("lineage_kind is invalid")
|
||||
if self.lineage_kind == "ROOT":
|
||||
if self.predecessor_candidate_fingerprint is not None or self.successor_lineage_sha256 is not None:
|
||||
raise ValueError("root candidate cannot carry successor lineage")
|
||||
else:
|
||||
_sha("predecessor_candidate_fingerprint", self.predecessor_candidate_fingerprint or "")
|
||||
_sha("successor_lineage_sha256", self.successor_lineage_sha256 or "")
|
||||
if self.predecessor_candidate_fingerprint == self.candidate_fingerprint:
|
||||
raise ValueError("successor predecessor must be a different candidate")
|
||||
if not isinstance(self.evidence_refs, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for value in self.evidence_refs:
|
||||
_text("evidence_ref", value)
|
||||
|
||||
|
||||
class RegisteredApprovalSigner(Protocol):
|
||||
@property
|
||||
def key_id(self) -> str: ...
|
||||
|
||||
def sign(
|
||||
self, unsigned: SignedReleasePlanApproval
|
||||
) -> SignedReleasePlanApproval | Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class StandingReleaseAuthorizationBroker:
|
||||
"""Issue and renew exact short approvals from two trusted read layers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: SilentReleaseActionService,
|
||||
authority_reader: Callable[[], StandingReleaseAuthorization],
|
||||
execution_scope_reader: Callable[[Mapping[str, Any]], ReleaseExecutionScope],
|
||||
signer: RegisteredApprovalSigner,
|
||||
*,
|
||||
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||
execution_ttl_seconds: int = 1200,
|
||||
renewal_margin_seconds: int = 120,
|
||||
):
|
||||
if not callable(authority_reader) or not callable(execution_scope_reader):
|
||||
raise TypeError("standing authority readers must be callable")
|
||||
if not callable(getattr(signer, "sign", None)):
|
||||
raise TypeError("registered approval signer must provide sign()")
|
||||
if not callable(clock):
|
||||
raise TypeError("clock must be callable")
|
||||
if not isinstance(service, SilentReleaseActionService):
|
||||
raise TypeError("service must be SilentReleaseActionService")
|
||||
if service.execution_enabled and getattr(signer, "test_only", False) is True:
|
||||
raise CapabilityAuthorizationError(
|
||||
"test-only approval signer is forbidden for production execution"
|
||||
)
|
||||
if not isinstance(execution_ttl_seconds, int) or isinstance(execution_ttl_seconds, bool) or not 1 <= execution_ttl_seconds <= 3600:
|
||||
raise ValueError("execution_ttl_seconds must be between 1 and 3600")
|
||||
if not isinstance(renewal_margin_seconds, int) or isinstance(renewal_margin_seconds, bool) or not 0 <= renewal_margin_seconds < execution_ttl_seconds:
|
||||
raise ValueError("renewal_margin_seconds must be shorter than the execution TTL")
|
||||
self.service = service
|
||||
self.authority_reader = authority_reader
|
||||
self.execution_scope_reader = execution_scope_reader
|
||||
self.signer = signer
|
||||
self.clock = clock
|
||||
self.execution_ttl_seconds = execution_ttl_seconds
|
||||
self.renewal_margin_seconds = renewal_margin_seconds
|
||||
self._lock_root = Path(service.approvals_root) / ".standing-authority-locks"
|
||||
|
||||
@staticmethod
|
||||
def _result(status: str, context: AuthorityDecisionContext, approval=None):
|
||||
# silent_release_execution imports this module during host composition.
|
||||
from silent_release_execution import StandingAuthorizationResult
|
||||
|
||||
return StandingAuthorizationResult(
|
||||
status=status, authority_context=context, approval=approval
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _context(
|
||||
authorization: StandingReleaseAuthorization | None,
|
||||
scope_state: ExactScopeState,
|
||||
*,
|
||||
lifecycle_state: AuthorityLifecycleState | None = None,
|
||||
boundary: OwnerBoundaryReason | None = None,
|
||||
) -> AuthorityDecisionContext:
|
||||
refs = () if authorization is None else authorization.evidence_refs
|
||||
return AuthorityDecisionContext(
|
||||
lifecycle_state=lifecycle_state
|
||||
or (authorization.lifecycle_state if authorization else AuthorityLifecycleState.UNKNOWN),
|
||||
scope_state=scope_state,
|
||||
owner_boundary_reason=boundary,
|
||||
evidence_refs=refs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _binding(
|
||||
authorization: StandingReleaseAuthorization, scope: ReleaseExecutionScope
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"authorization_id": authorization.authorization_id,
|
||||
"generation": authorization.generation,
|
||||
"authorization_sha256": authorization.authorization_sha256,
|
||||
"version": authorization.version,
|
||||
"repository": authorization.repository,
|
||||
"production_scope": authorization.production_scope,
|
||||
"authority_base_oid": authorization.authority_base_oid,
|
||||
"trust_root_sha256": authorization.trust_root_sha256,
|
||||
"actor_id": scope.actor_id,
|
||||
"mission_id": authorization.mission_id,
|
||||
"run_id": authorization.run_id,
|
||||
}
|
||||
|
||||
def _read(self, envelope: Mapping[str, Any]):
|
||||
authorization = self.authority_reader()
|
||||
# The trusted scope reader receives only the locally checked release
|
||||
# identity. Caller-added envelope keys cannot become authority input.
|
||||
query_keys = (
|
||||
"approval_plan_id",
|
||||
"artifact_sha256",
|
||||
"candidate_fingerprint",
|
||||
"candidate_id",
|
||||
"deployment_plan_sha256",
|
||||
"evidence_closure_oid",
|
||||
"manifest_sha256",
|
||||
"policy_sha256",
|
||||
"release_plan_sha256",
|
||||
"required_capabilities",
|
||||
"schema",
|
||||
"source_commit",
|
||||
"tree_oid",
|
||||
)
|
||||
scope = self.execution_scope_reader(
|
||||
{name: envelope[name] for name in query_keys if name in envelope}
|
||||
)
|
||||
if not isinstance(authorization, StandingReleaseAuthorization):
|
||||
raise StandingAuthorityError("AUTHORITY_SNAPSHOT_INVALID", "authority reader returned an invalid snapshot")
|
||||
if not isinstance(scope, ReleaseExecutionScope):
|
||||
raise StandingAuthorityError("RELEASE_SCOPE_INVALID", "scope reader returned an invalid snapshot")
|
||||
return authorization, scope
|
||||
|
||||
def _load_plan_truth(self, envelope: Mapping[str, Any]):
|
||||
candidate_id = str(envelope.get("candidate_id") or "")
|
||||
if not CANDIDATE_ID_RE.fullmatch(candidate_id):
|
||||
raise StandingAuthorityError("APPROVAL_ENVELOPE_INVALID", "candidate id is invalid")
|
||||
candidate = self.service.candidates.load(candidate_id)
|
||||
plan = self.service.release_plans.load(candidate_id)
|
||||
required = tuple(sorted(str(item) for item in envelope.get("required_capabilities", ())))
|
||||
if not required or len(required) != len(set(required)) or not set(required).issubset(ALLOWED_CAPABILITIES):
|
||||
raise StandingAuthorityError("APPROVAL_ENVELOPE_INVALID", "required capabilities are invalid")
|
||||
plan_scope = canonical_plan_scope(plan, required)
|
||||
expected_envelope = {
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"candidate_fingerprint": candidate.fingerprint,
|
||||
"source_commit": candidate.source_commit,
|
||||
"tree_oid": candidate.tree_oid,
|
||||
"artifact_sha256": candidate.artifact_sha256,
|
||||
"manifest_sha256": candidate.manifest_sha256,
|
||||
"deployment_plan_sha256": plan.deployment.sha256,
|
||||
"release_plan_sha256": plan.sha256,
|
||||
"approval_plan_id": plan.tag.approval_plan_id,
|
||||
"evidence_closure_oid": plan.tag.evidence_closure_oid,
|
||||
"policy_sha256": self.service.policy_sha256,
|
||||
}
|
||||
for name, expected in expected_envelope.items():
|
||||
if envelope.get(name) != expected:
|
||||
raise StandingAuthorityError("APPROVAL_ENVELOPE_STALE", f"approval envelope {name} is stale")
|
||||
return candidate, plan, plan_scope, required
|
||||
|
||||
def _assert_current(
|
||||
self,
|
||||
authorization: StandingReleaseAuthorization,
|
||||
scope: ReleaseExecutionScope,
|
||||
envelope: Mapping[str, Any],
|
||||
plan_scope: CanonicalPlanScope,
|
||||
required: tuple[str, ...],
|
||||
now: datetime,
|
||||
) -> None:
|
||||
if authorization.acceptance_complete:
|
||||
raise StandingAuthorityError("AUTHORITY_ACCEPTED_AND_CLOSED", "trusted release acceptance is complete")
|
||||
if authorization.lifecycle_state is not AuthorityLifecycleState.ACTIVE:
|
||||
raise StandingAuthorityError(f"AUTHORITY_{authorization.lifecycle_state.value}", "standing authorization is not active")
|
||||
if authorization.expires_at is not None and _parse_utc(authorization.expires_at) <= now:
|
||||
raise StandingAuthorityError("AUTHORITY_EXPIRED", "standing authorization has expired")
|
||||
if authorization.version != plan_scope.version or authorization.repository != plan_scope.repository:
|
||||
raise StandingAuthorityError("AUTHORITY_RELEASE_SCOPE_MISMATCH", "release identity is outside standing authority")
|
||||
if authorization.production_scope != plan_scope.sha256 or authorization.rollback_target != plan_scope.rollback_target:
|
||||
raise StandingAuthorityError("AUTHORITY_PRODUCTION_SCOPE_MISMATCH", "production or rollback scope drifted")
|
||||
if not set(required).issubset(authorization.allowed_capabilities):
|
||||
raise StandingAuthorityError("AUTHORITY_CAPABILITY_DENIED", "required capability is outside standing authority")
|
||||
exact_scope = (
|
||||
scope.candidate_id == envelope["candidate_id"]
|
||||
and scope.candidate_fingerprint == envelope["candidate_fingerprint"]
|
||||
and scope.source_commit == envelope["source_commit"]
|
||||
and scope.tree_oid == envelope["tree_oid"]
|
||||
and scope.artifact_sha256 == envelope["artifact_sha256"]
|
||||
and scope.manifest_sha256 == envelope["manifest_sha256"]
|
||||
and scope.deployment_plan_sha256 == envelope["deployment_plan_sha256"]
|
||||
and scope.release_plan_sha256 == envelope["release_plan_sha256"]
|
||||
and scope.approval_plan_id == envelope["approval_plan_id"]
|
||||
and scope.evidence_closure_oid == envelope["evidence_closure_oid"]
|
||||
and scope.authority_base_oid == authorization.authority_base_oid
|
||||
and scope.trust_root_sha256 == authorization.trust_root_sha256
|
||||
and scope.actor_id in authorization.allowed_actor_ids
|
||||
and scope.mission_id == authorization.mission_id
|
||||
and scope.run_id == authorization.run_id
|
||||
)
|
||||
if not exact_scope:
|
||||
raise StandingAuthorityError("AUTHORITY_EXACT_SCOPE_MISMATCH", "trusted release execution scope does not match")
|
||||
durable_release_plan_id = self.service.sagas.load(
|
||||
scope.candidate_id
|
||||
).release_plan_id
|
||||
if scope.release_plan_id != durable_release_plan_id:
|
||||
raise StandingAuthorityError(
|
||||
"RELEASE_PLAN_ID_MISMATCH",
|
||||
"trusted scope does not match the durable Saga release plan",
|
||||
)
|
||||
if scope.lineage_root_candidate_fingerprint != authorization.lineage_root_candidate_fingerprint:
|
||||
raise StandingAuthorityError("AUTHORITY_LINEAGE_ROOT_MISMATCH", "candidate lineage is not rooted in standing authority")
|
||||
if scope.lineage_kind == "ROOT" and scope.candidate_fingerprint != authorization.lineage_root_candidate_fingerprint:
|
||||
raise StandingAuthorityError("AUTHORITY_LINEAGE_REQUIRED", "changed candidate requires verified successor lineage")
|
||||
if scope.effect_state == "UNKNOWN":
|
||||
raise StandingAuthorityError("RELEASE_EFFECT_UNKNOWN", "release effect must be reconciled before issuance")
|
||||
|
||||
def _unsigned(
|
||||
self,
|
||||
authorization: StandingReleaseAuthorization,
|
||||
scope: ReleaseExecutionScope,
|
||||
plan: ReleasePlan,
|
||||
required: tuple[str, ...],
|
||||
now: datetime,
|
||||
) -> SignedReleasePlanApproval:
|
||||
expires = now + timedelta(seconds=self.execution_ttl_seconds)
|
||||
if authorization.expires_at is not None:
|
||||
expires = min(expires, _parse_utc(authorization.expires_at))
|
||||
if expires <= now:
|
||||
raise StandingAuthorityError("AUTHORITY_EXPIRED", "standing authorization cannot cover a new child")
|
||||
issued_at = _format_utc(now)
|
||||
return SignedReleasePlanApproval(
|
||||
release_plan_id=scope.release_plan_id,
|
||||
candidate_id=scope.candidate_id,
|
||||
source_commit=scope.source_commit,
|
||||
tree_oid=scope.tree_oid,
|
||||
artifact_sha256=scope.artifact_sha256,
|
||||
manifest_sha256=scope.manifest_sha256,
|
||||
deployment_plan_sha256=plan.deployment.sha256,
|
||||
policy_sha256=self.service.policy_sha256,
|
||||
approval_plan_id=scope.approval_plan_id,
|
||||
evidence_closure_oid=scope.evidence_closure_oid,
|
||||
owner_approval_record_oid=scope.owner_approval_record_oid,
|
||||
capabilities=required,
|
||||
expires_at=_format_utc(expires),
|
||||
nonce="standing-" + secrets.token_hex(16),
|
||||
key_id=_text("signer.key_id", self.signer.key_id),
|
||||
signature="",
|
||||
owner_identity=authorization.owner_identity,
|
||||
trust_domain=authorization.trust_domain,
|
||||
issued_at=issued_at,
|
||||
not_before=issued_at,
|
||||
release_plan_sha256=plan.sha256,
|
||||
authorization_binding=self._binding(authorization, scope),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _signed(value: SignedReleasePlanApproval | Mapping[str, Any]) -> SignedReleasePlanApproval:
|
||||
return value if isinstance(value, SignedReleasePlanApproval) else SignedReleasePlanApproval.from_dict(value)
|
||||
|
||||
def _verify_signed(self, approval, candidate, plan, now):
|
||||
verifier = self.service.approval_verifier
|
||||
if verifier is None:
|
||||
raise StandingAuthorityError("APPROVAL_VERIFIER_MISSING", "approval verifier is not configured")
|
||||
verifier.verify_release_plan(approval, candidate, plan, self.service.policy_sha256, now=now)
|
||||
|
||||
def __call__(self, envelope: Mapping[str, Any]):
|
||||
if not isinstance(envelope, Mapping):
|
||||
raise TypeError("approval envelope must be a mapping")
|
||||
candidate_id = str(envelope.get("candidate_id") or "")
|
||||
lock_name = hashlib.sha256(candidate_id.encode("utf-8")).hexdigest() + ".lock"
|
||||
lock_path = self._lock_root / lock_name
|
||||
with release_file_lock(lock_path, blocking=True):
|
||||
try:
|
||||
candidate, plan, plan_scope, required = self._load_plan_truth(envelope)
|
||||
now = _utc(self.clock())
|
||||
authorization, scope = self._read(envelope)
|
||||
self._assert_current(authorization, scope, envelope, plan_scope, required, now)
|
||||
try:
|
||||
existing = self.service.approvals.load(candidate_id)
|
||||
except FileNotFoundError:
|
||||
existing = None
|
||||
if existing is not None:
|
||||
# Expiry is the one expected reason an otherwise valid
|
||||
# short child needs replacement. Verify its signature,
|
||||
# immutable evidence, key and revocation state at the end
|
||||
# of its signed window; a future/not-yet-valid or malformed
|
||||
# child still fails the verifier.
|
||||
# Expiry is exclusive. When renewing an already expired
|
||||
# child, verify its signature at the final instant inside
|
||||
# its original signed window rather than at expiry itself.
|
||||
old_expiry = _parse_utc(existing.expires_at)
|
||||
old_verification_time = min(
|
||||
now, old_expiry - timedelta(microseconds=1)
|
||||
)
|
||||
self._verify_signed(existing, candidate, plan, old_verification_time)
|
||||
self._check_binding(existing, authorization, scope, required)
|
||||
if _parse_utc(existing.expires_at) - now > timedelta(seconds=self.renewal_margin_seconds):
|
||||
return self._result("APPROVAL_ISSUED", self._context(authorization, ExactScopeState.IN_SCOPE), existing.to_dict())
|
||||
unsigned = self._unsigned(authorization, scope, plan, required, now)
|
||||
signed = self._signed(self.signer.sign(unsigned))
|
||||
# A signer may only add its signature to the complete request.
|
||||
if signed.canonical_payload != unsigned.canonical_payload:
|
||||
raise StandingAuthorityError("SIGNER_MUTATED_APPROVAL", "registered signer changed the approval request")
|
||||
self._verify_signed(signed, candidate, plan, now)
|
||||
after_authorization, after_scope = self._read(envelope)
|
||||
after_now = _utc(self.clock())
|
||||
self._assert_current(after_authorization, after_scope, envelope, plan_scope, required, after_now)
|
||||
if after_authorization != authorization or after_scope != scope:
|
||||
raise StandingAuthorityError("AUTHORITY_CHANGED_DURING_SIGN", "authority changed while the child was signed")
|
||||
self._verify_signed(signed, candidate, plan, after_now)
|
||||
if existing is None:
|
||||
self.service.submit_owner_approval(signed.to_dict())
|
||||
else:
|
||||
self.service.renew_owner_approval(candidate_id, signed.to_dict(), expected_fingerprint=existing.fingerprint)
|
||||
persisted = self.service.approvals.load(candidate_id)
|
||||
if persisted.fingerprint != signed.fingerprint:
|
||||
raise StandingAuthorityError("APPROVAL_PERSISTENCE_MISMATCH", "persisted approval differs from signed approval")
|
||||
return self._result("APPROVAL_ISSUED", self._context(authorization, ExactScopeState.IN_SCOPE), persisted.to_dict())
|
||||
except StandingAuthorityError as exc:
|
||||
authorization = locals().get("authorization")
|
||||
closed_states = {
|
||||
"AUTHORITY_ACCEPTED_AND_CLOSED": AuthorityLifecycleState.CLOSED,
|
||||
"AUTHORITY_EXPIRED": AuthorityLifecycleState.CLOSED,
|
||||
"AUTHORITY_REVOKED": AuthorityLifecycleState.REVOKED,
|
||||
"AUTHORITY_CLOSED": AuthorityLifecycleState.CLOSED,
|
||||
"AUTHORITY_CANCELLED": AuthorityLifecycleState.CANCELLED,
|
||||
}
|
||||
if exc.code in closed_states:
|
||||
context = self._context(
|
||||
authorization,
|
||||
ExactScopeState.IN_SCOPE,
|
||||
lifecycle_state=closed_states[exc.code],
|
||||
)
|
||||
return self._result("AUTHORIZATION_DENIED", context)
|
||||
if exc.code in {
|
||||
"AUTHORITY_UNKNOWN",
|
||||
"AUTHORITY_SNAPSHOT_INVALID",
|
||||
"AUTHORITY_CHANGED_DURING_SIGN",
|
||||
}:
|
||||
return self._result(
|
||||
"WAITING_AUTHORITY",
|
||||
self._context(
|
||||
authorization,
|
||||
ExactScopeState.UNKNOWN,
|
||||
lifecycle_state=AuthorityLifecycleState.UNKNOWN,
|
||||
),
|
||||
)
|
||||
if exc.code.startswith("AUTHORITY_") and exc.code not in {"AUTHORITY_SNAPSHOT_INVALID", "AUTHORITY_CHANGED_DURING_SIGN"}:
|
||||
goal_change = {
|
||||
"AUTHORITY_RELEASE_SCOPE_MISMATCH",
|
||||
"AUTHORITY_PRODUCTION_SCOPE_MISMATCH",
|
||||
"AUTHORITY_CAPABILITY_DENIED",
|
||||
"AUTHORITY_LINEAGE_REQUIRED",
|
||||
}
|
||||
context = self._context(
|
||||
authorization,
|
||||
ExactScopeState.OUT_OF_SCOPE,
|
||||
boundary=(
|
||||
OwnerBoundaryReason.GOAL_CHANGE
|
||||
if exc.code in goal_change
|
||||
else OwnerBoundaryReason.TRUST_BOUNDARY_CHANGE
|
||||
),
|
||||
)
|
||||
return self._result("AUTHORIZATION_DENIED", context)
|
||||
context = self._context(authorization, ExactScopeState.UNKNOWN)
|
||||
return self._result("WAITING_AUTHORITY", context)
|
||||
except Exception:
|
||||
return self._result("WAITING_AUTHORITY", self._context(None, ExactScopeState.UNKNOWN))
|
||||
|
||||
def _check_binding(self, approval, authorization, scope, required=None):
|
||||
binding = getattr(approval, "authorization_binding", None)
|
||||
if not isinstance(binding, Mapping) or set(binding) != _BINDING_KEYS:
|
||||
raise StandingAuthorityError("AUTHORIZATION_BINDING_INVALID", "approval lacks an exact standing authorization binding")
|
||||
if dict(binding) != self._binding(authorization, scope):
|
||||
raise StandingAuthorityError("AUTHORIZATION_BINDING_STALE", "approval standing authorization binding is stale")
|
||||
if approval.owner_identity != authorization.owner_identity or approval.trust_domain != authorization.trust_domain:
|
||||
raise StandingAuthorityError("AUTHORIZATION_IDENTITY_MISMATCH", "approval owner or trust binding is stale")
|
||||
if (
|
||||
approval.release_plan_id != scope.release_plan_id
|
||||
or approval.evidence_closure_oid != scope.evidence_closure_oid
|
||||
or approval.owner_approval_record_oid != scope.owner_approval_record_oid
|
||||
):
|
||||
raise StandingAuthorityError(
|
||||
"AUTHORIZATION_OPERATION_BINDING_MISMATCH",
|
||||
"approval operation identity is stale",
|
||||
)
|
||||
if required is not None and tuple(sorted(approval.capabilities)) != tuple(required):
|
||||
raise StandingAuthorityError(
|
||||
"AUTHORIZATION_CAPABILITY_BINDING_MISMATCH",
|
||||
"approval capability set does not match the exact plan scope",
|
||||
)
|
||||
|
||||
def check_current(self, approval: SignedReleasePlanApproval, capability: str, operation: str) -> None:
|
||||
if not isinstance(approval, SignedReleasePlanApproval):
|
||||
raise StandingAuthorityError("APPROVAL_INVALID", "current authority guard requires a signed approval")
|
||||
_text("operation", operation)
|
||||
if capability not in approval.capabilities:
|
||||
raise StandingAuthorityError("CAPABILITY_NOT_GRANTED", "approval does not grant the requested capability")
|
||||
envelope = self.service.get_approval_envelope(approval.candidate_id)
|
||||
candidate, plan, plan_scope, required = self._load_plan_truth(envelope)
|
||||
now = _utc(self.clock())
|
||||
authorization, scope = self._read(envelope)
|
||||
self._assert_current(authorization, scope, envelope, plan_scope, required, now)
|
||||
self._check_binding(approval, authorization, scope, required)
|
||||
self._verify_signed(approval, candidate, plan, now)
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Distinct filename/path contracts and no-follow writes at the filesystem edge."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import ipaddress
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
|
||||
|
||||
def require_isolated_dev_profile(host: str) -> None:
|
||||
if os.getenv("AIOS_GITOPS_DEV_ALLOW", "0") != "1":
|
||||
return
|
||||
try:
|
||||
loopback = ipaddress.ip_address(host).is_loopback
|
||||
except (TypeError, ValueError):
|
||||
loopback = False
|
||||
if os.getenv("AIOS_RUNTIME_PROFILE") != "isolated-staging" or not loopback:
|
||||
raise RuntimeError("DEV_ALLOW requires isolated-staging and loopback")
|
||||
|
||||
|
||||
def safe_segment(value: str, *, max_length: int = 160) -> str:
|
||||
if (not isinstance(value, str) or not value or len(value) > max_length
|
||||
or value in (".", "..") or value.endswith((".", " "))
|
||||
or re.fullmatch(r"[A-Za-z0-9_@.-]+", value) is None
|
||||
or value.split(".")[0].upper() in {"CON", "PRN", "AUX", "NUL", *[f"COM{i}" for i in range(1, 10)], *[f"LPT{i}" for i in range(1, 10)]}):
|
||||
raise ValueError("invalid single path segment")
|
||||
return value
|
||||
|
||||
|
||||
def relative_parts(value: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, str) or "\\" in value or ":" in value or value.startswith("/"):
|
||||
raise ValueError("invalid relative path")
|
||||
parts = tuple(value.split("/"))
|
||||
for part in parts:
|
||||
safe_segment(part, max_length=255)
|
||||
if parts[0] == ".git":
|
||||
raise ValueError("Git metadata path forbidden")
|
||||
return parts
|
||||
|
||||
|
||||
def contained_path(root: Path, segment: str) -> Path:
|
||||
safe_segment(segment)
|
||||
root = Path(root)
|
||||
if root.is_symlink():
|
||||
raise ValueError("symlink root forbidden")
|
||||
path = root / segment
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
|
||||
raise ValueError("path escapes root")
|
||||
return path
|
||||
|
||||
|
||||
def write_text_no_follow(root: Path, relative: str, content: str) -> None:
|
||||
"""Atomic POSIX write using directory descriptors; no lexical-only fallback."""
|
||||
parts = relative_parts(relative)
|
||||
if os.name != "posix" or not hasattr(os, "O_NOFOLLOW"):
|
||||
raise OSError("secure directory-relative writes require POSIX no-follow support")
|
||||
root = Path(root)
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
current = os.open(root, flags)
|
||||
temporary = ".c6-" + secrets.token_hex(16)
|
||||
try:
|
||||
for part in parts[:-1]:
|
||||
try:
|
||||
os.mkdir(part, mode=0o750, dir_fd=current)
|
||||
except FileExistsError:
|
||||
pass
|
||||
child = os.open(part, flags, dir_fd=current)
|
||||
os.close(current)
|
||||
current = child
|
||||
try:
|
||||
info = os.stat(parts[-1], dir_fd=current, follow_symlinks=False)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
||||
raise ValueError("nonregular or multiply-linked target forbidden")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600, dir_fd=current)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(content); stream.flush(); os.fsync(stream.fileno())
|
||||
os.replace(temporary, parts[-1], src_dir_fd=current, dst_dir_fd=current)
|
||||
os.fsync(current)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=current)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
finally:
|
||||
os.close(current)
|
||||
@@ -22,16 +22,6 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
)
|
||||
|
||||
from silent_release_controller import _sanitize, utc_now
|
||||
from task_plane.governance_control_kernel import (
|
||||
AutonomousDecisionEngine,
|
||||
AuthorityDecisionContext,
|
||||
AuthorityLifecycleState,
|
||||
DecisionInput,
|
||||
DecisionEffectIntent,
|
||||
ExactScopeState,
|
||||
NextAction,
|
||||
OwnerBoundaryReason,
|
||||
)
|
||||
from silent_release_hardening import (
|
||||
BuilderAttestation,
|
||||
CapabilityAuthorizationError,
|
||||
@@ -43,60 +33,9 @@ from silent_release_hardening import (
|
||||
_canonical_json,
|
||||
_durable_json,
|
||||
_safe_child,
|
||||
release_file_lock,
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class StandingAuthorizationResult:
|
||||
"""Typed result from a current authority reader plus formal issuer.
|
||||
|
||||
This object is routing evidence, not an authorization token. A result that
|
||||
carries an approval still has to pass ``SignedApprovalVerifier`` inside the
|
||||
release service before the saga can advance.
|
||||
"""
|
||||
|
||||
status: str
|
||||
authority_context: AuthorityDecisionContext
|
||||
approval: Mapping[str, Any] | None = None
|
||||
effect_intent: DecisionEffectIntent = DecisionEffectIntent.SIDE_EFFECT
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
allowed = {
|
||||
"APPROVAL_ISSUED",
|
||||
"AUTHORIZATION_DENIED",
|
||||
"OWNER_REQUIRED",
|
||||
"WAITING_AUTHORITY",
|
||||
}
|
||||
if self.status not in allowed:
|
||||
raise ValueError("invalid standing authorization status")
|
||||
if not isinstance(self.authority_context, AuthorityDecisionContext):
|
||||
raise ValueError("authority_context must be AuthorityDecisionContext")
|
||||
if self.effect_intent is not DecisionEffectIntent.SIDE_EFFECT:
|
||||
raise ValueError("standing authorization must be a SIDE_EFFECT decision")
|
||||
if self.status == "APPROVAL_ISSUED":
|
||||
if not isinstance(self.approval, Mapping):
|
||||
raise ValueError("issued authorization requires a signed approval")
|
||||
if (
|
||||
self.authority_context.lifecycle_state
|
||||
is not AuthorityLifecycleState.ACTIVE
|
||||
or self.authority_context.scope_state is not ExactScopeState.IN_SCOPE
|
||||
or self.authority_context.owner_boundary_reason is not None
|
||||
):
|
||||
raise ValueError("issued approval is inconsistent with authority context")
|
||||
elif self.approval is not None:
|
||||
raise ValueError("non-issued authorization cannot carry an approval")
|
||||
if self.status == "OWNER_REQUIRED" and (
|
||||
self.authority_context.owner_boundary_reason is None
|
||||
):
|
||||
raise ValueError("OWNER_REQUIRED needs an explicit boundary reason")
|
||||
|
||||
|
||||
StandingAuthorizationBroker = Callable[
|
||||
[Mapping[str, Any]], StandingAuthorizationResult | Mapping[str, Any]
|
||||
]
|
||||
|
||||
|
||||
class BuilderSigner(Protocol):
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
@@ -323,56 +262,38 @@ class LeaderLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
self.handle = None
|
||||
self._lock_context = None
|
||||
|
||||
def __enter__(self):
|
||||
self._lock_context = release_file_lock(self.path, blocking=False)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.path.parent, 0o700)
|
||||
self.handle = self.path.open("a+", encoding="utf-8")
|
||||
os.chmod(self.path, 0o600)
|
||||
try:
|
||||
self.handle = self._lock_context.__enter__()
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(
|
||||
self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB
|
||||
)
|
||||
except BlockingIOError as exc:
|
||||
self.handle.close()
|
||||
self.handle = None
|
||||
self._lock_context = None
|
||||
raise RuntimeError("silent release leader is already active") from exc
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if self.handle is not None:
|
||||
try:
|
||||
self._lock_context.__exit__(exc_type, exc, tb)
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
self.handle.close()
|
||||
self.handle = None
|
||||
self._lock_context = None
|
||||
|
||||
|
||||
class PersistentSilentReleaseController:
|
||||
"""Idempotent main-event controller with durable startup/recovery reports."""
|
||||
|
||||
_UNKNOWN_EFFECT_ACTIONS = frozenset(
|
||||
{
|
||||
"reconcile_baseline_registry",
|
||||
"reconcile_release",
|
||||
"reconcile_tag",
|
||||
"resume_rollback",
|
||||
"resume_runtime_switch",
|
||||
"verify_runtime",
|
||||
}
|
||||
)
|
||||
_RECOVERY_SIDE_EFFECT_STATES = frozenset(
|
||||
{
|
||||
"APPROVED",
|
||||
"PRE_SWITCH_REVALIDATING",
|
||||
"SWITCH_PENDING",
|
||||
"SWITCHED",
|
||||
"RUNTIME_VERIFYING",
|
||||
"RUNTIME_VERIFIED",
|
||||
"TAG_PENDING",
|
||||
"TAG_VERIFIED",
|
||||
"BASELINE_REGISTRY_PENDING",
|
||||
"ROLLBACK_PENDING",
|
||||
"RECONCILIATION_REQUIRED",
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
@@ -380,42 +301,14 @@ class PersistentSilentReleaseController:
|
||||
*,
|
||||
main_probe: Callable[[], str],
|
||||
recovery_worker: ReleaseRecoveryWorker | None = None,
|
||||
owner_approval_broker: StandingAuthorizationBroker | None = None,
|
||||
decision_engine: AutonomousDecisionEngine | None = None,
|
||||
):
|
||||
self.root = Path(root)
|
||||
self.service = service
|
||||
self.main_probe = main_probe
|
||||
self.recovery_worker = recovery_worker
|
||||
self.owner_approval_broker = owner_approval_broker
|
||||
self.decision_engine = decision_engine or AutonomousDecisionEngine()
|
||||
self.leader_path = self.root / "controller.leader.lock"
|
||||
self.status_path = self.root / "controller-status.json"
|
||||
|
||||
def run_once_safely(self) -> dict[str, Any]:
|
||||
"""Keep read-only diagnostics available when a control tick fails.
|
||||
|
||||
A dependency failure never grants write permission or repairs a corrupt
|
||||
journal. Only a later successful, fully checked tick may advance work.
|
||||
Exception text can contain credentials, so persist its type only.
|
||||
"""
|
||||
try:
|
||||
return self.run_once()
|
||||
except Exception as exc:
|
||||
result = {
|
||||
"error": type(exc).__name__,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
# A failed production tick may have reached an adapter before
|
||||
# losing its acknowledgement. Never assert zero effects here.
|
||||
"side_effects_executed": (
|
||||
None if self.service.execution_enabled else False
|
||||
),
|
||||
"status": "FAIL_CLOSED",
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
|
||||
def startup_verify(self) -> dict[str, Any]:
|
||||
checked = 0
|
||||
failed = 0
|
||||
@@ -436,196 +329,6 @@ class PersistentSilentReleaseController:
|
||||
_durable_json(self.status_path, status)
|
||||
return status
|
||||
|
||||
@classmethod
|
||||
def _unknown_effects_pending(cls, recovery: Mapping[str, Any]) -> bool:
|
||||
return any(
|
||||
isinstance(item, Mapping)
|
||||
and item.get("recovery_action") in cls._UNKNOWN_EFFECT_ACTIONS
|
||||
for item in recovery.get("sagas", ())
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _recovery_side_effect_candidates(
|
||||
cls, recovery: Mapping[str, Any]
|
||||
) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
str(item["candidate_id"])
|
||||
for item in recovery.get("sagas", ())
|
||||
if isinstance(item, Mapping)
|
||||
and item.get("state") in cls._RECOVERY_SIDE_EFFECT_STATES
|
||||
and item.get("candidate_id")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _authority_context_payload(
|
||||
context: AuthorityDecisionContext,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"lifecycle_state": context.lifecycle_state.value,
|
||||
"scope_state": context.scope_state.value,
|
||||
"owner_boundary_reason": (
|
||||
context.owner_boundary_reason.value
|
||||
if context.owner_boundary_reason is not None
|
||||
else None
|
||||
),
|
||||
"evidence_refs": list(context.evidence_refs),
|
||||
}
|
||||
|
||||
def _try_standing_authorization(self, candidate_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
existing = self.service.approvals.load(candidate_id)
|
||||
except FileNotFoundError:
|
||||
existing = None
|
||||
|
||||
# Default-off compatibility: an already accepted exact approval may be
|
||||
# revalidated, but absence of a current broker never derives new
|
||||
# authority. When a broker is configured it must be consulted on every
|
||||
# side-effect decision so a still-valid short approval cannot mask a
|
||||
# revoked/closed root authorization or an exact-scope change.
|
||||
if self.owner_approval_broker is None and existing is not None:
|
||||
try:
|
||||
accepted = self.service.submit_owner_approval(existing.to_dict())
|
||||
except Exception:
|
||||
accepted = None
|
||||
else:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"approval_fingerprint": accepted.get("fingerprint"),
|
||||
"status": "APPROVED",
|
||||
"idempotent": True,
|
||||
}
|
||||
|
||||
if self.owner_approval_broker is None:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": "OWNER_APPROVAL_BACKEND_MISSING",
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
|
||||
envelope = self.service.get_approval_envelope(candidate_id)
|
||||
try:
|
||||
issued = self.owner_approval_broker(dict(envelope))
|
||||
except Exception as exc:
|
||||
code = str(getattr(exc, "code", "") or type(exc).__name__)
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": code,
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
|
||||
authority_context = None
|
||||
decision = None
|
||||
if isinstance(issued, StandingAuthorizationResult):
|
||||
authority_context = issued.authority_context
|
||||
context_payload = self._authority_context_payload(authority_context)
|
||||
decision = self.decision_engine.decide(
|
||||
DecisionInput(
|
||||
goal_id=candidate_id,
|
||||
admission_ready=True,
|
||||
effect_intent=DecisionEffectIntent.SIDE_EFFECT,
|
||||
authority_context=authority_context,
|
||||
evidence_refs=authority_context.evidence_refs,
|
||||
)
|
||||
)
|
||||
decision_payload = decision.to_dict()
|
||||
if decision.next_valid_action is NextAction.DENY_EFFECT:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": decision.blocker_code
|
||||
or "STANDING_AUTHORIZATION_DENIED",
|
||||
"status": "AUTHORIZATION_DENIED",
|
||||
}
|
||||
if decision.next_valid_action is NextAction.OWNER_BOUNDARY:
|
||||
boundary_reason = decision.owner_boundary_reason
|
||||
if boundary_reason is None:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": "OWNER_BOUNDARY_REASON_MISSING",
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": boundary_reason.value,
|
||||
"status": "OWNER_REQUIRED",
|
||||
}
|
||||
if decision.next_valid_action is not NextAction.CREATE_OR_RESUME_NEXT_CHILD:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": decision.blocker_code
|
||||
or decision.next_valid_action.value,
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
if issued.status != "APPROVAL_ISSUED":
|
||||
status = (
|
||||
"AUTHORIZATION_DENIED"
|
||||
if issued.status == "AUTHORIZATION_DENIED"
|
||||
else "WAITING_AUTHORITY"
|
||||
)
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"authority_context": context_payload,
|
||||
"decision": decision_payload,
|
||||
"reason_code": issued.status,
|
||||
"status": status,
|
||||
}
|
||||
approval = dict(issued.approval or {})
|
||||
elif isinstance(issued, Mapping):
|
||||
# Backward-compatible C5 callback contract. Mapping coverage does
|
||||
# not grant authority; submit_owner_approval verifies the signed
|
||||
# object against the current exact candidate and ReleasePlan.
|
||||
approval = dict(issued)
|
||||
else:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": "OWNER_APPROVAL_MISSING",
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
|
||||
try:
|
||||
accepted = self.service.submit_owner_approval(approval)
|
||||
except Exception as exc:
|
||||
code = str(getattr(exc, "code", "") or type(exc).__name__)
|
||||
result = {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": code,
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
if authority_context is not None:
|
||||
result["authority_context"] = self._authority_context_payload(
|
||||
authority_context
|
||||
)
|
||||
if decision is not None:
|
||||
result["decision"] = decision.to_dict()
|
||||
return result
|
||||
if accepted.get("status") not in {"VERIFIED_AND_STORED", "IDEMPOTENT"}:
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"reason_code": "APPROVAL_NOT_VERIFIED",
|
||||
"status": "WAITING_AUTHORITY",
|
||||
}
|
||||
result = {
|
||||
"candidate_id": candidate_id,
|
||||
"approval_fingerprint": accepted.get("fingerprint"),
|
||||
"idempotent": accepted.get("status") == "IDEMPOTENT",
|
||||
"saga_state": accepted.get("saga_state"),
|
||||
"status": "APPROVED",
|
||||
}
|
||||
if authority_context is not None:
|
||||
result["authority_context"] = self._authority_context_payload(
|
||||
authority_context
|
||||
)
|
||||
if decision is not None:
|
||||
result["decision"] = decision.to_dict()
|
||||
return result
|
||||
|
||||
def run_once(self) -> dict[str, Any]:
|
||||
with LeaderLock(self.leader_path):
|
||||
startup = self.startup_verify()
|
||||
@@ -642,98 +345,26 @@ class PersistentSilentReleaseController:
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
|
||||
# ReleaseRecoveryWorker can resume an externally visible effect.
|
||||
# Re-resolve current root lifecycle and exact scope for every such
|
||||
# candidate before invoking it; executor/verifier checks remain the
|
||||
# final fence. This also prevents a cached short approval from
|
||||
# bypassing root revocation during restart recovery.
|
||||
recovery_status = self.service.get_recovery_status()
|
||||
unknown_effect = self._unknown_effects_pending(recovery_status)
|
||||
recovery_authority_decisions: list[dict[str, Any]] = []
|
||||
recovery_candidates = (
|
||||
self._recovery_side_effect_candidates(recovery_status)
|
||||
if self.recovery_worker is not None
|
||||
else ()
|
||||
)
|
||||
for candidate_id in recovery_candidates:
|
||||
authorization = self._try_standing_authorization(candidate_id)
|
||||
recovery_authority_decisions.append(authorization)
|
||||
if authorization.get("status") != "APPROVED":
|
||||
notification = None
|
||||
if (
|
||||
not unknown_effect
|
||||
and authorization.get("status") == "OWNER_REQUIRED"
|
||||
):
|
||||
notification = self.service.ensure_owner_notification(
|
||||
candidate_id,
|
||||
reason_code=str(authorization["reason_code"]),
|
||||
)
|
||||
result = {
|
||||
"authority_decision": authorization,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"intent": None,
|
||||
"notification": notification,
|
||||
"recovery": recovery_status,
|
||||
"recovery_authority_decisions": (
|
||||
recovery_authority_decisions
|
||||
),
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
"startup": startup,
|
||||
"status": (
|
||||
"RECONCILIATION_REQUIRED"
|
||||
if unknown_effect
|
||||
else authorization.get("status")
|
||||
),
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
recovery = (
|
||||
self.recovery_worker.run_once()
|
||||
if self.recovery_worker is not None
|
||||
else recovery_status
|
||||
)
|
||||
recovery_status = self.service.get_recovery_status()
|
||||
if self._unknown_effects_pending(recovery_status):
|
||||
result = {
|
||||
"authority_decision": None,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"intent": None,
|
||||
"notification": None,
|
||||
"recovery": recovery,
|
||||
"recovery_authority_decisions": (
|
||||
recovery_authority_decisions
|
||||
),
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
"startup": startup,
|
||||
"status": "RECONCILIATION_REQUIRED",
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
source_commit = str(self.main_probe())
|
||||
event_id = f"main-{source_commit}"
|
||||
intent = self.service.create_release_intent(
|
||||
{"event_id": event_id, "source_commit": source_commit}
|
||||
)
|
||||
notification = None
|
||||
authorization = None
|
||||
if intent.get("candidate_id"):
|
||||
candidate_id = str(intent["candidate_id"])
|
||||
authorization = self._try_standing_authorization(candidate_id)
|
||||
if authorization.get("status") == "OWNER_REQUIRED":
|
||||
notification = self.service.ensure_owner_notification(
|
||||
candidate_id,
|
||||
reason_code=str(authorization["reason_code"]),
|
||||
)
|
||||
notification = self.service.ensure_owner_notification(
|
||||
str(intent["candidate_id"])
|
||||
)
|
||||
recovery = (
|
||||
self.recovery_worker.run_once()
|
||||
if self.recovery_worker is not None
|
||||
else self.service.get_recovery_status()
|
||||
)
|
||||
result = {
|
||||
"authority_decision": authorization,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"intent": intent,
|
||||
"notification": notification,
|
||||
"recovery": recovery,
|
||||
"recovery_authority_decisions": recovery_authority_decisions,
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
"startup": startup,
|
||||
@@ -742,51 +373,6 @@ class PersistentSilentReleaseController:
|
||||
return _sanitize(result)
|
||||
|
||||
|
||||
def compose_standing_release_controller(
|
||||
root: Path,
|
||||
service: SilentReleaseActionService,
|
||||
*,
|
||||
main_probe: Callable[[], str],
|
||||
authority_reader: Callable[[], Any],
|
||||
execution_scope_reader: Callable[[Mapping[str, Any]], Any],
|
||||
signer: Any,
|
||||
recovery_worker: ReleaseRecoveryWorker | None = None,
|
||||
execution_ttl_seconds: int = 1200,
|
||||
renewal_margin_seconds: int = 120,
|
||||
clock: Callable[[], Any] | None = None,
|
||||
) -> PersistentSilentReleaseController:
|
||||
"""Compose trusted host adapters into the existing controller and effects.
|
||||
|
||||
Readers must return the validated snapshot types defined by
|
||||
``release_standing_authority``. The signer must already belong to the
|
||||
service's release approval trust domain. This function neither registers
|
||||
keys nor performs network/authority reads during cold startup.
|
||||
"""
|
||||
from release_standing_authority import StandingReleaseAuthorizationBroker
|
||||
|
||||
options = {
|
||||
"execution_ttl_seconds": execution_ttl_seconds,
|
||||
"renewal_margin_seconds": renewal_margin_seconds,
|
||||
}
|
||||
if clock is not None:
|
||||
options["clock"] = clock
|
||||
broker = StandingReleaseAuthorizationBroker(
|
||||
service,
|
||||
authority_reader,
|
||||
execution_scope_reader,
|
||||
signer,
|
||||
**options,
|
||||
)
|
||||
service.install_current_authority_guard(broker.check_current)
|
||||
return PersistentSilentReleaseController(
|
||||
root,
|
||||
service,
|
||||
main_probe=main_probe,
|
||||
recovery_worker=recovery_worker,
|
||||
owner_approval_broker=broker,
|
||||
)
|
||||
|
||||
|
||||
class ShadowActionHandler(BaseHTTPRequestHandler):
|
||||
service: SilentReleaseActionService
|
||||
controller: PersistentSilentReleaseController
|
||||
@@ -899,12 +485,24 @@ def serve_shadow(
|
||||
service,
|
||||
main_probe=git_main_probe(repository),
|
||||
)
|
||||
controller.run_once_safely()
|
||||
controller.run_once()
|
||||
stop = threading.Event()
|
||||
|
||||
def controller_loop() -> None:
|
||||
while not stop.wait(poll_seconds):
|
||||
controller.run_once_safely()
|
||||
try:
|
||||
controller.run_once()
|
||||
except Exception as exc: # fail closed and keep the API available
|
||||
_durable_json(
|
||||
controller.status_path,
|
||||
{
|
||||
"error": type(exc).__name__,
|
||||
"execution_enabled": False,
|
||||
"shadow_mode": True,
|
||||
"side_effects_executed": False,
|
||||
"status": "FAIL_CLOSED",
|
||||
},
|
||||
)
|
||||
|
||||
handler = type(
|
||||
"ConfiguredShadowActionHandler",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,271 +0,0 @@
|
||||
"""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,28 +105,6 @@ 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();
|
||||
@@ -146,204 +124,130 @@ async function fetchData() {
|
||||
|
||||
async function loadVersions() {
|
||||
const el = document.getElementById('versions-list');
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading versions...'; el.appendChild(loading);
|
||||
el.innerHTML = '<div class="loading">Loading versions...</div>';
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/versions');
|
||||
const data = await r.json();
|
||||
clearNode(el);
|
||||
if (data.error) { const e = div('error'); e.textContent = data.error; el.appendChild(e); return; }
|
||||
const grid = div('grid');
|
||||
if (data.error) { el.innerHTML = '<div class="error">' + data.error + '</div>'; return; }
|
||||
let html = '<div class="grid">';
|
||||
for (const v of data.versions || []) {
|
||||
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 class="card"><div class="card-title">' + v.tag + '</div><div class="card-value">' + (v.version || v.tag) + '</div></div>';
|
||||
}
|
||||
el.appendChild(grid);
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load versions: ' + e.message; el.appendChild(err);
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load versions: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommands() {
|
||||
const el = document.getElementById('commands-list');
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading commands...'; el.appendChild(loading);
|
||||
el.innerHTML = '<div class="loading">Loading commands...</div>';
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/data');
|
||||
const data = await r.json();
|
||||
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);
|
||||
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 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)) {
|
||||
const line = div(''); line.style.fontSize = '14px'; line.style.color = '#8b949e'; line.style.margin = '4px 0';
|
||||
line.textContent = k + ': ' + v; catCard.appendChild(line);
|
||||
}
|
||||
wrapper.appendChild(catCard); el.appendChild(wrapper);
|
||||
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()) {
|
||||
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);
|
||||
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>';
|
||||
}
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load commands: ' + e.message; el.appendChild(err);
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load commands: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
const el = document.getElementById('agents-list');
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading agent runs...'; el.appendChild(loading);
|
||||
el.innerHTML = '<div class="loading">Loading agent runs...</div>';
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/agents');
|
||||
const data = await r.json();
|
||||
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;
|
||||
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>';
|
||||
}
|
||||
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);
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
||||
function showPanel(name) {
|
||||
|
||||
async function loadOwnerGate() {
|
||||
const el = document.getElementById('arch-owner-content');
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading owner gate data...'; el.appendChild(loading);
|
||||
el.innerHTML = '<div class="loading">Loading owner gate data...</div>';
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/owner-gate');
|
||||
const data = await r.json();
|
||||
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);
|
||||
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>';
|
||||
}
|
||||
} else {
|
||||
const none = div(''); none.style.color = '#8b949e'; none.style.padding = '12px';
|
||||
none.textContent = 'No pending requests.'; listCard.appendChild(none);
|
||||
html += '<div style="color:#8b949e;padding:12px;">No pending requests.</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);
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApprove() {
|
||||
const el = document.getElementById('arch-approve-content');
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading pending approvals...'; el.appendChild(loading);
|
||||
el.innerHTML = '<div class="loading">Loading pending approvals...</div>';
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/owner-gate');
|
||||
const data = await r.json();
|
||||
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);
|
||||
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>';
|
||||
}
|
||||
el.appendChild(card);
|
||||
html += '</div>';
|
||||
} else {
|
||||
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);
|
||||
html += '<div class="card"><div style="padding:24px;text-align:center;color:#8b949e;">✅ All changes processed. No pending approval requests.</div></div>';
|
||||
}
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load: ' + e.message; el.appendChild(err);
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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,['arch-owner']:5,['arch-approve']:6}[name] || 1) + ')').classList.add('active');
|
||||
if (name === 'overview') fetchData();
|
||||
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();
|
||||
}
|
||||
const loaders = {
|
||||
'arch-owner': loadOwnerGate,
|
||||
'arch-approve': loadApprove,
|
||||
|
||||
@@ -12,29 +12,6 @@ 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 {
|
||||
@@ -54,8 +31,7 @@ def build_router():
|
||||
|
||||
@router.post("/{task_id}/actions/{action}", operation_id="executeTaskAction")
|
||||
def action(task_id: str, action: str, payload: dict[str, Any]):
|
||||
# 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 execute_task_action(payload.get("task", {}), action, payload.get("role", "user"), payload.get("reason"))
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -1,257 +1,20 @@
|
||||
"""Authenticated read-only Task Center projections and event transport."""
|
||||
"""Task Center API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
except Exception: # pragma: no cover - importable without FastAPI for model tests
|
||||
APIRouter = HTTPException = Request = StreamingResponse = None
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .task_center import TaskCenter
|
||||
from .task_dashboard_view import ProjectionCursorExpired, ProjectionCursorTransportLimit, TaskDashboardView
|
||||
from .task_plane.errors import ForbiddenError, UnauthorizedError
|
||||
|
||||
|
||||
class ProjectionUnavailable(RuntimeError):
|
||||
"""The host did not install the source and identity bindings required to read."""
|
||||
|
||||
|
||||
task_center = TaskCenter()
|
||||
dashboard = TaskDashboardView()
|
||||
_projection_reader: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None
|
||||
_principal_resolver: Callable[[Any], Mapping[str, Any] | None] | None = None
|
||||
|
||||
|
||||
def set_task_projection_reader(reader: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None) -> None:
|
||||
"""Install a host-owned reader that returns already-authorized task/event data."""
|
||||
global _projection_reader
|
||||
_projection_reader = reader
|
||||
|
||||
|
||||
def set_task_projection_principal_resolver(
|
||||
resolver: Callable[[Any], Mapping[str, Any] | None] | None,
|
||||
) -> None:
|
||||
"""Install the host's authenticated-principal resolver for all projection reads."""
|
||||
global _principal_resolver
|
||||
_principal_resolver = resolver
|
||||
|
||||
|
||||
def get_task_overview(
|
||||
tasks: list[dict[str, Any]] | None = None,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Project provided durable records; route callers use ``_read_projection``."""
|
||||
summary = task_center.summarize(tasks)
|
||||
projection = dashboard.build(tasks, events_by_task)
|
||||
return {
|
||||
**summary,
|
||||
"tasks": projection["tasks"],
|
||||
"views": projection["views"],
|
||||
"snapshot_cursor": dashboard.snapshot_cursor(tasks, events_by_task),
|
||||
}
|
||||
|
||||
|
||||
def get_task_events(
|
||||
cursor: str | None,
|
||||
*,
|
||||
principal: Mapping[str, Any],
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
source = _read_projection(principal)
|
||||
feed = dashboard.event_feed(source["tasks"], source["events_by_task"], cursor, limit=limit)
|
||||
cards = {card["task_id"]: card for card in dashboard.build(source["tasks"], source["events_by_task"])["tasks"]}
|
||||
for event in feed["events"]:
|
||||
card = cards.get(event["entity_id"])
|
||||
if card is None:
|
||||
continue
|
||||
# Event sequence and task revision have different authority domains.
|
||||
# The client fences entity snapshots by task revision, while resumable
|
||||
# transport remains governed by the per-task append sequence.
|
||||
event["event_sequence"] = event["sequence"]
|
||||
event["event_revision"] = event.get("revision")
|
||||
event["task_revision"] = card.get("revision")
|
||||
event["revision"] = card.get("revision")
|
||||
event["task"] = card
|
||||
event["related_tasks"] = _ancestor_cards(card, cards)
|
||||
return feed
|
||||
|
||||
|
||||
def _ancestor_cards(card: Mapping[str, Any], cards: Mapping[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
related: list[dict[str, Any]] = []
|
||||
parent_id = card.get("lineage", {}).get("parent_task_id") if isinstance(card.get("lineage"), Mapping) else None
|
||||
visited: set[str] = set()
|
||||
while isinstance(parent_id, str) and parent_id and parent_id not in visited:
|
||||
visited.add(parent_id)
|
||||
parent = cards.get(parent_id)
|
||||
if parent is None:
|
||||
break
|
||||
related.append(parent)
|
||||
lineage = parent.get("lineage")
|
||||
parent_id = lineage.get("parent_task_id") if isinstance(lineage, Mapping) else None
|
||||
return related
|
||||
|
||||
|
||||
def configure_task_plane_projection(service: Any, principal_resolver: Callable[[Any], Mapping[str, Any] | None]) -> None:
|
||||
"""Bind this FastAPI router to an existing authorized ``TaskPlaneService``.
|
||||
|
||||
The resolver must return a host-authenticated principal containing the
|
||||
server-derived ``task_plane_identity``. No browser-provided role or scope
|
||||
participates in these reads.
|
||||
"""
|
||||
def reader(principal: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
actor = principal.get("task_plane_identity")
|
||||
if not isinstance(actor, str) or not actor:
|
||||
raise PermissionError("task projection identity is missing")
|
||||
return _read_task_plane_snapshot(service, actor)
|
||||
|
||||
set_task_projection_principal_resolver(principal_resolver)
|
||||
set_task_projection_reader(reader)
|
||||
|
||||
|
||||
_TASKPLANE_DISPLAY_STATUSES = {
|
||||
"CREATED": "pending",
|
||||
"QUEUED": "pending",
|
||||
"CLAIMED": "running",
|
||||
"RUNNING": "running",
|
||||
"WAITING_CHILD": "running",
|
||||
"VERIFYING": "running",
|
||||
"COMPLETED": "completed",
|
||||
"FAILED_RECOVERABLE": "blocked",
|
||||
"FAILED_FINAL": "blocked",
|
||||
"CANCELLED": "blocked",
|
||||
"MANUAL_REVIEW_REQUIRED": "waiting_approval",
|
||||
}
|
||||
|
||||
|
||||
def _read_task_plane_snapshot(service: Any, actor: str) -> Mapping[str, Any]:
|
||||
"""Read an authorized, bounded-consistency TaskPlane projection.
|
||||
|
||||
``list_tasks`` and ``get_events`` are separate authorized reads. We only
|
||||
publish their result when a second task and event-position read confirms
|
||||
that no visible task revision, membership, or per-task append position
|
||||
moved while the first snapshot was collected. A busy plane is retried
|
||||
once and then fails closed rather than emitting a mixed snapshot.
|
||||
"""
|
||||
for _attempt in range(2):
|
||||
raw_tasks = _visible_tasks(service.list_tasks(actor))
|
||||
task_ids = [task["task_id"] for task in raw_tasks]
|
||||
events_by_task = {task_id: service.get_events(task_id, actor) for task_id in task_ids}
|
||||
verification_events = {task_id: service.get_events(task_id, actor) for task_id in task_ids}
|
||||
verified_tasks = _visible_tasks(service.list_tasks(actor))
|
||||
if _task_revisions(raw_tasks) == _task_revisions(verified_tasks) and all(
|
||||
_event_positions(events_by_task[task_id]) == _event_positions(verification_events[task_id])
|
||||
for task_id in task_ids
|
||||
):
|
||||
return {"tasks": raw_tasks, "events_by_task": events_by_task}
|
||||
raise ProjectionUnavailable("task projection changed during snapshot")
|
||||
|
||||
|
||||
def _visible_tasks(records: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(records, list):
|
||||
raise ProjectionUnavailable("task projection source returned invalid tasks")
|
||||
tasks: list[dict[str, Any]] = []
|
||||
for raw in records:
|
||||
if not isinstance(raw, Mapping) or not isinstance(raw.get("task_id"), str) or not raw["task_id"]:
|
||||
raise ProjectionUnavailable("task projection source returned invalid task")
|
||||
source_status = str(raw.get("status") or "").upper()
|
||||
if source_status not in _TASKPLANE_DISPLAY_STATUSES:
|
||||
raise ProjectionUnavailable("task projection source returned unknown TaskPlane status")
|
||||
tasks.append({
|
||||
**raw,
|
||||
"source_status": source_status or None,
|
||||
"status": _TASKPLANE_DISPLAY_STATUSES[source_status],
|
||||
})
|
||||
return tasks
|
||||
|
||||
|
||||
def _task_revisions(tasks: list[Mapping[str, Any]]) -> tuple[tuple[str, Any], ...]:
|
||||
return tuple(sorted((task["task_id"], task.get("revision")) for task in tasks))
|
||||
|
||||
|
||||
def _event_positions(events: Any) -> tuple[tuple[str, int], ...]:
|
||||
if not isinstance(events, list):
|
||||
raise ProjectionUnavailable("task projection source returned invalid events")
|
||||
positions: list[tuple[str, int]] = []
|
||||
for event in events:
|
||||
if not isinstance(event, Mapping) or not isinstance(event.get("event_id"), str):
|
||||
raise ProjectionUnavailable("task projection source returned invalid event")
|
||||
sequence = event.get("seq")
|
||||
if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
|
||||
raise ProjectionUnavailable("task projection event has no durable sequence")
|
||||
positions.append((event["event_id"], sequence))
|
||||
return tuple(positions)
|
||||
|
||||
|
||||
def _read_projection(principal: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if _projection_reader is None:
|
||||
raise ProjectionUnavailable("task projection source is not configured")
|
||||
source = _projection_reader(principal)
|
||||
if not isinstance(source, Mapping):
|
||||
raise ProjectionUnavailable("task projection source returned invalid data")
|
||||
tasks, events = source.get("tasks"), source.get("events_by_task")
|
||||
if not isinstance(tasks, list) or not isinstance(events, Mapping):
|
||||
raise ProjectionUnavailable("task projection source returned incomplete data")
|
||||
return {"tasks": tasks, "events_by_task": events}
|
||||
|
||||
|
||||
def _principal(request: Any) -> Mapping[str, Any]:
|
||||
if _principal_resolver is None:
|
||||
raise ProjectionUnavailable("task projection authentication is not configured")
|
||||
principal = _principal_resolver(request)
|
||||
if not isinstance(principal, Mapping):
|
||||
raise PermissionError("task projection authentication failed")
|
||||
return principal
|
||||
|
||||
|
||||
def _sse(payload: Mapping[str, Any]):
|
||||
# The bounded response is deliberately resumable. The browser reconnects
|
||||
# with the returned cursor; polling remains available when it cannot.
|
||||
for event in payload["events"]:
|
||||
yield f"id: {event['cursor']}\ndata: {json.dumps(event, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
||||
yield ": end-of-batch\n\n"
|
||||
|
||||
|
||||
def _http(call: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
return call()
|
||||
except ProjectionCursorTransportLimit as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=413, detail="TASK_EVENT_CURSOR_TOO_LARGE") from exc
|
||||
except ProjectionCursorExpired as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=410, detail="EVENT_CURSOR_EXPIRED") from exc
|
||||
except ProjectionUnavailable as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=503, detail="TASK_PROJECTION_UNAVAILABLE") from exc
|
||||
except UnauthorizedError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=401, detail="TASK_PROJECTION_UNAUTHORIZED") from exc
|
||||
except ForbiddenError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=403, detail="TASK_PROJECTION_DENIED") from exc
|
||||
except PermissionError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=403, detail="TASK_PROJECTION_DENIED") from exc
|
||||
except ValueError as exc:
|
||||
if HTTPException is None:
|
||||
raise
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _overview(principal: Mapping[str, Any]) -> dict[str, Any]:
|
||||
source = _read_projection(principal)
|
||||
return get_task_overview(source["tasks"], source["events_by_task"])
|
||||
def get_task_overview(tasks: list[dict[str, Any]] | None = None):
|
||||
return task_center.summarize(tasks)
|
||||
|
||||
|
||||
def build_router():
|
||||
@@ -260,37 +23,12 @@ def build_router():
|
||||
router = APIRouter(prefix="/admin/ai-evolution/tasks", tags=["task-center"])
|
||||
|
||||
@router.get("/overview", operation_id="getTaskCenterOverview")
|
||||
def overview(request: Request):
|
||||
return _http(lambda: _overview(_principal(request)))
|
||||
|
||||
@router.get("/events", operation_id="streamTaskCenterEvents")
|
||||
def events(request: Request, cursor: str | None = None, after_cursor: str | None = None, limit: int = 100):
|
||||
payload = _http(lambda: get_task_events(
|
||||
_effective_cursor(request, cursor, after_cursor), principal=_principal(request), limit=limit,
|
||||
))
|
||||
return StreamingResponse(_sse(payload), media_type="text/event-stream")
|
||||
|
||||
@router.get("/events/poll", operation_id="pollTaskCenterEvents")
|
||||
def poll(request: Request, cursor: str | None = None, after_cursor: str | None = None, limit: int = 100):
|
||||
return _http(lambda: get_task_events(
|
||||
_effective_cursor(request, cursor, after_cursor), principal=_principal(request), limit=limit,
|
||||
))
|
||||
def overview():
|
||||
return get_task_overview()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = [
|
||||
"router", "get_task_overview", "get_task_events", "set_task_projection_reader",
|
||||
"set_task_projection_principal_resolver", "configure_task_plane_projection",
|
||||
"ProjectionUnavailable",
|
||||
]
|
||||
|
||||
|
||||
def _effective_cursor(request: Any, cursor: str | None, after_cursor: str | None) -> str | None:
|
||||
header_cursor = request.headers.get("Last-Event-ID") if request is not None else None
|
||||
supplied = [value for value in (cursor, after_cursor, header_cursor) if value]
|
||||
if len(set(supplied)) > 1:
|
||||
raise ValueError("conflicting event cursors")
|
||||
return supplied[0] if supplied else None
|
||||
__all__ = ["router", "get_task_overview"]
|
||||
|
||||
@@ -1,438 +1,81 @@
|
||||
"""Read-only Task Center projections built from durable task and event records."""
|
||||
"""Task Center visualization model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import zlib
|
||||
from collections import defaultdict
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
class ProjectionCursorExpired(ValueError):
|
||||
"""The requested event cursor no longer names a visible durable event."""
|
||||
|
||||
|
||||
class ProjectionCursorTransportLimit(ValueError):
|
||||
"""The authorized per-task watermark cannot fit a request/header cursor."""
|
||||
|
||||
|
||||
_MAX_CURSOR_BYTES = 8192
|
||||
_MAX_CURSOR_PAYLOAD_BYTES = 32768
|
||||
_INITIAL_INTERACTION_KIND = "initial_request"
|
||||
_ADDITIONAL_INTERACTION_KINDS = frozenset({"additional_interaction", "follow_up"})
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TaskDashboardView:
|
||||
"""Build the Task Center read model without maintaining another state store."""
|
||||
|
||||
def build(
|
||||
self,
|
||||
tasks: list[dict[str, Any]] | None = None,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
raw_tasks = [dict(task) for task in tasks or [] if isinstance(task, Mapping)]
|
||||
by_id = {
|
||||
task_id: task for task in raw_tasks
|
||||
if isinstance((task_id := task.get("task_id")), str) and task_id
|
||||
}
|
||||
children = self._children_by_parent(by_id)
|
||||
cards = [
|
||||
self._task_card(task, by_id, children, events_by_task)
|
||||
for task in raw_tasks
|
||||
]
|
||||
def build(self, tasks: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
tasks = tasks or []
|
||||
return {
|
||||
"module": "task-center",
|
||||
"route": "/admin/ai-evolution/tasks",
|
||||
"title": "AIOS Task Center",
|
||||
"views": {
|
||||
"kanban": self._kanban(cards),
|
||||
"timeline": self._timeline(cards),
|
||||
"flow": self._flow(cards),
|
||||
"statistics": self._statistics(cards),
|
||||
"kanban": self._kanban(tasks),
|
||||
"timeline": self._timeline(tasks),
|
||||
"flow": self._flow(tasks),
|
||||
"statistics": self._statistics(tasks),
|
||||
},
|
||||
"tasks": cards,
|
||||
"tasks": [self._task_card(task) for task in tasks],
|
||||
}
|
||||
|
||||
def event_feed(
|
||||
self,
|
||||
tasks: list[dict[str, Any]] | None,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None,
|
||||
cursor: str | None = None,
|
||||
*,
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
"""Return an authorization-filtered, resumable page of durable events."""
|
||||
if not 1 <= limit <= 100:
|
||||
raise ValueError("invalid event page limit")
|
||||
rows = self._event_rows(tasks, events_by_task)
|
||||
watermarks = self._decode_cursor(cursor) if cursor else {}
|
||||
self._validate_watermarks(rows, watermarks)
|
||||
pending = [
|
||||
row for row in rows
|
||||
if row["sequence"] > watermarks.get(row["entity_id"], -1)
|
||||
]
|
||||
page = pending[:limit]
|
||||
next_watermarks = dict(watermarks)
|
||||
emitted_events: list[dict[str, Any]] = []
|
||||
for row in page:
|
||||
next_watermarks[row["entity_id"]] = max(next_watermarks.get(row["entity_id"], -1), row["sequence"])
|
||||
# ``Last-Event-ID`` holds the cursor from the final SSE frame. It
|
||||
# must therefore include every preceding entity watermark, not just
|
||||
# the frame's own task, or a reconnect would replay another task.
|
||||
emitted_events.append({**row, "cursor": self._encode_cursor(next_watermarks)})
|
||||
next_cursor = self._encode_cursor(next_watermarks) if next_watermarks else None
|
||||
def _task_card(self, task: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"events": emitted_events,
|
||||
"cursor": cursor,
|
||||
"next_cursor": next_cursor,
|
||||
"has_more": len(pending) > len(page),
|
||||
}
|
||||
|
||||
def snapshot_cursor(
|
||||
self,
|
||||
tasks: list[dict[str, Any]] | None,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None,
|
||||
) -> str | None:
|
||||
rows = self._event_rows(tasks, events_by_task)
|
||||
watermarks: dict[str, int] = {}
|
||||
for row in rows:
|
||||
watermarks[row["entity_id"]] = max(watermarks.get(row["entity_id"], -1), row["sequence"])
|
||||
return self._encode_cursor(watermarks) if watermarks else None
|
||||
|
||||
def _task_card(
|
||||
self,
|
||||
task: dict[str, Any],
|
||||
by_id: Mapping[str, dict[str, Any]],
|
||||
children: Mapping[str, list[str]],
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None,
|
||||
) -> dict[str, Any]:
|
||||
task_id = task.get("task_id")
|
||||
progress = task.get("progress") if isinstance(task.get("progress"), Mapping) else {}
|
||||
parent_task_id = task.get("parent_task_id")
|
||||
event_result = self._project_events(
|
||||
task_id,
|
||||
events_by_task,
|
||||
complete=task.get("interaction_history_coverage") == "complete",
|
||||
)
|
||||
direct_children = list(children.get(task_id, ())) if isinstance(task_id, str) else []
|
||||
grandchildren = [grandchild for child in direct_children for grandchild in children.get(child, ())]
|
||||
authorization = self._authorization(task)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"title": task.get("title") or task.get("display_name"),
|
||||
"root_task_id": task.get("root_task_id"),
|
||||
"created_at": task.get("created_at"),
|
||||
"updated_at": task.get("updated_at"),
|
||||
"task_id": task.get("task_id"),
|
||||
"title": task.get("title"),
|
||||
"assigned_by": task.get("assigned_by"),
|
||||
"executor": task.get("executor"),
|
||||
"status": task.get("status"),
|
||||
"source_status": task.get("source_status", task.get("status")),
|
||||
"revision": task.get("revision"),
|
||||
"progress": progress,
|
||||
"elapsed_seconds": self._known_nonnegative_int(task.get("elapsed_seconds")),
|
||||
"remaining_steps": progress.get("remaining_steps", []),
|
||||
"progress": task.get("progress", {}),
|
||||
"elapsed_seconds": task.get("elapsed_seconds", 0),
|
||||
"remaining_steps": task.get("progress", {}).get("remaining_steps", []),
|
||||
"notification": task.get("notification", {}),
|
||||
"lineage": {
|
||||
"parent_task_id": parent_task_id if isinstance(parent_task_id, str) and parent_task_id in by_id else None,
|
||||
"child_task_ids": direct_children,
|
||||
"grandchild_task_ids": grandchildren,
|
||||
},
|
||||
"responsibility": {
|
||||
"assigned_by": task.get("assigned_by"),
|
||||
"executor": task.get("executor"),
|
||||
"owner_principal_id": task.get("owner_principal_id"),
|
||||
},
|
||||
"authorization": authorization,
|
||||
"actions": self._actions(task, authorization),
|
||||
"interaction_history": event_result,
|
||||
"actions": [
|
||||
"start",
|
||||
"update_progress",
|
||||
"block",
|
||||
"request_approval",
|
||||
"approve",
|
||||
"reject",
|
||||
"retry",
|
||||
"complete",
|
||||
"notify",
|
||||
"close",
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _known_nonnegative_int(value: Any) -> int | None:
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _children_by_parent(by_id: Mapping[str, dict[str, Any]]) -> dict[str, list[str]]:
|
||||
result: dict[str, list[str]] = defaultdict(list)
|
||||
for task_id, task in by_id.items():
|
||||
parent_id = task.get("parent_task_id")
|
||||
if isinstance(parent_id, str) and parent_id in by_id:
|
||||
result[parent_id].append(task_id)
|
||||
return {parent: sorted(child_ids) for parent, child_ids in result.items()}
|
||||
|
||||
def _project_events(
|
||||
self,
|
||||
task_id: Any,
|
||||
events_by_task: Mapping[str, list[dict[str, Any]]] | None,
|
||||
*,
|
||||
complete: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(task_id, str) or events_by_task is None or task_id not in events_by_task:
|
||||
return self._unknown_history()
|
||||
raw_events = events_by_task[task_id]
|
||||
if not isinstance(raw_events, list):
|
||||
return self._unknown_history()
|
||||
seen: dict[str, dict[str, Any]] = {}
|
||||
semantic_ids: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
events: list[dict[str, Any]] = []
|
||||
conflicts: list[dict[str, str]] = []
|
||||
for raw in raw_events:
|
||||
if not isinstance(raw, Mapping) or not isinstance(raw.get("event_id"), str):
|
||||
conflicts.append({"kind": "invalid_event", "event_id": ""})
|
||||
continue
|
||||
event = dict(raw)
|
||||
event_id = event["event_id"]
|
||||
previous = seen.get(event_id)
|
||||
if previous is not None:
|
||||
if previous != event:
|
||||
conflicts.append({"kind": "duplicate_event_id_conflict", "event_id": event_id})
|
||||
continue
|
||||
seen[event_id] = event
|
||||
item = self._event_item(event)
|
||||
if self._malformed_human_interaction(event):
|
||||
conflicts.append({"kind": "invalid_human_interaction_evidence", "event_id": event_id})
|
||||
if self._malformed_metric_evidence(event):
|
||||
conflicts.append({"kind": "invalid_metric_evidence", "event_id": event_id})
|
||||
semantic_id = item.get("semantic_id")
|
||||
if semantic_id:
|
||||
semantic_namespace = "human_interaction" if item["category"] in {"initial_request", "additional_interaction"} else item["category"]
|
||||
previous_semantic = semantic_ids.get((semantic_namespace, semantic_id))
|
||||
if previous_semantic is not None:
|
||||
if self._semantic_content(previous_semantic) != self._semantic_content(item):
|
||||
conflicts.append({"kind": "duplicate_semantic_id_conflict", "event_id": event_id})
|
||||
continue
|
||||
semantic_ids[(semantic_namespace, semantic_id)] = item
|
||||
events.append(item)
|
||||
events.sort(key=lambda event: (event.get("created_at") or "", event["event_id"]))
|
||||
interactions = sum(event["category"] in {"initial_request", "additional_interaction"} for event in events)
|
||||
is_exact = complete and not conflicts
|
||||
return {
|
||||
"history_status": "partial" if not complete else ("conflicted" if conflicts else "complete"),
|
||||
"interaction_count": interactions if is_exact else None,
|
||||
"initial_request_count": sum(event["category"] == "initial_request" for event in events) if is_exact else None,
|
||||
"additional_interaction_count": sum(event["category"] == "additional_interaction" for event in events) if is_exact else None,
|
||||
"decision_count": sum(event["category"] == "decision" for event in events) if is_exact else None,
|
||||
"offered_choice_count": sum(event.get("choice_count", 0) for event in events if event["category"] == "offered_choice") if is_exact else None,
|
||||
"selection_submission_count": sum(event["category"] == "selection_submission" for event in events) if is_exact else None,
|
||||
"events": events,
|
||||
"source_conflicts": conflicts,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _unknown_history() -> dict[str, Any]:
|
||||
return {
|
||||
"history_status": "partial",
|
||||
"interaction_count": None,
|
||||
"initial_request_count": None,
|
||||
"additional_interaction_count": None,
|
||||
"decision_count": None,
|
||||
"offered_choice_count": None,
|
||||
"selection_submission_count": None,
|
||||
"events": [],
|
||||
"source_conflicts": [],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _semantic_content(event: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: value for key, value in event.items()
|
||||
if key not in {"event_id", "sequence", "revision", "created_at"}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _malformed_human_interaction(event: Mapping[str, Any]) -> bool:
|
||||
data = event.get("data") if isinstance(event.get("data"), Mapping) else {}
|
||||
source = str(data.get("source") or event.get("source") or "").upper()
|
||||
if source != "HUMAN_INTERACTION":
|
||||
return False
|
||||
interaction_id = data.get("interaction_id")
|
||||
kind = data.get("kind")
|
||||
return not (
|
||||
isinstance(interaction_id, str) and interaction_id
|
||||
and isinstance(kind, str)
|
||||
and (kind == _INITIAL_INTERACTION_KIND or kind in _ADDITIONAL_INTERACTION_KINDS)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _malformed_metric_evidence(event: Mapping[str, Any]) -> bool:
|
||||
data = event.get("data") if isinstance(event.get("data"), Mapping) else {}
|
||||
event_type = str(event.get("type") or "").upper()
|
||||
if event_type in {"DECISION", "TASK_DECISION", "APPROVAL_DECIDED"}:
|
||||
return not isinstance(data.get("decision_id"), str) or not data["decision_id"]
|
||||
if event_type in {"CHOICE_OFFERED", "OPTIONS_OFFERED"}:
|
||||
choice_count = data.get("choice_count")
|
||||
return not (
|
||||
isinstance(data.get("choice_offer_id"), str) and data["choice_offer_id"]
|
||||
and isinstance(choice_count, int) and not isinstance(choice_count, bool) and choice_count >= 0
|
||||
)
|
||||
if event_type in {"CHOICE_SELECTED", "SELECTION_SUBMITTED"}:
|
||||
return not isinstance(data.get("selection_submission_id"), str) or not data["selection_submission_id"]
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _event_item(event: Mapping[str, Any]) -> dict[str, Any]:
|
||||
event_type = str(event.get("type") or "UNKNOWN")
|
||||
data = event.get("data") if isinstance(event.get("data"), Mapping) else {}
|
||||
normalized = event_type.upper()
|
||||
source = str(data.get("source") or event.get("source") or "").upper()
|
||||
interaction_id = data.get("interaction_id")
|
||||
decision_id = data.get("decision_id")
|
||||
selection_id = data.get("selection_submission_id")
|
||||
kind = data.get("kind")
|
||||
if source == "HUMAN_INTERACTION" and isinstance(interaction_id, str) and interaction_id and kind == _INITIAL_INTERACTION_KIND:
|
||||
category = "initial_request"
|
||||
semantic_id = interaction_id
|
||||
elif source == "HUMAN_INTERACTION" and isinstance(interaction_id, str) and interaction_id and isinstance(kind, str) and kind in _ADDITIONAL_INTERACTION_KINDS:
|
||||
category = "additional_interaction"
|
||||
semantic_id = interaction_id
|
||||
elif normalized in {"DECISION", "TASK_DECISION", "APPROVAL_DECIDED"} and isinstance(decision_id, str) and decision_id:
|
||||
category = "decision"
|
||||
semantic_id = decision_id
|
||||
elif normalized in {"CHOICE_OFFERED", "OPTIONS_OFFERED"} and isinstance(data.get("choice_offer_id"), str) and isinstance(data.get("choice_count"), int) and not isinstance(data["choice_count"], bool) and data["choice_count"] >= 0:
|
||||
category = "offered_choice"
|
||||
semantic_id = data["choice_offer_id"]
|
||||
elif normalized in {"CHOICE_SELECTED", "SELECTION_SUBMITTED"} and isinstance(selection_id, str) and selection_id:
|
||||
category = "selection_submission"
|
||||
semantic_id = selection_id
|
||||
else:
|
||||
category = "other"
|
||||
semantic_id = None
|
||||
return {
|
||||
"event_id": event["event_id"],
|
||||
"sequence": event.get("seq"),
|
||||
"revision": event.get("revision", event.get("seq")),
|
||||
"type": event_type,
|
||||
"category": category,
|
||||
"semantic_id": semantic_id,
|
||||
"subject": event.get("subject"),
|
||||
"created_at": event.get("created_at"),
|
||||
"decision": data.get("decision") if category == "decision" else None,
|
||||
"choice_id": data.get("choice_id") if category in {"offered_choice", "selection_submission"} else None,
|
||||
"choice_count": data.get("choice_count", 0) if category == "offered_choice" else 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _authorization(task: Mapping[str, Any]) -> dict[str, Any]:
|
||||
raw = task.get("authorization")
|
||||
raw = raw if isinstance(raw, Mapping) else {}
|
||||
capabilities = raw.get("capabilities", task.get("capabilities", []))
|
||||
return {
|
||||
"owner_gate_required": bool(task.get("owner_gate_required", raw.get("owner_gate_required", False))),
|
||||
"owner_gate_status": task.get("owner_gate_status", raw.get("owner_gate_status")),
|
||||
"decision": raw.get("decision"),
|
||||
"capabilities": list(capabilities) if isinstance(capabilities, (list, tuple, set, frozenset)) else [],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _actions(task: Mapping[str, Any], authorization: Mapping[str, Any]) -> list[str]:
|
||||
explicit = task.get("authorized_actions")
|
||||
if not isinstance(explicit, (list, tuple, set, frozenset)):
|
||||
return []
|
||||
allowed = [action for action in explicit if isinstance(action, str)]
|
||||
capabilities = set(authorization.get("capabilities", []))
|
||||
decision = authorization.get("decision")
|
||||
if "approve" in allowed and not (
|
||||
{"approve", "decision.approve", "task.approve"} & capabilities
|
||||
and isinstance(decision, Mapping)
|
||||
and decision.get("status") in {"pending", "eligible"}
|
||||
):
|
||||
allowed.remove("approve")
|
||||
return allowed
|
||||
|
||||
@staticmethod
|
||||
def _kanban(tasks: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||
def _kanban(self, tasks):
|
||||
columns = {"pending": [], "running": [], "blocked": [], "waiting_approval": [], "completed": []}
|
||||
for task in tasks:
|
||||
columns.setdefault(task.get("status", "pending"), []).append(task.get("task_id"))
|
||||
return columns
|
||||
|
||||
@staticmethod
|
||||
def _timeline(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [{"task_id": task.get("task_id"), "status": task.get("status"), "revision": task.get("revision")} for task in tasks]
|
||||
def _timeline(self, tasks):
|
||||
return [
|
||||
{
|
||||
"task_id": task.get("task_id"),
|
||||
"updated_at": task.get("updated_at"),
|
||||
"status": task.get("status"),
|
||||
}
|
||||
for task in tasks
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _flow(tasks: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {"nodes": ["created", "assigned", "running", "blocked", "approval", "completed", "closed"], "task_count": len(tasks)}
|
||||
|
||||
@staticmethod
|
||||
def _statistics(tasks: list[dict[str, Any]]) -> dict[str, int]:
|
||||
def _flow(self, tasks):
|
||||
return {
|
||||
"total": len(tasks),
|
||||
"running": sum(task.get("status") == "running" for task in tasks),
|
||||
"blocked": sum(task.get("status") == "blocked" for task in tasks),
|
||||
"waiting_approval": sum(task.get("status") == "waiting_approval" for task in tasks),
|
||||
"completed": sum(task.get("status") == "completed" for task in tasks),
|
||||
"nodes": ["created", "assigned", "running", "blocked", "approval", "completed", "closed"],
|
||||
"task_count": len(tasks),
|
||||
}
|
||||
|
||||
def _event_rows(self, tasks, events_by_task) -> list[dict[str, Any]]:
|
||||
visible_ids = {task.get("task_id") for task in tasks or [] if isinstance(task, Mapping)}
|
||||
result: list[dict[str, Any]] = []
|
||||
for task_id, events in (events_by_task or {}).items():
|
||||
if task_id not in visible_ids or not isinstance(events, list):
|
||||
continue
|
||||
# The stream carries every durable append. Semantic deduplication
|
||||
# belongs only to human-history metrics: a later conflicting event
|
||||
# must reach the client so its current task card becomes conflicted.
|
||||
for event in events:
|
||||
if not isinstance(event, Mapping) or not isinstance(event.get("event_id"), str):
|
||||
continue
|
||||
sequence = event.get("seq")
|
||||
if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
|
||||
continue
|
||||
result.append({"event_id": event["event_id"], "entity_id": task_id, "sequence": sequence, "revision": event.get("revision"), "type": event.get("type", "UNKNOWN"), "created_at": event.get("created_at")})
|
||||
# A per-task append sequence is the cursor authority. Timestamp order
|
||||
# can be displayed by clients, but cannot drive paging: a backdated
|
||||
# seq=6 must never be emitted before seq=5 for the same task.
|
||||
result.sort(key=lambda event: (event["sequence"], event["entity_id"], event["event_id"]))
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _encode_cursor(watermarks: Mapping[str, int]) -> str:
|
||||
raw = json.dumps({"v": 2, "after": dict(watermarks)}, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if len(raw) > _MAX_CURSOR_PAYLOAD_BYTES:
|
||||
raise ProjectionCursorTransportLimit("event cursor exceeds transport limit")
|
||||
token = base64.urlsafe_b64encode(zlib.compress(raw, level=9)).decode("ascii").rstrip("=")
|
||||
if len(token) > _MAX_CURSOR_BYTES:
|
||||
raise ProjectionCursorTransportLimit("event cursor exceeds transport limit")
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def _decode_cursor(cursor: str) -> dict[str, int]:
|
||||
if not isinstance(cursor, str) or not cursor or len(cursor) > _MAX_CURSOR_BYTES:
|
||||
raise ProjectionCursorExpired("event cursor expired")
|
||||
try:
|
||||
padded = cursor + "=" * (-len(cursor) % 4)
|
||||
encoded = base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
decompressor = zlib.decompressobj()
|
||||
raw = decompressor.decompress(encoded, _MAX_CURSOR_PAYLOAD_BYTES + 1)
|
||||
if len(raw) > _MAX_CURSOR_PAYLOAD_BYTES:
|
||||
raise ValueError("cursor payload invalid")
|
||||
raw += decompressor.flush(_MAX_CURSOR_PAYLOAD_BYTES + 1 - len(raw))
|
||||
if len(raw) > _MAX_CURSOR_PAYLOAD_BYTES or decompressor.unconsumed_tail or not decompressor.eof:
|
||||
raise ValueError("cursor payload invalid")
|
||||
value = json.loads(raw)
|
||||
except (UnicodeEncodeError, ValueError, zlib.error, json.JSONDecodeError) as exc:
|
||||
raise ProjectionCursorExpired("event cursor expired") from exc
|
||||
after = value.get("after") if isinstance(value, Mapping) and value.get("v") == 2 else None
|
||||
if not isinstance(after, Mapping) or not after or any(
|
||||
not isinstance(task_id, str) or not task_id or not isinstance(sequence, int)
|
||||
or isinstance(sequence, bool) or sequence < 0
|
||||
for task_id, sequence in after.items()
|
||||
):
|
||||
raise ProjectionCursorExpired("event cursor expired")
|
||||
return dict(after)
|
||||
|
||||
@staticmethod
|
||||
def _validate_watermarks(rows: list[dict[str, Any]], watermarks: Mapping[str, int]) -> None:
|
||||
positions_by_task: dict[str, set[int]] = defaultdict(set)
|
||||
for row in rows:
|
||||
positions_by_task[row["entity_id"]].add(row["sequence"])
|
||||
# A retained sequence floor is not otherwise exposed by TaskPlane.
|
||||
# Requiring the acknowledged position itself prevents silently skipping
|
||||
# purged seqs 4..9 when a stale cursor still says seq=3.
|
||||
if any(sequence not in positions_by_task.get(task_id, set()) for task_id, sequence in watermarks.items()):
|
||||
raise ProjectionCursorExpired("event cursor expired")
|
||||
def _statistics(self, tasks):
|
||||
return {
|
||||
"total": len(tasks),
|
||||
"running": len([t for t in tasks if t.get("status") == "running"]),
|
||||
"blocked": len([t for t in tasks if t.get("status") == "blocked"]),
|
||||
"waiting_approval": len([t for t in tasks if t.get("status") == "waiting_approval"]),
|
||||
"completed": len([t for t in tasks if t.get("status") == "completed"]),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["ProjectionCursorExpired", "ProjectionCursorTransportLimit", "TaskDashboardView"]
|
||||
__all__ = ["TaskDashboardView"]
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -24,8 +23,6 @@ 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,
|
||||
@@ -35,12 +32,7 @@ from .models import (
|
||||
contains_secret_text,
|
||||
validate_identifier,
|
||||
)
|
||||
from .policy import IdentityVerifier, PolicyEngine
|
||||
from .root_action_consumer import (
|
||||
RootActionConsumer,
|
||||
RootActionExecutor,
|
||||
RootActionRunResult,
|
||||
)
|
||||
from .policy import PolicyEngine
|
||||
from .store import TaskPlaneStore
|
||||
from .worker_registry import WorkerRegistry
|
||||
|
||||
@@ -61,19 +53,9 @@ def _bounded_int(value: Any, name: str, minimum: int, maximum: int) -> int:
|
||||
|
||||
|
||||
class TaskPlaneService:
|
||||
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,
|
||||
):
|
||||
def __init__(self, store: TaskPlaneStore, policy: PolicyEngine | None = None):
|
||||
self.store = store
|
||||
self.c6_runtime = None
|
||||
self.policy = policy or PolicyEngine(
|
||||
identity_verifier=IdentityVerifier({"anon"}),
|
||||
execution_enabled=False,
|
||||
identity_capabilities={
|
||||
"anon": {"task.creator", "task.read", "task.audit"}
|
||||
@@ -84,260 +66,30 @@ 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.configure_evidence_attestation_verifier(
|
||||
self.policy.evidence_attestation_verifier
|
||||
)
|
||||
|
||||
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]:
|
||||
def create_task(self, body: dict[str, Any], idempotency_key: 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", {})
|
||||
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"
|
||||
)
|
||||
actor = 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
|
||||
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
|
||||
)
|
||||
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)
|
||||
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]
|
||||
return [task.to_dict(include_task=False) for task in self.store.list_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=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()
|
||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||
return self.store.get_task(task_id).to_dict()
|
||||
|
||||
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||
if actor is not None:
|
||||
@@ -417,11 +169,6 @@ class TaskPlaneService:
|
||||
lease_seconds = _bounded_int(
|
||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||
)
|
||||
if self.c6_runtime is not None and self.c6_runtime.bindings.enabled:
|
||||
return self.c6_runtime.claim_task(
|
||||
task_id=task_id, worker_id=worker_id, actor_identity=actor,
|
||||
lease_seconds=lease_seconds,
|
||||
).to_dict()
|
||||
claim = self.lease.claim(
|
||||
task_id=task_id,
|
||||
worker_id=worker_id,
|
||||
@@ -446,10 +193,6 @@ class TaskPlaneService:
|
||||
lease_seconds = _bounded_int(
|
||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||
)
|
||||
if self.c6_runtime is not None and self.c6_runtime.bindings.enabled:
|
||||
return self.c6_runtime.renew_claim(task_id=task_id, claim_id=claim_id,
|
||||
actor_identity=actor, receipt=body.get("claim_receipt"),
|
||||
lease_seconds=lease_seconds)
|
||||
claim = self.lease.heartbeat(task_id, claim_id, lease_seconds)
|
||||
return {"claim_id": claim_id, "lease_until": claim.lease_until}
|
||||
|
||||
@@ -552,79 +295,29 @@ class TaskPlaneService:
|
||||
previous = self.store._last_evidence_hash(task_id)
|
||||
evidence_entries = []
|
||||
for evidence in evidences:
|
||||
if "schema_version" in evidence:
|
||||
try:
|
||||
envelope = EvidenceEnvelope.from_dict(evidence)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("invalid evidence envelope") from exc
|
||||
else:
|
||||
envelope = EvidenceEnvelope.create(
|
||||
task_id=task_id,
|
||||
claim_id=claim_id,
|
||||
kind=str(
|
||||
evidence.get("kind") or evidence.get("type") or "generic"
|
||||
),
|
||||
worker_identity=actor,
|
||||
payload=evidence.get("payload", evidence),
|
||||
previous_evidence_hash=previous,
|
||||
evidence_id=evidence.get("evidence_id"),
|
||||
created_at=evidence.get("created_at"),
|
||||
expires_at=evidence.get("expires_at"),
|
||||
signature=evidence.get("signature"),
|
||||
attestation_ref=evidence.get("attestation_ref"),
|
||||
signature_algorithm=evidence.get("signature_algorithm"),
|
||||
signing_key_id=evidence.get("signing_key_id"),
|
||||
verifier_identity=evidence.get("verifier_identity"),
|
||||
authority_domain=evidence.get("authority_domain"),
|
||||
sequence=evidence.get("sequence"),
|
||||
)
|
||||
envelope = EvidenceEnvelope.create(
|
||||
task_id=task_id,
|
||||
claim_id=claim_id,
|
||||
kind=str(evidence.get("kind") or evidence.get("type") or "generic"),
|
||||
worker_identity=actor,
|
||||
payload=evidence,
|
||||
previous_evidence_hash=previous,
|
||||
signature=evidence.get("signature"),
|
||||
attestation_ref=evidence.get("attestation_ref"),
|
||||
)
|
||||
evidence_entries.append(envelope)
|
||||
previous = envelope.evidence_digest
|
||||
final_status = (
|
||||
self.store.complete_claim_with_evidence_and_event(
|
||||
task_id,
|
||||
claim_id,
|
||||
status
|
||||
if status
|
||||
in {"COMPLETED", "FAILED_FINAL", "FAILED_RECOVERABLE", "CANCELLED"}
|
||||
else "COMPLETED"
|
||||
else "COMPLETED",
|
||||
result.to_dict(),
|
||||
evidence_entries,
|
||||
)
|
||||
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
|
||||
return result.to_dict()
|
||||
|
||||
def fail(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
claim_id = body.get("claim_id")
|
||||
@@ -653,38 +346,14 @@ class TaskPlaneService:
|
||||
)
|
||||
return updated.to_dict()
|
||||
|
||||
def delegate(
|
||||
self,
|
||||
task_id: str,
|
||||
body: dict[str, Any],
|
||||
idempotency_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
actor = body.get("actor_identity") or "owner"
|
||||
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)
|
||||
parent = self.store.get_task(task_id)
|
||||
self.policy.assert_allowed("delegate", actor, task=parent)
|
||||
if "task" in body:
|
||||
plan_body = body["task"]
|
||||
try:
|
||||
child_plan = TaskPlan.from_dict(plan_body)
|
||||
child_plan = TaskPlan.from_dict(body["task"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("invalid child task plan") from exc
|
||||
else:
|
||||
@@ -695,67 +364,25 @@ class TaskPlaneService:
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("invalid child task plan") from exc
|
||||
child_plan.parent_task_id = task_id
|
||||
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)
|
||||
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=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
|
||||
)
|
||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||
self.store.get_task(task_id)
|
||||
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=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
|
||||
)
|
||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||
self.store.get_task(task_id)
|
||||
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=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
|
||||
)
|
||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||
self.store.get_task(task_id)
|
||||
return self.store.list_children(task_id)
|
||||
|
||||
|
||||
@@ -847,10 +474,6 @@ 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)
|
||||
@@ -876,13 +499,6 @@ 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 (
|
||||
@@ -890,11 +506,7 @@ 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"),
|
||||
actor_identity=identity,
|
||||
)
|
||||
return 201, self.service.create_task(body or {}, headers.get("idempotency-key"))
|
||||
if body.get("actor_identity") not in {None, identity}:
|
||||
raise ForbiddenError("actor identity mismatch")
|
||||
body["actor_identity"] = identity
|
||||
@@ -911,9 +523,7 @@ 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 {}, headers.get("idempotency-key")
|
||||
)
|
||||
return 200, self.service.delegate(parts[1], body or {})
|
||||
|
||||
if len(parts) == 2 and parts[0] == "workers" and parts[1] == "register":
|
||||
if (
|
||||
@@ -940,29 +550,8 @@ def run_server(
|
||||
*,
|
||||
authenticator: Callable[[str], str | None] | None = None,
|
||||
):
|
||||
# A separate handler class prevents one server/tenant from overwriting
|
||||
# another concurrently running server's service or authenticator.
|
||||
handler = type("BoundTaskPlaneRequestHandler", (TaskPlaneRequestHandler,), {
|
||||
"service": service, "authenticator": authenticator,
|
||||
})
|
||||
handler = TaskPlaneRequestHandler
|
||||
handler.service = service
|
||||
handler.authenticator = authenticator
|
||||
server = ThreadingHTTPServer((host, port), handler)
|
||||
runtime = service.c6_runtime
|
||||
stop = __import__("threading").Event()
|
||||
runner = None
|
||||
if runtime is not None and runtime.bindings.enabled:
|
||||
import threading
|
||||
def run_ticks():
|
||||
while not stop.is_set():
|
||||
runtime.tick()
|
||||
stop.wait(1.0)
|
||||
runner = threading.Thread(target=run_ticks, name="c6-host-tick", daemon=True)
|
||||
runner.start()
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
stop.set()
|
||||
if runtime is not None:
|
||||
runtime.close()
|
||||
if runner is not None:
|
||||
runner.join(timeout=2.0)
|
||||
server.server_close()
|
||||
server.serve_forever()
|
||||
|
||||
@@ -1,666 +0,0 @@
|
||||
"""C6 ARO Phase A: bounded read-only audit evaluation and immutable reports."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from .operation_view import DomainStamp, validate_digest
|
||||
|
||||
|
||||
class Dimension(str, Enum):
|
||||
FUNCTION = "function"
|
||||
PERFORMANCE = "performance"
|
||||
EXCEPTION = "exception"
|
||||
USAGE = "usage"
|
||||
CODE_DEFECT = "code_defect"
|
||||
HISTORY = "history"
|
||||
DEADLOCK = "deadlock"
|
||||
LONG_RUNNING = "long_running"
|
||||
|
||||
|
||||
_DIMENSIONS = tuple(Dimension)
|
||||
_SAFE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,255}$")
|
||||
_METRICS: dict[Dimension, frozenset[str]] = {
|
||||
Dimension.FUNCTION: frozenset({"required_checks", "passed_checks"}),
|
||||
Dimension.PERFORMANCE: frozenset({"duration_p95_ms"}),
|
||||
Dimension.EXCEPTION: frozenset({"unhandled_exception_count"}),
|
||||
Dimension.USAGE: frozenset({"operational_prompt_count", "manual_continue_count"}),
|
||||
Dimension.CODE_DEFECT: frozenset({"validated_open_defect_count"}),
|
||||
Dimension.HISTORY: frozenset({"same_fingerprint_failure_count"}),
|
||||
Dimension.DEADLOCK: frozenset({"wait_cycle_count", "oldest_no_progress_seconds"}),
|
||||
Dimension.LONG_RUNNING: frozenset({"oldest_nonterminal_age_seconds"}),
|
||||
}
|
||||
_COUNT_METRICS = frozenset(
|
||||
{
|
||||
"required_checks",
|
||||
"passed_checks",
|
||||
"unhandled_exception_count",
|
||||
"operational_prompt_count",
|
||||
"manual_continue_count",
|
||||
"validated_open_defect_count",
|
||||
"same_fingerprint_failure_count",
|
||||
"wait_cycle_count",
|
||||
}
|
||||
)
|
||||
_THRESHOLDS = frozenset(
|
||||
{
|
||||
"max_duration_p95_ms",
|
||||
"repeated_failure_threshold",
|
||||
"deadlock_seconds",
|
||||
"max_nonterminal_age_seconds",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _text(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{name} must be a canonical non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_ref(value: object) -> str:
|
||||
ref = _text(value, "evidence_ref")
|
||||
lowered = ref.casefold()
|
||||
if (
|
||||
_SAFE_REF_RE.fullmatch(ref) is None
|
||||
or ".." in ref
|
||||
or ref.startswith(("/", "\\"))
|
||||
or "://" in ref
|
||||
or any(
|
||||
marker in lowered
|
||||
for marker in (
|
||||
"token=",
|
||||
"secret=",
|
||||
"password=",
|
||||
"cookie=",
|
||||
"authorization=",
|
||||
"private_key=",
|
||||
"bearer ",
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError("evidence_ref is not an allowed opaque reference")
|
||||
return ref
|
||||
|
||||
|
||||
def _aware(value: object, name: str) -> datetime:
|
||||
if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError(f"{name} must be timezone-aware")
|
||||
return value
|
||||
|
||||
|
||||
def _number(value: object, name: str, *, non_negative: bool = True) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{name} must be numeric")
|
||||
result = float(value)
|
||||
if not math.isfinite(result) or (non_negative and result < 0):
|
||||
raise ValueError(f"{name} must be finite and non-negative")
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Sample:
|
||||
metric: str
|
||||
value: float
|
||||
observed_at: datetime
|
||||
evidence_ref: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.metric, "metric")
|
||||
_number(self.value, "sample value")
|
||||
_aware(self.observed_at, "observed_at")
|
||||
_safe_ref(self.evidence_ref)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DimensionInput:
|
||||
dimension: Dimension
|
||||
samples: tuple[Sample, ...]
|
||||
stamp: DomainStamp
|
||||
captured_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.dimension, Dimension):
|
||||
raise ValueError("dimension must be Dimension")
|
||||
if not isinstance(self.samples, tuple):
|
||||
raise ValueError("samples must be a tuple")
|
||||
if any(not isinstance(sample, Sample) for sample in self.samples):
|
||||
raise ValueError("samples must contain Sample values")
|
||||
if not isinstance(self.stamp, DomainStamp):
|
||||
raise ValueError("stamp must be DomainStamp")
|
||||
captured = _aware(self.captured_at, "captured_at")
|
||||
expires = _aware(self.expires_at, "expires_at")
|
||||
if expires <= captured:
|
||||
raise ValueError("expires_at must be after captured_at")
|
||||
if any(sample.observed_at > captured for sample in self.samples):
|
||||
raise ValueError("sample cannot be observed after capture")
|
||||
|
||||
|
||||
def _canonical_datetime(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _snapshot_payload(
|
||||
*,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
dimensions: tuple[DimensionInput, ...],
|
||||
rules_revision: str,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"window_start": _canonical_datetime(window_start),
|
||||
"window_end": _canonical_datetime(window_end),
|
||||
"rules_revision": rules_revision,
|
||||
"dimensions": [
|
||||
{
|
||||
"dimension": item.dimension.value,
|
||||
"stamp": {
|
||||
"domain": item.stamp.domain,
|
||||
"object_id": item.stamp.object_id,
|
||||
"generation": item.stamp.generation,
|
||||
"revision": item.stamp.revision,
|
||||
"event_id": item.stamp.event_id,
|
||||
},
|
||||
"captured_at": _canonical_datetime(item.captured_at),
|
||||
"expires_at": _canonical_datetime(item.expires_at),
|
||||
"samples": [
|
||||
{
|
||||
"metric": sample.metric,
|
||||
"value": sample.value,
|
||||
"observed_at": _canonical_datetime(sample.observed_at),
|
||||
"evidence_ref": sample.evidence_ref,
|
||||
}
|
||||
for sample in sorted(
|
||||
item.samples,
|
||||
key=lambda value: (
|
||||
value.metric,
|
||||
value.observed_at,
|
||||
value.evidence_ref,
|
||||
),
|
||||
)
|
||||
],
|
||||
}
|
||||
for item in sorted(
|
||||
dimensions,
|
||||
key=lambda value: (
|
||||
value.dimension.value,
|
||||
value.stamp.domain,
|
||||
value.stamp.object_id,
|
||||
value.stamp.generation,
|
||||
value.stamp.revision,
|
||||
value.stamp.event_id,
|
||||
),
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _digest(value: Mapping[str, object]) -> str:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditSnapshot:
|
||||
snapshot_id: str
|
||||
window_start: datetime
|
||||
window_end: datetime
|
||||
dimensions: tuple[DimensionInput, ...]
|
||||
rules_revision: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.snapshot_id)
|
||||
start = _aware(self.window_start, "window_start")
|
||||
end = _aware(self.window_end, "window_end")
|
||||
if end <= start:
|
||||
raise ValueError("audit window must be increasing")
|
||||
if not isinstance(self.dimensions, tuple):
|
||||
raise ValueError("dimensions must be a tuple")
|
||||
if any(not isinstance(item, DimensionInput) for item in self.dimensions):
|
||||
raise ValueError("dimensions must contain DimensionInput values")
|
||||
_text(self.rules_revision, "rules_revision")
|
||||
expected = _digest(
|
||||
_snapshot_payload(
|
||||
window_start=start,
|
||||
window_end=end,
|
||||
dimensions=self.dimensions,
|
||||
rules_revision=self.rules_revision,
|
||||
)
|
||||
)
|
||||
if self.snapshot_id != expected:
|
||||
raise ValueError("snapshot_id does not match complete canonical inputs")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
dimensions: tuple[DimensionInput, ...],
|
||||
rules_revision: str,
|
||||
) -> "AuditSnapshot":
|
||||
payload = _snapshot_payload(
|
||||
window_start=_aware(window_start, "window_start"),
|
||||
window_end=_aware(window_end, "window_end"),
|
||||
dimensions=dimensions,
|
||||
rules_revision=_text(rules_revision, "rules_revision"),
|
||||
)
|
||||
return cls(
|
||||
snapshot_id=_digest(payload),
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
dimensions=dimensions,
|
||||
rules_revision=rules_revision,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuleLimits:
|
||||
rules_revision: str
|
||||
max_snapshot_skew_seconds: int
|
||||
threshold_by_metric: tuple[tuple[str, float], ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.rules_revision, "rules_revision")
|
||||
if (
|
||||
isinstance(self.max_snapshot_skew_seconds, bool)
|
||||
or not isinstance(self.max_snapshot_skew_seconds, int)
|
||||
or self.max_snapshot_skew_seconds < 0
|
||||
):
|
||||
raise ValueError("max_snapshot_skew_seconds must be non-negative int")
|
||||
if not isinstance(self.threshold_by_metric, tuple):
|
||||
raise ValueError("threshold_by_metric must be a tuple")
|
||||
names: list[str] = []
|
||||
for item in self.threshold_by_metric:
|
||||
if not isinstance(item, tuple) or len(item) != 2:
|
||||
raise ValueError("threshold entries must be pairs")
|
||||
name = _text(item[0], "threshold metric")
|
||||
if name not in _THRESHOLDS:
|
||||
raise ValueError("unknown threshold metric")
|
||||
_number(item[1], "threshold")
|
||||
names.append(name)
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("duplicate threshold metric")
|
||||
|
||||
@property
|
||||
def thresholds(self) -> dict[str, float]:
|
||||
return {name: float(value) for name, value in self.threshold_by_metric}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Finding:
|
||||
dimension: Dimension
|
||||
status: Literal["CLEAR", "FINDING", "UNKNOWN"]
|
||||
reason: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.dimension, Dimension):
|
||||
raise ValueError("dimension must be Dimension")
|
||||
if self.status not in ("CLEAR", "FINDING", "UNKNOWN"):
|
||||
raise ValueError("invalid finding status")
|
||||
_text(self.reason, "reason")
|
||||
if not isinstance(self.evidence_refs, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for ref in self.evidence_refs:
|
||||
_safe_ref(ref)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditReport:
|
||||
report_id: str
|
||||
snapshot_id: str
|
||||
rules_revision: str
|
||||
findings: tuple[Finding, ...]
|
||||
mutation_count: Literal[0]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.report_id)
|
||||
validate_digest(self.snapshot_id)
|
||||
_text(self.rules_revision, "rules_revision")
|
||||
if not isinstance(self.findings, tuple) or any(
|
||||
not isinstance(item, Finding) for item in self.findings
|
||||
):
|
||||
raise ValueError("findings must be a tuple of Finding")
|
||||
if type(self.mutation_count) is not int or self.mutation_count != 0:
|
||||
raise ValueError("ARO Phase A mutation_count must be exactly zero")
|
||||
|
||||
|
||||
def _unknown(dimension: Dimension, reason: str, refs: tuple[str, ...] = ()) -> Finding:
|
||||
return Finding(dimension, "UNKNOWN", reason, refs)
|
||||
|
||||
|
||||
def _metric_values(item: DimensionInput) -> tuple[dict[str, float] | None, tuple[str, ...]]:
|
||||
expected = _METRICS[item.dimension]
|
||||
if not item.samples:
|
||||
return None, ()
|
||||
names = [sample.metric for sample in item.samples]
|
||||
refs = tuple(dict.fromkeys(sample.evidence_ref for sample in item.samples))
|
||||
if set(names) != expected or len(names) != len(set(names)):
|
||||
return None, refs
|
||||
values = {sample.metric: float(sample.value) for sample in item.samples}
|
||||
for name in _COUNT_METRICS & expected:
|
||||
if not values[name].is_integer():
|
||||
return None, refs
|
||||
return values, refs
|
||||
|
||||
|
||||
def _evaluate_dimension(
|
||||
item: DimensionInput, thresholds: Mapping[str, float]
|
||||
) -> Finding:
|
||||
values, refs = _metric_values(item)
|
||||
if values is None:
|
||||
return _unknown(item.dimension, "METRICS_MISSING_DUPLICATE_OR_UNKNOWN", refs)
|
||||
dimension = item.dimension
|
||||
if dimension == Dimension.FUNCTION:
|
||||
required = values["required_checks"]
|
||||
passed = values["passed_checks"]
|
||||
if required <= 0 or passed > required:
|
||||
return _unknown(dimension, "FUNCTION_COUNTS_INVALID", refs)
|
||||
status = "FINDING" if passed < required else "CLEAR"
|
||||
return Finding(dimension, status, "REQUIRED_CHECKS_INCOMPLETE" if status == "FINDING" else "ALL_REQUIRED_CHECKS_PASSED", refs)
|
||||
if dimension == Dimension.PERFORMANCE:
|
||||
threshold = thresholds.get("max_duration_p95_ms")
|
||||
if threshold is None:
|
||||
return _unknown(dimension, "THRESHOLD_MISSING", refs)
|
||||
status = "FINDING" if values["duration_p95_ms"] > threshold else "CLEAR"
|
||||
return Finding(dimension, status, "P95_DURATION_EXCEEDED" if status == "FINDING" else "P95_DURATION_WITHIN_LIMIT", refs)
|
||||
if dimension == Dimension.EXCEPTION:
|
||||
status = "FINDING" if values["unhandled_exception_count"] > 0 else "CLEAR"
|
||||
return Finding(dimension, status, "UNHANDLED_EXCEPTIONS_PRESENT" if status == "FINDING" else "NO_UNHANDLED_EXCEPTIONS", refs)
|
||||
if dimension == Dimension.USAGE:
|
||||
status = "FINDING" if any(value > 0 for value in values.values()) else "CLEAR"
|
||||
return Finding(dimension, status, "OPERATIONAL_PROMPT_OR_CONTINUE_PRESENT" if status == "FINDING" else "NO_OPERATIONAL_PROMPT_FRICTION", refs)
|
||||
if dimension == Dimension.CODE_DEFECT:
|
||||
status = "FINDING" if values["validated_open_defect_count"] > 0 else "CLEAR"
|
||||
return Finding(dimension, status, "VALIDATED_OPEN_DEFECTS_PRESENT" if status == "FINDING" else "NO_VALIDATED_OPEN_DEFECTS", refs)
|
||||
if dimension == Dimension.HISTORY:
|
||||
threshold = thresholds.get("repeated_failure_threshold")
|
||||
if threshold is None or threshold <= 0 or not threshold.is_integer():
|
||||
return _unknown(dimension, "THRESHOLD_MISSING_OR_INVALID", refs)
|
||||
status = "FINDING" if values["same_fingerprint_failure_count"] >= threshold else "CLEAR"
|
||||
return Finding(dimension, status, "FAILURE_FINGERPRINT_REPEATED" if status == "FINDING" else "FAILURE_FINGERPRINT_BELOW_LIMIT", refs)
|
||||
if dimension == Dimension.DEADLOCK:
|
||||
threshold = thresholds.get("deadlock_seconds")
|
||||
if threshold is None:
|
||||
return _unknown(dimension, "THRESHOLD_MISSING", refs)
|
||||
status = "FINDING" if (
|
||||
values["wait_cycle_count"] > 0
|
||||
and values["oldest_no_progress_seconds"] > threshold
|
||||
) else "CLEAR"
|
||||
return Finding(dimension, status, "WAIT_CYCLE_WITHOUT_PROGRESS" if status == "FINDING" else "NO_STALE_WAIT_CYCLE", refs)
|
||||
if dimension == Dimension.LONG_RUNNING:
|
||||
threshold = thresholds.get("max_nonterminal_age_seconds")
|
||||
if threshold is None:
|
||||
return _unknown(dimension, "THRESHOLD_MISSING", refs)
|
||||
status = "FINDING" if values["oldest_nonterminal_age_seconds"] > threshold else "CLEAR"
|
||||
return Finding(dimension, status, "NONTERMINAL_AGE_EXCEEDED" if status == "FINDING" else "NONTERMINAL_AGE_WITHIN_LIMIT", refs)
|
||||
raise AssertionError("unhandled dimension")
|
||||
|
||||
|
||||
def evaluate_snapshot(
|
||||
snapshot: AuditSnapshot, *, limits: RuleLimits, now: datetime
|
||||
) -> AuditReport:
|
||||
"""Evaluate all eight dimensions; missing or inconsistent evidence is UNKNOWN."""
|
||||
if not isinstance(snapshot, AuditSnapshot) or not isinstance(limits, RuleLimits):
|
||||
raise TypeError("snapshot and limits must be typed values")
|
||||
now = _aware(now, "now")
|
||||
grouped: dict[Dimension, list[DimensionInput]] = {dimension: [] for dimension in _DIMENSIONS}
|
||||
for item in snapshot.dimensions:
|
||||
grouped[item.dimension].append(item)
|
||||
|
||||
global_reason: str | None = None
|
||||
if snapshot.rules_revision != limits.rules_revision:
|
||||
global_reason = "RULES_REVISION_UNKNOWN"
|
||||
elif snapshot.window_end > now:
|
||||
global_reason = "AUDIT_WINDOW_NOT_CLOSED"
|
||||
else:
|
||||
observed = [sample.observed_at for item in snapshot.dimensions for sample in item.samples]
|
||||
if observed and (max(observed) - min(observed)).total_seconds() > limits.max_snapshot_skew_seconds:
|
||||
global_reason = "CROSS_DOMAIN_SNAPSHOT_SKEW"
|
||||
|
||||
findings: list[Finding] = []
|
||||
for dimension in _DIMENSIONS:
|
||||
items = grouped[dimension]
|
||||
if global_reason is not None:
|
||||
refs = tuple(
|
||||
dict.fromkeys(sample.evidence_ref for item in items for sample in item.samples)
|
||||
)
|
||||
findings.append(_unknown(dimension, global_reason, refs))
|
||||
continue
|
||||
if len(items) != 1:
|
||||
findings.append(
|
||||
_unknown(
|
||||
dimension,
|
||||
"DIMENSION_MISSING" if not items else "DIMENSION_CONFLICT",
|
||||
)
|
||||
)
|
||||
continue
|
||||
item = items[0]
|
||||
refs = tuple(dict.fromkeys(sample.evidence_ref for sample in item.samples))
|
||||
if item.captured_at > now or now >= item.expires_at:
|
||||
findings.append(_unknown(dimension, "DIMENSION_STALE_OR_FUTURE", refs))
|
||||
continue
|
||||
if any(
|
||||
sample.observed_at < snapshot.window_start
|
||||
or sample.observed_at > snapshot.window_end
|
||||
for sample in item.samples
|
||||
):
|
||||
findings.append(_unknown(dimension, "SAMPLE_OUTSIDE_WINDOW", refs))
|
||||
continue
|
||||
findings.append(_evaluate_dimension(item, limits.thresholds))
|
||||
|
||||
report_payload = {
|
||||
"window_start": _canonical_datetime(snapshot.window_start),
|
||||
"window_end": _canonical_datetime(snapshot.window_end),
|
||||
"snapshot_id": snapshot.snapshot_id,
|
||||
"rules_revision": limits.rules_revision,
|
||||
}
|
||||
return AuditReport(
|
||||
report_id=_digest(report_payload),
|
||||
snapshot_id=snapshot.snapshot_id,
|
||||
rules_revision=limits.rules_revision,
|
||||
findings=tuple(findings),
|
||||
mutation_count=0,
|
||||
)
|
||||
|
||||
|
||||
class ReadonlyAuditSource(Protocol):
|
||||
def collect(
|
||||
self, *, window_start: datetime, window_end: datetime
|
||||
) -> AuditSnapshot: ...
|
||||
|
||||
|
||||
class AuditReportSink(Protocol):
|
||||
def put_if_absent(self, report: AuditReport) -> str: ...
|
||||
|
||||
|
||||
def _report_bytes(report: AuditReport) -> bytes:
|
||||
payload = {
|
||||
"schema": "aios.aro-readonly-report.v1",
|
||||
"report_id": report.report_id,
|
||||
"snapshot_id": report.snapshot_id,
|
||||
"rules_revision": report.rules_revision,
|
||||
"mutation_count": report.mutation_count,
|
||||
"findings": [
|
||||
{
|
||||
"dimension": finding.dimension.value,
|
||||
"status": finding.status,
|
||||
"reason": finding.reason,
|
||||
"evidence_refs": list(finding.evidence_refs),
|
||||
}
|
||||
for finding in report.findings
|
||||
],
|
||||
}
|
||||
return (
|
||||
json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
class FilesystemAuditReportSink:
|
||||
"""Write one immutable, deterministic report under a host-fixed root."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
if not isinstance(root, Path) or not root.is_absolute() or ".." in root.parts:
|
||||
raise ValueError("audit root must be an absolute fixed path")
|
||||
self.root = root
|
||||
self._prepare_root()
|
||||
|
||||
def _prepare_root(self) -> None:
|
||||
existing = self.root
|
||||
while not existing.exists() and existing != existing.parent:
|
||||
existing = existing.parent
|
||||
cursor = existing
|
||||
while True:
|
||||
if cursor.is_symlink():
|
||||
raise ValueError("audit root ancestry must not contain symlinks")
|
||||
if cursor == cursor.parent:
|
||||
break
|
||||
cursor = cursor.parent
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
if self.root.is_symlink() or not self.root.is_dir():
|
||||
raise ValueError("audit root must be a real directory")
|
||||
if self.root.resolve(strict=True) != self.root.absolute():
|
||||
raise ValueError("audit root must not traverse reparse points or symlinks")
|
||||
|
||||
def put_if_absent(self, report: AuditReport) -> str:
|
||||
if not isinstance(report, AuditReport):
|
||||
raise TypeError("report must be AuditReport")
|
||||
self._prepare_root()
|
||||
validate_digest(report.report_id)
|
||||
body = _report_bytes(report)
|
||||
target = self.root / f"{report.report_id}.json"
|
||||
if target.parent != self.root or target.is_symlink():
|
||||
raise ValueError("REPORT_PATH_INVALID")
|
||||
if target.exists():
|
||||
if not target.is_file() or target.read_bytes() != body:
|
||||
raise ValueError("REPORT_CONFLICT")
|
||||
return f"aro:{report.report_id}"
|
||||
file_descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{report.report_id}.", suffix=".tmp", dir=self.root
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(file_descriptor, "wb") as handle:
|
||||
handle.write(body)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
try:
|
||||
os.link(temporary, target)
|
||||
except FileExistsError:
|
||||
if target.is_symlink() or not target.is_file() or target.read_bytes() != body:
|
||||
raise ValueError("REPORT_CONFLICT")
|
||||
if target.read_bytes() != body:
|
||||
raise ValueError("REPORT_CONFLICT")
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return f"aro:{report.report_id}"
|
||||
|
||||
|
||||
class AroReadonlyTick:
|
||||
"""Default-off in-process tick over the most recently closed UTC window."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: ReadonlyAuditSource,
|
||||
sink: AuditReportSink,
|
||||
limits: RuleLimits,
|
||||
interval_seconds: int,
|
||||
clock: Callable[[], datetime],
|
||||
*,
|
||||
enabled: bool = False,
|
||||
) -> None:
|
||||
if not hasattr(source, "collect") or not callable(source.collect):
|
||||
raise ValueError("source must implement read-only collect")
|
||||
if not hasattr(sink, "put_if_absent") or not callable(sink.put_if_absent):
|
||||
raise ValueError("sink must implement put_if_absent")
|
||||
if not isinstance(limits, RuleLimits):
|
||||
raise ValueError("limits must be RuleLimits")
|
||||
if isinstance(interval_seconds, bool) or not isinstance(interval_seconds, int) or interval_seconds <= 0:
|
||||
raise ValueError("interval_seconds must be positive int")
|
||||
if not callable(clock) or type(enabled) is not bool:
|
||||
raise ValueError("clock must be callable and enabled must be bool")
|
||||
self.source = source
|
||||
self.sink = sink
|
||||
self.limits = limits
|
||||
self.interval_seconds = interval_seconds
|
||||
self.clock = clock
|
||||
self.enabled = enabled
|
||||
self._last_window_end: datetime | None = None
|
||||
|
||||
def run_due(self) -> str | None:
|
||||
if not self.enabled:
|
||||
return None
|
||||
now = _aware(self.clock(), "clock result").astimezone(timezone.utc)
|
||||
end_timestamp = int(now.timestamp()) // self.interval_seconds * self.interval_seconds
|
||||
window_end = datetime.fromtimestamp(end_timestamp, tz=timezone.utc)
|
||||
window_start = window_end - timedelta(seconds=self.interval_seconds)
|
||||
if self._last_window_end == window_end:
|
||||
return None
|
||||
try:
|
||||
snapshot = self.source.collect(
|
||||
window_start=window_start, window_end=window_end
|
||||
)
|
||||
if (
|
||||
not isinstance(snapshot, AuditSnapshot)
|
||||
or snapshot.window_start != window_start
|
||||
or snapshot.window_end != window_end
|
||||
):
|
||||
raise ValueError("source returned a snapshot for the wrong window")
|
||||
except Exception:
|
||||
snapshot = AuditSnapshot.create(
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
dimensions=(),
|
||||
rules_revision=self.limits.rules_revision,
|
||||
)
|
||||
report = evaluate_snapshot(snapshot, limits=self.limits, now=now)
|
||||
try:
|
||||
reference = self.sink.put_if_absent(report)
|
||||
except Exception:
|
||||
return None
|
||||
self._last_window_end = window_end
|
||||
return reference
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AroReadonlyTick",
|
||||
"AuditReport",
|
||||
"AuditReportSink",
|
||||
"AuditSnapshot",
|
||||
"Dimension",
|
||||
"DimensionInput",
|
||||
"FilesystemAuditReportSink",
|
||||
"Finding",
|
||||
"ReadonlyAuditSource",
|
||||
"RuleLimits",
|
||||
"Sample",
|
||||
"evaluate_snapshot",
|
||||
]
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Narrow production seam from a validated C6 plan to existing acquisition."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import math
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
from .authorization_planner import (
|
||||
AuthorizationPlan,
|
||||
AuthorizationPlanInput,
|
||||
CapabilityNamespace,
|
||||
GitManifestRegistry,
|
||||
RuntimeObservation,
|
||||
StaticCapabilityManifest,
|
||||
VerifiedRootSnapshot,
|
||||
VerifiedRootSnapshotReader,
|
||||
authorization_input_digest,
|
||||
authorization_scope_digest,
|
||||
build_authorization_plan,
|
||||
verified_root_snapshot_digest,
|
||||
)
|
||||
from .governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
EpochStatus,
|
||||
GovernedWriterAdapters,
|
||||
GovernedWriterAutomation,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
GovernedWriteReadiness,
|
||||
RecoveryAuthorization,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeObservationReader(Protocol):
|
||||
"""Host-owned fresh runtime authority used at writer acquisition."""
|
||||
|
||||
def read_runtime_observation(
|
||||
self,
|
||||
*,
|
||||
plan_input: AuthorizationPlanInput,
|
||||
manifest: StaticCapabilityManifest,
|
||||
root: VerifiedRootSnapshot | None,
|
||||
now: float,
|
||||
) -> RuntimeObservation | None: ...
|
||||
|
||||
|
||||
def acquire_authorized_writer(
|
||||
*,
|
||||
automation: GovernedWriterAutomation,
|
||||
request: GovernedWriterRequest,
|
||||
authorization_ref: str,
|
||||
readiness: GovernedWriteReadiness,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
plan: AuthorizationPlan,
|
||||
plan_input: AuthorizationPlanInput,
|
||||
root_reader: VerifiedRootSnapshotReader,
|
||||
runtime_reader: RuntimeObservationReader,
|
||||
registry: GitManifestRegistry,
|
||||
expected_registry_oid: str,
|
||||
expected_manifest_sha256: str,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
) -> GovernedWriterSession:
|
||||
"""Re-read existing verified Root immediately before `acquire_verified`.
|
||||
|
||||
This function deliberately has no signer, claim, lease, or direct
|
||||
`automation.acquire` path. The existing automation remains the sole
|
||||
composition root for effects.
|
||||
"""
|
||||
supplied_now = time.time() if now is None else now
|
||||
if isinstance(supplied_now, bool):
|
||||
raise AuthorizationDenied("TIME_INVALID")
|
||||
try:
|
||||
current = float(supplied_now)
|
||||
except (TypeError, ValueError):
|
||||
raise AuthorizationDenied("TIME_INVALID") from None
|
||||
if not math.isfinite(current):
|
||||
raise AuthorizationDenied("TIME_INVALID")
|
||||
input_digest = authorization_input_digest(plan_input)
|
||||
scope_digest = authorization_scope_digest(plan_input.scope)
|
||||
if (plan_input.root_approval_ref != authorization_ref
|
||||
or plan.input_digest != input_digest
|
||||
or plan.scope_digest != scope_digest):
|
||||
raise AuthorizationDenied("PLAN_INPUT_DRIFT")
|
||||
if (request.subject != plan_input.actor_subject or request.task_id != plan_input.task_id
|
||||
or request.repository != plan_input.scope.repository
|
||||
or request.target_branch != plan_input.scope.target_branch
|
||||
or request.base_oid != plan_input.scope.source_oid
|
||||
or request.risk != plan_input.scope.risk_level
|
||||
or request.file_allowlist != plan_input.scope.file_allowlist
|
||||
or request.plan_digest != input_digest):
|
||||
raise AuthorizationDenied("WRITER_REQUEST_PLAN_DRIFT")
|
||||
if any(ref.namespace != CapabilityNamespace.AGENT_TASK for ref in plan_input.required_capabilities):
|
||||
raise AuthorizationDenied("WRITER_REQUEST_CAPABILITY_NAMESPACE_INVALID")
|
||||
requested_capabilities = frozenset(ref.token for ref in plan_input.required_capabilities)
|
||||
if request.required_capabilities != requested_capabilities:
|
||||
raise AuthorizationDenied("WRITER_REQUEST_CAPABILITY_DRIFT")
|
||||
# This is a second, explicit frozen-runtime read. The existing
|
||||
# acquire_verified path repeats it before effects as its own invariant.
|
||||
automation.manifest_store.assert_current(
|
||||
manifest_id=request.manifest_id,
|
||||
runtime_fingerprint=request.runtime_fingerprint,
|
||||
process_start_identity=request.process_start_identity,
|
||||
)
|
||||
manifest = registry.read_manifest(
|
||||
expected_registry_oid=expected_registry_oid,
|
||||
expected_manifest_sha256=expected_manifest_sha256,
|
||||
)
|
||||
if manifest.manifest_id != request.manifest_id or manifest.declaration_digest != plan.policy_digest:
|
||||
raise AuthorizationDenied("POLICY_MANIFEST_DRIFT")
|
||||
root = root_reader.read_verified_root(
|
||||
authorization_ref=authorization_ref,
|
||||
root_request_sha256=plan_input.root_request_sha256,
|
||||
now=current,
|
||||
)
|
||||
if root is None or root.status != "VALID" or root.approval_ref != authorization_ref:
|
||||
raise AuthorizationDenied("ROOT_REVOKED_OR_UNAVAILABLE")
|
||||
if (root.root_request_sha256 != plan_input.root_request_sha256
|
||||
or root.subject != plan_input.actor_subject
|
||||
or root.audience != plan_input.audience
|
||||
or root.mission_id != plan_input.mission_id
|
||||
or root.scope != plan_input.scope):
|
||||
raise AuthorizationDenied("ROOT_SCOPE_DRIFT")
|
||||
if verified_root_snapshot_digest(root) != plan.root_snapshot_digest:
|
||||
raise AuthorizationDenied("ROOT_SNAPSHOT_DRIFT")
|
||||
if not (root.not_before <= current and root.observed_at <= current < root.snapshot_expires_at <= root.expires_at):
|
||||
raise AuthorizationDenied("ROOT_EXPIRED")
|
||||
reader = getattr(runtime_reader, "read_runtime_observation", None)
|
||||
if not callable(reader):
|
||||
raise AuthorizationDenied("RUNTIME_OBSERVATION_UNAVAILABLE")
|
||||
try:
|
||||
runtime = reader(
|
||||
plan_input=plan_input,
|
||||
manifest=manifest,
|
||||
root=root,
|
||||
now=current,
|
||||
)
|
||||
except Exception:
|
||||
raise AuthorizationDenied("RUNTIME_OBSERVATION_UNAVAILABLE") from None
|
||||
if not isinstance(runtime, RuntimeObservation):
|
||||
raise AuthorizationDenied("RUNTIME_OBSERVATION_UNAVAILABLE")
|
||||
fresh_input = replace(plan_input, now=current)
|
||||
fresh_plan = build_authorization_plan(
|
||||
request=fresh_input,
|
||||
manifest=manifest,
|
||||
runtime=runtime,
|
||||
root=root,
|
||||
)
|
||||
if fresh_plan.next_action != "EXECUTE_GOVERNED_PLAN":
|
||||
raise AuthorizationDenied(
|
||||
fresh_plan.reason_code or "authorization plan is not executable"
|
||||
)
|
||||
if (
|
||||
plan.policy_digest != fresh_plan.policy_digest
|
||||
or plan.required_authorization != fresh_plan.required_authorization
|
||||
or plan.scope_digest != fresh_plan.scope_digest
|
||||
or plan.root_snapshot_digest != fresh_plan.root_snapshot_digest
|
||||
):
|
||||
raise AuthorizationDenied("AUTHORIZATION_PLAN_DRIFT")
|
||||
# `acquire_verified` repeats the existing Owner authority verification
|
||||
# before its own effect path. Do not substitute a locally reconstructed
|
||||
# policy or call its test-only `acquire` method here.
|
||||
return automation.acquire_verified(
|
||||
request=request,
|
||||
authorization_ref=authorization_ref,
|
||||
readiness=readiness,
|
||||
epoch=epoch,
|
||||
adapters=adapters,
|
||||
recovery_authorization=recovery_authorization,
|
||||
now=current,
|
||||
)
|
||||
@@ -1,325 +0,0 @@
|
||||
"""Read-only C6 authorization planning over existing authority boundaries."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Literal, Mapping, Protocol
|
||||
|
||||
from aios_capability_contract import normalize_required_capabilities
|
||||
from .governed_writer_automation import RISK_ORDER
|
||||
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA = re.compile(r"^[0-9a-f]{64}$")
|
||||
_REGISTRY_PATH = "governance/capabilities/registry.v1.json"
|
||||
|
||||
|
||||
class RegistryReadError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class CapabilityNamespace(str, Enum):
|
||||
AGENT_TASK = "agent-task"
|
||||
TASK_PLANE_WILDCARD = "task-plane-wildcard"
|
||||
|
||||
|
||||
def _text(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _oid(value: object, field: str) -> str:
|
||||
value = _text(value, field)
|
||||
if _OID.fullmatch(value) is None:
|
||||
raise ValueError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _digest(value: object, field: str) -> str:
|
||||
value = _text(value, field)
|
||||
if _SHA.fullmatch(value) is None:
|
||||
raise ValueError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _finite(value: object, field: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (float, int)) or not math.isfinite(float(value)):
|
||||
raise ValueError(f"{field}_INVALID")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode("utf-8")
|
||||
|
||||
|
||||
def authorization_scope_digest(scope: "ScopeSpec") -> str:
|
||||
return hashlib.sha256(_canonical({
|
||||
"repository": scope.repository, "target_branch": scope.target_branch,
|
||||
"source_oid": scope.source_oid, "file_allowlist": list(scope.file_allowlist),
|
||||
"budget_units": scope.budget_units, "risk_level": scope.risk_level,
|
||||
})).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityRef:
|
||||
namespace: CapabilityNamespace
|
||||
token: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.namespace, CapabilityNamespace):
|
||||
raise ValueError("CAPABILITY_NAMESPACE_INVALID")
|
||||
_text(self.token, "capability")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScopeSpec:
|
||||
repository: str
|
||||
target_branch: str
|
||||
source_oid: str
|
||||
file_allowlist: tuple[str, ...]
|
||||
budget_units: int
|
||||
risk_level: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.repository, "repository"); _text(self.target_branch, "target_branch")
|
||||
_oid(self.source_oid, "source_oid"); _text(self.risk_level, "risk_level")
|
||||
if type(self.budget_units) is not int or self.budget_units < 0:
|
||||
raise ValueError("budget_units_INVALID")
|
||||
if not self.file_allowlist or len(set(self.file_allowlist)) != len(self.file_allowlist):
|
||||
raise ValueError("file_allowlist_INVALID")
|
||||
for path in self.file_allowlist:
|
||||
if not isinstance(path, str) or not path or path != PurePosixPath(path).as_posix() or PurePosixPath(path).is_absolute() or ".." in PurePosixPath(path).parts:
|
||||
raise ValueError("file_allowlist_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeObservation:
|
||||
observation_id: str; observed_at: float; expires_at: float; source_revision: str
|
||||
actor_subject: str; audience: str; operation_id: str; scope_digest: str
|
||||
manifest_revision: str; manifest_digest: str; executor_installed: bool | None
|
||||
executor_callable: bool | None; authorization_live: bool | None
|
||||
preconditions_pass: bool | None; revocation_epoch: int | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("observation_id", "source_revision", "actor_subject", "audience", "operation_id", "manifest_revision"):
|
||||
_text(getattr(self, name), name)
|
||||
_finite(self.observed_at, "observed_at"); _finite(self.expires_at, "expires_at")
|
||||
if self.expires_at <= self.observed_at: raise ValueError("OBSERVATION_EXPIRY_INVALID")
|
||||
_digest(self.scope_digest, "scope_digest"); _digest(self.manifest_digest, "manifest_digest")
|
||||
for name in ("executor_installed", "executor_callable", "authorization_live", "preconditions_pass"):
|
||||
if getattr(self, name) is not None and type(getattr(self, name)) is not bool: raise ValueError(f"{name}_INVALID")
|
||||
if self.revocation_epoch is not None and (type(self.revocation_epoch) is not int or self.revocation_epoch < 0): raise ValueError("revocation_epoch_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedRootSnapshot:
|
||||
approval_ref: str; root_request_sha256: str; subject: str; audience: str; mission_id: str
|
||||
scope: ScopeSpec; capabilities: tuple[CapabilityRef, ...]; nonce: str; not_before: float
|
||||
expires_at: float; revocation_epoch: int; receipt_ref: str; observed_at: float
|
||||
snapshot_expires_at: float; status: Literal["VALID", "REVOKED"]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("approval_ref", "subject", "audience", "mission_id", "nonce", "receipt_ref"):
|
||||
_text(getattr(self, name), name)
|
||||
_digest(self.root_request_sha256, "root_request_sha256")
|
||||
for name in ("not_before", "expires_at", "observed_at", "snapshot_expires_at"): _finite(getattr(self, name), name)
|
||||
if not self.not_before <= self.snapshot_expires_at <= self.expires_at: raise ValueError("ROOT_TIME_INVALID")
|
||||
if type(self.revocation_epoch) is not int or self.revocation_epoch < 0: raise ValueError("revocation_epoch_INVALID")
|
||||
if self.status not in ("VALID", "REVOKED"): raise ValueError("ROOT_STATUS_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaticCapabilityManifest:
|
||||
manifest_id: str; revision: str; capabilities: tuple[CapabilityRef, ...]
|
||||
executor_refs: tuple[str, ...]; source_oid: str; declaration_digest: str
|
||||
allowed_scopes: tuple[ScopeSpec, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.manifest_id, "manifest_id"); _text(self.revision, "revision")
|
||||
_oid(self.source_oid, "source_oid"); _digest(self.declaration_digest, "declaration_digest")
|
||||
if len(set(self.capabilities)) != len(self.capabilities): raise ValueError("CAPABILITY_DUPLICATE")
|
||||
for ref in self.executor_refs: _text(ref, "executor_ref")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationPlanInput:
|
||||
operation_id: str; actor_subject: str; audience: str; mission_id: str; task_id: str
|
||||
scope: ScopeSpec; required_capabilities: tuple[CapabilityRef, ...]
|
||||
root_approval_ref: str; root_request_sha256: str; now: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("operation_id", "actor_subject", "audience", "mission_id", "task_id", "root_approval_ref"):
|
||||
_text(getattr(self, name), name)
|
||||
_digest(self.root_request_sha256, "root_request_sha256"); _finite(self.now, "now")
|
||||
if not self.required_capabilities: raise ValueError("REQUIRED_CAPABILITIES_EMPTY")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationPlan:
|
||||
input_digest: str; scope_digest: str; policy_digest: str; required_authorization: str
|
||||
root_snapshot_digest: str | None
|
||||
reason_code: str | None; next_action: Literal["EXECUTE_GOVERNED_PLAN", "WAITING_ROOT_AUTHORIZATION", "AUTHORIZATION_REQUIRED", "CAPABILITY_UNAVAILABLE", "PRECONDITION_FAILED", "UNKNOWN"]
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
|
||||
class GitManifestRegistry(Protocol):
|
||||
def read_manifest(self, *, expected_registry_oid: str, expected_manifest_sha256: str) -> StaticCapabilityManifest: ...
|
||||
def read_supersession(self, *, manifest_id: str, revision: str) -> str | None: ...
|
||||
|
||||
|
||||
class VerifiedRootSnapshotReader(Protocol):
|
||||
def read_verified_root(self, *, authorization_ref: str, root_request_sha256: str, now: float) -> VerifiedRootSnapshot | None: ...
|
||||
|
||||
|
||||
def verified_root_snapshot_digest(root: VerifiedRootSnapshot | None) -> str | None:
|
||||
return None if root is None else hashlib.sha256(_canonical(asdict(root))).hexdigest()
|
||||
|
||||
|
||||
def authorization_input_digest(request: AuthorizationPlanInput) -> str:
|
||||
return hashlib.sha256(_canonical(asdict(request))).hexdigest()
|
||||
|
||||
|
||||
def _plan(request: AuthorizationPlanInput, manifest: StaticCapabilityManifest, action: str, reason: str | None, refs: tuple[str, ...], root: VerifiedRootSnapshot | None) -> AuthorizationPlan:
|
||||
return AuthorizationPlan(
|
||||
input_digest=authorization_input_digest(request), scope_digest=authorization_scope_digest(request.scope),
|
||||
policy_digest=manifest.declaration_digest, required_authorization=request.root_approval_ref,
|
||||
root_snapshot_digest=verified_root_snapshot_digest(root),
|
||||
reason_code=reason, next_action=action, evidence_refs=refs,
|
||||
)
|
||||
|
||||
|
||||
def _capabilities_cover(granted: tuple[CapabilityRef, ...], required: tuple[CapabilityRef, ...]) -> tuple[bool, str | None]:
|
||||
for capability in required:
|
||||
if capability.namespace == CapabilityNamespace.AGENT_TASK:
|
||||
if "*" in capability.token:
|
||||
return False, "CAPABILITY_NAMESPACE_MISMATCH"
|
||||
normalized = normalize_required_capabilities([capability.token])
|
||||
if any(value.startswith("exact:") for value in normalized): return False, "CAPABILITY_UNKNOWN"
|
||||
same_namespace = [item.token for item in granted if item.namespace == CapabilityNamespace.AGENT_TASK]
|
||||
if not same_namespace and granted:
|
||||
return False, "CAPABILITY_NAMESPACE_MISMATCH"
|
||||
effective_grants = normalize_required_capabilities(same_namespace)
|
||||
if any(value.startswith("exact:") for value in effective_grants):
|
||||
effective_grants = frozenset(value for value in effective_grants if not value.startswith("exact:"))
|
||||
if not normalized.issubset(effective_grants): return False, "CAPABILITY_UNAVAILABLE"
|
||||
elif capability.namespace == CapabilityNamespace.TASK_PLANE_WILDCARD:
|
||||
if not any(item.namespace == capability.namespace and item.token == capability.token for item in granted): return False, "CAPABILITY_UNAVAILABLE"
|
||||
return True, None
|
||||
|
||||
|
||||
def _scope_covers(parent: ScopeSpec, child: ScopeSpec) -> bool:
|
||||
# Use the same risk vocabulary as the authority that will issue the lease.
|
||||
rank = RISK_ORDER
|
||||
return (parent.repository == child.repository and parent.target_branch == child.target_branch and parent.source_oid == child.source_oid
|
||||
and set(child.file_allowlist).issubset(parent.file_allowlist) and child.budget_units <= parent.budget_units
|
||||
and rank.get(child.risk_level.lower()) is not None
|
||||
and rank.get(parent.risk_level.lower(), 0) >= rank[child.risk_level.lower()])
|
||||
|
||||
|
||||
def build_authorization_plan(*, request: AuthorizationPlanInput, manifest: StaticCapabilityManifest, runtime: RuntimeObservation, root: VerifiedRootSnapshot | None) -> AuthorizationPlan:
|
||||
refs = () if root is None else (root.receipt_ref,)
|
||||
if manifest.source_oid != request.scope.source_oid or not any(_scope_covers(scope, request.scope) for scope in manifest.allowed_scopes):
|
||||
return _plan(request, manifest, "UNKNOWN", "REGISTRY_DRIFT", refs, root)
|
||||
allowed, reason = _capabilities_cover(manifest.capabilities, request.required_capabilities)
|
||||
if not allowed: return _plan(request, manifest, "CAPABILITY_UNAVAILABLE", reason, refs, root)
|
||||
if root is None: return _plan(request, manifest, "WAITING_ROOT_AUTHORIZATION", "ROOT_MISSING", refs, root)
|
||||
if root.status == "REVOKED": return _plan(request, manifest, "AUTHORIZATION_REQUIRED", "ROOT_REVOKED", refs, root)
|
||||
if root.approval_ref != request.root_approval_ref or root.root_request_sha256 != request.root_request_sha256: return _plan(request, manifest, "AUTHORIZATION_REQUIRED", "ROOT_DIGEST_MISMATCH", refs, root)
|
||||
if root.subject != request.actor_subject or root.audience != request.audience: return _plan(request, manifest, "AUTHORIZATION_REQUIRED", "ROOT_SUBJECT_AUDIENCE_MISMATCH", refs, root)
|
||||
if root.mission_id != request.mission_id or not _scope_covers(root.scope, request.scope): return _plan(request, manifest, "AUTHORIZATION_REQUIRED", "ROOT_SCOPE_EXCEEDED", refs, root)
|
||||
allowed, reason = _capabilities_cover(root.capabilities, request.required_capabilities)
|
||||
if not allowed: return _plan(request, manifest, "AUTHORIZATION_REQUIRED", reason, refs, root)
|
||||
if not (root.observed_at <= request.now < root.snapshot_expires_at <= root.expires_at) or request.now < root.not_before: return _plan(request, manifest, "WAITING_ROOT_AUTHORIZATION", "ROOT_EXPIRED", refs, root)
|
||||
if runtime.expires_at <= request.now or runtime.observed_at > request.now: return _plan(request, manifest, "UNKNOWN", "PRECONDITION_UNKNOWN", refs, root)
|
||||
if runtime.revocation_epoch is None or runtime.revocation_epoch != root.revocation_epoch: return _plan(request, manifest, "UNKNOWN", "EPOCH_REVOKED", refs, root)
|
||||
if (runtime.actor_subject != request.actor_subject or runtime.audience != request.audience or runtime.operation_id != request.operation_id
|
||||
or runtime.scope_digest != authorization_scope_digest(request.scope) or runtime.manifest_revision != manifest.revision
|
||||
or runtime.manifest_digest != manifest.declaration_digest): return _plan(request, manifest, "UNKNOWN", "PLAN_INPUT_DRIFT", refs, root)
|
||||
if any(value is None for value in (runtime.executor_installed, runtime.executor_callable, runtime.authorization_live, runtime.preconditions_pass)):
|
||||
return _plan(request, manifest, "UNKNOWN", "PRECONDITION_UNKNOWN", refs, root)
|
||||
if not all((runtime.executor_installed, runtime.executor_callable, runtime.authorization_live, runtime.preconditions_pass)):
|
||||
return _plan(request, manifest, "PRECONDITION_FAILED", "PRECONDITION_FAILED", refs, root)
|
||||
return _plan(request, manifest, "EXECUTE_GOVERNED_PLAN", None, refs, root)
|
||||
|
||||
|
||||
class GitManifestRegistryReader:
|
||||
"""Fixed-path, immutable Git blob reader; intentionally has no write API."""
|
||||
def __init__(self, *, configured_repo_root: Path, manifest_id: str) -> None:
|
||||
self.root = Path(configured_repo_root); self.manifest_id = _text(manifest_id, "manifest_id")
|
||||
self._last_registry: Mapping[str, Any] | None = None
|
||||
|
||||
def _blob(self, oid: str) -> bytes:
|
||||
_oid(oid, "expected_registry_oid")
|
||||
if self.root.is_symlink() or not self.root.is_dir(): raise RegistryReadError("REGISTRY_ROOT_INVALID")
|
||||
result = subprocess.run(["git", "-C", str(self.root), "show", f"{oid}:{_REGISTRY_PATH}"], capture_output=True, timeout=15)
|
||||
if result.returncode: raise RegistryReadError("REGISTRY_UNAVAILABLE")
|
||||
return result.stdout
|
||||
|
||||
def _registry(self, *, expected_registry_oid: str, expected_manifest_sha256: str) -> Mapping[str, Any]:
|
||||
raw = self._blob(expected_registry_oid)
|
||||
if hashlib.sha256(raw).hexdigest() != _digest(expected_manifest_sha256, "expected_manifest_sha256"): raise RegistryReadError("REGISTRY_DRIFT")
|
||||
try: value = json.loads(raw)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc: raise RegistryReadError("REGISTRY_INVALID") from exc
|
||||
if not isinstance(value, Mapping) or set(value) != {"schema", "records", "supersessions"} or value["schema"] != "aios.capability-registry.v1": raise RegistryReadError("REGISTRY_INVALID")
|
||||
return value
|
||||
|
||||
def read_manifest(self, *, expected_registry_oid: str, expected_manifest_sha256: str) -> StaticCapabilityManifest:
|
||||
registry = self._registry(expected_registry_oid=expected_registry_oid, expected_manifest_sha256=expected_manifest_sha256)
|
||||
records = registry["records"]
|
||||
if not isinstance(records, list): raise RegistryReadError("REGISTRY_INVALID")
|
||||
supersessions = registry["supersessions"]
|
||||
if not isinstance(supersessions, list): raise RegistryReadError("REGISTRY_INVALID")
|
||||
record_index: dict[tuple[str, str], Mapping[str, Any]] = {}
|
||||
for record in records:
|
||||
if not isinstance(record, Mapping) or not isinstance(record.get("manifest_id"), str) or not isinstance(record.get("revision"), str):
|
||||
raise RegistryReadError("REGISTRY_INVALID")
|
||||
key = (record["manifest_id"], record["revision"])
|
||||
if key in record_index: raise RegistryReadError("REGISTRY_DRIFT")
|
||||
record_index[key] = record
|
||||
replaced: dict[tuple[str, str], tuple[str, str]] = {}
|
||||
for item in supersessions:
|
||||
if not isinstance(item, Mapping) or set(item) != {"manifest_id", "prior_revision", "new_revision", "evidence_ref"}:
|
||||
raise RegistryReadError("REGISTRY_INVALID")
|
||||
try:
|
||||
manifest_id = _text(item["manifest_id"], "manifest_id")
|
||||
prior = _text(item["prior_revision"], "prior_revision")
|
||||
newer = _text(item["new_revision"], "new_revision")
|
||||
_text(item["evidence_ref"], "evidence_ref")
|
||||
except ValueError as exc:
|
||||
raise RegistryReadError("REGISTRY_INVALID") from exc
|
||||
prior_key, new_key = (manifest_id, prior), (manifest_id, newer)
|
||||
if prior_key not in record_index or new_key not in record_index or prior_key == new_key or prior_key in replaced:
|
||||
raise RegistryReadError("REGISTRY_DRIFT")
|
||||
replaced[prior_key] = new_key
|
||||
candidates = [record for record in records if isinstance(record, Mapping) and record.get("manifest_id") == self.manifest_id]
|
||||
if not candidates: raise RegistryReadError("REGISTRY_DRIFT")
|
||||
current = [record for record in candidates if (self.manifest_id, record.get("revision")) not in replaced]
|
||||
if len(current) != 1: raise RegistryReadError("REGISTRY_DRIFT")
|
||||
record = current[0]; required = {"manifest_id", "revision", "capabilities", "executor_refs", "source_oid", "declaration_digest", "allowed_scopes"}
|
||||
if set(record) != required: raise RegistryReadError("REGISTRY_INVALID")
|
||||
unsigned = {key: value for key, value in record.items() if key != "declaration_digest"}
|
||||
if hashlib.sha256(_canonical(unsigned)).hexdigest() != _digest(record["declaration_digest"], "declaration_digest"): raise RegistryReadError("REGISTRY_DRIFT")
|
||||
try:
|
||||
manifest = StaticCapabilityManifest(str(record["manifest_id"]), str(record["revision"]), tuple(CapabilityRef(CapabilityNamespace(item["namespace"]), item["token"]) for item in record["capabilities"]), tuple(record["executor_refs"]), str(record["source_oid"]), str(record["declaration_digest"]), tuple(ScopeSpec(**item) for item in record["allowed_scopes"]))
|
||||
except (KeyError, TypeError, ValueError) as exc: raise RegistryReadError("REGISTRY_INVALID") from exc
|
||||
self._last_registry = registry
|
||||
return manifest
|
||||
|
||||
def read_supersession(self, *, manifest_id: str, revision: str) -> str | None:
|
||||
_text(manifest_id, "manifest_id"); _text(revision, "revision")
|
||||
if self._last_registry is None:
|
||||
raise RegistryReadError("REGISTRY_READ_REQUIRES_PINNED_DIGEST")
|
||||
matches = [
|
||||
item["new_revision"] for item in self._last_registry["supersessions"]
|
||||
if item["manifest_id"] == manifest_id and item["prior_revision"] == revision
|
||||
]
|
||||
if len(matches) > 1:
|
||||
raise RegistryReadError("REGISTRY_DRIFT")
|
||||
return matches[0] if matches else None
|
||||
@@ -1,94 +0,0 @@
|
||||
"""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
@@ -1,972 +0,0 @@
|
||||
"""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,
|
||||
})
|
||||
@@ -1,877 +0,0 @@
|
||||
"""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),
|
||||
)
|
||||
@@ -1,209 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,462 +0,0 @@
|
||||
"""Explicit C6 composition in the existing TaskPlane host, default disabled.
|
||||
|
||||
All authorities are host-owned dependencies. No request can register an
|
||||
executor, construct a worker identity, or turn an observation into authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import threading
|
||||
from typing import Callable, Literal, Protocol
|
||||
from aios_capability_contract import has_capabilities as formal_has_capabilities, normalize_required_capabilities
|
||||
|
||||
from .aro_readonly import AroReadonlyTick
|
||||
from .authorization_planner import CapabilityNamespace, CapabilityRef, GitManifestRegistryReader, authorization_scope_digest
|
||||
from .auto_claim import AutoClaimCoordinator, DiscoveryResult
|
||||
from .errors import ForbiddenError
|
||||
from .operation_readback import OperationReaders, collect_closure, collect_observation
|
||||
from .operation_view import (ClosureObservation, OperationBinding, PreflightResult, ClosureResult,
|
||||
binding_digest, derive_closure, derive_preflight, reconcile_effect, validate_digest, validate_oid)
|
||||
from .schedulability import MemoryProofRepository, SchedulabilityService
|
||||
from .schedulability_store_adapter import (TaskPlaneSchedulabilityAuthority,
|
||||
TaskPlaneClaimReceiptV3, ScopedLeaseController, validate_task_plane_receipt)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedWorker:
|
||||
worker_id: str
|
||||
actor_identity: str
|
||||
authority_receipt_ref: str
|
||||
expires_at: datetime
|
||||
|
||||
def __post_init__(self):
|
||||
if any(not isinstance(v, str) or not v.strip() for v in
|
||||
(self.worker_id, self.actor_identity, self.authority_receipt_ref)):
|
||||
raise ValueError("authenticated worker receipt required")
|
||||
if not isinstance(self.expires_at, datetime) or self.expires_at.tzinfo is None:
|
||||
raise ValueError("worker expiry must be timezone aware")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationCapabilityBinding:
|
||||
operation_id: str
|
||||
namespace: CapabilityNamespace
|
||||
capability_id: str
|
||||
registry_oid: str
|
||||
manifest_id: str
|
||||
manifest_revision: str
|
||||
|
||||
def __post_init__(self):
|
||||
validate_oid(self.registry_oid)
|
||||
if not isinstance(self.namespace, CapabilityNamespace):
|
||||
raise ValueError("explicit capability namespace required")
|
||||
if any(not isinstance(v, str) or not v.strip() for v in
|
||||
(self.operation_id, self.capability_id, self.manifest_id, self.manifest_revision)):
|
||||
raise ValueError("operation capability identity required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DurableExecutionIntent:
|
||||
binding_digest: str
|
||||
effect_key: str
|
||||
claim_id: str
|
||||
receipt_ref: str
|
||||
|
||||
def __post_init__(self):
|
||||
validate_digest(self.binding_digest)
|
||||
if any(not isinstance(v, str) or not v.strip() for v in
|
||||
(self.effect_key, self.claim_id, self.receipt_ref)):
|
||||
raise ValueError("exact durable execution intent required")
|
||||
|
||||
|
||||
class GovernedSourcePatchPilot(Protocol):
|
||||
"""Existing governed writer/executor adapter, never a general shell port.
|
||||
|
||||
Implementation must revalidate Root, revocation, scope, generation and
|
||||
WriterLease/CAS at write linearization, and persist intent before effect.
|
||||
Readback uses the same immutable effect key through OperationReaders.
|
||||
"""
|
||||
def prepare_intent(self, *, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3) -> DurableExecutionIntent: ...
|
||||
def execute(self, *, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3,
|
||||
intent: DurableExecutionIntent) -> None: ...
|
||||
def reconcile(self, *, binding: OperationBinding,
|
||||
intent: DurableExecutionIntent) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C6Bindings:
|
||||
claim_authority: TaskPlaneSchedulabilityAuthority
|
||||
registry_reader: GitManifestRegistryReader
|
||||
registry_oid: str
|
||||
registry_digest: str
|
||||
operation_capabilities: tuple[OperationCapabilityBinding, ...]
|
||||
operation_readers: OperationReaders
|
||||
worker_provider: Callable[[], AuthenticatedWorker | None]
|
||||
operation_provider: Callable[[str, AuthenticatedWorker], OperationBinding]
|
||||
pilot: GovernedSourcePatchPilot | None = None
|
||||
aro_tick: AroReadonlyTick | None = None
|
||||
enabled: bool = False
|
||||
request_worker_provider: Callable[[str, str], AuthenticatedWorker | None] | None = None
|
||||
# Readers are injected from the host's existing durable intent and worker
|
||||
# authority. A provider error is UNKNOWN, never an absent record.
|
||||
intent_reader: Callable[[OperationBinding], DurableExecutionIntent | None] | None = None
|
||||
pending_intent_provider: Callable[
|
||||
[], tuple[tuple[OperationBinding, DurableExecutionIntent], ...]
|
||||
] | None = None
|
||||
pending_intent_completion: Callable[
|
||||
[OperationBinding, DurableExecutionIntent, ClosureObservation], None
|
||||
] | None = None
|
||||
worker_authority_verifier: Callable[[AuthenticatedWorker, OperationBinding], bool] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C6TickResult:
|
||||
status: Literal["DISABLED", "NO_CANDIDATES", "CLAIMED", "OBSERVED", "BLOCKED", "UNKNOWN"]
|
||||
reason_code: str
|
||||
discovery: DiscoveryResult | None = None
|
||||
claim: TaskPlaneClaimReceiptV3 | None = None
|
||||
preflight: PreflightResult | None = None
|
||||
closure: ClosureResult | None = None
|
||||
audit_report_ref: str | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class C6Runtime:
|
||||
def __init__(self, service, bindings: C6Bindings, *, clock=None):
|
||||
if not isinstance(bindings, C6Bindings) or type(bindings.enabled) is not bool:
|
||||
raise ValueError("explicit C6 bindings required")
|
||||
if not isinstance(bindings.claim_authority, TaskPlaneSchedulabilityAuthority) or bindings.claim_authority.store is not service.store:
|
||||
raise ValueError("C6 must share the host's existing TaskPlane authority")
|
||||
if not callable(bindings.worker_provider) or not callable(bindings.operation_provider):
|
||||
raise ValueError("host authenticated worker and operation providers required")
|
||||
if bindings.enabled and not service.policy.execution_enabled:
|
||||
raise ValueError("execution policy and explicit trust must be enabled first")
|
||||
if bindings.pilot is not None and not all(callable(getattr(bindings.pilot, method, None)) for method in ("prepare_intent", "execute", "reconcile")):
|
||||
raise ValueError("governed source patch pilot required")
|
||||
if bindings.enabled and bindings.pilot is not None and not all(
|
||||
callable(value)
|
||||
for value in (
|
||||
bindings.intent_reader,
|
||||
bindings.pending_intent_provider,
|
||||
bindings.pending_intent_completion,
|
||||
bindings.worker_authority_verifier,
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"enabled C6 pilot requires intent, recovery, and worker authority providers"
|
||||
)
|
||||
validate_oid(bindings.registry_oid); validate_digest(bindings.registry_digest)
|
||||
if not isinstance(bindings.operation_capabilities, tuple) or not bindings.operation_capabilities:
|
||||
raise ValueError("immutable operation capability map required")
|
||||
self.service, self.bindings = service, bindings
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self._lock = threading.Lock()
|
||||
self._closed = False
|
||||
self._pending: OperationBinding | None = None
|
||||
self._validate_map()
|
||||
self.schedulability = SchedulabilityService(bindings.claim_authority,
|
||||
MemoryProofRepository(), clock=lambda: self.clock().timestamp(),
|
||||
capability_matcher=bindings.claim_authority.capability_matcher)
|
||||
self.coordinator = AutoClaimCoordinator(self.schedulability)
|
||||
self.lease_controller = ScopedLeaseController(service.store, bindings.claim_authority.binding)
|
||||
|
||||
def _validate_map(self):
|
||||
manifest = self.bindings.registry_reader.read_manifest(
|
||||
expected_registry_oid=self.bindings.registry_oid,
|
||||
expected_manifest_sha256=self.bindings.registry_digest)
|
||||
seen = set()
|
||||
for item in self.bindings.operation_capabilities:
|
||||
key = (item.operation_id, item.namespace)
|
||||
if key in seen or item.registry_oid != self.bindings.registry_oid or item.manifest_id != manifest.manifest_id or item.manifest_revision != manifest.revision:
|
||||
raise ValueError("operation capability map revision conflict")
|
||||
if CapabilityRef(item.namespace, item.capability_id) not in manifest.capabilities:
|
||||
raise ValueError("operation capability not declared in authoritative manifest")
|
||||
if item.namespace == CapabilityNamespace.AGENT_TASK and any(
|
||||
value.startswith("exact:") for value in normalize_required_capabilities([item.capability_id])):
|
||||
raise ValueError("unknown formal operation capability")
|
||||
seen.add(key)
|
||||
return manifest
|
||||
|
||||
def _worker(self, worker=None):
|
||||
worker = self.bindings.worker_provider() if worker is None else worker
|
||||
if not isinstance(worker, AuthenticatedWorker) or worker.expires_at <= self.clock():
|
||||
raise ForbiddenError("fresh authenticated worker required")
|
||||
registered = self.service.store.get_worker(worker.worker_id)
|
||||
if registered.identity != worker.actor_identity or registered.disabled:
|
||||
raise ForbiddenError("registered worker identity mismatch")
|
||||
self.service.policy.assert_allowed("claim", worker.actor_identity)
|
||||
return worker
|
||||
|
||||
def _prepare(self, task_id, worker):
|
||||
manifest = self._validate_map()
|
||||
binding = self.bindings.operation_provider(task_id, worker)
|
||||
if not isinstance(binding, OperationBinding) or binding.task_id != task_id or binding.actor_id != worker.actor_identity or binding.source_oid != manifest.source_oid:
|
||||
raise ForbiddenError("operation identity mismatch")
|
||||
if binding.operation_id != "governed_source_patch_apply":
|
||||
raise ForbiddenError("operation outside C6 pilot")
|
||||
task = self.service.store.get_task(task_id)
|
||||
metadata = task.task_plan.metadata
|
||||
if (metadata.get("c6_operation_binding_digest") != binding_digest(binding)
|
||||
or metadata.get("generation") != binding.generation
|
||||
or binding.policy_digest != manifest.declaration_digest
|
||||
or binding.scope_digest not in {authorization_scope_digest(scope) for scope in manifest.allowed_scopes}):
|
||||
raise ForbiddenError("operation scope or immutable TaskPlan binding mismatch")
|
||||
self.service.policy.assert_allowed("claim", worker.actor_identity, task=task)
|
||||
required = task.task_plan.required_capabilities
|
||||
if binding.capability_namespace == CapabilityNamespace.AGENT_TASK.value:
|
||||
registered = self.service.store.get_worker(worker.worker_id)
|
||||
if (not formal_has_capabilities(registered.capabilities, [binding.capability_id])
|
||||
or not normalize_required_capabilities([binding.capability_id]).issubset(
|
||||
normalize_required_capabilities(required))):
|
||||
raise ForbiddenError("formal capability cannot use wildcard worker authority")
|
||||
elif binding.capability_id not in required:
|
||||
raise ForbiddenError("operation capability absent from TaskPlan")
|
||||
verifier = self.bindings.worker_authority_verifier
|
||||
if verifier is None or verifier(worker, binding) is not True:
|
||||
raise ForbiddenError("worker receipt not bound to operation authority")
|
||||
if not any(item.operation_id == binding.operation_id and item.namespace.value == binding.capability_namespace and item.capability_id == binding.capability_id for item in self.bindings.operation_capabilities):
|
||||
raise ForbiddenError("operation capability namespace mismatch")
|
||||
preflight = derive_preflight(collect_observation(binding, self.bindings.operation_readers, now=self.clock()), now=self.clock())
|
||||
return binding, preflight
|
||||
|
||||
def _intent(self, binding):
|
||||
reader = self.bindings.intent_reader
|
||||
if reader is None:
|
||||
raise ForbiddenError("durable execution intent reader required")
|
||||
intent = reader(binding)
|
||||
if intent is not None and (not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key):
|
||||
raise ForbiddenError("durable execution intent binding mismatch")
|
||||
return intent
|
||||
|
||||
def _durable_pending(self):
|
||||
provider = self.bindings.pending_intent_provider
|
||||
if provider is None:
|
||||
raise ForbiddenError("durable pending intent provider required")
|
||||
values = provider()
|
||||
if not isinstance(values, tuple) or len(values) > 16:
|
||||
raise ForbiddenError("bounded durable pending intent enumeration required")
|
||||
result = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
if not isinstance(value, tuple) or len(value) != 2:
|
||||
raise ForbiddenError("durable pending intent entry is invalid")
|
||||
binding, intent = value
|
||||
if (
|
||||
not isinstance(binding, OperationBinding)
|
||||
or not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key
|
||||
or binding.effect_key in seen
|
||||
):
|
||||
raise ForbiddenError("durable pending intent binding mismatch")
|
||||
seen.add(binding.effect_key)
|
||||
result.append((binding, intent))
|
||||
return tuple(result)
|
||||
|
||||
def _reconcile_pending(self, binding, intent, worker):
|
||||
current, _preflight = self._prepare(binding.task_id, worker)
|
||||
if current != binding or self._intent(binding) != intent:
|
||||
raise ForbiddenError("durable pending authority changed")
|
||||
assert self.bindings.pilot is not None
|
||||
try:
|
||||
self.bindings.pilot.reconcile(binding=binding, intent=intent)
|
||||
except Exception:
|
||||
pass
|
||||
observation = collect_closure(
|
||||
binding, self.bindings.operation_readers, now=self.clock()
|
||||
)
|
||||
return observation, derive_closure(binding, observation, now=self.clock())
|
||||
|
||||
def renew_claim(self, *, task_id, claim_id, actor_identity, receipt, lease_seconds):
|
||||
if self._closed or not self.bindings.enabled or not isinstance(receipt, dict):
|
||||
raise ForbiddenError("C6 v3 claim receipt required")
|
||||
if receipt.get("task_id") != task_id or receipt.get("claim_id") != claim_id:
|
||||
raise ForbiddenError("C6 claim receipt identity mismatch")
|
||||
provider = self.bindings.request_worker_provider
|
||||
worker_id = receipt.get("worker_id")
|
||||
candidate = provider(worker_id, actor_identity) if provider is not None else None
|
||||
if candidate is None:
|
||||
raise ForbiddenError("authenticated request worker required")
|
||||
worker = self._worker(candidate)
|
||||
if (worker.worker_id, worker.actor_identity) != (worker_id, actor_identity):
|
||||
raise ForbiddenError("request worker identity mismatch")
|
||||
self._prepare(task_id, worker)
|
||||
return self.lease_controller.renew(receipt, worker_id=worker_id,
|
||||
actor_identity=actor_identity, lease_seconds=lease_seconds)
|
||||
|
||||
def claim_task(self, *, task_id: str, worker_id: str, actor_identity: str, lease_seconds: int = 30):
|
||||
"""Manual and automatic host claims share this exact authority seam."""
|
||||
if self._closed or not self.bindings.enabled or self.bindings.pilot is None:
|
||||
raise ForbiddenError("C6 claim binding disabled or unavailable")
|
||||
provider = self.bindings.request_worker_provider
|
||||
if provider is None:
|
||||
raise ForbiddenError("authenticated request worker provider required")
|
||||
candidate = provider(worker_id, actor_identity)
|
||||
if candidate is None:
|
||||
raise ForbiddenError("authenticated request worker unavailable")
|
||||
worker = self._worker(candidate)
|
||||
if (worker.worker_id, worker.actor_identity) != (worker_id, actor_identity):
|
||||
raise ForbiddenError("worker provider binding mismatch")
|
||||
binding, preflight = self._prepare(task_id, worker)
|
||||
return self._claim_prepared(binding, preflight, worker, lease_seconds=lease_seconds)
|
||||
|
||||
def _claim_prepared(self, binding, preflight, worker, *, lease_seconds=30):
|
||||
task_id, worker_id, actor_identity = binding.task_id, worker.worker_id, worker.actor_identity
|
||||
if preflight.state != "READY":
|
||||
raise ForbiddenError("C6 preflight: " + preflight.reason_code)
|
||||
if self._intent(binding) is not None:
|
||||
raise ForbiddenError("existing execution intent requires readback")
|
||||
closure = collect_closure(binding, self.bindings.operation_readers, now=self.clock())
|
||||
if reconcile_effect(closure.effect, binding=binding, now=self.clock()) != "REVALIDATE_BEFORE_RETRY":
|
||||
raise ForbiddenError("C6 effect must be authoritatively absent before claim")
|
||||
proof = self.schedulability.preflight(task_id=task_id, worker_id=worker_id,
|
||||
actor_identity=actor_identity, persist=True)
|
||||
if proof.observation.scope_digest != binding.scope_digest:
|
||||
raise ForbiddenError("claim authority scope differs from operation scope")
|
||||
result = self.coordinator.claim(proof_id=proof.proof_id, worker_id=worker_id,
|
||||
actor_identity=actor_identity, lease_seconds=lease_seconds)
|
||||
parsed = validate_task_plane_receipt(result, proof=proof)
|
||||
return parsed
|
||||
|
||||
def tick(self) -> C6TickResult:
|
||||
if self._closed or not self.bindings.enabled:
|
||||
return C6TickResult("DISABLED", "C6_DISABLED")
|
||||
if not self._lock.acquire(blocking=False):
|
||||
return C6TickResult("BLOCKED", "LOCAL_TICK_IN_PROGRESS")
|
||||
try:
|
||||
audit = self.bindings.aro_tick.run_due() if self.bindings.aro_tick else None
|
||||
if self.bindings.pilot is None:
|
||||
return C6TickResult("BLOCKED", "EXECUTION_ENTRYPOINT_ABSENT", audit_report_ref=audit)
|
||||
if self.bindings.pending_intent_provider is None:
|
||||
return C6TickResult(
|
||||
"BLOCKED", "DURABLE_PENDING_INTENT_PROVIDER_ABSENT",
|
||||
audit_report_ref=audit,
|
||||
)
|
||||
# Restart recovery is ledger-first. It does not depend on the Task
|
||||
# remaining discoverable and it never calls execute/resubmits.
|
||||
pending = self._durable_pending()
|
||||
if pending:
|
||||
worker = self._worker()
|
||||
closures = []
|
||||
for binding, intent in pending:
|
||||
observation, closed = self._reconcile_pending(binding, intent, worker)
|
||||
closures.append(closed)
|
||||
if closed.outcome == "SUCCEEDED":
|
||||
assert self.bindings.pending_intent_completion is not None
|
||||
self.bindings.pending_intent_completion(
|
||||
binding, intent, observation
|
||||
)
|
||||
all_succeeded = all(item.outcome == "SUCCEEDED" for item in closures)
|
||||
return C6TickResult(
|
||||
"OBSERVED" if all_succeeded else "UNKNOWN",
|
||||
"DURABLE_PENDING_INTENTS_RECONCILED"
|
||||
if all_succeeded
|
||||
else "DURABLE_PENDING_INTENT_READBACK",
|
||||
closure=closures[-1],
|
||||
audit_report_ref=audit,
|
||||
)
|
||||
if self._pending is not None:
|
||||
# ACK loss is sticky. Reconciliation is a distinct, pure
|
||||
# owner seam: it may observe/reconcile the exact durable action
|
||||
# but may never acquire a new WriterLease or resend the commit.
|
||||
intent = self._intent(self._pending)
|
||||
if intent is None:
|
||||
return C6TickResult("UNKNOWN", "PENDING_INTENT_UNAVAILABLE",
|
||||
audit_report_ref=audit)
|
||||
worker = self._worker()
|
||||
observation, closure = self._reconcile_pending(
|
||||
self._pending, intent, worker
|
||||
)
|
||||
if closure.outcome == "SUCCEEDED":
|
||||
assert self.bindings.pending_intent_completion is not None
|
||||
self.bindings.pending_intent_completion(
|
||||
self._pending, intent, observation
|
||||
)
|
||||
self._pending = None
|
||||
return C6TickResult("OBSERVED" if closure.outcome == "SUCCEEDED" else "UNKNOWN",
|
||||
"PENDING_EFFECT_READBACK", closure=closure, audit_report_ref=audit)
|
||||
worker = self._worker()
|
||||
discovered = self.coordinator.discover(worker_id=worker.worker_id, actor_identity=worker.actor_identity)
|
||||
if discovered.proof is None:
|
||||
return C6TickResult("NO_CANDIDATES" if discovered.candidate_count == 0 else "BLOCKED",
|
||||
discovered.status, discovery=discovered, audit_report_ref=audit)
|
||||
task_id = discovered.proof.task_id
|
||||
binding, preflight = self._prepare(task_id, worker)
|
||||
if self._intent(binding) is not None:
|
||||
self._pending = binding
|
||||
intent = self._intent(binding)
|
||||
try:
|
||||
if intent is not None:
|
||||
self.bindings.pilot.reconcile(binding=binding, intent=intent)
|
||||
except Exception:
|
||||
pass
|
||||
closed = derive_closure(binding, collect_closure(binding,
|
||||
self.bindings.operation_readers, now=self.clock()), now=self.clock())
|
||||
return C6TickResult("OBSERVED" if closed.outcome == "SUCCEEDED" else "UNKNOWN",
|
||||
"DURABLE_INTENT_READBACK", discovered, closure=closed, audit_report_ref=audit)
|
||||
if preflight.state != "READY":
|
||||
return C6TickResult("BLOCKED", preflight.reason_code, discovered, preflight=preflight, audit_report_ref=audit)
|
||||
claim = self._claim_prepared(binding, preflight, worker)
|
||||
# Re-read every precondition immediately before the effect. The
|
||||
# executor independently enforces its linearization-point fences.
|
||||
binding_after, refreshed = self._prepare(task_id, worker)
|
||||
self._worker(worker)
|
||||
if binding_after != binding or refreshed.state != "READY" or self._closed:
|
||||
return C6TickResult("BLOCKED", "PRE_EFFECT_BINDING_CHANGED", discovered, claim, refreshed)
|
||||
try:
|
||||
with self.lease_controller.execution_guard(claim.to_dict(),
|
||||
worker_id=worker.worker_id, actor_identity=worker.actor_identity):
|
||||
binding_after, refreshed = self._prepare(task_id, self._worker(worker))
|
||||
effect = collect_closure(binding, self.bindings.operation_readers, now=self.clock()).effect
|
||||
if (binding_after != binding or refreshed.state != "READY" or self._closed
|
||||
or reconcile_effect(effect, binding=binding, now=self.clock()) != "REVALIDATE_BEFORE_RETRY"
|
||||
or self._intent(binding) is not None):
|
||||
return C6TickResult("BLOCKED", "PRE_EFFECT_AUTHORITY_CHANGED", discovered, claim, refreshed)
|
||||
self._pending = binding
|
||||
intent = self.bindings.pilot.prepare_intent(binding=binding, claim=claim)
|
||||
observed_intent = self._intent(binding)
|
||||
if (not isinstance(intent, DurableExecutionIntent) or intent != observed_intent
|
||||
or intent.claim_id != claim.claim_id):
|
||||
raise ForbiddenError("execution intent not durably observed")
|
||||
# Intent persistence may take time: acquire a new guard and
|
||||
# read authority/effect again before calling the governed writer.
|
||||
with self.lease_controller.execution_guard(claim.to_dict(),
|
||||
worker_id=worker.worker_id, actor_identity=worker.actor_identity):
|
||||
after, ready = self._prepare(task_id, self._worker(worker))
|
||||
effect = collect_closure(binding, self.bindings.operation_readers, now=self.clock()).effect
|
||||
if (after != binding or ready.state != "READY" or self._closed
|
||||
or self._intent(binding) != intent
|
||||
or reconcile_effect(effect, binding=binding, now=self.clock()) != "REVALIDATE_BEFORE_RETRY"):
|
||||
raise ForbiddenError("pre-effect authority changed during intent persistence")
|
||||
self.bindings.pilot.execute(binding=binding, claim=claim, intent=intent)
|
||||
except Exception:
|
||||
return C6TickResult("UNKNOWN", "EXECUTION_OUTCOME_UNKNOWN", discovered, claim, refreshed)
|
||||
observed = collect_closure(binding, self.bindings.operation_readers, now=self.clock())
|
||||
closed = derive_closure(binding, observed, now=self.clock())
|
||||
if closed.outcome == "SUCCEEDED":
|
||||
assert self.bindings.pending_intent_completion is not None
|
||||
self.bindings.pending_intent_completion(binding, intent, observed)
|
||||
self._pending = None
|
||||
return C6TickResult("OBSERVED" if closed.outcome == "SUCCEEDED" else "UNKNOWN",
|
||||
"THREE_LAYER_CLOSURE" if closed.outcome == "SUCCEEDED" else "CLOSURE_INCOMPLETE",
|
||||
discovered, claim, refreshed, closed, audit)
|
||||
except Exception:
|
||||
return C6TickResult("UNKNOWN", "AUTHORITY_OR_OBSERVATION_UNAVAILABLE")
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def close(self):
|
||||
# Local tick lifecycle only. Authority leases are never implicitly
|
||||
# released or renewed by closing an observer/coordinator.
|
||||
self._closed = True
|
||||
|
||||
|
||||
def bind_c6(service, bindings: C6Bindings, *, clock=None) -> C6Runtime:
|
||||
if getattr(service, "c6_runtime", None) is not None:
|
||||
raise ValueError("C6 runtime already bound")
|
||||
runtime = C6Runtime(service, bindings, clock=clock)
|
||||
service.c6_runtime = runtime
|
||||
return runtime
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Pure six-value C6 decision explanation policy.
|
||||
|
||||
The result is advice over already-read facts. It is not an authorization,
|
||||
does not map implicitly to legacy NextAction, and owns no write capability.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
|
||||
RootState = Literal["VALID", "MISSING", "EXPIRED", "REVOKED", "UNKNOWN"]
|
||||
|
||||
|
||||
class DecisionKind(str, Enum):
|
||||
AUTO_FIX = "AUTO_FIX"
|
||||
PLAN_ONLY = "PLAN_ONLY"
|
||||
OWNER_APPROVAL_REQUIRED = "OWNER_APPROVAL_REQUIRED"
|
||||
HARD_BLOCK = "HARD_BLOCK"
|
||||
WAITING_ROOT_AUTHORIZATION = "WAITING_ROOT_AUTHORIZATION"
|
||||
BLOCKED_WITH_EVIDENCE = "BLOCKED_WITH_EVIDENCE"
|
||||
|
||||
|
||||
def _evidence_refs(value: object) -> tuple[str, ...]:
|
||||
if not isinstance(value, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for ref in value:
|
||||
if not isinstance(ref, str) or not ref or ref != ref.strip() or "\x00" in ref:
|
||||
raise ValueError("invalid evidence reference")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionInput:
|
||||
root_state: RootState
|
||||
facts_complete: bool
|
||||
scope_covered: bool
|
||||
policy_denied: bool
|
||||
proposal_only: bool
|
||||
executor_ready: bool
|
||||
aro_phase_a: bool
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.root_state not in ("VALID", "MISSING", "EXPIRED", "REVOKED", "UNKNOWN"):
|
||||
raise ValueError("invalid root_state")
|
||||
for name in (
|
||||
"facts_complete",
|
||||
"scope_covered",
|
||||
"policy_denied",
|
||||
"proposal_only",
|
||||
"executor_ready",
|
||||
"aro_phase_a",
|
||||
):
|
||||
if type(getattr(self, name)) is not bool:
|
||||
raise ValueError(f"{name} must be bool")
|
||||
_evidence_refs(self.evidence_refs)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
kind: DecisionKind
|
||||
reason: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.kind, DecisionKind):
|
||||
raise ValueError("kind must be DecisionKind")
|
||||
if not isinstance(self.reason, str) or not self.reason:
|
||||
raise ValueError("reason must be non-empty")
|
||||
_evidence_refs(self.evidence_refs)
|
||||
|
||||
|
||||
def decide(value: DecisionInput) -> Decision:
|
||||
if not isinstance(value, DecisionInput):
|
||||
raise TypeError("value must be DecisionInput")
|
||||
if value.policy_denied or value.root_state == "REVOKED":
|
||||
return Decision(
|
||||
DecisionKind.HARD_BLOCK,
|
||||
"POLICY_DENIED_OR_ROOT_REVOKED",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if not value.facts_complete or value.root_state == "UNKNOWN":
|
||||
return Decision(
|
||||
DecisionKind.BLOCKED_WITH_EVIDENCE,
|
||||
"CRITICAL_FACTS_INCOMPLETE",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if value.root_state in ("MISSING", "EXPIRED"):
|
||||
return Decision(
|
||||
DecisionKind.WAITING_ROOT_AUTHORIZATION,
|
||||
"ROOT_AUTHORIZATION_MISSING_OR_EXPIRED",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if not value.scope_covered:
|
||||
return Decision(
|
||||
DecisionKind.OWNER_APPROVAL_REQUIRED,
|
||||
"SCOPE_OR_BUDGET_REQUIRES_OWNER_DECISION",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if value.proposal_only or value.aro_phase_a:
|
||||
return Decision(
|
||||
DecisionKind.PLAN_ONLY,
|
||||
"NON_EXECUTABLE_PLAN_OR_READONLY_ARO",
|
||||
value.evidence_refs,
|
||||
)
|
||||
if not value.executor_ready:
|
||||
return Decision(
|
||||
DecisionKind.BLOCKED_WITH_EVIDENCE,
|
||||
"GOVERNED_EXECUTOR_NOT_READY",
|
||||
value.evidence_refs,
|
||||
)
|
||||
return Decision(
|
||||
DecisionKind.AUTO_FIX,
|
||||
"EXISTING_AUTHORIZED_SCOPE_ELIGIBLE_FOR_GOVERNED_EXECUTION",
|
||||
value.evidence_refs,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["Decision", "DecisionInput", "DecisionKind", "decide"]
|
||||
@@ -31,16 +31,7 @@ class DelegationEngine:
|
||||
current = self.store.parent_of(current)
|
||||
return True
|
||||
|
||||
def delegate(
|
||||
self,
|
||||
parent_id: str,
|
||||
child_plan: TaskPlan,
|
||||
*,
|
||||
caller: str,
|
||||
owner_principal_id: str = "",
|
||||
mission_id: str = "",
|
||||
created_at: float | None = None,
|
||||
) -> TaskRecord:
|
||||
def delegate(self, parent_id: str, child_plan: TaskPlan, *, caller: str) -> 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(
|
||||
@@ -89,12 +80,7 @@ class DelegationEngine:
|
||||
raise ConflictError("delegation task_id conflict")
|
||||
|
||||
return self.store.delegate_task_with_budget_and_events(
|
||||
parent_id,
|
||||
child_plan,
|
||||
caller=caller,
|
||||
owner_principal_id=owner_principal_id,
|
||||
mission_id=mission_id,
|
||||
created_at=created_at,
|
||||
parent_id, child_plan, caller=caller
|
||||
)
|
||||
|
||||
def _depth(self, task_id: str) -> int:
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from .models import EvidenceEnvelope
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
@@ -18,44 +17,8 @@ class EvidenceService:
|
||||
def __init__(self, store: TaskPlaneStore):
|
||||
self.store = store
|
||||
|
||||
def add(
|
||||
self,
|
||||
task_id: str,
|
||||
kind: str,
|
||||
payload: dict,
|
||||
worker_identity: str,
|
||||
*,
|
||||
envelope: EvidenceEnvelope | None = None,
|
||||
claim_id: str | None = None,
|
||||
evidence_id: str | None = None,
|
||||
created_at: float | None = None,
|
||||
expires_at: float | None = None,
|
||||
signature: str | None = None,
|
||||
attestation_ref: str | None = None,
|
||||
signature_algorithm: str | None = None,
|
||||
signing_key_id: str | None = None,
|
||||
verifier_identity: str | None = None,
|
||||
authority_domain: str | None = None,
|
||||
sequence: int | None = None,
|
||||
) -> str:
|
||||
return self.store.add_evidence(
|
||||
task_id,
|
||||
kind,
|
||||
payload,
|
||||
worker_identity,
|
||||
envelope=envelope,
|
||||
claim_id=claim_id,
|
||||
evidence_id=evidence_id,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
signature=signature,
|
||||
attestation_ref=attestation_ref,
|
||||
signature_algorithm=signature_algorithm,
|
||||
signing_key_id=signing_key_id,
|
||||
verifier_identity=verifier_identity,
|
||||
authority_domain=authority_domain,
|
||||
sequence=sequence,
|
||||
)
|
||||
def add(self, task_id: str, kind: str, payload: dict, worker_identity: str) -> str:
|
||||
return self.store.add_evidence(task_id, kind, payload, worker_identity)
|
||||
|
||||
def list(self, task_id: str) -> list[dict]:
|
||||
return self.store.list_evidence(task_id)
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
"""Authenticated Task Plane evidence envelope adapter.
|
||||
|
||||
The adapter deliberately receives key material through an external resolver.
|
||||
It does not create, persist, or select a production trust root.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import Callable
|
||||
|
||||
from .models import EvidenceEnvelope, canonical_json, compute_sha256_hex
|
||||
|
||||
|
||||
HMAC_SHA256_V1 = "hmac-sha256-v1"
|
||||
_SIGNATURE_DOMAIN = b"aios.task-plane.evidence-envelope.v1"
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _signature_payload(envelope: EvidenceEnvelope) -> bytes:
|
||||
return (
|
||||
_SIGNATURE_DOMAIN
|
||||
+ b"\0"
|
||||
+ canonical_json(envelope.digest_payload()).encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
class HmacEvidenceVerifier:
|
||||
"""Verify full evidence envelopes using an externally managed HMAC key."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
verifier_identity: str,
|
||||
key_id: str,
|
||||
key_resolver: Callable[[str], bytes],
|
||||
max_age_seconds: int = 300,
|
||||
now_fn: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
if not verifier_identity or not key_id:
|
||||
raise ValueError("verifier_identity and key_id are required")
|
||||
if not callable(key_resolver):
|
||||
raise ValueError("key_resolver is required")
|
||||
if (
|
||||
isinstance(max_age_seconds, bool)
|
||||
or not isinstance(max_age_seconds, int)
|
||||
or max_age_seconds <= 0
|
||||
):
|
||||
raise ValueError("max_age_seconds must be a positive integer")
|
||||
self.verifier_identity = verifier_identity
|
||||
self.key_id = key_id
|
||||
self._key_resolver = key_resolver
|
||||
self.max_age_seconds = max_age_seconds
|
||||
self._now_fn = now_fn or time.time
|
||||
|
||||
def __call__(self, envelope: EvidenceEnvelope) -> bool:
|
||||
try:
|
||||
now = float(self._now_fn())
|
||||
finite_timestamps = all(
|
||||
math.isfinite(value)
|
||||
for value in (now, envelope.created_at, envelope.expires_at)
|
||||
)
|
||||
expected_digest = compute_sha256_hex(envelope.digest_payload())
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return False
|
||||
if (
|
||||
not finite_timestamps
|
||||
or envelope.expires_at <= envelope.created_at
|
||||
or envelope.signature_algorithm != HMAC_SHA256_V1
|
||||
or envelope.signing_key_id != self.key_id
|
||||
or envelope.verifier_identity != self.verifier_identity
|
||||
or envelope.authority_domain != self.verifier_identity
|
||||
or not isinstance(envelope.sequence, int)
|
||||
or isinstance(envelope.sequence, bool)
|
||||
or envelope.sequence < 1
|
||||
or not isinstance(envelope.signature, str)
|
||||
or not _HEX64.fullmatch(envelope.signature)
|
||||
or envelope.created_at > now
|
||||
or envelope.expires_at <= now
|
||||
or envelope.expires_at - envelope.created_at > self.max_age_seconds
|
||||
or envelope.evidence_digest != expected_digest
|
||||
):
|
||||
return False
|
||||
try:
|
||||
key = self._key_resolver(self.key_id)
|
||||
except Exception:
|
||||
return False
|
||||
if not isinstance(key, (bytes, bytearray)) or not key:
|
||||
return False
|
||||
expected = hmac.new(
|
||||
bytes(key), _signature_payload(envelope), hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(envelope.signature, expected)
|
||||
|
||||
|
||||
def sign_evidence_envelope(
|
||||
envelope: EvidenceEnvelope,
|
||||
*,
|
||||
key_id: str,
|
||||
key: bytes,
|
||||
verifier_identity: str,
|
||||
) -> EvidenceEnvelope:
|
||||
"""Return a signed envelope for an already authorized producer.
|
||||
|
||||
Production code should obtain ``key`` from its existing credential provider;
|
||||
this helper intentionally performs no key generation or storage.
|
||||
"""
|
||||
|
||||
if not isinstance(key, (bytes, bytearray)) or not key:
|
||||
raise ValueError("key is required")
|
||||
if not key_id or not verifier_identity:
|
||||
raise ValueError("key_id and verifier_identity are required")
|
||||
if envelope.authority_domain != verifier_identity:
|
||||
raise ValueError("envelope authority_domain must match verifier_identity")
|
||||
if not math.isfinite(envelope.created_at) or not math.isfinite(
|
||||
envelope.expires_at
|
||||
):
|
||||
raise ValueError("evidence timestamps must be finite")
|
||||
if envelope.expires_at <= envelope.created_at:
|
||||
raise ValueError("evidence expiry must follow creation")
|
||||
if (
|
||||
not isinstance(envelope.sequence, int)
|
||||
or isinstance(envelope.sequence, bool)
|
||||
or envelope.sequence < 1
|
||||
):
|
||||
raise ValueError("evidence sequence must be a positive integer")
|
||||
unsigned = replace(
|
||||
envelope,
|
||||
signature=None,
|
||||
signature_algorithm=HMAC_SHA256_V1,
|
||||
signing_key_id=key_id,
|
||||
verifier_identity=verifier_identity,
|
||||
evidence_digest="",
|
||||
)
|
||||
unsigned = replace(
|
||||
unsigned,
|
||||
evidence_digest=compute_sha256_hex(unsigned.digest_payload()),
|
||||
)
|
||||
signature = hmac.new(
|
||||
bytes(key), _signature_payload(unsigned), hashlib.sha256
|
||||
).hexdigest()
|
||||
return replace(unsigned, signature=signature)
|
||||
@@ -1,401 +0,0 @@
|
||||
"""现场(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,
|
||||
)
|
||||
@@ -1,147 +0,0 @@
|
||||
|
||||
"""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),
|
||||
)
|
||||
@@ -1,132 +0,0 @@
|
||||
|
||||
"""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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,118 +0,0 @@
|
||||
"""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,
|
||||
))
|
||||
@@ -1,474 +0,0 @@
|
||||
|
||||
"""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,
|
||||
))
|
||||
@@ -1,229 +0,0 @@
|
||||
|
||||
"""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)
|
||||
@@ -1,912 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,789 +0,0 @@
|
||||
"""Fail-closed host adapter for the C6 governed source-patch pilot.
|
||||
|
||||
The adapter owns no worker registration, authority database, effect ledger, or
|
||||
Git mutation implementation. Those are host-owned. It only joins an exact C6
|
||||
operation binding and v3 TaskPlane claim to an existing durable external
|
||||
transaction owner.
|
||||
|
||||
The transaction owner is a security boundary. Its ``execute_prepared`` method
|
||||
must durably record the send phase before an external call, call
|
||||
``before_external_call`` while holding its own lease/fence, acquire and persist
|
||||
the real WriterLease, enforce expected-OID CAS at the write linearization point,
|
||||
and leave an ambiguous call in EFFECT_UNKNOWN. A callback by itself is not an
|
||||
atomic revocation or CAS fence.
|
||||
|
||||
``RootActionConsumer`` exposes an exact-key facade only after the host installs
|
||||
a trusted operation-to-review binding provider. C6 callers cannot synthesize
|
||||
review_id, mission_id, root_task_id, root_revision, plan digest, or execution
|
||||
key. The facade reuses the consumer's existing review, fence, and effect rows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from release_host_authority import VerifiedCurrentRecordReader
|
||||
|
||||
from .c6_composition import DurableExecutionIntent
|
||||
from .operation_view import ClosureObservation, OperationBinding, binding_digest, validate_oid
|
||||
from .patch_contract import ExecutablePatchV1, parse_executable_patch
|
||||
from .schedulability_store_adapter import TaskPlaneClaimReceiptV3
|
||||
|
||||
|
||||
_ACTION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,191}$")
|
||||
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
||||
HOST_PATCH_AUTHORITY_PURPOSE = "aios.c6.host-patch-authority.current.v1"
|
||||
HOST_PATCH_RECONCILE_AUTHORITY_PURPOSE = (
|
||||
"aios.c6.host-patch-reconcile-authority.current.v1"
|
||||
)
|
||||
HOST_PATCH_AUTHORITY_SCHEMA = "aios.c6.host-patch-authority.v1"
|
||||
_AUTHORITY_RECORD_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"authorization_ref",
|
||||
"authority_generation",
|
||||
"task_generation",
|
||||
"claim_id",
|
||||
"claim_fence",
|
||||
"expected_branch_oid",
|
||||
"binding_digest",
|
||||
"lifecycle_state",
|
||||
"scope_state",
|
||||
"expires_at",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _canonical_json_bytes(value: Mapping[str, object]) -> bytes:
|
||||
return json.dumps(
|
||||
dict(value), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
ActionState = Literal[
|
||||
"PREPARED", "IN_PROGRESS", "EFFECT_UNKNOWN", "COMMITTED", "FAILED_FINAL"
|
||||
]
|
||||
|
||||
|
||||
class HostPatchIntegrationError(RuntimeError):
|
||||
"""The host binding is incomplete, stale, or contradictory."""
|
||||
|
||||
|
||||
class HostPatchEffectUnknown(HostPatchIntegrationError):
|
||||
"""The same immutable action must be reconciled, never resubmitted."""
|
||||
|
||||
|
||||
class HostPatchConfigurationError(HostPatchIntegrationError):
|
||||
"""No production-safe exact-key transaction owner is configured."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostPatchArtifact:
|
||||
document: Mapping[str, object]
|
||||
patch_bytes: bytes
|
||||
allowed_files: frozenset[str]
|
||||
commit_message: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.document, Mapping):
|
||||
raise ValueError("patch document provider must return a mapping")
|
||||
if not isinstance(self.patch_bytes, bytes) or not self.patch_bytes:
|
||||
raise ValueError("patch provider must return non-empty bytes")
|
||||
if not isinstance(self.allowed_files, frozenset) or not self.allowed_files:
|
||||
raise ValueError("patch provider must return a frozen non-empty allowlist")
|
||||
if (
|
||||
not isinstance(self.commit_message, str)
|
||||
or not self.commit_message.strip()
|
||||
or self.commit_message != self.commit_message.strip()
|
||||
):
|
||||
raise ValueError("canonical commit message required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostPatchAuthority:
|
||||
"""Fresh host readback before a transaction-owner call.
|
||||
|
||||
``claim_fence`` is the TaskPlane receipt revision. It is deliberately
|
||||
separate from the WriterLease fence returned by the external transaction.
|
||||
"""
|
||||
|
||||
authorization_ref: str
|
||||
authority_key_id: str
|
||||
authority_key_fingerprint: str
|
||||
authority_record_sha256: str
|
||||
authority_generation: int
|
||||
task_generation: int
|
||||
claim_id: str
|
||||
claim_fence: int
|
||||
expected_branch_oid: str
|
||||
expires_at: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
not isinstance(self.authorization_ref, str)
|
||||
or not self.authorization_ref.strip()
|
||||
or not isinstance(self.authority_key_id, str)
|
||||
or not self.authority_key_id.strip()
|
||||
or not isinstance(self.claim_id, str)
|
||||
or not self.claim_id.strip()
|
||||
):
|
||||
raise ValueError("host authority identity required")
|
||||
if (
|
||||
not isinstance(self.authority_key_fingerprint, str)
|
||||
or _DIGEST.fullmatch(self.authority_key_fingerprint) is None
|
||||
):
|
||||
raise ValueError("verified host authority key fingerprint required")
|
||||
if (
|
||||
not isinstance(self.authority_record_sha256, str)
|
||||
or _DIGEST.fullmatch(self.authority_record_sha256) is None
|
||||
):
|
||||
raise ValueError("verified host authority record digest required")
|
||||
for name in ("authority_generation", "task_generation", "claim_fence"):
|
||||
value = getattr(self, name)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"invalid host authority {name}")
|
||||
validate_oid(self.expected_branch_oid)
|
||||
if (
|
||||
isinstance(self.expires_at, bool)
|
||||
or not isinstance(self.expires_at, (int, float))
|
||||
or not math.isfinite(float(self.expires_at))
|
||||
):
|
||||
raise ValueError("invalid host authority expiry")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostPatchAction:
|
||||
"""Readback owned by the existing external transaction boundary."""
|
||||
|
||||
action_id: str
|
||||
state: ActionState
|
||||
binding_digest: str
|
||||
effect_key: str
|
||||
authorization_ref: str
|
||||
authority_key_id: str
|
||||
authority_key_fingerprint: str
|
||||
authority_record_sha256: str
|
||||
claim_id: str
|
||||
claim_fence: int
|
||||
task_generation: int
|
||||
authority_generation: int
|
||||
expected_branch_oid: str
|
||||
claim_receipt: Mapping[str, object]
|
||||
writer_lease_id: str | None = None
|
||||
writer_lease_fence: int | None = None
|
||||
result_oid: str | None = None
|
||||
receipt_ref: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.action_id, str) or _ACTION_ID.fullmatch(self.action_id) is None:
|
||||
raise ValueError("canonical immutable action_id required")
|
||||
if self.state not in {
|
||||
"PREPARED", "IN_PROGRESS", "EFFECT_UNKNOWN", "COMMITTED", "FAILED_FINAL"
|
||||
}:
|
||||
raise ValueError("invalid host patch action state")
|
||||
if not isinstance(self.binding_digest, str) or _DIGEST.fullmatch(self.binding_digest) is None:
|
||||
raise ValueError("action binding digest required")
|
||||
if not isinstance(self.effect_key, str) or not self.effect_key:
|
||||
raise ValueError("action effect key required")
|
||||
if (
|
||||
not isinstance(self.authorization_ref, str)
|
||||
or not self.authorization_ref
|
||||
or not isinstance(self.authority_key_id, str)
|
||||
or not self.authority_key_id
|
||||
or not isinstance(self.authority_key_fingerprint, str)
|
||||
or _DIGEST.fullmatch(self.authority_key_fingerprint) is None
|
||||
or not isinstance(self.authority_record_sha256, str)
|
||||
or _DIGEST.fullmatch(self.authority_record_sha256) is None
|
||||
or not isinstance(self.claim_id, str)
|
||||
or not self.claim_id
|
||||
or not isinstance(self.claim_receipt, Mapping)
|
||||
):
|
||||
raise ValueError("action authority/claim identity required")
|
||||
for name in ("claim_fence", "task_generation", "authority_generation"):
|
||||
value = getattr(self, name)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"invalid action {name}")
|
||||
validate_oid(self.expected_branch_oid)
|
||||
if self.writer_lease_fence is not None and (
|
||||
isinstance(self.writer_lease_fence, bool)
|
||||
or not isinstance(self.writer_lease_fence, int)
|
||||
or self.writer_lease_fence < 0
|
||||
):
|
||||
raise ValueError("invalid WriterLease fence")
|
||||
if self.result_oid is not None:
|
||||
validate_oid(self.result_oid)
|
||||
if self.state == "COMMITTED":
|
||||
if (
|
||||
not self.writer_lease_id
|
||||
or self.writer_lease_fence is None
|
||||
or self.result_oid is None
|
||||
or not self.receipt_ref
|
||||
):
|
||||
raise ValueError("committed action lacks WriterLease/OID/receipt binding")
|
||||
|
||||
|
||||
class SignedHostPatchAuthorityReader:
|
||||
"""Build host authority only from a verified, purpose-bound current record.
|
||||
|
||||
The underlying reader enforces a registered key, signature, short lifetime,
|
||||
exact query hash, and a fresh nonce. This remains a freshness challenge;
|
||||
the root-action transaction owner still supplies the atomic fence and CAS.
|
||||
|
||||
Host configuration must register both effect and reconcile purposes to the
|
||||
same authority key id/raw-key fingerprint. Reconcile has a distinct signed
|
||||
query and grants observation only, while prepared identity remains the same
|
||||
stable authorization semantics across the two purposes.
|
||||
"""
|
||||
|
||||
def __init__(self, current_records: VerifiedCurrentRecordReader) -> None:
|
||||
if not isinstance(current_records, VerifiedCurrentRecordReader):
|
||||
raise TypeError("current_records must be VerifiedCurrentRecordReader")
|
||||
purposes = (
|
||||
HOST_PATCH_AUTHORITY_PURPOSE,
|
||||
HOST_PATCH_RECONCILE_AUTHORITY_PURPOSE,
|
||||
)
|
||||
try:
|
||||
key_ids = [current_records.purpose_key_ids[purpose] for purpose in purposes]
|
||||
fingerprints = [
|
||||
current_records.purpose_key_fingerprints[purpose]
|
||||
for purpose in purposes
|
||||
]
|
||||
except KeyError as exc:
|
||||
raise HostPatchConfigurationError(
|
||||
"effect and reconcile authority purposes must both be registered"
|
||||
) from exc
|
||||
if (
|
||||
any(len(ids) != 1 for ids in key_ids)
|
||||
or key_ids[0] != key_ids[1]
|
||||
or set(fingerprints[0]) != set(key_ids[0])
|
||||
or fingerprints[0] != fingerprints[1]
|
||||
):
|
||||
raise HostPatchConfigurationError(
|
||||
"effect and reconcile purposes require one identical key/fingerprint"
|
||||
)
|
||||
self._records = current_records
|
||||
|
||||
@staticmethod
|
||||
def _query(
|
||||
binding: OperationBinding, claim: Mapping[str, object], *, reconcile: bool
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"schema": (
|
||||
"aios.c6.host-patch-reconcile-authority-query.v1"
|
||||
if reconcile
|
||||
else "aios.c6.host-patch-authority-query.v1"
|
||||
),
|
||||
"binding": asdict(binding),
|
||||
"binding_digest": binding_digest(binding),
|
||||
"claim": dict(claim),
|
||||
}
|
||||
|
||||
def _read(
|
||||
self, binding: OperationBinding, claim: Mapping[str, object], *, reconcile: bool
|
||||
) -> HostPatchAuthority:
|
||||
purpose = (
|
||||
HOST_PATCH_RECONCILE_AUTHORITY_PURPOSE
|
||||
if reconcile
|
||||
else HOST_PATCH_AUTHORITY_PURPOSE
|
||||
)
|
||||
verified = self._records.read(
|
||||
purpose, self._query(binding, claim, reconcile=reconcile)
|
||||
)
|
||||
record = verified.record
|
||||
if frozenset(record) != _AUTHORITY_RECORD_FIELDS:
|
||||
missing = sorted(_AUTHORITY_RECORD_FIELDS - frozenset(record))
|
||||
extra = sorted(frozenset(record) - _AUTHORITY_RECORD_FIELDS)
|
||||
raise HostPatchIntegrationError(
|
||||
f"host authority record fields are not exact; missing={missing!r} extra={extra!r}"
|
||||
)
|
||||
if (
|
||||
record["schema"] != HOST_PATCH_AUTHORITY_SCHEMA
|
||||
or record["lifecycle_state"] != "ACTIVE"
|
||||
or record["scope_state"] != "IN_SCOPE"
|
||||
or record["binding_digest"] != binding_digest(binding)
|
||||
or record["task_generation"] != binding.generation
|
||||
or record["claim_id"] != claim.get("claim_id")
|
||||
or record["claim_fence"] != claim.get("receipt_revision")
|
||||
or record["expected_branch_oid"] != binding.source_oid
|
||||
):
|
||||
raise HostPatchIntegrationError("signed host authority binding mismatch")
|
||||
if any(
|
||||
not isinstance(record[name], str) or not record[name].strip()
|
||||
for name in (
|
||||
"authorization_ref", "claim_id", "expected_branch_oid",
|
||||
"binding_digest", "lifecycle_state", "scope_state",
|
||||
)
|
||||
):
|
||||
raise HostPatchIntegrationError("signed host authority text fields are invalid")
|
||||
for name in ("authority_generation", "task_generation", "claim_fence"):
|
||||
if (
|
||||
isinstance(record[name], bool)
|
||||
or not isinstance(record[name], int)
|
||||
or record[name] < 0
|
||||
):
|
||||
raise HostPatchIntegrationError(
|
||||
f"signed host authority {name} is invalid"
|
||||
)
|
||||
if (
|
||||
isinstance(record["expires_at"], bool)
|
||||
or not isinstance(record["expires_at"], (int, float))
|
||||
or not math.isfinite(float(record["expires_at"]))
|
||||
):
|
||||
raise HostPatchIntegrationError("signed host authority expiry is invalid")
|
||||
try:
|
||||
envelope_expiry = datetime.fromisoformat(
|
||||
verified.expires_at.replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc).timestamp()
|
||||
authority = HostPatchAuthority(
|
||||
authorization_ref=record["authorization_ref"],
|
||||
authority_key_id=verified.key_id,
|
||||
authority_key_fingerprint=verified.key_fingerprint,
|
||||
# The signed envelope and its short observation expiry change
|
||||
# across fresh reads. Prepared identity binds only the stable
|
||||
# authorization semantics; freshness is independently checked
|
||||
# on every read.
|
||||
authority_record_sha256=hashlib.sha256(
|
||||
_canonical_json_bytes(
|
||||
{key: value for key, value in record.items() if key != "expires_at"}
|
||||
)
|
||||
).hexdigest(),
|
||||
authority_generation=record["authority_generation"], # type: ignore[arg-type]
|
||||
task_generation=record["task_generation"], # type: ignore[arg-type]
|
||||
claim_id=record["claim_id"],
|
||||
claim_fence=record["claim_fence"], # type: ignore[arg-type]
|
||||
expected_branch_oid=record["expected_branch_oid"],
|
||||
expires_at=min(float(record["expires_at"]), envelope_expiry),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HostPatchIntegrationError("signed host authority record is invalid") from exc
|
||||
if (
|
||||
not reconcile
|
||||
and authority.expires_at
|
||||
> float(claim.get("lease_until", authority.expires_at))
|
||||
):
|
||||
raise HostPatchIntegrationError("host authority outlives the TaskPlane claim")
|
||||
return authority
|
||||
|
||||
def __call__(
|
||||
self, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3
|
||||
) -> HostPatchAuthority:
|
||||
if not isinstance(claim, TaskPlaneClaimReceiptV3):
|
||||
raise TypeError("v3 TaskPlane claim receipt required")
|
||||
return self._read(binding, claim.to_dict(), reconcile=False)
|
||||
|
||||
def read_for_action(
|
||||
self, binding: OperationBinding, action: HostPatchAction
|
||||
) -> HostPatchAuthority:
|
||||
if not isinstance(action, HostPatchAction):
|
||||
raise TypeError("host patch action required")
|
||||
return self._read(binding, action.claim_receipt, reconcile=True)
|
||||
|
||||
|
||||
def signed_host_patch_authority_provider(
|
||||
current_records: VerifiedCurrentRecordReader,
|
||||
) -> SignedHostPatchAuthorityReader:
|
||||
"""Factory used by host composition without weakening reader type checks."""
|
||||
|
||||
return SignedHostPatchAuthorityReader(current_records)
|
||||
|
||||
|
||||
class HostPatchTransactionOwner(Protocol):
|
||||
"""Exact-key facade implemented by an existing durable transaction owner."""
|
||||
|
||||
def read_exact(self, effect_key: str) -> HostPatchAction | None: ...
|
||||
|
||||
def iter_pending_exact(
|
||||
self, *, limit: int
|
||||
) -> tuple[tuple[OperationBinding, HostPatchAction], ...]: ...
|
||||
|
||||
def mark_exact_closed(
|
||||
self, *, binding: OperationBinding, action_id: str,
|
||||
observation: ClosureObservation
|
||||
) -> None: ...
|
||||
|
||||
def prepare_exact(
|
||||
self,
|
||||
*,
|
||||
binding: OperationBinding,
|
||||
claim: TaskPlaneClaimReceiptV3,
|
||||
authority: HostPatchAuthority,
|
||||
contract: ExecutablePatchV1,
|
||||
patch_bytes: bytes,
|
||||
commit_message: str,
|
||||
) -> HostPatchAction: ...
|
||||
|
||||
def reconcile_exact(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
binding: OperationBinding,
|
||||
authority: HostPatchAuthority,
|
||||
before_external_call: Callable[[], None],
|
||||
) -> HostPatchAction: ...
|
||||
|
||||
def execute_prepared(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
binding: OperationBinding,
|
||||
claim: TaskPlaneClaimReceiptV3,
|
||||
authority: HostPatchAuthority,
|
||||
contract: ExecutablePatchV1,
|
||||
patch_bytes: bytes,
|
||||
commit_message: str,
|
||||
before_external_call: Callable[[], None],
|
||||
) -> HostPatchAction: ...
|
||||
|
||||
|
||||
_EXACT_OWNER_METHODS = (
|
||||
"read_exact", "iter_pending_exact", "mark_exact_closed", "prepare_exact", "reconcile_exact",
|
||||
"execute_prepared"
|
||||
)
|
||||
|
||||
|
||||
def require_exact_root_action_owner(service: object) -> HostPatchTransactionOwner:
|
||||
"""Return an exact-key root-action facade or fail before enabling C6.
|
||||
|
||||
``TaskPlaneService.root_actions`` currently exposes ``run_once`` only, so a
|
||||
stock service is rejected. A production composition may install a facade
|
||||
backed by the same RootActionConsumer/PublicBridgeRootActionExecutor store;
|
||||
it must not create another effect ledger.
|
||||
"""
|
||||
|
||||
owner = getattr(service, "root_actions", None)
|
||||
missing = [name for name in _EXACT_OWNER_METHODS if not callable(getattr(owner, name, None))]
|
||||
if missing:
|
||||
raise HostPatchConfigurationError(
|
||||
"exact root-action transaction owner unavailable: " + ",".join(missing)
|
||||
)
|
||||
if getattr(owner, "store", None) is not getattr(service, "store", None):
|
||||
raise HostPatchConfigurationError(
|
||||
"exact root-action owner must reuse the host TaskPlane store"
|
||||
)
|
||||
configured = getattr(owner, "assert_exact_owner_configured", None)
|
||||
if not callable(configured):
|
||||
raise HostPatchConfigurationError(
|
||||
"exact root-action owner configuration assertion unavailable"
|
||||
)
|
||||
try:
|
||||
configured()
|
||||
except Exception as exc:
|
||||
raise HostPatchConfigurationError(
|
||||
"exact root-action transaction owner is not configured"
|
||||
) from exc
|
||||
return owner # type: ignore[return-value]
|
||||
|
||||
|
||||
class HostPatchExecutor:
|
||||
"""Concrete ``GovernedSourcePatchPilot`` over host-owned formal providers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
artifact_provider: Callable[[OperationBinding], HostPatchArtifact],
|
||||
authority_provider: Callable[
|
||||
[OperationBinding, TaskPlaneClaimReceiptV3], HostPatchAuthority
|
||||
],
|
||||
transaction_owner: HostPatchTransactionOwner,
|
||||
clock: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
if not callable(artifact_provider) or not callable(authority_provider):
|
||||
raise HostPatchConfigurationError("artifact and authority providers required")
|
||||
if not callable(getattr(authority_provider, "read_for_action", None)):
|
||||
raise HostPatchConfigurationError(
|
||||
"authority provider must support signed restart reconciliation"
|
||||
)
|
||||
if any(not callable(getattr(transaction_owner, name, None)) for name in _EXACT_OWNER_METHODS):
|
||||
raise HostPatchConfigurationError("exact external transaction owner required")
|
||||
self._artifacts = artifact_provider
|
||||
self._authority_provider = authority_provider
|
||||
self._transactions = transaction_owner
|
||||
self._clock = clock or time.time
|
||||
|
||||
def _artifact(self, binding: OperationBinding) -> tuple[HostPatchArtifact, ExecutablePatchV1]:
|
||||
artifact = self._artifacts(binding)
|
||||
if not isinstance(artifact, HostPatchArtifact):
|
||||
raise HostPatchIntegrationError("invalid host patch artifact")
|
||||
contract = parse_executable_patch(
|
||||
artifact.document,
|
||||
patch_bytes=artifact.patch_bytes,
|
||||
allowed_files=artifact.allowed_files,
|
||||
expected_binding=binding,
|
||||
)
|
||||
return artifact, contract
|
||||
|
||||
def _authority(
|
||||
self, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3
|
||||
) -> HostPatchAuthority:
|
||||
value = self._authority_provider(binding, claim)
|
||||
if not isinstance(value, HostPatchAuthority):
|
||||
raise HostPatchIntegrationError("invalid host authority readback")
|
||||
if (
|
||||
value.claim_id != claim.claim_id
|
||||
or value.claim_fence != claim.receipt_revision
|
||||
or value.task_generation != binding.generation
|
||||
or value.expected_branch_oid != binding.source_oid
|
||||
or value.expires_at <= float(self._clock())
|
||||
):
|
||||
raise HostPatchIntegrationError("stale or mismatched host authority")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _validate_action(
|
||||
action: HostPatchAction,
|
||||
binding: OperationBinding,
|
||||
*,
|
||||
claim_id: str | None = None,
|
||||
claim_fence: int | None = None,
|
||||
authority: HostPatchAuthority | None = None,
|
||||
) -> HostPatchAction:
|
||||
if not isinstance(action, HostPatchAction):
|
||||
raise HostPatchIntegrationError("transaction owner returned invalid action")
|
||||
if (
|
||||
action.binding_digest != binding_digest(binding)
|
||||
or action.effect_key != binding.effect_key
|
||||
or action.task_generation != binding.generation
|
||||
or action.expected_branch_oid != binding.source_oid
|
||||
or (claim_id is not None and action.claim_id != claim_id)
|
||||
or (claim_fence is not None and action.claim_fence != claim_fence)
|
||||
):
|
||||
raise HostPatchIntegrationError("external action binding mismatch")
|
||||
if authority is not None and (
|
||||
action.authorization_ref != authority.authorization_ref
|
||||
or action.authority_key_id != authority.authority_key_id
|
||||
or action.authority_key_fingerprint != authority.authority_key_fingerprint
|
||||
or action.authority_record_sha256 != authority.authority_record_sha256
|
||||
or action.authority_generation != authority.authority_generation
|
||||
or action.claim_id != authority.claim_id
|
||||
or action.claim_fence != authority.claim_fence
|
||||
or action.expected_branch_oid != authority.expected_branch_oid
|
||||
):
|
||||
raise HostPatchIntegrationError("external action authority changed")
|
||||
if action.state == "FAILED_FINAL":
|
||||
raise HostPatchIntegrationError("external action failed final")
|
||||
return action
|
||||
|
||||
def read_intent(self, binding: OperationBinding) -> DurableExecutionIntent | None:
|
||||
"""C6 ``intent_reader`` wiring backed only by the transaction owner."""
|
||||
|
||||
action = self._transactions.read_exact(binding.effect_key)
|
||||
if action is None:
|
||||
return None
|
||||
action = self._validate_action(action, binding)
|
||||
return DurableExecutionIntent(
|
||||
binding_digest(binding), binding.effect_key, action.claim_id, action.action_id
|
||||
)
|
||||
|
||||
def pending_intents(
|
||||
self, *, limit: int = 16
|
||||
) -> tuple[tuple[OperationBinding, DurableExecutionIntent], ...]:
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 64:
|
||||
raise HostPatchConfigurationError("bounded pending intent limit required")
|
||||
result: list[tuple[OperationBinding, DurableExecutionIntent]] = []
|
||||
for pending_binding, action in self._transactions.iter_pending_exact(limit=limit):
|
||||
checked = self._validate_action(action, pending_binding)
|
||||
if checked.state not in {
|
||||
"PREPARED", "IN_PROGRESS", "EFFECT_UNKNOWN", "COMMITTED"
|
||||
}:
|
||||
raise HostPatchIntegrationError("transaction owner enumerated failed action")
|
||||
result.append(
|
||||
(
|
||||
pending_binding,
|
||||
DurableExecutionIntent(
|
||||
binding_digest(pending_binding),
|
||||
pending_binding.effect_key,
|
||||
checked.claim_id,
|
||||
checked.action_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
def mark_intent_closed(
|
||||
self, binding: OperationBinding, intent: DurableExecutionIntent,
|
||||
observation: ClosureObservation,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key
|
||||
or not isinstance(observation, ClosureObservation)
|
||||
):
|
||||
raise HostPatchIntegrationError("invalid durable closure acknowledgement")
|
||||
self._transactions.mark_exact_closed(
|
||||
binding=binding, action_id=intent.receipt_ref, observation=observation
|
||||
)
|
||||
|
||||
def prepare_intent(
|
||||
self, *, binding: OperationBinding, claim: TaskPlaneClaimReceiptV3
|
||||
) -> DurableExecutionIntent:
|
||||
artifact, contract = self._artifact(binding)
|
||||
authority = self._authority(binding, claim)
|
||||
action = self._transactions.prepare_exact(
|
||||
binding=binding,
|
||||
claim=claim,
|
||||
authority=authority,
|
||||
contract=contract,
|
||||
patch_bytes=artifact.patch_bytes,
|
||||
commit_message=artifact.commit_message,
|
||||
)
|
||||
action = self._validate_action(
|
||||
action,
|
||||
binding,
|
||||
claim_id=claim.claim_id,
|
||||
claim_fence=claim.receipt_revision,
|
||||
authority=authority,
|
||||
)
|
||||
return DurableExecutionIntent(
|
||||
binding_digest(binding), binding.effect_key, claim.claim_id, action.action_id
|
||||
)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
binding: OperationBinding,
|
||||
claim: TaskPlaneClaimReceiptV3,
|
||||
intent: DurableExecutionIntent,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key
|
||||
or intent.claim_id != claim.claim_id
|
||||
):
|
||||
raise HostPatchIntegrationError("execution intent binding mismatch")
|
||||
artifact, contract = self._artifact(binding)
|
||||
authority = self._authority(binding, claim)
|
||||
action = self._transactions.read_exact(binding.effect_key)
|
||||
if action is None or action.action_id != intent.receipt_ref:
|
||||
raise HostPatchIntegrationError("durable external action unavailable")
|
||||
action = self._validate_action(
|
||||
action, binding, claim_id=claim.claim_id,
|
||||
claim_fence=claim.receipt_revision, authority=authority,
|
||||
)
|
||||
|
||||
def revalidate() -> None:
|
||||
fresh = self._authority(binding, claim)
|
||||
if (
|
||||
fresh.authorization_ref != authority.authorization_ref
|
||||
or fresh.authority_key_id != authority.authority_key_id
|
||||
or fresh.authority_key_fingerprint != authority.authority_key_fingerprint
|
||||
or fresh.authority_record_sha256 != authority.authority_record_sha256
|
||||
or fresh.authority_generation != authority.authority_generation
|
||||
or fresh.task_generation != authority.task_generation
|
||||
or fresh.claim_id != authority.claim_id
|
||||
or fresh.claim_fence != authority.claim_fence
|
||||
or fresh.expected_branch_oid != authority.expected_branch_oid
|
||||
):
|
||||
raise HostPatchIntegrationError("host authority changed before external call")
|
||||
|
||||
observed = self._transactions.reconcile_exact(
|
||||
action_id=action.action_id,
|
||||
binding=binding,
|
||||
authority=authority,
|
||||
before_external_call=revalidate,
|
||||
)
|
||||
observed = self._validate_action(
|
||||
observed, binding, claim_id=claim.claim_id,
|
||||
claim_fence=claim.receipt_revision, authority=authority,
|
||||
)
|
||||
if observed.action_id != action.action_id:
|
||||
raise HostPatchIntegrationError("immutable external action_id changed")
|
||||
if observed.state == "COMMITTED":
|
||||
return
|
||||
if observed.state in {"IN_PROGRESS", "EFFECT_UNKNOWN"}:
|
||||
raise HostPatchEffectUnknown("external effect requires exact-key reconciliation")
|
||||
if observed.state != "PREPARED":
|
||||
raise HostPatchIntegrationError("external action is not executable")
|
||||
|
||||
completed = self._transactions.execute_prepared(
|
||||
action_id=action.action_id,
|
||||
binding=binding,
|
||||
claim=claim,
|
||||
authority=authority,
|
||||
contract=contract,
|
||||
patch_bytes=artifact.patch_bytes,
|
||||
commit_message=artifact.commit_message,
|
||||
before_external_call=revalidate,
|
||||
)
|
||||
completed = self._validate_action(
|
||||
completed, binding, claim_id=claim.claim_id,
|
||||
claim_fence=claim.receipt_revision, authority=authority,
|
||||
)
|
||||
if completed.action_id != action.action_id:
|
||||
raise HostPatchIntegrationError("immutable external action_id changed")
|
||||
if completed.state != "COMMITTED":
|
||||
raise HostPatchEffectUnknown("external transaction did not return committed readback")
|
||||
|
||||
def reconcile(
|
||||
self, *, binding: OperationBinding, intent: DurableExecutionIntent
|
||||
) -> None:
|
||||
"""Reconcile a durable intent without acquiring a lease or resending a commit."""
|
||||
|
||||
if (
|
||||
not isinstance(intent, DurableExecutionIntent)
|
||||
or intent.binding_digest != binding_digest(binding)
|
||||
or intent.effect_key != binding.effect_key
|
||||
):
|
||||
raise HostPatchIntegrationError("reconciliation intent binding mismatch")
|
||||
action = self._transactions.read_exact(binding.effect_key)
|
||||
if action is None or action.action_id != intent.receipt_ref:
|
||||
raise HostPatchIntegrationError("durable external action unavailable")
|
||||
action = self._validate_action(action, binding, claim_id=intent.claim_id)
|
||||
reader = self._authority_provider
|
||||
authority = reader.read_for_action(binding, action) # type: ignore[attr-defined]
|
||||
if not isinstance(authority, HostPatchAuthority):
|
||||
raise HostPatchIntegrationError("invalid reconciliation authority readback")
|
||||
action = self._validate_action(action, binding, authority=authority)
|
||||
|
||||
def revalidate() -> None:
|
||||
fresh = reader.read_for_action(binding, action) # type: ignore[attr-defined]
|
||||
if (
|
||||
not isinstance(fresh, HostPatchAuthority)
|
||||
or fresh.authorization_ref != authority.authorization_ref
|
||||
or fresh.authority_key_id != authority.authority_key_id
|
||||
or fresh.authority_key_fingerprint != authority.authority_key_fingerprint
|
||||
or fresh.authority_record_sha256 != authority.authority_record_sha256
|
||||
or fresh.authority_generation != authority.authority_generation
|
||||
or fresh.task_generation != authority.task_generation
|
||||
or fresh.claim_id != authority.claim_id
|
||||
or fresh.claim_fence != authority.claim_fence
|
||||
or fresh.expected_branch_oid != authority.expected_branch_oid
|
||||
):
|
||||
raise HostPatchIntegrationError(
|
||||
"host authority changed before reconciliation call"
|
||||
)
|
||||
|
||||
observed = self._transactions.reconcile_exact(
|
||||
action_id=action.action_id,
|
||||
binding=binding,
|
||||
authority=authority,
|
||||
before_external_call=revalidate,
|
||||
)
|
||||
observed = self._validate_action(observed, binding, authority=authority)
|
||||
if observed.action_id != action.action_id:
|
||||
raise HostPatchIntegrationError("immutable external action_id changed")
|
||||
if observed.state != "COMMITTED":
|
||||
raise HostPatchEffectUnknown("external effect remains unresolved")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HostPatchAction",
|
||||
"HostPatchArtifact",
|
||||
"HostPatchAuthority",
|
||||
"HostPatchConfigurationError",
|
||||
"HostPatchEffectUnknown",
|
||||
"HostPatchExecutor",
|
||||
"HostPatchIntegrationError",
|
||||
"HostPatchTransactionOwner",
|
||||
"HOST_PATCH_AUTHORITY_PURPOSE",
|
||||
"HOST_PATCH_AUTHORITY_SCHEMA",
|
||||
"SignedHostPatchAuthorityReader",
|
||||
"require_exact_root_action_owner",
|
||||
"signed_host_patch_authority_provider",
|
||||
]
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Authenticated host readback for independent verification and acceptance.
|
||||
|
||||
The host supplies separate, registered signing identities and current-record
|
||||
transports. This adapter never creates receipts or treats an ``authenticated``
|
||||
payload flag as evidence. Missing providers remain UNKNOWN through OperationReaders.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from release_host_authority import VerifiedCurrentRecordReader
|
||||
from .operation_view import OperationBinding, binding_digest, validate_digest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReceiptAuthorityPolicy:
|
||||
verifier_id: str
|
||||
verifier_key_id: str
|
||||
acceptor_id: str
|
||||
acceptor_key_id: str
|
||||
acceptance_revision: str
|
||||
verifier_key_fingerprint: str
|
||||
acceptor_key_fingerprint: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name, value in asdict(self).items():
|
||||
if not isinstance(value, str) or not value or value.strip() != value or "\x00" in value:
|
||||
raise ValueError(f"invalid {name}")
|
||||
if self.verifier_id == self.acceptor_id or self.verifier_key_id == self.acceptor_key_id:
|
||||
raise ValueError("verification and acceptance must use independent identities and keys")
|
||||
validate_digest(self.verifier_key_fingerprint)
|
||||
validate_digest(self.acceptor_key_fingerprint)
|
||||
if self.verifier_key_fingerprint == self.acceptor_key_fingerprint:
|
||||
raise ValueError("verification and acceptance require independent public keys")
|
||||
|
||||
|
||||
class HostOperationReceiptReaders:
|
||||
"""Bind verified transport signatures to host-pinned role identities.
|
||||
|
||||
Policies are trusted host configuration, never request or receipt fields.
|
||||
Receipt/result linkage and freshness are also checked by collect_closure /
|
||||
derive_closure against the separately observed Git effect.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
verifier_reader: VerifiedCurrentRecordReader,
|
||||
acceptance_reader: VerifiedCurrentRecordReader,
|
||||
policy: ReceiptAuthorityPolicy,
|
||||
) -> None:
|
||||
self._verifier = verifier_reader
|
||||
self._acceptance = acceptance_reader
|
||||
self._policy = policy
|
||||
|
||||
def _read(self, binding: OperationBinding, *, acceptance: bool) -> dict[str, object]:
|
||||
policy = self._policy
|
||||
identity = policy.acceptor_id if acceptance else policy.verifier_id
|
||||
key_id = policy.acceptor_key_id if acceptance else policy.verifier_key_id
|
||||
if binding.actor_id in (policy.verifier_id, policy.acceptor_id):
|
||||
raise ValueError("executor cannot verify or accept its own operation")
|
||||
kind = "acceptance" if acceptance else "verification"
|
||||
query = {
|
||||
"binding": asdict(binding),
|
||||
"binding_digest": binding_digest(binding),
|
||||
"expected_identity": identity,
|
||||
"expected_key_id": key_id,
|
||||
"expected_key_fingerprint": policy.acceptor_key_fingerprint if acceptance else policy.verifier_key_fingerprint,
|
||||
}
|
||||
if acceptance:
|
||||
query["expected_acceptance_revision"] = policy.acceptance_revision
|
||||
reader = self._acceptance if acceptance else self._verifier
|
||||
verified = reader.read(f"operation-{kind}", query)
|
||||
if verified.key_id != key_id:
|
||||
raise ValueError("receipt signer does not match the pinned role key")
|
||||
fingerprint = policy.acceptor_key_fingerprint if acceptance else policy.verifier_key_fingerprint
|
||||
if verified.key_fingerprint != fingerprint:
|
||||
raise ValueError("receipt signer public key fingerprint mismatch")
|
||||
document = verified.record
|
||||
if not isinstance(document, Mapping) or set(document) != {"schema", kind}:
|
||||
raise ValueError("invalid signed receipt document")
|
||||
schema = f"aios.operation-{kind}-observation.v1"
|
||||
if document["schema"] != schema:
|
||||
raise ValueError("invalid receipt schema")
|
||||
observation = document[kind]
|
||||
common = {"binding_digest", "receipt_ref", "result_artifact_digest", "outcome", "observed_at", "expires_at"}
|
||||
fields = common | (
|
||||
{"acceptor_id", "acceptance_revision", "verifier_receipt_ref"}
|
||||
if acceptance else {"verifier_id", "verification_receipt_ref"}
|
||||
)
|
||||
if not isinstance(observation, Mapping) or set(observation) != fields:
|
||||
raise ValueError("invalid signed receipt observation")
|
||||
if observation["binding_digest"] != binding_digest(binding):
|
||||
raise ValueError("receipt binding mismatch")
|
||||
if observation["acceptor_id" if acceptance else "verifier_id"] != identity:
|
||||
raise ValueError("receipt identity mismatch")
|
||||
if acceptance and observation["acceptance_revision"] != policy.acceptance_revision:
|
||||
raise ValueError("acceptance revision mismatch")
|
||||
result: dict[str, object] = {
|
||||
"schema": schema,
|
||||
kind: {**observation, "authenticated": True},
|
||||
}
|
||||
if acceptance:
|
||||
result["expected_acceptor_id"] = policy.acceptor_id
|
||||
result["expected_acceptance_revision"] = policy.acceptance_revision
|
||||
return result
|
||||
|
||||
def verifier(self, binding: OperationBinding) -> Mapping[str, object]:
|
||||
return self._read(binding, acceptance=False)
|
||||
|
||||
def acceptance(self, binding: OperationBinding) -> Mapping[str, object]:
|
||||
return self._read(binding, acceptance=True)
|
||||
@@ -1,536 +0,0 @@
|
||||
"""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
|
||||
@@ -1,846 +0,0 @@
|
||||
"""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
|
||||
@@ -1,990 +0,0 @@
|
||||
"""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 re
|
||||
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
|
||||
|
||||
|
||||
class IssuerResponseError(LiveProtocolError):
|
||||
"""Safe, structured issuer failure metadata for isolated diagnostics."""
|
||||
|
||||
def __init__(self, message: str, *, status: int, error_code: str | None,
|
||||
operation: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.error_code = error_code
|
||||
self.operation = operation
|
||||
|
||||
|
||||
@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
|
||||
raw_code = None if last is None else last.body.get("error")
|
||||
error_code = (
|
||||
raw_code if isinstance(raw_code, str)
|
||||
and re.fullmatch(r"(?:TASK_PRINCIPAL|EXECUTION_PLAN|GOVERNED)_[A-Z0-9_]{3,120}", raw_code) else None
|
||||
)
|
||||
raise IssuerResponseError(
|
||||
f"TaskPrincipal assertion issue failed operation={operation} HTTP {status}",
|
||||
status=status, error_code=error_code, operation=operation,
|
||||
)
|
||||
|
||||
|
||||
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
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
@@ -75,13 +74,7 @@ def _now_ts() -> float:
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def compute_sha256_hex(value: Any) -> str:
|
||||
@@ -395,8 +388,6 @@ 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())
|
||||
@@ -416,8 +407,6 @@ 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]:
|
||||
@@ -433,9 +422,6 @@ 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
|
||||
@@ -539,16 +525,10 @@ class EvidenceEnvelope:
|
||||
worker_identity: str
|
||||
payload: dict[str, Any]
|
||||
created_at: float
|
||||
expires_at: float
|
||||
previous_evidence_hash: str
|
||||
evidence_digest: str
|
||||
signature: str | None = None
|
||||
attestation_ref: str | None = None
|
||||
signature_algorithm: str | None = None
|
||||
signing_key_id: str | None = None
|
||||
verifier_identity: str | None = None
|
||||
authority_domain: str | None = None
|
||||
sequence: int | None = None
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@@ -564,17 +544,7 @@ class EvidenceEnvelope:
|
||||
attestation_ref: str | None = None,
|
||||
evidence_id: str | None = None,
|
||||
created_at: float | None = None,
|
||||
expires_at: float | None = None,
|
||||
signature_algorithm: str | None = None,
|
||||
signing_key_id: str | None = None,
|
||||
verifier_identity: str | None = None,
|
||||
authority_domain: str | None = None,
|
||||
sequence: int | None = None,
|
||||
) -> "EvidenceEnvelope":
|
||||
issued_at = float(_now_ts() if created_at is None else created_at)
|
||||
valid_until = float(issued_at + 300.0 if expires_at is None else expires_at)
|
||||
if not math.isfinite(issued_at) or not math.isfinite(valid_until):
|
||||
raise ValueError("evidence timestamps must be finite")
|
||||
envelope = cls(
|
||||
schema_version=1,
|
||||
evidence_id=evidence_id or f"evd-{uuid.uuid4()}",
|
||||
@@ -583,17 +553,11 @@ class EvidenceEnvelope:
|
||||
kind=validate_type(kind, "evidence kind"),
|
||||
worker_identity=str(worker_identity),
|
||||
payload=redact_value(payload),
|
||||
created_at=issued_at,
|
||||
expires_at=valid_until,
|
||||
created_at=float(created_at or _now_ts()),
|
||||
previous_evidence_hash=str(previous_evidence_hash or ""),
|
||||
evidence_digest="",
|
||||
signature=signature,
|
||||
attestation_ref=attestation_ref,
|
||||
signature_algorithm=signature_algorithm,
|
||||
signing_key_id=signing_key_id,
|
||||
verifier_identity=verifier_identity,
|
||||
authority_domain=authority_domain,
|
||||
sequence=sequence,
|
||||
)
|
||||
return dataclass_replace(
|
||||
envelope, evidence_digest=compute_sha256_hex(envelope.digest_payload())
|
||||
@@ -609,14 +573,8 @@ class EvidenceEnvelope:
|
||||
"worker_identity": self.worker_identity,
|
||||
"payload": self.payload,
|
||||
"created_at": self.created_at,
|
||||
"expires_at": self.expires_at,
|
||||
"previous_evidence_hash": self.previous_evidence_hash,
|
||||
"attestation_ref": self.attestation_ref,
|
||||
"signature_algorithm": self.signature_algorithm,
|
||||
"signing_key_id": self.signing_key_id,
|
||||
"verifier_identity": self.verifier_identity,
|
||||
"authority_domain": self.authority_domain,
|
||||
"sequence": self.sequence,
|
||||
}
|
||||
|
||||
def verify(
|
||||
@@ -626,40 +584,18 @@ class EvidenceEnvelope:
|
||||
expected_claim_id: str,
|
||||
expected_previous_hash: str,
|
||||
attestation_verifier: Any = None,
|
||||
now: float | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
observed_at = float(_now_ts() if now is None else now)
|
||||
finite_timestamps = all(
|
||||
math.isfinite(value)
|
||||
for value in (observed_at, self.created_at, self.expires_at)
|
||||
)
|
||||
expected_digest = compute_sha256_hex(self.digest_payload())
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return False
|
||||
if (
|
||||
not finite_timestamps
|
||||
or self.schema_version != 1
|
||||
self.schema_version != 1
|
||||
or self.task_id != expected_task_id
|
||||
or self.claim_id != expected_claim_id
|
||||
or self.previous_evidence_hash != expected_previous_hash
|
||||
or self.created_at > observed_at
|
||||
or self.expires_at <= observed_at
|
||||
or self.expires_at <= self.created_at
|
||||
or self.evidence_digest != expected_digest
|
||||
or self.evidence_digest != compute_sha256_hex(self.digest_payload())
|
||||
):
|
||||
return False
|
||||
# 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
|
||||
if self.signature or self.attestation_ref:
|
||||
return True if attestation_verifier is None else bool(attestation_verifier(self))
|
||||
return True
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -668,41 +604,6 @@ class EvidenceEnvelope:
|
||||
"signature": self.signature,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: dict[str, Any]) -> "EvidenceEnvelope":
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("evidence envelope must be an object")
|
||||
try:
|
||||
envelope = cls(
|
||||
schema_version=int(value["schema_version"]),
|
||||
evidence_id=validate_identifier(value["evidence_id"], "evidence_id"),
|
||||
task_id=validate_identifier(value["task_id"], "task_id"),
|
||||
claim_id=validate_identifier(value["claim_id"], "claim_id"),
|
||||
kind=validate_type(value["kind"], "evidence kind"),
|
||||
worker_identity=str(value["worker_identity"]),
|
||||
payload=redact_value(value["payload"]),
|
||||
created_at=float(value["created_at"]),
|
||||
expires_at=float(value["expires_at"]),
|
||||
previous_evidence_hash=str(value["previous_evidence_hash"]),
|
||||
evidence_digest=str(value["evidence_digest"]),
|
||||
signature=value.get("signature"),
|
||||
attestation_ref=value.get("attestation_ref"),
|
||||
signature_algorithm=value.get("signature_algorithm"),
|
||||
signing_key_id=value.get("signing_key_id"),
|
||||
verifier_identity=value.get("verifier_identity"),
|
||||
authority_domain=value.get("authority_domain"),
|
||||
sequence=(
|
||||
None if value.get("sequence") is None else int(value["sequence"])
|
||||
),
|
||||
)
|
||||
if not math.isfinite(envelope.created_at) or not math.isfinite(
|
||||
envelope.expires_at
|
||||
):
|
||||
raise ValueError("evidence timestamps must be finite")
|
||||
return envelope
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError("invalid evidence envelope") from exc
|
||||
|
||||
|
||||
class ProbeOutcome(StrEnum):
|
||||
NOT_APPLIED = "NOT_APPLIED"
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
"""Strict adapters from existing authority readers into C6 observations.
|
||||
|
||||
Readers are injected by the trusted composition root. They are query-only:
|
||||
this module has no controller/reconcile, signer, store, or network factory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from .operation_view import (
|
||||
AcceptanceObservation,
|
||||
ClosureObservation,
|
||||
DomainStamp,
|
||||
EffectObservation,
|
||||
Fact,
|
||||
OperationBinding,
|
||||
OperationObservation,
|
||||
VerificationObservation,
|
||||
binding_digest,
|
||||
)
|
||||
|
||||
|
||||
Reader = Callable[[OperationBinding], Mapping[str, object]]
|
||||
|
||||
_STAMP_FIELDS = frozenset({"domain", "object_id", "generation", "revision", "event_id"})
|
||||
_FACT_FIELDS = frozenset(
|
||||
{"value", "stamp", "binding_digest", "observed_at", "expires_at", "evidence_refs"}
|
||||
)
|
||||
_DECLARATION_FIELDS = frozenset({"schema", "declared", "installed", "callable"})
|
||||
_TASK_FIELDS = frozenset({"schema", "preconditions"})
|
||||
_AUTHORIZATION_FIELDS = frozenset({"schema", "authorized"})
|
||||
_EFFECT_FIELDS = frozenset(
|
||||
{
|
||||
"binding_digest",
|
||||
"outcome",
|
||||
"receipt_ref",
|
||||
"result_artifact_digest",
|
||||
"actor_id",
|
||||
"stamp",
|
||||
"observed_at",
|
||||
"expires_at",
|
||||
"trust_status",
|
||||
}
|
||||
)
|
||||
_GITOPS_FIELDS = frozenset({"schema", "effect"})
|
||||
_VERIFICATION_FIELDS = frozenset(
|
||||
{
|
||||
"binding_digest",
|
||||
"receipt_ref",
|
||||
"result_artifact_digest",
|
||||
"verifier_id",
|
||||
"outcome",
|
||||
"authenticated",
|
||||
"observed_at",
|
||||
"expires_at",
|
||||
"verification_receipt_ref",
|
||||
}
|
||||
)
|
||||
_VERIFIER_FIELDS = frozenset({"schema", "verification"})
|
||||
_ACCEPTANCE_OBSERVATION_FIELDS = frozenset(
|
||||
{
|
||||
"binding_digest",
|
||||
"receipt_ref",
|
||||
"result_artifact_digest",
|
||||
"verifier_receipt_ref",
|
||||
"acceptance_revision",
|
||||
"acceptor_id",
|
||||
"outcome",
|
||||
"authenticated",
|
||||
"observed_at",
|
||||
"expires_at",
|
||||
}
|
||||
)
|
||||
_ACCEPTANCE_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"acceptance",
|
||||
"expected_acceptor_id",
|
||||
"expected_acceptance_revision",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationReaders:
|
||||
declaration: Reader | None
|
||||
task: Reader | None
|
||||
authorization: Reader | None
|
||||
gitops: Reader | None
|
||||
verifier: Reader | None
|
||||
acceptance: Reader | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"declaration",
|
||||
"task",
|
||||
"authorization",
|
||||
"gitops",
|
||||
"verifier",
|
||||
"acceptance",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if value is not None and not callable(value):
|
||||
raise ValueError(f"{name} reader must be callable or None")
|
||||
|
||||
|
||||
def _exact_mapping(value: object, fields: frozenset[str], name: str) -> Mapping[str, object]:
|
||||
if not isinstance(value, Mapping) or set(value) != fields:
|
||||
raise ValueError(f"{name} has unknown or missing fields")
|
||||
if any(not isinstance(key, str) for key in value):
|
||||
raise ValueError(f"{name} keys must be strings")
|
||||
return value
|
||||
|
||||
|
||||
def _text(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip() or "\x00" in value:
|
||||
raise ValueError(f"{name} must be a canonical non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_text(value: object, name: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _text(value, name)
|
||||
|
||||
|
||||
def _datetime(value: object, name: str) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
text = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||
try:
|
||||
value = datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{name} is not an RFC3339 datetime") from exc
|
||||
if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError(f"{name} must be timezone-aware")
|
||||
return value
|
||||
|
||||
|
||||
def _tuple_of_text(value: object, name: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, (tuple, list)) or isinstance(value, (str, bytes)):
|
||||
raise ValueError(f"{name} must be an array")
|
||||
return tuple(_text(item, name) for item in value)
|
||||
|
||||
|
||||
def _stamp(value: object) -> DomainStamp | None:
|
||||
if value is None:
|
||||
return None
|
||||
raw = _exact_mapping(value, _STAMP_FIELDS, "stamp")
|
||||
return DomainStamp(
|
||||
domain=_text(raw["domain"], "domain"),
|
||||
object_id=_text(raw["object_id"], "object_id"),
|
||||
generation=raw["generation"], # type: ignore[arg-type]
|
||||
revision=raw["revision"], # type: ignore[arg-type]
|
||||
event_id=_text(raw["event_id"], "event_id"),
|
||||
)
|
||||
|
||||
|
||||
def _fact(value: object) -> Fact:
|
||||
raw = _exact_mapping(value, _FACT_FIELDS, "fact")
|
||||
return Fact(
|
||||
value=raw["value"], # type: ignore[arg-type]
|
||||
stamp=_stamp(raw["stamp"]),
|
||||
binding_digest=_text(raw["binding_digest"], "binding_digest"),
|
||||
observed_at=_datetime(raw["observed_at"], "observed_at"),
|
||||
expires_at=_datetime(raw["expires_at"], "expires_at"),
|
||||
evidence_refs=_tuple_of_text(raw["evidence_refs"], "evidence_refs"),
|
||||
)
|
||||
|
||||
|
||||
def _unknown_fact(binding: OperationBinding, code: str) -> Fact:
|
||||
return Fact(
|
||||
value="UNKNOWN",
|
||||
stamp=None,
|
||||
binding_digest=binding_digest(binding),
|
||||
observed_at=None,
|
||||
expires_at=None,
|
||||
evidence_refs=(f"operation-readback:{code}",),
|
||||
)
|
||||
|
||||
|
||||
def _fact_candidates(value: object) -> tuple[Fact, ...]:
|
||||
if isinstance(value, Mapping):
|
||||
return (_fact(value),)
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
if not value:
|
||||
raise ValueError("fact candidate list is empty")
|
||||
return tuple(_fact(item) for item in value)
|
||||
raise ValueError("fact must be an object or non-empty candidate list")
|
||||
|
||||
|
||||
def _select_fact(
|
||||
value: object, binding: OperationBinding, *, expected_domain: str
|
||||
) -> Fact:
|
||||
candidates = _fact_candidates(value)
|
||||
digest = binding_digest(binding)
|
||||
if any(item.binding_digest != digest for item in candidates):
|
||||
return _unknown_fact(binding, "BINDING_MISMATCH")
|
||||
if any(item.stamp is None for item in candidates):
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
return _unknown_fact(binding, "INVALID_CANDIDATE_SET")
|
||||
stamps = [item.stamp for item in candidates]
|
||||
assert all(stamp is not None for stamp in stamps)
|
||||
domains = {(stamp.domain, stamp.object_id) for stamp in stamps if stamp is not None}
|
||||
if len(domains) != 1 or next(iter(domains))[0] != expected_domain:
|
||||
return _unknown_fact(binding, "CROSS_DOMAIN_CANDIDATES")
|
||||
highest = max((stamp.generation, stamp.revision) for stamp in stamps if stamp is not None)
|
||||
current = [
|
||||
item
|
||||
for item in candidates
|
||||
if item.stamp is not None
|
||||
and (item.stamp.generation, item.stamp.revision) == highest
|
||||
]
|
||||
if len(current) > 1:
|
||||
first = current[0]
|
||||
# Event identity may differ for an idempotent duplicate. Every semantic
|
||||
# field must still agree at the same authority revision.
|
||||
semantic = (
|
||||
first.value,
|
||||
first.binding_digest,
|
||||
first.observed_at,
|
||||
first.expires_at,
|
||||
first.evidence_refs,
|
||||
)
|
||||
if any(
|
||||
(
|
||||
item.value,
|
||||
item.binding_digest,
|
||||
item.observed_at,
|
||||
item.expires_at,
|
||||
item.evidence_refs,
|
||||
)
|
||||
!= semantic
|
||||
for item in current[1:]
|
||||
):
|
||||
return _unknown_fact(binding, "SAME_REVISION_CONFLICT")
|
||||
return current[0]
|
||||
|
||||
|
||||
def _read(reader: Reader | None, binding: OperationBinding) -> Mapping[str, object] | None:
|
||||
if reader is None:
|
||||
return None
|
||||
try:
|
||||
value = reader(binding)
|
||||
except Exception:
|
||||
return None
|
||||
return value if isinstance(value, Mapping) else None
|
||||
|
||||
|
||||
def collect_observation(
|
||||
binding: OperationBinding,
|
||||
readers: OperationReaders,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> OperationObservation:
|
||||
"""Query three authority seams and build a typed preflight observation."""
|
||||
_datetime(now, "now")
|
||||
declared = installed = callable_fact = _unknown_fact(binding, "DECLARATION_UNAVAILABLE")
|
||||
raw = _read(readers.declaration, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _DECLARATION_FIELDS, "declaration reader result")
|
||||
if doc["schema"] != "aios.operation-declaration-observation.v1":
|
||||
raise ValueError("unsupported declaration schema")
|
||||
declared = _select_fact(
|
||||
doc["declared"], binding, expected_domain="declaration"
|
||||
)
|
||||
installed = _select_fact(
|
||||
doc["installed"], binding, expected_domain="runtime"
|
||||
)
|
||||
callable_fact = _select_fact(
|
||||
doc["callable"], binding, expected_domain="runtime"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
declared = installed = callable_fact = _unknown_fact(
|
||||
binding, "INVALID_DECLARATION_OBSERVATION"
|
||||
)
|
||||
|
||||
preconditions = _unknown_fact(binding, "TASK_ADAPTER_UNAVAILABLE")
|
||||
raw = _read(readers.task, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _TASK_FIELDS, "task reader result")
|
||||
if doc["schema"] != "aios.operation-task-observation.v1":
|
||||
raise ValueError("unsupported task schema")
|
||||
preconditions = _select_fact(
|
||||
doc["preconditions"], binding, expected_domain="task"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
preconditions = _unknown_fact(binding, "INVALID_TASK_OBSERVATION")
|
||||
|
||||
authorized = _unknown_fact(binding, "AUTHORIZATION_ADAPTER_UNAVAILABLE")
|
||||
raw = _read(readers.authorization, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _AUTHORIZATION_FIELDS, "authorization reader result")
|
||||
if doc["schema"] != "aios.operation-authorization-observation.v1":
|
||||
raise ValueError("unsupported authorization schema")
|
||||
authorized = _select_fact(
|
||||
doc["authorized"], binding, expected_domain="authorization"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
authorized = _unknown_fact(binding, "INVALID_AUTHORIZATION_OBSERVATION")
|
||||
|
||||
return OperationObservation(
|
||||
binding=binding,
|
||||
declared=declared,
|
||||
installed=installed,
|
||||
callable=callable_fact,
|
||||
authorized=authorized,
|
||||
preconditions=preconditions,
|
||||
)
|
||||
|
||||
|
||||
def _unknown_effect(binding: OperationBinding) -> EffectObservation:
|
||||
return EffectObservation(
|
||||
binding_digest=binding_digest(binding),
|
||||
outcome="UNKNOWN",
|
||||
receipt_ref=None,
|
||||
result_artifact_digest=None,
|
||||
actor_id=None,
|
||||
stamp=None,
|
||||
observed_at=None,
|
||||
expires_at=None,
|
||||
trust_status="UNTRUSTED",
|
||||
)
|
||||
|
||||
|
||||
def _effect(value: object) -> EffectObservation:
|
||||
raw = _exact_mapping(value, _EFFECT_FIELDS, "effect")
|
||||
return EffectObservation(
|
||||
binding_digest=_text(raw["binding_digest"], "binding_digest"),
|
||||
outcome=raw["outcome"], # type: ignore[arg-type]
|
||||
receipt_ref=_optional_text(raw["receipt_ref"], "receipt_ref"),
|
||||
result_artifact_digest=_optional_text(
|
||||
raw["result_artifact_digest"], "result_artifact_digest"
|
||||
),
|
||||
actor_id=_optional_text(raw["actor_id"], "actor_id"),
|
||||
stamp=_stamp(raw["stamp"]),
|
||||
observed_at=_datetime(raw["observed_at"], "observed_at"),
|
||||
expires_at=_datetime(raw["expires_at"], "expires_at"),
|
||||
trust_status=raw["trust_status"], # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _unknown_verification(binding: OperationBinding) -> VerificationObservation:
|
||||
return VerificationObservation(
|
||||
binding_digest=binding_digest(binding),
|
||||
receipt_ref=None,
|
||||
result_artifact_digest=None,
|
||||
verifier_id=None,
|
||||
outcome="UNAVAILABLE",
|
||||
authenticated=False,
|
||||
observed_at=None,
|
||||
expires_at=None,
|
||||
verification_receipt_ref=None,
|
||||
)
|
||||
|
||||
|
||||
def _verification(value: object) -> VerificationObservation:
|
||||
raw = _exact_mapping(value, _VERIFICATION_FIELDS, "verification")
|
||||
return VerificationObservation(
|
||||
binding_digest=_text(raw["binding_digest"], "binding_digest"),
|
||||
receipt_ref=_optional_text(raw["receipt_ref"], "receipt_ref"),
|
||||
result_artifact_digest=_optional_text(
|
||||
raw["result_artifact_digest"], "result_artifact_digest"
|
||||
),
|
||||
verifier_id=_optional_text(raw["verifier_id"], "verifier_id"),
|
||||
outcome=raw["outcome"], # type: ignore[arg-type]
|
||||
authenticated=raw["authenticated"], # type: ignore[arg-type]
|
||||
observed_at=_datetime(raw["observed_at"], "observed_at"),
|
||||
expires_at=_datetime(raw["expires_at"], "expires_at"),
|
||||
verification_receipt_ref=_optional_text(
|
||||
raw["verification_receipt_ref"], "verification_receipt_ref"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _unknown_acceptance(binding: OperationBinding) -> AcceptanceObservation:
|
||||
return AcceptanceObservation(
|
||||
binding_digest=binding_digest(binding),
|
||||
receipt_ref=None,
|
||||
result_artifact_digest=None,
|
||||
verifier_receipt_ref=None,
|
||||
acceptance_revision=None,
|
||||
acceptor_id=None,
|
||||
outcome="UNKNOWN",
|
||||
authenticated=False,
|
||||
observed_at=None,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _acceptance(value: object) -> AcceptanceObservation:
|
||||
raw = _exact_mapping(value, _ACCEPTANCE_OBSERVATION_FIELDS, "acceptance")
|
||||
return AcceptanceObservation(
|
||||
binding_digest=_text(raw["binding_digest"], "binding_digest"),
|
||||
receipt_ref=_optional_text(raw["receipt_ref"], "receipt_ref"),
|
||||
result_artifact_digest=_optional_text(
|
||||
raw["result_artifact_digest"], "result_artifact_digest"
|
||||
),
|
||||
verifier_receipt_ref=_optional_text(
|
||||
raw["verifier_receipt_ref"], "verifier_receipt_ref"
|
||||
),
|
||||
acceptance_revision=_optional_text(
|
||||
raw["acceptance_revision"], "acceptance_revision"
|
||||
),
|
||||
acceptor_id=_optional_text(raw["acceptor_id"], "acceptor_id"),
|
||||
outcome=raw["outcome"], # type: ignore[arg-type]
|
||||
authenticated=raw["authenticated"], # type: ignore[arg-type]
|
||||
observed_at=_datetime(raw["observed_at"], "observed_at"),
|
||||
expires_at=_datetime(raw["expires_at"], "expires_at"),
|
||||
)
|
||||
|
||||
|
||||
def collect_closure(
|
||||
binding: OperationBinding,
|
||||
readers: OperationReaders,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> ClosureObservation:
|
||||
"""Read closure claims independently; absent adapters remain UNKNOWN."""
|
||||
_datetime(now, "now")
|
||||
effect = _unknown_effect(binding)
|
||||
raw = _read(readers.gitops, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _GITOPS_FIELDS, "gitops reader result")
|
||||
if doc["schema"] != "aios.operation-effect-observation.v1":
|
||||
raise ValueError("unsupported effect schema")
|
||||
effect = _effect(doc["effect"])
|
||||
except (TypeError, ValueError):
|
||||
effect = _unknown_effect(binding)
|
||||
|
||||
verification = _unknown_verification(binding)
|
||||
raw = _read(readers.verifier, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _VERIFIER_FIELDS, "verifier reader result")
|
||||
if doc["schema"] != "aios.operation-verification-observation.v1":
|
||||
raise ValueError("unsupported verifier schema")
|
||||
verification = _verification(doc["verification"])
|
||||
except (TypeError, ValueError):
|
||||
verification = _unknown_verification(binding)
|
||||
|
||||
acceptance = _unknown_acceptance(binding)
|
||||
expected_acceptor_id = "unresolved-acceptor"
|
||||
expected_acceptance_revision = "unresolved-revision"
|
||||
raw = _read(readers.acceptance, binding)
|
||||
if raw is not None:
|
||||
try:
|
||||
doc = _exact_mapping(raw, _ACCEPTANCE_FIELDS, "acceptance reader result")
|
||||
if doc["schema"] != "aios.operation-acceptance-observation.v1":
|
||||
raise ValueError("unsupported acceptance schema")
|
||||
acceptance = _acceptance(doc["acceptance"])
|
||||
expected_acceptor_id = _text(
|
||||
doc["expected_acceptor_id"], "expected_acceptor_id"
|
||||
)
|
||||
expected_acceptance_revision = _text(
|
||||
doc["expected_acceptance_revision"],
|
||||
"expected_acceptance_revision",
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
acceptance = _unknown_acceptance(binding)
|
||||
|
||||
return ClosureObservation(
|
||||
effect=effect,
|
||||
verification=verification,
|
||||
acceptance=acceptance,
|
||||
expected_acceptor_id=expected_acceptor_id,
|
||||
expected_acceptance_revision=expected_acceptance_revision,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OperationReaders", "collect_closure", "collect_observation"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user