Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
540e28a724 | ||
|
|
e33664f033 | ||
|
|
83b77a53d2 | ||
|
|
30fff31145 | ||
|
|
765f337b82 | ||
|
|
034a07a05e | ||
|
|
5f65e45895 | ||
|
|
d841948c21 | ||
|
|
5d821096ce | ||
|
|
e87c70778a | ||
|
|
396ea98111 | ||
|
|
a6851c0a7e | ||
|
|
7b0d387221 | ||
|
|
faf6eb2289 | ||
|
|
d54f832668 | ||
|
|
b915386ccb | ||
|
|
2af1dfbc40 | ||
|
|
3b36619487 | ||
|
|
3e432fe42c | ||
|
|
7dbe51de39 | ||
|
|
b707f8a05f | ||
|
|
83c41c8f2e | ||
|
|
a96901ce52 | ||
|
|
084fbc9930 | ||
|
|
581e2d3c0c | ||
|
|
7ffe99a0b5 | ||
|
|
6582e8a5e1 | ||
|
|
8fd457f8f5 | ||
|
|
4e66d483e4 | ||
|
|
a887646377 | ||
|
|
1227714ce3 | ||
|
|
757de6b8ed | ||
|
|
eff070bc37 | ||
|
|
3b1c3a1b60 | ||
|
|
e55392184d | ||
|
|
7e0acaae4c | ||
|
|
3bd736db7f | ||
|
|
7022801a2d | ||
|
|
f9253dd28b | ||
|
|
c24ed5ee8e | ||
|
|
db18ca306d | ||
|
|
8f3bd0d11f | ||
|
|
7a64e326c2 | ||
|
|
3609f45d69 | ||
|
|
41df96a46a | ||
|
|
4c17ce3c98 | ||
|
|
5d0f9970f6 | ||
|
|
7837f67119 | ||
|
|
4a5e3cbfec | ||
|
|
64eed3b119 | ||
|
|
cd0a90bc37 | ||
|
|
6c1dcb829a | ||
|
|
fb8a0f3e47 | ||
|
|
d2ad00139d | ||
|
|
b5c27fec1d | ||
|
|
e43209ebdd | ||
|
|
ab149d0a57 | ||
|
|
dbefdcda07 | ||
|
|
536a2af0f0 | ||
|
|
7cc1730025 | ||
|
|
30f501c77e | ||
|
|
58987681fe | ||
|
|
440f1aedc9 | ||
|
|
9e7024ba63 | ||
|
|
e5f8209f57 | ||
|
|
4420b2afcb | ||
|
|
057e915664 | ||
|
|
89ed795e33 | ||
|
|
f18c7f1b51 | ||
|
|
9d2843c6b0 | ||
|
|
da0474ce96 | ||
|
|
e48c93ca28 |
@@ -1,40 +1,79 @@
|
||||
"""API adapter for agent context routing and visibility."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
APIRouter = HTTPException = 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]:
|
||||
route = rbac.route(payload["user_id"], payload.get("group_id"), payload.get("role", "user"))
|
||||
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)
|
||||
|
||||
context = resolver.resolve(
|
||||
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",
|
||||
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",
|
||||
)
|
||||
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
|
||||
@@ -42,15 +81,15 @@ def build_router():
|
||||
|
||||
@router.post("/resolve", operation_id="resolveAgentContext")
|
||||
def resolve(payload: dict[str, Any]):
|
||||
return resolve_agent_context(payload)
|
||||
return _http(lambda: resolve_agent_context(payload))
|
||||
|
||||
@router.get("/overview", operation_id="getAgentContextDashboard")
|
||||
def overview():
|
||||
return get_context_dashboard()
|
||||
return _http(get_context_dashboard)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "resolve_agent_context", "get_context_dashboard"]
|
||||
__all__ = ["router", "set_authenticator", "resolve_agent_context", "get_context_dashboard"]
|
||||
|
||||
@@ -1,47 +1,129 @@
|
||||
"""Actions API for unified Agent Event Bus."""
|
||||
|
||||
"""Authenticated loopback API for the 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
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
except Exception: # pragma: no cover
|
||||
APIRouter = HTTPException = Request = 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 create_agent_event(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.publish(**payload)}
|
||||
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 list_agent_events(kind: str | None = None, status: str = "pending") -> dict[str, Any]:
|
||||
return {"status": "ok", "events": bus.list_events(kind, status)}
|
||||
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 get_agent_event(event_id: str) -> dict[str, Any]:
|
||||
return {"status": "ok", "event": bus.get(event_id)}
|
||||
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 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 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 agent_event_stats() -> dict[str, Any]:
|
||||
return {"status": "ok", "stats": bus.stats()}
|
||||
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,
|
||||
)}
|
||||
|
||||
|
||||
# Compatibility adapters
|
||||
|
||||
def list_gpt_inbox_messages():
|
||||
return list_agent_events(kind="gpt_inbox")
|
||||
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)}
|
||||
|
||||
|
||||
def ack_gpt_inbox_message(event_id: str):
|
||||
return transition_agent_event(event_id, "completed", "custom-gpt")
|
||||
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 build_router():
|
||||
@@ -50,28 +132,32 @@ def build_router():
|
||||
router = APIRouter(prefix="/agent-events", tags=["agent-events"])
|
||||
|
||||
@router.post("", operation_id="createAgentEvent")
|
||||
def create(payload: dict[str, Any]):
|
||||
return create_agent_event(payload)
|
||||
def create(payload: dict[str, Any], request: Request):
|
||||
return _http(lambda: create_agent_event(payload, client_host=_host(request)))
|
||||
|
||||
@router.get("", operation_id="listAgentEvents")
|
||||
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):
|
||||
return get_agent_event(event_id)
|
||||
|
||||
@router.post("/{event_id}/transition", operation_id="transitionAgentEvent")
|
||||
def transition(event_id: str, payload: dict[str, Any]):
|
||||
return transition_agent_event(event_id, payload["status"], payload.get("actor", "system"))
|
||||
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():
|
||||
return agent_event_stats()
|
||||
def stats(request: Request):
|
||||
return _http(lambda: agent_event_stats(client_host=_host(request)))
|
||||
|
||||
@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)))
|
||||
|
||||
@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),
|
||||
))
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "create_agent_event", "list_agent_events", "get_agent_event", "transition_agent_event"]
|
||||
__all__ = [
|
||||
"router", "set_authenticator", "create_agent_event", "list_agent_events",
|
||||
"get_agent_event", "transition_agent_event", "agent_event_stats",
|
||||
]
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
"""Filesystem-backed Agent Event Bus with strict path contracts."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
@@ -12,8 +6,12 @@ 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()
|
||||
@@ -27,6 +25,7 @@ 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)
|
||||
@@ -39,87 +38,223 @@ 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)
|
||||
for state in self.STATES:
|
||||
(self.root / state).mkdir(parents=True, exist_ok=True)
|
||||
(self.root / "audit").mkdir(parents=True, exist_ok=True)
|
||||
self.root = Path(root).absolute()
|
||||
if self.root.is_symlink():
|
||||
raise ValueError("symlink event root forbidden")
|
||||
|
||||
def publish(
|
||||
self,
|
||||
kind: str,
|
||||
source: str,
|
||||
target: str,
|
||||
summary: str,
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
self, kind: str, source: str, target: str, summary: str,
|
||||
payload: dict[str, Any], correlation_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
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)
|
||||
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,
|
||||
)
|
||||
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) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for path in sorted((self.root / status).glob("*.json")):
|
||||
event = self._read(path)
|
||||
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)
|
||||
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)
|
||||
return result[:limit]
|
||||
if len(result) == limit:
|
||||
break
|
||||
return result
|
||||
|
||||
def get(self, event_id: str) -> dict[str, Any] | None:
|
||||
safe_segment(event_id)
|
||||
for state in self.STATES:
|
||||
path = self.root / state / f"{event_id}.json"
|
||||
if path.exists():
|
||||
return self._read(path)
|
||||
if f"{event_id}.json" in self._names(state):
|
||||
return self._read(state, event_id)
|
||||
return None
|
||||
|
||||
def transition(self, event_id: str, status: str, actor: str | None = None) -> dict[str, Any]:
|
||||
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
|
||||
event = self.get(event_id)
|
||||
if not event:
|
||||
return {"status": "not_found", "id": event_id}
|
||||
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()
|
||||
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())
|
||||
self._write(status, event)
|
||||
if old_path.exists() and old != status:
|
||||
old_path.unlink()
|
||||
if old != status:
|
||||
self._unlink(old, event_id)
|
||||
self._audit("transition", event, {"from": old, "to": status})
|
||||
return event
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
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"}
|
||||
return {
|
||||
"events": {state: len(list((self.root / state).glob("*.json"))) for state in self.STATES},
|
||||
"audit_events": len(list((self.root / "audit").glob("*.json"))),
|
||||
"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")),
|
||||
"status": "healthy",
|
||||
}
|
||||
|
||||
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 _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 _read(self, path: Path):
|
||||
return json.loads(path.read_text(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 _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"
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
__all__ = ["AgentEvent", "AgentEventBus"]
|
||||
|
||||
@@ -1,77 +1,178 @@
|
||||
"""API for agent orchestration observability."""
|
||||
|
||||
"""Authenticated API for agent orchestration observability."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
from fastapi import APIRouter, HTTPException
|
||||
except Exception: # pragma: no cover
|
||||
APIRouter = HTTPException = None
|
||||
|
||||
from .agent_observability import AgentExecutionRecord
|
||||
from .agent_observability_dashboard import AgentObservabilityDashboard
|
||||
from .agent_observability_store import AgentObservabilityStore
|
||||
|
||||
store = AgentObservabilityStore()
|
||||
dashboard = AgentObservabilityDashboard(store.load())
|
||||
|
||||
_ROLE_WHITELIST = {"user", "group_admin", "owner"}
|
||||
_authenticator = None # callable() -> {"user_id":..., "role":...} | None, injected by host
|
||||
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
|
||||
|
||||
|
||||
def set_authenticator(fn):
|
||||
"""Install a server-side authenticator used to derive the querying tenant.
|
||||
class ApiSecurityError(PermissionError):
|
||||
def __init__(self, status_code: int, code: str):
|
||||
super().__init__(code)
|
||||
self.status_code, self.code = status_code, code
|
||||
|
||||
The callable returns the authenticated principal (dict with "user_id" and
|
||||
a whitelisted "role") or None for anonymous. The client-supplied role and
|
||||
user/group ids are NOT trusted to escalate scope.
|
||||
"""
|
||||
|
||||
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 _principal():
|
||||
if _authenticator is not None:
|
||||
p = _authenticator()
|
||||
if isinstance(p, dict) and p.get("role") in _ROLE_WHITELIST:
|
||||
return p
|
||||
return None
|
||||
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 record_agent_execution(payload: dict[str, Any]):
|
||||
record = AgentExecutionRecord(**payload)
|
||||
return {"status": "ok", "record": store.append(record)}
|
||||
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 get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||
# The authenticated principal (tenant) is authoritative; the request params
|
||||
# role/user_id/group_id can never choose owner or another tenant.
|
||||
principal = _principal()
|
||||
pr_user = principal.get("user_id") if principal else None
|
||||
pr_role = principal.get("role", "user") if principal else "user"
|
||||
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)
|
||||
|
||||
if pr_role == "owner":
|
||||
# Server-verified owner may browse a requested scope.
|
||||
q_user, q_group, q_role = user_id, group_id, "owner"
|
||||
summary_scope = None
|
||||
elif pr_user:
|
||||
# Authenticated non-owner: strictly scoped to own tenant.
|
||||
q_user, q_group, q_role = pr_user, None, "user"
|
||||
summary_scope = pr_user
|
||||
else:
|
||||
# Anonymous: nothing scoped to them.
|
||||
q_user, q_group, q_role = None, None, "user"
|
||||
summary_scope = None
|
||||
|
||||
records = store.query(q_user, q_group, q_role)
|
||||
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"))
|
||||
return {
|
||||
"status": "ok",
|
||||
"records": records,
|
||||
"summary": store.load().summary(summary_scope),
|
||||
"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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -79,15 +180,13 @@ def build_router():
|
||||
|
||||
@router.post("/execution", operation_id="recordAgentExecution")
|
||||
def record(payload: dict[str, Any]):
|
||||
return record_agent_execution(payload)
|
||||
return _http(lambda: 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 get_agent_observability(user_id, group_id, role)
|
||||
|
||||
return _http(lambda: get_agent_observability(user_id, group_id, role))
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "record_agent_execution", "get_agent_observability"]
|
||||
__all__ = ["ApiSecurityError", "authenticated_principal", "set_authenticator", "router", "record_agent_execution", "get_agent_observability"]
|
||||
|
||||
@@ -0,0 +1,940 @@
|
||||
#!/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
|
||||
from dataclasses import dataclass, replace
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime,timezone
|
||||
import base64
|
||||
@@ -18,7 +18,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget,EvidenceEnvelope
|
||||
from task_plane.errors import TaskPlaneHTTPError
|
||||
from aios_gitops_job_controller import GitOpsJobController,JobStore,JobState,JobError
|
||||
from .evidence import ReceiptSigner,canonical,digest
|
||||
@@ -69,6 +69,7 @@ class BaselineService:
|
||||
self.store.conn.execute('''CREATE TABLE IF NOT EXISTS baseline_worker_policy(
|
||||
worker_id TEXT PRIMARY KEY,policy_json TEXT NOT NULL)''')
|
||||
self.store.probe_attestation_verifier=self._verify_probe
|
||||
self.store.evidence_attestation_verifier=self._verify_task_evidence
|
||||
|
||||
def _user(self,identity):
|
||||
if not isinstance(identity,Identity) or identity.role not in {'user','owner'}:
|
||||
@@ -379,11 +380,39 @@ class BaselineService:
|
||||
|
||||
def _close_task(self,task,claim,envelope):
|
||||
if not self._receipt_valid(task,envelope): raise ServiceError('RESULT_ATTESTATION_INVALID',403)
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
self.store.complete_claim(task.task_id,claim.claim_id,'COMPLETED',envelope,
|
||||
evidence_entries=[('diagnostic',envelope,claim.worker_identity)])
|
||||
with self.store._transaction():
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
evidence=EvidenceEnvelope.create(task_id=task.task_id,claim_id=claim.claim_id,
|
||||
kind='diagnostic',worker_identity=claim.worker_identity,payload=envelope,
|
||||
previous_evidence_hash=self.store._last_evidence_hash(task.task_id),
|
||||
attestation_ref='baseline-machine:'+self.signer.key_id)
|
||||
proof=canonical({'domain':'aios.baseline.task-evidence.v1',
|
||||
'key_id':self.signer.key_id,'evidence':evidence.digest_payload()})
|
||||
evidence=replace(evidence,signature=base64.b64encode(self.signer.private_key.sign(proof)).decode())
|
||||
self.store.complete_claim_with_evidence_and_event(task.task_id,claim.claim_id,
|
||||
'COMPLETED',envelope,[evidence])
|
||||
|
||||
def _verify_task_evidence(self,evidence):
|
||||
"""Verify the TaskPlane wrapper and its separately signed operation receipt."""
|
||||
try:
|
||||
if not isinstance(evidence,EvidenceEnvelope) or evidence.kind!='diagnostic': return False
|
||||
prefix='baseline-machine:'
|
||||
if not evidence.attestation_ref or not evidence.attestation_ref.startswith(prefix): return False
|
||||
key_id=evidence.attestation_ref[len(prefix):]
|
||||
key=self.signer.trusted_public_keys.get(key_id)
|
||||
if key is None or not evidence.signature: return False
|
||||
proof=canonical({'domain':'aios.baseline.task-evidence.v1',
|
||||
'key_id':key_id,'evidence':evidence.digest_payload()})
|
||||
key.verify(base64.b64decode(evidence.signature,validate=True),proof)
|
||||
claim=self.store.get_claim(evidence.claim_id)
|
||||
task=self.store.get_task(evidence.task_id)
|
||||
if claim.task_id!=task.task_id or claim.worker_identity!=evidence.worker_identity: return False
|
||||
if claim.operation_digest!=task.task_plan.operation_digest(): return False
|
||||
return self._receipt_valid(task,evidence.payload)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def cancel_task(self,identity,task_id):
|
||||
self._user(identity)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""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),
|
||||
}
|
||||
@@ -158,7 +158,10 @@ 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]}'
|
||||
def safe(s: str, n=160) -> str: return re.sub(r'[^A-Za-z0-9_./@-]+', '-', str(s or 'unknown')).strip('/-')[:n] or 'unknown'
|
||||
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 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')
|
||||
@@ -206,7 +209,10 @@ 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 = safe(name or f'{prefix}-{uid("br")}', 180)
|
||||
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')
|
||||
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:
|
||||
@@ -254,8 +260,12 @@ 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': return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1':
|
||||
require_isolated_dev_profile(HOST)
|
||||
return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if not token: return None
|
||||
digest=hashlib.sha256(token.encode()).hexdigest()
|
||||
for r in token_records():
|
||||
@@ -284,7 +294,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); t=WS/r; t.parent.mkdir(parents=True, exist_ok=True); t.write_text(content, encoding='utf-8'); return r
|
||||
r=relpath(path); write_text_no_follow(WS, str(r).replace('\\','/'), content); 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/'):
|
||||
@@ -835,7 +845,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
|
||||
@@ -942,7 +952,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'):
|
||||
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}'
|
||||
sid=uid('llmkey'); write_text_no_follow(SECRETS, f'llm/{owner}/{sid}.secret', str(data['api_key'])); 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()})
|
||||
@@ -1080,6 +1090,7 @@ 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,6 +216,7 @@ 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 {
|
||||
@@ -229,6 +230,35 @@ 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)
|
||||
@@ -254,7 +284,12 @@ class ControlPlaneReleaseManifest:
|
||||
)
|
||||
if not valid:
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT")
|
||||
return {**self.as_dict(), "drift_status": "PASS"}
|
||||
# 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}
|
||||
|
||||
|
||||
class BridgeV2Adapter:
|
||||
|
||||
@@ -39,6 +39,7 @@ 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[
|
||||
@@ -104,6 +105,10 @@ 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:
|
||||
@@ -236,6 +241,12 @@ 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,
|
||||
@@ -246,7 +257,8 @@ class GovernedRuntime:
|
||||
"runtime_path": str(self.config.state_root),
|
||||
"artifact_sha256": os.environ.get("AIOS_CONTROL_PLANE_ARTIFACT_SHA256"),
|
||||
"deployment_state": "STAGING",
|
||||
"drift_status": "PASS",
|
||||
"drift_status": identity["status"],
|
||||
"component_runtime_identity": identity,
|
||||
"repository": self.config.repository,
|
||||
"repository_identity": self.config.repository,
|
||||
"authoritative_git_dir": str(self.config.authoritative_git_dir),
|
||||
@@ -1332,6 +1344,17 @@ 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
|
||||
@@ -1342,21 +1365,39 @@ def main(
|
||||
BridgeHandler.principal_verifier = task_principal_verifier
|
||||
if task_admission_handlers is None:
|
||||
admission_runtime = None
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_environment,
|
||||
source_activation = os.environ.get("AIOS_SOURCE_PUBLICATION_ACTIVATION_FILE", "").strip()
|
||||
try:
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_environment,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_environment(
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(activation_raw),
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
except TaskAdmissionError as exc:
|
||||
if not source_activation or str(exc) != "TASK_ADMISSION_EPOCH_EXPIRED":
|
||||
raise
|
||||
from source_publication_runtime import expired_admission_runtime
|
||||
admission_runtime = expired_admission_runtime()
|
||||
if source_activation:
|
||||
from source_publication_runtime import (
|
||||
AdmissionRuntimeRouter, build_source_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_environment(
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(activation_raw),
|
||||
post_admission=runtime.project_admitted_task,
|
||||
if admission_runtime is None or task_principal_verifier is not None:
|
||||
raise RuntimeError("SOURCE_ACTIVATION_ORDINARY_RUNTIME_REQUIRED")
|
||||
source_runtime = build_source_runtime_from_activation_file(
|
||||
Path(source_activation), post_admission=runtime.project_admitted_task,
|
||||
ordinary_activation_file=Path(activation_raw),
|
||||
)
|
||||
admission_runtime = AdmissionRuntimeRouter(admission_runtime, source_runtime).bundle()
|
||||
if admission_runtime is not None:
|
||||
task_admission_handlers = admission_runtime.handlers
|
||||
if BridgeHandler.principal_verifier is None:
|
||||
|
||||
@@ -11,10 +11,32 @@ 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 all(self.read_plane.values()) and all(self.write_plane.values()) and all(self.release_plane.values())
|
||||
return self._readiness_reason() == "READY"
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -22,4 +44,5 @@ 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(),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html, ipaddress
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -16,6 +16,25 @@ 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},
|
||||
@@ -68,6 +87,7 @@ def token_identity(token: str):
|
||||
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
|
||||
@@ -85,7 +105,9 @@ def token_identity(token: str):
|
||||
return None
|
||||
|
||||
def valid_token(token: str) -> bool:
|
||||
if DEV_ALLOW: return True
|
||||
if DEV_ALLOW:
|
||||
validate_dev_profile()
|
||||
return True
|
||||
return token_identity(token) is not None
|
||||
def git_cmd(args, check=False):
|
||||
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
||||
@@ -163,7 +185,19 @@ 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):
|
||||
init_db(); path = urllib.parse.urlparse(self.path).path; data = parse_body(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()
|
||||
try:
|
||||
if path == '/api/model/custom/add':
|
||||
a = self.require_write();
|
||||
@@ -217,7 +251,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':
|
||||
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')
|
||||
username,role,tier=runtime_selection(a, data); 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'
|
||||
@@ -292,5 +326,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(): init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
def main(): validate_dev_profile(); init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
@@ -486,8 +486,12 @@ 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': return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1':
|
||||
require_isolated_dev_profile(HOST)
|
||||
return {'account':'dev-owner','role':'owner','scopes':['*']}
|
||||
if not token: return None
|
||||
digest=hashlib.sha256(token.encode()).hexdigest()
|
||||
for r in token_records():
|
||||
@@ -1303,6 +1307,7 @@ 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()
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""aios_owner_based_executor — Owner 基于授权的可执行 CLI 入口。
|
||||
|
||||
把 "owner 基于授权 → Agent 自主完成受治理写链" 从测试/装配层提升为**可直接执行的命令**。
|
||||
集成 V2R2C3 现场组合根 `field_governed_writer.py`,装配到生产入口
|
||||
`GovernedWriterAutomation.acquire_verified()`。
|
||||
|
||||
子命令:
|
||||
register <authorization_ref> 登记一条 Owner 授权到 authority(可 --policy-json 指定,
|
||||
默认用内置参考授权物 azn-owner-gw-v1)。
|
||||
acquire <authorization_ref> 装配 FieldGovernedWriterFactory,运行 acquire_verified(),
|
||||
输出 GovernedWriterSession JSON。
|
||||
|
||||
行为契约:
|
||||
- 生产入口唯一授权来源是 authority 已登记的 authorization_ref;未登记即 fail-closed。
|
||||
- authority 未装配(--no-authority 或构造时 owner_authority=None)→ AuthorizationDenied。
|
||||
- EXPIRED 任务无预签 recovery → OwnerActionRequired(OWNER_BOUNDARY,非自动恢复)。
|
||||
- status==error 输出到 stderr,退出码非 0;成功输出会话 JSON,退出码 0。
|
||||
- legacy_global_writes_enabled 默认 false = 安全不变式,不是 blocker。
|
||||
|
||||
用法示例:
|
||||
python aios_owner_based_executor.py register azn-owner-gw-v1
|
||||
python aios_owner_based_executor.py acquire azn-owner-gw-v1
|
||||
python aios_owner_based_executor.py acquire azn-owner-gw-v1 --branch-mode continue --head-oid <oid>
|
||||
python aios_owner_based_executor.py acquire azn-owner-gw-v1 --task-status EXPIRED # OwnerBoundary
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.task_plane.field_governed_writer import (
|
||||
FieldGovernedWriterFactory,
|
||||
FieldOwnerAuthorizationAuthority,
|
||||
FieldRuntimeStatus,
|
||||
)
|
||||
from app.task_plane.governance_control_kernel import (
|
||||
ActionLedger,
|
||||
ConsistencyBarrier,
|
||||
ManifestStore,
|
||||
DiscoveryObservation,
|
||||
)
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
OwnerActionRequired,
|
||||
OwnerAuthorizationPolicy,
|
||||
TaskState,
|
||||
)
|
||||
|
||||
# 版本与示例授权引用
|
||||
VERSION = "V2R2C3"
|
||||
DEFAULT_AZN_REF = "azn-owner-gw-v1"
|
||||
|
||||
_AZN_CAPS = frozenset(
|
||||
{
|
||||
"code.write",
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
"test.execute",
|
||||
"writer.lease",
|
||||
"task.principal.issue",
|
||||
}
|
||||
)
|
||||
_AZN_OPS = frozenset(
|
||||
{
|
||||
"claim",
|
||||
"epoch_reissue",
|
||||
"governed_commit",
|
||||
"heartbeat",
|
||||
"job_reconcile",
|
||||
"readback",
|
||||
"reconcile",
|
||||
"successor_recovery",
|
||||
"task_lease",
|
||||
"task_principal",
|
||||
"writer_lease",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 授权物 / 请求构造
|
||||
# ---------------------------------------------------------------------------
|
||||
def reference_policy(*, expires_at: float | None = None) -> OwnerAuthorizationPolicy:
|
||||
"""内置参考授权物 azn-owner-gw-v1(现场登记时 Owner 设定当下有效 expires_at)。"""
|
||||
if expires_at is None:
|
||||
expires_at = time.time() + 30 * 86400.0
|
||||
return OwnerAuthorizationPolicy(
|
||||
authorization_id=DEFAULT_AZN_REF,
|
||||
subject="aios-autonomous-admin",
|
||||
repository="aios/aios",
|
||||
allowed_branch_prefixes=("agent/autonomy-",),
|
||||
allowed_capabilities=_AZN_CAPS,
|
||||
risk_ceiling="medium",
|
||||
trust_domain="aios.owner",
|
||||
revocation_epoch=1,
|
||||
not_before=0.0,
|
||||
expires_at=expires_at,
|
||||
revoked=False,
|
||||
allowed_operations=_AZN_OPS,
|
||||
)
|
||||
|
||||
|
||||
def _default_subject_repo_branch(task: str) -> tuple[str, str, str]:
|
||||
return ("aios-autonomous-admin", "aios/aios", f"agent/autonomy-{task}")
|
||||
|
||||
|
||||
def build_request(
|
||||
*,
|
||||
task_id: str,
|
||||
manifest_id: str,
|
||||
runtime_fingerprint: str,
|
||||
process_start_identity: str,
|
||||
plan_digest: str,
|
||||
base_oid: str,
|
||||
generation: int,
|
||||
risk: str,
|
||||
caps: frozenset[str],
|
||||
files: tuple[str, ...],
|
||||
overrides: dict | None = None,
|
||||
) -> GovernedWriterRequest:
|
||||
subject, repo, branch = _default_subject_repo_branch(task_id)
|
||||
body = dict(
|
||||
subject=subject,
|
||||
task_id=task_id,
|
||||
repository=repo,
|
||||
target_branch=branch,
|
||||
risk=risk,
|
||||
generation=generation,
|
||||
base_oid=base_oid,
|
||||
required_capabilities=caps,
|
||||
file_allowlist=files,
|
||||
plan_digest=plan_digest,
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint=runtime_fingerprint,
|
||||
process_start_identity=process_start_identity,
|
||||
)
|
||||
if overrides:
|
||||
body.update(overrides)
|
||||
return GovernedWriterRequest(**body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 依据配置构造 authority(含 --no-authority fail-closed 场景)
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_authority(
|
||||
*, policy_json_path: str | None, use_reference: bool, expires_at: float | None
|
||||
) -> FieldOwnerAuthorizationAuthority | None:
|
||||
if policy_json_path:
|
||||
data = json.loads(Path(policy_json_path).read_text(encoding="utf-8"))
|
||||
pol = OwnerAuthorizationPolicy(**data["policy"])
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(data["authorization_ref"], pol)
|
||||
return auth
|
||||
if use_reference:
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(DEFAULT_AZN_REF, reference_policy(expires_at=expires_at))
|
||||
return auth
|
||||
return None # authority 未装配 -> acquire_verified 直接 fail-closed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子命令实现
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_register(args: argparse.Namespace) -> int:
|
||||
expires_at = float(args.expires_at) if args.expires_at else None
|
||||
if args.policy_json:
|
||||
data = json.loads(Path(args.policy_json).read_text(encoding="utf-8"))
|
||||
pol = OwnerAuthorizationPolicy(**data["policy"])
|
||||
ref = data.get("authorization_ref", pol.authorization_id)
|
||||
else:
|
||||
pol = reference_policy(expires_at=expires_at)
|
||||
ref = DEFAULT_AZN_REF
|
||||
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(ref, pol)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "registered",
|
||||
"authorization_ref": ref,
|
||||
"policy_fingerprint": pol.policy_fingerprint,
|
||||
"subject": pol.subject,
|
||||
"repository": pol.repository,
|
||||
"allowed_branch_prefixes": list(pol.allowed_branch_prefixes),
|
||||
"risk_ceiling": pol.risk_ceiling,
|
||||
"expires_at": pol.expires_at,
|
||||
"note": "registered in-memory authority; field authority must persist",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_acquire(args: argparse.Namespace) -> int:
|
||||
from app.task_plane.governance_control_kernel import StaleManifest
|
||||
|
||||
# 使用独立工作目录构造 manifest + ledger,避免污染运行目录
|
||||
workdir = Path(args.workdir) if args.workdir else None
|
||||
if workdir is None:
|
||||
workdir = Path(tempfile.mkdtemp(prefix="gw_exec_"))
|
||||
|
||||
store = ManifestStore(str(workdir / "manifest.db"))
|
||||
ledger = ActionLedger(str(workdir / "ledger.db"))
|
||||
barrier = ConsistencyBarrier(["W1"])
|
||||
barrier.submit(
|
||||
DiscoveryObservation.build(
|
||||
worker="W1",
|
||||
runtime_fingerprint=args.runtime,
|
||||
process_start_identity=args.process_start,
|
||||
activation_digest="activation-exec",
|
||||
epoch_id="epoch-exec-prov",
|
||||
policy_digest="policy-exec",
|
||||
plan_provider_root="/plans/exec",
|
||||
task_id=args.task,
|
||||
grant_id="grant-exec",
|
||||
facts={},
|
||||
evidence_refs=(),
|
||||
observed_at=100.0,
|
||||
)
|
||||
)
|
||||
manifest_id = store.freeze(barrier.freeze())
|
||||
|
||||
auth = build_authority(
|
||||
policy_json_path=args.policy_json,
|
||||
use_reference=args.reference,
|
||||
expires_at=float(args.expires_at) if args.expires_at else None,
|
||||
)
|
||||
|
||||
task_status = args.task_status.upper()
|
||||
task = TaskState(task_id=args.task, status=task_status, revision=1)
|
||||
|
||||
if args.branch_mode == "create":
|
||||
branch_exists, head_oid = False, None
|
||||
elif args.branch_mode == "continue":
|
||||
branch_exists, head_oid = True, args.head_oid
|
||||
else: # auto: 由 read 决定(默认新分支)
|
||||
branch_exists, head_oid = (True, args.head_oid) if args.head_oid else (False, None)
|
||||
|
||||
runtime_status = FieldRuntimeStatus(
|
||||
legacy_global_writes_enabled=args.legacy_global,
|
||||
task_principal_writes_enabled=args.principal_writes,
|
||||
task_principal_verifier_ready=args.verifier_ready,
|
||||
governed_write_blockers=tuple(args.blocker or ()),
|
||||
)
|
||||
|
||||
# fail-closed authority 场景:--no-authority 显式不装配
|
||||
if args.no_authority:
|
||||
auth = None
|
||||
|
||||
adapters = _make_adapters(
|
||||
authority=auth,
|
||||
manifest_store=store,
|
||||
action_ledger=ledger,
|
||||
task=task,
|
||||
branch_exists=branch_exists,
|
||||
head_oid=head_oid,
|
||||
repo=args.repo if args.repo else "aios/aios",
|
||||
branch=args.branch if args.branch else f"agent/autonomy-{args.task}",
|
||||
generation=args.generation,
|
||||
runtime_status=runtime_status,
|
||||
)
|
||||
if auth is None:
|
||||
adapters.owner_authority = None
|
||||
|
||||
automation = _automation(store, ledger)
|
||||
request = build_request(
|
||||
task_id=args.task,
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint=args.runtime,
|
||||
process_start_identity=args.process_start,
|
||||
plan_digest=args.plan_digest,
|
||||
base_oid=args.base_oid,
|
||||
generation=args.generation,
|
||||
risk=args.risk,
|
||||
caps=set(_AZN_CAPS),
|
||||
files=tuple(args.files),
|
||||
)
|
||||
|
||||
try:
|
||||
session = automation.acquire_verified(
|
||||
request=request,
|
||||
authorization_ref=args.authorization_ref,
|
||||
readiness=runtime_status_to_readiness(runtime_status),
|
||||
epoch=None,
|
||||
adapters=adapters,
|
||||
now=time.time(),
|
||||
)
|
||||
print(json.dumps({"status": "ok", "version": VERSION, "session": session.to_dict()},
|
||||
ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except OwnerActionRequired as exc:
|
||||
print(json.dumps({"status": "owner_boundary", "version": VERSION, "reason": str(exc)},
|
||||
ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 3
|
||||
except AuthorizationDenied as exc:
|
||||
print(json.dumps({"status": "auth_denied", "version": VERSION, "reason": str(exc)},
|
||||
ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 2
|
||||
except Exception as exc: # noqa: BLE001 - CLI 边界统一收口
|
||||
print(json.dumps({"status": "error", "version": VERSION,
|
||||
"error": f"{type(exc).__name__}: {exc}"},
|
||||
ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
_cleanup_workdir(workdir)
|
||||
|
||||
|
||||
def _make_adapters(
|
||||
*,
|
||||
authority,
|
||||
manifest_store,
|
||||
action_ledger,
|
||||
task,
|
||||
branch_exists,
|
||||
head_oid,
|
||||
repo,
|
||||
branch,
|
||||
generation,
|
||||
runtime_status,
|
||||
):
|
||||
from app.task_plane.field_governed_writer import _DefaultExecutionPlane, _DefaultGit, \
|
||||
_DefaultJobs, _DefaultPrincipalIssuer, _DefaultTrustedSigner, _DefaultWriterLease
|
||||
from app.task_plane.governed_writer_automation import GovernedWriterAdapters
|
||||
from app.task_plane.governed_writer_automation import GovernedWriteReadiness
|
||||
|
||||
return GovernedWriterAdapters(
|
||||
signer=_DefaultTrustedSigner(),
|
||||
execution=_DefaultExecutionPlane(task),
|
||||
principal_issuer=_DefaultPrincipalIssuer(),
|
||||
jobs=_DefaultJobs(repository=repo, target_branch=branch, generation=generation),
|
||||
git=_DefaultGit(exists=branch_exists, head_oid=head_oid),
|
||||
writer_lease=_DefaultWriterLease(),
|
||||
owner_authority=authority,
|
||||
)
|
||||
|
||||
|
||||
def runtime_status_to_readiness(rs: FieldRuntimeStatus):
|
||||
from app.task_plane.governed_writer_automation import GovernedWriteReadiness
|
||||
|
||||
return GovernedWriteReadiness(
|
||||
legacy_global_writes_enabled=rs.legacy_global_writes_enabled,
|
||||
task_principal_writes_enabled=rs.task_principal_writes_enabled,
|
||||
task_principal_verifier_ready=rs.task_principal_verifier_ready,
|
||||
governed_write_blockers=tuple(rs.governed_write_blockers),
|
||||
)
|
||||
|
||||
|
||||
def _automation(store, ledger):
|
||||
from app.task_plane.governed_writer_automation import GovernedWriterAutomation
|
||||
|
||||
return GovernedWriterAutomation(manifest_store=store, action_ledger=ledger)
|
||||
|
||||
|
||||
def _cleanup_workdir(workdir: Path) -> None:
|
||||
import gc
|
||||
import shutil
|
||||
|
||||
try:
|
||||
gc.collect()
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
except Exception: # pragma: no cover - 清理失败不影响结果
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 参数与入口
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
import tempfile # noqa: F401 (确保模块可见)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="AIOS Owner 基于授权执行器 (V2R2C3)",
|
||||
prog="aios_owner_based_executor",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_register = sub.add_parser("register", help="登记 Owner 授权到 authority")
|
||||
p_register.add_argument("authorization_ref", nargs="?", default=DEFAULT_AZN_REF)
|
||||
p_register.add_argument("--policy-json", help="policy JSON 文件(含 authorization_ref + policy)")
|
||||
p_register.add_argument("--expires-at", help="override expires_at (unix)")
|
||||
p_register.set_defaults(func=cmd_register)
|
||||
|
||||
p_acquire = sub.add_parser("acquire", help="运行 acquire_verified() 完成受治理写链")
|
||||
p_acquire.add_argument("authorization_ref", nargs="?", default=DEFAULT_AZN_REF)
|
||||
p_acquire.add_argument("--no-authority", action="store_true",
|
||||
help="显式不装配 authority -> fail-closed")
|
||||
p_acquire.add_argument("--policy-json", help="从文件装载授权物(替代内置参考)")
|
||||
p_acquire.add_argument("--reference", action="store_true",
|
||||
help="使用内置参考授权物 azn-owner-gw-v1")
|
||||
p_acquire.add_argument("--expires-at", help="override expires_at (unix)")
|
||||
p_acquire.add_argument("--task", default="task-gw-exec-0001")
|
||||
p_acquire.add_argument("--task-status", default="QUEUED",
|
||||
choices=["QUEUED", "CLAIMED", "RUNNING", "EXPIRED"])
|
||||
p_acquire.add_argument("--branch-mode", default="create", choices=["create", "continue", "auto"])
|
||||
p_acquire.add_argument("--head-oid", help="继续分支的 live HEAD oid")
|
||||
p_acquire.add_argument("--base-oid", default="5cf20128d8be2d10aef87b09f8f7a2aecb2000f3")
|
||||
p_acquire.add_argument("--repo", help="仓库(默认 aios/aios)")
|
||||
p_acquire.add_argument("--branch", help="目标分支(默认 agent/autonomy-<task>)")
|
||||
p_acquire.add_argument("--generation", type=int, default=1)
|
||||
p_acquire.add_argument("--risk", default="medium")
|
||||
p_acquire.add_argument("--runtime", default="runtime-gw-exec")
|
||||
p_acquire.add_argument("--process-start", default="pid:gw:exec:1")
|
||||
p_acquire.add_argument("--plan-digest", default="sha256:plan-exec")
|
||||
p_acquire.add_argument("--files", nargs="*", default=[
|
||||
"active-runtime/app/task_plane/governance_control_kernel.py",
|
||||
"active-runtime/app/task_plane/governed_writer_automation.py",
|
||||
"active-runtime/app/task_plane/field_governed_writer.py",
|
||||
])
|
||||
p_acquire.add_argument("--workdir", help="manifest/ledger 工作目录(默认临时)")
|
||||
# 运行时状态(W-RUNTIME 只读线)
|
||||
p_acquire.add_argument("--legacy-global", action="store_true",
|
||||
help="legacy_global_writes_enabled(默认 false=不变式)")
|
||||
p_acquire.add_argument("--principal-writes", action="store_true", default=True,
|
||||
help="task_principal_writes_enabled(默认 true)")
|
||||
p_acquire.add_argument("--verifier-ready", action="store_true", default=True,
|
||||
help="task_principal_verifier_ready(默认 true)")
|
||||
p_acquire.add_argument("--blocker", action="append",
|
||||
help="governed_write_blockers 追加")
|
||||
p_acquire.set_defaults(func=cmd_acquire)
|
||||
|
||||
parser.add_argument("--version", action="version", version=VERSION)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return int(args.func(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile # noqa: F401
|
||||
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit(130)
|
||||
@@ -51,15 +51,30 @@ AUTHORIZATION_BUNDLE_SCHEMA = "aios.owner-authorization-bundle.v1"
|
||||
MACHINE_CAPABILITY_SCHEMA = "aios.machine-derived-capability.v1"
|
||||
BOOTSTRAP_STATE_SCHEMA = "aios.single-host-bootstrap-state.v1"
|
||||
OWNER_EPOCH_SCHEMA = "aios.owner-delegation-epoch.v2"
|
||||
SOURCE_PUBLICATION_POLICY_SCHEMA = "aios.source-publication-policy.v1"
|
||||
SOURCE_PUBLICATION_POLICY_ID = "C5_SOURCE_PUBLICATION_ONLY"
|
||||
SOURCE_PUBLICATION_CAPABILITIES = frozenset(
|
||||
{
|
||||
"agent-task:control",
|
||||
"governed.task.create",
|
||||
"governed.write.files",
|
||||
"task.principal.issue",
|
||||
"writer.lease",
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_REGISTRY_PATH = Path("/etc/aios/single-host-signer-registry.json")
|
||||
DEFAULT_OWNER_SIGNER_ID = "local-owner-authority"
|
||||
DEFAULT_MACHINE_SIGNER_ID = "machine-operational"
|
||||
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_CAP = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{2,255}$")
|
||||
_RISK = {"low": 0, "medium": 1, "high": 2}
|
||||
# ``engineering`` is deliberately between medium and high. Adding this rank
|
||||
# does not raise any configured ceiling; existing policy files keep their
|
||||
# existing value (the normal local-owner policy is ``medium``).
|
||||
_RISK = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
_MAX_JSON = 128 * 1024
|
||||
_MAX_KEY = 64 * 1024
|
||||
_MAGIC = b"SSHSIG"
|
||||
@@ -691,6 +706,30 @@ class OwnerAuthorizationBundle:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SourcePublicationPolicyRequest:
|
||||
"""One exact, source-only unattended publication authorization request."""
|
||||
|
||||
policy_id: str
|
||||
repository: str
|
||||
authority_base_oid: str
|
||||
source_oid: str
|
||||
source_tree_oid: str
|
||||
target_agent: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
execution_digest: str
|
||||
exact_files: tuple[str, ...]
|
||||
nonce: str
|
||||
ttl_seconds: int
|
||||
risk: str = "engineering"
|
||||
production_impact: str = "none"
|
||||
renewal: str = "none"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "exact_files", tuple(self.exact_files))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MachineCapabilityRequest:
|
||||
capability_id: str
|
||||
@@ -755,20 +794,28 @@ class LocalOwnerAuthority(LocalFileSigner):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_path: Path,
|
||||
key_path: Path | None,
|
||||
policy: SingleHostOwnerPolicy,
|
||||
*,
|
||||
require_root_owner: bool = True,
|
||||
clock: Any = time.time,
|
||||
registered_signer: Any | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
key_path,
|
||||
key_path or Path("/dev/null"),
|
||||
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.policy = policy
|
||||
self.clock = clock
|
||||
self.registered_signer = registered_signer
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
if self.registered_signer is not None:
|
||||
return str(self.registered_signer.fingerprint)
|
||||
return super().fingerprint
|
||||
|
||||
@staticmethod
|
||||
def _prefix_narrows(requested: str, allowed: Sequence[str]) -> bool:
|
||||
@@ -831,6 +878,111 @@ class LocalOwnerAuthority(LocalFileSigner):
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return self._sign_canonical(value)
|
||||
|
||||
def _sign_canonical(self, value: Mapping[str, Any]) -> SignedArtifact:
|
||||
if self.registered_signer is None:
|
||||
return super()._sign_canonical(value)
|
||||
payload = canonical_json_bytes(dict(value))
|
||||
signature = self.registered_signer._sign_canonical_payload(
|
||||
self.namespace, payload
|
||||
)
|
||||
return SignedArtifact(
|
||||
payload=dict(value),
|
||||
canonical_payload=payload,
|
||||
signature=signature,
|
||||
key_id=self.fingerprint,
|
||||
namespace=self.namespace,
|
||||
)
|
||||
|
||||
def issue_source_publication_policy(
|
||||
self, request: SourcePublicationPolicyRequest
|
||||
) -> SignedArtifact:
|
||||
"""Sign one exact C5 source-publication policy.
|
||||
|
||||
This is an issuance operation only. It cannot change the configured
|
||||
owner policy, its ceiling, branch/file allowlists, or trust domain.
|
||||
"""
|
||||
now = int(self.clock())
|
||||
files = tuple(request.exact_files)
|
||||
def source_scope_narrows(value: str, allowed: Sequence[str]) -> bool:
|
||||
base = value.rstrip("/")
|
||||
return any(
|
||||
base == prefix.rstrip("/")
|
||||
or base.startswith(prefix.rstrip("/") + "/")
|
||||
for prefix in allowed
|
||||
)
|
||||
if (
|
||||
request.policy_id != SOURCE_PUBLICATION_POLICY_ID
|
||||
or request.repository != self.policy.repository
|
||||
or not _HEX40.fullmatch(request.authority_base_oid)
|
||||
or not _HEX40.fullmatch(request.source_oid)
|
||||
or not _HEX40.fullmatch(request.source_tree_oid)
|
||||
or not _ID.fullmatch(request.target_agent)
|
||||
or not request.target_agent.startswith("agent/")
|
||||
or not source_scope_narrows(
|
||||
request.target_branch, self.policy.allowed_branch_prefixes
|
||||
)
|
||||
or not request.target_branch.startswith("agent/")
|
||||
or "\\" in request.target_branch
|
||||
or ".." in PurePosixPath(request.target_branch).parts
|
||||
or request.target_branch.endswith(("/", "."))
|
||||
or type(request.generation) is not int
|
||||
or request.generation < 1
|
||||
or not _HEX64.fullmatch(request.execution_digest)
|
||||
or not files
|
||||
or any(
|
||||
not item
|
||||
or PurePosixPath(item).is_absolute()
|
||||
or "\\" in item
|
||||
or ".." in PurePosixPath(item).parts
|
||||
or item.startswith(".git")
|
||||
or not source_scope_narrows(item, self.policy.allowed_file_prefixes)
|
||||
for item in files
|
||||
)
|
||||
or not SOURCE_PUBLICATION_CAPABILITIES.issubset(self.policy.allowed_capabilities)
|
||||
or len(set(files)) != len(files)
|
||||
or not _ID.fullmatch(request.nonce)
|
||||
or request.risk != "engineering"
|
||||
or request.risk not in _RISK
|
||||
or _RISK[request.risk] > _RISK[self.policy.risk_ceiling]
|
||||
or request.production_impact != "none"
|
||||
or request.renewal != "none"
|
||||
or type(request.ttl_seconds) is not int
|
||||
or not 1 <= request.ttl_seconds <= 3600
|
||||
or request.ttl_seconds > self.policy.maximum_epoch_ttl_seconds
|
||||
):
|
||||
_fail("SOURCE_PUBLICATION_REQUEST_WIDENS_POLICY")
|
||||
value: dict[str, Any] = {
|
||||
"schema": SOURCE_PUBLICATION_POLICY_SCHEMA,
|
||||
"policy_id": SOURCE_PUBLICATION_POLICY_ID,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"repository": request.repository,
|
||||
"trust_domain": self.policy.trust_domain,
|
||||
"policy_digest": self.policy.digest,
|
||||
"authority_base_oid": request.authority_base_oid,
|
||||
"source_oid": request.source_oid,
|
||||
"source_tree_oid": request.source_tree_oid,
|
||||
"target_agent": request.target_agent,
|
||||
"target_branch": request.target_branch,
|
||||
"generation": request.generation,
|
||||
"execution_digest": request.execution_digest,
|
||||
"exact_files": list(files),
|
||||
"capabilities": sorted(SOURCE_PUBLICATION_CAPABILITIES),
|
||||
"risk": "engineering",
|
||||
"production_impact": "none",
|
||||
"renewal": "none",
|
||||
"nonce": request.nonce,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 30),
|
||||
"expires_at": now + request.ttl_seconds,
|
||||
"status": "ACTIVE",
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": self.fingerprint,
|
||||
}
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return self._sign_canonical(value)
|
||||
|
||||
issue_source_only_policy = issue_source_publication_policy
|
||||
|
||||
def issue_authorization_bundle(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -35,6 +35,8 @@ from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
SCHEMA_ENVELOPE = "aios.task-admission-envelope.v1"
|
||||
SCHEMA_GRANT = "aios.task-admission-grant.v1"
|
||||
SCHEMA_EPOCH = "aios.owner-delegation-epoch.v1"
|
||||
SCHEMA_EPOCH_V2 = "aios.owner-delegation-epoch.v2"
|
||||
SUPPORTED_EPOCH_SCHEMAS = frozenset({SCHEMA_EPOCH, SCHEMA_EPOCH_V2})
|
||||
SCHEMA_RECEIPT = "aios.task-admission-receipt.v1"
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
@@ -142,9 +144,30 @@ class OwnerDelegationEpoch:
|
||||
revocation_epoch: int
|
||||
policy_digest: str
|
||||
schema: str = SCHEMA_EPOCH
|
||||
production_scope: str | None = None
|
||||
destructive_policy: str | None = None
|
||||
# Provenance retained by the consumer. These fields are populated by the
|
||||
# signed-directory provider for v2 (and left at their v1-compatible
|
||||
# defaults when reading legacy epochs); they are never used to translate or
|
||||
# re-sign the original artifact.
|
||||
status: str = ""
|
||||
signature_namespace: str = ""
|
||||
signature_key_id: str = ""
|
||||
canonical_payload_digest: str = ""
|
||||
canonical_payload: bytes = b""
|
||||
|
||||
@property
|
||||
def issuer(self) -> str:
|
||||
"""The signed epoch issuer, retained under the legacy identity name."""
|
||||
return self.owner_identity
|
||||
|
||||
@property
|
||||
def canonical_payload_bytes(self) -> bytes:
|
||||
return self.canonical_payload
|
||||
|
||||
def as_public_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value.pop("canonical_payload", None)
|
||||
value["allowed_capabilities"] = sorted(self.allowed_capabilities)
|
||||
value["allowed_branch_prefixes"] = list(self.allowed_branch_prefixes)
|
||||
value["allowed_file_prefixes"] = list(self.allowed_file_prefixes)
|
||||
@@ -309,7 +332,7 @@ def _normalize_capabilities(
|
||||
|
||||
|
||||
def _risk_rank(value: str) -> int:
|
||||
ranks = {"low": 0, "medium": 1, "high": 2}
|
||||
ranks = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
try:
|
||||
return ranks[value]
|
||||
except KeyError:
|
||||
@@ -838,7 +861,7 @@ class TaskAdmissionBootstrapService:
|
||||
now: int,
|
||||
) -> OwnerDelegationEpoch:
|
||||
epoch = self.authorization.verify_delegation_epoch(epoch_id)
|
||||
if epoch.schema != SCHEMA_EPOCH:
|
||||
if epoch.schema not in SUPPORTED_EPOCH_SCHEMAS:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_SCHEMA_INVALID", 403)
|
||||
if epoch.epoch_id != epoch_id:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_ID_MISMATCH", 403)
|
||||
|
||||
@@ -26,6 +26,8 @@ import urllib.request
|
||||
|
||||
from aios_task_admission_bootstrap import (
|
||||
OwnerDelegationEpoch,
|
||||
SCHEMA_EPOCH,
|
||||
SCHEMA_EPOCH_V2,
|
||||
TaskAdmissionError,
|
||||
TaskAdmissionGrant,
|
||||
canonical_json_bytes,
|
||||
@@ -44,6 +46,19 @@ from aios_gitops_job_identity import (
|
||||
|
||||
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):
|
||||
@@ -463,6 +478,7 @@ class TaskAdmissionPrincipalProvider:
|
||||
class TaskAdmissionRuntimeBundle:
|
||||
handlers: Mapping[str, Any]
|
||||
principal_verifier: TaskAdmissionPrincipalProvider
|
||||
service: Any | None = None
|
||||
|
||||
|
||||
class TaskAdmissionHostError(RuntimeError):
|
||||
@@ -622,11 +638,15 @@ class SignedEpochDirectoryProvider:
|
||||
"TASK_ADMISSION_EPOCH_CANONICAL_MISMATCH",
|
||||
403,
|
||||
)
|
||||
if value.get("schema") != "aios.owner-delegation-epoch.v1":
|
||||
schema = value.get("schema")
|
||||
if schema not in {SCHEMA_EPOCH, SCHEMA_EPOCH_V2}:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EPOCH_SCHEMA_INVALID",
|
||||
403,
|
||||
)
|
||||
expected_fields = _EPOCH_V1_FIELDS if schema == SCHEMA_EPOCH else _EPOCH_V2_FIELDS
|
||||
if set(value) != expected_fields:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_FIELDS_INVALID", 403)
|
||||
if value.get("epoch_id") != epoch_id:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EPOCH_ID_MISMATCH",
|
||||
@@ -642,7 +662,34 @@ class SignedEpochDirectoryProvider:
|
||||
if epoch_id in self._revoked_ids():
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REVOKED", 403)
|
||||
|
||||
# v2 is a producer-authored envelope. Validate its self-digest and
|
||||
# signature binding before the cryptographic verifier sees the exact
|
||||
# bytes from disk. The digest is over the payload with only the digest
|
||||
# member removed; no schema translation is permitted.
|
||||
if schema == SCHEMA_EPOCH_V2:
|
||||
if (
|
||||
value.get("status") != "ACTIVE"
|
||||
or not isinstance(value.get("business_goal"), str)
|
||||
or not value["business_goal"]
|
||||
or not isinstance(value.get("production_scope"), str)
|
||||
or not value["production_scope"]
|
||||
or not isinstance(value.get("destructive_policy"), str)
|
||||
or value["destructive_policy"] not in {"forbidden", "rollback-only"}
|
||||
or value.get("signature_namespace") != OWNER_NAMESPACE
|
||||
or not isinstance(value.get("signature_key_id"), str)
|
||||
or not _SSH_FINGERPRINT_RE.fullmatch(value["signature_key_id"])
|
||||
or not isinstance(value.get("canonical_payload_digest"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", value["canonical_payload_digest"])
|
||||
):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_V2_BINDING_INVALID", 403)
|
||||
digest_value = dict(value)
|
||||
supplied_digest = digest_value.pop("canonical_payload_digest", None)
|
||||
if sha256_json(digest_value) != supplied_digest:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_PAYLOAD_DIGEST_INVALID", 403)
|
||||
|
||||
key_id = key_id_file.read_text(encoding="utf-8").strip()
|
||||
if schema == SCHEMA_EPOCH_V2 and key_id != value["signature_key_id"]:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_KEY_BINDING_INVALID", 403)
|
||||
armored = signature.read_text(encoding="utf-8")
|
||||
if self.verifier.verify(
|
||||
namespace=OWNER_NAMESPACE,
|
||||
@@ -659,6 +706,23 @@ class SignedEpochDirectoryProvider:
|
||||
not_before = value.get("not_before")
|
||||
expires_at = value.get("expires_at")
|
||||
if (
|
||||
not isinstance(value.get("epoch_id"), str)
|
||||
or not value["epoch_id"]
|
||||
or not isinstance(value.get("owner_identity"), str)
|
||||
or not value["owner_identity"]
|
||||
or not isinstance(value.get("trust_domain"), str)
|
||||
or not value["trust_domain"]
|
||||
or not isinstance(value.get("repository"), str)
|
||||
or not value["repository"]
|
||||
or not isinstance(value.get("risk_ceiling"), str)
|
||||
or value["risk_ceiling"] not in {"low", "medium", "engineering", "high"}
|
||||
or not isinstance(value.get("issued_at"), int)
|
||||
or isinstance(value.get("issued_at"), bool)
|
||||
or not isinstance(value.get("revocation_epoch"), int)
|
||||
or isinstance(value.get("revocation_epoch"), bool)
|
||||
or not isinstance(value.get("policy_digest"), str)
|
||||
or not value["policy_digest"]
|
||||
or
|
||||
not isinstance(not_before, int)
|
||||
or isinstance(not_before, bool)
|
||||
or not isinstance(expires_at, int)
|
||||
@@ -672,13 +736,16 @@ class SignedEpochDirectoryProvider:
|
||||
files = value.get("allowed_file_prefixes")
|
||||
if (
|
||||
not isinstance(branches, list)
|
||||
or not branches
|
||||
or not all(isinstance(item, str) and item for item in branches)
|
||||
or not isinstance(capabilities, list)
|
||||
or not capabilities
|
||||
or not all(
|
||||
isinstance(item, str) and item
|
||||
for item in capabilities
|
||||
)
|
||||
or not isinstance(files, list)
|
||||
or not files
|
||||
or not all(isinstance(item, str) and item for item in files)
|
||||
):
|
||||
raise TaskAdmissionError(
|
||||
@@ -701,6 +768,19 @@ class SignedEpochDirectoryProvider:
|
||||
revocation_epoch=int(value["revocation_epoch"]),
|
||||
policy_digest=str(value["policy_digest"]),
|
||||
schema=str(value["schema"]),
|
||||
production_scope=(
|
||||
str(value["production_scope"])
|
||||
if schema == SCHEMA_EPOCH_V2 else None
|
||||
),
|
||||
destructive_policy=(
|
||||
str(value["destructive_policy"])
|
||||
if schema == SCHEMA_EPOCH_V2 else None
|
||||
),
|
||||
status=str(value.get("status", "")),
|
||||
signature_namespace=str(value.get("signature_namespace", "")),
|
||||
signature_key_id=str(value.get("signature_key_id", "")),
|
||||
canonical_payload_digest=str(value.get("canonical_payload_digest", "")),
|
||||
canonical_payload=payload,
|
||||
)
|
||||
|
||||
|
||||
@@ -915,6 +995,7 @@ def _build_task_admission_runtime(
|
||||
task_port=task_port,
|
||||
epoch_id=epoch.epoch_id,
|
||||
),
|
||||
service=service,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ 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}$")
|
||||
|
||||
@@ -199,6 +200,31 @@ 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)
|
||||
@@ -251,6 +277,8 @@ 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"]),
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""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
|
||||
@@ -0,0 +1,167 @@
|
||||
"""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),
|
||||
)
|
||||
@@ -24,6 +24,19 @@ 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")
|
||||
|
||||
@@ -135,6 +148,7 @@ 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":
|
||||
@@ -145,16 +159,52 @@ class TrustedCandidateBuilderConfig:
|
||||
"allowed_peer_runtime_prefix", "allowed_peer_runtime_suffix",
|
||||
"required_checks", "evidence_layout", "recipes", "governed_recipes",
|
||||
}
|
||||
if set(policy) != required or policy.get("schema") != "aios.trusted-candidate-builder-policy.v1":
|
||||
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"}:
|
||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
||||
recipes = policy.get("recipes")
|
||||
if not isinstance(recipes, Mapping) or set(recipes) != {"V2R2C2", "V2R2C3"}:
|
||||
expected_recipes = {"V2R2C2", "V2R2C3", "V2R2C5"} if is_c5 else {"V2R2C2", "V2R2C3"}
|
||||
if not isinstance(recipes, Mapping) or set(recipes) != expected_recipes:
|
||||
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"
|
||||
@@ -210,6 +260,7 @@ 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]:
|
||||
@@ -231,6 +282,36 @@ 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.
|
||||
@@ -247,6 +328,8 @@ 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")
|
||||
@@ -263,8 +346,9 @@ class TrustedCandidateBuilderConfig:
|
||||
"version",
|
||||
"purpose",
|
||||
}
|
||||
allowed_fields = (required, required | {"tree_oid"}) if version == "V2R2C5" else (required,)
|
||||
if (
|
||||
set(admitted) != required
|
||||
set(admitted) not in allowed_fields
|
||||
or admitted.get("mode") != "governed-source-admission"
|
||||
or admitted.get("version") != version
|
||||
or admitted.get("source_commit") != source_commit
|
||||
@@ -275,7 +359,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"):
|
||||
for name in ("base_oid", "source_commit", *(["tree_oid"] if "tree_oid" in admitted else [])):
|
||||
value = admitted.get(name)
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
@@ -395,7 +479,44 @@ 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}}"],
|
||||
@@ -412,6 +533,16 @@ 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"}:
|
||||
@@ -488,6 +619,13 @@ 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()
|
||||
|
||||
@@ -18,7 +18,10 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import fcntl
|
||||
try:
|
||||
import fcntl
|
||||
except ModuleNotFoundError: # pragma: no cover - production host is POSIX
|
||||
fcntl = None
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -376,6 +379,8 @@ 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)
|
||||
@@ -388,7 +393,7 @@ class FileLock:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if self.handle is not None:
|
||||
if self.handle is not None and fcntl is not None:
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
self.handle.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,100 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,630 @@
|
||||
"""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
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,503 @@
|
||||
"""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
@@ -0,0 +1,688 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,977 @@
|
||||
#!/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
@@ -0,0 +1,326 @@
|
||||
"""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,6 +9,8 @@ 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"}
|
||||
@@ -25,6 +27,13 @@ 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,
|
||||
@@ -33,6 +42,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""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
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Narrow C5 unattended source-publication policy lifecycle.
|
||||
|
||||
The policy is an Owner-signed, exact-scope admission object. It is checked
|
||||
against the configured Owner policy at issuance and against a live revocation
|
||||
source for every request. It is not an activation/configuration mechanism
|
||||
and does not create release or production authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
OWNER_NAMESPACE,
|
||||
OWNER_IDENTITY,
|
||||
SOURCE_PUBLICATION_CAPABILITIES,
|
||||
SOURCE_PUBLICATION_POLICY_ID,
|
||||
SOURCE_PUBLICATION_POLICY_SCHEMA,
|
||||
SignedArtifact,
|
||||
SingleHostAuthorityError,
|
||||
SourcePublicationPolicyRequest,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
|
||||
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_RISK_ORDER = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
|
||||
_POLICY_FIELDS = frozenset(
|
||||
{
|
||||
"schema", "policy_id", "owner_identity", "repository", "trust_domain",
|
||||
"policy_digest",
|
||||
"authority_base_oid", "source_oid", "source_tree_oid", "target_agent",
|
||||
"target_branch", "generation", "execution_digest", "exact_files",
|
||||
"capabilities", "risk", "production_impact", "renewal", "nonce",
|
||||
"issued_at", "not_before", "expires_at", "status", "signature_namespace",
|
||||
"signature_key_id", "canonical_payload_digest",
|
||||
}
|
||||
)
|
||||
_REQUEST_FIELDS = frozenset(SourcePublicationPolicyRequest.__dataclass_fields__)
|
||||
|
||||
# The typed Owner epoch producer uses the configured policy's production_scope.
|
||||
# The installed C5 source-only policy contract uses ``none`` for production
|
||||
# impact; keep this exact value bound to the C5 policy rather than accepting an
|
||||
# arbitrary non-empty scope from a raw signer.
|
||||
SOURCE_PUBLICATION_EPOCH_PRODUCTION_SCOPE = "none"
|
||||
SOURCE_PUBLICATION_EPOCH_DESTRUCTIVE_POLICY = "forbidden"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskAdmissionPolicyProjection:
|
||||
"""The only policy projection exposed after an exact request guard."""
|
||||
|
||||
max_risk: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
capabilities: tuple[str, ...]
|
||||
exact_files: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _request_dict(request: SourcePublicationPolicyRequest | Mapping[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(request, SourcePublicationPolicyRequest):
|
||||
value = asdict(request)
|
||||
elif isinstance(request, Mapping):
|
||||
value = dict(request)
|
||||
else:
|
||||
raise TypeError("request must be SourcePublicationPolicyRequest or mapping")
|
||||
if set(value) != _REQUEST_FIELDS:
|
||||
raise ValueError("SOURCE_PUBLICATION_REQUEST_FIELDS_INVALID")
|
||||
if "exact_files" in value:
|
||||
value["exact_files"] = tuple(value["exact_files"])
|
||||
return value
|
||||
|
||||
|
||||
class SourcePublicationPolicyVerifier:
|
||||
"""Strict verifier over the existing Owner registry verifier interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
owner_verifier: Any,
|
||||
*,
|
||||
revocation_check: Callable[[Mapping[str, Any]], bool] | None,
|
||||
expected_policy_digest: str | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.owner_verifier = owner_verifier
|
||||
self.revocation_check = revocation_check
|
||||
self.expected_policy_digest = expected_policy_digest
|
||||
self.clock = clock
|
||||
|
||||
def _deny(self, code: str) -> None:
|
||||
raise SingleHostAuthorityError(code)
|
||||
|
||||
def _validate_payload(self, artifact: SignedArtifact) -> dict[str, Any]:
|
||||
if not isinstance(artifact, SignedArtifact):
|
||||
self._deny("SOURCE_PUBLICATION_ARTIFACT_INVALID")
|
||||
payload = dict(artifact.payload)
|
||||
if set(payload) != _POLICY_FIELDS:
|
||||
self._deny("SOURCE_PUBLICATION_UNKNOWN_FIELDS")
|
||||
try:
|
||||
if artifact.canonical_payload != canonical_json_bytes(payload):
|
||||
self._deny("SOURCE_PUBLICATION_CANONICAL_BYTES_MISMATCH")
|
||||
if payload["canonical_payload_digest"] != sha256_json(
|
||||
{k: v for k, v in payload.items() if k != "canonical_payload_digest"}
|
||||
):
|
||||
self._deny("SOURCE_PUBLICATION_PAYLOAD_DIGEST_INVALID")
|
||||
if payload["schema"] != SOURCE_PUBLICATION_POLICY_SCHEMA:
|
||||
self._deny("SOURCE_PUBLICATION_SCHEMA_INVALID")
|
||||
if payload["policy_id"] != SOURCE_PUBLICATION_POLICY_ID:
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_ID_INVALID")
|
||||
if payload["owner_identity"] != OWNER_IDENTITY:
|
||||
self._deny("SOURCE_PUBLICATION_OWNER_INVALID")
|
||||
if not isinstance(payload["policy_digest"], str) or not _HEX64.fullmatch(payload["policy_digest"]):
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_DIGEST_INVALID")
|
||||
if self.expected_policy_digest is not None and payload["policy_digest"] != self.expected_policy_digest:
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_DIGEST_MISMATCH")
|
||||
if payload["signature_namespace"] != OWNER_NAMESPACE:
|
||||
self._deny("SOURCE_PUBLICATION_NAMESPACE_INVALID")
|
||||
if payload["signature_key_id"] != artifact.key_id or artifact.namespace != OWNER_NAMESPACE:
|
||||
self._deny("SOURCE_PUBLICATION_ISSUER_MISMATCH")
|
||||
for name in ("authority_base_oid", "source_oid", "source_tree_oid"):
|
||||
if not isinstance(payload[name], str) or not _HEX40.fullmatch(payload[name]):
|
||||
self._deny("SOURCE_PUBLICATION_OID_INVALID")
|
||||
if not isinstance(payload["execution_digest"], str) or not _HEX64.fullmatch(payload["execution_digest"]):
|
||||
self._deny("SOURCE_PUBLICATION_EXECUTION_DIGEST_INVALID")
|
||||
branch = payload["target_branch"]
|
||||
agent = payload["target_agent"]
|
||||
if (
|
||||
not isinstance(branch, str) or not branch.startswith("agent/")
|
||||
or "\\" in branch or ".." in PurePosixPath(branch).parts
|
||||
or branch.endswith(("/", "."))
|
||||
or not isinstance(agent, str) or not agent.startswith("agent/")
|
||||
):
|
||||
self._deny("SOURCE_PUBLICATION_TARGET_INVALID")
|
||||
if payload["risk"] != "engineering" or payload["production_impact"] != "none" or payload["renewal"] != "none":
|
||||
self._deny("SOURCE_PUBLICATION_SCOPE_INVALID")
|
||||
if payload["capabilities"] != sorted(SOURCE_PUBLICATION_CAPABILITIES):
|
||||
self._deny("SOURCE_PUBLICATION_CAPABILITIES_INVALID")
|
||||
if not isinstance(payload["exact_files"], list) or not payload["exact_files"] or len(set(payload["exact_files"])) != len(payload["exact_files"]):
|
||||
self._deny("SOURCE_PUBLICATION_FILES_INVALID")
|
||||
if type(payload["generation"]) is not int or payload["generation"] < 1:
|
||||
self._deny("SOURCE_PUBLICATION_GENERATION_INVALID")
|
||||
if type(payload["issued_at"]) is not int or type(payload["not_before"]) is not int or type(payload["expires_at"]) is not int:
|
||||
self._deny("SOURCE_PUBLICATION_TIME_INVALID")
|
||||
now = int(self.clock())
|
||||
if not payload["not_before"] <= payload["issued_at"] <= now < payload["expires_at"]:
|
||||
self._deny("SOURCE_PUBLICATION_EXPIRED")
|
||||
if payload["expires_at"] <= payload["issued_at"]:
|
||||
self._deny("SOURCE_PUBLICATION_LIFETIME_INVALID")
|
||||
if payload["expires_at"] - payload["issued_at"] > 3600 or payload["status"] != "ACTIVE":
|
||||
self._deny("SOURCE_PUBLICATION_LIFETIME_INVALID")
|
||||
if self.revocation_check is None:
|
||||
self._deny("SOURCE_PUBLICATION_REVOCATION_CHECK_MISSING")
|
||||
if self.revocation_check(payload) is not True:
|
||||
self._deny("SOURCE_PUBLICATION_REVOKED")
|
||||
except SingleHostAuthorityError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_INVALID") from exc
|
||||
return payload
|
||||
|
||||
def verify(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any] | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
payload = self._validate_payload(artifact)
|
||||
# Verify exactly the bytes supplied by the signed artifact. Do not
|
||||
# reconstruct or reserialize them before invoking the registry API.
|
||||
if self.owner_verifier.verify(
|
||||
namespace=artifact.namespace,
|
||||
payload=artifact.canonical_payload,
|
||||
signature=artifact.signature,
|
||||
key_id=artifact.key_id,
|
||||
) is not True:
|
||||
return False
|
||||
if request is not None:
|
||||
expected = _request_dict(request)
|
||||
for field in (
|
||||
"policy_id", "repository", "authority_base_oid", "source_oid",
|
||||
"source_tree_oid", "target_agent", "target_branch", "generation",
|
||||
"execution_digest", "exact_files", "nonce", "risk",
|
||||
"production_impact", "renewal",
|
||||
):
|
||||
actual = tuple(payload[field]) if field == "exact_files" else payload[field]
|
||||
if actual != expected.get(field):
|
||||
return False
|
||||
if int(payload["expires_at"]) - int(payload["issued_at"]) != int(expected["ttl_seconds"]):
|
||||
return False
|
||||
return True
|
||||
except (SingleHostAuthorityError, TypeError, ValueError, KeyError):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def require(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not self.verify(artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
return dict(artifact.payload)
|
||||
|
||||
|
||||
def require_source_publication_epoch_scope(epoch: Any, policy_payload: Mapping[str, Any]) -> None:
|
||||
"""Require v2 epoch provenance to match the signed C5 policy exactly."""
|
||||
schema = getattr(epoch, "schema", None)
|
||||
if schema is None and isinstance(epoch, Mapping):
|
||||
schema = epoch.get("schema")
|
||||
if schema != "aios.owner-delegation-epoch.v2":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_EPOCH_SCHEMA_REQUIRED")
|
||||
|
||||
def field(name: str) -> Any:
|
||||
if isinstance(epoch, Mapping):
|
||||
return epoch.get(name)
|
||||
return getattr(epoch, name, None)
|
||||
|
||||
if (
|
||||
field("production_scope") != SOURCE_PUBLICATION_EPOCH_PRODUCTION_SCOPE
|
||||
or field("destructive_policy") != SOURCE_PUBLICATION_EPOCH_DESTRUCTIVE_POLICY
|
||||
or field("policy_digest") != policy_payload.get("policy_digest")
|
||||
or field("repository") != policy_payload.get("repository")
|
||||
or field("trust_domain") != policy_payload.get("trust_domain")
|
||||
):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_EPOCH_SCOPE_MISMATCH")
|
||||
|
||||
|
||||
class ScopedSourcePublicationPolicyAdmission:
|
||||
"""Rechecks a policy for every exact request before scoped admission."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verifier: SourcePublicationPolicyVerifier,
|
||||
*,
|
||||
bootstrap: Any | None = None,
|
||||
artifact: SignedArtifact | None = None,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any] | None = None,
|
||||
expected_agent_id: str | None = None,
|
||||
) -> None:
|
||||
self.verifier = verifier
|
||||
self.bootstrap = bootstrap
|
||||
self.artifact = artifact
|
||||
self.request = request
|
||||
self.expected_agent_id = expected_agent_id
|
||||
|
||||
def _check_bootstrap_binding(self, payload: Mapping[str, Any]) -> None:
|
||||
if self.bootstrap is None:
|
||||
return
|
||||
policy = getattr(self.bootstrap, "policy", None)
|
||||
digest_provider = getattr(policy, "normalized_policy_digest", None)
|
||||
digest = digest_provider() if callable(digest_provider) else None
|
||||
trust_domain = getattr(policy, "trust_domain", None)
|
||||
if not isinstance(digest, str) or digest != payload.get("policy_digest"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_POLICY_DIGEST_MISMATCH")
|
||||
if not isinstance(trust_domain, str) or trust_domain != payload.get("trust_domain"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_TRUST_DOMAIN_MISMATCH")
|
||||
if self.expected_agent_id is None or payload.get("target_agent") != self.expected_agent_id:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_TARGET_AGENT_MISMATCH")
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> bool:
|
||||
try:
|
||||
payload = self.verifier._validate_payload(artifact)
|
||||
if self.expected_agent_id is not None and payload.get("target_agent") != self.expected_agent_id:
|
||||
return False
|
||||
self._check_bootstrap_binding(payload)
|
||||
except SingleHostAuthorityError:
|
||||
return False
|
||||
return self.verifier.verify(artifact, request)
|
||||
|
||||
verify = authorize
|
||||
|
||||
def _request_for_raw(self, raw: Mapping[str, Any]) -> SourcePublicationPolicyRequest:
|
||||
if self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_ARTIFACT_REQUIRED")
|
||||
expected_task = getattr(self, "expected_task_id", None)
|
||||
if expected_task is not None and raw.get("task_id") != expected_task:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_PLAN_TASK_MISMATCH")
|
||||
payload = self.verifier._validate_payload(self.artifact)
|
||||
self._check_bootstrap_binding(payload)
|
||||
if self.request is not None:
|
||||
base = _request_dict(self.request)
|
||||
else:
|
||||
base = {
|
||||
"policy_id": payload["policy_id"], "repository": payload["repository"],
|
||||
"authority_base_oid": payload["authority_base_oid"], "source_oid": payload["source_oid"],
|
||||
"source_tree_oid": payload["source_tree_oid"], "target_agent": payload["target_agent"],
|
||||
"target_branch": payload["target_branch"], "generation": payload["generation"],
|
||||
"execution_digest": payload["execution_digest"], "exact_files": tuple(payload["exact_files"]),
|
||||
"nonce": payload["nonce"], "ttl_seconds": payload["expires_at"] - payload["issued_at"],
|
||||
"risk": payload["risk"], "production_impact": payload["production_impact"], "renewal": payload["renewal"],
|
||||
}
|
||||
checks = {
|
||||
"repository": base["repository"], "authority_base_oid": base["authority_base_oid"],
|
||||
"target_branch": base["target_branch"], "generation": base["generation"],
|
||||
"execution_digest": base["execution_digest"], "risk": base["risk"],
|
||||
}
|
||||
for field, expected in checks.items():
|
||||
if raw.get(field) != expected:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_REQUEST_SCOPE_MISMATCH")
|
||||
caps = tuple(raw.get("requested_capabilities", ()))
|
||||
if set(caps) != set(base["capabilities"] if "capabilities" in base else payload["capabilities"]):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_CAPABILITY_SCOPE_MISMATCH")
|
||||
if tuple(raw.get("file_allowlist", ())) != tuple(base["exact_files"]):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_FILE_SCOPE_MISMATCH")
|
||||
return SourcePublicationPolicyRequest(**base)
|
||||
|
||||
def create_envelope(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.bootstrap is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
request = self._request_for_raw(raw)
|
||||
if not self.verifier.verify(self.artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
return self.bootstrap.create_envelope(raw)
|
||||
|
||||
def authorize_and_admit(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.bootstrap is None or self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
if raw.get("authorization_kind") != "delegation_epoch":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_OWNER_SIGNATURE_BYPASS_DENIED")
|
||||
envelope = self.bootstrap._load_envelope(str(raw.get("envelope_id") or ""))
|
||||
envelope_raw = envelope.as_dict() if hasattr(envelope, "as_dict") else dict(envelope)
|
||||
request = self._request_for_raw(envelope_raw)
|
||||
if not self.verifier.verify(self.artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
epoch = self.bootstrap._verify_epoch(
|
||||
epoch_id=str(raw.get("delegation_epoch_id") or ""),
|
||||
envelope=envelope,
|
||||
now=int(self.verifier.clock()),
|
||||
)
|
||||
require_source_publication_epoch_scope(
|
||||
epoch, self.verifier._validate_payload(self.artifact)
|
||||
)
|
||||
return self.bootstrap.authorize_and_admit(raw)
|
||||
|
||||
def status(self, raw: Mapping[str, Any] | str) -> Any:
|
||||
"""Read status only after the same policy and epoch guard as admission."""
|
||||
if self.bootstrap is None or self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
if isinstance(raw, str):
|
||||
raw = {"envelope_id": raw}
|
||||
envelope = self.bootstrap._load_envelope(str(raw.get("envelope_id") or ""))
|
||||
envelope_raw = envelope.as_dict() if hasattr(envelope, "as_dict") else dict(envelope)
|
||||
request = self._request_for_raw(envelope_raw)
|
||||
self.verifier.require(self.artifact, request)
|
||||
epoch_id = str(raw.get("delegation_epoch_id") or "")
|
||||
if not epoch_id:
|
||||
grant = self.bootstrap.store.get_grant_by_task_id(envelope.task_id)
|
||||
epoch_id = grant.parent_epoch_id if grant is not None else ""
|
||||
if not epoch_id or not hasattr(self.bootstrap, "_verify_epoch"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_STATUS_EPOCH_REQUIRED")
|
||||
epoch = self.bootstrap._verify_epoch(
|
||||
epoch_id=epoch_id,
|
||||
envelope=envelope,
|
||||
now=int(self.verifier.clock()),
|
||||
)
|
||||
require_source_publication_epoch_scope(
|
||||
epoch, self.verifier._validate_payload(self.artifact)
|
||||
)
|
||||
if hasattr(self.bootstrap, "status"):
|
||||
return self.bootstrap.status(envelope.envelope_id)
|
||||
return envelope_raw
|
||||
|
||||
def reconcile_status(self, envelope_id: str, authorization: Mapping[str, Any]) -> Any:
|
||||
"""Carry the verified epoch identity when a Task effect has no receipt yet."""
|
||||
if authorization.get("authorization_kind") != "delegation_epoch":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_OWNER_SIGNATURE_BYPASS_DENIED")
|
||||
return self.status({"envelope_id": envelope_id,
|
||||
"delegation_epoch_id": authorization.get("delegation_epoch_id")})
|
||||
|
||||
def project_task_admission_policy(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> TaskAdmissionPolicyProjection:
|
||||
payload = self.verifier.require(artifact, request)
|
||||
return TaskAdmissionPolicyProjection(
|
||||
max_risk="engineering",
|
||||
repository=payload["repository"],
|
||||
target_branch=payload["target_branch"],
|
||||
capabilities=tuple(payload["capabilities"]),
|
||||
exact_files=tuple(payload["exact_files"]),
|
||||
)
|
||||
|
||||
to_task_admission_policy = project_task_admission_policy
|
||||
|
||||
|
||||
# Short aliases for callers that prefer the lifecycle terminology.
|
||||
SourcePublicationPolicyAdmission = ScopedSourcePublicationPolicyAdmission
|
||||
SourcePublicationVerifier = SourcePublicationPolicyVerifier
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Thin unattended release runner.
|
||||
|
||||
This module is an adapter around the existing admission and silent-release
|
||||
services. It deliberately owns no release state machine and never creates
|
||||
approval evidence. The runner only binds immutable source facts, delegates
|
||||
authority, and validates durable readbacks before moving to the next phase.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
IDENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||||
RETRYABLE_CODES = {408, 425, 429, 500, 502, 503, 504}
|
||||
RETRYABLE_NAMES = {
|
||||
"UNAVAILABLE", "BUSY", "TEMPORARY_FAILURE", "TASK_ADMISSION_IN_PROGRESS",
|
||||
}
|
||||
RISKS = {"low", "medium", "engineering", "high"}
|
||||
|
||||
|
||||
class UnattendedReleaseError(RuntimeError):
|
||||
"""A fail-closed runner error with a stable code."""
|
||||
|
||||
def __init__(self, code: str, message: str | None = None):
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
def _canon(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return hashlib.sha256(_canon(value)).hexdigest()
|
||||
|
||||
|
||||
def _require_oid(name: str, value: Any) -> str:
|
||||
value = str(value or "")
|
||||
if not OID_RE.fullmatch(value):
|
||||
raise UnattendedReleaseError("INVALID_" + name.upper())
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _require_sha(name: str, value: Any) -> str:
|
||||
value = str(value or "")
|
||||
if not SHA256_RE.fullmatch(value):
|
||||
raise UnattendedReleaseError("INVALID_" + name.upper())
|
||||
return value.lower()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourcePublicationIntent:
|
||||
"""Immutable, deterministic source and execution binding."""
|
||||
|
||||
repository: str
|
||||
source_commit: str
|
||||
source_tree_oid: str
|
||||
authority_base_oid: str
|
||||
target_branch: str
|
||||
file_allowlist: tuple[str, ...]
|
||||
requested_capabilities: tuple[str, ...]
|
||||
risk: str
|
||||
execution_digest: str
|
||||
generation: int = 1
|
||||
event_id: str = "release"
|
||||
version: str = ""
|
||||
admission_task_id: str = ""
|
||||
admission_idempotency_key: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.repository or not isinstance(self.repository, str):
|
||||
raise UnattendedReleaseError("INVALID_REPOSITORY")
|
||||
for name in ("source_commit", "source_tree_oid", "authority_base_oid"):
|
||||
object.__setattr__(self, name, _require_oid(name, getattr(self, name)))
|
||||
if not isinstance(self.target_branch, str) or not self.target_branch or not self.target_branch.startswith("agent/") or self.target_branch.startswith("-"):
|
||||
raise UnattendedReleaseError("INVALID_TARGET_BRANCH")
|
||||
if ".." in self.target_branch or "//" in self.target_branch or "@{" in self.target_branch:
|
||||
raise UnattendedReleaseError("INVALID_TARGET_BRANCH")
|
||||
if not isinstance(self.generation, int) or isinstance(self.generation, bool) or self.generation < 1:
|
||||
raise UnattendedReleaseError("INVALID_GENERATION")
|
||||
object.__setattr__(self, "execution_digest", _require_sha("execution_digest", self.execution_digest))
|
||||
if not self.file_allowlist or any(not isinstance(p, str) or not p or p.startswith(("/", "\\")) or ".." in p or "\\" in p or p == ".git" or p.startswith(".git/") for p in self.file_allowlist):
|
||||
raise UnattendedReleaseError("INVALID_FILE_ALLOWLIST")
|
||||
if len(set(self.file_allowlist)) != len(self.file_allowlist):
|
||||
raise UnattendedReleaseError("DUPLICATE_FILE_ALLOWLIST")
|
||||
if not self.requested_capabilities or any(not isinstance(c, str) or not c or "*" in c for c in self.requested_capabilities):
|
||||
raise UnattendedReleaseError("INVALID_CAPABILITIES")
|
||||
if not isinstance(self.risk, str) or self.risk.lower() not in RISKS:
|
||||
raise UnattendedReleaseError("INVALID_RISK")
|
||||
object.__setattr__(self, "risk", self.risk.lower())
|
||||
object.__setattr__(self, "file_allowlist", tuple(sorted(self.file_allowlist)))
|
||||
object.__setattr__(self, "requested_capabilities", tuple(sorted(set(self.requested_capabilities))))
|
||||
if not IDENT_RE.fullmatch(self.event_id):
|
||||
raise UnattendedReleaseError("INVALID_EVENT_ID")
|
||||
if self.version and not IDENT_RE.fullmatch(self.version):
|
||||
raise UnattendedReleaseError("INVALID_VERSION")
|
||||
if bool(self.admission_task_id) != bool(self.admission_idempotency_key):
|
||||
raise UnattendedReleaseError("INCOMPLETE_ADMISSION_IDENTITY")
|
||||
for value in (self.admission_task_id, self.admission_idempotency_key):
|
||||
if value and (not isinstance(value, str) or not IDENT_RE.fullmatch(value)):
|
||||
raise UnattendedReleaseError("INVALID_ADMISSION_IDENTITY")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = {
|
||||
"repository": self.repository, "source_commit": self.source_commit.lower(),
|
||||
"source_tree_oid": self.source_tree_oid.lower(), "authority_base_oid": self.authority_base_oid.lower(),
|
||||
"target_branch": self.target_branch, "file_allowlist": list(self.file_allowlist),
|
||||
"requested_capabilities": list(self.requested_capabilities), "risk": self.risk,
|
||||
"execution_digest": self.execution_digest.lower(), "generation": self.generation,
|
||||
"event_id": self.event_id, "version": self.version,
|
||||
}
|
||||
if self.admission_task_id:
|
||||
value.update(admission_task_id=self.admission_task_id,
|
||||
admission_idempotency_key=self.admission_idempotency_key)
|
||||
return value
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.admission_task_id or "release-task-" + _digest(self.as_dict())[:32]
|
||||
|
||||
@property
|
||||
def idempotency_key(self) -> str:
|
||||
return self.admission_idempotency_key or "release-intake-" + _digest({"task_id": self.task_id, "execution_digest": self.execution_digest.lower()})[:32]
|
||||
|
||||
def admission_request(self) -> dict[str, Any]:
|
||||
"""Return the sole normalized request accepted by Task admission."""
|
||||
return {"idempotency_key": self.idempotency_key, "task_id": self.task_id,
|
||||
"repository": self.repository, "authority_base_oid": self.authority_base_oid,
|
||||
"target_branch": self.target_branch, "generation": self.generation,
|
||||
"execution_digest": self.execution_digest, "requested_capabilities": list(self.requested_capabilities),
|
||||
"file_allowlist": list(self.file_allowlist), "risk": self.risk}
|
||||
|
||||
|
||||
def _error_code(exc: BaseException) -> str:
|
||||
code = getattr(exc, "code", None) or getattr(exc, "status", None) or exc.__class__.__name__
|
||||
value = str(code).upper()
|
||||
return value if re.fullmatch(r"[A-Z0-9_]{1,100}", value) else "BACKEND_ERROR"
|
||||
|
||||
|
||||
def _bound_receipt(receipt: Any, intent: SourcePublicationIntent) -> bool:
|
||||
if not isinstance(receipt, Mapping):
|
||||
return False
|
||||
expected = {"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest}
|
||||
return (receipt.get("schema") == "aios.task-admission-receipt.v1"
|
||||
and all(_canon(receipt.get(k)) == _canon(v) for k, v in expected.items())
|
||||
and receipt.get("task_state") in {"QUEUED", "CLAIMED", "RUNNING"}
|
||||
and all(isinstance(receipt.get(k), str) and receipt[k] for k in ("envelope_id", "grant_id")))
|
||||
|
||||
|
||||
def _retryable(exc: BaseException) -> bool:
|
||||
status = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) or getattr(exc, "status", None)
|
||||
if isinstance(status, int):
|
||||
return status in RETRYABLE_CODES
|
||||
code = _error_code(exc)
|
||||
return code in RETRYABLE_NAMES
|
||||
|
||||
|
||||
def _unknown_outcome(exc: BaseException) -> bool:
|
||||
return isinstance(exc, (TimeoutError, ConnectionError, OSError)) or _error_code(exc) in {"TASK_ADMISSION_TASK_STATE_UNKNOWN", "TIMEOUT", "TIMED_OUT", "CONNECTION_LOST"}
|
||||
|
||||
|
||||
class GovernedUnattendedReleaseRunner:
|
||||
"""Phase-separated runner; preparation has no release/deployment rights."""
|
||||
|
||||
def __init__(self, *, admission: Any, release_service: Any = None,
|
||||
publication_adapter: Any = None, owner_approval_broker: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None,
|
||||
deployment_enabled: bool = False, deployment_service: Any = None,
|
||||
max_attempts: int = 3, deadline_seconds: float = 30.0,
|
||||
environment_mode: str = "SIMULATION",
|
||||
clock: Callable[[], float] = time.monotonic, sleep: Callable[[float], None] = time.sleep):
|
||||
if admission is None:
|
||||
raise ValueError("admission is required")
|
||||
if max_attempts < 1 or max_attempts > 10 or deadline_seconds <= 0:
|
||||
raise ValueError("invalid retry bounds")
|
||||
self.admission = admission
|
||||
self.environment_mode = str(environment_mode).upper()
|
||||
if self.environment_mode not in {"SIMULATION", "AUTHORITY"}:
|
||||
raise ValueError("environment_mode must be SIMULATION or AUTHORITY")
|
||||
self.release_service = release_service
|
||||
self.publication_adapter = publication_adapter
|
||||
self.owner_approval_broker = owner_approval_broker
|
||||
self.deployment_enabled = bool(deployment_enabled)
|
||||
self.deployment_service = deployment_service
|
||||
self.max_attempts = max_attempts
|
||||
self.deadline_seconds = deadline_seconds
|
||||
self.clock, self.sleep = clock, sleep
|
||||
|
||||
def intake(self, intent: SourcePublicationIntent) -> dict[str, Any]:
|
||||
if not isinstance(intent, SourcePublicationIntent):
|
||||
raise UnattendedReleaseError("INTENT_TYPE_REQUIRED")
|
||||
return {"phase": "PREPARATION", "mode": self.environment_mode, "task_id": intent.task_id,
|
||||
"idempotency_key": intent.idempotency_key, "intent": intent.as_dict(),
|
||||
"release_rights": False, "deployment_rights": False}
|
||||
|
||||
def admit(self, intent: SourcePublicationIntent, authorization: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(authorization, Mapping):
|
||||
raise UnattendedReleaseError("AUTHORIZATION_REQUIRED")
|
||||
if self.environment_mode == "SIMULATION" and getattr(self.admission, "simulation_only", False) is not True:
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "task_id": intent.task_id, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
raw = intent.admission_request()
|
||||
deadline = self.clock() + self.deadline_seconds
|
||||
envelope_id = None
|
||||
for attempt in range(1, self.max_attempts + 1):
|
||||
if self.clock() >= deadline:
|
||||
return {"phase": "RECONCILIATION_REQUIRED" if envelope_id else "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": "ADMISSION_DEADLINE_EXCEEDED"}
|
||||
try:
|
||||
created = self.admission.create_envelope(raw)
|
||||
envelope = created.get("envelope", created) if isinstance(created, Mapping) else {}
|
||||
envelope_id = envelope.get("envelope_id") or envelope_id
|
||||
if not isinstance(envelope_id, str) or not envelope_id:
|
||||
raise UnattendedReleaseError("ENVELOPE_BINDING_MISSING")
|
||||
auth = dict(authorization)
|
||||
auth["envelope_id"] = envelope_id
|
||||
if self.clock() >= deadline:
|
||||
return self._reconcile(envelope_id, intent, "ADMISSION_DEADLINE_EXCEEDED", authorization)
|
||||
receipt = self.admission.authorize_and_admit(auth)
|
||||
if not _bound_receipt(receipt, intent) or receipt["envelope_id"] != envelope_id:
|
||||
raise UnattendedReleaseError("ADMISSION_RECEIPT_BINDING_MISMATCH")
|
||||
if self.clock() >= deadline:
|
||||
return self._reconcile(envelope_id, intent, "ADMISSION_DEADLINE_EXCEEDED", authorization)
|
||||
return {"phase": "ADMITTED", "mode": self.environment_mode, "attempt": attempt,
|
||||
"envelope": created, "receipt": receipt}
|
||||
except Exception as exc:
|
||||
status_code = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) or getattr(exc, "status", None)
|
||||
if status_code == 403 or _error_code(exc) in {"403", "FORBIDDEN"}:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": _error_code(exc)}
|
||||
# Once authorization may have run, every failure reconciles.
|
||||
# A 5xx is never sufficient proof of NOT_APPLIED.
|
||||
if envelope_id:
|
||||
return self._reconcile(envelope_id, intent, _error_code(exc), authorization)
|
||||
if not _retryable(exc) or self.clock() >= deadline or attempt >= self.max_attempts:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": _error_code(exc)}
|
||||
self.sleep(min(0.05 * (2 ** (attempt - 1)), max(0.0, deadline - self.clock())))
|
||||
raise UnattendedReleaseError("ADMISSION_RETRY_EXHAUSTED")
|
||||
|
||||
def _reconcile(self, envelope_id: str | None, intent: SourcePublicationIntent, error: str,
|
||||
authorization: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not envelope_id:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": error}
|
||||
try:
|
||||
reconcile = getattr(self.admission, "reconcile_status", None)
|
||||
status = reconcile(envelope_id, authorization) if callable(reconcile) else self.admission.status(envelope_id)
|
||||
except Exception as exc:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": error, "status_error": _error_code(exc)}
|
||||
state = str(status.get("status") or status.get("task_state") or "").upper() if isinstance(status, Mapping) else ""
|
||||
if state in {"NOT_APPLIED", "ABSENT", "NOT_FOUND"}:
|
||||
return {"phase": "RETRYABLE_RECONCILIATION", "mode": "AUTHORITY", "task_id": intent.task_id, "status": status, "error": error}
|
||||
if state == "ADMITTED" and _bound_receipt(status.get("task"), intent):
|
||||
return {"phase": "RECONCILED", "mode": self.environment_mode, "task_id": intent.task_id, "status": status, "receipt": status["task"]}
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "status": status, "error": error}
|
||||
|
||||
def publish(self, intent: SourcePublicationIntent, admission_receipt: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.environment_mode != "AUTHORITY":
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
if self.publication_adapter is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_BACKEND_MISSING"}
|
||||
required = ("task_lease", "task_principal", "writer_lease", "commit_receipt", "source_oid", "tree_oid", "authenticated")
|
||||
try:
|
||||
if not _bound_receipt(admission_receipt, intent):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "ADMISSION_RECEIPT_BINDING_MISMATCH"}
|
||||
result = self.publication_adapter.publish(intent, admission_receipt)
|
||||
if not isinstance(result, Mapping) or result.get("authenticated") is not True or any(not result.get(k) for k in required[:-1]):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_LEASE_CHAIN_MISSING"}
|
||||
if any(not isinstance(result[k], Mapping) or result[k].get("task_id") != intent.task_id
|
||||
for k in ("task_lease", "task_principal", "writer_lease")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_LEASE_BINDING_MISMATCH"}
|
||||
commit = result["commit_receipt"]
|
||||
# Existing continuation receipts carry remote OIDs, while the
|
||||
# authenticated task/job readback carries scope. Preserve both
|
||||
# originals; never fabricate missing fields inside a receipt.
|
||||
optional_bindings = {"task_id": intent.task_id, "tree_oid": result["tree_oid"],
|
||||
"execution_digest": intent.execution_digest,
|
||||
"target_branch": intent.target_branch,
|
||||
"base_oid": intent.authority_base_oid}
|
||||
if (not isinstance(commit, Mapping) or commit.get("commit_oid") != result["source_oid"]
|
||||
or any(key in commit and commit[key] != value for key, value in optional_bindings.items())
|
||||
or result.get("generation") != intent.generation):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_COMMIT_BINDING_MISMATCH"}
|
||||
if not OID_RE.fullmatch(str(result["source_oid"])) or str(result["tree_oid"]).lower() != intent.source_tree_oid.lower() or any(str(result.get(k, "")) != str(v) for k, v in (("task_id", intent.task_id), ("authority_base_oid", intent.authority_base_oid), ("target_branch", intent.target_branch), ("execution_digest", intent.execution_digest))):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_RC_BINDING_MISMATCH"}
|
||||
return {"phase": "PUBLISHED", "mode": "AUTHORITY", "readback": dict(result)}
|
||||
except Exception as exc:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
|
||||
def postpublication_rc(self, *, event_id: str, source_commit: str, version: str = "", source_readback: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
if self.environment_mode != "AUTHORITY":
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
if not isinstance(source_readback, Mapping) or source_readback.get("authenticated") is not True or str(source_readback.get("source_commit", "")).lower() != str(source_commit).lower():
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "SOURCE_READBACK_REQUIRED"}
|
||||
if self.release_service is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "RELEASE_BACKEND_MISSING"}
|
||||
try:
|
||||
# Only a host-injected authenticated resolver can admit a source.
|
||||
# The caller's readback object alone is never authorization.
|
||||
resolver = getattr(self.release_service, "source_admission_resolver", None)
|
||||
admitted_source = resolver(source_commit) if callable(resolver) else None
|
||||
if (not isinstance(admitted_source, Mapping) or not admitted_source.get("admission_id")
|
||||
or admitted_source.get("source_commit") != source_commit
|
||||
or admitted_source.get("tree_oid") != source_readback.get("tree_oid")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "AUTHENTICATED_SOURCE_ADMISSION_REQUIRED"}
|
||||
intent = self.release_service.create_release_intent({"event_id": event_id, "source_commit": source_commit, **({"version": version} if version else {})})
|
||||
candidate_id = str(intent.get("candidate_id") or intent.get("candidate", {}).get("candidate_id") or "")
|
||||
if not candidate_id:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "CANDIDATE_BINDING_MISSING"}
|
||||
readers = ("get_release_candidate", "get_release_plan", "get_acceptance")
|
||||
if any(not callable(getattr(self.release_service, name, None)) for name in readers):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "RC_READBACK_BACKEND_MISSING"}
|
||||
candidate = self.release_service.get_release_candidate(candidate_id)
|
||||
plan = self.release_service.get_release_plan(candidate_id)
|
||||
acceptance = self.release_service.get_acceptance(candidate_id)
|
||||
deployment = plan.get("deployment") if isinstance(plan, Mapping) else None
|
||||
if (not isinstance(candidate, Mapping) or not isinstance(plan, Mapping) or not isinstance(deployment, Mapping) or not isinstance(acceptance, Mapping)
|
||||
or str(candidate.get("candidate_id")) != candidate_id
|
||||
or str(candidate.get("source_commit")) .lower() != str(source_commit).lower()
|
||||
or str(plan.get("candidate_id", candidate_id)) != candidate_id
|
||||
or str(deployment.get("source_commit")) .lower() != str(source_commit).lower()
|
||||
or str(deployment.get("tree_oid", "")).lower() != str(source_readback.get("tree_oid", "")).lower()
|
||||
or candidate.get("tree_oid") != deployment.get("tree_oid")
|
||||
or candidate.get("artifact_sha256") != deployment.get("artifact_sha256")
|
||||
or candidate.get("manifest_sha256") != deployment.get("manifest_sha256")
|
||||
or str(acceptance.get("candidate_id")) != candidate_id
|
||||
or acceptance.get("release_plan_sha256") != plan.get("sha256")
|
||||
or str(acceptance.get("status")) != "ACCEPTED"):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "RC_READBACK_BINDING_MISMATCH"}
|
||||
# Public readback is redacted. Reverify original signed bytes inside
|
||||
# the existing service, not by reconstructing them from API output.
|
||||
verify_acceptance = getattr(self.release_service, "verify_release_acceptance", None)
|
||||
if not callable(verify_acceptance):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "BUILDER_ATTESTATION_REQUIRED"}
|
||||
verified = verify_acceptance(candidate_id)
|
||||
if (not isinstance(verified, Mapping) or verified.get("status") != "VERIFIED"
|
||||
or verified.get("candidate_id") != candidate_id
|
||||
or verified.get("release_plan_sha256") != plan.get("sha256")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "BUILDER_ATTESTATION_NOT_VERIFIED"}
|
||||
envelope = self.release_service.get_approval_envelope(candidate_id)
|
||||
if self.owner_approval_broker is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "OWNER_APPROVAL_BACKEND_MISSING"}
|
||||
approval = self.owner_approval_broker(dict(envelope))
|
||||
if not isinstance(approval, Mapping):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "OWNER_APPROVAL_MISSING"}
|
||||
accepted = self.release_service.submit_owner_approval(dict(approval))
|
||||
if not isinstance(accepted, Mapping) or accepted.get("status") != "VERIFIED_AND_STORED":
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "APPROVAL_NOT_VERIFIED"}
|
||||
return {"phase": "APPROVED", "mode": "AUTHORITY", "candidate_id": candidate_id,
|
||||
"readback": {"candidate": dict(candidate), "plan": dict(plan), "acceptance": dict(acceptance)},
|
||||
"approval": accepted}
|
||||
except Exception as exc:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
|
||||
def deploy(self, candidate_id: str) -> dict[str, Any]:
|
||||
if (self.environment_mode != "AUTHORITY" or not self.deployment_enabled or self.deployment_service is None
|
||||
or getattr(self.deployment_service, "execution_enabled", False) is not True):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "DEPLOYMENT_NOT_ENABLED"}
|
||||
try:
|
||||
result = self.deployment_service.start_deployment(candidate_id)
|
||||
saga = self.deployment_service.get_release_saga(candidate_id)
|
||||
if (not isinstance(result, Mapping) or result.get("candidate_id") != candidate_id
|
||||
or result.get("state") != "COMPLETED" or result.get("status") not in {"VERIFIED", "IDEMPOTENT"}
|
||||
or not isinstance(saga, Mapping) or saga.get("state") != "COMPLETED"):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "DEPLOYMENT_NOT_VERIFIED", "result": dict(result) if isinstance(result, Mapping) else {}}
|
||||
return {"phase": "DEPLOYED", "mode": "AUTHORITY", "result": dict(result)}
|
||||
except Exception as exc:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Local adapter for the root-owned registered Owner signer service.
|
||||
|
||||
The adapter intentionally has no public arbitrary-payload signing method.
|
||||
``LocalOwnerAuthority`` calls the private typed seam after validating and
|
||||
canonicalizing a structured Owner authorization object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
DEFAULT_REGISTERED_SIGNER_COMMAND = Path("/usr/local/libexec/aios-grant-signer-adapter")
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
MAX_SIGNATURE_BYTES = 128 * 1024
|
||||
MAX_KEY_ID_BYTES = 512
|
||||
|
||||
|
||||
class RegisteredOwnerSignerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RegisteredOwnerSignerAdapter:
|
||||
"""Invoke the registered signer without reading or copying private keys."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command: Path | str = DEFAULT_REGISTERED_SIGNER_COMMAND,
|
||||
*,
|
||||
key_id: str,
|
||||
owner_verifier: Any,
|
||||
require_root: bool = True,
|
||||
test_mode: bool = False,
|
||||
runner: Callable[..., Any] = subprocess.run,
|
||||
) -> None:
|
||||
self.command = str(command)
|
||||
self.key_id = key_id
|
||||
self.owner_verifier = owner_verifier
|
||||
self.require_root = require_root
|
||||
self.test_mode = test_mode
|
||||
self.runner = runner
|
||||
if not isinstance(key_id, str) or not key_id:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_KEY_ID_REQUIRED")
|
||||
if not test_mode:
|
||||
self._validate_command()
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.key_id
|
||||
|
||||
def _validate_command(self) -> None:
|
||||
path = Path(self.command)
|
||||
if not path.is_absolute() or path.is_symlink() or not path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_INVALID")
|
||||
st = path.stat()
|
||||
if not hasattr(os, "geteuid") or st.st_uid != os.geteuid():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_OWNER_INVALID")
|
||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_PERMISSIONS_INVALID")
|
||||
|
||||
def _read_output(self, path: Path, maximum: int) -> bytes:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_INVALID")
|
||||
st = path.stat()
|
||||
if not self.test_mode and (not hasattr(os, "geteuid") or st.st_uid != os.geteuid()):
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_OWNER_INVALID")
|
||||
if st.st_size > maximum:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_TOO_LARGE")
|
||||
return path.read_bytes()
|
||||
|
||||
def _sign_canonical_payload(self, namespace: str, payload: bytes) -> str:
|
||||
"""Private seam used only by typed ``LocalOwnerAuthority`` issuance."""
|
||||
if namespace != OWNER_NAMESPACE:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_NAMESPACE_INVALID")
|
||||
if not isinstance(payload, bytes) or not payload:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_PAYLOAD_INVALID")
|
||||
if self.require_root:
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_ROOT_REQUIRED")
|
||||
if not self.test_mode:
|
||||
self._validate_command()
|
||||
with tempfile.TemporaryDirectory(prefix="aios-owner-sign-") as directory:
|
||||
root = Path(directory)
|
||||
input_path = root / "payload"
|
||||
signature_path = root / "payload.sig"
|
||||
key_id_path = root / "key-id"
|
||||
input_path.write_bytes(payload)
|
||||
try:
|
||||
result = self.runner(
|
||||
[
|
||||
self.command,
|
||||
"sign",
|
||||
"--namespace",
|
||||
namespace,
|
||||
"--input",
|
||||
str(input_path),
|
||||
"--signature-out",
|
||||
str(signature_path),
|
||||
"--key-id-out",
|
||||
str(key_id_path),
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_FAILED") from exc
|
||||
if getattr(result, "returncode", None) != 0:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_FAILED")
|
||||
if not signature_path.is_file() or not key_id_path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_MISSING")
|
||||
supplied_key_id = self._read_output(key_id_path, MAX_KEY_ID_BYTES).decode("utf-8").strip()
|
||||
if supplied_key_id != self.key_id:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_KEY_ID_MISMATCH")
|
||||
signature = self._read_output(signature_path, MAX_SIGNATURE_BYTES).decode("utf-8")
|
||||
if not signature.startswith("-----BEGIN SSH SIGNATURE-----"):
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_SIGNATURE_INVALID")
|
||||
try:
|
||||
verified = self.owner_verifier.verify(
|
||||
namespace=namespace,
|
||||
payload=payload,
|
||||
signature=signature,
|
||||
key_id=self.key_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_VERIFY_FAILED") from exc
|
||||
if verified is not True:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_VERIFY_REJECTED")
|
||||
return signature
|
||||
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")
|
||||
return {
|
||||
result = {
|
||||
"mode": RECIPE_MODE,
|
||||
"task_id": record["task_id"],
|
||||
"candidate_id": record["candidate_id"],
|
||||
@@ -562,6 +562,9 @@ class ReleaseSourceAdmissionStore:
|
||||
"version": record["version"],
|
||||
"purpose": record["purpose"],
|
||||
}
|
||||
if version == "V2R2C5":
|
||||
result["tree_oid"] = record["tree_oid"]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""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,6 +22,16 @@ 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,
|
||||
@@ -33,9 +43,60 @@ 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:
|
||||
@@ -262,38 +323,56 @@ class LeaderLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
self.handle = None
|
||||
self._lock_context = None
|
||||
|
||||
def __enter__(self):
|
||||
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)
|
||||
self._lock_context = release_file_lock(self.path, blocking=False)
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(
|
||||
self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB
|
||||
)
|
||||
self.handle = self._lock_context.__enter__()
|
||||
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:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
self._lock_context.__exit__(exc_type, exc, tb)
|
||||
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,
|
||||
@@ -301,14 +380,42 @@ 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
|
||||
@@ -329,6 +436,196 @@ 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()
|
||||
@@ -345,26 +642,98 @@ 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"):
|
||||
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()
|
||||
)
|
||||
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"]),
|
||||
)
|
||||
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,
|
||||
@@ -373,6 +742,51 @@ 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
|
||||
@@ -485,24 +899,12 @@ def serve_shadow(
|
||||
service,
|
||||
main_probe=git_main_probe(repository),
|
||||
)
|
||||
controller.run_once()
|
||||
controller.run_once_safely()
|
||||
stop = threading.Event()
|
||||
|
||||
def controller_loop() -> None:
|
||||
while not stop.wait(poll_seconds):
|
||||
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",
|
||||
},
|
||||
)
|
||||
controller.run_once_safely()
|
||||
|
||||
handler = type(
|
||||
"ConfiguredShadowActionHandler",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
"""Exact source-only admission composition within the existing Bridge.
|
||||
|
||||
This module neither installs itself nor issues authority. A host activation must
|
||||
pin an Owner-signed policy and an independently verified epoch. The existing
|
||||
Task admission store, V3 producer, Task Plane and principal verifier are reused.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
SignedArtifact, canonical_json_bytes, _open_secure,
|
||||
)
|
||||
from aios_task_admission_bootstrap import TaskAdmissionError
|
||||
from aios_task_admission_bridge_contract import TaskAdmissionBridgeContract
|
||||
from aios_task_admission_live_adapters import TaskAdmissionRuntimeBundle
|
||||
from governed_source_publication_policy import (
|
||||
ScopedSourcePublicationPolicyAdmission, SourcePublicationPolicyVerifier,
|
||||
)
|
||||
|
||||
|
||||
def _read_object(path: Path) -> tuple[bytes, dict[str, Any]]:
|
||||
raw = _open_secure(path, private=False, require_root_owner=True, maximum=1024 * 1024)
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("SOURCE_ACTIVATION_OBJECT_REQUIRED")
|
||||
return raw, value
|
||||
|
||||
|
||||
class ScopedPrincipalProvider:
|
||||
"""Policy revocation also fences principals from already admitted Tasks."""
|
||||
|
||||
def __init__(self, guarded: ScopedSourcePublicationPolicyAdmission, delegate: Any):
|
||||
self.guarded = guarded
|
||||
self.delegate = delegate
|
||||
|
||||
def readiness(self) -> Mapping[str, Any]:
|
||||
try:
|
||||
self.guarded.verifier.require(self.guarded.artifact, self.guarded.request)
|
||||
return self.delegate.readiness()
|
||||
except Exception as exc:
|
||||
return {"ready": False, "mode": "source-publication-only", "error": str(exc)}
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]) -> Any:
|
||||
grant = self.delegate.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
if grant is None or grant.parent_epoch_id != self.delegate.epoch_id:
|
||||
raise TaskAdmissionError("SOURCE_PRINCIPAL_GRANT_REQUIRED", 403)
|
||||
# status rechecks the signed policy, exact durable envelope and original
|
||||
# signed epoch. The delegate then verifies the live TaskLease/state.
|
||||
self.guarded.status({"envelope_id": grant.envelope_id,
|
||||
"delegation_epoch_id": grant.parent_epoch_id})
|
||||
return self.delegate(transport, request)
|
||||
|
||||
|
||||
class _AdmissionContext:
|
||||
"""Metadata only for legacy hosts retaining their signed principal verifier."""
|
||||
def __init__(self, bundle):
|
||||
self.store, self.authorization = bundle.store, bundle.authorization
|
||||
self.task_port, self.epoch_id = bundle.task_port, bundle.epoch_id
|
||||
|
||||
def readiness(self):
|
||||
return {"ready": False, "error": "REGISTERED_SIGNED_PRINCIPAL_VERIFIER_REQUIRED"}
|
||||
|
||||
def __call__(self, *_args, **_kwargs):
|
||||
raise TaskAdmissionError("REGISTERED_SIGNED_PRINCIPAL_VERIFIER_REQUIRED", 403)
|
||||
|
||||
|
||||
def guard_source_runtime(
|
||||
runtime: TaskAdmissionRuntimeBundle, *, artifact: SignedArtifact,
|
||||
verifier: SourcePublicationPolicyVerifier, expected_agent_id: str,
|
||||
) -> TaskAdmissionRuntimeBundle:
|
||||
service = runtime.service
|
||||
if service is None:
|
||||
raise ValueError("SOURCE_ACTIVATION_BOOTSTRAP_REQUIRED")
|
||||
guarded = ScopedSourcePublicationPolicyAdmission(
|
||||
verifier, bootstrap=service, artifact=artifact,
|
||||
expected_agent_id=expected_agent_id,
|
||||
)
|
||||
# Build the typed request through the same exact-envelope guard used by the
|
||||
# HTTP contract, instead of accepting a caller-supplied request projection.
|
||||
payload = artifact.payload
|
||||
request = guarded._request_for_raw({
|
||||
"repository": payload["repository"],
|
||||
"authority_base_oid": payload["authority_base_oid"],
|
||||
"target_branch": payload["target_branch"], "generation": payload["generation"],
|
||||
"execution_digest": payload["execution_digest"], "risk": payload["risk"],
|
||||
"file_allowlist": payload["exact_files"], "requested_capabilities": payload["capabilities"],
|
||||
})
|
||||
guarded.request = request
|
||||
verifier.require(artifact, request)
|
||||
principal = runtime.principal_verifier or _AdmissionContext(runtime)
|
||||
epoch = principal.authorization.verify_delegation_epoch(principal.epoch_id)
|
||||
# The complete per-envelope check runs again on authorization and every
|
||||
# principal use; reject a production-capable epoch already at composition.
|
||||
if (getattr(epoch, "production_scope", None) != "none"
|
||||
or getattr(epoch, "destructive_policy", None) != "forbidden"):
|
||||
raise TaskAdmissionError("SOURCE_ACTIVATION_EPOCH_SCOPE_DENIED", 403)
|
||||
return TaskAdmissionRuntimeBundle(
|
||||
handlers=TaskAdmissionBridgeContract(guarded).handlers(),
|
||||
principal_verifier=ScopedPrincipalProvider(guarded, principal),
|
||||
service=guarded,
|
||||
)
|
||||
|
||||
|
||||
def build_source_runtime_from_activation_file(path: Path, *, post_admission: Any,
|
||||
ordinary_activation_file: Path):
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
load_owner_approval_verifier,
|
||||
)
|
||||
_, config = _read_object(path)
|
||||
if set(config) != {"schema", "policy_artifact", "policy_sha256", "policy_revocations",
|
||||
"admission_activation", "expected_agent_id"} or config["schema"] != "aios.source-publication-host-activation.v1":
|
||||
raise ValueError("SOURCE_ACTIVATION_FIELDS_INVALID")
|
||||
for key in ("policy_artifact", "policy_revocations", "admission_activation"):
|
||||
value = config[key]
|
||||
if not isinstance(value, str) or not value.startswith("/") or ".." in Path(value).parts:
|
||||
raise ValueError("SOURCE_ACTIVATION_PATH_INVALID")
|
||||
raw, signed = _read_object(Path(config["policy_artifact"]))
|
||||
if hashlib.sha256(raw).hexdigest() != config["policy_sha256"]:
|
||||
raise ValueError("SOURCE_ACTIVATION_POLICY_HASH_MISMATCH")
|
||||
if set(signed) != {"payload", "signature", "key_id", "namespace"}:
|
||||
raise ValueError("SOURCE_ACTIVATION_ARTIFACT_INVALID")
|
||||
artifact = SignedArtifact(canonical_payload=canonical_json_bytes(signed["payload"]), **signed)
|
||||
_, ordinary_config = _read_object(ordinary_activation_file)
|
||||
_, source_config = _read_object(Path(config["admission_activation"]))
|
||||
for field in ("state_db", "task_api_url", "task_control_token_file", "owner_verifier_module"):
|
||||
if not ordinary_config.get(field) or source_config.get(field) != ordinary_config[field]:
|
||||
raise ValueError("SOURCE_ACTIVATION_CONTROL_PLANE_BINDING_MISMATCH")
|
||||
|
||||
def not_revoked(payload: Mapping[str, Any]) -> bool:
|
||||
_, value = _read_object(Path(config["policy_revocations"]))
|
||||
if (set(value) != {"schema", "revoked_nonces"}
|
||||
or value["schema"] != "aios.source-publication-revocations.v1"
|
||||
or not isinstance(value["revoked_nonces"], list)
|
||||
or any(not isinstance(nonce, str) or not nonce for nonce in value["revoked_nonces"])):
|
||||
return False
|
||||
return payload["nonce"] not in value["revoked_nonces"]
|
||||
|
||||
runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(config["admission_activation"]), post_admission=post_admission)
|
||||
verifier = SourcePublicationPolicyVerifier(
|
||||
load_owner_approval_verifier(Path(source_config["owner_verifier_module"])),
|
||||
revocation_check=not_revoked,
|
||||
expected_policy_digest=runtime.service.policy.normalized_policy_digest(),
|
||||
)
|
||||
guarded_runtime = guard_source_runtime(runtime, artifact=artifact, verifier=verifier,
|
||||
expected_agent_id=config["expected_agent_id"])
|
||||
payload = artifact.payload
|
||||
plan_path = Path(source_config["execution_plan_dir"]) / (payload["execution_digest"] + ".json")
|
||||
plan_bytes, plan = _read_object(plan_path)
|
||||
if (hashlib.sha256(plan_bytes).hexdigest() != payload["execution_digest"]
|
||||
or canonical_json_bytes(plan) != plan_bytes):
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_DIGEST_MISMATCH")
|
||||
hashes = plan.get("file_sha256")
|
||||
if not isinstance(hashes, dict) or set(hashes) != set(payload["exact_files"]):
|
||||
raise ValueError("SOURCE_ACTIVATION_FILE_HASH_SET_MISMATCH")
|
||||
for field in ("repository", "authority_base_oid", "target_branch", "generation", "risk"):
|
||||
if plan.get(field) != payload[field]:
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_SCOPE_MISMATCH")
|
||||
if sorted(plan.get("capabilities", [])) != sorted(payload["capabilities"]):
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_CAPABILITIES_MISMATCH")
|
||||
if not isinstance(plan.get("task_id"), str) or not plan["task_id"]:
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_TASK_REQUIRED")
|
||||
guarded_runtime.service.expected_file_sha256 = dict(hashes)
|
||||
guarded_runtime.service.expected_task_id = plan["task_id"]
|
||||
return guarded_runtime
|
||||
|
||||
|
||||
class AdmissionRuntimeRouter:
|
||||
"""Route an exact branch/epoch without widening the ordinary policy."""
|
||||
|
||||
def __init__(self, ordinary: TaskAdmissionRuntimeBundle, source: TaskAdmissionRuntimeBundle):
|
||||
self.ordinary = ordinary
|
||||
self.source = source
|
||||
self.guard = source.service
|
||||
self.store = self.guard.bootstrap.store
|
||||
|
||||
def _source_request(self, operation: str, request: Mapping[str, Any]) -> bool:
|
||||
if operation == "getTaskAdmissionEnvelope":
|
||||
return request.get("target_branch") == self.guard.artifact.payload["target_branch"]
|
||||
envelope_id = str(request.get("envelope_id") or "")
|
||||
row = self.store.get_envelope(envelope_id)
|
||||
if row is None:
|
||||
# Never fall through after finding a source envelope, including
|
||||
# when it has expired or been revoked.
|
||||
return False
|
||||
envelope = self.guard.bootstrap._load_envelope(envelope_id)
|
||||
return envelope.target_branch == self.guard.artifact.payload["target_branch"]
|
||||
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
def dispatch(operation: str, request: Mapping[str, Any]):
|
||||
runtime = self.source if self._source_request(operation, request) else self.ordinary
|
||||
return runtime.handlers[operation](request)
|
||||
return {op: (lambda request, op=op: dispatch(op, request)) for op in self.source.handlers}
|
||||
|
||||
def readiness(self) -> Mapping[str, Any]:
|
||||
return {"mode": "scoped-admission-router", "ordinary": self.ordinary.principal_verifier.readiness(),
|
||||
"source_publication": self.source.principal_verifier.readiness()}
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]):
|
||||
grant = self.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
provider = (self.source.principal_verifier if grant is not None
|
||||
and grant.target_branch == self.guard.artifact.payload["target_branch"]
|
||||
else self.ordinary.principal_verifier)
|
||||
return provider(transport, request)
|
||||
|
||||
def bundle(self) -> TaskAdmissionRuntimeBundle:
|
||||
return TaskAdmissionRuntimeBundle(handlers=self.handlers(), principal_verifier=self)
|
||||
|
||||
|
||||
class ExistingPrincipalPolicyFence:
|
||||
"""Add a C5 policy fence while retaining the registered signed verifier.
|
||||
|
||||
Layered staging hosts use a signed TaskPrincipal verifier with their own
|
||||
storage scope. They must not be migrated to a server attestation merely to
|
||||
add source admission. Every request still reaches the original verifier.
|
||||
"""
|
||||
|
||||
def __init__(self, delegate: Any, source_runtime: TaskAdmissionRuntimeBundle):
|
||||
self.delegate = delegate
|
||||
self.guard = source_runtime.service
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(self.delegate, name)
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]):
|
||||
grant = self.guard.bootstrap.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
if grant is not None and grant.target_branch == self.guard.artifact.payload["target_branch"]:
|
||||
self.guard.status({"envelope_id": grant.envelope_id,
|
||||
"delegation_epoch_id": grant.parent_epoch_id})
|
||||
if transport.get("operation") in {"commitFilesGoverned", "commitFileGoverned"}:
|
||||
expected = getattr(self.guard, "expected_file_sha256", None)
|
||||
if not isinstance(expected, dict) or not expected:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_FILE_HASHES_REQUIRED", 403)
|
||||
try:
|
||||
raw = request.get("files")
|
||||
if raw is None:
|
||||
raw = {request["path"]: request["content_base64"]}
|
||||
pairs = list(raw.items()) if isinstance(raw, dict) else [(item["path"], item["content_base64"]) for item in raw]
|
||||
if len({path for path, _ in pairs}) != len(pairs):
|
||||
raise ValueError("duplicate path")
|
||||
actual = {path: hashlib.sha256(base64.b64decode(data, validate=True)).hexdigest()
|
||||
for path, data in pairs}
|
||||
except Exception as exc:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_FILE_PAYLOAD_INVALID", 403) from exc
|
||||
if actual != expected:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_CONTENT_SCOPE_MISMATCH", 403)
|
||||
return self.delegate(transport, request)
|
||||
|
||||
|
||||
def expired_admission_runtime() -> TaskAdmissionRuntimeBundle:
|
||||
"""Keep a verified-expired ordinary epoch denied while serving other routes."""
|
||||
def deny(*_args: Any, **_kwargs: Any):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_EXPIRED", 403)
|
||||
|
||||
class ClosedPrincipal:
|
||||
__call__ = staticmethod(deny)
|
||||
|
||||
def readiness(self):
|
||||
return {"ready": False, "error": "TASK_ADMISSION_EPOCH_EXPIRED"}
|
||||
|
||||
from aios_task_admission_bridge_contract import TASK_ADMISSION_OPERATION_REQUIRED_FIELDS
|
||||
return TaskAdmissionRuntimeBundle(
|
||||
handlers={op: deny for op in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS},
|
||||
principal_verifier=ClosedPrincipal(),
|
||||
)
|
||||
@@ -1,20 +1,257 @@
|
||||
"""Task Center API."""
|
||||
"""Authenticated read-only Task Center projections and event transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
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 .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 get_task_overview(tasks: list[dict[str, Any]] | None = None):
|
||||
return task_center.summarize(tasks)
|
||||
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 build_router():
|
||||
@@ -23,12 +260,37 @@ def build_router():
|
||||
router = APIRouter(prefix="/admin/ai-evolution/tasks", tags=["task-center"])
|
||||
|
||||
@router.get("/overview", operation_id="getTaskCenterOverview")
|
||||
def overview():
|
||||
return get_task_overview()
|
||||
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,
|
||||
))
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
__all__ = ["router", "get_task_overview"]
|
||||
__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
|
||||
|
||||
@@ -1,81 +1,438 @@
|
||||
"""Task Center visualization model."""
|
||||
"""Read-only Task Center projections built from durable task and event records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
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"})
|
||||
|
||||
|
||||
class TaskDashboardView:
|
||||
def build(self, tasks: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
tasks = tasks or []
|
||||
"""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
|
||||
]
|
||||
return {
|
||||
"module": "task-center",
|
||||
"route": "/admin/ai-evolution/tasks",
|
||||
"title": "AIOS Task Center",
|
||||
"views": {
|
||||
"kanban": self._kanban(tasks),
|
||||
"timeline": self._timeline(tasks),
|
||||
"flow": self._flow(tasks),
|
||||
"statistics": self._statistics(tasks),
|
||||
"kanban": self._kanban(cards),
|
||||
"timeline": self._timeline(cards),
|
||||
"flow": self._flow(cards),
|
||||
"statistics": self._statistics(cards),
|
||||
},
|
||||
"tasks": [self._task_card(task) for task in tasks],
|
||||
"tasks": cards,
|
||||
}
|
||||
|
||||
def _task_card(self, task: dict[str, Any]) -> dict[str, Any]:
|
||||
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
|
||||
return {
|
||||
"task_id": task.get("task_id"),
|
||||
"title": task.get("title"),
|
||||
"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"),
|
||||
"assigned_by": task.get("assigned_by"),
|
||||
"executor": task.get("executor"),
|
||||
"status": task.get("status"),
|
||||
"progress": task.get("progress", {}),
|
||||
"elapsed_seconds": task.get("elapsed_seconds", 0),
|
||||
"remaining_steps": task.get("progress", {}).get("remaining_steps", []),
|
||||
"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", []),
|
||||
"notification": task.get("notification", {}),
|
||||
"actions": [
|
||||
"start",
|
||||
"update_progress",
|
||||
"block",
|
||||
"request_approval",
|
||||
"approve",
|
||||
"reject",
|
||||
"retry",
|
||||
"complete",
|
||||
"notify",
|
||||
"close",
|
||||
],
|
||||
"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,
|
||||
}
|
||||
|
||||
def _kanban(self, tasks):
|
||||
@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]]:
|
||||
columns = {"pending": [], "running": [], "blocked": [], "waiting_approval": [], "completed": []}
|
||||
for task in tasks:
|
||||
columns.setdefault(task.get("status", "pending"), []).append(task.get("task_id"))
|
||||
return columns
|
||||
|
||||
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 _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 _flow(self, tasks):
|
||||
return {
|
||||
"nodes": ["created", "assigned", "running", "blocked", "approval", "completed", "closed"],
|
||||
"task_count": len(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)}
|
||||
|
||||
def _statistics(self, tasks):
|
||||
@staticmethod
|
||||
def _statistics(tasks: list[dict[str, Any]]) -> dict[str, int]:
|
||||
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"]),
|
||||
"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),
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
__all__ = ["TaskDashboardView"]
|
||||
@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")
|
||||
|
||||
|
||||
__all__ = ["ProjectionCursorExpired", "ProjectionCursorTransportLimit", "TaskDashboardView"]
|
||||
|
||||
@@ -35,7 +35,7 @@ from .models import (
|
||||
contains_secret_text,
|
||||
validate_identifier,
|
||||
)
|
||||
from .policy import PolicyEngine
|
||||
from .policy import IdentityVerifier, PolicyEngine
|
||||
from .root_action_consumer import (
|
||||
RootActionConsumer,
|
||||
RootActionExecutor,
|
||||
@@ -71,7 +71,9 @@ class TaskPlaneService:
|
||||
root_action_executor: RootActionExecutor | 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"}
|
||||
@@ -117,6 +119,9 @@ class TaskPlaneService:
|
||||
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,
|
||||
@@ -412,6 +417,11 @@ 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,
|
||||
@@ -436,6 +446,10 @@ 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}
|
||||
|
||||
@@ -538,16 +552,32 @@ class TaskPlaneService:
|
||||
previous = self.store._last_evidence_hash(task_id)
|
||||
evidence_entries = []
|
||||
for evidence in evidences:
|
||||
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"),
|
||||
)
|
||||
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"),
|
||||
)
|
||||
evidence_entries.append(envelope)
|
||||
previous = envelope.evidence_digest
|
||||
final_status = (
|
||||
@@ -910,8 +940,29 @@ def run_server(
|
||||
*,
|
||||
authenticator: Callable[[str], str | None] | None = None,
|
||||
):
|
||||
handler = TaskPlaneRequestHandler
|
||||
handler.service = service
|
||||
handler.authenticator = authenticator
|
||||
# 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,
|
||||
})
|
||||
server = ThreadingHTTPServer((host, port), handler)
|
||||
server.serve_forever()
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,173 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,325 @@
|
||||
"""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
|
||||
@@ -0,0 +1,972 @@
|
||||
"""AIOS-native binding layer for the unattended resume controller.
|
||||
|
||||
This module intentionally contains no HTTP client, Unix-socket client, database,
|
||||
systemd integration, scheduler loop, secret handling, or privileged identity
|
||||
mutation.
|
||||
|
||||
It converts existing AIOS authorities into the generic callbacks defined by
|
||||
``autonomous_resume_integration``. Field code supplies the current authoritative
|
||||
implementations for:
|
||||
|
||||
- durable Owner authorization/grant projection;
|
||||
- canonical governed-worker activation/status;
|
||||
- governed task admission;
|
||||
- task/claim/TaskLease/TaskPrincipal/WriterLease/CAS ownership;
|
||||
- bounded source-lane execution and integration;
|
||||
- Gate0;
|
||||
- ReleaseReady/deployability;
|
||||
- PromotionController;
|
||||
- final production acceptance;
|
||||
- mission resume lease/checkpoint/effect reconciliation.
|
||||
|
||||
The adapter is deterministic and fail-closed. It never fabricates any credential,
|
||||
principal, lease, fence, task, commit, receipt, approval, or deployment result.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping, Protocol, Sequence
|
||||
|
||||
from .autonomous_resume_controller import (
|
||||
EffectState,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
StepStatus,
|
||||
)
|
||||
from .autonomous_resume_integration import (
|
||||
AuthorityCallbacks,
|
||||
CallableAutonomousExecutionPort,
|
||||
PhaseCallbacks,
|
||||
)
|
||||
|
||||
|
||||
def _canonical(value) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
class WorkerState(str, Enum):
|
||||
ACTIVE = "ACTIVE"
|
||||
STARTING = "STARTING"
|
||||
INACTIVE = "INACTIVE"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class WorkState(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETE = "COMPLETE"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class GateState(str, Enum):
|
||||
PASS = "PASS"
|
||||
HOLD = "HOLD"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ReadyState(str, Enum):
|
||||
READY = "READY"
|
||||
NOT_READY = "NOT_READY"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class PromotionState(str, Enum):
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
VERIFIED = "VERIFIED"
|
||||
ROLLED_BACK = "ROLLED_BACK"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class AcceptanceState(str, Enum):
|
||||
PASS = "PASS"
|
||||
FAIL_ROLLED_BACK = "FAIL_ROLLED_BACK"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DurableGrantView:
|
||||
grant_id: str
|
||||
active: bool
|
||||
repository: str
|
||||
allowed_target_prefixes: tuple[str, ...]
|
||||
max_task_risk: str
|
||||
expires_at: float | None = None
|
||||
owner_action_required: bool = False
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def permits(self, *, repository: str, branch: str, risk: str) -> bool:
|
||||
if not self.active:
|
||||
return False
|
||||
if self.repository != repository:
|
||||
return False
|
||||
if not any(branch.startswith(prefix) for prefix in self.allowed_target_prefixes):
|
||||
return False
|
||||
rank = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
if risk not in rank or self.max_task_risk not in rank:
|
||||
return False
|
||||
return rank[risk] <= rank[self.max_task_risk]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskLaneSpec:
|
||||
lane: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
description: str
|
||||
write_set: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.lane or not self.task_id or not self.repository:
|
||||
raise ValueError("lane_task_repository_required")
|
||||
if not self.target_branch.startswith("agent/"):
|
||||
raise ValueError("target_branch_must_use_agent_prefix")
|
||||
if not self.write_set:
|
||||
raise ValueError("write_set_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskAdmissionRequest:
|
||||
task_id: str
|
||||
idempotency_key: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
expected_branch_oid: str
|
||||
risk: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskAdmissionReceipt:
|
||||
task_id: str
|
||||
generation: int
|
||||
target_branch: str
|
||||
persisted: bool
|
||||
claimable: bool
|
||||
receipt_ref: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.generation < 1:
|
||||
raise ValueError("task_generation_must_be_positive")
|
||||
if not self.receipt_ref:
|
||||
raise ValueError("task_admission_receipt_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerActivationReceipt:
|
||||
state: WorkerState
|
||||
receipt_ref: str | None = None
|
||||
retry_after_seconds: int | None = None
|
||||
owner_action_required: bool = False
|
||||
code: str = "WORKER_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaneExecutionView:
|
||||
lane: str
|
||||
state: WorkState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "LANE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationView:
|
||||
state: WorkState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "INTEGRATION_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GateView:
|
||||
state: GateState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "GATE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseReadyView:
|
||||
state: ReadyState
|
||||
condition_fingerprint: str
|
||||
material_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "READY_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromotionView:
|
||||
state: PromotionState
|
||||
condition_fingerprint: str
|
||||
material_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "PROMOTION_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceView:
|
||||
state: AcceptanceState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
code: str = "ACCEPTANCE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionEffectView:
|
||||
effect_state: EffectState
|
||||
condition_fingerprint: str
|
||||
requires_reconcile: bool
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class AiosMissionAuthorities(Protocol):
|
||||
"""Existing AIOS authority surface consumed by this adapter."""
|
||||
|
||||
# Live truth.
|
||||
def read_head_oid(self, repository: str) -> str: ...
|
||||
def read_material_digest(self, mission_id: str) -> str: ...
|
||||
def read_generation(self, mission_id: str) -> int: ...
|
||||
def read_effect_view(self, mission_id: str) -> MissionEffectView: ...
|
||||
def owner_action_resolved(self, mission_id: str) -> bool: ...
|
||||
|
||||
# Durable mission state / one-winner resume authority.
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None: ...
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None: ...
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> None: ...
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload: Mapping[str, object],
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str: ...
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
outcome_code: str,
|
||||
) -> None: ...
|
||||
def reconcile_effects(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
# Admission.
|
||||
def read_durable_grant(self, mission_id: str) -> DurableGrantView: ...
|
||||
def ensure_canonical_worker(
|
||||
self,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WorkerActivationReceipt: ...
|
||||
def create_or_resume_task(
|
||||
self,
|
||||
request: TaskAdmissionRequest,
|
||||
) -> TaskAdmissionReceipt: ...
|
||||
def read_task_admission(
|
||||
self,
|
||||
lane: TaskLaneSpec,
|
||||
) -> TaskAdmissionReceipt | None: ...
|
||||
|
||||
# Phase execution.
|
||||
def read_lane_execution(
|
||||
self,
|
||||
lane: TaskLaneSpec,
|
||||
task: TaskAdmissionReceipt,
|
||||
) -> LaneExecutionView: ...
|
||||
def drive_integration(
|
||||
self,
|
||||
lanes: Sequence[TaskLaneSpec],
|
||||
tasks: Sequence[TaskAdmissionReceipt],
|
||||
) -> IntegrationView: ...
|
||||
def run_gate0(self, mission_id: str) -> GateView: ...
|
||||
def build_release_ready(self, mission_id: str) -> ReleaseReadyView: ...
|
||||
def run_promotion(self, mission_id: str) -> PromotionView: ...
|
||||
def run_final_acceptance(self, mission_id: str) -> AcceptanceView: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AiosUnattendedMissionConfig:
|
||||
mission_id: str
|
||||
repository: str
|
||||
lanes: tuple[TaskLaneSpec, ...]
|
||||
admission_risk: str = "medium"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.mission_id or not self.repository:
|
||||
raise ValueError("mission_and_repository_required")
|
||||
if not self.lanes:
|
||||
raise ValueError("at_least_one_lane_required")
|
||||
names = [lane.lane for lane in self.lanes]
|
||||
task_ids = [lane.task_id for lane in self.lanes]
|
||||
branches = [lane.target_branch for lane in self.lanes]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("duplicate_lane")
|
||||
if len(task_ids) != len(set(task_ids)):
|
||||
raise ValueError("duplicate_task_id")
|
||||
if len(branches) != len(set(branches)):
|
||||
raise ValueError("duplicate_target_branch")
|
||||
owners: dict[str, str] = {}
|
||||
for lane in self.lanes:
|
||||
if lane.repository != self.repository:
|
||||
raise ValueError("lane_repository_mismatch")
|
||||
for resource in lane.write_set:
|
||||
if resource in owners:
|
||||
raise ValueError(
|
||||
f"overlapping_lane_resource:{resource}:"
|
||||
f"{owners[resource]}:{lane.lane}"
|
||||
)
|
||||
owners[resource] = lane.lane
|
||||
|
||||
|
||||
class AiosUnattendedCallbackFactory:
|
||||
"""Construct generic controller callbacks from current AIOS authorities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority: AiosMissionAuthorities,
|
||||
config: AiosUnattendedMissionConfig,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.config = config
|
||||
self._admissions: dict[str, TaskAdmissionReceipt] = {}
|
||||
|
||||
def build_port(self) -> CallableAutonomousExecutionPort:
|
||||
return CallableAutonomousExecutionPort(
|
||||
AuthorityCallbacks(
|
||||
read_observation=self.read_observation,
|
||||
load_checkpoint=self.authority.load_checkpoint,
|
||||
acquire_resume_lease=self.authority.acquire_resume_lease,
|
||||
persist_checkpoint=self.authority.persist_checkpoint,
|
||||
persist_owner_action_required=self.authority.persist_owner_action_required,
|
||||
release_resume_lease=self.authority.release_resume_lease,
|
||||
reconcile=self.authority.reconcile_effects,
|
||||
),
|
||||
PhaseCallbacks(
|
||||
admission=self.admission,
|
||||
source_execution=self.source_execution,
|
||||
gate0=self.gate0,
|
||||
release_ready=self.release_ready,
|
||||
promotion=self.promotion,
|
||||
final_acceptance=self.final_acceptance,
|
||||
),
|
||||
)
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation:
|
||||
if mission_id != self.config.mission_id:
|
||||
raise ValueError("mission_id_mismatch")
|
||||
generation = self.authority.read_generation(mission_id)
|
||||
head_oid = self.authority.read_head_oid(self.config.repository)
|
||||
material = self.authority.read_material_digest(mission_id)
|
||||
effect = self.authority.read_effect_view(mission_id)
|
||||
fingerprint = _digest({
|
||||
"schema": "aios.unattended-condition.v1",
|
||||
"mission_id": mission_id,
|
||||
"generation": generation,
|
||||
"head_oid": head_oid,
|
||||
"material_digest": material,
|
||||
"effect_state": effect.effect_state.value,
|
||||
"effect_condition": effect.condition_fingerprint,
|
||||
"requires_reconcile": effect.requires_reconcile,
|
||||
})
|
||||
return MissionObservation(
|
||||
mission_id=mission_id,
|
||||
generation=generation,
|
||||
head_oid=head_oid,
|
||||
material_digest=material,
|
||||
condition_fingerprint=fingerprint,
|
||||
effect_state=effect.effect_state,
|
||||
requires_reconcile=effect.requires_reconcile,
|
||||
owner_action_resolved=self.authority.owner_action_resolved(mission_id),
|
||||
evidence_refs=effect.receipt_refs,
|
||||
)
|
||||
|
||||
def admission(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
grant = self.authority.read_durable_grant(observation.mission_id)
|
||||
grant_refs = tuple(grant.evidence_refs)
|
||||
|
||||
if not grant.active:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if grant.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
"DURABLE_GRANT_NOT_ACTIVE",
|
||||
self._phase_fingerprint(
|
||||
"admission", observation, {"grant_id": grant.grant_id, "active": False}
|
||||
),
|
||||
receipt_refs=grant_refs,
|
||||
owner_action_payload=(
|
||||
{
|
||||
"boundary": "DURABLE_GRANT_NOT_ACTIVE",
|
||||
"grant_id": grant.grant_id,
|
||||
}
|
||||
if grant.owner_action_required else None
|
||||
),
|
||||
)
|
||||
|
||||
for lane in self.config.lanes:
|
||||
if not grant.permits(
|
||||
repository=lane.repository,
|
||||
branch=lane.target_branch,
|
||||
risk=lane.risk,
|
||||
):
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if grant.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
"DURABLE_GRANT_SCOPE_MISMATCH",
|
||||
self._phase_fingerprint(
|
||||
"admission",
|
||||
observation,
|
||||
{
|
||||
"grant_id": grant.grant_id,
|
||||
"lane": lane.lane,
|
||||
"branch": lane.target_branch,
|
||||
"risk": lane.risk,
|
||||
},
|
||||
),
|
||||
receipt_refs=grant_refs,
|
||||
owner_action_payload=(
|
||||
{
|
||||
"boundary": "DURABLE_GRANT_SCOPE_MISMATCH",
|
||||
"grant_id": grant.grant_id,
|
||||
"lane": lane.lane,
|
||||
}
|
||||
if grant.owner_action_required else None
|
||||
),
|
||||
)
|
||||
|
||||
worker = self.authority.ensure_canonical_worker(
|
||||
observation.mission_id,
|
||||
observation.generation,
|
||||
self._idempotency(
|
||||
"worker",
|
||||
observation.mission_id,
|
||||
observation.generation,
|
||||
),
|
||||
)
|
||||
worker_refs = self._refs(grant_refs, (worker.receipt_ref or "",))
|
||||
|
||||
if worker.state is WorkerState.UNKNOWN:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
)
|
||||
if worker.state is WorkerState.BLOCKED:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if worker.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
owner_action_payload=(
|
||||
{"boundary": worker.code}
|
||||
if worker.owner_action_required else None
|
||||
),
|
||||
)
|
||||
if worker.state in {WorkerState.STARTING, WorkerState.INACTIVE}:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
retry_after_seconds=worker.retry_after_seconds,
|
||||
)
|
||||
if worker.state is WorkerState.ACTIVE and not worker.receipt_ref:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"WORKER_ACTIVE_WITHOUT_RECEIPT",
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
)
|
||||
|
||||
task_refs: list[str] = list(worker_refs)
|
||||
admissions: dict[str, TaskAdmissionReceipt] = {}
|
||||
for lane in self.config.lanes:
|
||||
request = TaskAdmissionRequest(
|
||||
task_id=lane.task_id,
|
||||
idempotency_key=self._idempotency(
|
||||
"task",
|
||||
observation.generation,
|
||||
lane.task_id,
|
||||
observation.head_oid,
|
||||
),
|
||||
repository=lane.repository,
|
||||
base_oid=observation.head_oid,
|
||||
target_branch=lane.target_branch,
|
||||
expected_branch_oid=observation.head_oid,
|
||||
risk=lane.risk,
|
||||
description=lane.description,
|
||||
)
|
||||
receipt = self.authority.create_or_resume_task(request)
|
||||
task_refs.append(receipt.receipt_ref)
|
||||
receipt_error = self._admission_receipt_error(
|
||||
lane,
|
||||
receipt,
|
||||
observation=observation,
|
||||
)
|
||||
if receipt_error is not None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
receipt_error,
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
if not receipt.persisted:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_PERSISTENCE_UNKNOWN",
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
if not receipt.claimable:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"TASK_NOT_CLAIMABLE_YET",
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
admissions[lane.lane] = receipt
|
||||
|
||||
self._admissions = admissions
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"GOVERNED_TASKS_ADMITTED",
|
||||
self._phase_fingerprint(
|
||||
"admission",
|
||||
observation,
|
||||
{"tasks": sorted(r.task_id for r in admissions.values())},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
next_phase=MissionPhase.S2_EXECUTION,
|
||||
)
|
||||
|
||||
def source_execution(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
recovery_refs: list[str] = []
|
||||
for lane in self.config.lanes:
|
||||
if lane.lane in self._admissions:
|
||||
continue
|
||||
receipt = self.authority.read_task_admission(lane)
|
||||
if receipt is None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_ADMISSION_NOT_RESOLVED",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
recovery_refs.append(receipt.receipt_ref)
|
||||
receipt_error = self._admission_receipt_error(
|
||||
lane,
|
||||
receipt,
|
||||
observation=observation,
|
||||
)
|
||||
if receipt_error is not None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
receipt_error,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
if not receipt.persisted:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_PERSISTENCE_UNKNOWN",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
if not receipt.claimable:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"TASK_NOT_CLAIMABLE_YET",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
self._admissions[lane.lane] = receipt
|
||||
|
||||
views: list[LaneExecutionView] = []
|
||||
refs: list[str] = list(recovery_refs)
|
||||
for lane in self.config.lanes:
|
||||
view = self.authority.read_lane_execution(
|
||||
lane,
|
||||
self._admissions[lane.lane],
|
||||
)
|
||||
if view.lane != lane.lane:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"LANE_IDENTITY_MISMATCH",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"expected": lane.lane, "actual": view.lane},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if view.state is WorkState.COMPLETE and not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"LANE_COMPLETE_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
views.append(view)
|
||||
refs.extend(view.receipt_refs)
|
||||
|
||||
unknown = [v for v in views if v.state is WorkState.UNKNOWN]
|
||||
if unknown:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
unknown[0].code,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
|
||||
blocked = [v for v in views if v.state is WorkState.BLOCKED]
|
||||
if blocked:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_DETERMINISTIC,
|
||||
blocked[0].code,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
|
||||
pending = [
|
||||
v for v in views
|
||||
if v.state in {WorkState.PENDING, WorkState.RUNNING}
|
||||
]
|
||||
if pending:
|
||||
retry_values = [
|
||||
v.retry_after_seconds
|
||||
for v in pending
|
||||
if v.retry_after_seconds is not None
|
||||
]
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"SOURCE_LANES_IN_PROGRESS",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
retry_after_seconds=min(retry_values) if retry_values else None,
|
||||
)
|
||||
|
||||
integration = self.authority.drive_integration(
|
||||
self.config.lanes,
|
||||
tuple(self._admissions[lane.lane] for lane in self.config.lanes),
|
||||
)
|
||||
refs.extend(integration.receipt_refs)
|
||||
if integration.state is WorkState.UNKNOWN:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if integration.state is WorkState.BLOCKED:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_DETERMINISTIC,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if integration.state in {WorkState.PENDING, WorkState.RUNNING}:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
retry_after_seconds=integration.retry_after_seconds,
|
||||
)
|
||||
if integration.state is WorkState.COMPLETE and not integration.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"INTEGRATION_COMPLETE_WITHOUT_RECEIPT",
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"SOURCE_INTEGRATION_COMPLETE",
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
next_phase=MissionPhase.GATE0,
|
||||
)
|
||||
|
||||
def gate0(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_gate0(observation.mission_id)
|
||||
if view.state is GateState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is GateState.HOLD:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"GATE0_PASS_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.RELEASE_READY
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
)
|
||||
|
||||
def release_ready(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.build_release_ready(observation.mission_id)
|
||||
if view.state is ReadyState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is ReadyState.NOT_READY:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
if not view.material_digest:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"READY_WITHOUT_MATERIAL_DIGEST",
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
)
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"READY_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.PROMOTION
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
|
||||
def promotion(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_promotion(observation.mission_id)
|
||||
if view.state is PromotionState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is PromotionState.IN_PROGRESS:
|
||||
status = StepStatus.IN_PROGRESS
|
||||
elif view.state is PromotionState.VERIFIED:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"PROMOTION_VERIFIED_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
elif view.state is PromotionState.ROLLED_BACK:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.FINAL_ACCEPTANCE
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
|
||||
def final_acceptance(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_final_acceptance(observation.mission_id)
|
||||
if view.state is AcceptanceState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is AcceptanceState.PASS:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"FINAL_ACCEPTANCE_PASS_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
else:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
next_phase=(
|
||||
MissionPhase.COMPLETE
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _admission_receipt_error(
|
||||
lane: TaskLaneSpec,
|
||||
receipt: TaskAdmissionReceipt,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
) -> str | None:
|
||||
if receipt.task_id != lane.task_id:
|
||||
return "TASK_IDENTITY_MISMATCH"
|
||||
if receipt.target_branch != lane.target_branch:
|
||||
return "TASK_BRANCH_MISMATCH"
|
||||
if receipt.generation != observation.generation:
|
||||
return "TASK_GENERATION_MISMATCH"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _refs(*groups: Sequence[str]) -> tuple[str, ...]:
|
||||
seen: set[str] = set()
|
||||
values: list[str] = []
|
||||
for group in groups:
|
||||
for item in group:
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
values.append(item)
|
||||
return tuple(values)
|
||||
|
||||
@staticmethod
|
||||
def _idempotency(*parts: object) -> str:
|
||||
return "aios:auto:" + _digest(
|
||||
{"schema": "aios.unattended-idempotency.v1", "parts": list(parts)}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _phase_fingerprint(
|
||||
phase: str,
|
||||
observation: MissionObservation,
|
||||
details: Mapping[str, object],
|
||||
) -> str:
|
||||
return _digest({
|
||||
"schema": "aios.unattended-phase-condition.v1",
|
||||
"phase": phase,
|
||||
"mission_id": observation.mission_id,
|
||||
"generation": observation.generation,
|
||||
"head_oid": observation.head_oid,
|
||||
"material_digest": observation.material_digest,
|
||||
"details": details,
|
||||
})
|
||||
@@ -0,0 +1,877 @@
|
||||
"""Deterministic unattended mission-resume controller for AIOS.
|
||||
|
||||
The controller is deliberately small and does not create a second scheduler,
|
||||
database, identity system, or release control plane.
|
||||
|
||||
It executes exactly one reconciliation/progress cycle per ``run_once`` call.
|
||||
An existing AIOS scheduler/timer/worker loop should invoke it periodically.
|
||||
|
||||
All consequential effects are delegated through ``AutonomousExecutionPort`` and
|
||||
must use the existing AIOS authorities: durable Owner authorization, task
|
||||
admission, claim/TaskLease/TaskPrincipal, WriterLease/CAS/fencing, Gate0,
|
||||
ReleaseReady, PromotionController and governed rollback.
|
||||
|
||||
The controller's responsibilities are limited to:
|
||||
- one-winner resume lease acquisition;
|
||||
- durable checkpoint progression;
|
||||
- bounded retry/backoff;
|
||||
- unchanged deterministic blocker suppression;
|
||||
- UNKNOWN/ambiguous-effect reconciliation before retry;
|
||||
- non-interactive owner-boundary recording;
|
||||
- crash-safe phase resumption.
|
||||
|
||||
It never manufactures credentials, group membership, principals, leases,
|
||||
signatures, branch OIDs, or release receipts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class MissionPhase(str, Enum):
|
||||
S2_ADMISSION = "S2_ADMISSION"
|
||||
S2_EXECUTION = "S2_EXECUTION"
|
||||
GATE0 = "GATE0"
|
||||
RELEASE_READY = "RELEASE_READY"
|
||||
PROMOTION = "PROMOTION"
|
||||
FINAL_ACCEPTANCE = "FINAL_ACCEPTANCE"
|
||||
OWNER_ACTION_REQUIRED = "OWNER_ACTION_REQUIRED"
|
||||
COMPLETE = "COMPLETE"
|
||||
|
||||
|
||||
class EffectState(str, Enum):
|
||||
ABSENT = "ABSENT"
|
||||
ACTIVE = "ACTIVE"
|
||||
COMPLETED = "COMPLETED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class StepStatus(str, Enum):
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
RETRYABLE = "RETRYABLE"
|
||||
RECONCILE = "RECONCILE"
|
||||
BLOCKED_DETERMINISTIC = "BLOCKED_DETERMINISTIC"
|
||||
BLOCKED_OWNER = "BLOCKED_OWNER"
|
||||
|
||||
|
||||
class OutcomeStatus(str, Enum):
|
||||
COMPLETE = "COMPLETE"
|
||||
PROGRESSED = "PROGRESSED"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
RETRY_SCHEDULED = "RETRY_SCHEDULED"
|
||||
WAIT_CONDITION_CHANGE = "WAIT_CONDITION_CHANGE"
|
||||
ACTIVE_ELSEWHERE = "ACTIVE_ELSEWHERE"
|
||||
OWNER_ACTION_REQUIRED = "OWNER_ACTION_REQUIRED"
|
||||
|
||||
|
||||
_PHASE_NEXT: dict[MissionPhase, MissionPhase] = {
|
||||
MissionPhase.S2_ADMISSION: MissionPhase.S2_EXECUTION,
|
||||
MissionPhase.S2_EXECUTION: MissionPhase.GATE0,
|
||||
MissionPhase.GATE0: MissionPhase.RELEASE_READY,
|
||||
MissionPhase.RELEASE_READY: MissionPhase.PROMOTION,
|
||||
MissionPhase.PROMOTION: MissionPhase.FINAL_ACCEPTANCE,
|
||||
MissionPhase.FINAL_ACCEPTANCE: MissionPhase.COMPLETE,
|
||||
}
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumePolicy:
|
||||
resume_lease_seconds: int = 120
|
||||
default_poll_seconds: int = 30
|
||||
deterministic_block_poll_seconds: int = 300
|
||||
transient_max_attempts: int = 3
|
||||
transient_base_delay_seconds: int = 2
|
||||
transient_max_delay_seconds: int = 60
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"resume_lease_seconds",
|
||||
"default_poll_seconds",
|
||||
"deterministic_block_poll_seconds",
|
||||
"transient_max_attempts",
|
||||
"transient_base_delay_seconds",
|
||||
"transient_max_delay_seconds",
|
||||
):
|
||||
if getattr(self, name) < 1:
|
||||
raise ValueError(f"{name}_must_be_positive")
|
||||
if self.transient_max_delay_seconds < self.transient_base_delay_seconds:
|
||||
raise ValueError("transient_max_delay_seconds_too_small")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionObservation:
|
||||
mission_id: str
|
||||
generation: int
|
||||
head_oid: str
|
||||
material_digest: str
|
||||
condition_fingerprint: str
|
||||
effect_state: EffectState = EffectState.ABSENT
|
||||
requires_reconcile: bool = False
|
||||
owner_action_resolved: bool = False
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if not self.head_oid:
|
||||
raise ValueError("head_oid_required")
|
||||
if not self.material_digest:
|
||||
raise ValueError("material_digest_required")
|
||||
if not self.condition_fingerprint:
|
||||
raise ValueError("condition_fingerprint_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumeLease:
|
||||
lease_id: str
|
||||
mission_id: str
|
||||
generation: int
|
||||
fencing_token: str
|
||||
expires_at: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.lease_id:
|
||||
raise ValueError("lease_id_required")
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if not self.fencing_token:
|
||||
raise ValueError("fencing_token_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepResult:
|
||||
status: StepStatus
|
||||
code: str
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
next_phase: MissionPhase | None = None
|
||||
material_digest: str | None = None
|
||||
owner_action_payload: Mapping[str, Any] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.code:
|
||||
raise ValueError("step_code_required")
|
||||
if not self.condition_fingerprint:
|
||||
raise ValueError("condition_fingerprint_required")
|
||||
if self.retry_after_seconds is not None and self.retry_after_seconds < 0:
|
||||
raise ValueError("retry_after_seconds_must_be_non_negative")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionCheckpoint:
|
||||
schema: str
|
||||
mission_id: str
|
||||
generation: int
|
||||
phase: MissionPhase
|
||||
head_oid: str
|
||||
material_digest: str
|
||||
last_condition_fingerprint: str
|
||||
attempt_count: int
|
||||
blocked_condition_fingerprint: str | None
|
||||
reconcile_required: bool
|
||||
frozen_material_digest: str | None
|
||||
resume_phase_after_owner: MissionPhase | None
|
||||
receipt_refs: tuple[str, ...]
|
||||
updated_at: float
|
||||
revision: int = 1
|
||||
|
||||
@classmethod
|
||||
def initial(
|
||||
cls,
|
||||
observation: MissionObservation,
|
||||
*,
|
||||
observed_at: float,
|
||||
) -> "MissionCheckpoint":
|
||||
return cls(
|
||||
schema="aios.autonomous-resume-checkpoint.v1",
|
||||
mission_id=observation.mission_id,
|
||||
generation=observation.generation,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=None,
|
||||
resume_phase_after_owner=None,
|
||||
receipt_refs=tuple(observation.evidence_refs),
|
||||
updated_at=observed_at,
|
||||
revision=1,
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.schema != "aios.autonomous-resume-checkpoint.v1":
|
||||
raise ValueError("unsupported_checkpoint_schema")
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if self.attempt_count < 0:
|
||||
raise ValueError("attempt_count_must_be_non_negative")
|
||||
if self.revision < 1:
|
||||
raise ValueError("revision_must_be_positive")
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
payload = asdict(self)
|
||||
payload["phase"] = self.phase.value
|
||||
payload["resume_phase_after_owner"] = (
|
||||
None
|
||||
if self.resume_phase_after_owner is None
|
||||
else self.resume_phase_after_owner.value
|
||||
)
|
||||
payload["receipt_refs"] = list(self.receipt_refs)
|
||||
return _sha256(_canonical(payload))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControllerOutcome:
|
||||
status: OutcomeStatus
|
||||
mission_id: str
|
||||
phase: MissionPhase
|
||||
code: str
|
||||
next_wake_after_seconds: int | None
|
||||
checkpoint_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class AutonomousExecutionPort(Protocol):
|
||||
"""Adapter boundary to existing AIOS authorities and executors."""
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation: ...
|
||||
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None: ...
|
||||
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None: ...
|
||||
|
||||
def step(
|
||||
self,
|
||||
*,
|
||||
phase: MissionPhase,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
*,
|
||||
lease: ResumeLease,
|
||||
) -> None: ...
|
||||
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str: ...
|
||||
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
outcome_code: str,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class AutonomousResumeController:
|
||||
"""One-cycle deterministic unattended mission controller."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: AutonomousExecutionPort,
|
||||
*,
|
||||
policy: ResumePolicy | None = None,
|
||||
clock: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
self.port = port
|
||||
self.policy = policy or ResumePolicy()
|
||||
self.clock = clock or time.time
|
||||
|
||||
def run_once(self, mission_id: str) -> ControllerOutcome:
|
||||
if not mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
|
||||
now = float(self.clock())
|
||||
observation = self.port.read_observation(mission_id)
|
||||
checkpoint = self.port.load_checkpoint(mission_id)
|
||||
if checkpoint is None:
|
||||
checkpoint = MissionCheckpoint.initial(observation, observed_at=now)
|
||||
forced_reconcile = False
|
||||
else:
|
||||
checkpoint, forced_reconcile = self._validate_and_refresh_checkpoint(
|
||||
checkpoint,
|
||||
observation,
|
||||
now=now,
|
||||
)
|
||||
|
||||
if checkpoint.phase is MissionPhase.COMPLETE:
|
||||
return self._outcome(
|
||||
OutcomeStatus.COMPLETE,
|
||||
checkpoint,
|
||||
"MISSION_ALREADY_COMPLETE",
|
||||
next_wake=None,
|
||||
)
|
||||
|
||||
if checkpoint.phase is MissionPhase.OWNER_ACTION_REQUIRED:
|
||||
if not observation.owner_action_resolved:
|
||||
return self._outcome(
|
||||
OutcomeStatus.OWNER_ACTION_REQUIRED,
|
||||
checkpoint,
|
||||
"OWNER_ACTION_STILL_REQUIRED",
|
||||
next_wake=None,
|
||||
)
|
||||
resume_phase = (
|
||||
checkpoint.resume_phase_after_owner
|
||||
or MissionPhase.S2_ADMISSION
|
||||
)
|
||||
checkpoint = replace(
|
||||
checkpoint,
|
||||
phase=resume_phase,
|
||||
resume_phase_after_owner=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
checkpoint.blocked_condition_fingerprint is not None
|
||||
and checkpoint.blocked_condition_fingerprint
|
||||
== observation.condition_fingerprint
|
||||
):
|
||||
return self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
checkpoint,
|
||||
"UNCHANGED_DETERMINISTIC_BLOCKER",
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
)
|
||||
|
||||
if (
|
||||
checkpoint.blocked_condition_fingerprint is not None
|
||||
and checkpoint.blocked_condition_fingerprint
|
||||
!= observation.condition_fingerprint
|
||||
):
|
||||
checkpoint = replace(
|
||||
checkpoint,
|
||||
blocked_condition_fingerprint=None,
|
||||
attempt_count=0,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
lease = self.port.acquire_resume_lease(
|
||||
mission_id=observation.mission_id,
|
||||
generation=observation.generation,
|
||||
idempotency_key=self._resume_idempotency_key(observation),
|
||||
lease_seconds=self.policy.resume_lease_seconds,
|
||||
)
|
||||
if lease is None:
|
||||
return self._outcome(
|
||||
OutcomeStatus.ACTIVE_ELSEWHERE,
|
||||
checkpoint,
|
||||
"RESUME_LEASE_HELD_ELSEWHERE",
|
||||
next_wake=self.policy.default_poll_seconds,
|
||||
)
|
||||
|
||||
try:
|
||||
self._validate_resume_lease(
|
||||
lease,
|
||||
observation=observation,
|
||||
now=now,
|
||||
)
|
||||
except Exception:
|
||||
self.port.release_resume_lease(
|
||||
lease,
|
||||
outcome_code="INVALID_RESUME_LEASE",
|
||||
)
|
||||
raise
|
||||
|
||||
outcome_code = "UNSET"
|
||||
try:
|
||||
reconciling = (
|
||||
forced_reconcile
|
||||
or checkpoint.reconcile_required
|
||||
or observation.requires_reconcile
|
||||
or observation.effect_state is EffectState.UNKNOWN
|
||||
)
|
||||
if reconciling:
|
||||
result = self.port.reconcile(
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
else:
|
||||
result = self.port.step(
|
||||
phase=checkpoint.phase,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
|
||||
outcome, new_checkpoint = self._apply_result(
|
||||
result=result,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
now=now,
|
||||
lease=lease,
|
||||
reconciling=reconciling,
|
||||
)
|
||||
outcome_code = outcome.code
|
||||
self.port.persist_checkpoint(new_checkpoint, lease=lease)
|
||||
return replace(
|
||||
outcome,
|
||||
checkpoint_digest=new_checkpoint.digest,
|
||||
)
|
||||
finally:
|
||||
self.port.release_resume_lease(
|
||||
lease,
|
||||
outcome_code=outcome_code,
|
||||
)
|
||||
|
||||
def _validate_and_refresh_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
observation: MissionObservation,
|
||||
*,
|
||||
now: float,
|
||||
) -> tuple[MissionCheckpoint, bool]:
|
||||
if checkpoint.mission_id != observation.mission_id:
|
||||
raise ValueError("checkpoint_mission_mismatch")
|
||||
if checkpoint.generation > observation.generation:
|
||||
raise ValueError("checkpoint_generation_ahead_of_authority")
|
||||
|
||||
refreshed = checkpoint
|
||||
forced_reconcile = False
|
||||
if checkpoint.generation < observation.generation:
|
||||
refreshed = replace(
|
||||
checkpoint,
|
||||
generation=observation.generation,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=None,
|
||||
resume_phase_after_owner=None,
|
||||
receipt_refs=self._merge_refs(
|
||||
checkpoint.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
refreshed.phase in {
|
||||
MissionPhase.RELEASE_READY,
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and refreshed.frozen_material_digest is not None
|
||||
and refreshed.frozen_material_digest
|
||||
!= observation.material_digest
|
||||
):
|
||||
# Material changed after READY. Promotion/final may already have
|
||||
# observable side effects, so reconcile before rebuilding PREPARE.
|
||||
previous_phase = refreshed.phase
|
||||
force_for_effect = (
|
||||
previous_phase in {
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and observation.effect_state in {
|
||||
EffectState.ACTIVE,
|
||||
EffectState.UNKNOWN,
|
||||
}
|
||||
)
|
||||
refreshed = replace(
|
||||
refreshed,
|
||||
phase=MissionPhase.RELEASE_READY,
|
||||
material_digest=observation.material_digest,
|
||||
head_oid=observation.head_oid,
|
||||
frozen_material_digest=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=force_for_effect,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
receipt_refs=self._merge_refs(
|
||||
refreshed.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=refreshed.revision + 1,
|
||||
)
|
||||
forced_reconcile = forced_reconcile or force_for_effect
|
||||
elif (
|
||||
refreshed.phase in {
|
||||
MissionPhase.S2_ADMISSION,
|
||||
MissionPhase.S2_EXECUTION,
|
||||
MissionPhase.GATE0,
|
||||
}
|
||||
and refreshed.head_oid != observation.head_oid
|
||||
):
|
||||
# Source moved before READY. Re-enter S2 admission/replan.
|
||||
refreshed = replace(
|
||||
refreshed,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
frozen_material_digest=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
receipt_refs=self._merge_refs(
|
||||
refreshed.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=refreshed.revision + 1,
|
||||
)
|
||||
|
||||
return refreshed, forced_reconcile
|
||||
|
||||
def _apply_result(
|
||||
self,
|
||||
*,
|
||||
result: StepResult,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
now: float,
|
||||
lease: ResumeLease,
|
||||
reconciling: bool,
|
||||
) -> tuple[ControllerOutcome, MissionCheckpoint]:
|
||||
refs = self._merge_refs(
|
||||
checkpoint.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
result.receipt_refs,
|
||||
)
|
||||
base = replace(
|
||||
checkpoint,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=(
|
||||
result.material_digest
|
||||
or observation.material_digest
|
||||
),
|
||||
last_condition_fingerprint=result.condition_fingerprint,
|
||||
receipt_refs=refs,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
result.status is StepStatus.SUCCEEDED
|
||||
and checkpoint.phase in {
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and checkpoint.frozen_material_digest is not None
|
||||
and result.material_digest is not None
|
||||
and result.material_digest != checkpoint.frozen_material_digest
|
||||
):
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=MissionPhase.RELEASE_READY,
|
||||
material_digest=result.material_digest,
|
||||
frozen_material_digest=None,
|
||||
reconcile_required=True,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
"RECONCILE_REQUIRED:MATERIAL_CHANGED_DURING_PROMOTION",
|
||||
next_wake=0,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.SUCCEEDED:
|
||||
next_phase = (
|
||||
result.next_phase
|
||||
or _PHASE_NEXT.get(checkpoint.phase, checkpoint.phase)
|
||||
)
|
||||
frozen = base.frozen_material_digest
|
||||
if checkpoint.phase is MissionPhase.RELEASE_READY:
|
||||
frozen = result.material_digest or observation.material_digest
|
||||
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=next_phase,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=frozen,
|
||||
)
|
||||
status = (
|
||||
OutcomeStatus.COMPLETE
|
||||
if next_phase is MissionPhase.COMPLETE
|
||||
else OutcomeStatus.PROGRESSED
|
||||
)
|
||||
code = (
|
||||
"MISSION_COMPLETE"
|
||||
if next_phase is MissionPhase.COMPLETE
|
||||
else result.code
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
status,
|
||||
new_checkpoint,
|
||||
code,
|
||||
next_wake=(
|
||||
None
|
||||
if status is OutcomeStatus.COMPLETE
|
||||
else self.policy.default_poll_seconds
|
||||
),
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.IN_PROGRESS:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=(
|
||||
result.retry_after_seconds
|
||||
if result.retry_after_seconds is not None
|
||||
else self.policy.default_poll_seconds
|
||||
),
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.RETRYABLE:
|
||||
attempts = checkpoint.attempt_count + 1
|
||||
if attempts > self.policy.transient_max_attempts:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=attempts,
|
||||
blocked_condition_fingerprint=result.condition_fingerprint,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
new_checkpoint,
|
||||
"RETRY_BUDGET_EXHAUSTED",
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
delay = (
|
||||
result.retry_after_seconds
|
||||
if result.retry_after_seconds is not None
|
||||
else min(
|
||||
self.policy.transient_base_delay_seconds
|
||||
* (2 ** (attempts - 1)),
|
||||
self.policy.transient_max_delay_seconds,
|
||||
)
|
||||
)
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=attempts,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.RETRY_SCHEDULED,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=delay,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.BLOCKED_DETERMINISTIC:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=checkpoint.attempt_count,
|
||||
blocked_condition_fingerprint=result.condition_fingerprint,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.BLOCKED_OWNER:
|
||||
payload = dict(result.owner_action_payload or {})
|
||||
payload.setdefault("mission_id", observation.mission_id)
|
||||
payload.setdefault("generation", observation.generation)
|
||||
payload.setdefault("phase", checkpoint.phase.value)
|
||||
payload.setdefault("code", result.code)
|
||||
record_ref = self.port.persist_owner_action_required(
|
||||
payload,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
if not record_ref:
|
||||
raise ValueError("owner_action_record_ref_required")
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=MissionPhase.OWNER_ACTION_REQUIRED,
|
||||
resume_phase_after_owner=checkpoint.phase,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
receipt_refs=self._merge_refs(refs, (record_ref,)),
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.OWNER_ACTION_REQUIRED,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=None,
|
||||
refs=(record_ref,),
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.RECONCILE:
|
||||
# The next scheduler cycle must reconcile before any retry.
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=True,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
"RECONCILE_REQUIRED:" + result.code,
|
||||
next_wake=0,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
raise AssertionError(f"unsupported_step_status:{result.status}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_resume_lease(
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
now: float,
|
||||
) -> None:
|
||||
if lease.mission_id != observation.mission_id:
|
||||
raise ValueError("resume_lease_mission_mismatch")
|
||||
if lease.generation != observation.generation:
|
||||
raise ValueError("resume_lease_generation_mismatch")
|
||||
if lease.expires_at <= now:
|
||||
raise ValueError("resume_lease_expired")
|
||||
|
||||
def _resume_idempotency_key(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
) -> str:
|
||||
payload = {
|
||||
"schema": "aios.resume-effect.v1",
|
||||
"mission_id": observation.mission_id,
|
||||
"generation": observation.generation,
|
||||
}
|
||||
return "resume:sha256:" + _sha256(_canonical(payload))
|
||||
|
||||
@staticmethod
|
||||
def _merge_refs(*groups: Sequence[str]) -> tuple[str, ...]:
|
||||
merged: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for group in groups:
|
||||
for item in group:
|
||||
value = str(item)
|
||||
if value and value not in seen:
|
||||
seen.add(value)
|
||||
merged.append(value)
|
||||
return tuple(merged)
|
||||
|
||||
@staticmethod
|
||||
def _outcome(
|
||||
status: OutcomeStatus,
|
||||
checkpoint: MissionCheckpoint,
|
||||
code: str,
|
||||
*,
|
||||
next_wake: int | None,
|
||||
refs: Sequence[str] = (),
|
||||
) -> ControllerOutcome:
|
||||
return ControllerOutcome(
|
||||
status=status,
|
||||
mission_id=checkpoint.mission_id,
|
||||
phase=checkpoint.phase,
|
||||
code=code,
|
||||
next_wake_after_seconds=next_wake,
|
||||
checkpoint_digest=None,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Thin callable adapter for :mod:`autonomous_resume_controller`.
|
||||
|
||||
This module contains no transport, daemon, database or scheduler loop. It only
|
||||
binds current AIOS runtime functions to the stable ``AutonomousExecutionPort``
|
||||
contract and exposes one scheduler tick.
|
||||
|
||||
A field integrator should:
|
||||
- resolve the live current APIs;
|
||||
- supply callables that use existing governed authorities;
|
||||
- register ``AutonomousSchedulerTick.run`` with the existing scheduler/timer.
|
||||
|
||||
No additional control plane is created here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from .autonomous_resume_controller import (
|
||||
AutonomousExecutionPort,
|
||||
AutonomousResumeController,
|
||||
ControllerOutcome,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
)
|
||||
|
||||
|
||||
PhaseHandler = Callable[
|
||||
[MissionObservation, MissionCheckpoint, ResumeLease],
|
||||
StepResult,
|
||||
]
|
||||
ReconcileHandler = PhaseHandler
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhaseCallbacks:
|
||||
admission: PhaseHandler
|
||||
source_execution: PhaseHandler
|
||||
gate0: PhaseHandler
|
||||
release_ready: PhaseHandler
|
||||
promotion: PhaseHandler
|
||||
final_acceptance: PhaseHandler
|
||||
|
||||
def for_phase(self, phase: MissionPhase) -> PhaseHandler:
|
||||
mapping = {
|
||||
MissionPhase.S2_ADMISSION: self.admission,
|
||||
MissionPhase.S2_EXECUTION: self.source_execution,
|
||||
MissionPhase.GATE0: self.gate0,
|
||||
MissionPhase.RELEASE_READY: self.release_ready,
|
||||
MissionPhase.PROMOTION: self.promotion,
|
||||
MissionPhase.FINAL_ACCEPTANCE: self.final_acceptance,
|
||||
}
|
||||
try:
|
||||
return mapping[phase]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"phase_has_no_executor:{phase.value}") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorityCallbacks:
|
||||
read_observation: Callable[[str], MissionObservation]
|
||||
load_checkpoint: Callable[[str], MissionCheckpoint | None]
|
||||
acquire_resume_lease: Callable[[str, int, str, int], ResumeLease | None]
|
||||
persist_checkpoint: Callable[[MissionCheckpoint, ResumeLease], None]
|
||||
persist_owner_action_required: Callable[
|
||||
[dict, MissionObservation, MissionCheckpoint, ResumeLease],
|
||||
str,
|
||||
]
|
||||
release_resume_lease: Callable[[ResumeLease, str], None]
|
||||
reconcile: ReconcileHandler
|
||||
|
||||
|
||||
class CallableAutonomousExecutionPort(AutonomousExecutionPort):
|
||||
"""Runtime adapter composed from already-governed AIOS callables."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority: AuthorityCallbacks,
|
||||
phases: PhaseCallbacks,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.phases = phases
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation:
|
||||
value = self.authority.read_observation(mission_id)
|
||||
if not isinstance(value, MissionObservation):
|
||||
raise TypeError("read_observation_must_return_MissionObservation")
|
||||
return value
|
||||
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None:
|
||||
value = self.authority.load_checkpoint(mission_id)
|
||||
if value is not None and not isinstance(value, MissionCheckpoint):
|
||||
raise TypeError("load_checkpoint_must_return_checkpoint_or_none")
|
||||
return value
|
||||
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None:
|
||||
value = self.authority.acquire_resume_lease(
|
||||
mission_id,
|
||||
generation,
|
||||
idempotency_key,
|
||||
lease_seconds,
|
||||
)
|
||||
if value is not None and not isinstance(value, ResumeLease):
|
||||
raise TypeError("acquire_resume_lease_must_return_lease_or_none")
|
||||
return value
|
||||
|
||||
def step(
|
||||
self,
|
||||
*,
|
||||
phase: MissionPhase,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
handler = self.phases.for_phase(phase)
|
||||
value = handler(observation, checkpoint, lease)
|
||||
if not isinstance(value, StepResult):
|
||||
raise TypeError("phase_handler_must_return_StepResult")
|
||||
return value
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
value = self.authority.reconcile(
|
||||
observation,
|
||||
checkpoint,
|
||||
lease,
|
||||
)
|
||||
if not isinstance(value, StepResult):
|
||||
raise TypeError("reconcile_must_return_StepResult")
|
||||
return value
|
||||
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
*,
|
||||
lease: ResumeLease,
|
||||
) -> None:
|
||||
self.authority.persist_checkpoint(checkpoint, lease)
|
||||
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str:
|
||||
value = self.authority.persist_owner_action_required(
|
||||
dict(payload),
|
||||
observation,
|
||||
checkpoint,
|
||||
lease,
|
||||
)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError("owner_action_record_ref_required")
|
||||
return value
|
||||
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
outcome_code: str,
|
||||
) -> None:
|
||||
self.authority.release_resume_lease(lease, outcome_code)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SchedulerTickResult:
|
||||
outcome: ControllerOutcome
|
||||
next_wake_after_seconds: int | None
|
||||
|
||||
|
||||
class AutonomousSchedulerTick:
|
||||
"""One invocation for an existing scheduler/timer.
|
||||
|
||||
This object never sleeps or loops. The host scheduler owns cadence.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: AutonomousResumeController,
|
||||
mission_id: str,
|
||||
) -> None:
|
||||
if not mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
self.controller = controller
|
||||
self.mission_id = mission_id
|
||||
|
||||
def run(self) -> SchedulerTickResult:
|
||||
outcome = self.controller.run_once(self.mission_id)
|
||||
return SchedulerTickResult(
|
||||
outcome=outcome,
|
||||
next_wake_after_seconds=outcome.next_wake_after_seconds,
|
||||
)
|
||||
@@ -0,0 +1,462 @@
|
||||
"""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
|
||||
@@ -0,0 +1,121 @@
|
||||
"""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"]
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from .models import EvidenceEnvelope
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
@@ -17,8 +18,44 @@ class EvidenceService:
|
||||
def __init__(self, store: TaskPlaneStore):
|
||||
self.store = store
|
||||
|
||||
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 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 list(self, task_id: str) -> list[dict]:
|
||||
return self.store.list_evidence(task_id)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,401 @@
|
||||
"""现场(field)组合根:让"Owner 基于授权"真正可接线、可执行。
|
||||
|
||||
本模块是 V2 governed-writer 机制的**现场装配层**,职责:
|
||||
- 实现正式的 `OwnerAuthorizationAuthority`:持有 Owner 登记的授权记录
|
||||
(authorization_ref -> OwnerAuthorizationPolicy),`verify()` 解析引用、
|
||||
核验策略、签发 verification receipt;未知引用 / 策略失效一律 fail-closed。
|
||||
- 提供 `FieldGovernedWriterFactory`(组合根):把 7 个协议适配器(signer /
|
||||
execution / principal_issuer / jobs / git / writer_lease / owner_authority)
|
||||
装配到 `GovernedWriterAutomation.acquire_verified()` 生产入口上。
|
||||
- 提供默认的生产适配器参考实现(`_DefaultOwnerAuthority`),供现场在真实
|
||||
端点未就绪时先闭环;真实端点就绪后替换对应 adapter 即可,组合根接口不变。
|
||||
|
||||
设计原则(与 OWNER_AUTHORIZATION_MODEL.md 的 `aios.owner-authorization.v1` 一致):
|
||||
1. 生产入口唯一授权来源是 Owner 登记的 `authorization_ref`;Agent 本地自造的
|
||||
`OwnerAuthorizationPolicy` 不构成 proof(fail-closed)。
|
||||
2. 长期业务授权(Policy)与短时执行凭证(epoch/lease/principal)分离。
|
||||
3. `legacy_global_writes_enabled=false` 是安全不变式,不是 governed-writer blocker。
|
||||
|
||||
本模块不修改 S6 frozen 9 文件,不触碰 legacy/global 写;只做 governed 写路径的
|
||||
现场装配。适配器接口全部复用 `governed_writer_automation.py` 的 Protocol。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
EpochStatus,
|
||||
GovernedWriterAdapters,
|
||||
GovernedWriterAutomation,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
GovernedWriteReadiness,
|
||||
JobBindingReceipt,
|
||||
OwnerAuthorizationPolicy,
|
||||
RecoveryAuthorization,
|
||||
TaskLeaseReceipt,
|
||||
TaskPrincipalReceipt,
|
||||
TaskState,
|
||||
VerifiedOwnerAuthorization,
|
||||
WriterLeaseReceipt,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 正式 OwnerAuthorizationAuthority 现场实现
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class FieldOwnerAuthorizationAuthority:
|
||||
"""现场 authority:登记 Owner 授权记录并按引用验证,签发核验回执。
|
||||
|
||||
生产语义:
|
||||
- `register(ref, policy)` 由 Owner/管理员在受信侧登记;登记即"一次授权"。
|
||||
- `verify(authorization_ref, request)` 解析引用 -> 核验 -> 返回
|
||||
`VerifiedOwnerAuthorization`(含非空 receipt)。
|
||||
- 任何未知引用、未登记、策略与请求越界,都抛 `AuthorizationDenied`
|
||||
(fail-closed:不猜测、不降级)。
|
||||
"""
|
||||
|
||||
_registry: dict[str, OwnerAuthorizationPolicy] = field(default_factory=dict)
|
||||
_receipt_counter: int = 0
|
||||
|
||||
def register(self, authorization_ref: str, policy: OwnerAuthorizationPolicy) -> None:
|
||||
if authorization_ref != policy.authorization_id:
|
||||
raise AuthorizationDenied(
|
||||
"authorization_ref must equal policy.authorization_id"
|
||||
)
|
||||
self._registry[authorization_ref] = policy
|
||||
|
||||
def contains(self, authorization_ref: str) -> bool:
|
||||
return authorization_ref in self._registry
|
||||
|
||||
def policy(self, authorization_ref: str) -> OwnerAuthorizationPolicy:
|
||||
if authorization_ref not in self._registry:
|
||||
raise AuthorizationDenied(f"unknown authorization reference: {authorization_ref}")
|
||||
return self._registry[authorization_ref]
|
||||
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
authorization_ref: str,
|
||||
request: GovernedWriterRequest,
|
||||
) -> VerifiedOwnerAuthorization:
|
||||
if authorization_ref not in self._registry:
|
||||
raise AuthorizationDenied(
|
||||
f"owner authorization reference not registered: {authorization_ref}"
|
||||
)
|
||||
policy = self._registry[authorization_ref]
|
||||
policy.validate(request, now=time.time())
|
||||
self._receipt_counter += 1
|
||||
receipt = f"receipt:owner-{authorization_ref}:{self._receipt_counter}"
|
||||
return VerifiedOwnerAuthorization(
|
||||
policy=policy,
|
||||
authorization_ref=authorization_ref,
|
||||
verification_receipt=receipt,
|
||||
verified_at=time.time(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 默认生产适配器参考实现(真实端点未就绪时先闭环,接口与协议一一对应)
|
||||
# ---------------------------------------------------------------------------
|
||||
class _DefaultTrustedSigner:
|
||||
"""TrustedSignerAdapter:JIT 重签同 scope 的 epoch(幂等由 ledger 守护)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = 0
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def reissue_epoch(
|
||||
self,
|
||||
*,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
request: GovernedWriterRequest,
|
||||
prior_epoch: EpochStatus | None,
|
||||
minimum_remaining_ttl: float,
|
||||
idempotency_key: str,
|
||||
) -> EpochStatus:
|
||||
self._counter += 1
|
||||
self.calls.append(
|
||||
{
|
||||
"key": idempotency_key,
|
||||
"scope": request.scope_fingerprint,
|
||||
"policy": owner_policy.policy_fingerprint,
|
||||
"prior": None if prior_epoch is None else prior_epoch.epoch_id,
|
||||
}
|
||||
)
|
||||
return EpochStatus(
|
||||
epoch_id=f"epoch-gw-{self._counter}",
|
||||
scope_fingerprint=request.scope_fingerprint,
|
||||
expires_at=time.time() + 7200.0,
|
||||
policy_digest=owner_policy.policy_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultExecutionPlane:
|
||||
"""ExecutionPlane:QUEUED 自动 claim + lease;heartbeat 记录。"""
|
||||
|
||||
def __init__(self, initial: TaskState) -> None:
|
||||
self.task = initial
|
||||
self._lease_counter = 0
|
||||
self.claims: list = []
|
||||
self.heartbeats: list = []
|
||||
|
||||
def read_task(self, task_id: str) -> TaskState:
|
||||
if task_id != self.task.task_id:
|
||||
raise AuthorizationDenied(f"unknown task: {task_id}")
|
||||
return self.task
|
||||
|
||||
def apply_recovery(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
authorization: RecoveryAuthorization,
|
||||
idempotency_key: str,
|
||||
) -> TaskState:
|
||||
raise AuthorizationDenied("terminal recovery requires a real recovery endpoint")
|
||||
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
actor: str,
|
||||
idempotency_key: str,
|
||||
):
|
||||
from app.task_plane.governed_writer_automation import ClaimReceipt
|
||||
|
||||
self.claims.append({"task": task.task_id, "actor": actor, "key": idempotency_key})
|
||||
return ClaimReceipt(
|
||||
claim_id=f"claim-gw-{len(self.claims)}",
|
||||
task_id=task.task_id,
|
||||
actor=actor,
|
||||
fencing=len(self.claims),
|
||||
)
|
||||
|
||||
def heartbeat(self, *, claim) -> None:
|
||||
self.heartbeats.append(claim.claim_id)
|
||||
|
||||
def issue_task_lease(
|
||||
self,
|
||||
*,
|
||||
claim,
|
||||
idempotency_key: str,
|
||||
) -> TaskLeaseReceipt:
|
||||
self._lease_counter += 1
|
||||
return TaskLeaseReceipt(
|
||||
task_lease_id=f"tasklease-gw-{self._lease_counter}",
|
||||
task_id=claim.task_id,
|
||||
token_ref=f"token-gw-{self._lease_counter}",
|
||||
expires_at=time.time() + 3600.0,
|
||||
fencing=claim.fencing,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultPrincipalIssuer:
|
||||
"""TaskPrincipalIssuer:按 lease 派发 scoped TaskPrincipal。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = 0
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
lease: TaskLeaseReceipt,
|
||||
requested_capabilities: frozenset[str],
|
||||
idempotency_key: str,
|
||||
) -> TaskPrincipalReceipt:
|
||||
self._counter += 1
|
||||
return TaskPrincipalReceipt(
|
||||
principal_id=f"principal-gw-{self._counter}",
|
||||
task_id=lease.task_id,
|
||||
capabilities=requested_capabilities,
|
||||
expires_at=time.time() + 1800.0,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultJobs:
|
||||
"""JobAuthority:reconcile 到与请求一致的 job 绑定。"""
|
||||
|
||||
def __init__(self, *, repository: str, target_branch: str, generation: int) -> None:
|
||||
self._repo = repository
|
||||
self._branch = target_branch
|
||||
self._gen = generation
|
||||
self._counter = 0
|
||||
|
||||
def read_or_reconcile(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
principal: TaskPrincipalReceipt,
|
||||
idempotency_key: str,
|
||||
) -> JobBindingReceipt:
|
||||
self._counter += 1
|
||||
return JobBindingReceipt(
|
||||
job_id=f"job-gw-{self._counter}",
|
||||
task_id=task_id,
|
||||
repository=self._repo,
|
||||
target_branch=self._branch,
|
||||
generation=self._gen,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultGit:
|
||||
"""GitAuthority:只读 branch 读回,决定 CAS 模式。"""
|
||||
|
||||
def __init__(self, *, exists: bool, head_oid: str | None = None) -> None:
|
||||
from app.task_plane.governed_writer_automation import BranchReadback
|
||||
|
||||
self._exists = exists
|
||||
self._head = head_oid
|
||||
self.calls: list = []
|
||||
|
||||
def read_branch(self, *, repository: str, target_branch: str):
|
||||
from app.task_plane.governed_writer_automation import BranchReadback
|
||||
|
||||
self.calls.append({"repo": repository, "branch": target_branch})
|
||||
return BranchReadback(exists=self._exists, head_oid=self._head)
|
||||
|
||||
|
||||
class _DefaultWriterLease:
|
||||
"""WriterLeaseAuthority:签发短时 writer lease(CAS 与 base 一致)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = 0
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
job: JobBindingReceipt,
|
||||
principal: TaskPrincipalReceipt,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
branch_mode: str,
|
||||
base_oid: str,
|
||||
expected_branch_oid: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WriterLeaseReceipt:
|
||||
self._counter += 1
|
||||
return WriterLeaseReceipt(
|
||||
writer_lease_id=f"writerlease-gw-{self._counter}",
|
||||
task_id=task_id,
|
||||
job_id=job.job_id,
|
||||
repository=repository,
|
||||
target_branch=target_branch,
|
||||
branch_mode=branch_mode,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
generation=generation,
|
||||
expires_at=time.time() + 900.0,
|
||||
fencing=self._counter,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 组合根:装配 7 适配器 -> 生产入口
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class FieldRuntimeStatus:
|
||||
"""现场运行时状态:由 W-RUNTIME 只读线提供,用于派生 governed 写就绪。"""
|
||||
|
||||
legacy_global_writes_enabled: bool = False
|
||||
task_principal_writes_enabled: bool = True
|
||||
task_principal_verifier_ready: bool = True
|
||||
governed_write_blockers: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldGovernedWriterFactory:
|
||||
"""组合根:把现场配置装配成 `acquire_verified()` 生产入口可直接执行的对象。
|
||||
|
||||
用法(现场接线):
|
||||
factory = FieldGovernedWriterFactory.build(
|
||||
authority=owner_authority,
|
||||
manifest_store=...,
|
||||
action_ledger=...,
|
||||
task_initial=TaskState(...),
|
||||
branch_exists=...,
|
||||
head_oid=...,
|
||||
repo=...,
|
||||
branch=...,
|
||||
generation=...,
|
||||
runtime_status=...,
|
||||
)
|
||||
session = factory.acquire(request, authorization_ref)
|
||||
"""
|
||||
|
||||
automation: GovernedWriterAutomation
|
||||
adapters: GovernedWriterAdapters
|
||||
readiness: GovernedWriteReadiness
|
||||
owner_authority: FieldOwnerAuthorizationAuthority
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
*,
|
||||
authority: FieldOwnerAuthorizationAuthority,
|
||||
manifest_store,
|
||||
action_ledger,
|
||||
task_initial: TaskState,
|
||||
branch_exists: bool,
|
||||
head_oid: str | None,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
generation: int,
|
||||
runtime_status: FieldRuntimeStatus | None = None,
|
||||
decision_engine=None,
|
||||
) -> "FieldGovernedWriterFactory":
|
||||
runtime_status = runtime_status or FieldRuntimeStatus()
|
||||
adapters = GovernedWriterAdapters(
|
||||
signer=_DefaultTrustedSigner(),
|
||||
execution=_DefaultExecutionPlane(task_initial),
|
||||
principal_issuer=_DefaultPrincipalIssuer(),
|
||||
jobs=_DefaultJobs(
|
||||
repository=repository, target_branch=target_branch, generation=generation
|
||||
),
|
||||
git=_DefaultGit(exists=branch_exists, head_oid=head_oid),
|
||||
writer_lease=_DefaultWriterLease(),
|
||||
owner_authority=authority,
|
||||
)
|
||||
automation = GovernedWriterAutomation(
|
||||
manifest_store=manifest_store,
|
||||
action_ledger=action_ledger,
|
||||
decision_engine=decision_engine,
|
||||
)
|
||||
readiness = GovernedWriteReadiness(
|
||||
legacy_global_writes_enabled=runtime_status.legacy_global_writes_enabled,
|
||||
task_principal_writes_enabled=runtime_status.task_principal_writes_enabled,
|
||||
task_principal_verifier_ready=runtime_status.task_principal_verifier_ready,
|
||||
governed_write_blockers=tuple(runtime_status.governed_write_blockers),
|
||||
)
|
||||
return cls(
|
||||
automation=automation,
|
||||
adapters=adapters,
|
||||
readiness=readiness,
|
||||
owner_authority=authority,
|
||||
)
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
request: GovernedWriterRequest,
|
||||
authorization_ref: str,
|
||||
*,
|
||||
epoch: EpochStatus | None = None,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
minimum_remaining_ttl: float = 1800.0,
|
||||
) -> GovernedWriterSession:
|
||||
"""生产入口:先验 Owner 授权,再自动完成受治理写链。"""
|
||||
return self.automation.acquire_verified(
|
||||
request=request,
|
||||
authorization_ref=authorization_ref,
|
||||
readiness=self.readiness,
|
||||
epoch=epoch,
|
||||
adapters=self.adapters,
|
||||
recovery_authorization=recovery_authorization,
|
||||
now=now,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
|
||||
"""Fail-closed candidate-specific governed admission preflight.
|
||||
|
||||
Ordinary/global writes are deliberately NOT a prerequisite. This evaluates the
|
||||
governed Bridge/TaskPrincipal path only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Sequence
|
||||
|
||||
from .governance_resolver import ResolutionResult, ResolutionState
|
||||
|
||||
|
||||
class AdmissionOperation(str, Enum):
|
||||
GOVERNED_COMMIT = "GOVERNED_COMMIT"
|
||||
RECOVER_EXISTING_CHILD = "RECOVER_EXISTING_CHILD"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceFacts:
|
||||
governed_writes_enabled: bool
|
||||
task_principal_writes_enabled: bool
|
||||
recovery_execution_enabled: bool
|
||||
recovery_shadow_mode: bool
|
||||
authenticated_principal_id: str | None
|
||||
target_branch: str | None
|
||||
branch_protection_known: bool
|
||||
expected_branch_oid: str | None
|
||||
generation: int | None
|
||||
task_lease_id: str | None
|
||||
task_principal_id: str | None
|
||||
writer_lease_id: str | None
|
||||
accepted_file_allowlist: tuple[str, ...] = ()
|
||||
acceptance_revision: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdmissionBlocker:
|
||||
code: str
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdmissionResult:
|
||||
ready: bool
|
||||
operation: AdmissionOperation
|
||||
blockers: tuple[AdmissionBlocker, ...]
|
||||
|
||||
|
||||
class AdmissionPreflight:
|
||||
def evaluate(
|
||||
self,
|
||||
*,
|
||||
resolution: ResolutionResult,
|
||||
facts: GovernanceFacts,
|
||||
operation: AdmissionOperation,
|
||||
) -> AdmissionResult:
|
||||
blockers: list[AdmissionBlocker] = []
|
||||
|
||||
if resolution.state is ResolutionState.NOT_FOUND:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_NOT_RESOLVED", "exact candidate task not found"
|
||||
))
|
||||
elif resolution.state is ResolutionState.AMBIGUOUS:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_RESOLUTION_AMBIGUOUS", "multiple exact candidate tasks"
|
||||
))
|
||||
elif resolution.state is not ResolutionState.RESOLVED:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_RESOLUTION_INVALID", resolution.detail or "resolver invalid"
|
||||
))
|
||||
|
||||
if not facts.governed_writes_enabled:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"GOVERNED_WRITES_DISABLED",
|
||||
"governed Bridge write path is disabled",
|
||||
))
|
||||
if not facts.task_principal_writes_enabled:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_PRINCIPAL_WRITES_DISABLED",
|
||||
"TaskPrincipal governed writes are disabled",
|
||||
))
|
||||
if not facts.authenticated_principal_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"CALLER_PRINCIPAL_UNRESOLVED",
|
||||
"authenticated governed caller principal is missing",
|
||||
))
|
||||
if not facts.target_branch:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TARGET_BRANCH_UNRESOLVED", "target branch is missing"
|
||||
))
|
||||
if not facts.branch_protection_known:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"BRANCH_PROTECTION_UNKNOWN",
|
||||
"branch protection has not been read through authority",
|
||||
))
|
||||
if not facts.expected_branch_oid:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"EXPECTED_BRANCH_OID_UNRESOLVED",
|
||||
"expected_branch_oid/CAS preimage is missing",
|
||||
))
|
||||
if facts.generation is None:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"GENERATION_UNRESOLVED", "governed generation is missing"
|
||||
))
|
||||
if not facts.task_lease_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_LEASE_MISSING", "TaskLease not established"
|
||||
))
|
||||
if not facts.task_principal_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_PRINCIPAL_MISSING", "TaskPrincipal not established"
|
||||
))
|
||||
if not facts.writer_lease_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"WRITER_LEASE_MISSING", "WriterLease not established"
|
||||
))
|
||||
if not facts.accepted_file_allowlist:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"FILE_ALLOWLIST_MISSING", "accepted file allowlist is empty"
|
||||
))
|
||||
if not facts.acceptance_revision:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"ACCEPTANCE_REVISION_MISSING",
|
||||
"candidate acceptance revision is missing",
|
||||
))
|
||||
|
||||
if operation is AdmissionOperation.RECOVER_EXISTING_CHILD:
|
||||
if not facts.recovery_execution_enabled:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"RECOVERY_EXECUTION_DISABLED",
|
||||
"recovery execution path is disabled",
|
||||
))
|
||||
if facts.recovery_shadow_mode:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"RECOVERY_SHADOW_MODE",
|
||||
"recovery is still shadow-only",
|
||||
))
|
||||
|
||||
# Ordinary/global write state is intentionally absent from the model.
|
||||
return AdmissionResult(
|
||||
ready=not blockers,
|
||||
operation=operation,
|
||||
blockers=tuple(blockers),
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
"""Canonical recovery observation checkpoint and unchanged-blocker budget.
|
||||
|
||||
The checkpoint is NOT an authority. It records the last reconciled observation
|
||||
so agents do not repeatedly prove the same unchanged blocker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
|
||||
def _canonical(value) -> str:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
)
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryCheckpoint:
|
||||
schema: str
|
||||
goal_id: str
|
||||
repository: str
|
||||
source_oid: str
|
||||
candidate_digest: str
|
||||
task_id: str | None
|
||||
mission_id: str | None
|
||||
root_task_id: str | None
|
||||
parent_task_id: str | None
|
||||
principal_id: str | None
|
||||
blocker_code: str | None
|
||||
blocker_fingerprint: str | None
|
||||
next_legal_action: str
|
||||
last_verified_at: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
unchanged_cycles: int
|
||||
mechanism_repair_required: bool
|
||||
recovery_idempotency_key: str | None = None
|
||||
recovery_receipt_ref: str | None = None
|
||||
recovery_context_digest: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
value = asdict(self)
|
||||
value["evidence_refs"] = list(self.evidence_refs)
|
||||
return value
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
return _digest(self.to_dict())
|
||||
|
||||
|
||||
class CheckpointStore(Protocol):
|
||||
def load(self) -> RecoveryCheckpoint | None: ...
|
||||
def save(self, checkpoint: RecoveryCheckpoint) -> None: ...
|
||||
|
||||
|
||||
class JsonCheckpointStore:
|
||||
"""Atomic local observation cache; never used as authorization evidence."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def load(self) -> RecoveryCheckpoint | None:
|
||||
if not self.path.exists():
|
||||
return None
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
raw["evidence_refs"] = tuple(raw.get("evidence_refs") or ())
|
||||
return RecoveryCheckpoint(**raw)
|
||||
|
||||
def save(self, checkpoint: RecoveryCheckpoint) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
temp.write_text(
|
||||
json.dumps(
|
||||
checkpoint.to_dict(),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temp, self.path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetryBudget:
|
||||
unchanged_threshold: int = 3
|
||||
escalation_eligible_codes: frozenset[str] = frozenset({
|
||||
"TASK_NOT_RESOLVED",
|
||||
"TASK_RESOLUTION_AMBIGUOUS",
|
||||
"CALLER_PRINCIPAL_UNRESOLVED",
|
||||
"CANONICAL_CHANNEL_MISSING",
|
||||
"RESOLVER_UNAVAILABLE",
|
||||
})
|
||||
|
||||
def __post_init__(self):
|
||||
if self.unchanged_threshold < 2 or self.unchanged_threshold > 100:
|
||||
raise ValueError("unchanged_threshold must be in [2,100]")
|
||||
|
||||
@staticmethod
|
||||
def blocker_fingerprint(code: str, details: Sequence[str]) -> str:
|
||||
return _digest({
|
||||
"code": code,
|
||||
"details": sorted(str(item) for item in details),
|
||||
})
|
||||
|
||||
def observe(
|
||||
self,
|
||||
*,
|
||||
previous: RecoveryCheckpoint | None,
|
||||
blocker_code: str,
|
||||
blocker_fingerprint: str,
|
||||
) -> tuple[int, bool]:
|
||||
same = (
|
||||
previous is not None
|
||||
and previous.blocker_code == blocker_code
|
||||
and previous.blocker_fingerprint == blocker_fingerprint
|
||||
)
|
||||
cycles = (previous.unchanged_cycles + 1) if same else 1
|
||||
repair = (
|
||||
blocker_code in self.escalation_eligible_codes
|
||||
and cycles >= self.unchanged_threshold
|
||||
)
|
||||
return cycles, repair
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
"""Live driver that converts external surface failures into one checkpoint.
|
||||
|
||||
The controller remains authority-neutral. This driver owns the site boundary:
|
||||
surface availability, binding-contract failures, and poll fallback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .governance_checkpoint import RecoveryCheckpoint, RetryBudget
|
||||
from .governance_recovery_controller import (
|
||||
GovernanceEvent,
|
||||
GovernanceRecoveryController,
|
||||
ReconcileResult,
|
||||
)
|
||||
from .site_adapters import LiveBindingViolation, LiveSurfaceUnavailable
|
||||
|
||||
|
||||
class GovernanceLiveDriver:
|
||||
def __init__(
|
||||
self,
|
||||
controller: GovernanceRecoveryController,
|
||||
*,
|
||||
retry_budget: RetryBudget | None = None,
|
||||
) -> None:
|
||||
self.controller = controller
|
||||
self.retry_budget = retry_budget or controller.retry_budget
|
||||
|
||||
def _candidate_digest(self) -> str:
|
||||
query = self.controller.query
|
||||
return (
|
||||
query.patch_digest
|
||||
or query.artifact_sha256
|
||||
or query.candidate_oid
|
||||
or ""
|
||||
)
|
||||
|
||||
def _persist_external_blocker(
|
||||
self,
|
||||
event: GovernanceEvent,
|
||||
*,
|
||||
code: str,
|
||||
detail: str,
|
||||
mechanism_repair_required: bool,
|
||||
) -> ReconcileResult:
|
||||
previous = self.controller.checkpoints.load()
|
||||
fingerprint = self.retry_budget.blocker_fingerprint(code, (detail,))
|
||||
if mechanism_repair_required:
|
||||
cycles = (
|
||||
previous.unchanged_cycles + 1
|
||||
if previous
|
||||
and previous.blocker_code == code
|
||||
and previous.blocker_fingerprint == fingerprint
|
||||
else 1
|
||||
)
|
||||
else:
|
||||
cycles, _ = self.retry_budget.observe(
|
||||
previous=previous,
|
||||
blocker_code=code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
)
|
||||
next_action = (
|
||||
"REPAIR_MECHANISM"
|
||||
if mechanism_repair_required
|
||||
else "WAIT_DEPENDENCY"
|
||||
)
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.controller.goal_id,
|
||||
repository=self.controller.query.repository,
|
||||
source_oid=self.controller.query.base_oid,
|
||||
candidate_digest=self._candidate_digest(),
|
||||
task_id=None,
|
||||
mission_id=None,
|
||||
root_task_id=None,
|
||||
parent_task_id=None,
|
||||
principal_id=None,
|
||||
blocker_code=code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
next_legal_action=next_action,
|
||||
last_verified_at=self.controller._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=cycles,
|
||||
mechanism_repair_required=mechanism_repair_required,
|
||||
)
|
||||
self.controller.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="BLOCKED",
|
||||
next_legal_action=next_action,
|
||||
task_id=None,
|
||||
blocker_code=code,
|
||||
mechanism_repair_required=mechanism_repair_required,
|
||||
)
|
||||
|
||||
def on_event(self, event: GovernanceEvent) -> ReconcileResult:
|
||||
try:
|
||||
return self.controller.on_event(event)
|
||||
except LiveSurfaceUnavailable as exc:
|
||||
return self._persist_external_blocker(
|
||||
event,
|
||||
code="LIVE_SURFACE_UNAVAILABLE",
|
||||
detail=str(exc),
|
||||
mechanism_repair_required=False,
|
||||
)
|
||||
except LiveBindingViolation as exc:
|
||||
return self._persist_external_blocker(
|
||||
event,
|
||||
code="LIVE_BINDING_VIOLATION",
|
||||
detail=str(exc),
|
||||
mechanism_repair_required=True,
|
||||
)
|
||||
|
||||
def poll_once(
|
||||
self, *, revision: str, occurred_at: str
|
||||
) -> ReconcileResult:
|
||||
return self.on_event(GovernanceEvent(
|
||||
kind="POLL_FALLBACK",
|
||||
revision=revision,
|
||||
occurred_at=occurred_at,
|
||||
))
|
||||
@@ -0,0 +1,474 @@
|
||||
|
||||
"""Event-driven governed recovery reconciler with polling fallback.
|
||||
|
||||
The controller cannot create a task and cannot bypass admission. It resumes
|
||||
only an exact, already-existing child through an injected governed executor.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping, Protocol
|
||||
|
||||
from .governance_admission import (
|
||||
AdmissionOperation,
|
||||
AdmissionPreflight,
|
||||
GovernanceFacts,
|
||||
)
|
||||
from .governance_checkpoint import (
|
||||
CheckpointStore,
|
||||
RecoveryCheckpoint,
|
||||
RetryBudget,
|
||||
)
|
||||
from .governance_resolver import ExactTaskResolver, ResolutionQuery, ResolutionState
|
||||
|
||||
|
||||
def _canonical(value) -> str:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
)
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceEvent:
|
||||
kind: str
|
||||
revision: str
|
||||
occurred_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryReceipt:
|
||||
receipt_ref: str
|
||||
task_id: str
|
||||
accepted: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconcileResult:
|
||||
status: str
|
||||
next_legal_action: str
|
||||
task_id: str | None
|
||||
blocker_code: str | None
|
||||
mechanism_repair_required: bool
|
||||
receipt_ref: str | None = None
|
||||
|
||||
|
||||
class GovernanceFactsProvider(Protocol):
|
||||
def snapshot(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
actor_identity: str,
|
||||
operation: AdmissionOperation,
|
||||
) -> GovernanceFacts: ...
|
||||
|
||||
|
||||
class ExistingChildRecoveryExecutor(Protocol):
|
||||
def recover_existing_child(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
actor_identity: str,
|
||||
idempotency_key: str,
|
||||
facts: GovernanceFacts,
|
||||
) -> RecoveryReceipt: ...
|
||||
|
||||
# Optional at runtime. If implemented, this is preferred after an
|
||||
# unknown-result write so the controller reconciles before re-executing.
|
||||
def reconcile_recovery_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
actor_identity: str,
|
||||
idempotency_key: str,
|
||||
) -> RecoveryReceipt | None: ...
|
||||
|
||||
|
||||
class GovernanceRecoveryController:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
goal_id: str,
|
||||
query: ResolutionQuery,
|
||||
actor_identity: str,
|
||||
resolver: ExactTaskResolver,
|
||||
facts_provider: GovernanceFactsProvider,
|
||||
executor: ExistingChildRecoveryExecutor,
|
||||
checkpoint_store: CheckpointStore,
|
||||
retry_budget: RetryBudget = RetryBudget(),
|
||||
) -> None:
|
||||
self.goal_id = goal_id
|
||||
self.query = query
|
||||
self.actor_identity = actor_identity
|
||||
self.resolver = resolver
|
||||
self.facts_provider = facts_provider
|
||||
self.executor = executor
|
||||
self.checkpoints = checkpoint_store
|
||||
self.retry_budget = retry_budget
|
||||
self.preflight = AdmissionPreflight()
|
||||
|
||||
def _timestamp(self, event: GovernanceEvent) -> str:
|
||||
return event.occurred_at or datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _save_blocked(
|
||||
self,
|
||||
*,
|
||||
event: GovernanceEvent,
|
||||
task_id: str | None,
|
||||
mission_id: str | None,
|
||||
root_task_id: str | None,
|
||||
parent_task_id: str | None,
|
||||
principal_id: str | None,
|
||||
blocker_code: str,
|
||||
blocker_details: tuple[str, ...],
|
||||
) -> ReconcileResult:
|
||||
previous = self.checkpoints.load()
|
||||
fingerprint = self.retry_budget.blocker_fingerprint(
|
||||
blocker_code, blocker_details
|
||||
)
|
||||
cycles, repair = self.retry_budget.observe(
|
||||
previous=previous,
|
||||
blocker_code=blocker_code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
)
|
||||
next_action = "REPAIR_MECHANISM" if repair else "WAIT_DEPENDENCY"
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=(
|
||||
self.query.patch_digest
|
||||
or self.query.artifact_sha256
|
||||
or self.query.candidate_oid
|
||||
or ""
|
||||
),
|
||||
task_id=task_id,
|
||||
mission_id=mission_id,
|
||||
root_task_id=root_task_id,
|
||||
parent_task_id=parent_task_id,
|
||||
principal_id=principal_id,
|
||||
blocker_code=blocker_code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
next_legal_action=next_action,
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=cycles,
|
||||
mechanism_repair_required=repair,
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="BLOCKED",
|
||||
next_legal_action=next_action,
|
||||
task_id=task_id,
|
||||
blocker_code=blocker_code,
|
||||
mechanism_repair_required=repair,
|
||||
)
|
||||
|
||||
def _candidate_digest(self) -> str:
|
||||
return (
|
||||
self.query.patch_digest
|
||||
or self.query.artifact_sha256
|
||||
or self.query.candidate_oid
|
||||
or ""
|
||||
)
|
||||
|
||||
def _recovery_context_digest(
|
||||
self, *, task_id: str, facts: GovernanceFacts
|
||||
) -> str:
|
||||
return _digest({
|
||||
"task_id": task_id,
|
||||
"repository": self.query.repository,
|
||||
"candidate": self._candidate_digest(),
|
||||
"principal": facts.authenticated_principal_id,
|
||||
"branch": facts.target_branch,
|
||||
"branch_oid": facts.expected_branch_oid,
|
||||
"generation": facts.generation,
|
||||
"task_lease_id": facts.task_lease_id,
|
||||
"task_principal_id": facts.task_principal_id,
|
||||
"writer_lease_id": facts.writer_lease_id,
|
||||
"acceptance_revision": facts.acceptance_revision,
|
||||
"file_allowlist": sorted(facts.accepted_file_allowlist),
|
||||
})
|
||||
|
||||
def _finish_recovered(
|
||||
self,
|
||||
*,
|
||||
event: GovernanceEvent,
|
||||
resolved,
|
||||
facts: GovernanceFacts,
|
||||
idempotency_key: str,
|
||||
context_digest: str,
|
||||
receipt: RecoveryReceipt,
|
||||
) -> ReconcileResult:
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=self._candidate_digest(),
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code=None,
|
||||
blocker_fingerprint=None,
|
||||
next_legal_action="ROOT_REVIEW",
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(
|
||||
f"event:{event.kind}:{event.revision}",
|
||||
receipt.receipt_ref,
|
||||
),
|
||||
unchanged_cycles=0,
|
||||
mechanism_repair_required=False,
|
||||
recovery_idempotency_key=idempotency_key,
|
||||
recovery_receipt_ref=receipt.receipt_ref,
|
||||
recovery_context_digest=context_digest,
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="RECOVERED",
|
||||
next_legal_action="ROOT_REVIEW",
|
||||
task_id=resolved.task_id,
|
||||
blocker_code=None,
|
||||
mechanism_repair_required=False,
|
||||
receipt_ref=receipt.receipt_ref,
|
||||
)
|
||||
|
||||
def reconcile(self, event: GovernanceEvent) -> ReconcileResult:
|
||||
resolution = self.resolver.resolve(
|
||||
self.query, actor_identity=self.actor_identity
|
||||
)
|
||||
if resolution.state is not ResolutionState.RESOLVED:
|
||||
code = {
|
||||
ResolutionState.NOT_FOUND: "TASK_NOT_RESOLVED",
|
||||
ResolutionState.AMBIGUOUS: "TASK_RESOLUTION_AMBIGUOUS",
|
||||
ResolutionState.INVALID_QUERY: "TASK_RESOLUTION_INVALID",
|
||||
}[resolution.state]
|
||||
details = (
|
||||
resolution.detail,
|
||||
*resolution.candidate_task_ids,
|
||||
)
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=None,
|
||||
mission_id=None,
|
||||
root_task_id=None,
|
||||
parent_task_id=None,
|
||||
principal_id=None,
|
||||
blocker_code=code,
|
||||
blocker_details=tuple(str(v) for v in details if v),
|
||||
)
|
||||
|
||||
resolved = resolution.resolved
|
||||
assert resolved is not None
|
||||
facts = self.facts_provider.snapshot(
|
||||
task_id=resolved.task_id,
|
||||
actor_identity=self.actor_identity,
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD,
|
||||
)
|
||||
admission = self.preflight.evaluate(
|
||||
resolution=resolution,
|
||||
facts=facts,
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD,
|
||||
)
|
||||
if not admission.ready:
|
||||
primary = admission.blockers[0]
|
||||
details = tuple(
|
||||
f"{blocker.code}:{blocker.detail}"
|
||||
for blocker in admission.blockers
|
||||
)
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code=primary.code,
|
||||
blocker_details=details,
|
||||
)
|
||||
|
||||
context_digest = self._recovery_context_digest(
|
||||
task_id=resolved.task_id, facts=facts
|
||||
)
|
||||
previous = self.checkpoints.load()
|
||||
candidate_digest = self._candidate_digest()
|
||||
unknown_previous = (
|
||||
previous is not None
|
||||
and previous.blocker_code == "RECOVERY_RESULT_UNKNOWN"
|
||||
and previous.task_id == resolved.task_id
|
||||
and previous.candidate_digest == candidate_digest
|
||||
and bool(previous.recovery_idempotency_key)
|
||||
)
|
||||
|
||||
if unknown_previous:
|
||||
idempotency_key = previous.recovery_idempotency_key
|
||||
reconcile = getattr(
|
||||
self.executor, "reconcile_recovery_result", None
|
||||
)
|
||||
if callable(reconcile):
|
||||
receipt = reconcile(
|
||||
task_id=resolved.task_id,
|
||||
actor_identity=self.actor_identity,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if receipt is not None:
|
||||
if receipt.accepted:
|
||||
return self._finish_recovered(
|
||||
event=event,
|
||||
resolved=resolved,
|
||||
facts=facts,
|
||||
idempotency_key=idempotency_key,
|
||||
context_digest=(
|
||||
previous.recovery_context_digest
|
||||
or context_digest
|
||||
),
|
||||
receipt=receipt,
|
||||
)
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_NOT_ACCEPTED",
|
||||
blocker_details=(receipt.receipt_ref,),
|
||||
)
|
||||
if (
|
||||
previous.recovery_context_digest is not None
|
||||
and previous.recovery_context_digest != context_digest
|
||||
):
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=candidate_digest,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
blocker_fingerprint=_digest({
|
||||
"task_id": resolved.task_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"context_changed": True,
|
||||
}),
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=1,
|
||||
mechanism_repair_required=False,
|
||||
recovery_idempotency_key=idempotency_key,
|
||||
recovery_context_digest=(
|
||||
previous.recovery_context_digest
|
||||
),
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="RESULT_UNKNOWN",
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
task_id=resolved.task_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
mechanism_repair_required=False,
|
||||
)
|
||||
else:
|
||||
idempotency_key = "recover-" + _digest({
|
||||
"task_id": resolved.task_id,
|
||||
"repository": self.query.repository,
|
||||
"candidate": candidate_digest,
|
||||
"branch_oid": facts.expected_branch_oid,
|
||||
"generation": facts.generation,
|
||||
"writer_lease_id": facts.writer_lease_id,
|
||||
})[:40]
|
||||
|
||||
try:
|
||||
receipt = self.executor.recover_existing_child(
|
||||
task_id=resolved.task_id,
|
||||
actor_identity=self.actor_identity,
|
||||
idempotency_key=idempotency_key,
|
||||
facts=facts,
|
||||
)
|
||||
except Exception:
|
||||
# Unknown-result write: retain the SAME idempotency key for the next
|
||||
# reconcile. Never assume the side effect did not happen.
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=(
|
||||
self.query.patch_digest
|
||||
or self.query.artifact_sha256
|
||||
or self.query.candidate_oid
|
||||
or ""
|
||||
),
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
blocker_fingerprint=_digest({
|
||||
"task_id": resolved.task_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
}),
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=1,
|
||||
mechanism_repair_required=False,
|
||||
recovery_idempotency_key=idempotency_key,
|
||||
recovery_context_digest=context_digest,
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="RESULT_UNKNOWN",
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
task_id=resolved.task_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
mechanism_repair_required=False,
|
||||
)
|
||||
|
||||
if not receipt.accepted:
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_NOT_ACCEPTED",
|
||||
blocker_details=(receipt.receipt_ref,),
|
||||
)
|
||||
|
||||
return self._finish_recovered(
|
||||
event=event,
|
||||
resolved=resolved,
|
||||
facts=facts,
|
||||
idempotency_key=idempotency_key,
|
||||
context_digest=context_digest,
|
||||
receipt=receipt,
|
||||
)
|
||||
|
||||
def on_event(self, event: GovernanceEvent) -> ReconcileResult:
|
||||
# Event-driven primary path: reconcile immediately in the same call.
|
||||
return self.reconcile(event)
|
||||
|
||||
def poll_once(self, *, revision: str, occurred_at: str) -> ReconcileResult:
|
||||
# Polling is only a fallback and uses exactly the same reconcile path.
|
||||
return self.reconcile(GovernanceEvent(
|
||||
kind="POLL_FALLBACK",
|
||||
revision=revision,
|
||||
occurred_at=occurred_at,
|
||||
))
|
||||
@@ -0,0 +1,229 @@
|
||||
|
||||
"""Read-only exact Task/lineage resolver.
|
||||
|
||||
This is an index/projection over existing TaskPlane authority, not a second task
|
||||
authority. It never creates, claims, mutates, or guesses a task.
|
||||
|
||||
The source supplied by the host MUST enforce the caller's existing TaskPlane
|
||||
read authorization before returning records.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class ResolutionState(str, Enum):
|
||||
RESOLVED = "RESOLVED"
|
||||
NOT_FOUND = "NOT_FOUND"
|
||||
AMBIGUOUS = "AMBIGUOUS"
|
||||
INVALID_QUERY = "INVALID_QUERY"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionQuery:
|
||||
repository: str
|
||||
base_oid: str
|
||||
task_id: str | None = None
|
||||
candidate_oid: str | None = None
|
||||
patch_digest: str | None = None
|
||||
artifact_sha256: str | None = None
|
||||
pr_id: str | None = None
|
||||
release_intent: str | None = None
|
||||
mission_id: str | None = None
|
||||
root_task_id: str | None = None
|
||||
parent_task_id: str | None = None
|
||||
owner_principal_id: str | None = None
|
||||
|
||||
def selectors(self) -> Mapping[str, str]:
|
||||
values = {
|
||||
"repository": self.repository,
|
||||
"base_oid": self.base_oid,
|
||||
"task_id": self.task_id,
|
||||
"candidate_oid": self.candidate_oid,
|
||||
"patch_digest": self.patch_digest,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"pr_id": self.pr_id,
|
||||
"release_intent": self.release_intent,
|
||||
"mission_id": self.mission_id,
|
||||
"root_task_id": self.root_task_id,
|
||||
"parent_task_id": self.parent_task_id,
|
||||
"owner_principal_id": self.owner_principal_id,
|
||||
}
|
||||
return {key: str(value) for key, value in values.items() if value}
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.repository.strip() or not self.base_oid.strip():
|
||||
raise ValueError("repository and base_oid required")
|
||||
strong = (
|
||||
self.task_id,
|
||||
self.candidate_oid,
|
||||
self.patch_digest,
|
||||
self.artifact_sha256,
|
||||
self.pr_id,
|
||||
self.release_intent,
|
||||
self.mission_id,
|
||||
self.root_task_id,
|
||||
)
|
||||
if not any(value for value in strong):
|
||||
raise ValueError(
|
||||
"at least one strong selector required "
|
||||
"(task/candidate/patch/artifact/pr/intent/mission/root)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedTask:
|
||||
task_id: str
|
||||
mission_id: str | None
|
||||
root_task_id: str | None
|
||||
parent_task_id: str | None
|
||||
owner_principal_id: str | None
|
||||
record: Mapping[str, Any]
|
||||
matched_selectors: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionResult:
|
||||
state: ResolutionState
|
||||
resolved: ResolvedTask | None = None
|
||||
candidate_task_ids: tuple[str, ...] = ()
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class TaskResolutionSource(Protocol):
|
||||
"""Caller-authorized read projection over the existing TaskPlane."""
|
||||
|
||||
def get_task(
|
||||
self, task_id: str, *, actor_identity: str
|
||||
) -> Mapping[str, Any] | None: ...
|
||||
|
||||
def list_tasks(
|
||||
self, *, actor_identity: str
|
||||
) -> Sequence[Mapping[str, Any]]: ...
|
||||
|
||||
|
||||
def _mapping(value: Any) -> Mapping[str, Any]:
|
||||
return value if isinstance(value, Mapping) else {}
|
||||
|
||||
|
||||
def _metadata(record: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
plan = _mapping(record.get("plan") or record.get("task_plan"))
|
||||
merged: dict[str, Any] = {}
|
||||
for source in (
|
||||
_mapping(record.get("metadata")),
|
||||
_mapping(plan.get("metadata")),
|
||||
):
|
||||
merged.update(source)
|
||||
# Some existing lineage metadata is nested.
|
||||
for key in ("aios_task_lineage", "lineage", "task_lineage"):
|
||||
nested = _mapping(merged.get(key))
|
||||
for nkey, value in nested.items():
|
||||
merged.setdefault(nkey, value)
|
||||
return merged
|
||||
|
||||
|
||||
def _first(record: Mapping[str, Any], metadata: Mapping[str, Any], *keys: str):
|
||||
for key in keys:
|
||||
if key in record and record.get(key) not in (None, ""):
|
||||
return record.get(key)
|
||||
if key in metadata and metadata.get(key) not in (None, ""):
|
||||
return metadata.get(key)
|
||||
return None
|
||||
|
||||
|
||||
def _projection(record: Mapping[str, Any]) -> Mapping[str, str]:
|
||||
metadata = _metadata(record)
|
||||
values = {
|
||||
"repository": _first(record, metadata, "repository", "repo"),
|
||||
"base_oid": _first(record, metadata, "base_oid", "source_oid"),
|
||||
"task_id": _first(record, metadata, "task_id"),
|
||||
"candidate_oid": _first(record, metadata, "candidate_oid", "head_oid"),
|
||||
"patch_digest": _first(record, metadata, "patch_digest", "candidate_digest"),
|
||||
"artifact_sha256": _first(record, metadata, "artifact_sha256", "artifact_digest"),
|
||||
"pr_id": _first(record, metadata, "pr_id", "pull_request_id"),
|
||||
"release_intent": _first(record, metadata, "release_intent", "release_intent_id"),
|
||||
"mission_id": _first(record, metadata, "mission_id"),
|
||||
"root_task_id": _first(record, metadata, "root_task_id"),
|
||||
"parent_task_id": _first(record, metadata, "parent_task_id"),
|
||||
"owner_principal_id": _first(record, metadata, "owner_principal_id"),
|
||||
}
|
||||
return {
|
||||
key: str(value)
|
||||
for key, value in values.items()
|
||||
if value not in (None, "")
|
||||
}
|
||||
|
||||
|
||||
def _matches(record: Mapping[str, Any], selectors: Mapping[str, str]) -> bool:
|
||||
projection = _projection(record)
|
||||
# Exact-only: a missing selector never counts as a partial match.
|
||||
return all(projection.get(key) == value for key, value in selectors.items())
|
||||
|
||||
|
||||
class ExactTaskResolver:
|
||||
def __init__(self, source: TaskResolutionSource) -> None:
|
||||
self.source = source
|
||||
|
||||
def resolve(
|
||||
self, query: ResolutionQuery, *, actor_identity: str
|
||||
) -> ResolutionResult:
|
||||
try:
|
||||
query.validate()
|
||||
except ValueError as exc:
|
||||
return ResolutionResult(
|
||||
ResolutionState.INVALID_QUERY, detail=str(exc)
|
||||
)
|
||||
|
||||
selectors = query.selectors()
|
||||
|
||||
if query.task_id:
|
||||
record = self.source.get_task(
|
||||
query.task_id, actor_identity=actor_identity
|
||||
)
|
||||
records = [record] if record is not None else []
|
||||
else:
|
||||
records = list(
|
||||
self.source.list_tasks(actor_identity=actor_identity)
|
||||
)
|
||||
|
||||
matches = [
|
||||
record
|
||||
for record in records
|
||||
if record is not None and _matches(record, selectors)
|
||||
]
|
||||
if not matches:
|
||||
return ResolutionResult(
|
||||
ResolutionState.NOT_FOUND,
|
||||
detail="no caller-authorized task exactly matches all selectors",
|
||||
)
|
||||
if len(matches) > 1:
|
||||
ids = sorted(
|
||||
str(_projection(record).get("task_id") or "")
|
||||
for record in matches
|
||||
)
|
||||
return ResolutionResult(
|
||||
ResolutionState.AMBIGUOUS,
|
||||
candidate_task_ids=tuple(ids),
|
||||
detail="multiple exact matches; resolver refuses to guess",
|
||||
)
|
||||
|
||||
record = matches[0]
|
||||
projected = _projection(record)
|
||||
task_id = projected.get("task_id")
|
||||
if not task_id:
|
||||
return ResolutionResult(
|
||||
ResolutionState.NOT_FOUND,
|
||||
detail="matching record lacks authoritative task_id",
|
||||
)
|
||||
resolved = ResolvedTask(
|
||||
task_id=task_id,
|
||||
mission_id=projected.get("mission_id"),
|
||||
root_task_id=projected.get("root_task_id"),
|
||||
parent_task_id=projected.get("parent_task_id"),
|
||||
owner_principal_id=projected.get("owner_principal_id"),
|
||||
record=record,
|
||||
matched_selectors=tuple(sorted(selectors)),
|
||||
)
|
||||
return ResolutionResult(ResolutionState.RESOLVED, resolved=resolved)
|
||||
@@ -0,0 +1,912 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .governance_control_kernel import (
|
||||
ActionKind,
|
||||
ActionLedger,
|
||||
AutonomousDecisionEngine,
|
||||
BlockerClassification,
|
||||
GovernanceControlError,
|
||||
ManifestStore,
|
||||
NextAction,
|
||||
ProducerKind,
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationDenied(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class GovernedWriterUnavailable(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class OwnerActionRequired(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class GovernedWriterState(str, Enum):
|
||||
PREFLIGHT_FROZEN = "PREFLIGHT_FROZEN"
|
||||
OWNER_AUTHORIZED = "OWNER_AUTHORIZED"
|
||||
EPOCH_VALID = "EPOCH_VALID"
|
||||
TASK_CLAIMED = "TASK_CLAIMED"
|
||||
TASK_LEASED = "TASK_LEASED"
|
||||
PRINCIPAL_ISSUED = "PRINCIPAL_ISSUED"
|
||||
JOB_BOUND = "JOB_BOUND"
|
||||
CAS_VERIFIED = "CAS_VERIFIED"
|
||||
WRITER_LEASED = "WRITER_LEASED"
|
||||
|
||||
|
||||
RISK_ORDER = {
|
||||
"low": 10,
|
||||
"medium": 20,
|
||||
"engineering": 25,
|
||||
"high": 30,
|
||||
"critical": 40,
|
||||
}
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return "sha256:" + hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OwnerAuthorizationPolicy:
|
||||
"""Long-lived business authorization, separate from short-lived credentials."""
|
||||
|
||||
authorization_id: str
|
||||
subject: str
|
||||
repository: str
|
||||
allowed_branch_prefixes: tuple[str, ...]
|
||||
allowed_capabilities: frozenset[str]
|
||||
risk_ceiling: str
|
||||
trust_domain: str
|
||||
revocation_epoch: int
|
||||
not_before: float
|
||||
expires_at: float | None = None
|
||||
revoked: bool = False
|
||||
allowed_operations: frozenset[str] = frozenset(
|
||||
{
|
||||
"claim",
|
||||
"heartbeat",
|
||||
"task_lease",
|
||||
"task_principal",
|
||||
"job_reconcile",
|
||||
"writer_lease",
|
||||
"governed_commit",
|
||||
"readback",
|
||||
"reconcile",
|
||||
"epoch_reissue",
|
||||
"successor_recovery",
|
||||
}
|
||||
)
|
||||
|
||||
def validate(self, request: "GovernedWriterRequest", *, now: float | None = None) -> None:
|
||||
now = time.time() if now is None else float(now)
|
||||
if self.revoked:
|
||||
raise AuthorizationDenied("owner authorization revoked")
|
||||
if now < self.not_before:
|
||||
raise AuthorizationDenied("owner authorization not yet valid")
|
||||
if self.expires_at is not None and now >= self.expires_at:
|
||||
raise AuthorizationDenied("owner authorization expired")
|
||||
if request.subject != self.subject:
|
||||
raise AuthorizationDenied("subject mismatch")
|
||||
if request.repository != self.repository:
|
||||
raise AuthorizationDenied("repository outside authorization")
|
||||
if not any(request.target_branch.startswith(p) for p in self.allowed_branch_prefixes):
|
||||
raise AuthorizationDenied("branch outside authorization")
|
||||
if not request.required_capabilities.issubset(self.allowed_capabilities):
|
||||
missing = sorted(request.required_capabilities - self.allowed_capabilities)
|
||||
raise AuthorizationDenied("capability outside authorization: " + ",".join(missing))
|
||||
ceiling = RISK_ORDER.get(self.risk_ceiling.lower())
|
||||
requested = RISK_ORDER.get(request.risk.lower())
|
||||
if ceiling is None or requested is None:
|
||||
raise AuthorizationDenied("unknown risk level")
|
||||
if requested > ceiling:
|
||||
raise AuthorizationDenied("risk exceeds authorization ceiling")
|
||||
|
||||
@property
|
||||
def policy_fingerprint(self) -> str:
|
||||
return _digest(
|
||||
{
|
||||
"authorization_id": self.authorization_id,
|
||||
"subject": self.subject,
|
||||
"repository": self.repository,
|
||||
"allowed_branch_prefixes": list(self.allowed_branch_prefixes),
|
||||
"allowed_capabilities": sorted(self.allowed_capabilities),
|
||||
"risk_ceiling": self.risk_ceiling,
|
||||
"trust_domain": self.trust_domain,
|
||||
"revocation_epoch": self.revocation_epoch,
|
||||
"not_before": self.not_before,
|
||||
"expires_at": self.expires_at,
|
||||
"allowed_operations": sorted(self.allowed_operations),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedWriterRequest:
|
||||
subject: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
generation: int
|
||||
base_oid: str
|
||||
required_capabilities: frozenset[str]
|
||||
file_allowlist: tuple[str, ...]
|
||||
plan_digest: str
|
||||
manifest_id: str
|
||||
runtime_fingerprint: str
|
||||
process_start_identity: str
|
||||
|
||||
@property
|
||||
def scope_fingerprint(self) -> str:
|
||||
return _digest(
|
||||
{
|
||||
"subject": self.subject,
|
||||
"task_id": self.task_id,
|
||||
"repository": self.repository,
|
||||
"target_branch": self.target_branch,
|
||||
"risk": self.risk,
|
||||
"generation": self.generation,
|
||||
"base_oid": self.base_oid,
|
||||
"required_capabilities": sorted(self.required_capabilities),
|
||||
"file_allowlist": list(self.file_allowlist),
|
||||
"plan_digest": self.plan_digest,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedWriteReadiness:
|
||||
"""Unambiguous write-path semantics.
|
||||
|
||||
`legacy_global_writes_enabled=False` is a required safety invariant, not a
|
||||
blocker. `governed_write_path_open` is derived from the scoped writer
|
||||
prerequisites below and is intentionally independent from any ambiguous
|
||||
legacy `writes_enabled` field.
|
||||
"""
|
||||
|
||||
legacy_global_writes_enabled: bool
|
||||
task_principal_writes_enabled: bool
|
||||
task_principal_verifier_ready: bool
|
||||
governed_write_blockers: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def blockers(self) -> tuple[str, ...]:
|
||||
blockers = list(self.governed_write_blockers)
|
||||
if self.legacy_global_writes_enabled:
|
||||
blockers.append("LEGACY_GLOBAL_WRITES_MUST_REMAIN_DISABLED")
|
||||
if not self.task_principal_writes_enabled:
|
||||
blockers.append("TASK_PRINCIPAL_WRITES_DISABLED")
|
||||
if not self.task_principal_verifier_ready:
|
||||
blockers.append("TASK_PRINCIPAL_VERIFIER_NOT_READY")
|
||||
return tuple(dict.fromkeys(blockers))
|
||||
|
||||
@property
|
||||
def governed_write_path_open(self) -> bool:
|
||||
return not self.blockers
|
||||
|
||||
@classmethod
|
||||
def from_runtime_status(cls, status: Mapping[str, Any]) -> "GovernedWriteReadiness":
|
||||
# Deliberately ignore ambiguous `writes_enabled` /
|
||||
# `AIOS_GITOPS_WRITES_ENABLED`; those describe legacy/global surfaces
|
||||
# in older control planes and must never be used as the sole governed
|
||||
# writer readiness signal.
|
||||
readiness = status.get("task_principal_readiness") or {}
|
||||
blockers = status.get("governed_write_blockers")
|
||||
if blockers is None:
|
||||
blockers = status.get("blockers") or ()
|
||||
return cls(
|
||||
legacy_global_writes_enabled=bool(
|
||||
status.get(
|
||||
"legacy_global_writes_enabled",
|
||||
status.get("legacy_writes_enabled", False),
|
||||
)
|
||||
),
|
||||
task_principal_writes_enabled=bool(
|
||||
status.get("task_principal_writes_enabled", False)
|
||||
),
|
||||
task_principal_verifier_ready=bool(
|
||||
readiness.get("ready", status.get("task_principal_verifier_ready", False))
|
||||
),
|
||||
governed_write_blockers=tuple(str(x) for x in blockers),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EpochStatus:
|
||||
epoch_id: str
|
||||
scope_fingerprint: str
|
||||
expires_at: float
|
||||
policy_digest: str
|
||||
|
||||
def is_valid(self, *, now: float, minimum_remaining_ttl: float) -> bool:
|
||||
return (self.expires_at - now) >= minimum_remaining_ttl
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskState:
|
||||
task_id: str
|
||||
status: str
|
||||
revision: int
|
||||
claimed_by: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimReceipt:
|
||||
claim_id: str
|
||||
task_id: str
|
||||
actor: str
|
||||
fencing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskLeaseReceipt:
|
||||
task_lease_id: str
|
||||
task_id: str
|
||||
token_ref: str
|
||||
expires_at: float
|
||||
fencing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskPrincipalReceipt:
|
||||
principal_id: str
|
||||
task_id: str
|
||||
capabilities: frozenset[str]
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobBindingReceipt:
|
||||
job_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchReadback:
|
||||
exists: bool
|
||||
head_oid: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WriterLeaseReceipt:
|
||||
writer_lease_id: str
|
||||
task_id: str
|
||||
job_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
generation: int
|
||||
expires_at: float
|
||||
fencing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedWriterSession:
|
||||
state: GovernedWriterState
|
||||
authorization_id: str
|
||||
policy_fingerprint: str
|
||||
scope_fingerprint: str
|
||||
epoch_id: str
|
||||
task_id: str
|
||||
claim_id: str
|
||||
task_lease_id: str
|
||||
principal_id: str
|
||||
job_id: str
|
||||
writer_lease_id: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
expires_at: float
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = asdict(self)
|
||||
result["state"] = self.state.value
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryAuthorization:
|
||||
"""Pre-approved signed recovery/successor authorization reference."""
|
||||
|
||||
authorization_ref: str
|
||||
task_id: str
|
||||
action: str # TERMINAL_RECOVERY | SUCCESSOR
|
||||
expires_at: float
|
||||
|
||||
|
||||
class TrustedSignerAdapter(Protocol):
|
||||
def reissue_epoch(
|
||||
self,
|
||||
*,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
request: GovernedWriterRequest,
|
||||
prior_epoch: EpochStatus | None,
|
||||
minimum_remaining_ttl: float,
|
||||
idempotency_key: str,
|
||||
) -> EpochStatus: ...
|
||||
|
||||
|
||||
class ExecutionPlane(Protocol):
|
||||
def read_task(self, task_id: str) -> TaskState: ...
|
||||
def apply_recovery(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
authorization: RecoveryAuthorization,
|
||||
idempotency_key: str,
|
||||
) -> TaskState: ...
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
actor: str,
|
||||
idempotency_key: str,
|
||||
) -> ClaimReceipt: ...
|
||||
def heartbeat(self, *, claim: ClaimReceipt) -> None: ...
|
||||
def issue_task_lease(
|
||||
self,
|
||||
*,
|
||||
claim: ClaimReceipt,
|
||||
idempotency_key: str,
|
||||
) -> TaskLeaseReceipt: ...
|
||||
|
||||
|
||||
class TaskPrincipalIssuer(Protocol):
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
lease: TaskLeaseReceipt,
|
||||
requested_capabilities: frozenset[str],
|
||||
idempotency_key: str,
|
||||
) -> TaskPrincipalReceipt: ...
|
||||
|
||||
|
||||
class JobAuthority(Protocol):
|
||||
def read_or_reconcile(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
principal: TaskPrincipalReceipt,
|
||||
idempotency_key: str,
|
||||
) -> JobBindingReceipt: ...
|
||||
|
||||
|
||||
class GitAuthority(Protocol):
|
||||
def read_branch(self, *, repository: str, target_branch: str) -> BranchReadback: ...
|
||||
|
||||
|
||||
class WriterLeaseAuthority(Protocol):
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
job: JobBindingReceipt,
|
||||
principal: TaskPrincipalReceipt,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
branch_mode: str,
|
||||
base_oid: str,
|
||||
expected_branch_oid: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WriterLeaseReceipt: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedOwnerAuthorization:
|
||||
policy: OwnerAuthorizationPolicy
|
||||
authorization_ref: str
|
||||
verification_receipt: str
|
||||
verified_at: float
|
||||
|
||||
|
||||
class OwnerAuthorizationAuthority(Protocol):
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
authorization_ref: str,
|
||||
request: GovernedWriterRequest,
|
||||
) -> VerifiedOwnerAuthorization: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class GovernedWriterAdapters:
|
||||
signer: TrustedSignerAdapter
|
||||
execution: ExecutionPlane
|
||||
principal_issuer: TaskPrincipalIssuer
|
||||
jobs: JobAuthority
|
||||
git: GitAuthority
|
||||
writer_lease: WriterLeaseAuthority
|
||||
owner_authority: OwnerAuthorizationAuthority | None = None
|
||||
|
||||
|
||||
class GovernedWriterAutomation:
|
||||
"""Owner-policy-driven acquisition of a scoped governed writer capability.
|
||||
|
||||
This is the mechanism that removes repeated manual intervention. Long-lived
|
||||
OwnerAuthorizationPolicy establishes allowed scope. Every concrete effect is
|
||||
still protected by fresh runtime manifest, short-lived TaskLease,
|
||||
TaskPrincipal, WriterLease, CAS and idempotency.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
manifest_store: ManifestStore,
|
||||
action_ledger: ActionLedger,
|
||||
decision_engine: AutonomousDecisionEngine | None = None,
|
||||
):
|
||||
self.manifest_store = manifest_store
|
||||
self.action_ledger = action_ledger
|
||||
self.decision_engine = decision_engine or AutonomousDecisionEngine()
|
||||
|
||||
def _begin_action(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
suffix: str,
|
||||
kind: ActionKind,
|
||||
target: str,
|
||||
precondition: Mapping[str, Any],
|
||||
):
|
||||
return self.action_ledger.begin(
|
||||
idempotency_key=f"writer:{request.scope_fingerprint}:{suffix}",
|
||||
manifest_id=request.manifest_id,
|
||||
phase="GOVERNED_WRITER",
|
||||
kind=kind,
|
||||
target=target,
|
||||
precondition_hash=_digest(precondition),
|
||||
)
|
||||
|
||||
def _ensure_epoch(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
now: float,
|
||||
minimum_remaining_ttl: float,
|
||||
) -> EpochStatus:
|
||||
if (
|
||||
epoch is not None
|
||||
and epoch.scope_fingerprint == request.scope_fingerprint
|
||||
and epoch.is_valid(now=now, minimum_remaining_ttl=minimum_remaining_ttl)
|
||||
):
|
||||
return epoch
|
||||
|
||||
# Changed scope may not silently inherit an old Owner grant.
|
||||
if epoch is not None and epoch.scope_fingerprint != request.scope_fingerprint:
|
||||
raise OwnerActionRequired("epoch scope changed; new Owner authorization required")
|
||||
|
||||
ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix="epoch",
|
||||
kind=ActionKind.EPOCH_ISSUANCE,
|
||||
target="trusted-grant-signer-adapter",
|
||||
precondition={
|
||||
"owner_policy": owner_policy.policy_fingerprint,
|
||||
"scope": request.scope_fingerprint,
|
||||
"prior_epoch": None if epoch is None else epoch.epoch_id,
|
||||
},
|
||||
)
|
||||
if ticket.reused and ticket.requires_reconcile:
|
||||
raise GovernedWriterUnavailable("epoch issuance effect unknown; reconcile required")
|
||||
if ticket.reused and ticket.state.value == "SUCCEEDED":
|
||||
# The ledger proves the action succeeded, but the concrete receipt
|
||||
# must be obtained from the signer/readback, not fabricated locally.
|
||||
raise GovernedWriterUnavailable("epoch already issued; signer readback required")
|
||||
|
||||
try:
|
||||
fresh = adapters.signer.reissue_epoch(
|
||||
owner_policy=owner_policy,
|
||||
request=request,
|
||||
prior_epoch=epoch,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
idempotency_key=ticket.idempotency_key,
|
||||
)
|
||||
if fresh.scope_fingerprint != request.scope_fingerprint:
|
||||
raise GovernedWriterUnavailable("signer returned mismatched scope")
|
||||
if not fresh.is_valid(now=now, minimum_remaining_ttl=minimum_remaining_ttl):
|
||||
raise GovernedWriterUnavailable("signer returned insufficient TTL")
|
||||
self.action_ledger.mark_succeeded(
|
||||
ticket.action_id,
|
||||
result={
|
||||
"epoch_id": fresh.epoch_id,
|
||||
"expires_at": fresh.expires_at,
|
||||
"scope_fingerprint": fresh.scope_fingerprint,
|
||||
},
|
||||
)
|
||||
return fresh
|
||||
except OwnerActionRequired:
|
||||
self.action_ledger.mark_failed(ticket.action_id, result={"owner_action": True})
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
def _ensure_claimable_task(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
recovery_authorization: RecoveryAuthorization | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
now: float,
|
||||
) -> TaskState:
|
||||
task = adapters.execution.read_task(request.task_id)
|
||||
status = task.status.upper()
|
||||
if status == "QUEUED":
|
||||
return task
|
||||
if status in {"CLAIMED", "RUNNING"}:
|
||||
return task
|
||||
if status == "EXPIRED":
|
||||
if recovery_authorization is None:
|
||||
raise OwnerActionRequired(
|
||||
"terminal task expired; owner-signed recovery/successor authorization required"
|
||||
)
|
||||
if recovery_authorization.task_id != task.task_id:
|
||||
raise AuthorizationDenied("recovery authorization task mismatch")
|
||||
if recovery_authorization.expires_at <= now:
|
||||
raise AuthorizationDenied("recovery authorization expired")
|
||||
if recovery_authorization.action not in {"TERMINAL_RECOVERY", "SUCCESSOR"}:
|
||||
raise AuthorizationDenied("unsupported recovery action")
|
||||
ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix="task-recovery",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="agent-task-terminal-recovery",
|
||||
precondition={
|
||||
"task_id": task.task_id,
|
||||
"revision": task.revision,
|
||||
"status": task.status,
|
||||
"authorization_ref": recovery_authorization.authorization_ref,
|
||||
"action": recovery_authorization.action,
|
||||
},
|
||||
)
|
||||
if ticket.reused and ticket.requires_reconcile:
|
||||
raise GovernedWriterUnavailable("task recovery effect unknown; reconcile required")
|
||||
try:
|
||||
recovered = adapters.execution.apply_recovery(
|
||||
task=task,
|
||||
authorization=recovery_authorization,
|
||||
idempotency_key=ticket.idempotency_key,
|
||||
)
|
||||
if recovered.status.upper() != "QUEUED":
|
||||
raise GovernedWriterUnavailable(
|
||||
"recovery did not return claimable QUEUED task"
|
||||
)
|
||||
self.action_ledger.mark_succeeded(
|
||||
ticket.action_id,
|
||||
result={
|
||||
"task_id": recovered.task_id,
|
||||
"status": recovered.status,
|
||||
"revision": recovered.revision,
|
||||
},
|
||||
)
|
||||
return recovered
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
raise GovernedWriterUnavailable(f"task status not claimable: {task.status}")
|
||||
|
||||
def acquire_verified(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
authorization_ref: str,
|
||||
readiness: GovernedWriteReadiness,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
minimum_remaining_ttl: float = 1800.0,
|
||||
) -> GovernedWriterSession:
|
||||
"""Production entry: verify Owner authorization before any effect.
|
||||
|
||||
The field composition root MUST use this method. `acquire()` is an
|
||||
internal/test hook for already-verified policy objects.
|
||||
"""
|
||||
if adapters.owner_authority is None:
|
||||
raise AuthorizationDenied("OwnerAuthorizationAuthority is not configured")
|
||||
verified = adapters.owner_authority.verify(
|
||||
authorization_ref=authorization_ref,
|
||||
request=request,
|
||||
)
|
||||
if verified.authorization_ref != authorization_ref:
|
||||
raise AuthorizationDenied("owner authorization reference mismatch")
|
||||
if not verified.verification_receipt:
|
||||
raise AuthorizationDenied("owner authorization verification receipt missing")
|
||||
return self.acquire(
|
||||
request=request,
|
||||
owner_policy=verified.policy,
|
||||
readiness=readiness,
|
||||
epoch=epoch,
|
||||
adapters=adapters,
|
||||
recovery_authorization=recovery_authorization,
|
||||
now=now,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
)
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
readiness: GovernedWriteReadiness,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
minimum_remaining_ttl: float = 1800.0,
|
||||
) -> GovernedWriterSession:
|
||||
now = time.time() if now is None else float(now)
|
||||
|
||||
# Gate 1: authoritative frozen runtime.
|
||||
self.manifest_store.assert_current(
|
||||
manifest_id=request.manifest_id,
|
||||
runtime_fingerprint=request.runtime_fingerprint,
|
||||
process_start_identity=request.process_start_identity,
|
||||
)
|
||||
|
||||
# Gate 2: long-lived Owner policy.
|
||||
owner_policy.validate(request, now=now)
|
||||
|
||||
# Gate 3: scoped writer readiness, deliberately independent from legacy
|
||||
# global-write switches.
|
||||
if not readiness.governed_write_path_open:
|
||||
raise GovernedWriterUnavailable(
|
||||
"governed writer not ready: " + ",".join(readiness.blockers)
|
||||
)
|
||||
|
||||
# Gate 4: JIT delegation epoch. If unchanged scope is authorized, the
|
||||
# Agent may use the trusted signer adapter autonomously.
|
||||
epoch = self._ensure_epoch(
|
||||
request=request,
|
||||
owner_policy=owner_policy,
|
||||
epoch=epoch,
|
||||
adapters=adapters,
|
||||
now=now,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
)
|
||||
|
||||
# Gate 5: task must be claimable, with Owner recovery only when the
|
||||
# lifecycle contract explicitly requires a signed terminal recovery.
|
||||
task = self._ensure_claimable_task(
|
||||
request=request,
|
||||
recovery_authorization=recovery_authorization,
|
||||
adapters=adapters,
|
||||
now=now,
|
||||
)
|
||||
|
||||
# Claim is idempotent per scope/task revision.
|
||||
claim_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"claim-r{task.revision}",
|
||||
kind=ActionKind.CLAIM,
|
||||
target=f"task:{task.task_id}",
|
||||
precondition={"task_id": task.task_id, "revision": task.revision, "status": task.status},
|
||||
)
|
||||
if claim_ticket.reused and claim_ticket.requires_reconcile:
|
||||
raise GovernedWriterUnavailable("claim effect unknown; reconcile required")
|
||||
if task.status.upper() in {"CLAIMED", "RUNNING"}:
|
||||
raise GovernedWriterUnavailable(
|
||||
"existing claim must be reconciled into a concrete claim receipt"
|
||||
)
|
||||
try:
|
||||
claim = adapters.execution.claim(
|
||||
task=task,
|
||||
actor=request.subject,
|
||||
idempotency_key=claim_ticket.idempotency_key,
|
||||
)
|
||||
adapters.execution.heartbeat(claim=claim)
|
||||
self.action_ledger.mark_succeeded(
|
||||
claim_ticket.action_id,
|
||||
result={"claim_id": claim.claim_id, "fencing": claim.fencing},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
claim_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
lease_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"tasklease-{claim.claim_id}",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="task-lease",
|
||||
precondition={"claim_id": claim.claim_id, "fencing": claim.fencing},
|
||||
)
|
||||
try:
|
||||
task_lease = adapters.execution.issue_task_lease(
|
||||
claim=claim,
|
||||
idempotency_key=lease_ticket.idempotency_key,
|
||||
)
|
||||
self.action_ledger.mark_succeeded(
|
||||
lease_ticket.action_id,
|
||||
result={
|
||||
"task_lease_id": task_lease.task_lease_id,
|
||||
"expires_at": task_lease.expires_at,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
lease_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
principal_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"principal-{task_lease.task_lease_id}",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="task-principal-issuer",
|
||||
precondition={
|
||||
"task_lease_id": task_lease.task_lease_id,
|
||||
"capabilities": sorted(request.required_capabilities),
|
||||
},
|
||||
)
|
||||
try:
|
||||
principal = adapters.principal_issuer.issue(
|
||||
lease=task_lease,
|
||||
requested_capabilities=request.required_capabilities,
|
||||
idempotency_key=principal_ticket.idempotency_key,
|
||||
)
|
||||
if not request.required_capabilities.issubset(principal.capabilities):
|
||||
raise GovernedWriterUnavailable(
|
||||
"issued TaskPrincipal lacks required capabilities"
|
||||
)
|
||||
self.action_ledger.mark_succeeded(
|
||||
principal_ticket.action_id,
|
||||
result={"principal_id": principal.principal_id, "expires_at": principal.expires_at},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
principal_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
job_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix="job",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="gitops-job",
|
||||
precondition={
|
||||
"task_id": task.task_id,
|
||||
"principal_id": principal.principal_id,
|
||||
"plan_digest": request.plan_digest,
|
||||
},
|
||||
)
|
||||
try:
|
||||
job = adapters.jobs.read_or_reconcile(
|
||||
task_id=task.task_id,
|
||||
principal=principal,
|
||||
idempotency_key=job_ticket.idempotency_key,
|
||||
)
|
||||
if (
|
||||
job.repository != request.repository
|
||||
or job.target_branch != request.target_branch
|
||||
or job.generation != request.generation
|
||||
):
|
||||
raise GovernedWriterUnavailable("job binding mismatch")
|
||||
self.action_ledger.mark_succeeded(
|
||||
job_ticket.action_id,
|
||||
result={"job_id": job.job_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
job_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
branch = adapters.git.read_branch(
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
)
|
||||
if branch.exists:
|
||||
if not branch.head_oid:
|
||||
raise GovernedWriterUnavailable("branch exists without readable HEAD")
|
||||
branch_mode = "CONTINUE_EXISTING"
|
||||
expected_branch_oid = branch.head_oid
|
||||
else:
|
||||
branch_mode = "CREATE_NEW"
|
||||
expected_branch_oid = request.base_oid
|
||||
|
||||
writer_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"writer-{job.job_id}-{expected_branch_oid}",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="writer-lease",
|
||||
precondition={
|
||||
"job_id": job.job_id,
|
||||
"principal_id": principal.principal_id,
|
||||
"branch_mode": branch_mode,
|
||||
"base_oid": request.base_oid,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"generation": request.generation,
|
||||
},
|
||||
)
|
||||
try:
|
||||
writer = adapters.writer_lease.acquire(
|
||||
task_id=task.task_id,
|
||||
job=job,
|
||||
principal=principal,
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
branch_mode=branch_mode,
|
||||
base_oid=request.base_oid,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
generation=request.generation,
|
||||
idempotency_key=writer_ticket.idempotency_key,
|
||||
)
|
||||
if writer.expected_branch_oid != expected_branch_oid:
|
||||
raise GovernedWriterUnavailable("WriterLease CAS mismatch")
|
||||
self.action_ledger.mark_succeeded(
|
||||
writer_ticket.action_id,
|
||||
result={
|
||||
"writer_lease_id": writer.writer_lease_id,
|
||||
"expires_at": writer.expires_at,
|
||||
"expected_branch_oid": writer.expected_branch_oid,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
writer_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
expires_at = min(
|
||||
epoch.expires_at,
|
||||
task_lease.expires_at,
|
||||
principal.expires_at,
|
||||
writer.expires_at,
|
||||
)
|
||||
return GovernedWriterSession(
|
||||
state=GovernedWriterState.WRITER_LEASED,
|
||||
authorization_id=owner_policy.authorization_id,
|
||||
policy_fingerprint=owner_policy.policy_fingerprint,
|
||||
scope_fingerprint=request.scope_fingerprint,
|
||||
epoch_id=epoch.epoch_id,
|
||||
task_id=task.task_id,
|
||||
claim_id=claim.claim_id,
|
||||
task_lease_id=task_lease.task_lease_id,
|
||||
principal_id=principal.principal_id,
|
||||
job_id=job.job_id,
|
||||
writer_lease_id=writer.writer_lease_id,
|
||||
branch_mode=branch_mode,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
@@ -0,0 +1,789 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,990 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
@@ -74,7 +75,13 @@ def _now_ts() -> float:
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
|
||||
|
||||
def compute_sha256_hex(value: Any) -> str:
|
||||
@@ -532,10 +539,16 @@ 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(
|
||||
@@ -551,7 +564,17 @@ 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()}",
|
||||
@@ -560,11 +583,17 @@ class EvidenceEnvelope:
|
||||
kind=validate_type(kind, "evidence kind"),
|
||||
worker_identity=str(worker_identity),
|
||||
payload=redact_value(payload),
|
||||
created_at=float(created_at or _now_ts()),
|
||||
created_at=issued_at,
|
||||
expires_at=valid_until,
|
||||
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())
|
||||
@@ -580,8 +609,14 @@ 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(
|
||||
@@ -591,18 +626,40 @@ 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 (
|
||||
self.schema_version != 1
|
||||
not finite_timestamps
|
||||
or 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.evidence_digest != compute_sha256_hex(self.digest_payload())
|
||||
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
|
||||
):
|
||||
return False
|
||||
if self.signature or self.attestation_ref:
|
||||
return True if attestation_verifier is None else bool(attestation_verifier(self))
|
||||
return True
|
||||
# An unsigned envelope has no proof of origin and must never verify.
|
||||
if not (self.signature or self.attestation_ref):
|
||||
return False
|
||||
# Every proof form requires an injected verifier. A reference or
|
||||
# signature must never become trusted merely because it is present.
|
||||
if attestation_verifier is None:
|
||||
return False
|
||||
try:
|
||||
return bool(attestation_verifier(self))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -611,6 +668,41 @@ 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"
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""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"]
|
||||
@@ -0,0 +1,547 @@
|
||||
"""Read-only C6 operation observations and fail-closed reductions.
|
||||
|
||||
This module deliberately owns no persistence, signer, lease, or mutation port.
|
||||
Its digests are local to the C6 operation-binding schema and do not replace any
|
||||
existing domain encoder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
|
||||
FactValue = Literal["TRUE", "FALSE", "UNKNOWN"]
|
||||
PreflightState = Literal[
|
||||
"EXECUTION_ENTRYPOINT_ABSENT",
|
||||
"CAPABILITY_UNAVAILABLE",
|
||||
"AUTHORIZATION_REQUIRED",
|
||||
"PRECONDITION_FAILED",
|
||||
"UNKNOWN",
|
||||
"READY",
|
||||
]
|
||||
EffectOutcome = Literal["COMMITTED", "NOT_COMMITTED", "UNKNOWN"]
|
||||
VerificationOutcome = Literal["PASS", "FAIL", "UNKNOWN", "UNAVAILABLE"]
|
||||
AcceptanceOutcome = Literal["ACCEPTED", "REJECTED", "UNKNOWN"]
|
||||
ClosureOutcome = Literal["SUCCEEDED", "PARTIAL", "OUTCOME_UNKNOWN"]
|
||||
EffectReconciliation = Literal[
|
||||
"REBUILD_RECEIPT_REFERENCE",
|
||||
"REVALIDATE_BEFORE_RETRY",
|
||||
"OUTCOME_UNKNOWN",
|
||||
]
|
||||
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_EFFECT_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,159}$")
|
||||
_CAPABILITY_NAMESPACES = frozenset({"agent-task", "task-plane-wildcard"})
|
||||
|
||||
|
||||
def _string(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip():
|
||||
raise ValueError(f"{name} must be a non-empty canonical string")
|
||||
if "\x00" in value:
|
||||
raise ValueError(f"{name} contains NUL")
|
||||
return value
|
||||
|
||||
|
||||
def _non_negative_int(value: object, name: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"{name} must be a non-negative integer")
|
||||
return value
|
||||
|
||||
|
||||
def _aware_utc(value: object, name: str) -> datetime:
|
||||
if not isinstance(value, datetime) or value.tzinfo is None:
|
||||
raise ValueError(f"{name} must be timezone-aware")
|
||||
if value.utcoffset() is None:
|
||||
raise ValueError(f"{name} must have a UTC offset")
|
||||
return value
|
||||
|
||||
|
||||
def validate_oid(value: str) -> str:
|
||||
if not isinstance(value, str) or _OID_RE.fullmatch(value) is None:
|
||||
raise ValueError("OID must be 40 lowercase hexadecimal characters")
|
||||
return value
|
||||
|
||||
|
||||
def validate_digest(value: str) -> str:
|
||||
if not isinstance(value, str) or _DIGEST_RE.fullmatch(value) is None:
|
||||
raise ValueError("digest must be 64 lowercase hexadecimal characters")
|
||||
return value
|
||||
|
||||
|
||||
def validate_effect_key(value: str) -> str:
|
||||
if not isinstance(value, str) or _EFFECT_KEY_RE.fullmatch(value) is None:
|
||||
raise ValueError("effect key has invalid length or characters")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationBinding:
|
||||
operation_id: str
|
||||
actor_id: str
|
||||
tenant_id: str
|
||||
repository: str
|
||||
capability_namespace: str
|
||||
capability_id: str
|
||||
task_id: str
|
||||
job_id: str
|
||||
generation: int
|
||||
input_kind: str
|
||||
source_oid: str
|
||||
patch_digest: str
|
||||
target_branch: str
|
||||
scope_digest: str
|
||||
policy_digest: str
|
||||
plan_digest: str
|
||||
effect_key: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"operation_id",
|
||||
"actor_id",
|
||||
"tenant_id",
|
||||
"repository",
|
||||
"capability_id",
|
||||
"task_id",
|
||||
"job_id",
|
||||
"input_kind",
|
||||
"target_branch",
|
||||
):
|
||||
_string(getattr(self, name), name)
|
||||
if self.capability_namespace not in _CAPABILITY_NAMESPACES:
|
||||
raise ValueError("unsupported capability namespace")
|
||||
_non_negative_int(self.generation, "generation")
|
||||
validate_oid(self.source_oid)
|
||||
for name in ("patch_digest", "scope_digest", "policy_digest", "plan_digest"):
|
||||
validate_digest(getattr(self, name))
|
||||
validate_effect_key(self.effect_key)
|
||||
|
||||
|
||||
def binding_digest(binding: OperationBinding) -> str:
|
||||
if not isinstance(binding, OperationBinding):
|
||||
raise TypeError("binding must be OperationBinding")
|
||||
payload = json.dumps(
|
||||
asdict(binding),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DomainStamp:
|
||||
domain: str
|
||||
object_id: str
|
||||
generation: int
|
||||
revision: int
|
||||
event_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_string(self.domain, "domain")
|
||||
_string(self.object_id, "object_id")
|
||||
_non_negative_int(self.generation, "generation")
|
||||
_non_negative_int(self.revision, "revision")
|
||||
_string(self.event_id, "event_id")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fact:
|
||||
value: FactValue
|
||||
stamp: DomainStamp | None
|
||||
binding_digest: str
|
||||
observed_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.value not in ("TRUE", "FALSE", "UNKNOWN"):
|
||||
raise ValueError("invalid fact value")
|
||||
validate_digest(self.binding_digest)
|
||||
if not isinstance(self.evidence_refs, tuple):
|
||||
raise ValueError("evidence_refs must be a tuple")
|
||||
for ref in self.evidence_refs:
|
||||
_string(ref, "evidence_ref")
|
||||
if self.value != "UNKNOWN" and self.stamp is None:
|
||||
raise ValueError("known fact requires an authority stamp")
|
||||
if (self.observed_at is None) != (self.expires_at is None):
|
||||
raise ValueError("fact timestamps must both be present or absent")
|
||||
if self.observed_at is None:
|
||||
if self.value != "UNKNOWN":
|
||||
raise ValueError("known fact requires timestamps")
|
||||
return
|
||||
observed_at = _aware_utc(self.observed_at, "observed_at")
|
||||
expires_at = _aware_utc(self.expires_at, "expires_at")
|
||||
if expires_at <= observed_at:
|
||||
raise ValueError("expires_at must be after observed_at")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationObservation:
|
||||
binding: OperationBinding
|
||||
declared: Fact
|
||||
installed: Fact
|
||||
callable: Fact
|
||||
authorized: Fact
|
||||
preconditions: Fact
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreflightResult:
|
||||
state: PreflightState
|
||||
next_action: str
|
||||
reason_code: str
|
||||
retryable: bool
|
||||
binding_digest: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
|
||||
|
||||
def _now(value: datetime) -> datetime:
|
||||
return _aware_utc(value, "now")
|
||||
|
||||
|
||||
def _fact_invalid_reason(fact: Fact, expected_digest: str, now: datetime) -> str | None:
|
||||
if fact.binding_digest != expected_digest:
|
||||
return "BINDING_MISMATCH"
|
||||
if fact.value == "UNKNOWN":
|
||||
return "OBSERVATION_UNKNOWN"
|
||||
if fact.stamp is None or fact.observed_at is None or fact.expires_at is None:
|
||||
return "INVALID_OBSERVATION"
|
||||
if fact.observed_at > now:
|
||||
return "FUTURE_OBSERVATION"
|
||||
if now >= fact.expires_at:
|
||||
return "STALE_OBSERVATION"
|
||||
return None
|
||||
|
||||
|
||||
def derive_preflight(
|
||||
observation: OperationObservation, *, now: datetime
|
||||
) -> PreflightResult:
|
||||
"""Reduce fresh authority observations without invoking any external port."""
|
||||
now = _now(now)
|
||||
if not isinstance(observation, OperationObservation):
|
||||
raise TypeError("observation must be OperationObservation")
|
||||
digest = binding_digest(observation.binding)
|
||||
evidence: list[str] = []
|
||||
frontier = (
|
||||
(
|
||||
"declared",
|
||||
observation.declared,
|
||||
"EXECUTION_ENTRYPOINT_ABSENT",
|
||||
"ENGINEERING_CHANGE_REQUIRED",
|
||||
"EXECUTION_ENTRYPOINT_ABSENT",
|
||||
),
|
||||
(
|
||||
"installed",
|
||||
observation.installed,
|
||||
"CAPABILITY_UNAVAILABLE",
|
||||
"REPAIR_DECLARED_EXECUTOR",
|
||||
"EXECUTOR_NOT_INSTALLED",
|
||||
),
|
||||
(
|
||||
"callable",
|
||||
observation.callable,
|
||||
"CAPABILITY_UNAVAILABLE",
|
||||
"REPAIR_DECLARED_EXECUTOR",
|
||||
"EXECUTOR_NOT_CALLABLE",
|
||||
),
|
||||
(
|
||||
"authorized",
|
||||
observation.authorized,
|
||||
"AUTHORIZATION_REQUIRED",
|
||||
"PREPARE_EXACT_AUTH_REQUEST",
|
||||
"EXACT_AUTHORIZATION_MISSING",
|
||||
),
|
||||
(
|
||||
"preconditions",
|
||||
observation.preconditions,
|
||||
"PRECONDITION_FAILED",
|
||||
"REPAIR_PRECONDITION",
|
||||
"PRECONDITION_FAILED",
|
||||
),
|
||||
)
|
||||
for name, fact, false_state, false_action, false_reason in frontier:
|
||||
evidence.extend(fact.evidence_refs)
|
||||
invalid_reason = _fact_invalid_reason(fact, digest, now)
|
||||
if invalid_reason is not None:
|
||||
return PreflightResult(
|
||||
state="UNKNOWN",
|
||||
next_action="OBSERVE_OR_RECONCILE",
|
||||
reason_code=f"{name.upper()}_{invalid_reason}",
|
||||
retryable=False,
|
||||
binding_digest=digest,
|
||||
evidence_refs=tuple(dict.fromkeys(evidence)),
|
||||
)
|
||||
if fact.value == "FALSE":
|
||||
return PreflightResult(
|
||||
state=false_state, # type: ignore[arg-type]
|
||||
next_action=false_action,
|
||||
reason_code=false_reason,
|
||||
retryable=False,
|
||||
binding_digest=digest,
|
||||
evidence_refs=tuple(dict.fromkeys(evidence)),
|
||||
)
|
||||
return PreflightResult(
|
||||
state="READY",
|
||||
next_action="EXECUTE_GOVERNED_PLAN",
|
||||
reason_code="ALL_REQUIRED_FACTS_FRESH_AND_TRUE",
|
||||
retryable=False,
|
||||
binding_digest=digest,
|
||||
evidence_refs=tuple(dict.fromkeys(evidence)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectObservation:
|
||||
binding_digest: str
|
||||
outcome: EffectOutcome
|
||||
receipt_ref: str | None
|
||||
result_artifact_digest: str | None
|
||||
actor_id: str | None
|
||||
stamp: DomainStamp | None
|
||||
observed_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
trust_status: Literal["TRUSTED", "UNTRUSTED", "SHADOW"] = "TRUSTED"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.binding_digest)
|
||||
if self.outcome not in ("COMMITTED", "NOT_COMMITTED", "UNKNOWN"):
|
||||
raise ValueError("invalid effect outcome")
|
||||
if self.trust_status not in ("TRUSTED", "UNTRUSTED", "SHADOW"):
|
||||
raise ValueError("invalid effect trust status")
|
||||
for value, name in ((self.receipt_ref, "receipt_ref"), (self.actor_id, "actor_id")):
|
||||
if value is not None:
|
||||
_string(value, name)
|
||||
if self.result_artifact_digest is not None:
|
||||
validate_digest(self.result_artifact_digest)
|
||||
if (self.observed_at is None) != (self.expires_at is None):
|
||||
raise ValueError("effect timestamps must both be present or absent")
|
||||
if self.observed_at is not None:
|
||||
observed_at = _aware_utc(self.observed_at, "observed_at")
|
||||
expires_at = _aware_utc(self.expires_at, "expires_at")
|
||||
if expires_at <= observed_at:
|
||||
raise ValueError("expires_at must be after observed_at")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationObservation:
|
||||
binding_digest: str
|
||||
receipt_ref: str | None
|
||||
result_artifact_digest: str | None
|
||||
verifier_id: str | None
|
||||
outcome: VerificationOutcome
|
||||
authenticated: bool
|
||||
observed_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
verification_receipt_ref: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.binding_digest)
|
||||
if self.outcome not in ("PASS", "FAIL", "UNKNOWN", "UNAVAILABLE"):
|
||||
raise ValueError("invalid verification outcome")
|
||||
if type(self.authenticated) is not bool:
|
||||
raise ValueError("authenticated must be bool")
|
||||
for value, name in (
|
||||
(self.receipt_ref, "receipt_ref"),
|
||||
(self.verifier_id, "verifier_id"),
|
||||
(self.verification_receipt_ref, "verification_receipt_ref"),
|
||||
):
|
||||
if value is not None:
|
||||
_string(value, name)
|
||||
if self.result_artifact_digest is not None:
|
||||
validate_digest(self.result_artifact_digest)
|
||||
_validate_optional_times(self.observed_at, self.expires_at, "verification")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceObservation:
|
||||
binding_digest: str
|
||||
receipt_ref: str | None
|
||||
result_artifact_digest: str | None
|
||||
verifier_receipt_ref: str | None
|
||||
acceptance_revision: str | None
|
||||
acceptor_id: str | None
|
||||
outcome: AcceptanceOutcome
|
||||
authenticated: bool
|
||||
observed_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.binding_digest)
|
||||
if self.outcome not in ("ACCEPTED", "REJECTED", "UNKNOWN"):
|
||||
raise ValueError("invalid acceptance outcome")
|
||||
if type(self.authenticated) is not bool:
|
||||
raise ValueError("authenticated must be bool")
|
||||
for value, name in (
|
||||
(self.receipt_ref, "receipt_ref"),
|
||||
(self.verifier_receipt_ref, "verifier_receipt_ref"),
|
||||
(self.acceptance_revision, "acceptance_revision"),
|
||||
(self.acceptor_id, "acceptor_id"),
|
||||
):
|
||||
if value is not None:
|
||||
_string(value, name)
|
||||
if self.result_artifact_digest is not None:
|
||||
validate_digest(self.result_artifact_digest)
|
||||
_validate_optional_times(self.observed_at, self.expires_at, "acceptance")
|
||||
|
||||
|
||||
def _validate_optional_times(
|
||||
observed_at: datetime | None, expires_at: datetime | None, prefix: str
|
||||
) -> None:
|
||||
if (observed_at is None) != (expires_at is None):
|
||||
raise ValueError(f"{prefix} timestamps must both be present or absent")
|
||||
if observed_at is None:
|
||||
return
|
||||
observed = _aware_utc(observed_at, "observed_at")
|
||||
expires = _aware_utc(expires_at, "expires_at")
|
||||
if expires <= observed:
|
||||
raise ValueError("expires_at must be after observed_at")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClosureObservation:
|
||||
effect: EffectObservation
|
||||
verification: VerificationObservation
|
||||
acceptance: AcceptanceObservation
|
||||
expected_acceptor_id: str
|
||||
expected_acceptance_revision: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_string(self.expected_acceptor_id, "expected_acceptor_id")
|
||||
_string(self.expected_acceptance_revision, "expected_acceptance_revision")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClosureResult:
|
||||
execution_recorded: bool
|
||||
independently_verified: bool
|
||||
business_accepted: bool
|
||||
outcome: ClosureOutcome
|
||||
reason_code: str
|
||||
|
||||
|
||||
def _fresh(
|
||||
observed_at: datetime | None, expires_at: datetime | None, now: datetime
|
||||
) -> bool:
|
||||
return (
|
||||
observed_at is not None
|
||||
and expires_at is not None
|
||||
and observed_at <= now < expires_at
|
||||
)
|
||||
|
||||
|
||||
def reconcile_effect(
|
||||
effect: EffectObservation, *, binding: OperationBinding, now: datetime
|
||||
) -> EffectReconciliation:
|
||||
"""Choose a safe recovery route; this function never retries the effect."""
|
||||
now = _now(now)
|
||||
digest = binding_digest(binding)
|
||||
if (
|
||||
effect.binding_digest != digest
|
||||
or effect.stamp is None
|
||||
or effect.trust_status != "TRUSTED"
|
||||
or not _fresh(effect.observed_at, effect.expires_at, now)
|
||||
):
|
||||
return "OUTCOME_UNKNOWN"
|
||||
if effect.outcome == "COMMITTED":
|
||||
if (
|
||||
effect.receipt_ref is not None
|
||||
and effect.result_artifact_digest is not None
|
||||
and effect.actor_id is not None
|
||||
):
|
||||
return "REBUILD_RECEIPT_REFERENCE"
|
||||
return "OUTCOME_UNKNOWN"
|
||||
if effect.outcome == "NOT_COMMITTED":
|
||||
# stamp + fresh trusted authority receipt is the deterministic negative
|
||||
# observation. Revalidation is still required before any governed retry.
|
||||
if effect.receipt_ref is not None and effect.result_artifact_digest is None:
|
||||
return "REVALIDATE_BEFORE_RETRY"
|
||||
return "OUTCOME_UNKNOWN"
|
||||
|
||||
|
||||
def derive_closure(
|
||||
binding: OperationBinding,
|
||||
observation: ClosureObservation,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> ClosureResult:
|
||||
"""Reduce execution, verification, and acceptance as three distinct claims."""
|
||||
now = _now(now)
|
||||
digest = binding_digest(binding)
|
||||
effect = observation.effect
|
||||
execution_recorded = (
|
||||
effect.binding_digest == digest
|
||||
and effect.outcome == "COMMITTED"
|
||||
and effect.trust_status == "TRUSTED"
|
||||
and effect.stamp is not None
|
||||
and effect.receipt_ref is not None
|
||||
and effect.result_artifact_digest is not None
|
||||
and effect.actor_id is not None
|
||||
and _fresh(effect.observed_at, effect.expires_at, now)
|
||||
)
|
||||
if not execution_recorded:
|
||||
outcome: ClosureOutcome = (
|
||||
"OUTCOME_UNKNOWN" if effect.outcome == "UNKNOWN" else "PARTIAL"
|
||||
)
|
||||
return ClosureResult(False, False, False, outcome, "EXECUTION_NOT_RECORDED")
|
||||
|
||||
verification = observation.verification
|
||||
verifier_receipt_ref = (
|
||||
verification.verification_receipt_ref or verification.receipt_ref
|
||||
)
|
||||
independently_verified = (
|
||||
verification.binding_digest == digest
|
||||
and verification.receipt_ref == effect.receipt_ref
|
||||
and verification.result_artifact_digest == effect.result_artifact_digest
|
||||
and verification.verifier_id is not None
|
||||
and verification.verifier_id != effect.actor_id
|
||||
and verification.outcome == "PASS"
|
||||
and verification.authenticated is True
|
||||
and verifier_receipt_ref is not None
|
||||
and _fresh(verification.observed_at, verification.expires_at, now)
|
||||
)
|
||||
if not independently_verified:
|
||||
return ClosureResult(True, False, False, "PARTIAL", "INDEPENDENT_VERIFICATION_MISSING")
|
||||
|
||||
acceptance = observation.acceptance
|
||||
business_accepted = (
|
||||
acceptance.binding_digest == digest
|
||||
and acceptance.receipt_ref == effect.receipt_ref
|
||||
and acceptance.result_artifact_digest == effect.result_artifact_digest
|
||||
and acceptance.verifier_receipt_ref == verifier_receipt_ref
|
||||
and acceptance.acceptance_revision
|
||||
== observation.expected_acceptance_revision
|
||||
and acceptance.acceptor_id == observation.expected_acceptor_id
|
||||
and acceptance.outcome == "ACCEPTED"
|
||||
and acceptance.authenticated is True
|
||||
and _fresh(acceptance.observed_at, acceptance.expires_at, now)
|
||||
)
|
||||
if not business_accepted:
|
||||
return ClosureResult(True, True, False, "PARTIAL", "AUTHORIZED_ACCEPTANCE_MISSING")
|
||||
return ClosureResult(True, True, True, "SUCCEEDED", "THREE_LAYER_CLOSURE_VALID")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AcceptanceObservation",
|
||||
"ClosureObservation",
|
||||
"ClosureResult",
|
||||
"DomainStamp",
|
||||
"EffectObservation",
|
||||
"Fact",
|
||||
"OperationBinding",
|
||||
"OperationObservation",
|
||||
"PreflightResult",
|
||||
"VerificationObservation",
|
||||
"binding_digest",
|
||||
"derive_closure",
|
||||
"derive_preflight",
|
||||
"reconcile_effect",
|
||||
"validate_digest",
|
||||
"validate_effect_key",
|
||||
"validate_oid",
|
||||
]
|
||||
@@ -0,0 +1,533 @@
|
||||
"""Strict, pure validation for the C6 executable source-patch contract.
|
||||
|
||||
Validation here proves contract and unified-diff shape only. The governed
|
||||
executor remains responsible for secure checkout containment, isolated apply,
|
||||
result-file hashing, live authorization, leases/fences, and CAS.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from .operation_view import (
|
||||
OperationBinding,
|
||||
validate_digest,
|
||||
validate_effect_key,
|
||||
validate_oid,
|
||||
)
|
||||
|
||||
|
||||
_TOP_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"input_kind",
|
||||
"type",
|
||||
"target",
|
||||
"before_hash",
|
||||
"after_hash",
|
||||
"operations",
|
||||
"rollback",
|
||||
"verification",
|
||||
"operation_id",
|
||||
"actor_id",
|
||||
"tenant_id",
|
||||
"capability_namespace",
|
||||
"capability_id",
|
||||
"task_id",
|
||||
"job_id",
|
||||
"generation",
|
||||
"executor_ref",
|
||||
"repository",
|
||||
"base_oid",
|
||||
"target_branch",
|
||||
"patch_digest",
|
||||
"file_digests",
|
||||
"scope_digest",
|
||||
"policy_digest",
|
||||
"plan_digest",
|
||||
"effect_key",
|
||||
"rollback_artifact_digest",
|
||||
"verification_contract_revision",
|
||||
"acceptance_contract_revision",
|
||||
}
|
||||
)
|
||||
_TARGET_FIELDS = frozenset({"repository", "branch"})
|
||||
_OPERATION_FIELDS = frozenset({"path", "kind", "before_hash", "after_hash"})
|
||||
_ROLLBACK_FIELDS = frozenset({"artifact_digest", "expected_after_hash"})
|
||||
_VERIFICATION_FIELDS = frozenset(
|
||||
{"revision", "acceptance_revision", "required_checks"}
|
||||
)
|
||||
_PROTECTED_PATHS = frozenset({".git", ".gitmodules"})
|
||||
_DIFF_HEADER_RE = re.compile(r"^diff --git a/([^\s]+) b/([^\s]+)$")
|
||||
|
||||
|
||||
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 _integer(value: object, name: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"{name} must be a non-negative integer")
|
||||
return value
|
||||
|
||||
|
||||
def _path(value: object) -> str:
|
||||
path = _text(value, "path")
|
||||
if "\\" in path or ":" in path or path.startswith(("/", "//")):
|
||||
raise ValueError("path must be repository-relative canonical POSIX")
|
||||
parsed = PurePosixPath(path)
|
||||
if path != parsed.as_posix() or any(part in ("", ".", "..") for part in parsed.parts):
|
||||
raise ValueError("path must be repository-relative canonical POSIX")
|
||||
if any(part.casefold() == ".git" for part in parsed.parts) or path.casefold() in _PROTECTED_PATHS:
|
||||
raise ValueError("protected Git metadata is outside patch scope")
|
||||
return path
|
||||
|
||||
|
||||
def _content_hash(value: object, name: str, *, literal: str | None = None) -> str:
|
||||
text = _text(value, name)
|
||||
if literal is not None and text == literal:
|
||||
return text
|
||||
return validate_digest(text)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchTarget:
|
||||
repository: str
|
||||
branch: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.repository, "repository")
|
||||
_text(self.branch, "branch")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchFileOperation:
|
||||
path: str
|
||||
kind: Literal["ADD", "MODIFY", "DELETE"]
|
||||
before_hash: str
|
||||
after_hash: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_path(self.path)
|
||||
if self.kind not in ("ADD", "MODIFY", "DELETE"):
|
||||
raise ValueError("unsupported patch operation")
|
||||
if self.kind == "ADD":
|
||||
if self.before_hash != "ABSENT":
|
||||
raise ValueError("ADD before_hash must be ABSENT")
|
||||
validate_digest(self.after_hash)
|
||||
elif self.kind == "DELETE":
|
||||
validate_digest(self.before_hash)
|
||||
if self.after_hash != "DELETED":
|
||||
raise ValueError("DELETE after_hash must be DELETED")
|
||||
else:
|
||||
validate_digest(self.before_hash)
|
||||
validate_digest(self.after_hash)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RollbackContract:
|
||||
artifact_digest: str
|
||||
expected_after_hash: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_digest(self.artifact_digest)
|
||||
validate_digest(self.expected_after_hash)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationContract:
|
||||
revision: str
|
||||
acceptance_revision: str
|
||||
required_checks: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.revision, "verification revision")
|
||||
_text(self.acceptance_revision, "acceptance revision")
|
||||
if not isinstance(self.required_checks, tuple) or not self.required_checks:
|
||||
raise ValueError("required_checks must be a non-empty tuple")
|
||||
if len(set(self.required_checks)) != len(self.required_checks):
|
||||
raise ValueError("required_checks contains duplicates")
|
||||
for check in self.required_checks:
|
||||
_text(check, "required check")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutablePatchV1:
|
||||
schema: Literal["aios.executable-patch.v1"]
|
||||
input_kind: Literal["EXECUTABLE_PATCH"]
|
||||
type: Literal["git_patch"]
|
||||
target: PatchTarget
|
||||
before_hash: str
|
||||
after_hash: str
|
||||
operations: tuple[PatchFileOperation, ...]
|
||||
rollback: RollbackContract
|
||||
verification: VerificationContract
|
||||
operation_id: str
|
||||
actor_id: str
|
||||
tenant_id: str
|
||||
capability_namespace: str
|
||||
capability_id: str
|
||||
task_id: str
|
||||
job_id: str
|
||||
generation: int
|
||||
executor_ref: Literal["governed_source_patch_apply"]
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
patch_digest: str
|
||||
file_digests: tuple[tuple[str, str], ...]
|
||||
scope_digest: str
|
||||
policy_digest: str
|
||||
plan_digest: str
|
||||
effect_key: str
|
||||
rollback_artifact_digest: str
|
||||
verification_contract_revision: str
|
||||
acceptance_contract_revision: str
|
||||
|
||||
|
||||
def _aggregate_hash(
|
||||
operations: tuple[PatchFileOperation, ...], attribute: Literal["before_hash", "after_hash"]
|
||||
) -> str:
|
||||
values = [
|
||||
{"path": item.path, "hash": getattr(item, attribute)}
|
||||
for item in sorted(operations, key=lambda operation: operation.path)
|
||||
]
|
||||
encoded = json.dumps(
|
||||
values,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _parse_target(value: object) -> PatchTarget:
|
||||
raw = _exact_mapping(value, _TARGET_FIELDS, "target")
|
||||
return PatchTarget(
|
||||
repository=_text(raw["repository"], "target.repository"),
|
||||
branch=_text(raw["branch"], "target.branch"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_operation(value: object) -> PatchFileOperation:
|
||||
raw = _exact_mapping(value, _OPERATION_FIELDS, "operation")
|
||||
kind = raw["kind"]
|
||||
if kind not in ("ADD", "MODIFY", "DELETE"):
|
||||
raise ValueError("unsupported patch operation")
|
||||
before = _content_hash(
|
||||
raw["before_hash"],
|
||||
"before_hash",
|
||||
literal="ABSENT" if kind == "ADD" else None,
|
||||
)
|
||||
after = _content_hash(
|
||||
raw["after_hash"],
|
||||
"after_hash",
|
||||
literal="DELETED" if kind == "DELETE" else None,
|
||||
)
|
||||
return PatchFileOperation(
|
||||
path=_path(raw["path"]),
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
before_hash=before,
|
||||
after_hash=after,
|
||||
)
|
||||
|
||||
|
||||
def _parse_operations(value: object) -> tuple[PatchFileOperation, ...]:
|
||||
if not isinstance(value, (tuple, list)) or isinstance(value, (str, bytes)) or not value:
|
||||
raise ValueError("operations must be a non-empty array")
|
||||
operations = tuple(_parse_operation(item) for item in value)
|
||||
paths = [item.path for item in operations]
|
||||
if len(paths) != len(set(paths)) or len(paths) != len({item.casefold() for item in paths}):
|
||||
raise ValueError("operation paths must be unique, including case-folding")
|
||||
return operations
|
||||
|
||||
|
||||
def _parse_rollback(value: object) -> RollbackContract:
|
||||
raw = _exact_mapping(value, _ROLLBACK_FIELDS, "rollback")
|
||||
return RollbackContract(
|
||||
artifact_digest=validate_digest(_text(raw["artifact_digest"], "artifact_digest")),
|
||||
expected_after_hash=validate_digest(
|
||||
_text(raw["expected_after_hash"], "expected_after_hash")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_verification(value: object) -> VerificationContract:
|
||||
raw = _exact_mapping(value, _VERIFICATION_FIELDS, "verification")
|
||||
checks = raw["required_checks"]
|
||||
if not isinstance(checks, (list, tuple)) or isinstance(checks, (str, bytes)):
|
||||
raise ValueError("required_checks must be an array")
|
||||
return VerificationContract(
|
||||
revision=_text(raw["revision"], "verification revision"),
|
||||
acceptance_revision=_text(
|
||||
raw["acceptance_revision"], "acceptance revision"
|
||||
),
|
||||
required_checks=tuple(_text(item, "required check") for item in checks),
|
||||
)
|
||||
|
||||
|
||||
def _parse_file_digests(value: object) -> tuple[tuple[str, str], ...]:
|
||||
if not isinstance(value, (list, tuple)) or isinstance(value, (str, bytes)):
|
||||
raise ValueError("file_digests must be an array of pairs")
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for item in value:
|
||||
if not isinstance(item, (list, tuple)) or len(item) != 2:
|
||||
raise ValueError("file_digests entries must be path/digest pairs")
|
||||
path = _path(item[0])
|
||||
digest = _content_hash(item[1], "file digest", literal="DELETED")
|
||||
pairs.append((path, digest))
|
||||
paths = [path for path, _ in pairs]
|
||||
if len(paths) != len(set(paths)) or len(paths) != len({item.casefold() for item in paths}):
|
||||
raise ValueError("file_digests paths must be unique")
|
||||
return tuple(sorted(pairs))
|
||||
|
||||
|
||||
def _strip_patch_path(line: str, prefix: str) -> str:
|
||||
value = line[len(prefix) :]
|
||||
if "\t" in value:
|
||||
raise ValueError("timestamped unified-diff paths are unsupported")
|
||||
if value == "/dev/null":
|
||||
return value
|
||||
expected = "a/" if prefix == "--- " else "b/"
|
||||
if not value.startswith(expected):
|
||||
raise ValueError("diff path has unexpected prefix")
|
||||
return _path(value[2:])
|
||||
|
||||
|
||||
def _actual_diff_operations(patch_bytes: bytes) -> tuple[tuple[str, str], ...]:
|
||||
if not isinstance(patch_bytes, bytes) or not patch_bytes:
|
||||
raise ValueError("patch_bytes must be non-empty bytes")
|
||||
if b"\x00" in patch_bytes:
|
||||
raise ValueError("binary patches are unsupported")
|
||||
try:
|
||||
text = patch_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("patch must be UTF-8 text") from exc
|
||||
lines = text.splitlines()
|
||||
starts = [index for index, line in enumerate(lines) if line.startswith("diff --git ")]
|
||||
if not starts or any(line for line in lines[: starts[0]]):
|
||||
raise ValueError("patch must consist only of git unified-diff blocks")
|
||||
starts.append(len(lines))
|
||||
actual: list[tuple[str, str]] = []
|
||||
for position in range(len(starts) - 1):
|
||||
block = lines[starts[position] : starts[position + 1]]
|
||||
match = _DIFF_HEADER_RE.fullmatch(block[0])
|
||||
if match is None:
|
||||
raise ValueError("quoted, whitespace, or malformed diff paths are unsupported")
|
||||
old_header_path = _path(match.group(1))
|
||||
new_header_path = _path(match.group(2))
|
||||
forbidden = (
|
||||
"GIT binary patch",
|
||||
"Binary files ",
|
||||
"rename from ",
|
||||
"rename to ",
|
||||
"copy from ",
|
||||
"copy to ",
|
||||
"similarity index ",
|
||||
"dissimilarity index ",
|
||||
"old mode ",
|
||||
"new mode ",
|
||||
)
|
||||
if any(line.startswith(forbidden) for line in block[1:]):
|
||||
raise ValueError("rename, copy, binary, or mode-only patches are unsupported")
|
||||
mode_lines = [
|
||||
line
|
||||
for line in block[1:]
|
||||
if line.startswith(("new file mode ", "deleted file mode "))
|
||||
]
|
||||
if any(not line.endswith("100644") for line in mode_lines):
|
||||
raise ValueError("symlink, executable, and submodule modes are unsupported")
|
||||
old_lines = [line for line in block[1:] if line.startswith("--- ")]
|
||||
new_lines = [line for line in block[1:] if line.startswith("+++ ")]
|
||||
if len(old_lines) != 1 or len(new_lines) != 1:
|
||||
raise ValueError("each diff block requires one old and new path")
|
||||
old_path = _strip_patch_path(old_lines[0], "--- ")
|
||||
new_path = _strip_patch_path(new_lines[0], "+++ ")
|
||||
if not any(line.startswith("@@ ") for line in block[1:]):
|
||||
raise ValueError("mode-only and empty diff blocks are unsupported")
|
||||
if old_path == "/dev/null":
|
||||
if new_path != new_header_path or old_header_path != new_header_path:
|
||||
raise ValueError("ADD paths do not agree")
|
||||
if mode_lines != ["new file mode 100644"]:
|
||||
raise ValueError("ADD requires regular-text new file mode")
|
||||
actual.append((new_path, "ADD"))
|
||||
elif new_path == "/dev/null":
|
||||
if old_path != old_header_path or old_header_path != new_header_path:
|
||||
raise ValueError("DELETE paths do not agree")
|
||||
if mode_lines != ["deleted file mode 100644"]:
|
||||
raise ValueError("DELETE requires regular-text deleted file mode")
|
||||
actual.append((old_path, "DELETE"))
|
||||
else:
|
||||
if mode_lines or len({old_path, new_path, old_header_path, new_header_path}) != 1:
|
||||
raise ValueError("rename or mode-changing MODIFY is unsupported")
|
||||
actual.append((old_path, "MODIFY"))
|
||||
paths = [path for path, _ in actual]
|
||||
if len(paths) != len(set(paths)) or len(paths) != len({item.casefold() for item in paths}):
|
||||
raise ValueError("diff paths must be unique, including case-folding")
|
||||
return tuple(sorted(actual))
|
||||
|
||||
|
||||
def validate_patch_shape(patch_bytes: bytes, contract: ExecutablePatchV1) -> None:
|
||||
actual = _actual_diff_operations(patch_bytes)
|
||||
declared = tuple(sorted((item.path, item.kind) for item in contract.operations))
|
||||
if actual != declared:
|
||||
raise ValueError("actual diff file set or operation kinds do not match contract")
|
||||
|
||||
|
||||
def parse_executable_patch(
|
||||
raw: Mapping[str, object],
|
||||
*,
|
||||
patch_bytes: bytes,
|
||||
allowed_files: frozenset[str],
|
||||
expected_binding: OperationBinding,
|
||||
) -> ExecutablePatchV1:
|
||||
"""Parse a complete FD-005 contract without applying or executing it."""
|
||||
document = _exact_mapping(raw, _TOP_FIELDS, "executable patch")
|
||||
if document["schema"] != "aios.executable-patch.v1":
|
||||
raise ValueError("unsupported executable patch schema")
|
||||
if document["input_kind"] != "EXECUTABLE_PATCH":
|
||||
raise ValueError("PATCH_PROPOSAL is not executable")
|
||||
if document["type"] != "git_patch":
|
||||
raise ValueError("unsupported executable patch type")
|
||||
if document["executor_ref"] != "governed_source_patch_apply":
|
||||
raise ValueError("executor_ref is not the governed pilot")
|
||||
if not isinstance(allowed_files, frozenset):
|
||||
raise ValueError("allowed_files must be a frozen manifest set")
|
||||
canonical_allowed = frozenset(_path(item) for item in allowed_files)
|
||||
if len(canonical_allowed) != len(allowed_files) or len(canonical_allowed) != len(
|
||||
{item.casefold() for item in canonical_allowed}
|
||||
):
|
||||
raise ValueError("allowed_files contains aliases or case-fold collisions")
|
||||
|
||||
target = _parse_target(document["target"])
|
||||
operations = _parse_operations(document["operations"])
|
||||
rollback = _parse_rollback(document["rollback"])
|
||||
verification = _parse_verification(document["verification"])
|
||||
file_digests = _parse_file_digests(document["file_digests"])
|
||||
before_hash = validate_digest(_text(document["before_hash"], "before_hash"))
|
||||
after_hash = validate_digest(_text(document["after_hash"], "after_hash"))
|
||||
patch_digest = validate_digest(_text(document["patch_digest"], "patch_digest"))
|
||||
|
||||
binding_fields = {
|
||||
"operation_id": expected_binding.operation_id,
|
||||
"actor_id": expected_binding.actor_id,
|
||||
"tenant_id": expected_binding.tenant_id,
|
||||
"capability_namespace": expected_binding.capability_namespace,
|
||||
"capability_id": expected_binding.capability_id,
|
||||
"task_id": expected_binding.task_id,
|
||||
"job_id": expected_binding.job_id,
|
||||
"generation": expected_binding.generation,
|
||||
"input_kind": expected_binding.input_kind,
|
||||
"repository": expected_binding.repository,
|
||||
"target_branch": expected_binding.target_branch,
|
||||
"patch_digest": expected_binding.patch_digest,
|
||||
"scope_digest": expected_binding.scope_digest,
|
||||
"policy_digest": expected_binding.policy_digest,
|
||||
"plan_digest": expected_binding.plan_digest,
|
||||
"effect_key": expected_binding.effect_key,
|
||||
}
|
||||
for name, expected in binding_fields.items():
|
||||
if document[name] != expected:
|
||||
raise ValueError(f"binding mismatch: {name}")
|
||||
generation = _integer(document["generation"], "generation")
|
||||
if generation != expected_binding.generation:
|
||||
raise ValueError("binding mismatch: generation")
|
||||
base_oid = validate_oid(_text(document["base_oid"], "base_oid"))
|
||||
if base_oid != expected_binding.source_oid:
|
||||
raise ValueError("binding mismatch: base_oid")
|
||||
if target.repository != expected_binding.repository or target.branch != expected_binding.target_branch:
|
||||
raise ValueError("target does not match top-level binding")
|
||||
actual_patch_digest = hashlib.sha256(patch_bytes).hexdigest()
|
||||
if patch_digest != actual_patch_digest:
|
||||
raise ValueError("patch_digest does not match raw patch bytes")
|
||||
if any(item.path not in canonical_allowed for item in operations):
|
||||
raise ValueError("operation path is outside allowed_files")
|
||||
expected_file_digests = tuple(sorted((item.path, item.after_hash) for item in operations))
|
||||
if file_digests != expected_file_digests:
|
||||
raise ValueError("file_digests do not match operation result hashes")
|
||||
if before_hash != _aggregate_hash(operations, "before_hash"):
|
||||
raise ValueError("before_hash does not match the operation manifest")
|
||||
if after_hash != _aggregate_hash(operations, "after_hash"):
|
||||
raise ValueError("after_hash does not match the operation manifest")
|
||||
if rollback.expected_after_hash != after_hash:
|
||||
raise ValueError("rollback expected_after_hash mismatch")
|
||||
rollback_artifact_digest = validate_digest(
|
||||
_text(document["rollback_artifact_digest"], "rollback_artifact_digest")
|
||||
)
|
||||
if rollback_artifact_digest != rollback.artifact_digest:
|
||||
raise ValueError("rollback_artifact_digest mismatch")
|
||||
verification_revision = _text(
|
||||
document["verification_contract_revision"],
|
||||
"verification_contract_revision",
|
||||
)
|
||||
acceptance_revision = _text(
|
||||
document["acceptance_contract_revision"],
|
||||
"acceptance_contract_revision",
|
||||
)
|
||||
if verification_revision != verification.revision:
|
||||
raise ValueError("verification_contract_revision mismatch")
|
||||
if acceptance_revision != verification.acceptance_revision:
|
||||
raise ValueError("acceptance_contract_revision mismatch")
|
||||
validate_effect_key(_text(document["effect_key"], "effect_key"))
|
||||
|
||||
contract = ExecutablePatchV1(
|
||||
schema="aios.executable-patch.v1",
|
||||
input_kind="EXECUTABLE_PATCH",
|
||||
type="git_patch",
|
||||
target=target,
|
||||
before_hash=before_hash,
|
||||
after_hash=after_hash,
|
||||
operations=operations,
|
||||
rollback=rollback,
|
||||
verification=verification,
|
||||
operation_id=expected_binding.operation_id,
|
||||
actor_id=expected_binding.actor_id,
|
||||
tenant_id=expected_binding.tenant_id,
|
||||
capability_namespace=expected_binding.capability_namespace,
|
||||
capability_id=expected_binding.capability_id,
|
||||
task_id=expected_binding.task_id,
|
||||
job_id=expected_binding.job_id,
|
||||
generation=generation,
|
||||
executor_ref="governed_source_patch_apply",
|
||||
repository=expected_binding.repository,
|
||||
base_oid=base_oid,
|
||||
target_branch=expected_binding.target_branch,
|
||||
patch_digest=patch_digest,
|
||||
file_digests=file_digests,
|
||||
scope_digest=expected_binding.scope_digest,
|
||||
policy_digest=expected_binding.policy_digest,
|
||||
plan_digest=expected_binding.plan_digest,
|
||||
effect_key=expected_binding.effect_key,
|
||||
rollback_artifact_digest=rollback_artifact_digest,
|
||||
verification_contract_revision=verification_revision,
|
||||
acceptance_contract_revision=acceptance_revision,
|
||||
)
|
||||
validate_patch_shape(patch_bytes, contract)
|
||||
return contract
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ExecutablePatchV1",
|
||||
"PatchFileOperation",
|
||||
"PatchTarget",
|
||||
"RollbackContract",
|
||||
"VerificationContract",
|
||||
"parse_executable_patch",
|
||||
"validate_patch_shape",
|
||||
]
|
||||
@@ -54,13 +54,27 @@ class ReadonlyWorkerProfile:
|
||||
|
||||
|
||||
class IdentityVerifier:
|
||||
def __init__(self, trusted_identities: set[str] | None = None):
|
||||
self.trusted_identities = trusted_identities
|
||||
def __init__(
|
||||
self,
|
||||
trusted_identities: set[str] | None = None,
|
||||
*,
|
||||
verifier_identity: str | None = None,
|
||||
):
|
||||
self.trusted_identities = frozenset(trusted_identities or ())
|
||||
self.verifier_identity = verifier_identity
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return any(identity != "*" for identity in self.trusted_identities)
|
||||
|
||||
def verify(self, identity: str) -> bool:
|
||||
if self.trusted_identities is None:
|
||||
return True
|
||||
return identity in self.trusted_identities
|
||||
return bool(
|
||||
identity
|
||||
and (
|
||||
identity in self.trusted_identities
|
||||
or "*" in self.trusted_identities
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class EvidenceVerifier:
|
||||
@@ -92,6 +106,7 @@ class PolicyEngine:
|
||||
max_risk: str = "medium",
|
||||
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
||||
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
||||
evidence_attestation_verifier: Callable[[object], bool] | None = None,
|
||||
identity_capabilities: dict[str, set[str]] | None = None,
|
||||
admin_identities: set[str] | None = None,
|
||||
readonly_worker_execution_enabled: bool = False,
|
||||
@@ -99,12 +114,28 @@ class PolicyEngine:
|
||||
) -> None:
|
||||
if max_risk not in RISK_RANK:
|
||||
raise ValueError("invalid max_risk")
|
||||
self.identity_verifier = identity_verifier or IdentityVerifier()
|
||||
if execution_enabled and (
|
||||
identity_verifier is None or not identity_verifier.is_configured
|
||||
):
|
||||
raise ValueError(
|
||||
"execution_enabled requires an explicit trusted identity verifier"
|
||||
)
|
||||
if execution_enabled and evidence_attestation_verifier is None:
|
||||
raise ValueError(
|
||||
"execution_enabled requires an evidence attestation verifier"
|
||||
)
|
||||
configured_identities = {
|
||||
identity for identity in (identity_capabilities or {}) if identity
|
||||
}
|
||||
self.identity_verifier = identity_verifier or IdentityVerifier(
|
||||
configured_identities if not execution_enabled else None
|
||||
)
|
||||
self.evidence_verifier = evidence_verifier or EvidenceVerifier()
|
||||
self.execution_enabled = execution_enabled
|
||||
self.max_risk = max_risk
|
||||
self.result_signature_verifier = result_signature_verifier
|
||||
self.probe_attestation_verifier = probe_attestation_verifier
|
||||
self.evidence_attestation_verifier = evidence_attestation_verifier
|
||||
self.identity_capabilities = {
|
||||
identity: set(capabilities)
|
||||
for identity, capabilities in (identity_capabilities or {}).items()
|
||||
@@ -195,6 +226,8 @@ class PolicyEngine:
|
||||
"worker.register": "worker.self_register",
|
||||
"worker.admin": "worker.admin",
|
||||
}
|
||||
if action not in action_capabilities:
|
||||
return PolicyDecision(False, f"unknown action {action}")
|
||||
required_capability = action_capabilities.get(action)
|
||||
if required_capability:
|
||||
granted = set(
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
@@ -39,6 +41,22 @@ def _digest(value: Mapping[str, object]) -> str:
|
||||
return hashlib.sha256(canonical_json(dict(value)).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _canonical_observation_value(value: object) -> object:
|
||||
"""Convert a typed readback observation to stable JSON data."""
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ConflictError("exact host closure time is not timezone-aware")
|
||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): _canonical_observation_value(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_canonical_observation_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthoritativeRootActionPlan:
|
||||
review_id: str
|
||||
@@ -265,6 +283,37 @@ class RootActionRunResult:
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExactRootActionBinding:
|
||||
"""Trusted host resolution from a C6 operation to an existing root review."""
|
||||
|
||||
review_id: str
|
||||
mission_id: str
|
||||
root_task_id: str
|
||||
root_revision: int
|
||||
plan_digest: str
|
||||
execution_key: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if any(
|
||||
not isinstance(value, str) or not value
|
||||
for value in (
|
||||
self.review_id,
|
||||
self.mission_id,
|
||||
self.root_task_id,
|
||||
self.plan_digest,
|
||||
self.execution_key,
|
||||
)
|
||||
):
|
||||
raise ValueError("exact root action binding identity required")
|
||||
if (
|
||||
isinstance(self.root_revision, bool)
|
||||
or not isinstance(self.root_revision, int)
|
||||
or self.root_revision < 0
|
||||
):
|
||||
raise ValueError("exact root action revision is invalid")
|
||||
|
||||
|
||||
class RootActionExecutor(Protocol):
|
||||
def reconcile(
|
||||
self,
|
||||
@@ -278,7 +327,7 @@ class RootActionExecutor(Protocol):
|
||||
plan: AuthoritativeRootActionPlan,
|
||||
*,
|
||||
before_external_call: Callable[[], None],
|
||||
) -> None: ...
|
||||
) -> Mapping[str, object] | None: ...
|
||||
|
||||
def commit_effect(
|
||||
self,
|
||||
@@ -292,6 +341,7 @@ PlanBuilder = Callable[[RootReviewRecord], AuthoritativeRootActionPlan]
|
||||
Rereviewer = Callable[
|
||||
[AuthoritativeRootActionPlan, Mapping[str, object] | None], RootActionRereview
|
||||
]
|
||||
ExactReviewBindingProvider = Callable[[object], ExactRootActionBinding]
|
||||
|
||||
|
||||
class RootActionConsumer:
|
||||
@@ -304,12 +354,14 @@ class RootActionConsumer:
|
||||
plan_builder: PlanBuilder,
|
||||
rereviewer: Rereviewer,
|
||||
executor: RootActionExecutor | None,
|
||||
exact_review_binding_provider: ExactReviewBindingProvider | None = None,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
) -> None:
|
||||
) -> Mapping[str, object]:
|
||||
self.store = store
|
||||
self._plan_builder = plan_builder
|
||||
self._rereviewer = rereviewer
|
||||
self._executor = executor
|
||||
self._exact_review_binding_provider = exact_review_binding_provider
|
||||
self._clock = clock or (lambda: datetime.now(tz=timezone.utc))
|
||||
|
||||
@staticmethod
|
||||
@@ -416,9 +468,20 @@ class RootActionConsumer:
|
||||
raise ConflictError("root action claim renewal CAS lost")
|
||||
|
||||
def _claim_next(
|
||||
self, *, consumer_id: str, now: float, lease_seconds: int
|
||||
self,
|
||||
*,
|
||||
consumer_id: str,
|
||||
now: float,
|
||||
lease_seconds: int,
|
||||
exact_binding: ExactRootActionBinding | None = None,
|
||||
) -> RootActionClaim | RootActionRunResult | None:
|
||||
with self.store._transaction():
|
||||
exact_sql = " AND d.review_id=?" if exact_binding is not None else ""
|
||||
args: tuple[object, ...] = (
|
||||
(now, exact_binding.review_id)
|
||||
if exact_binding is not None
|
||||
else (now,)
|
||||
)
|
||||
rows = self.store.conn.execute(
|
||||
"SELECT d.*,r.record_json,r.root_revision_after "
|
||||
"FROM task_root_action_dispatches d JOIN task_root_reviews r "
|
||||
@@ -426,11 +489,13 @@ class RootActionConsumer:
|
||||
"WHERE d.status IN ('EFFECT_UNKNOWN','DISPATCHED',"
|
||||
"'EXECUTED_PENDING_REREVIEW','CLAIMED','PENDING') "
|
||||
"AND (d.retry_at IS NULL OR d.retry_at<=?) "
|
||||
+ exact_sql
|
||||
+ " "
|
||||
"ORDER BY r.root_revision_after DESC,CASE d.status "
|
||||
"WHEN 'EFFECT_UNKNOWN' THEN 0 WHEN 'DISPATCHED' THEN 0 "
|
||||
"WHEN 'EXECUTED_PENDING_REREVIEW' THEN 1 "
|
||||
"WHEN 'CLAIMED' THEN 2 ELSE 3 END,d.created_at ASC",
|
||||
(now,),
|
||||
args,
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
if (
|
||||
@@ -443,7 +508,11 @@ class RootActionConsumer:
|
||||
}
|
||||
and float(row["claim_expires_at"] or 0) > now
|
||||
):
|
||||
continue
|
||||
if not (
|
||||
exact_binding is not None
|
||||
and row["claim_owner"] == consumer_id
|
||||
):
|
||||
continue
|
||||
root_task_id = str(row["root_task_id"])
|
||||
active = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_fences WHERE root_task_id=?",
|
||||
@@ -488,6 +557,43 @@ class RootActionConsumer:
|
||||
or plan.action != review.next_legal_action
|
||||
):
|
||||
raise ConflictError("root action plan/review authority mismatch")
|
||||
host_effect = self.store.conn.execute(
|
||||
"SELECT host_effect_key FROM task_root_action_effects "
|
||||
"WHERE execution_key=?",
|
||||
(plan.execution_key,),
|
||||
).fetchone()
|
||||
if (
|
||||
exact_binding is None
|
||||
and host_effect is not None
|
||||
and host_effect["host_effect_key"] is not None
|
||||
):
|
||||
# A C6-prepared action may only be resumed through the
|
||||
# exact facade, which supplies fresh signed authority.
|
||||
continue
|
||||
if exact_binding is not None and (
|
||||
plan.review_id != exact_binding.review_id
|
||||
or plan.mission_id != exact_binding.mission_id
|
||||
or plan.root_task_id != exact_binding.root_task_id
|
||||
or plan.root_revision != exact_binding.root_revision
|
||||
or plan.plan_digest != exact_binding.plan_digest
|
||||
or plan.execution_key != exact_binding.execution_key
|
||||
):
|
||||
raise ConflictError("trusted exact root action binding mismatch")
|
||||
if (
|
||||
exact_binding is not None
|
||||
and row["claim_owner"] == consumer_id
|
||||
and float(row["claim_expires_at"] or 0) > now
|
||||
):
|
||||
return RootActionClaim(
|
||||
plan=plan,
|
||||
consumer_id=consumer_id,
|
||||
claim_token=str(row["claim_token"]),
|
||||
fence=int(row["claim_fence"]),
|
||||
lease_expires_at=float(row["claim_expires_at"]),
|
||||
lease_seconds=lease_seconds,
|
||||
reconcile_only=row["status"]
|
||||
in {"EFFECT_UNKNOWN", "DISPATCHED"},
|
||||
)
|
||||
if active is not None and active["active_review_id"] not in {
|
||||
None,
|
||||
row["review_id"],
|
||||
@@ -582,7 +688,13 @@ class RootActionConsumer:
|
||||
return claim
|
||||
return None
|
||||
|
||||
def _prepare_effect(self, claim: RootActionClaim, now: float):
|
||||
def _prepare_effect(
|
||||
self,
|
||||
claim: RootActionClaim,
|
||||
now: float,
|
||||
*,
|
||||
host_binding: Mapping[str, object] | None = None,
|
||||
):
|
||||
plan = claim.plan
|
||||
request_digest = _digest(
|
||||
{
|
||||
@@ -635,6 +747,41 @@ class RootActionConsumer:
|
||||
if effect["receipt_json"] is not None
|
||||
else None
|
||||
)
|
||||
if host_binding is not None:
|
||||
host_json = canonical_json(dict(host_binding))
|
||||
host_digest = hashlib.sha256(host_json.encode("utf-8")).hexdigest()
|
||||
host_effect_key = host_binding.get("effect_key")
|
||||
if not isinstance(host_effect_key, str) or not host_effect_key:
|
||||
raise ConflictError("exact host effect key is invalid")
|
||||
current = self.store.conn.execute(
|
||||
"SELECT host_effect_key,host_binding_json,host_binding_digest "
|
||||
"FROM task_root_action_effects WHERE execution_key=?",
|
||||
(plan.execution_key,),
|
||||
).fetchone()
|
||||
if current["host_effect_key"] is None:
|
||||
try:
|
||||
changed = self.store.conn.execute(
|
||||
"UPDATE task_root_action_effects SET host_effect_key=?,"
|
||||
"host_binding_json=?,host_binding_digest=?,updated_at=? "
|
||||
"WHERE execution_key=? AND host_effect_key IS NULL",
|
||||
(
|
||||
host_effect_key,
|
||||
host_json,
|
||||
host_digest,
|
||||
now,
|
||||
plan.execution_key,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ConflictError("exact host effect key already bound") from exc
|
||||
if changed.rowcount != 1:
|
||||
raise ConflictError("exact host binding CAS lost")
|
||||
elif (
|
||||
current["host_effect_key"] != host_effect_key
|
||||
or current["host_binding_json"] != host_json
|
||||
or current["host_binding_digest"] != host_digest
|
||||
):
|
||||
raise ConflictError("exact host action binding conflict")
|
||||
status = (
|
||||
"EXECUTED_PENDING_REREVIEW"
|
||||
if effect_state == "SUCCEEDED"
|
||||
@@ -654,6 +801,52 @@ class RootActionConsumer:
|
||||
)
|
||||
return effect_state, receipt, effect_phase
|
||||
|
||||
def _record_writer_lease(
|
||||
self,
|
||||
claim: RootActionClaim,
|
||||
lease: Mapping[str, object] | None,
|
||||
now: float,
|
||||
) -> None:
|
||||
if lease is None:
|
||||
return
|
||||
lease_json = canonical_json(dict(lease))
|
||||
lease_digest = hashlib.sha256(lease_json.encode("utf-8")).hexdigest()
|
||||
with self.store._transaction():
|
||||
changed = self.store.conn.execute(
|
||||
"UPDATE task_root_action_effects SET writer_lease_json=?,"
|
||||
"writer_lease_digest=?,updated_at=? WHERE execution_key=? "
|
||||
"AND writer_lease_json IS NULL",
|
||||
(lease_json, lease_digest, now, claim.plan.execution_key),
|
||||
)
|
||||
if changed.rowcount != 1:
|
||||
row = self.store.conn.execute(
|
||||
"SELECT writer_lease_json,writer_lease_digest "
|
||||
"FROM task_root_action_effects WHERE execution_key=?",
|
||||
(claim.plan.execution_key,),
|
||||
).fetchone()
|
||||
if (
|
||||
row is None
|
||||
or row["writer_lease_json"] != lease_json
|
||||
or row["writer_lease_digest"] != lease_digest
|
||||
):
|
||||
raise ConflictError("WriterLease durable binding conflict")
|
||||
|
||||
@staticmethod
|
||||
def _validate_exact_writer_lease(lease: object) -> Mapping[str, object]:
|
||||
if not isinstance(lease, Mapping):
|
||||
raise ConflictError("exact host WriterLease identity unavailable")
|
||||
lease_id = lease.get("writer_lease_id")
|
||||
fence = lease.get("writer_lease_fence")
|
||||
if (
|
||||
not isinstance(lease_id, str)
|
||||
or not lease_id.strip()
|
||||
or isinstance(fence, bool)
|
||||
or not isinstance(fence, int)
|
||||
or fence < 0
|
||||
):
|
||||
raise ConflictError("exact host WriterLease identity unavailable")
|
||||
return lease
|
||||
|
||||
def _transition_effect_phase(
|
||||
self,
|
||||
claim: RootActionClaim,
|
||||
@@ -910,18 +1103,25 @@ class RootActionConsumer:
|
||||
plan.execution_key,
|
||||
)
|
||||
|
||||
def run_once(
|
||||
def _run(
|
||||
self,
|
||||
*,
|
||||
consumer_id: str,
|
||||
now: datetime,
|
||||
lease_seconds: int = 60,
|
||||
exact_binding: ExactRootActionBinding | None = None,
|
||||
host_binding: Mapping[str, object] | None = None,
|
||||
prepare_only: bool = False,
|
||||
external_revalidator: Callable[[], None] | None = None,
|
||||
) -> RootActionRunResult:
|
||||
if not consumer_id or lease_seconds < 1:
|
||||
raise ValueError("invalid root action consumer claim")
|
||||
now_value = self._now(now)
|
||||
claimed = self._claim_next(
|
||||
consumer_id=consumer_id, now=now_value, lease_seconds=lease_seconds
|
||||
consumer_id=consumer_id,
|
||||
now=now_value,
|
||||
lease_seconds=lease_seconds,
|
||||
exact_binding=exact_binding,
|
||||
)
|
||||
if claimed is None:
|
||||
return RootActionRunResult(RootActionRunState.IDLE)
|
||||
@@ -949,7 +1149,11 @@ class RootActionConsumer:
|
||||
claim, status="EXECUTED_PENDING_REREVIEW", now=now_value
|
||||
)
|
||||
return self._rereview(claim, now_value)
|
||||
effect_state, receipt, effect_phase = self._prepare_effect(claim, now_value)
|
||||
effect_state, receipt, effect_phase = (
|
||||
self._prepare_effect(claim, now_value)
|
||||
if host_binding is None
|
||||
else self._prepare_effect(claim, now_value, host_binding=host_binding)
|
||||
)
|
||||
if effect_state == "SUPERSEDED":
|
||||
return RootActionRunResult(
|
||||
RootActionRunState.CLOSED,
|
||||
@@ -970,6 +1174,13 @@ class RootActionConsumer:
|
||||
extra_args=(receipt_json, _digest(receipt)),
|
||||
)
|
||||
return self._rereview(claim, now_value)
|
||||
if prepare_only:
|
||||
return RootActionRunResult(
|
||||
RootActionRunState.EXECUTED,
|
||||
plan.review_id,
|
||||
plan.execution_key,
|
||||
"PREPARED",
|
||||
)
|
||||
if self._executor is None:
|
||||
with self.store._transaction():
|
||||
self._set_dispatch(
|
||||
@@ -986,9 +1197,10 @@ class RootActionConsumer:
|
||||
plan.execution_key,
|
||||
"ROOT_ACTION_EXECUTOR_UNAVAILABLE",
|
||||
)
|
||||
before_external_call = lambda: self._assert_live_claim(
|
||||
claim, now_value, renew=True
|
||||
)
|
||||
def before_external_call() -> None:
|
||||
self._assert_live_claim(claim, now_value, renew=True)
|
||||
if external_revalidator is not None:
|
||||
external_revalidator()
|
||||
if effect_phase == "BEFORE_LEASE":
|
||||
# Admission and the immutable plan are the authority for this one
|
||||
# first send. A pre-lease GET cannot prove absence: the live
|
||||
@@ -997,10 +1209,13 @@ class RootActionConsumer:
|
||||
claim, expected="BEFORE_LEASE", target="LEASE_INTENT", now=now_value
|
||||
)
|
||||
before_external_call()
|
||||
self._executor.acquire_lease(
|
||||
lease = self._executor.acquire_lease(
|
||||
plan, before_external_call=before_external_call
|
||||
)
|
||||
before_external_call()
|
||||
if host_binding is not None:
|
||||
lease = self._validate_exact_writer_lease(lease)
|
||||
self._record_writer_lease(claim, lease, now_value)
|
||||
self._transition_effect_phase(
|
||||
claim, expected="LEASE_INTENT", target="LEASE_ACQUIRED", now=now_value
|
||||
)
|
||||
@@ -1033,6 +1248,594 @@ class RootActionConsumer:
|
||||
return result
|
||||
return self._rereview(claim, now_value)
|
||||
|
||||
def run_once(
|
||||
self,
|
||||
*,
|
||||
consumer_id: str,
|
||||
now: datetime,
|
||||
lease_seconds: int = 60,
|
||||
) -> RootActionRunResult:
|
||||
return self._run(
|
||||
consumer_id=consumer_id,
|
||||
now=now,
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
|
||||
def configure_exact_review_binding_provider(
|
||||
self, provider: ExactReviewBindingProvider
|
||||
) -> None:
|
||||
if not callable(provider):
|
||||
raise TypeError("trusted exact review binding provider required")
|
||||
if self._exact_review_binding_provider is not None:
|
||||
raise ConflictError("exact review binding provider already configured")
|
||||
self._exact_review_binding_provider = provider
|
||||
|
||||
def assert_exact_owner_configured(self) -> None:
|
||||
if self._exact_review_binding_provider is None or self._executor is None:
|
||||
raise ConflictError("exact root action owner is not configured")
|
||||
|
||||
def _exact_resolution(self, binding: object) -> ExactRootActionBinding:
|
||||
self.assert_exact_owner_configured()
|
||||
assert self._exact_review_binding_provider is not None
|
||||
resolved = self._exact_review_binding_provider(binding)
|
||||
if not isinstance(resolved, ExactRootActionBinding):
|
||||
raise ConflictError("trusted exact review binding is invalid")
|
||||
return resolved
|
||||
|
||||
def _exact_plan(
|
||||
self, binding: object, resolved: ExactRootActionBinding
|
||||
) -> AuthoritativeRootActionPlan:
|
||||
from .operation_view import OperationBinding
|
||||
|
||||
if not isinstance(binding, OperationBinding):
|
||||
raise TypeError("operation binding required")
|
||||
row = self.store.conn.execute(
|
||||
"SELECT d.*,r.record_json FROM task_root_action_dispatches d "
|
||||
"JOIN task_root_reviews r ON r.review_id=d.review_id "
|
||||
"WHERE d.review_id=?",
|
||||
(resolved.review_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ConflictError("trusted exact root review is unavailable")
|
||||
if row["plan_json"]:
|
||||
raw = json.loads(str(row["plan_json"]))
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ConflictError("persisted root action plan is invalid")
|
||||
plan = AuthoritativeRootActionPlan.from_dict(raw)
|
||||
else:
|
||||
plan = self._plan_builder(self._review(row))
|
||||
if (
|
||||
plan.review_id != resolved.review_id
|
||||
or plan.mission_id != resolved.mission_id
|
||||
or plan.root_task_id != resolved.root_task_id
|
||||
or plan.root_revision != resolved.root_revision
|
||||
or plan.plan_digest != resolved.plan_digest
|
||||
or plan.execution_key != resolved.execution_key
|
||||
or plan.target_task_id != binding.task_id
|
||||
or plan.target_job_id != binding.job_id
|
||||
or plan.external_idempotency_key != binding.effect_key
|
||||
):
|
||||
raise ConflictError("C6 operation/root action authority mismatch")
|
||||
for name in ("writer_lease_request", "commit_request"):
|
||||
request = plan.effect_payload.get(name)
|
||||
if not isinstance(request, Mapping):
|
||||
raise ConflictError(f"exact root action {name} unavailable")
|
||||
expected = {
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"repository": binding.repository,
|
||||
"expected_branch_oid": binding.source_oid,
|
||||
"target_branch": binding.target_branch,
|
||||
"generation": binding.generation,
|
||||
}
|
||||
allowed = set(expected)
|
||||
if name == "writer_lease_request":
|
||||
expected.update(
|
||||
{"branch_mode": "CREATE_NEW", "base_oid": binding.source_oid}
|
||||
)
|
||||
allowed.update({"branch_mode", "base_oid"})
|
||||
else:
|
||||
allowed.update({"files", "message"})
|
||||
if set(request) != allowed:
|
||||
raise ConflictError(f"exact root action {name} fields mismatch")
|
||||
if any(request.get(key) != value for key, value in expected.items()):
|
||||
raise ConflictError(f"exact root action {name} binding mismatch")
|
||||
return plan
|
||||
|
||||
@staticmethod
|
||||
def _c6_commit_mutation(
|
||||
*, binding, contract, patch_bytes: bytes, commit_message: str,
|
||||
plan: AuthoritativeRootActionPlan,
|
||||
) -> dict[str, object]:
|
||||
"""Bind the real Bridge mutation to the signed C6 operation identity."""
|
||||
from .operation_view import binding_digest
|
||||
|
||||
if hashlib.sha256(patch_bytes).hexdigest() != contract.patch_digest:
|
||||
raise ConflictError("C6 patch bytes digest mismatch")
|
||||
commit = plan.effect_payload.get("commit_request")
|
||||
if not isinstance(commit, Mapping):
|
||||
raise ConflictError("exact root action commit request unavailable")
|
||||
if commit.get("message") != commit_message:
|
||||
raise ConflictError("C6 commit message/root mutation mismatch")
|
||||
raw_files = commit.get("files")
|
||||
if not isinstance(raw_files, (list, tuple)) or isinstance(
|
||||
raw_files, (str, bytes)
|
||||
):
|
||||
raise ConflictError("C6 exact commit files require Bridge entry array")
|
||||
actual: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
seen_folded: set[str] = set()
|
||||
for raw in raw_files:
|
||||
if not isinstance(raw, Mapping) or set(raw) != {"path", "content_base64"}:
|
||||
raise ConflictError("C6 exact commit file entry is invalid")
|
||||
path = raw.get("path")
|
||||
encoded = raw.get("content_base64")
|
||||
if not isinstance(path, str) or not path or not isinstance(encoded, str):
|
||||
raise ConflictError("C6 exact commit file entry is invalid")
|
||||
if path in seen or path.casefold() in seen_folded:
|
||||
raise ConflictError("C6 exact commit files contain duplicate paths")
|
||||
try:
|
||||
content = base64.b64decode(encoded.encode("ascii"), validate=True)
|
||||
except (UnicodeEncodeError, binascii.Error, ValueError) as exc:
|
||||
raise ConflictError("C6 exact commit file content is invalid") from exc
|
||||
seen.add(path)
|
||||
seen_folded.add(path.casefold())
|
||||
actual.append({"path": path, "sha256": hashlib.sha256(content).hexdigest()})
|
||||
actual.sort(key=lambda item: item["path"])
|
||||
expected = [
|
||||
{"path": path, "sha256": digest}
|
||||
for path, digest in contract.file_digests
|
||||
]
|
||||
if any(item["sha256"] == "DELETED" for item in expected) or actual != expected:
|
||||
raise ConflictError("C6 patch contract/root files mismatch")
|
||||
mutation = {
|
||||
"schema": "aios.c6.commit-mutation.v1",
|
||||
"files": actual,
|
||||
"message": commit_message,
|
||||
}
|
||||
mutation_digest = hashlib.sha256(
|
||||
canonical_json(mutation).encode("utf-8")
|
||||
).hexdigest()
|
||||
expected_projection = {
|
||||
"schema": "aios.c6.root-plan-binding.v1",
|
||||
"operation_binding_digest": binding_digest(binding),
|
||||
"plan_digest": binding.plan_digest,
|
||||
"patch_digest": contract.patch_digest,
|
||||
"commit_mutation_digest": mutation_digest,
|
||||
}
|
||||
projection = plan.effect_payload.get("c6_operation_binding")
|
||||
if not isinstance(projection, Mapping) or dict(projection) != expected_projection:
|
||||
raise ConflictError("C6 operation/root plan mutation identity mismatch")
|
||||
return {**mutation, "digest": mutation_digest}
|
||||
|
||||
@staticmethod
|
||||
def _host_binding_document(
|
||||
*, binding, claim, authority, contract, patch_bytes: bytes, commit_message: str,
|
||||
plan: AuthoritativeRootActionPlan,
|
||||
) -> dict[str, object]:
|
||||
from .operation_view import binding_digest
|
||||
|
||||
mutation = RootActionConsumer._c6_commit_mutation(
|
||||
binding=binding,
|
||||
contract=contract,
|
||||
patch_bytes=patch_bytes,
|
||||
commit_message=commit_message,
|
||||
plan=plan,
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": "aios.c6.root-action-host-binding.v2",
|
||||
"action_id": plan.execution_key,
|
||||
"operation_binding": asdict(binding),
|
||||
"binding_digest": binding_digest(binding),
|
||||
"effect_key": binding.effect_key,
|
||||
"claim_receipt": claim.to_dict(),
|
||||
"authorization_ref": authority.authorization_ref,
|
||||
"authority_key_id": authority.authority_key_id,
|
||||
"authority_key_fingerprint": authority.authority_key_fingerprint,
|
||||
"authority_record_sha256": authority.authority_record_sha256,
|
||||
"authority_generation": authority.authority_generation,
|
||||
"task_generation": authority.task_generation,
|
||||
"expected_branch_oid": authority.expected_branch_oid,
|
||||
"review_id": plan.review_id,
|
||||
"mission_id": plan.mission_id,
|
||||
"root_task_id": plan.root_task_id,
|
||||
"root_revision": plan.root_revision,
|
||||
"plan_digest": plan.plan_digest,
|
||||
"root_execution_key": plan.execution_key,
|
||||
"patch_digest": contract.patch_digest,
|
||||
"c6_plan_digest": binding.plan_digest,
|
||||
"commit_mutation_digest": mutation["digest"],
|
||||
"commit_message_sha256": hashlib.sha256(
|
||||
commit_message.encode("utf-8")
|
||||
).hexdigest(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _validate_host_document(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ConflictError("exact host binding is unavailable")
|
||||
result = dict(value)
|
||||
if result.get("schema") != "aios.c6.root-action-host-binding.v2":
|
||||
raise ConflictError("exact host binding schema is invalid")
|
||||
return result
|
||||
|
||||
def _action_from_row(self, row):
|
||||
from .host_patch_executor import HostPatchAction
|
||||
|
||||
raw = str(row["host_binding_json"] or "")
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
if not raw or row["host_binding_digest"] != digest:
|
||||
raise ConflictError("exact host binding digest mismatch")
|
||||
host = self._validate_host_document(json.loads(raw))
|
||||
if host.get("effect_key") != row["host_effect_key"]:
|
||||
raise ConflictError("exact host effect key mismatch")
|
||||
lease = (
|
||||
json.loads(str(row["writer_lease_json"]))
|
||||
if row["writer_lease_json"] is not None
|
||||
else None
|
||||
)
|
||||
receipt = (
|
||||
json.loads(str(row["receipt_json"]))
|
||||
if row["receipt_json"] is not None
|
||||
else None
|
||||
)
|
||||
if row["state"] == "SUCCEEDED":
|
||||
state = "COMMITTED"
|
||||
elif row["state"] == "BLOCKED":
|
||||
state = "FAILED_FINAL"
|
||||
elif row["effect_phase"] == "BEFORE_LEASE":
|
||||
state = "PREPARED"
|
||||
else:
|
||||
state = "EFFECT_UNKNOWN"
|
||||
lease_id = None
|
||||
lease_fence = None
|
||||
if isinstance(lease, Mapping):
|
||||
lease_id = lease.get("writer_lease_id")
|
||||
lease_fence = lease.get("writer_lease_fence")
|
||||
return HostPatchAction(
|
||||
action_id=str(host["action_id"]),
|
||||
state=state,
|
||||
binding_digest=str(host["binding_digest"]),
|
||||
effect_key=str(host["effect_key"]),
|
||||
authorization_ref=str(host["authorization_ref"]),
|
||||
authority_key_id=str(host["authority_key_id"]),
|
||||
authority_key_fingerprint=str(host["authority_key_fingerprint"]),
|
||||
authority_record_sha256=str(host["authority_record_sha256"]),
|
||||
claim_id=str(host["claim_receipt"]["claim_id"]),
|
||||
claim_fence=int(host["claim_receipt"]["receipt_revision"]),
|
||||
task_generation=int(host["task_generation"]),
|
||||
authority_generation=int(host["authority_generation"]),
|
||||
expected_branch_oid=str(host["expected_branch_oid"]),
|
||||
claim_receipt=dict(host["claim_receipt"]),
|
||||
writer_lease_id=(str(lease_id) if lease_id is not None else None),
|
||||
writer_lease_fence=(int(lease_fence) if lease_fence is not None else None),
|
||||
result_oid=(
|
||||
str(receipt["commit_oid"])
|
||||
if isinstance(receipt, Mapping) and receipt.get("commit_oid") is not None
|
||||
else None
|
||||
),
|
||||
receipt_ref=(
|
||||
str(row["receipt_digest"])
|
||||
if row["receipt_digest"] is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def read_exact(self, effect_key: str):
|
||||
row = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_effects WHERE host_effect_key=?",
|
||||
(effect_key,),
|
||||
).fetchone()
|
||||
return None if row is None else self._action_from_row(row)
|
||||
|
||||
def iter_pending_exact(self, *, limit: int = 16):
|
||||
"""Enumerate durable host intents from the existing effect ledger."""
|
||||
from .operation_view import OperationBinding
|
||||
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 64:
|
||||
raise ConflictError("bounded exact pending limit required")
|
||||
rows = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_effects "
|
||||
"WHERE host_effect_key IS NOT NULL AND host_binding_json IS NOT NULL "
|
||||
"AND host_closure_digest IS NULL "
|
||||
"ORDER BY created_at,execution_key LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
action = self._action_from_row(row)
|
||||
if action.state not in {
|
||||
"PREPARED", "IN_PROGRESS", "EFFECT_UNKNOWN", "COMMITTED"
|
||||
}:
|
||||
continue
|
||||
raw = self._validate_host_document(
|
||||
json.loads(str(row["host_binding_json"]))
|
||||
).get("operation_binding")
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ConflictError("pending exact operation binding unavailable")
|
||||
try:
|
||||
operation = OperationBinding(**dict(raw))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ConflictError("pending exact operation binding invalid") from exc
|
||||
result.append((operation, action))
|
||||
return tuple(result)
|
||||
|
||||
def mark_exact_closed(self, *, binding, action_id: str, observation) -> None:
|
||||
from .operation_view import (
|
||||
ClosureObservation,
|
||||
binding_digest,
|
||||
derive_closure,
|
||||
)
|
||||
|
||||
if not isinstance(observation, ClosureObservation):
|
||||
raise ConflictError("typed exact host closure observation required")
|
||||
observed_at = self._clock()
|
||||
now = self._now(observed_at)
|
||||
closure = derive_closure(binding, observation, now=observed_at)
|
||||
if (
|
||||
closure.execution_recorded,
|
||||
closure.independently_verified,
|
||||
closure.business_accepted,
|
||||
closure.outcome,
|
||||
closure.reason_code,
|
||||
) != (True, True, True, "SUCCEEDED", "THREE_LAYER_CLOSURE_VALID"):
|
||||
raise ConflictError("exact host closure is incomplete")
|
||||
|
||||
with self.store._transaction():
|
||||
row = self.store.conn.execute(
|
||||
"SELECT * FROM task_root_action_effects WHERE host_effect_key=?",
|
||||
(binding.effect_key,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ConflictError("exact host action unavailable")
|
||||
action = self._action_from_row(row)
|
||||
expected_binding_digest = binding_digest(binding)
|
||||
effect = observation.effect
|
||||
verification = observation.verification
|
||||
acceptance = observation.acceptance
|
||||
verifier_receipt_ref = (
|
||||
verification.verification_receipt_ref
|
||||
or verification.receipt_ref
|
||||
)
|
||||
if (
|
||||
action.action_id != action_id
|
||||
or action.state != "COMMITTED"
|
||||
or action.binding_digest != expected_binding_digest
|
||||
or not action.receipt_ref
|
||||
or effect.binding_digest != expected_binding_digest
|
||||
or effect.receipt_ref != action.receipt_ref
|
||||
or effect.result_artifact_digest is None
|
||||
or verification.binding_digest != expected_binding_digest
|
||||
or verification.receipt_ref != action.receipt_ref
|
||||
or verification.result_artifact_digest
|
||||
!= effect.result_artifact_digest
|
||||
or not verifier_receipt_ref
|
||||
or acceptance.binding_digest != expected_binding_digest
|
||||
or acceptance.receipt_ref != action.receipt_ref
|
||||
or acceptance.result_artifact_digest
|
||||
!= effect.result_artifact_digest
|
||||
or acceptance.verifier_receipt_ref != verifier_receipt_ref
|
||||
):
|
||||
raise ConflictError("exact host closure/action binding mismatch")
|
||||
|
||||
observation_value = _canonical_observation_value(asdict(observation))
|
||||
observation_raw = canonical_json(observation_value)
|
||||
observation_digest = hashlib.sha256(
|
||||
observation_raw.encode("utf-8")
|
||||
).hexdigest()
|
||||
document = {
|
||||
"schema": "aios.c6.three-layer-closure.v2",
|
||||
"binding_digest": action.binding_digest,
|
||||
"effect_key": action.effect_key,
|
||||
"action_id": action.action_id,
|
||||
"root_receipt_ref": action.receipt_ref,
|
||||
"effect_receipt_ref": effect.receipt_ref,
|
||||
"result_artifact_digest": effect.result_artifact_digest,
|
||||
"verification_receipt_ref": verifier_receipt_ref,
|
||||
"verifier_id": verification.verifier_id,
|
||||
"acceptance_revision": acceptance.acceptance_revision,
|
||||
"acceptor_id": acceptance.acceptor_id,
|
||||
"expected_acceptance_revision": (
|
||||
observation.expected_acceptance_revision
|
||||
),
|
||||
"expected_acceptor_id": observation.expected_acceptor_id,
|
||||
"closure_observation_digest": observation_digest,
|
||||
"execution_recorded": closure.execution_recorded,
|
||||
"independently_verified": closure.independently_verified,
|
||||
"business_accepted": closure.business_accepted,
|
||||
"outcome": closure.outcome,
|
||||
"reason_code": closure.reason_code,
|
||||
}
|
||||
raw = canonical_json(document)
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
changed = self.store.conn.execute(
|
||||
"UPDATE task_root_action_effects SET host_closure_json=?,"
|
||||
"host_closure_digest=?,host_closure_observed_at=?,updated_at=? "
|
||||
"WHERE host_effect_key=? AND state='SUCCEEDED' "
|
||||
"AND host_binding_json=? AND host_binding_digest=? "
|
||||
"AND receipt_digest=? AND host_closure_digest IS NULL",
|
||||
(
|
||||
raw, digest, now, now, binding.effect_key,
|
||||
row["host_binding_json"], row["host_binding_digest"],
|
||||
action.receipt_ref,
|
||||
),
|
||||
)
|
||||
if changed.rowcount != 1:
|
||||
current = self.store.conn.execute(
|
||||
"SELECT host_closure_json,host_closure_digest "
|
||||
"FROM task_root_action_effects WHERE host_effect_key=?",
|
||||
(binding.effect_key,),
|
||||
).fetchone()
|
||||
if (
|
||||
current is None
|
||||
or current["host_closure_json"] != raw
|
||||
or current["host_closure_digest"] != digest
|
||||
):
|
||||
raise ConflictError("exact host closure CAS conflict")
|
||||
|
||||
@staticmethod
|
||||
def _assert_action_authority(action, binding, authority) -> None:
|
||||
from .operation_view import binding_digest
|
||||
|
||||
if (
|
||||
action.binding_digest != binding_digest(binding)
|
||||
or action.effect_key != binding.effect_key
|
||||
or 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.task_generation != authority.task_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 ConflictError("exact host action authority mismatch")
|
||||
|
||||
def prepare_exact(
|
||||
self,
|
||||
*,
|
||||
binding,
|
||||
claim,
|
||||
authority,
|
||||
contract,
|
||||
patch_bytes: bytes,
|
||||
commit_message: str,
|
||||
):
|
||||
resolved = self._exact_resolution(binding)
|
||||
plan = self._exact_plan(binding, resolved)
|
||||
host = self._host_binding_document(
|
||||
binding=binding,
|
||||
claim=claim,
|
||||
authority=authority,
|
||||
contract=contract,
|
||||
patch_bytes=patch_bytes,
|
||||
commit_message=commit_message,
|
||||
plan=plan,
|
||||
)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
lease_seconds = max(1, min(60, int(authority.expires_at - now.timestamp())))
|
||||
result = self._run(
|
||||
consumer_id=f"c6:{claim.claim_id}",
|
||||
now=now,
|
||||
lease_seconds=lease_seconds,
|
||||
exact_binding=resolved,
|
||||
host_binding=host,
|
||||
prepare_only=True,
|
||||
)
|
||||
if result.detail != "PREPARED":
|
||||
raise ConflictError("exact root action was not prepared")
|
||||
action = self.read_exact(binding.effect_key)
|
||||
if action is None:
|
||||
raise ConflictError("prepared exact root action is unavailable")
|
||||
self._assert_action_authority(action, binding, authority)
|
||||
return action
|
||||
|
||||
def _claim_exact_existing(self, binding, action, authority, now: float):
|
||||
resolved = self._exact_resolution(binding)
|
||||
plan = self._exact_plan(binding, resolved)
|
||||
if plan.execution_key != action.action_id:
|
||||
raise ConflictError("exact root action identity changed")
|
||||
claimed = self._claim_next(
|
||||
consumer_id=f"c6:{action.claim_id}",
|
||||
now=now,
|
||||
lease_seconds=60,
|
||||
exact_binding=resolved,
|
||||
)
|
||||
if not isinstance(claimed, RootActionClaim):
|
||||
raise ConflictError("exact root action claim unavailable")
|
||||
self._assert_action_authority(action, binding, authority)
|
||||
return claimed
|
||||
|
||||
def reconcile_exact(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
binding,
|
||||
authority,
|
||||
before_external_call: Callable[[], None],
|
||||
):
|
||||
action = self.read_exact(binding.effect_key)
|
||||
if action is None or action.action_id != action_id:
|
||||
raise ConflictError("exact root action unavailable")
|
||||
self._assert_action_authority(action, binding, authority)
|
||||
if action.state in {"COMMITTED", "FAILED_FINAL", "PREPARED"}:
|
||||
return action
|
||||
now = self._now(self._clock())
|
||||
claim = self._claim_exact_existing(binding, action, authority, now)
|
||||
_state, _receipt, phase = self._prepare_effect(claim, now)
|
||||
if phase in {"BEFORE_LEASE", "LEASE_ACQUIRED"}:
|
||||
return self.read_exact(binding.effect_key)
|
||||
if self._executor is None:
|
||||
raise ConflictError("root action executor unavailable")
|
||||
|
||||
def revalidate() -> None:
|
||||
self._assert_live_claim(claim, now, renew=True)
|
||||
before_external_call()
|
||||
|
||||
revalidate()
|
||||
observation = self._executor.reconcile(
|
||||
claim.plan, before_external_call=revalidate
|
||||
)
|
||||
revalidate()
|
||||
result = self._record_observation(claim, observation, now)
|
||||
if result is None:
|
||||
self._rereview(claim, now)
|
||||
observed = self.read_exact(binding.effect_key)
|
||||
if observed is None:
|
||||
raise ConflictError("reconciled exact root action is unavailable")
|
||||
return observed
|
||||
|
||||
def execute_prepared(
|
||||
self,
|
||||
*,
|
||||
action_id: str,
|
||||
binding,
|
||||
claim,
|
||||
authority,
|
||||
contract,
|
||||
patch_bytes: bytes,
|
||||
commit_message: str,
|
||||
before_external_call: Callable[[], None],
|
||||
):
|
||||
action = self.read_exact(binding.effect_key)
|
||||
if action is None or action.action_id != action_id:
|
||||
raise ConflictError("exact root action unavailable")
|
||||
self._assert_action_authority(action, binding, authority)
|
||||
if action.state != "PREPARED":
|
||||
return action
|
||||
resolved = self._exact_resolution(binding)
|
||||
plan = self._exact_plan(binding, resolved)
|
||||
host = self._host_binding_document(
|
||||
binding=binding,
|
||||
claim=claim,
|
||||
authority=authority,
|
||||
contract=contract,
|
||||
patch_bytes=patch_bytes,
|
||||
commit_message=commit_message,
|
||||
plan=plan,
|
||||
)
|
||||
persisted = self.store.conn.execute(
|
||||
"SELECT host_binding_json FROM task_root_action_effects "
|
||||
"WHERE host_effect_key=?",
|
||||
(binding.effect_key,),
|
||||
).fetchone()
|
||||
if persisted is None or persisted["host_binding_json"] != canonical_json(host):
|
||||
raise ConflictError("prepared exact host binding changed")
|
||||
self._run(
|
||||
consumer_id=f"c6:{claim.claim_id}",
|
||||
now=self._clock(),
|
||||
lease_seconds=60,
|
||||
exact_binding=resolved,
|
||||
host_binding=host,
|
||||
external_revalidator=before_external_call,
|
||||
)
|
||||
completed = self.read_exact(binding.effect_key)
|
||||
if completed is None:
|
||||
raise ConflictError("exact root action completion unavailable")
|
||||
return completed
|
||||
|
||||
|
||||
class PublicBridgeRootActionExecutor:
|
||||
"""Contract adapter for the authenticated public Bridge operation surface."""
|
||||
@@ -1329,6 +2132,7 @@ class PublicBridgeRootActionExecutor:
|
||||
raise ConflictError("Bridge writer lease response binding mismatch")
|
||||
if "job_id" in result and result["job_id"] != plan.target_job_id:
|
||||
raise ConflictError("Bridge writer lease response Job mismatch")
|
||||
return dict(result)
|
||||
|
||||
def commit_effect(
|
||||
self,
|
||||
|
||||
@@ -15,7 +15,7 @@ claim transaction / repository operation.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
from typing import Any, Callable, Iterable, Mapping, Protocol, Sequence
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
@@ -94,6 +94,7 @@ class ClaimConditions:
|
||||
authorization_revision: str
|
||||
scope_digest: str
|
||||
capacity_limit: int
|
||||
operation_digest: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
@@ -121,6 +122,7 @@ class SchedulabilityObservation:
|
||||
dependencies_satisfied: bool
|
||||
freeze_blocked: bool
|
||||
conflicting_lease: bool
|
||||
operation_digest: str | None = None
|
||||
|
||||
@property
|
||||
def capacity_remaining(self) -> int:
|
||||
@@ -128,7 +130,7 @@ class SchedulabilityObservation:
|
||||
|
||||
def stable_binding(self) -> dict[str, object]:
|
||||
"""Fields whose change invalidates an earlier proof."""
|
||||
return {
|
||||
binding = {
|
||||
"task_id": self.task_id,
|
||||
"task_revision": self.task_revision,
|
||||
"task_generation": self.task_generation,
|
||||
@@ -149,6 +151,9 @@ class SchedulabilityObservation:
|
||||
"conflicting_lease": self.conflicting_lease,
|
||||
"heartbeat_fresh": self.heartbeat_fresh,
|
||||
}
|
||||
if self.operation_digest is not None:
|
||||
binding["operation_digest"] = self.operation_digest
|
||||
return binding
|
||||
|
||||
def claim_conditions(self) -> ClaimConditions:
|
||||
return ClaimConditions(
|
||||
@@ -161,6 +166,7 @@ class SchedulabilityObservation:
|
||||
authorization_revision=self.authorization_revision,
|
||||
scope_digest=self.scope_digest,
|
||||
capacity_limit=self.capacity_limit,
|
||||
operation_digest=self.operation_digest,
|
||||
)
|
||||
|
||||
|
||||
@@ -194,7 +200,11 @@ class SchedulabilityProof:
|
||||
if ttl_seconds <= 0 or ttl_seconds > 300:
|
||||
raise ValueError("schedulability proof ttl must be in (0,300]")
|
||||
payload = {
|
||||
"schema": "aios.schedulability-proof.v2",
|
||||
"schema": (
|
||||
"aios.schedulability-proof.v3"
|
||||
if observation.operation_digest is not None
|
||||
else "aios.schedulability-proof.v2"
|
||||
),
|
||||
"proof_id": proof_id or f"sched-{uuid.uuid4()}",
|
||||
"task_id": _text(task_id, "task_id"),
|
||||
"worker_id": _text(worker_id, "worker_id"),
|
||||
@@ -301,12 +311,18 @@ class SchedulabilityService:
|
||||
clock=time.time,
|
||||
proof_ttl_seconds: float = 30.0,
|
||||
heartbeat_grace_factor: float = 3.0,
|
||||
capability_matcher: Callable[[Iterable[str], Iterable[str]], bool] | None = None,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.proofs = proofs
|
||||
self.clock = clock
|
||||
self.proof_ttl_seconds = proof_ttl_seconds
|
||||
self.heartbeat_grace_factor = heartbeat_grace_factor
|
||||
if capability_matcher is None:
|
||||
from .capabilities import has_capabilities
|
||||
|
||||
capability_matcher = has_capabilities
|
||||
self.capability_matcher = capability_matcher
|
||||
|
||||
@staticmethod
|
||||
def _caps(value: object) -> tuple[str, ...]:
|
||||
@@ -366,9 +382,8 @@ class SchedulabilityService:
|
||||
blockers.append(capacity_error)
|
||||
if not fresh:
|
||||
blockers.append(Blocker("STALE_OR_INACTIVE_WORKER", "worker", "worker heartbeat is not fresh"))
|
||||
missing = sorted(set(required) - set(offered))
|
||||
if missing:
|
||||
blockers.append(Blocker("MISSING_CAPABILITY", "worker", ",".join(missing)))
|
||||
if not self.capability_matcher(offered, required):
|
||||
blockers.append(Blocker("MISSING_CAPABILITY", "worker", ",".join(required)))
|
||||
if not scope_ok:
|
||||
blockers.append(Blocker("EXACT_SCOPE_MISMATCH", "authorization", "worker/task signed scope mismatch"))
|
||||
if not auth_allowed:
|
||||
@@ -403,6 +418,11 @@ class SchedulabilityService:
|
||||
dependencies_satisfied=deps,
|
||||
freeze_blocked=freeze,
|
||||
conflicting_lease=lease,
|
||||
operation_digest=(
|
||||
str(task["operation_digest"])
|
||||
if task.get("operation_digest") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return obs, tuple(blockers)
|
||||
|
||||
@@ -468,7 +488,14 @@ class SchedulabilityService:
|
||||
return proof
|
||||
|
||||
@staticmethod
|
||||
def _validate_claim_receipt(result: Mapping[str, Any]) -> None:
|
||||
def _validate_claim_receipt(
|
||||
result: Mapping[str, Any], proof: SchedulabilityProof
|
||||
) -> None:
|
||||
if proof.schema == "aios.schedulability-proof.v3":
|
||||
from .schedulability_store_adapter import validate_task_plane_receipt
|
||||
|
||||
validate_task_plane_receipt(result, proof=proof)
|
||||
return
|
||||
required = ("claim_id", "lease_id", "fencing_generation", "receipt_revision")
|
||||
missing = [name for name in required if result.get(name) in (None, "")]
|
||||
if missing:
|
||||
@@ -493,6 +520,11 @@ class SchedulabilityService:
|
||||
raise SchedulabilityChanged("proof expired")
|
||||
if proof.worker_id != worker_id or proof.actor_identity != actor_identity:
|
||||
raise SchedulabilityChanged("proof identity mismatch")
|
||||
if (
|
||||
getattr(self.authority, "requires_v3_proof", False)
|
||||
and proof.schema != "aios.schedulability-proof.v3"
|
||||
):
|
||||
raise SchedulabilityChanged("v3 claim authority requires v3 proof")
|
||||
current, blockers = self.observe(proof.task_id, worker_id, actor_identity)
|
||||
if blockers:
|
||||
raise SchedulabilityChanged(
|
||||
@@ -509,5 +541,5 @@ class SchedulabilityService:
|
||||
lease_seconds=lease_seconds,
|
||||
schedulability_proof_digest=proof.proof_digest,
|
||||
)
|
||||
self._validate_claim_receipt(result)
|
||||
self._validate_claim_receipt(result, proof)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
"""Task Plane-backed atomic schedulability authority for the C6 pilot.
|
||||
|
||||
This adapter introduces no lease or authority database. It is enabled only
|
||||
when a host supplies a binding that holds the TaskPlane transaction together
|
||||
with its real authorization and freeze boundary.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Callable, Iterable, Literal, Mapping, Protocol
|
||||
import math
|
||||
import time
|
||||
|
||||
from .capabilities import has_capabilities
|
||||
from .errors import ConflictError, LockedError, ServiceUnavailableError
|
||||
from .schedulability import ClaimConditions, SchedulabilityChanged, SchedulabilityProof
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
_RECEIPT_SCHEMA = "aios.task-plane-claim-receipt.v3"
|
||||
_RECEIPT_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"authority_namespace",
|
||||
"task_id",
|
||||
"claim_id",
|
||||
"worker_id",
|
||||
"worker_identity",
|
||||
"claimed_revision",
|
||||
"receipt_revision",
|
||||
"attempt",
|
||||
"operation_digest",
|
||||
"lease_until",
|
||||
"proof_digest",
|
||||
}
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthorityAtomicBindingUnavailable",
|
||||
"ClaimAuthorityBinding",
|
||||
"ClaimTransaction",
|
||||
"ScopedLeaseController",
|
||||
"TaskPlaneClaimReceiptV3",
|
||||
"TaskPlaneSchedulabilityAuthority",
|
||||
"validate_task_plane_receipt",
|
||||
]
|
||||
|
||||
|
||||
class AuthorityAtomicBindingUnavailable(ServiceUnavailableError):
|
||||
code = "AUTHORITY_ATOMIC_BINDING_UNAVAILABLE"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(self.code)
|
||||
|
||||
|
||||
class ClaimTransaction(Protocol):
|
||||
"""A host-owned context that holds the store and authority lock together."""
|
||||
|
||||
store: TaskPlaneStore
|
||||
authority_id: str
|
||||
|
||||
def assert_conditions(
|
||||
self,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
expected: ClaimConditions,
|
||||
) -> None: ...
|
||||
|
||||
def assert_lease_holder(
|
||||
self, task_id: str, claim_id: str, worker_id: str, actor_identity: str
|
||||
) -> None: ...
|
||||
|
||||
def assert_lease_authorized(
|
||||
self,
|
||||
task_id: str,
|
||||
claim_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
receipt: TaskPlaneClaimReceiptV3,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class ClaimAuthorityBinding(Protocol):
|
||||
"""Host integration point; request-provided callbacks are never accepted."""
|
||||
|
||||
def claim_context(self, store: TaskPlaneStore) -> AbstractContextManager[ClaimTransaction]: ...
|
||||
|
||||
def read_authorization(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def exact_scope_digest(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> tuple[bool, str]: ...
|
||||
|
||||
def freeze_blocked(self, task: Mapping[str, Any]) -> bool: ...
|
||||
|
||||
|
||||
_BINDING_METHODS = (
|
||||
"claim_context",
|
||||
"read_authorization",
|
||||
"exact_scope_digest",
|
||||
"freeze_blocked",
|
||||
)
|
||||
|
||||
|
||||
def _require_binding(store: TaskPlaneStore, binding: ClaimAuthorityBinding) -> None:
|
||||
"""Reject a partial host wiring before it can produce a lease effect."""
|
||||
if store is None or binding is None or any(
|
||||
not callable(getattr(binding, name, None)) for name in _BINDING_METHODS
|
||||
):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskPlaneClaimReceiptV3:
|
||||
schema: Literal["aios.task-plane-claim-receipt.v3"]
|
||||
authority_namespace: Literal["task-plane"]
|
||||
task_id: str
|
||||
claim_id: str
|
||||
worker_id: str
|
||||
worker_identity: str
|
||||
claimed_revision: int
|
||||
receipt_revision: int
|
||||
attempt: int
|
||||
operation_digest: str
|
||||
lease_until: float
|
||||
proof_digest: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _text(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise SchedulabilityChanged(f"invalid v3 receipt {name}")
|
||||
return value
|
||||
|
||||
|
||||
def _int(value: object, name: str, *, minimum: int = 0) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||||
raise SchedulabilityChanged(f"invalid v3 receipt {name}")
|
||||
return value
|
||||
|
||||
|
||||
def _float(value: object, name: str) -> float:
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or not isinstance(value, (int, float))
|
||||
or not math.isfinite(float(value))
|
||||
or float(value) <= 0
|
||||
):
|
||||
raise SchedulabilityChanged(f"invalid v3 receipt {name}")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _parse_receipt(result: Mapping[str, object]) -> TaskPlaneClaimReceiptV3:
|
||||
if not isinstance(result, Mapping) or set(result) != _RECEIPT_FIELDS:
|
||||
raise SchedulabilityChanged("invalid v3 claim receipt fields")
|
||||
if result["schema"] != _RECEIPT_SCHEMA or result["authority_namespace"] != "task-plane":
|
||||
raise SchedulabilityChanged("invalid v3 claim receipt namespace")
|
||||
return TaskPlaneClaimReceiptV3(
|
||||
schema=_RECEIPT_SCHEMA,
|
||||
authority_namespace="task-plane",
|
||||
task_id=_text(result["task_id"], "task_id"),
|
||||
claim_id=_text(result["claim_id"], "claim_id"),
|
||||
worker_id=_text(result["worker_id"], "worker_id"),
|
||||
worker_identity=_text(result["worker_identity"], "worker_identity"),
|
||||
claimed_revision=_int(result["claimed_revision"], "claimed_revision"),
|
||||
receipt_revision=_int(result["receipt_revision"], "receipt_revision"),
|
||||
attempt=_int(result["attempt"], "attempt", minimum=1),
|
||||
operation_digest=_text(result["operation_digest"], "operation_digest"),
|
||||
lease_until=_float(result["lease_until"], "lease_until"),
|
||||
proof_digest=_text(result["proof_digest"], "proof_digest"),
|
||||
)
|
||||
|
||||
|
||||
def validate_task_plane_receipt(
|
||||
result: Mapping[str, object], *, proof: SchedulabilityProof
|
||||
) -> TaskPlaneClaimReceiptV3:
|
||||
"""Validate the C6 receipt without treating it as a WriterLease fence."""
|
||||
if proof.schema != "aios.schedulability-proof.v3":
|
||||
raise SchedulabilityChanged("v3 receipt requires v3 proof")
|
||||
receipt = _parse_receipt(result)
|
||||
if (
|
||||
receipt.task_id != proof.task_id
|
||||
or receipt.worker_id != proof.worker_id
|
||||
or receipt.worker_identity != proof.observation.worker_identity
|
||||
or receipt.proof_digest != proof.proof_digest
|
||||
or receipt.operation_digest != proof.observation.operation_digest
|
||||
or receipt.receipt_revision <= receipt.claimed_revision
|
||||
or receipt.lease_until <= proof.created_at
|
||||
):
|
||||
raise SchedulabilityChanged("v3 claim receipt does not bind proof")
|
||||
return receipt
|
||||
|
||||
|
||||
class TaskPlaneSchedulabilityAuthority:
|
||||
"""Use the existing TaskPlane store as the only claim/lease authority."""
|
||||
|
||||
requires_v3_proof = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: TaskPlaneStore,
|
||||
binding: ClaimAuthorityBinding,
|
||||
capability_matcher: Callable[[Iterable[str], Iterable[str]], bool] = has_capabilities,
|
||||
) -> None:
|
||||
_require_binding(store, binding)
|
||||
if not callable(capability_matcher):
|
||||
raise TypeError("capability_matcher must be callable")
|
||||
self.store = store
|
||||
self.binding = binding
|
||||
self.capability_matcher = capability_matcher
|
||||
|
||||
@staticmethod
|
||||
def _task_view(task: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"revision": task.revision,
|
||||
"generation": task.task_plan.metadata.get("generation", 0),
|
||||
"status": task.status,
|
||||
"required_capabilities": list(task.task_plan.required_capabilities),
|
||||
"operation_digest": task.task_plan.operation_digest(),
|
||||
"priority": task.task_plan.metadata.get("priority", 0),
|
||||
"created_at": task.created_at,
|
||||
"metadata": dict(task.task_plan.metadata),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _worker_view(worker: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"worker_id": worker.worker_id,
|
||||
"identity": worker.identity,
|
||||
"capabilities": list(worker.capabilities),
|
||||
"status": worker.status,
|
||||
"disabled": worker.disabled,
|
||||
"registration_revision": worker.registration_revision,
|
||||
"max_concurrency": worker.max_concurrency,
|
||||
"heartbeat_interval_seconds": worker.heartbeat_interval_seconds,
|
||||
"last_heartbeat": worker.last_heartbeat,
|
||||
}
|
||||
|
||||
def read_task(self, task_id: str) -> Mapping[str, Any]:
|
||||
return self._task_view(self.store.get_task(task_id))
|
||||
|
||||
def list_candidate_tasks(self) -> list[Mapping[str, Any]]:
|
||||
return [self._task_view(task) for task in self.store.list_tasks()]
|
||||
|
||||
def read_worker(self, worker_id: str) -> Mapping[str, Any]:
|
||||
return self._worker_view(self.store.get_worker(worker_id))
|
||||
|
||||
def read_authorization(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> Mapping[str, Any]:
|
||||
value = self.binding.read_authorization(
|
||||
task=task, worker=worker, actor_identity=actor_identity
|
||||
)
|
||||
if (
|
||||
not isinstance(value, Mapping)
|
||||
or not isinstance(value.get("allowed"), bool)
|
||||
or not isinstance(value.get("authorization_ref"), str)
|
||||
or not value["authorization_ref"]
|
||||
or not isinstance(value.get("revision"), str)
|
||||
or not value["revision"]
|
||||
):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
return value
|
||||
|
||||
def active_claim_count(self, worker_id: str) -> int:
|
||||
with self.store._lock:
|
||||
return int(
|
||||
self.store.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(worker_id, time.time()),
|
||||
).fetchone()[0]
|
||||
)
|
||||
|
||||
def dependencies_satisfied(self, task: Mapping[str, Any]) -> bool:
|
||||
record = self.store.get_task(str(task["task_id"]))
|
||||
return all(
|
||||
self.store.get_task(dependency_id).status == "COMPLETED"
|
||||
for dependency_id in record.task_plan.dependencies
|
||||
)
|
||||
|
||||
def freeze_blocked(self, task: Mapping[str, Any]) -> bool:
|
||||
# Only the exact boolean False proves the absence of a freeze. An
|
||||
# unavailable or malformed authority value must deny an automatic claim.
|
||||
return self.binding.freeze_blocked(task) is not False
|
||||
|
||||
def conflicting_lease(self, task_id: str) -> bool:
|
||||
return self.store.active_claim(task_id) is not None
|
||||
|
||||
def exact_scope_digest(
|
||||
self, *, task: Mapping[str, Any], worker: Mapping[str, Any], actor_identity: str
|
||||
) -> tuple[bool, str]:
|
||||
result = self.binding.exact_scope_digest(
|
||||
task=task, worker=worker, actor_identity=actor_identity
|
||||
)
|
||||
if (
|
||||
not isinstance(result, tuple)
|
||||
or len(result) != 2
|
||||
or not isinstance(result[0], bool)
|
||||
or not isinstance(result[1], str)
|
||||
or not result[1]
|
||||
):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
return result
|
||||
|
||||
def claim_atomically(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
expected: ClaimConditions,
|
||||
lease_seconds: int,
|
||||
schedulability_proof_digest: str,
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(lease_seconds, int) or isinstance(lease_seconds, bool) or lease_seconds <= 0:
|
||||
raise SchedulabilityChanged("invalid claim lease duration")
|
||||
with self.binding.claim_context(self.store) as transaction:
|
||||
if transaction.store is not self.store or not getattr(transaction, "authority_id", ""):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
transaction.assert_conditions(task_id, worker_id, actor_identity, expected)
|
||||
task = self.store.get_task(task_id)
|
||||
worker = self.store.get_worker(worker_id)
|
||||
operation_digest = task.task_plan.operation_digest()
|
||||
generation = task.task_plan.metadata.get("generation", 0)
|
||||
task_view = self._task_view(task)
|
||||
worker_view = self._worker_view(worker)
|
||||
authorization = self.read_authorization(
|
||||
task=task_view, worker=worker_view, actor_identity=actor_identity
|
||||
)
|
||||
scope = self.exact_scope_digest(
|
||||
task=task_view, worker=worker_view, actor_identity=actor_identity
|
||||
)
|
||||
if (
|
||||
task.revision != expected.task_revision
|
||||
or task.status != expected.task_status
|
||||
or generation != expected.task_generation
|
||||
or worker.registration_revision != expected.worker_generation
|
||||
or worker.identity != expected.worker_identity
|
||||
or worker.max_concurrency != expected.capacity_limit
|
||||
or (expected.operation_digest is not None and operation_digest != expected.operation_digest)
|
||||
or not self.capability_matcher(worker.capabilities, task.task_plan.required_capabilities)
|
||||
or worker.disabled
|
||||
or worker.status != "active"
|
||||
or authorization["allowed"] is not True
|
||||
or authorization["authorization_ref"] != expected.authorization_ref
|
||||
or authorization["revision"] != expected.authorization_revision
|
||||
or scope != (True, expected.scope_digest)
|
||||
or self.freeze_blocked(task_view)
|
||||
or not all(
|
||||
self.store.get_task(dependency_id).status == "COMPLETED"
|
||||
for dependency_id in task.task_plan.dependencies
|
||||
)
|
||||
or self.store.active_claim(task_id) is not None
|
||||
or self.active_claim_count(worker_id) >= worker.max_concurrency
|
||||
):
|
||||
raise ConflictError("atomic claim condition changed")
|
||||
claim_internal = getattr(self.store, "_claim_task_in_transaction", None)
|
||||
if not callable(claim_internal):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
claim = claim_internal(
|
||||
task_id=task_id,
|
||||
worker_id=worker_id,
|
||||
worker_identity=actor_identity,
|
||||
lease_seconds=lease_seconds,
|
||||
expected_revision=expected.task_revision,
|
||||
operation_digest=operation_digest,
|
||||
proof_digest=schedulability_proof_digest,
|
||||
expected_binding=expected.to_dict(),
|
||||
)
|
||||
post_claim = self.store.get_task(task_id)
|
||||
receipt = TaskPlaneClaimReceiptV3(
|
||||
schema=_RECEIPT_SCHEMA,
|
||||
authority_namespace="task-plane",
|
||||
task_id=claim.task_id,
|
||||
claim_id=claim.claim_id,
|
||||
worker_id=claim.worker_id,
|
||||
worker_identity=claim.worker_identity,
|
||||
claimed_revision=claim.task_revision,
|
||||
receipt_revision=post_claim.revision,
|
||||
attempt=claim.attempt,
|
||||
operation_digest=claim.operation_digest,
|
||||
lease_until=claim.lease_until,
|
||||
proof_digest=schedulability_proof_digest,
|
||||
)
|
||||
return receipt.to_dict()
|
||||
|
||||
|
||||
class ScopedLeaseController:
|
||||
"""Renew an existing TaskPlane claim; it never creates a second lease."""
|
||||
|
||||
def __init__(self, store: TaskPlaneStore, binding: ClaimAuthorityBinding) -> None:
|
||||
_require_binding(store, binding)
|
||||
self.store = store
|
||||
self.binding = binding
|
||||
|
||||
def _validate_persisted_claim_evidence(
|
||||
self,
|
||||
current: TaskPlaneClaimReceiptV3,
|
||||
*,
|
||||
active: Any,
|
||||
task: Any,
|
||||
worker: Any,
|
||||
) -> Mapping[str, object]:
|
||||
"""Bind a C6 receipt to the durable claim decision, not its caller input."""
|
||||
# The claim row is created before the task's CLAIMED transition. The
|
||||
# transition is the only source for a v3 receipt revision; accepting a
|
||||
# larger caller-provided value would let a legacy claim masquerade as
|
||||
# a C6 claim.
|
||||
if current.receipt_revision != active.task_revision + 1:
|
||||
raise ConflictError("receipt revision does not bind active claim")
|
||||
expected_task_revision = (
|
||||
current.receipt_revision
|
||||
if task.status == "CLAIMED"
|
||||
else current.receipt_revision + 1
|
||||
)
|
||||
if task.revision != expected_task_revision:
|
||||
raise ConflictError("receipt revision does not bind task state")
|
||||
|
||||
# A C6 proof digest is durable only when it appears in the exact
|
||||
# TASK_CLAIMED event for this claim. The legacy claim path records no
|
||||
# proof digest, so it cannot be upgraded by assembling v3-shaped JSON.
|
||||
if not self.store.verify_event_chain(current.task_id):
|
||||
raise ConflictError("claim event chain verification failed")
|
||||
claim_events = [
|
||||
event
|
||||
for event in self.store.list_events(current.task_id)
|
||||
if (
|
||||
event.get("type") == "TASK_CLAIMED"
|
||||
and isinstance(event.get("data"), Mapping)
|
||||
and event["data"].get("claim_id") == current.claim_id
|
||||
and event["data"].get("worker_id") == current.worker_id
|
||||
and event["data"].get("proof_digest") == current.proof_digest
|
||||
)
|
||||
]
|
||||
if len(claim_events) != 1:
|
||||
raise ConflictError("receipt proof is not bound to claimed event")
|
||||
expected_binding = claim_events[0]["data"].get("expected_binding")
|
||||
required_binding_fields = {
|
||||
"task_revision",
|
||||
"task_generation",
|
||||
"task_status",
|
||||
"worker_generation",
|
||||
"worker_identity",
|
||||
"authorization_ref",
|
||||
"authorization_revision",
|
||||
"scope_digest",
|
||||
"capacity_limit",
|
||||
"operation_digest",
|
||||
}
|
||||
if not isinstance(expected_binding, Mapping) or set(expected_binding) != required_binding_fields:
|
||||
raise ConflictError("claim lacks durable C6 authority binding")
|
||||
task_generation = task.task_plan.metadata.get("generation", 0)
|
||||
if (
|
||||
isinstance(expected_binding["task_revision"], bool)
|
||||
or not isinstance(expected_binding["task_revision"], int)
|
||||
or expected_binding["task_revision"] < 0
|
||||
or expected_binding["task_revision"] != current.claimed_revision
|
||||
or expected_binding["task_revision"] != active.task_revision
|
||||
or isinstance(expected_binding["task_generation"], bool)
|
||||
or not isinstance(expected_binding["task_generation"], int)
|
||||
or expected_binding["task_generation"] != task_generation
|
||||
or expected_binding["task_status"] not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}
|
||||
or isinstance(expected_binding["worker_generation"], bool)
|
||||
or not isinstance(expected_binding["worker_generation"], int)
|
||||
or expected_binding["worker_generation"] < 1
|
||||
or expected_binding["worker_identity"] != current.worker_identity
|
||||
or isinstance(expected_binding["capacity_limit"], bool)
|
||||
or not isinstance(expected_binding["capacity_limit"], int)
|
||||
or expected_binding["capacity_limit"] < 1
|
||||
or expected_binding["capacity_limit"] != worker.max_concurrency
|
||||
or not isinstance(expected_binding["operation_digest"], str)
|
||||
or expected_binding["operation_digest"] != current.operation_digest
|
||||
or not isinstance(expected_binding["authorization_ref"], str)
|
||||
or not expected_binding["authorization_ref"]
|
||||
or not isinstance(expected_binding["authorization_revision"], str)
|
||||
or not expected_binding["authorization_revision"]
|
||||
or not isinstance(expected_binding["scope_digest"], str)
|
||||
or not expected_binding["scope_digest"]
|
||||
):
|
||||
raise ConflictError("durable C6 claim binding changed")
|
||||
|
||||
# The scheduler decision retains the remaining immutable claim facts.
|
||||
# Requiring exactly one matching decision also rejects a proof digest
|
||||
# copied between attempts for the same task.
|
||||
decision_payload = {
|
||||
"worker_id": current.worker_id,
|
||||
"worker_identity": current.worker_identity,
|
||||
"operation_digest": current.operation_digest,
|
||||
"attempt": current.attempt,
|
||||
"proof_digest": current.proof_digest,
|
||||
}
|
||||
decision_events = [
|
||||
event
|
||||
for event in self.store.list_events(current.task_id)
|
||||
if event.get("type") == "SCHEDULE_DECISION" and event.get("data") == decision_payload
|
||||
]
|
||||
if len(decision_events) != 1:
|
||||
raise ConflictError("receipt proof is not bound to schedule decision")
|
||||
self._validate_worker_heartbeat_chain(
|
||||
bound_revision=expected_binding["worker_generation"],
|
||||
worker=worker,
|
||||
claim_actor_identity=current.worker_identity,
|
||||
)
|
||||
return expected_binding
|
||||
|
||||
def _validate_worker_heartbeat_chain(
|
||||
self,
|
||||
*,
|
||||
bound_revision: object,
|
||||
worker: Any,
|
||||
claim_actor_identity: str,
|
||||
) -> None:
|
||||
"""Allow only a complete run of ordinary worker heartbeats after claim.
|
||||
|
||||
Worker registration revisions advance on every worker heartbeat. The
|
||||
worker audit stream is therefore the authoritative distinction between
|
||||
liveness refresh and re-registration or privilege change.
|
||||
"""
|
||||
current_revision = worker.registration_revision
|
||||
if (
|
||||
isinstance(bound_revision, bool)
|
||||
or not isinstance(bound_revision, int)
|
||||
or isinstance(current_revision, bool)
|
||||
or not isinstance(current_revision, int)
|
||||
or current_revision < bound_revision
|
||||
):
|
||||
raise ConflictError("worker registration revision changed")
|
||||
if current_revision == bound_revision:
|
||||
return
|
||||
|
||||
audit_task_id = "task-worker-registry"
|
||||
if not self.store.verify_event_chain(audit_task_id):
|
||||
raise ConflictError("worker registry event chain verification failed")
|
||||
expected_revisions = list(range(bound_revision + 1, current_revision + 1))
|
||||
events = []
|
||||
for event in self.store.list_events(audit_task_id):
|
||||
data = event.get("data")
|
||||
if not isinstance(data, Mapping) or data.get("worker_id") != worker.worker_id:
|
||||
continue
|
||||
revision = data.get("registration_revision")
|
||||
if isinstance(revision, bool) or not isinstance(revision, int) or revision <= bound_revision:
|
||||
continue
|
||||
events.append((event, data, revision))
|
||||
if len(events) != len(expected_revisions):
|
||||
raise ConflictError("worker registration audit chain incomplete")
|
||||
for expected_revision, (event, data, revision) in zip(expected_revisions, events):
|
||||
if (
|
||||
event.get("type") != "WORKER_HEARTBEAT"
|
||||
or set(data) != {"worker_id", "actor_identity", "registration_revision"}
|
||||
or data.get("actor_identity") != claim_actor_identity
|
||||
or revision != expected_revision
|
||||
):
|
||||
raise ConflictError("worker registration changed after C6 claim")
|
||||
|
||||
def _validate_active_receipt(
|
||||
self,
|
||||
transaction: ClaimTransaction,
|
||||
current: TaskPlaneClaimReceiptV3,
|
||||
*,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
) -> None:
|
||||
"""Validate a receipt without extending its lease or granting a new one."""
|
||||
if transaction.store is not self.store or not getattr(transaction, "authority_id", ""):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
holder_checker = getattr(transaction, "assert_lease_holder", None)
|
||||
authorization_checker = getattr(transaction, "assert_lease_authorized", None)
|
||||
if not callable(holder_checker) or not callable(authorization_checker):
|
||||
raise AuthorityAtomicBindingUnavailable()
|
||||
holder_checker(current.task_id, current.claim_id, worker_id, actor_identity)
|
||||
|
||||
# ``active_claim`` uses the store's authoritative clock and status
|
||||
# filter. Do not trust a receipt's former lease expiry by itself.
|
||||
active = self.store.active_claim(current.task_id)
|
||||
if active is None:
|
||||
raise LockedError("lease expired or inactive")
|
||||
if (
|
||||
active.claim_id != current.claim_id
|
||||
or active.task_id != current.task_id
|
||||
or active.worker_id != worker_id
|
||||
or active.worker_identity != actor_identity
|
||||
or active.task_revision != current.claimed_revision
|
||||
or active.operation_digest != current.operation_digest
|
||||
or active.attempt != current.attempt
|
||||
or active.lease_until <= time.time()
|
||||
):
|
||||
raise ConflictError("active claim does not match receipt")
|
||||
|
||||
task = self.store.get_task(current.task_id)
|
||||
worker = self.store.get_worker(worker_id)
|
||||
expected_binding = self._validate_persisted_claim_evidence(
|
||||
current, active=active, task=task, worker=worker
|
||||
)
|
||||
task_view = TaskPlaneSchedulabilityAuthority._task_view(task)
|
||||
worker_view = TaskPlaneSchedulabilityAuthority._worker_view(worker)
|
||||
authorization = self.binding.read_authorization(
|
||||
task=task_view, worker=worker_view, actor_identity=actor_identity
|
||||
)
|
||||
scope = self.binding.exact_scope_digest(
|
||||
task=task_view, worker=worker_view, actor_identity=actor_identity
|
||||
)
|
||||
if (
|
||||
task.status not in {"CLAIMED", "RUNNING"}
|
||||
or task.task_plan.operation_digest() != current.operation_digest
|
||||
or not isinstance(authorization, Mapping)
|
||||
or authorization.get("allowed") is not True
|
||||
or authorization.get("authorization_ref") != expected_binding["authorization_ref"]
|
||||
or authorization.get("revision") != expected_binding["authorization_revision"]
|
||||
or not isinstance(scope, tuple)
|
||||
or len(scope) != 2
|
||||
or scope[0] is not True
|
||||
or not isinstance(scope[1], str)
|
||||
or not scope[1]
|
||||
or scope[1] != expected_binding["scope_digest"]
|
||||
or self.binding.freeze_blocked(task_view) is not False
|
||||
or worker.disabled
|
||||
or worker.status != "active"
|
||||
or worker.identity != current.worker_identity
|
||||
or not has_capabilities(worker.capabilities, task.task_plan.required_capabilities)
|
||||
):
|
||||
raise ConflictError("lease authority changed")
|
||||
authorization_checker(
|
||||
current.task_id, current.claim_id, worker_id, actor_identity, current
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def execution_guard(
|
||||
self,
|
||||
receipt: Mapping[str, object],
|
||||
*,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
):
|
||||
"""Hold the authoritative claim binding through one pilot critical section.
|
||||
|
||||
This guard never calls ``heartbeat_claim`` and never makes a new claim.
|
||||
Its context remains open for the caller's critical section, so another
|
||||
same-store state change cannot interleave between validation and use.
|
||||
"""
|
||||
current = _parse_receipt(receipt)
|
||||
if current.worker_id != worker_id or current.worker_identity != actor_identity:
|
||||
raise SchedulabilityChanged("lease holder mismatch")
|
||||
with self.binding.claim_context(self.store) as transaction:
|
||||
self._validate_active_receipt(
|
||||
transaction, current, worker_id=worker_id, actor_identity=actor_identity
|
||||
)
|
||||
yield current
|
||||
|
||||
def renew(
|
||||
self,
|
||||
receipt: Mapping[str, object],
|
||||
*,
|
||||
worker_id: str,
|
||||
actor_identity: str,
|
||||
lease_seconds: int,
|
||||
) -> Mapping[str, object]:
|
||||
current = _parse_receipt(receipt)
|
||||
if current.worker_id != worker_id or current.worker_identity != actor_identity:
|
||||
raise SchedulabilityChanged("lease holder mismatch")
|
||||
with self.binding.claim_context(self.store) as transaction:
|
||||
self._validate_active_receipt(
|
||||
transaction, current, worker_id=worker_id, actor_identity=actor_identity
|
||||
)
|
||||
renewed = self.store.heartbeat_claim(
|
||||
current.task_id,
|
||||
current.claim_id,
|
||||
lease_seconds,
|
||||
expected_task_revision=current.claimed_revision,
|
||||
operation_digest=current.operation_digest,
|
||||
)
|
||||
return TaskPlaneClaimReceiptV3(
|
||||
schema=_RECEIPT_SCHEMA,
|
||||
authority_namespace="task-plane",
|
||||
task_id=renewed.task_id,
|
||||
claim_id=renewed.claim_id,
|
||||
worker_id=renewed.worker_id,
|
||||
worker_identity=renewed.worker_identity,
|
||||
claimed_revision=renewed.task_revision,
|
||||
receipt_revision=current.receipt_revision,
|
||||
attempt=renewed.attempt,
|
||||
operation_digest=renewed.operation_digest,
|
||||
lease_until=renewed.lease_until,
|
||||
proof_digest=current.proof_digest,
|
||||
).to_dict()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""S5.1 live authority adapters with exact caller/candidate binding.
|
||||
|
||||
These adapters are projections over existing authorities. They do not create
|
||||
tasks, grants, leases, branches, or recovery authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from .governance_admission import AdmissionOperation, GovernanceFacts
|
||||
from .governance_recovery_controller import GovernanceEvent
|
||||
from .governance_resolver import ExactTaskResolver, ResolutionQuery, ResolutionResult
|
||||
|
||||
|
||||
class LiveSurfaceUnavailable(RuntimeError):
|
||||
def __init__(self, role: str, reason: str) -> None:
|
||||
self.role = role
|
||||
self.reason = reason
|
||||
super().__init__(f"LIVE_SURFACE_UNAVAILABLE[{role}]: {reason}")
|
||||
|
||||
|
||||
class LiveBindingViolation(RuntimeError):
|
||||
def __init__(self, role: str, reason: str) -> None:
|
||||
self.role = role
|
||||
self.reason = reason
|
||||
super().__init__(f"LIVE_BINDING_VIOLATION[{role}]: {reason}")
|
||||
|
||||
|
||||
class TaskPlaneReadClient(Protocol):
|
||||
def get_task(
|
||||
self, task_id: str, *, actor_identity: str
|
||||
) -> Mapping[str, Any] | None: ...
|
||||
|
||||
def list_tasks(
|
||||
self, *, actor_identity: str
|
||||
) -> list[Mapping[str, Any]]: ...
|
||||
|
||||
|
||||
class LiveTaskResolverAdapter:
|
||||
"""Bind ExactTaskResolver to one caller-authorized TaskPlane projection."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: TaskPlaneReadClient | None = None,
|
||||
actor_identity: str | None = None,
|
||||
bound_repository: str | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._actor_identity = actor_identity
|
||||
self._bound_repository = bound_repository
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self._client is not None and bool(self._actor_identity)
|
||||
|
||||
def resolve(
|
||||
self, query: ResolutionQuery, *, actor_identity: str
|
||||
) -> ResolutionResult:
|
||||
if not self.live:
|
||||
raise LiveSurfaceUnavailable(
|
||||
"A1",
|
||||
"no caller-authorized TaskPlaneReadClient and bound identity",
|
||||
)
|
||||
if actor_identity != self._actor_identity:
|
||||
raise LiveBindingViolation(
|
||||
"A1",
|
||||
"controller actor_identity does not match bound read principal",
|
||||
)
|
||||
if (
|
||||
self._bound_repository
|
||||
and query.repository != self._bound_repository
|
||||
):
|
||||
raise LiveBindingViolation(
|
||||
"A1",
|
||||
"query repository is outside the bound authority projection",
|
||||
)
|
||||
return ExactTaskResolver(self._client).resolve(
|
||||
query, actor_identity=self._actor_identity
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedAuthoritySnapshot:
|
||||
"""One revision-consistent, candidate-specific governed read."""
|
||||
|
||||
task_id: str
|
||||
actor_identity: str
|
||||
operation: AdmissionOperation
|
||||
snapshot_revision: str
|
||||
facts: GovernanceFacts
|
||||
|
||||
|
||||
class GovernedAuthorityReadClient(Protocol):
|
||||
def read_snapshot(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
actor_identity: str,
|
||||
operation: AdmissionOperation,
|
||||
) -> GovernedAuthoritySnapshot: ...
|
||||
|
||||
|
||||
class LiveGovernanceFactsAdapter:
|
||||
"""Materialize GovernanceFacts from one atomic/revision-consistent read."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: GovernedAuthorityReadClient | None = None,
|
||||
actor_identity: str | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._actor_identity = actor_identity
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self._client is not None and bool(self._actor_identity)
|
||||
|
||||
def snapshot(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
actor_identity: str,
|
||||
operation: AdmissionOperation,
|
||||
) -> GovernanceFacts:
|
||||
if not self.live:
|
||||
raise LiveSurfaceUnavailable(
|
||||
"A2",
|
||||
"no candidate-specific governed authority reader and identity",
|
||||
)
|
||||
if task_id is None:
|
||||
raise LiveBindingViolation("A2", "exact task_id required")
|
||||
if actor_identity != self._actor_identity:
|
||||
raise LiveBindingViolation(
|
||||
"A2",
|
||||
"controller actor_identity does not match bound governed principal",
|
||||
)
|
||||
snapshot = self._client.read_snapshot(
|
||||
task_id=task_id,
|
||||
actor_identity=actor_identity,
|
||||
operation=operation,
|
||||
)
|
||||
if not snapshot.snapshot_revision:
|
||||
raise LiveBindingViolation(
|
||||
"A2", "authority snapshot revision is missing"
|
||||
)
|
||||
if snapshot.task_id != task_id:
|
||||
raise LiveBindingViolation("A2", "authority snapshot task mismatch")
|
||||
if snapshot.actor_identity != actor_identity:
|
||||
raise LiveBindingViolation("A2", "authority snapshot actor mismatch")
|
||||
if snapshot.operation is not operation:
|
||||
raise LiveBindingViolation("A2", "authority snapshot operation mismatch")
|
||||
return snapshot.facts
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveGovernanceEvent:
|
||||
kind: str
|
||||
revision: str
|
||||
occurred_at: str
|
||||
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class GovernanceEventSubscriber(Protocol):
|
||||
def subscribe(
|
||||
self, on_event: Callable[[LiveGovernanceEvent], object]
|
||||
) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
class LiveEventSubscriptionAdapter:
|
||||
"""Translate site events into the core GovernanceEvent contract."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sink: Callable[[GovernanceEvent], object],
|
||||
subscriber: GovernanceEventSubscriber | None = None,
|
||||
) -> None:
|
||||
self._sink = sink
|
||||
self._subscriber = subscriber
|
||||
self._active = False
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self._subscriber is not None
|
||||
|
||||
def _on_live_event(self, event: LiveGovernanceEvent):
|
||||
if not isinstance(event, LiveGovernanceEvent):
|
||||
raise LiveBindingViolation(
|
||||
"A3", "subscriber emitted an unsupported event type"
|
||||
)
|
||||
return self._sink(GovernanceEvent(
|
||||
kind=event.kind,
|
||||
revision=event.revision,
|
||||
occurred_at=event.occurred_at,
|
||||
))
|
||||
|
||||
def start(self) -> None:
|
||||
if not self.live:
|
||||
raise LiveSurfaceUnavailable(
|
||||
"A3",
|
||||
"no governance event subscriber injected; polling remains fallback",
|
||||
)
|
||||
self._subscriber.subscribe(self._on_live_event)
|
||||
self._active = True
|
||||
|
||||
def close(self) -> None:
|
||||
if self._subscriber is not None:
|
||||
self._subscriber.close()
|
||||
self._active = False
|
||||
@@ -10,7 +10,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .capabilities import has_capabilities
|
||||
from .errors import (
|
||||
@@ -26,6 +26,7 @@ from .models import (
|
||||
ProbeOutcome,
|
||||
TaskPlan,
|
||||
TaskRecord,
|
||||
TaskStateTransitionError,
|
||||
WorkerClaim,
|
||||
WorkerRegistration,
|
||||
canonical_json,
|
||||
@@ -65,11 +66,38 @@ class TaskPlaneStore:
|
||||
self.high_assurance = high_assurance
|
||||
self.fault_injector = None
|
||||
self.probe_attestation_verifier = None
|
||||
self.evidence_attestation_verifier = None
|
||||
self._lock = threading.RLock()
|
||||
self._tx_state = threading.local()
|
||||
self._closed = False
|
||||
self._init_db(db_path)
|
||||
|
||||
@staticmethod
|
||||
def _verifier_identity(verifier: Any) -> str | None:
|
||||
identity = getattr(verifier, "verifier_identity", None)
|
||||
return str(identity) if identity else None
|
||||
|
||||
def configure_evidence_attestation_verifier(self, verifier: Any) -> Any:
|
||||
"""Install one evidence verifier without weakening an existing binding."""
|
||||
|
||||
current = self.evidence_attestation_verifier
|
||||
if verifier is None:
|
||||
return current
|
||||
if current is None:
|
||||
self.evidence_attestation_verifier = verifier
|
||||
return verifier
|
||||
if current is verifier:
|
||||
return current
|
||||
current_identity = self._verifier_identity(current)
|
||||
candidate_identity = self._verifier_identity(verifier)
|
||||
if (
|
||||
current_identity is not None
|
||||
and candidate_identity is not None
|
||||
and current_identity == candidate_identity
|
||||
):
|
||||
return current
|
||||
raise ValueError("conflicting evidence attestation verifier identity")
|
||||
|
||||
def _init_db(self, path: str) -> None:
|
||||
if path != ":memory:":
|
||||
parent = os.path.dirname(os.path.abspath(path))
|
||||
@@ -410,6 +438,18 @@ class TaskPlaneStore:
|
||||
# an external request. They must never migrate to a phase
|
||||
# that permits a new lease or commit.
|
||||
"effect_phase": "TEXT NOT NULL DEFAULT 'LEGACY_UNKNOWN'",
|
||||
# C6 exact-owner integration extends this existing effect
|
||||
# ledger. The versioned binding is immutable and its
|
||||
# separately indexed effect key permits exact readback;
|
||||
# it is deliberately not a second action ledger.
|
||||
"host_effect_key": "TEXT",
|
||||
"host_binding_json": "TEXT",
|
||||
"host_binding_digest": "TEXT",
|
||||
"writer_lease_json": "TEXT",
|
||||
"writer_lease_digest": "TEXT",
|
||||
"host_closure_json": "TEXT",
|
||||
"host_closure_digest": "TEXT",
|
||||
"host_closure_observed_at": "REAL",
|
||||
},
|
||||
)
|
||||
self.conn.execute(
|
||||
@@ -436,6 +476,11 @@ class TaskPlaneStore:
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_root_review_trigger "
|
||||
"ON task_root_reviews(trigger_child_task_id, trigger_child_revision)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_root_action_host_effect "
|
||||
"ON task_root_action_effects(host_effect_key) "
|
||||
"WHERE host_effect_key IS NOT NULL"
|
||||
)
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_missions (
|
||||
@@ -754,22 +799,60 @@ class TaskPlaneStore:
|
||||
status: str,
|
||||
failure_reason: str | None = None,
|
||||
) -> TaskRecord:
|
||||
with self._transaction():
|
||||
current = self.get_task(task_id)
|
||||
if current.revision != expected_revision:
|
||||
raise ConflictError("task revision mismatch")
|
||||
current.with_status(status)
|
||||
outer_depth = getattr(self._tx_state, "depth", 0)
|
||||
observed: TaskRecord | None = None
|
||||
try:
|
||||
with self._transaction():
|
||||
current = self.get_task(task_id)
|
||||
observed = current
|
||||
if current.revision != expected_revision:
|
||||
raise ConflictError("task revision mismatch")
|
||||
current.with_status(status)
|
||||
|
||||
now = _now()
|
||||
self.conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status=?, revision=revision+1, updated_at=?, failure_reason=?
|
||||
WHERE task_id=?
|
||||
""",
|
||||
(status, now, failure_reason, task_id),
|
||||
now = _now()
|
||||
self.conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status=?, revision=revision+1, updated_at=?, failure_reason=?
|
||||
WHERE task_id=?
|
||||
""",
|
||||
(status, now, failure_reason, task_id),
|
||||
)
|
||||
return self.get_task(task_id)
|
||||
except TaskStateTransitionError as exc:
|
||||
if observed is None:
|
||||
raise
|
||||
if outer_depth:
|
||||
raise
|
||||
self._append_transition_denial(
|
||||
task_id, observed, attempted_status=status, error=exc
|
||||
)
|
||||
return self.get_task(task_id)
|
||||
raise
|
||||
|
||||
def _append_transition_denial(
|
||||
self,
|
||||
task_id: str,
|
||||
observed: TaskRecord,
|
||||
*,
|
||||
attempted_status: str,
|
||||
error: TaskStateTransitionError,
|
||||
) -> None:
|
||||
try:
|
||||
self.append_event(
|
||||
task_id,
|
||||
"TASK_TRANSITION_DENIED",
|
||||
{
|
||||
"from_status": observed.status,
|
||||
"attempted_status": attempted_status,
|
||||
"observed_revision": observed.revision,
|
||||
"reason": str(error),
|
||||
},
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
except Exception as audit_error:
|
||||
raise RuntimeError(
|
||||
"task transition denied but denial audit persistence failed"
|
||||
) from audit_error
|
||||
|
||||
def register_worker(
|
||||
self,
|
||||
@@ -1048,112 +1131,203 @@ class TaskPlaneStore:
|
||||
lease_seconds: int,
|
||||
expected_revision: int,
|
||||
operation_digest: str,
|
||||
*,
|
||||
proof_digest: str | None = None,
|
||||
expected_binding: Mapping[str, object] | None = None,
|
||||
) -> WorkerClaim:
|
||||
with self._transaction():
|
||||
return self._claim_task_in_transaction(
|
||||
task_id,
|
||||
worker_id,
|
||||
worker_identity,
|
||||
lease_seconds,
|
||||
expected_revision,
|
||||
operation_digest,
|
||||
proof_digest=proof_digest,
|
||||
expected_binding=expected_binding,
|
||||
)
|
||||
|
||||
def _claim_task_in_transaction(
|
||||
self,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
worker_identity: str,
|
||||
lease_seconds: int,
|
||||
expected_revision: int,
|
||||
operation_digest: str,
|
||||
*,
|
||||
proof_digest: str | None = None,
|
||||
expected_binding: Mapping[str, object] | None = None,
|
||||
) -> WorkerClaim:
|
||||
"""Claim after revalidating the supplied binding in the active transaction."""
|
||||
|
||||
now = _now()
|
||||
lease_until = now + max(1, int(lease_seconds))
|
||||
with self._transaction():
|
||||
worker = self.get_worker(worker_id)
|
||||
if worker.disabled:
|
||||
raise LockedError("worker disabled")
|
||||
if worker.identity != worker_identity:
|
||||
raise ConflictError("worker identity mismatch")
|
||||
worker = self.get_worker(worker_id)
|
||||
if worker.disabled:
|
||||
raise LockedError("worker disabled")
|
||||
if worker.identity != worker_identity:
|
||||
raise ConflictError("worker identity mismatch")
|
||||
|
||||
task = self.get_task(task_id)
|
||||
if task.revision != expected_revision:
|
||||
raise ConflictError("task revision mismatch")
|
||||
if task.status not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}:
|
||||
raise ConflictError(f"task in invalid state for claim: {task.status}")
|
||||
if task.status == "FAILED_RECOVERABLE" and not self.task_has_retryable_probe(
|
||||
task_id
|
||||
task = self.get_task(task_id)
|
||||
if task.revision != expected_revision:
|
||||
raise ConflictError("task revision mismatch")
|
||||
if expected_binding is not None:
|
||||
task_generation = task.task_plan.metadata.get("generation", 0)
|
||||
if (
|
||||
not isinstance(task_generation, int)
|
||||
or isinstance(task_generation, bool)
|
||||
or task_generation < 0
|
||||
):
|
||||
raise LockedError("recoverable task lacks NOT_APPLIED probe evidence")
|
||||
if self.active_claim(task_id):
|
||||
raise ConflictError("task already claimed")
|
||||
if task.task_plan.data_classification == "credential-secret":
|
||||
if (
|
||||
worker.worker_type != "secure_file_transformer"
|
||||
and "secure_file_transformer" not in worker.capabilities
|
||||
):
|
||||
from .errors import ForbiddenError
|
||||
|
||||
raise ForbiddenError(
|
||||
"credential-secret task requires secure file transformer"
|
||||
)
|
||||
for dependency_id in task.task_plan.dependencies:
|
||||
if self.get_task(dependency_id).status != "COMPLETED":
|
||||
raise LockedError("task dependency incomplete")
|
||||
|
||||
required = task.task_plan.required_capabilities
|
||||
if required and not has_capabilities(worker.capabilities, required):
|
||||
raise ConflictError("worker lacks required capabilities")
|
||||
attempts = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE task_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(task_id, now),
|
||||
).fetchone()[0]
|
||||
if attempts > 0:
|
||||
raise ConflictError("existing active claim")
|
||||
|
||||
claim_id = f"claim-{uuid.uuid4()}"
|
||||
active_for_worker = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(worker_id, now),
|
||||
).fetchone()[0]
|
||||
if active_for_worker >= worker.max_concurrency:
|
||||
raise LockedError("worker concurrency exceeded")
|
||||
attempt = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE task_id=?",
|
||||
(task_id,),
|
||||
).fetchone()[0] + 1
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO claims(claim_id, task_id, worker_id, worker_identity, claimed_at, lease_until, attempt, task_revision, operation_digest, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'ACTIVE')",
|
||||
(
|
||||
claim_id,
|
||||
task_id,
|
||||
worker_id,
|
||||
worker_identity,
|
||||
now,
|
||||
lease_until,
|
||||
attempt,
|
||||
expected_revision,
|
||||
operation_digest,
|
||||
),
|
||||
)
|
||||
schedule_payload = {
|
||||
"worker_id": worker_id,
|
||||
"worker_identity": worker_identity,
|
||||
"operation_digest": operation_digest,
|
||||
"attempt": attempt,
|
||||
raise ConflictError("task generation invalid")
|
||||
checks = {
|
||||
"task_revision": task.revision,
|
||||
"task_generation": task_generation,
|
||||
"task_status": task.status,
|
||||
"worker_generation": worker.registration_revision,
|
||||
"worker_identity": worker.identity,
|
||||
"capacity_limit": worker.max_concurrency,
|
||||
"operation_digest": task.task_plan.operation_digest(),
|
||||
}
|
||||
existing_decision = self.conn.execute(
|
||||
"""
|
||||
SELECT 1 FROM events
|
||||
WHERE task_id=? AND type='SCHEDULE_DECISION' AND event_data=?
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
json.dumps(redact_value(schedule_payload), ensure_ascii=False),
|
||||
),
|
||||
).fetchone()
|
||||
if existing_decision is None:
|
||||
self.append_event(
|
||||
task_id,
|
||||
"SCHEDULE_DECISION",
|
||||
schedule_payload,
|
||||
subject=f"task/{task_id}",
|
||||
for field, actual in checks.items():
|
||||
if expected_binding.get(field) != actual:
|
||||
raise ConflictError(f"claim binding {field} mismatch")
|
||||
if expected_binding.get("operation_digest") != operation_digest:
|
||||
raise ConflictError("claim binding operation_digest mismatch")
|
||||
if task.status not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}:
|
||||
raise ConflictError(f"task in invalid state for claim: {task.status}")
|
||||
if task.status == "FAILED_RECOVERABLE" and not self.task_has_retryable_probe(
|
||||
task_id
|
||||
):
|
||||
raise LockedError("recoverable task lacks NOT_APPLIED probe evidence")
|
||||
if self.active_claim(task_id):
|
||||
raise ConflictError("task already claimed")
|
||||
if task.task_plan.data_classification == "credential-secret":
|
||||
if (
|
||||
worker.worker_type != "secure_file_transformer"
|
||||
and "secure_file_transformer" not in worker.capabilities
|
||||
):
|
||||
from .errors import ForbiddenError
|
||||
|
||||
raise ForbiddenError(
|
||||
"credential-secret task requires secure file transformer"
|
||||
)
|
||||
self.conn.execute(
|
||||
"UPDATE tasks SET status='CLAIMED', revision=revision+1, updated_at=? WHERE task_id=?",
|
||||
(now, task_id),
|
||||
for dependency_id in task.task_plan.dependencies:
|
||||
if self.get_task(dependency_id).status != "COMPLETED":
|
||||
raise LockedError("task dependency incomplete")
|
||||
|
||||
required = task.task_plan.required_capabilities
|
||||
if required and not has_capabilities(worker.capabilities, required):
|
||||
raise ConflictError("worker lacks required capabilities")
|
||||
attempts = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE task_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(task_id, now),
|
||||
).fetchone()[0]
|
||||
if attempts > 0:
|
||||
raise ConflictError("existing active claim")
|
||||
|
||||
claim_id = f"claim-{uuid.uuid4()}"
|
||||
active_for_worker = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",
|
||||
(worker_id, now),
|
||||
).fetchone()[0]
|
||||
if active_for_worker >= worker.max_concurrency:
|
||||
raise LockedError("worker concurrency exceeded")
|
||||
attempt = self.conn.execute(
|
||||
"SELECT COUNT(1) FROM claims WHERE task_id=?",
|
||||
(task_id,),
|
||||
).fetchone()[0] + 1
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO claims(claim_id, task_id, worker_id, worker_identity, claimed_at, lease_until, attempt, task_revision, operation_digest, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'ACTIVE')",
|
||||
(
|
||||
claim_id,
|
||||
task_id,
|
||||
worker_id,
|
||||
worker_identity,
|
||||
now,
|
||||
lease_until,
|
||||
attempt,
|
||||
expected_revision,
|
||||
operation_digest,
|
||||
),
|
||||
)
|
||||
schedule_payload = {
|
||||
"worker_id": worker_id,
|
||||
"worker_identity": worker_identity,
|
||||
"operation_digest": operation_digest,
|
||||
"attempt": attempt,
|
||||
}
|
||||
if proof_digest is not None:
|
||||
schedule_payload["proof_digest"] = proof_digest
|
||||
existing_decision = self.conn.execute(
|
||||
"""
|
||||
SELECT 1 FROM events
|
||||
WHERE task_id=? AND type='SCHEDULE_DECISION' AND event_data=?
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
json.dumps(redact_value(schedule_payload), ensure_ascii=False),
|
||||
),
|
||||
).fetchone()
|
||||
if existing_decision is None:
|
||||
self.append_event(
|
||||
task_id,
|
||||
"SCHEDULE_DECISION",
|
||||
schedule_payload,
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
self.append_event(task_id, "TASK_CLAIMED", {"claim_id": claim_id, "worker_id": worker_id}, subject=f"task/{task_id}")
|
||||
return self._row_to_claim(self.conn.execute(
|
||||
self.conn.execute(
|
||||
"UPDATE tasks SET status='CLAIMED', revision=revision+1, updated_at=? WHERE task_id=?",
|
||||
(now, task_id),
|
||||
)
|
||||
claimed_payload = {"claim_id": claim_id, "worker_id": worker_id}
|
||||
if proof_digest is not None:
|
||||
claimed_payload["proof_digest"] = proof_digest
|
||||
if expected_binding is not None:
|
||||
# Preserve only the C6 conditions rechecked above plus the host
|
||||
# authorization/scope version fields. A later renew/execute must
|
||||
# bind to this durable decision, rather than caller-shaped receipt
|
||||
# JSON. Do not serialize arbitrary values accepted by this
|
||||
# internal compatibility seam.
|
||||
claimed_payload["expected_binding"] = {
|
||||
field: expected_binding.get(field)
|
||||
for field in (
|
||||
"task_revision",
|
||||
"task_generation",
|
||||
"task_status",
|
||||
"worker_generation",
|
||||
"worker_identity",
|
||||
"authorization_ref",
|
||||
"authorization_revision",
|
||||
"scope_digest",
|
||||
"capacity_limit",
|
||||
"operation_digest",
|
||||
)
|
||||
}
|
||||
self.append_event(
|
||||
task_id,
|
||||
"TASK_CLAIMED",
|
||||
claimed_payload,
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
return self._row_to_claim(
|
||||
self.conn.execute(
|
||||
"SELECT * FROM claims WHERE claim_id=?",
|
||||
(claim_id,),
|
||||
).fetchone())
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def heartbeat_claim(self, task_id: str, claim_id: str, lease_seconds: int) -> WorkerClaim:
|
||||
def heartbeat_claim(
|
||||
self,
|
||||
task_id: str,
|
||||
claim_id: str,
|
||||
lease_seconds: int,
|
||||
*,
|
||||
expected_task_revision: int | None = None,
|
||||
operation_digest: str | None = None,
|
||||
) -> WorkerClaim:
|
||||
now = _now()
|
||||
lease_until = now + max(1, lease_seconds)
|
||||
with self._transaction():
|
||||
@@ -1168,14 +1342,32 @@ class TaskPlaneStore:
|
||||
raise ConflictError("claim not active")
|
||||
if claim.lease_until <= now:
|
||||
raise LockedError("lease expired")
|
||||
if (
|
||||
expected_task_revision is not None
|
||||
and claim.task_revision != expected_task_revision
|
||||
):
|
||||
raise ConflictError("claim task revision mismatch")
|
||||
if (
|
||||
operation_digest is not None
|
||||
and claim.operation_digest != operation_digest
|
||||
):
|
||||
raise ConflictError("claim operation digest mismatch")
|
||||
if self.get_task(task_id).status in {"CANCELLED", "COMPLETED", "FAILED_FINAL"}:
|
||||
raise ConflictError("task is terminal")
|
||||
updated = self.conn.execute(
|
||||
"""
|
||||
UPDATE claims SET lease_until=?
|
||||
WHERE claim_id=? AND task_id=? AND status='ACTIVE' AND lease_until>?
|
||||
AND task_revision=? AND operation_digest=?
|
||||
""",
|
||||
(lease_until, claim_id, task_id, now),
|
||||
(
|
||||
lease_until,
|
||||
claim_id,
|
||||
task_id,
|
||||
now,
|
||||
claim.task_revision,
|
||||
claim.operation_digest,
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise LockedError("claim changed during heartbeat")
|
||||
@@ -1408,6 +1600,7 @@ class TaskPlaneStore:
|
||||
expected_task_id=task_id,
|
||||
expected_claim_id=claim_id,
|
||||
expected_previous_hash=previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
):
|
||||
raise ConflictError("evidence envelope invalid")
|
||||
self.add_evidence_envelope(envelope)
|
||||
@@ -1455,16 +1648,26 @@ class TaskPlaneStore:
|
||||
def cancel_task_with_event(
|
||||
self, task_id: str, expected_revision: int, reason: str
|
||||
) -> TaskRecord:
|
||||
with self._transaction():
|
||||
updated = self.cancel_task(task_id, expected_revision, reason)
|
||||
self._fault("cancel.after_state_before_event")
|
||||
self.append_event(
|
||||
task_id,
|
||||
"TASK_CANCELLED",
|
||||
{"reason": reason},
|
||||
subject=f"task/{task_id}",
|
||||
observed: TaskRecord | None = None
|
||||
try:
|
||||
with self._transaction():
|
||||
observed = self.get_task(task_id)
|
||||
updated = self.cancel_task(task_id, expected_revision, reason)
|
||||
self._fault("cancel.after_state_before_event")
|
||||
self.append_event(
|
||||
task_id,
|
||||
"TASK_CANCELLED",
|
||||
{"reason": reason},
|
||||
subject=f"task/{task_id}",
|
||||
)
|
||||
return updated
|
||||
except TaskStateTransitionError as exc:
|
||||
if observed is None:
|
||||
raise
|
||||
self._append_transition_denial(
|
||||
task_id, observed, attempted_status="CANCELLED", error=exc
|
||||
)
|
||||
return updated
|
||||
raise
|
||||
|
||||
def delegate_task_with_budget_and_events(
|
||||
self,
|
||||
@@ -1699,38 +1902,120 @@ class TaskPlaneStore:
|
||||
prev = event["hash"]
|
||||
return True
|
||||
|
||||
def add_evidence(self, task_id: str, kind: str, payload: dict[str, Any], worker_identity: str) -> str:
|
||||
def add_evidence(
|
||||
self,
|
||||
task_id: str,
|
||||
kind: str,
|
||||
payload: dict[str, Any],
|
||||
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:
|
||||
"""Add authenticated evidence while retaining the legacy call shape.
|
||||
|
||||
Old unsigned calls now fail with a migration error before any write.
|
||||
Producers may submit a complete envelope or all explicit proof fields.
|
||||
"""
|
||||
|
||||
if envelope is not None:
|
||||
if (
|
||||
envelope.task_id != task_id
|
||||
or envelope.kind != kind
|
||||
or envelope.worker_identity != worker_identity
|
||||
or envelope.payload != redact_value(payload)
|
||||
):
|
||||
raise ValidationError("evidence envelope arguments mismatch")
|
||||
self.add_evidence_envelope(envelope)
|
||||
return envelope.evidence_id
|
||||
if not (signature or attestation_ref):
|
||||
raise ValidationError(
|
||||
"evidence authentication required; submit a complete verified "
|
||||
"envelope or explicit signature/provider proof"
|
||||
)
|
||||
claim = self.active_claim(task_id)
|
||||
claim_id = claim.claim_id if claim else "claim-unbound-shadow"
|
||||
envelope = EvidenceEnvelope.create(
|
||||
effective_claim_id = claim_id or (
|
||||
claim.claim_id if claim else "claim-unbound-shadow"
|
||||
)
|
||||
if claim is not None and effective_claim_id != claim.claim_id:
|
||||
raise ValidationError("evidence claim does not match active claim")
|
||||
built = EvidenceEnvelope.create(
|
||||
task_id=task_id,
|
||||
claim_id=claim_id,
|
||||
claim_id=effective_claim_id,
|
||||
kind=kind,
|
||||
worker_identity=worker_identity,
|
||||
payload=payload,
|
||||
previous_evidence_hash=self._last_evidence_hash(task_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,
|
||||
)
|
||||
self.add_evidence_envelope(envelope)
|
||||
return envelope.evidence_id
|
||||
self.add_evidence_envelope(built)
|
||||
return built.evidence_id
|
||||
|
||||
def _last_evidence_hash(self, task_id: str) -> str:
|
||||
row = self.conn.execute(
|
||||
"""
|
||||
SELECT digest FROM evidence WHERE task_id=?
|
||||
ORDER BY created_at DESC,evidence_id DESC LIMIT 1
|
||||
ORDER BY rowid DESC LIMIT 1
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return str(row["digest"]) if row else ""
|
||||
|
||||
def _last_evidence_sequence(self, authority_domain: str) -> int | None:
|
||||
row = self.conn.execute(
|
||||
"""
|
||||
SELECT MAX(CAST(json_extract(envelope_json, '$.sequence') AS INTEGER))
|
||||
FROM evidence
|
||||
WHERE json_extract(envelope_json, '$.authority_domain')=?
|
||||
AND json_type(envelope_json, '$.sequence')='integer'
|
||||
""",
|
||||
(authority_domain,),
|
||||
).fetchone()
|
||||
return None if row is None or row[0] is None else int(row[0])
|
||||
|
||||
def add_evidence_envelope(self, envelope: EvidenceEnvelope) -> str:
|
||||
with self._transaction():
|
||||
self.get_task(envelope.task_id)
|
||||
if (envelope.authority_domain is None) != (envelope.sequence is None):
|
||||
raise ConflictError("evidence authority domain and sequence must be paired")
|
||||
if envelope.authority_domain is not None:
|
||||
if (
|
||||
not envelope.authority_domain
|
||||
or not isinstance(envelope.sequence, int)
|
||||
or isinstance(envelope.sequence, bool)
|
||||
or envelope.sequence < 1
|
||||
):
|
||||
raise ConflictError("evidence authority sequence invalid")
|
||||
last_sequence = self._last_evidence_sequence(
|
||||
envelope.authority_domain
|
||||
)
|
||||
if last_sequence is not None and envelope.sequence <= last_sequence:
|
||||
raise ConflictError("evidence authority sequence not monotonic")
|
||||
expected_previous = self._last_evidence_hash(envelope.task_id)
|
||||
if not envelope.verify(
|
||||
expected_task_id=envelope.task_id,
|
||||
expected_claim_id=envelope.claim_id,
|
||||
expected_previous_hash=expected_previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
):
|
||||
raise ConflictError("evidence envelope invalid")
|
||||
self.conn.execute(
|
||||
@@ -1759,13 +2044,14 @@ class TaskPlaneStore:
|
||||
task_id = _validated_id(task_id, "task_id")
|
||||
with self._lock:
|
||||
rows = self.conn.execute(
|
||||
"SELECT * FROM evidence WHERE task_id=? ORDER BY created_at ASC",
|
||||
"SELECT * FROM evidence WHERE task_id=? ORDER BY rowid ASC",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def verify_evidence_integrity(self, task_id: str) -> bool:
|
||||
previous = ""
|
||||
domain_sequences: dict[str, int] = {}
|
||||
for evidence in self.list_evidence(task_id):
|
||||
try:
|
||||
raw = evidence.get("envelope_json")
|
||||
@@ -1781,10 +2067,24 @@ class TaskPlaneStore:
|
||||
worker_identity=payload["worker_identity"],
|
||||
payload=payload["payload"],
|
||||
created_at=float(payload["created_at"]),
|
||||
expires_at=float(
|
||||
payload.get(
|
||||
"expires_at", float(payload["created_at"]) + 300.0
|
||||
)
|
||||
),
|
||||
previous_evidence_hash=payload["previous_evidence_hash"],
|
||||
evidence_digest=payload["evidence_digest"],
|
||||
signature=payload.get("signature"),
|
||||
attestation_ref=payload.get("attestation_ref"),
|
||||
signature_algorithm=payload.get("signature_algorithm"),
|
||||
signing_key_id=payload.get("signing_key_id"),
|
||||
verifier_identity=payload.get("verifier_identity"),
|
||||
authority_domain=payload.get("authority_domain"),
|
||||
sequence=(
|
||||
None
|
||||
if payload.get("sequence") is None
|
||||
else int(payload["sequence"])
|
||||
),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return False
|
||||
@@ -1798,9 +2098,20 @@ class TaskPlaneStore:
|
||||
expected_task_id=task_id,
|
||||
expected_claim_id=envelope.claim_id,
|
||||
expected_previous_hash=previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
)
|
||||
):
|
||||
return False
|
||||
if envelope.authority_domain is not None:
|
||||
if (
|
||||
not isinstance(envelope.sequence, int)
|
||||
or isinstance(envelope.sequence, bool)
|
||||
or envelope.sequence <= domain_sequences.get(
|
||||
envelope.authority_domain, 0
|
||||
)
|
||||
):
|
||||
return False
|
||||
domain_sequences[envelope.authority_domain] = envelope.sequence
|
||||
previous = envelope.evidence_digest
|
||||
return True
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -405,51 +407,85 @@ def run_secret_scan_provider(
|
||||
tree_oid: str,
|
||||
store_id: str = "secret_scan",
|
||||
provider_identity: str = "aios.secret-scanner",
|
||||
artifact_path: Path | None = None,
|
||||
) -> tuple[Mapping[str, Any], str]:
|
||||
"""Execute the real secret scanner (gitleaks) and produce a root receipt."""
|
||||
if not Path(gitleaks_bin).is_file():
|
||||
raise TrustedProviderExecutionError("SECRET_SCANNER_UNAVAILABLE")
|
||||
artifact_sha = None
|
||||
if artifact_path is not None:
|
||||
artifact_sha = _validate_artifact_scan_target(artifact_path, target)
|
||||
# --no-git scans the working tree as a filesystem (does not require a .git
|
||||
# dir), so an exported release checkout can be scanned faithfully.
|
||||
scanned = _run(
|
||||
[
|
||||
gitleaks_bin, "detect",
|
||||
"--source", str(target),
|
||||
"--no-git",
|
||||
"--no-banner",
|
||||
"--exit-code", "0",
|
||||
],
|
||||
cwd=target,
|
||||
timeout=1800,
|
||||
)
|
||||
warnings = 0
|
||||
# gitleaks exits 1 when findings are present; with --exit-code 0 it returns 0
|
||||
# but prints findings. Count lines that indicate a finding.
|
||||
finding_lines = [
|
||||
ln for ln in scanned.stdout.splitlines()
|
||||
if ("Finding:" in ln and ":" in ln and "RuleID" not in ln)
|
||||
] + [
|
||||
ln for ln in scanned.stderr.splitlines()
|
||||
if ("Finding:" in ln and ":" in ln and "RuleID" not in ln)
|
||||
]
|
||||
artifact_sha = None
|
||||
if target.is_file():
|
||||
artifact_sha = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
with tempfile.TemporaryDirectory(prefix="aios-secret-report-") as report_root:
|
||||
report = Path(report_root) / "findings.json"
|
||||
scanned = _run(
|
||||
[
|
||||
gitleaks_bin, "detect",
|
||||
"--source", str(target),
|
||||
"--no-git",
|
||||
"--no-banner",
|
||||
"--exit-code", "1",
|
||||
"--report-format", "json", "--report-path", str(report),
|
||||
],
|
||||
cwd=target,
|
||||
timeout=1800,
|
||||
)
|
||||
try:
|
||||
findings = json.loads(report.read_text())
|
||||
except (OSError, ValueError) as exc:
|
||||
raise TrustedProviderExecutionError("SECRET_SCANNER_REPORT_INVALID") from exc
|
||||
if not isinstance(findings, list) or any(not isinstance(v, dict) for v in findings):
|
||||
raise TrustedProviderExecutionError("SECRET_SCANNER_REPORT_INVALID")
|
||||
report_sha = hashlib.sha256(report.read_bytes()).hexdigest()
|
||||
if artifact_path is not None and _validate_artifact_scan_target(artifact_path, target) != artifact_sha:
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARTIFACT_CHANGED")
|
||||
# Passed only when the scanner actually ran cleanly (exit 0) AND found zero
|
||||
# leaks. A scanner crash (non-zero exit) is never a pass.
|
||||
clean_run = scanned.returncode == 0
|
||||
result = {
|
||||
"passed": clean_run and len(finding_lines) == 0,
|
||||
"passed": clean_run and len(findings) == 0,
|
||||
"scanner": "gitleaks",
|
||||
"exit_code": scanned.returncode,
|
||||
"target": str(target),
|
||||
"findings": len(finding_lines),
|
||||
"findings": len(findings),
|
||||
"report_sha256": report_sha,
|
||||
"artifact_sha256": artifact_sha,
|
||||
"clean_run": clean_run,
|
||||
}
|
||||
return result, _receipt_ref_for(result, "secret_scan")
|
||||
|
||||
|
||||
def _validate_artifact_scan_target(artifact: Path, target: Path) -> str:
|
||||
"""Bind a real filesystem scan to every byte of its exact archive payload."""
|
||||
if artifact.is_symlink() or not artifact.is_file() or target.is_symlink() or not target.is_dir():
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARTIFACT_TARGET_INVALID")
|
||||
raw = artifact.read_bytes()
|
||||
import io
|
||||
expected: set[str] = set()
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as archive:
|
||||
members = archive.getmembers()
|
||||
if not members or len(members) > 20_000 or sum(m.size for m in members) > 2 * 1024**3:
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARCHIVE_LIMIT")
|
||||
for member in members:
|
||||
parts = Path(member.name).parts
|
||||
if not member.isfile() or member.name.startswith("/") or "\\" in member.name or not parts or any(p in {".", ".."} for p in parts) or member.name in expected:
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARCHIVE_INVALID")
|
||||
path = target.joinpath(*parts)
|
||||
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(target.resolve()):
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_PAYLOAD_MISMATCH")
|
||||
if archive.extractfile(member).read() != path.read_bytes():
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_PAYLOAD_MISMATCH")
|
||||
expected.add(member.name)
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_ARCHIVE_INVALID") from exc
|
||||
actual = {p.relative_to(target).as_posix() for p in target.rglob("*") if p.is_file()}
|
||||
if actual != expected or any(p.is_symlink() for p in target.rglob("*")):
|
||||
raise TrustedProviderExecutionError("SECRET_SCAN_PAYLOAD_MISMATCH")
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _receipt_ref_for(result: Mapping[str, Any], kind: str) -> str:
|
||||
run_id = hashlib.sha256(canonical_json_bytes(result)).hexdigest()[:16]
|
||||
return f"run-{run_id}/receipt.json"
|
||||
@@ -531,6 +567,7 @@ def _run_provider_executor(
|
||||
target=repo_root,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
artifact_path=(Path(os.environ["AIOS_RELEASE_ARTIFACT"]) if os.environ.get("AIOS_RELEASE_ARTIFACT") else None),
|
||||
)
|
||||
if kind == "gitea":
|
||||
gitea_api_url = os.environ.get("AIOS_GITEA_API_URL", "").strip()
|
||||
@@ -601,9 +638,14 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--store-root", required=True)
|
||||
parser.add_argument("--receipt-ref", required=True)
|
||||
parser.add_argument("--repo-root", default=None, help="Real source checkout to execute against")
|
||||
parser.add_argument("--artifact-path", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
if args.repo_root:
|
||||
os.environ["AIOS_REPO_ROOT"] = args.repo_root
|
||||
if args.artifact_path:
|
||||
if args.kind != "secret_scan":
|
||||
raise SystemExit("ARTIFACT_BINDING_REQUIRES_SECRET_SCAN")
|
||||
os.environ["AIOS_RELEASE_ARTIFACT"] = str(args.artifact_path)
|
||||
try:
|
||||
digest = produce(
|
||||
args.kind,
|
||||
|
||||
@@ -46,12 +46,23 @@ def _paths(binding: Mapping[str, Any]) -> dict[str, Path]:
|
||||
slug=version.lower()
|
||||
return {"release_dir":root/(version+"-"+str(binding["target_oid"])[:12]+"-"+str(binding["artifact_sha256"])[:12]),"current_link":Path("/opt/aios")/slug,"env_file":Path("/etc/aios")/(slug+".env"),"state_file":Path("/var/lib/aios")/slug/"deployment-state.json","unit_file":Path("/etc/systemd/system")/(service+".service" if not service.endswith(".service") else service),"health_url":f"http://127.0.0.1:{int(binding['production_port'])}/health"}
|
||||
|
||||
def _health(url: str, binding: Mapping[str, Any]) -> dict[str, Any]:
|
||||
def _health(url: str, binding: Mapping[str, Any], *, runtime_verifier=None) -> dict[str, Any]:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as response: body=json.loads(response.read().decode()); status=response.status
|
||||
except (OSError, ValueError, urllib.error.URLError): return {"status":"UNAVAILABLE"}
|
||||
exact=(status==200 and body.get("version")==binding["version"] and body.get("commit_oid")==binding["target_oid"] and body.get("artifact_sha256")==binding["artifact_sha256"] and body.get("deployment_state")=="ACTIVE" and body.get("production_deployed") is True)
|
||||
return {"status":"ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status":status, **body}
|
||||
if str(binding["version"]).startswith("V2R2C6"):
|
||||
# Health bodies are untrusted declarations; even a body containing PASS
|
||||
# cannot certify itself. A host-owned independent verifier must bind
|
||||
# the response to the exact candidate and fresh observed runtime.
|
||||
try:
|
||||
independently_verified = callable(runtime_verifier) and runtime_verifier(binding, body) is True
|
||||
except Exception:
|
||||
independently_verified = False
|
||||
result_status = "ACTIVE_VERIFIED" if exact and independently_verified else "UNKNOWN"
|
||||
else:
|
||||
result_status = "ACTIVE_VERIFIED" if exact else "CONFLICT"
|
||||
return {**body, "status": result_status, "http_status": status}
|
||||
|
||||
def _extract(artifact: Path, destination: Path) -> None:
|
||||
import tarfile
|
||||
@@ -70,7 +81,13 @@ def probe(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return _health(str(p["health_url"]),binding) if active else {"status":"NOT_APPLIED","version":binding["version"],"target_oid":binding["target_oid"],"artifact_sha256":binding["artifact_sha256"],"production_deployed":False,"deployment_state":"ABSENT"}
|
||||
|
||||
def apply(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
binding=_binding(value); p=_paths(binding); p["release_dir"].parent.mkdir(parents=True, exist_ok=True); artifact=Path(str(binding["artifact_path"]))
|
||||
binding=_binding(value)
|
||||
if str(binding["version"]).startswith("V2R2C6"):
|
||||
# This adapter installs exactly one legacy health service. A C6
|
||||
# multi-component payload requires the governed component host binding;
|
||||
# do not report a health-only installation as a C6 deployment.
|
||||
raise AdapterBlocked("component_host_binding_required")
|
||||
p=_paths(binding); p["release_dir"].parent.mkdir(parents=True, exist_ok=True); artifact=Path(str(binding["artifact_path"]))
|
||||
if artifact.stat().st_size != int(binding["artifact_size"]) or _sha(artifact)!=binding["artifact_sha256"]: raise AdapterBlocked("artifact_binding_mismatch")
|
||||
marker={"schema":"aios.trusted-runtime-release-binding.v1",**binding}
|
||||
marker_path=p["release_dir"]/"DEPLOYMENT-BINDING.json"
|
||||
|
||||
@@ -78,7 +78,12 @@ def serve(host: str, port: int) -> None:
|
||||
|
||||
def main() -> int:
|
||||
parser=argparse.ArgumentParser(); parser.add_argument("--root",type=Path,default=Path(".")); parser.add_argument("--manifest",type=Path); parser.add_argument("--serve",action="store_true"); parser.add_argument("--host",default="127.0.0.1"); parser.add_argument("--port",type=int,default=18927); args=parser.parse_args()
|
||||
if args.serve: serve(args.host,args.port); return 0
|
||||
if args.serve:
|
||||
# A health listener may report liveness only after its real artifact
|
||||
# manifest has been verified. This is intentionally not runtime
|
||||
# identity proof; that needs a separate trusted observation.
|
||||
verify_manifest(args.root,args.manifest)
|
||||
serve(args.host,args.port); return 0
|
||||
print(json.dumps(verify_manifest(args.root,args.manifest),sort_keys=True)); return 0
|
||||
|
||||
if __name__ == "__main__": raise SystemExit(main())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html
|
||||
import json, os, re, sqlite3, subprocess, time, uuid, hashlib, urllib.parse, html, ipaddress
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -14,6 +14,25 @@ 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},
|
||||
@@ -66,6 +85,7 @@ def token_identity(token: str):
|
||||
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
|
||||
@@ -83,7 +103,9 @@ def token_identity(token: str):
|
||||
return None
|
||||
|
||||
def valid_token(token: str) -> bool:
|
||||
if DEV_ALLOW: return True
|
||||
if DEV_ALLOW:
|
||||
validate_dev_profile()
|
||||
return True
|
||||
return token_identity(token) is not None
|
||||
def git_cmd(args, check=False):
|
||||
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
||||
@@ -159,7 +181,19 @@ 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):
|
||||
init_db(); path = urllib.parse.urlparse(self.path).path; data = parse_body(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()
|
||||
try:
|
||||
if path == '/api/model/custom/add':
|
||||
a = self.require_write();
|
||||
@@ -213,7 +247,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':
|
||||
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')
|
||||
username,role,tier=runtime_selection(a, data); 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'
|
||||
@@ -268,5 +302,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(): init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
def main(): validate_dev_profile(); init_db(); print(f'AIOS V1R1C0 GitOps Model API listening on {HOST}:{PORT}'); ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# C4 capability contract provenance
|
||||
|
||||
The C4 checkout has no `aios_capability_contract.py`, although
|
||||
`active-runtime/app/aios_agent_task_protocol.py` imports it at module scope.
|
||||
The read-only QA capture used for the missing source closure is:
|
||||
|
||||
- source: `C:\\Users\\ImRuY\\.codex-restored\\work\\v2r2c5-release-20260926\\qa\\capability-contract.txt`
|
||||
- SHA256: `c97c9f8a3cf199a9c0348da1212aa26d161aad8ddaed84db534e46f490f2422f`
|
||||
- 140 lines
|
||||
|
||||
The contract preserves separate task and worker alias maps, maps unknown task
|
||||
names to `exact:<token>`, ignores unknown worker names, and returns false for
|
||||
unknown or mismatched eligibility. It is used by formal AgentTask poll and
|
||||
claim paths only. Generic `task_plane` matching remains outside this lane.
|
||||
|
||||
The authoritative runtime overlay is captured separately by QA:
|
||||
|
||||
- base SHA256: `953de1d491b4f1bc47c71f502027fd488e6c48ecc49da8a91dc9d8754c320d17`
|
||||
- overlay SHA256: `7555aa067fc01c6a25276a834dda4924b404f3b0c529ed3f99e9f1d83d18798e`
|
||||
|
||||
The consolidated module preserves the base mappings and adds only the seven
|
||||
explicit lifecycle labels: `agent-task:control`, `governed.task.create`,
|
||||
`governed.write.files`, `task.principal.issue`, `testing`, `verification`, and
|
||||
`writer.lease`. It does not depend on an absolute deployed path.
|
||||
|
||||
Validation logs:
|
||||
|
||||
- `PYTHONPATH=active-runtime/app python -m pytest -q tests/test_v2r2c5_capability_contract.py` -> 6 passed.
|
||||
- `PYTHONPATH=active-runtime/app;active-runtime python -m pytest -q tests/test_task_admission_bootstrap.py tests/test_task_admission_bootstrap_chain_contract.py` -> 45 passed.
|
||||
- `tests/test_task_admission_bootstrap_http.py` could not collect on Windows because the existing `gitea_tag_publication.py` imports unavailable `fcntl`; no capability assertion ran in that file.
|
||||
@@ -0,0 +1,357 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.aios.invalid/release/v1/authorization-bundle.schema.json",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "1.0.0"
|
||||
},
|
||||
"kind": {
|
||||
"const": "ReleaseAuthorizationBundle"
|
||||
},
|
||||
"data_class": {
|
||||
"$ref": "common.schema.json#/$defs/fixture"
|
||||
},
|
||||
"bundle_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"bundle_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"mission_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"release_run_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"REQUESTED",
|
||||
"ACTIVE",
|
||||
"REVOKED",
|
||||
"COMPLETED"
|
||||
]
|
||||
},
|
||||
"owner_intent_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"issuer_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"root_grant_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"authority_receipt_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repositories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"environments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"component_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/action"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"repositories",
|
||||
"environments",
|
||||
"component_ids",
|
||||
"actions"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"delegation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"child_tasks": {
|
||||
"const": true
|
||||
},
|
||||
"successor_tasks": {
|
||||
"const": true
|
||||
},
|
||||
"agent_transfer": {
|
||||
"const": true
|
||||
},
|
||||
"scope_must_not_expand": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"child_tasks",
|
||||
"successor_tasks",
|
||||
"agent_transfer",
|
||||
"scope_must_not_expand"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"validity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"not_before": {
|
||||
"$ref": "common.schema.json#/$defs/time"
|
||||
},
|
||||
"until": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"RELEASE_CLOSED",
|
||||
"REVOKED",
|
||||
"CANCELLED"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
},
|
||||
"revocation_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"revocation_generation": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"not_before",
|
||||
"until",
|
||||
"revocation_ref",
|
||||
"revocation_generation"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"credentials": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"maximum_ttl_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 3600
|
||||
},
|
||||
"renewable_within_goal": {
|
||||
"const": true
|
||||
},
|
||||
"issuer_operation_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"maximum_ttl_seconds",
|
||||
"renewable_within_goal",
|
||||
"issuer_operation_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"risk_budget": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_cumulative_unavailability_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"max_error_rate_delta": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"observation_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"policy_source_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_cumulative_unavailability_seconds",
|
||||
"max_error_rate_delta",
|
||||
"observation_seconds",
|
||||
"policy_source_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"forbidden": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"delete_database",
|
||||
"format_storage",
|
||||
"destroy_unique_backup",
|
||||
"change_root_trust",
|
||||
"disable_audit"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"minItems": 5,
|
||||
"maxItems": 5
|
||||
},
|
||||
"owner_required_reasons": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/owner_reason"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"minItems": 4,
|
||||
"maxItems": 4
|
||||
},
|
||||
"execution_binding": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contract_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"contract_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"contract_sha256": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"content_key": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"target_environment": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"task_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"attempt_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"lease_generation": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"capability_receipt_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"expires_at": {
|
||||
"$ref": "common.schema.json#/$defs/time"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"contract_id",
|
||||
"contract_revision",
|
||||
"contract_sha256",
|
||||
"content_key",
|
||||
"target_environment",
|
||||
"task_id",
|
||||
"attempt_id",
|
||||
"lease_generation",
|
||||
"capability_receipt_ref",
|
||||
"expires_at"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"extensions": {
|
||||
"$ref": "common.schema.json#/$defs/extensions"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schema_version",
|
||||
"kind",
|
||||
"data_class",
|
||||
"bundle_id",
|
||||
"bundle_revision",
|
||||
"mission_id",
|
||||
"release_run_id",
|
||||
"version",
|
||||
"status",
|
||||
"owner_intent_ref",
|
||||
"issuer_ref",
|
||||
"root_grant_ref",
|
||||
"authority_receipt_ref",
|
||||
"scope",
|
||||
"delegation",
|
||||
"validity",
|
||||
"credentials",
|
||||
"risk_budget",
|
||||
"forbidden",
|
||||
"owner_required_reasons",
|
||||
"execution_binding",
|
||||
"extensions"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"const": "ACTIVE"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"root_grant_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"authority_receipt_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.aios.invalid/release/v1/common.schema.json",
|
||||
"$comment": "Local identifiers only; no network retrieval. LIVE_CONFIG is a declaration, never proof of authorization.",
|
||||
"$defs": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"oid": {
|
||||
"type": "string",
|
||||
"pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$"
|
||||
},
|
||||
"time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 3
|
||||
},
|
||||
"digest_ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ref",
|
||||
"sha256"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"stage": {
|
||||
"enum": [
|
||||
"resolve_contract",
|
||||
"preflight",
|
||||
"build_test",
|
||||
"evidence_closure",
|
||||
"candidate_freeze",
|
||||
"authorization_check",
|
||||
"runtime_switch",
|
||||
"verify",
|
||||
"tag",
|
||||
"baseline_registry",
|
||||
"acceptance",
|
||||
"close"
|
||||
]
|
||||
},
|
||||
"action": {
|
||||
"enum": [
|
||||
"source_update",
|
||||
"build",
|
||||
"test",
|
||||
"staging",
|
||||
"backup",
|
||||
"governed_merge",
|
||||
"production_switch",
|
||||
"rollback",
|
||||
"verify",
|
||||
"tag",
|
||||
"baseline_update",
|
||||
"acceptance",
|
||||
"task_create",
|
||||
"agent_transfer",
|
||||
"credential_derive",
|
||||
"lease_renew",
|
||||
"evidence_append"
|
||||
]
|
||||
},
|
||||
"actor": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"OWNER",
|
||||
"AGENT",
|
||||
"SYSTEM"
|
||||
]
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"fixture": {
|
||||
"enum": [
|
||||
"FIXTURE",
|
||||
"LIVE_CONFIG"
|
||||
]
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"CREATED",
|
||||
"PREPARING",
|
||||
"RUNNING",
|
||||
"WAITING_ENVIRONMENT",
|
||||
"WAITING_CAPACITY",
|
||||
"WAITING_AUTHORITY",
|
||||
"WAITING_AGENT",
|
||||
"WAITING_OWNER",
|
||||
"RECONCILING",
|
||||
"RECOVERING",
|
||||
"VERIFYING",
|
||||
"FINALIZING",
|
||||
"ACCEPTED",
|
||||
"FAILED",
|
||||
"ROLLED_BACK",
|
||||
"CLOSED",
|
||||
"CANCELLED"
|
||||
]
|
||||
},
|
||||
"activity": {
|
||||
"enum": [
|
||||
"EXECUTING",
|
||||
"WAIT_AUTHORITY",
|
||||
"WAIT_ENVIRONMENT",
|
||||
"WAIT_AGENT",
|
||||
"WAIT_REVIEW",
|
||||
"WAIT_OWNER",
|
||||
"RECOVERY",
|
||||
"QUEUED",
|
||||
"UNKNOWN"
|
||||
]
|
||||
},
|
||||
"owner_reason": {
|
||||
"enum": [
|
||||
"GOAL_CHANGE",
|
||||
"TRUST_BOUNDARY_CHANGE",
|
||||
"IRREVERSIBLE_OPERATION",
|
||||
"BUSINESS_RISK_BUDGET_EXCEEDED"
|
||||
]
|
||||
},
|
||||
"extensions": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.aios.invalid/release/v1/release-contract.schema.json",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "1.0.0"
|
||||
},
|
||||
"kind": {
|
||||
"const": "ReleaseContract"
|
||||
},
|
||||
"data_class": {
|
||||
"$ref": "common.schema.json#/$defs/fixture"
|
||||
},
|
||||
"contract_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"contract_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"mission_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"release_run_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"DRAFT",
|
||||
"RESOLVED",
|
||||
"FROZEN"
|
||||
]
|
||||
},
|
||||
"target_environment": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"policy_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"catalog_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repository_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"authority_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"branch": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"commit_oid": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tree_oid": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"governed_commit_oid": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"repository_id",
|
||||
"authority_ref",
|
||||
"branch",
|
||||
"commit_oid",
|
||||
"tree_oid",
|
||||
"governed_commit_oid"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"build": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"definition_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"output_kind": {
|
||||
"const": "runtime"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"definition_ref",
|
||||
"output_kind"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"artifact": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"runtime",
|
||||
"source_archive"
|
||||
]
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sha256": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"provenance_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"ref",
|
||||
"sha256",
|
||||
"size_bytes",
|
||||
"provenance_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"components": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"host_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"service_unit": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"runtime_root": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"bind_address": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"config_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"credential_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"minItems": 0,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"owner_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"next_owner_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"health_probe_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"component_id",
|
||||
"host_ref",
|
||||
"service_unit",
|
||||
"runtime_root",
|
||||
"bind_address",
|
||||
"port",
|
||||
"config_ref",
|
||||
"credential_refs",
|
||||
"owner_ref",
|
||||
"next_owner_ref",
|
||||
"health_probe_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"rollback": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseline_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"artifact_sha256": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"restore_operation_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"restore_evidence_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"data_compatibility": {
|
||||
"enum": [
|
||||
"VERIFIED",
|
||||
"UNKNOWN",
|
||||
"INCOMPATIBLE"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"baseline_ref",
|
||||
"version",
|
||||
"artifact_sha256",
|
||||
"restore_operation_ref",
|
||||
"restore_evidence_ref",
|
||||
"data_compatibility"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"operations": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resolve_contract": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"preflight": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"build_test": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"evidence_closure": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"candidate_freeze": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"authorization_check": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"runtime_switch": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"verify": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"baseline_registry": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"acceptance": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"close": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resolve_contract",
|
||||
"preflight",
|
||||
"build_test",
|
||||
"evidence_closure",
|
||||
"candidate_freeze",
|
||||
"authorization_check",
|
||||
"runtime_switch",
|
||||
"verify",
|
||||
"tag",
|
||||
"baseline_registry",
|
||||
"acceptance",
|
||||
"close"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"risk_budget": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_cumulative_unavailability_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"max_error_rate_delta": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"observation_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"policy_source_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_cumulative_unavailability_seconds",
|
||||
"max_error_rate_delta",
|
||||
"observation_seconds",
|
||||
"policy_source_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"acceptance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"probe_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"required_receipt_kinds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"runtime_identity",
|
||||
"business_acceptance",
|
||||
"authorization",
|
||||
"backup",
|
||||
"rollback_readiness",
|
||||
"tag",
|
||||
"baseline",
|
||||
"source_lineage"
|
||||
]
|
||||
},
|
||||
"minItems": 8,
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"probe_refs",
|
||||
"required_receipt_kinds"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"evidence": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content_key": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"test_report_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"review_report_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"minItems": 0,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"closure_ref": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"content_key",
|
||||
"test_report_ref",
|
||||
"review_report_refs",
|
||||
"closure_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"workload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_tree_oid": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"files_added": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"files_modified": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"files_deleted": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lines_added": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lines_deleted": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"base_tree_oid",
|
||||
"files_added",
|
||||
"files_modified",
|
||||
"files_deleted",
|
||||
"lines_added",
|
||||
"lines_deleted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"extensions": {
|
||||
"$ref": "common.schema.json#/$defs/extensions"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schema_version",
|
||||
"kind",
|
||||
"data_class",
|
||||
"contract_id",
|
||||
"contract_revision",
|
||||
"mission_id",
|
||||
"release_run_id",
|
||||
"version",
|
||||
"state",
|
||||
"target_environment",
|
||||
"policy_ref",
|
||||
"catalog_ref",
|
||||
"source",
|
||||
"build",
|
||||
"artifact",
|
||||
"components",
|
||||
"rollback",
|
||||
"operations",
|
||||
"risk_budget",
|
||||
"acceptance",
|
||||
"evidence",
|
||||
"workload",
|
||||
"extensions"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"enum": [
|
||||
"RESOLVED",
|
||||
"FROZEN"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"source": {
|
||||
"properties": {
|
||||
"commit_oid": {
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
},
|
||||
"tree_oid": {
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"const": "FROZEN"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"source": {
|
||||
"properties": {
|
||||
"governed_commit_oid": {
|
||||
"$ref": "common.schema.json#/$defs/oid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"artifact": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "runtime"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sha256": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"provenance_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"ref",
|
||||
"sha256",
|
||||
"size_bytes",
|
||||
"provenance_ref"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"evidence": {
|
||||
"properties": {
|
||||
"content_key": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"test_report_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"closure_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rollback": {
|
||||
"properties": {
|
||||
"restore_evidence_ref": {
|
||||
"$ref": "common.schema.json#/$defs/digest_ref"
|
||||
},
|
||||
"data_compatibility": {
|
||||
"const": "VERIFIED"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"schema": "aios.component-build.v1",
|
||||
"components": [
|
||||
{
|
||||
"component_id": "formal-agent-task",
|
||||
"roots": [
|
||||
"active-runtime/app/aios_agent_task_protocol.py",
|
||||
"active-runtime/app/aios_agent_task_api_lineage_supersession.py",
|
||||
"active-runtime/app/aios_task_parameterized_recovery.py"
|
||||
],
|
||||
"extra_files": [
|
||||
"active-runtime/migrations/005_task_lineage_supersession.sql",
|
||||
"active-runtime/migrations/006_task_lineage_supersession_initial_projection.sql"
|
||||
],
|
||||
"entrypoint": "aios_agent_task_protocol:TaskStore",
|
||||
"dependency_contract_version": "aios.agent-task.capability-contract.v1",
|
||||
"config": {
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 18940,
|
||||
"writes_enabled": false,
|
||||
"credential_refs": [],
|
||||
"feature_flags": {
|
||||
"auto_claim": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"component_id": "bridge",
|
||||
"roots": [
|
||||
"active-runtime/app/aios_chatgpt_bridge_v2_server.py",
|
||||
"active-runtime/app/agent_context_api.py",
|
||||
"active-runtime/app/agent_observability_api.py",
|
||||
"active-runtime/app/agent_event_api.py",
|
||||
"active-runtime/app/aios_chatgpt_actions_api.py",
|
||||
"active-runtime/app/aios_gitops_model_api.py",
|
||||
"active-runtime/app/aios_model_api_staging.py",
|
||||
"active-runtime/app/aios_control_plane_runtime_status.py",
|
||||
"active-runtime/app/governed_unattended_release.py",
|
||||
"active-runtime/app/silent_release_execution.py",
|
||||
"active-runtime/app/release_standing_authority.py",
|
||||
"active-runtime/app/release_host_authority.py",
|
||||
"active-runtime/app/release_campaign_authorization.py",
|
||||
"active-runtime/app/owner_authorization_broker.py",
|
||||
"active-runtime/app/task_center_api.py"
|
||||
],
|
||||
"extra_files": [
|
||||
"deploy/systemd/aios-chatgpt-bridge-v2-staging.service",
|
||||
"adapters/gitops/aios_gitops_model_api.py",
|
||||
"contracts/release/v1/common.schema.json",
|
||||
"contracts/release/v1/release-contract.schema.json",
|
||||
"contracts/release/v1/authorization-bundle.schema.json",
|
||||
"contracts/release/v1/release-run-event.schema.json",
|
||||
"deploy/aios-v2/release-policy.v1.json",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/app.js",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/index.html",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/event_recovery.js",
|
||||
"overlays/user-control-center/portal/gitops-dashboard/styles.css"
|
||||
],
|
||||
"entrypoint": "aios_chatgpt_bridge_v2_server:main",
|
||||
"dependency_contract_version": "aios.chatgpt-gitops-bridge.v2",
|
||||
"config": {
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 18892,
|
||||
"writes_enabled": false,
|
||||
"credential_refs": [
|
||||
"gitea-governance-executor-token"
|
||||
],
|
||||
"feature_flags": {
|
||||
"auto_claim": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"component_id": "task-plane",
|
||||
"roots": [
|
||||
"active-runtime/app/task_plane/api.py",
|
||||
"active-runtime/app/task_plane/c6_composition.py",
|
||||
"active-runtime/app/task_plane/host_patch_executor.py",
|
||||
"active-runtime/app/task_plane/host_receipt_readers.py",
|
||||
"active-runtime/app/task_plane/authorization_composition.py",
|
||||
"active-runtime/app/task_plane/patch_contract.py",
|
||||
"active-runtime/app/task_plane/evidence_verifier.py",
|
||||
"active-runtime/app/task_plane/decision_policy.py"
|
||||
],
|
||||
"extra_files": [
|
||||
"scripts/autonomous-release/c6_host_lab.py",
|
||||
"deploy/aios-v2/c6-host-lab.requirements.lock",
|
||||
"deploy/aios-v2/c6-host-lab.config.json",
|
||||
"deploy/aios-v2/c6-host-lab.service"
|
||||
],
|
||||
"entrypoint": "app.task_plane.api:run_server",
|
||||
"dependency_contract_version": "aios.task-plane.v3",
|
||||
"config": {
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 18940,
|
||||
"writes_enabled": false,
|
||||
"credential_refs": [],
|
||||
"feature_flags": {
|
||||
"auto_claim": false,
|
||||
"aro": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"component_id": "health",
|
||||
"roots": [
|
||||
"active-runtime/app/v2r2c2_health_service.py"
|
||||
],
|
||||
"extra_files": [
|
||||
"deploy/aios-v2/systemd/aios-v2r2c2-production.service"
|
||||
],
|
||||
"entrypoint": "v2r1c6_privacy_tools:main",
|
||||
"dependency_contract_version": "aios.component-manifest.v1",
|
||||
"config": {
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 18928,
|
||||
"writes_enabled": false,
|
||||
"credential_refs": [],
|
||||
"feature_flags": {
|
||||
"runtime_verification": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schema": "aios.c6-host-lab-config.v1",
|
||||
"activation": "DIAGNOSTIC_ONLY",
|
||||
"deployable": false,
|
||||
"production_deployed": false,
|
||||
"service_type": "simple",
|
||||
"network_listeners": true,
|
||||
"listen_mode": "DYNAMIC_LOOPBACK_PORT",
|
||||
"allowed_lab_root_regex": "^/var/tmp/aios-v2r2c6-lab-[0-9]{8}([-A-Za-z0-9_.]+)?$",
|
||||
"required_safety_properties": [
|
||||
"NoNewPrivileges",
|
||||
"ProtectSystem",
|
||||
"ProtectHome",
|
||||
"ReadOnlyPaths",
|
||||
"ReadWritePaths"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Linux x86_64 CPython 3.12 isolated C6 validation environment; no production readiness claim.
|
||||
annotated-doc==0.0.5 --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101
|
||||
annotated-types==0.8.0 --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
|
||||
anyio==4.15.1 --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101
|
||||
attrs==26.1.0 --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309
|
||||
cffi==2.1.1 --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf
|
||||
click==8.5.0 --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360
|
||||
cryptography==50.0.1 --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a
|
||||
fastapi==0.141.1 --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3
|
||||
h11==0.16.0 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
|
||||
idna==3.20 --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
|
||||
iniconfig==2.3.0 --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
|
||||
jsonschema==4.26.0 --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
|
||||
jsonschema-specifications==2025.9.1 --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe
|
||||
packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
|
||||
pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
pycparser==3.0 --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
|
||||
pydantic==2.13.5 --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73
|
||||
pydantic_core==2.46.5 --hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2
|
||||
Pygments==2.21.0 --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9
|
||||
pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
|
||||
PyYAML==6.0.3 --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc
|
||||
referencing==0.37.0 --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231
|
||||
rpds-py==2026.6.3 --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6
|
||||
starlette==1.7.0 --hash=sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e
|
||||
typing-inspection==0.4.4 --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
|
||||
typing_extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
|
||||
uvicorn==0.54.0 --hash=sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=AIOS C6 isolated provisional artifact diagnostic
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Render this reference with scripts/autonomous-release/c6_host_lab.py. The
|
||||
# generated unit contains the pinned artifact, receipt, runner and contract paths.
|
||||
NoNewPrivileges=true
|
||||
# The authorized isolated root is under /var/tmp, so systemd PrivateTmp would
|
||||
# hide the only permitted runtime path from this service.
|
||||
PrivateTmp=false
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=127.0.0.1/32
|
||||
UMask=0077
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"kind": "ReleasePolicy",
|
||||
"policy_id": "AIOS-RELEASE-POLICY-V1",
|
||||
"installation_state": "NOT_INSTALLED",
|
||||
"entrypoint": "release promote VERSION",
|
||||
"read_only_prep_alias": "release prep VERSION",
|
||||
"stage_order": [
|
||||
"resolve_contract",
|
||||
"preflight",
|
||||
"build_test",
|
||||
"evidence_closure",
|
||||
"candidate_freeze",
|
||||
"authorization_check",
|
||||
"runtime_switch",
|
||||
"verify",
|
||||
"tag",
|
||||
"baseline_registry",
|
||||
"acceptance",
|
||||
"close"
|
||||
],
|
||||
"root_identity": {
|
||||
"reuse_existing_mission": true,
|
||||
"reuse_existing_release_run": true,
|
||||
"recovery_creates_attempt_not_new_goal": true
|
||||
},
|
||||
"human_policy": {
|
||||
"additional_interactions_target": 0,
|
||||
"owner_required_reasons": [
|
||||
"GOAL_CHANGE",
|
||||
"TRUST_BOUNDARY_CHANGE",
|
||||
"IRREVERSIBLE_OPERATION",
|
||||
"BUSINESS_RISK_BUDGET_EXCEEDED"
|
||||
],
|
||||
"signer_unavailable_state": "WAITING_AUTHORITY",
|
||||
"reviewer_unavailable_state": "WAITING_CAPACITY",
|
||||
"initial_request_target": 1,
|
||||
"selection_submission_target": 0,
|
||||
"receipt_or_notification_is_human_interaction": false
|
||||
},
|
||||
"timing_targets_seconds": {
|
||||
"preflight": 300,
|
||||
"ready_candidate_to_acceptance_ordinary": 3600,
|
||||
"ready_candidate_to_acceptance_hotfix": 900
|
||||
},
|
||||
"timing_measurement": {
|
||||
"always_report_request_to_acceptance": true,
|
||||
"hide_preparation_time": false,
|
||||
"exclude_failures_from_reporting": false,
|
||||
"initial_calibration_runs": 5,
|
||||
"calibration_does_not_establish_p95": true
|
||||
},
|
||||
"retry": {
|
||||
"fast_attempts": 3,
|
||||
"backoff_seconds": [
|
||||
5,
|
||||
15
|
||||
],
|
||||
"maximum_wait_interval_seconds": 300,
|
||||
"unknown_effect_requires_reconcile": true,
|
||||
"no_identical_blocker_without_new_fact": true,
|
||||
"fast_attempts_includes_initial": true,
|
||||
"dependency_probe_initial_seconds": 60,
|
||||
"exhaustion_action": "ROUTE_TO_ENGINEERING_PRODUCER_WITHOUT_NEW_OWNER_PROMPT"
|
||||
},
|
||||
"infrastructure": {
|
||||
"reuse_existing_signer": true,
|
||||
"reuse_existing_outbox": true,
|
||||
"new_mandatory_daemons": [],
|
||||
"new_mandatory_databases": [],
|
||||
"new_mandatory_message_brokers": []
|
||||
},
|
||||
"runtime_budget_source": "existing_authoritative_environment_policy",
|
||||
"missing_budget_behavior": "BLOCKED_CONFIGURATION_GAP",
|
||||
"trial_mode": "SHADOW_THEN_STAGING_THEN_SMALL_REVERSIBLE_PRODUCTION",
|
||||
"live_fixture_policy": "DENY_FIXTURE_AND_UNRESOLVED_REFERENCES",
|
||||
"rollback": {
|
||||
"reconcile_before_retry": true,
|
||||
"retain_previous_artifact": true,
|
||||
"bind_current_accepted_baseline": true,
|
||||
"close_only_after_compensation_or_failure_receipt": true
|
||||
},
|
||||
"revision": "1.1.0",
|
||||
"stage_timing_targets_seconds": {
|
||||
"request_to_start": 30,
|
||||
"preflight_aspirational": 120,
|
||||
"authorization_check": 10,
|
||||
"runtime_switch": 300,
|
||||
"verify": 300
|
||||
},
|
||||
"stage_target_status": "PROPOSED_NOT_MEASURED_DO_NOT_SHORTEN_REQUIRED_OBSERVATION",
|
||||
"failure_routing": {
|
||||
"F0": "EXISTING_RETRY_RENEW_RECONCILE",
|
||||
"F1": "ENGINEERING_OR_AUTHORITY_PRODUCER",
|
||||
"F2": "VERIFIED_OWNER_BOUNDARY_ONLY",
|
||||
"unmapped": "F1_STOP_RELATED_EFFECTS_AND_CLASSIFY",
|
||||
"mixed_boundary_precedence": true
|
||||
},
|
||||
"continuation": {
|
||||
"reuse_existing_checkpoint_and_saga": true,
|
||||
"checkpoint_is_authority": false,
|
||||
"revalidate_authority_before_effect": true,
|
||||
"preserve_root_clock": true,
|
||||
"unknown_effect_before_retry": "RECONCILE",
|
||||
"goal_valid_until": [
|
||||
"RELEASE_CLOSED",
|
||||
"REVOKED",
|
||||
"CANCELLED"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# C5 控制面闭合报告
|
||||
|
||||
状态:本地工程闭合候选已形成;live control plane 尚未闭合。本文是交付准备记录,不是生产发布、activation、Task admission 或 RC 成功证明。
|
||||
|
||||
## 已闭合的工程边界
|
||||
|
||||
架构决定和实施计划将 unattended runner 定义为编排客户端。它不拥有 Owner authority、epoch issuer、Task DB、Git writer、approval verifier 或 production executor。现有组件仍是唯一权威:`TaskAdmissionBootstrapService`/`AgentTaskAdmissionPort` 负责 envelope、grant 和 Task admission;既有 lease/principal/writer chain 负责写入;trusted builder 和 `SilentReleaseActionService` 负责 candidate/provider evidence;`ReleaseSagaStore`、`ReleaseOrchestrator` 和 `ReleaseRecoveryWorker` 负责部署、回滚与未知副作用恢复。旧的 `release_state_machine.py` 不应再作为第二状态权威。
|
||||
|
||||
本地 runner `active-runtime/app/governed_unattended_release.py` 已提供以下受限组合接口:
|
||||
|
||||
```text
|
||||
GovernedUnattendedReleaseRunner.intake(intent)
|
||||
GovernedUnattendedReleaseRunner.admit(intent, authorization)
|
||||
GovernedUnattendedReleaseRunner.publish(intent, admission_receipt)
|
||||
GovernedUnattendedReleaseRunner.postpublication_rc(event_id, source_commit, version, source_readback)
|
||||
GovernedUnattendedReleaseRunner.deploy(candidate_id)
|
||||
```
|
||||
|
||||
`SourcePublicationIntent` 对 repository、source/tree/base OID、精确 target branch、generation、execution digest、file allowlist、capability set 和 risk 做不可变规范化,并派生稳定 `task_id` 与 idempotency key。`admit` 在 envelope 已创建后遇到超时、连接断开或 5xx 时先 reconcile,不把未知结果当作未应用,也不重复提交;禁止错误不会重试。`publish` 要求同一 Task 的 `task_lease`、`task_principal`、`writer_lease`、authenticated commit receipt、base/branch/generation/execution digest/source/tree 全部绑定。缺一即阻断。
|
||||
|
||||
本地模拟器 `scripts/simulate_c5_unattended_release.py` 从 Git object 读取 source/base/tree/diff/archive,运行候选 C5 contract tests,使用临时 Owner key 和真实 admission/bootstrap/TaskStore 实现完成隔离签名与 admission,再写入现有模型的 candidate/manifest/RC JSON。输出明确标记 `SIMULATION`、`official_release=false`、`authority_source_commit=null`、`acceptance=PENDING_AUTHENTICATED_PRODUCTION_PROVIDER_EVIDENCE`、`approval=NOT_REQUESTED`、`deployment=NOT_ATTEMPTED`。这条链路可证明结构、签名验证、隔离 TaskStore、幂等和非权威 artifact 写出;不能证明 live authority、live publication、trusted production provider、release approval、runtime health 或 deployment。
|
||||
|
||||
`governed_release_evidence_producer.py` 的 `produce_evidence_closure(...)` 可生成现有 RC evidence closure,但要求 tests、canary、Gitea、rollback、secret-scan 每一类均有 trusted-provider receipt,并校验 artifact/manifest/package closure/source/tree。相同 closure 幂等,内容变化为冲突。该闭合器不得被调用来伪造 provider pass。
|
||||
|
||||
现有 release service 的正式接口仍应按以下顺序复用:
|
||||
|
||||
```text
|
||||
SilentReleaseActionService.create_release_intent(payload)
|
||||
-> get_release_candidate(candidate_id)
|
||||
-> get_release_plan(candidate_id)
|
||||
-> get_acceptance(candidate_id)
|
||||
-> verify_release_acceptance(candidate_id)
|
||||
-> get_approval_envelope(candidate_id)
|
||||
-> submit_owner_approval(signed_payload)
|
||||
-> start_deployment(candidate_id)
|
||||
```
|
||||
|
||||
`create_release_intent` 只能接收 event/source/version,并从 authenticated source admission resolver 得到版本与 admission ID;不能接收 caller evidence。`submit_owner_approval` 必须经既有 signed release-plan verifier 和 durable approval store。`start_deployment` 会重新加载 durable approval,调用既有 saga/orchestrator;caller 传入的 approval 不作为授权来源。
|
||||
|
||||
## 当前 live 未闭合
|
||||
|
||||
以下条件仍未证实,不能写成 PASS 或 COMPLETE:
|
||||
|
||||
- `authority_source_commit` 仍为 `null`;没有正式 live source publication receipt、authority Git readback、TaskLease/TaskPrincipal/WriterLease/commit closure。
|
||||
- 没有 live C5 exact source-only policy 的 Owner-signed activation、policy digest、issuer、revocation source 和 fresh epoch。不能用本地 policy/request hash、旧 activation binding、simulation epoch 或借用 credential 替代。
|
||||
- 没有 live registered machine caller 的可用 admission route;当前 runner 不能自行成为 authority issuer 或把 `engineering` 风险提升为全局允许值。
|
||||
- 没有真实 trusted builder/provider resolver 的完整绑定,尤其是 authenticated tests、canary、Gitea、rollback、secret-scan receipts;本地 candidate artifact 不是生产 evidence closure。
|
||||
- 没有 pre-authorized Owner approval broker 为精确 candidate/release plan 签名。source-only authorization 不等于 release approval。
|
||||
- 没有完成 live `SilentReleaseActionService` 的 candidate、acceptance、release plan、saga readback,也没有 deployment composition、runtime health、rollback 和 post-tag/finalization readback。
|
||||
- C6 已冻结;C5 自动化支持若进入后续发布,必须重新计算 source/tree/file binding、execution plan 和 signed authority,不能把支持代码自动并入既有 candidate。
|
||||
|
||||
## 最终本地验证
|
||||
|
||||
2026-09-26,在 Linux/Python 3.12.3 的隔离目录中完成 **184 passed** 定向回归(5.53 秒)。范围包含 epoch v1/v2、source policy、runner、真实 release service/Owner signature 集成、既有 admission/bootstrap/recovery、production contract 和 provider evidence producer。没有重跑历史全量业务测试,也没有把旧的 598 passed 计为本次新增代码的结果。
|
||||
|
||||
新增集成覆盖实际 signed builder、signed Owner approval、source substitution 拒绝、无 verifier 拒绝及 durable attestation 篡改拒绝。公开 acceptance 接口包含脱敏字段;`verify_release_acceptance` 在现有 service 内加载原始 durable 数据重新验签,保持公开脱敏规则,向 runner 返回验证摘要。
|
||||
|
||||
最终 `demo-run-4` 从不可变候选 `536a2af0f07962495c88526061ce43fb27994c15` 的 archive 执行 C5 capability N01/N02/N03,经过真实 SSHSIG 验签、真实隔离 Task admission、幂等重放和 scope/revocation 负例,成功写出 `V2R2C5_RC.simulation.json`。该 candidate 保持 `BUILDING`;正式 source OID 为空,provider acceptance 待补、approval 未请求、deployment 未执行。
|
||||
|
||||
可复核证据位于集成工作树相邻的 `../evidence/final-targeted-tests.xml`、`../evidence/demo-run-4/V2R2C5_RC.simulation.json` 和 `../evidence/demo-run-4/SHA256_MANIFEST.json`。这些结果证明隔离工程闭环,不证明 live 发布闭环。
|
||||
|
||||
## 可恢复下一步
|
||||
|
||||
1. 在现有 governed Task → TaskLease → TaskPrincipal → WriterLease → Governed Commit 链上发布 runner/epoch compatibility/exact policy guard 支持,并 read back commit/tree/receipt。
|
||||
2. 由已有 Owner authority 通过控制面激活精确 C5 source-only policy,记录 signed activation、policy digest、issuer、expiry、revocation source;随后请求 fresh epoch。禁止直接写受信任目录或复用旧 epoch。
|
||||
3. 用真实 registered caller 执行 admission;对 envelope、admission receipt、Task 状态和 authority OID 做 readback。任何 unknown effect 先 reconcile。
|
||||
4. 仅在 publication receipt、lease chain、commit receipt 和 authority readback 完整后,调用 `create_release_intent`,再由真实 provider 生成并验证 candidate、acceptance、plan、saga 和 RC evidence。
|
||||
5. 由预授权 Owner approval broker 对精确 release plan 签名,经 `submit_owner_approval` 验证并 durable 存储。缺 broker 时保持阻断,不降低 risk、不 mint client signature。
|
||||
6. 若另有明确生产授权,再调用 `start_deployment`;依赖既有 pre-switch/canary、health、rollback、tag/finalization reconciliation。否则将结果保留在已签名、可重放的 RC/approval-ready evidence,而不触碰生产。
|
||||
|
||||
## 运行时假设
|
||||
|
||||
真实组合依赖 Linux/POSIX `fcntl` locks、`fsync`/atomic rename、受限 symlink/open、Git authoritative bare repo、Gitea/ref readback、systemd service identity/restart、health endpoint、Ed25519 verification,以及 KMS/HSM/secret-manager reference。`execution_enabled=false`/shadow mode 是默认安全状态;生产执行缺少 approval verifier、trusted builder、builder attestation verifier、orchestrator、pre-switch validator 或 trusted policy 时必须 fail closed。
|
||||
@@ -0,0 +1,52 @@
|
||||
# C5 release automation implementation and rollout
|
||||
|
||||
The engineering task implements a repeatable orchestration path and isolated executable proof. It does not treat local artifacts as an activated production capability. V2R2C6 is frozen.
|
||||
|
||||
## Parallel implementation ownership
|
||||
|
||||
1. Epoch compatibility: admission bootstrap and signed-epoch provider; v1 regression, original-byte v2 verification, tamper/schema/revocation tests.
|
||||
2. Exact policy lifecycle: existing structured Owner issuer plus an exact source-publication guard; TTL, current trust, revocation, scope and configured-risk checks.
|
||||
3. Thin runner: generated immutable intake, admission/reconciliation, publication proof gate, existing trusted candidate service and signed approval/deployment entry points.
|
||||
4. Integration: isolated executable demo, targeted contract tests, architecture/gap reports and immutable local commit/archive evidence.
|
||||
|
||||
Each implementation lane uses a separate Git branch/worktree. Integration uses ordinary local commits without force-push, main mutation, TaskDB mutation or production writes.
|
||||
|
||||
## Minimum executable closure
|
||||
|
||||
Run the simulation CLI with a Git repository, committed source/base and a new output directory. It must derive the file list, tree and source archive from Git objects; run the selected checks; issue a fresh test-only Owner policy/epoch using ephemeral keys; verify them with the actual registry verifier and dual-schema provider; submit an envelope through the actual admission service against an isolated Task transport; and write an existing-model RC artifact with all evidence hashes.
|
||||
|
||||
The output must say `SIMULATION`, `official_release=false`, and retain `authority_source_commit=null`. It must not invent live provider receipts or set `READY_FOR_APPROVAL` without the existing acceptance path. Ephemeral private keys are confined to a temporary directory and excluded from exported evidence. A signed test receipt authenticates only its stated isolated environment.
|
||||
|
||||
From this implementation checkout on Linux/POSIX, with Git, OpenSSH, pytest and cryptography available:
|
||||
|
||||
```bash
|
||||
python3 scripts/simulate_c5_unattended_release.py \
|
||||
--repository /path/to/local/candidate/repository \
|
||||
--source 536a2af0f07962495c88526061ce43fb27994c15 \
|
||||
--base e5f8209f57b43fc39e55c4555776e6362ce326eb \
|
||||
--output /path/to/new/isolated/evidence-directory
|
||||
```
|
||||
|
||||
The command creates its own isolated Task store and ephemeral signing domain. It has no live transport or deployment adapter. No manually assembled JSON, existing Owner credential, service restart or production endpoint is needed.
|
||||
|
||||
## Required checks
|
||||
|
||||
- v1/v2 consumer compatibility, original bytes, issuer/key/namespace/digest, unknown schema, invalid fields, expiry and revocation.
|
||||
- Existing medium policy still denies engineering; exact source policy denies any changed branch/base/source/tree/files/caps/execution binding.
|
||||
- Missing verifier and missing/failing revocation lookup deny; admission replay rechecks authorization.
|
||||
- Stable intake identity, bounded retry, no retry for policy rejection, unknown side-effect reconciliation and conflicting receipt rejection.
|
||||
- Missing TaskLease/principal/WriterLease/commit/readback cannot produce a live RC.
|
||||
- Existing signed approval, provider evidence, saga integrity, deployment verification, rollback and post-tag reconciliation contracts.
|
||||
- C5 capability negatives N01/N02/N03 and affected regression tests only; historical full-suite counts are not reused as evidence for new changes.
|
||||
|
||||
## Governed rollout after local verification
|
||||
|
||||
1. Publish the support commit through the existing Task -> TaskLease -> TaskPrincipal -> WriterLease -> Governed Commit chain. Read back its OID/tree/receipt.
|
||||
2. Governed-install the dual-schema consumer and exact source-only policy guard on the existing control-plane service, preserving the old artifact for rollback.
|
||||
3. Activate the exact policy and dedicated machine caller using the existing Owner authority. Record signed activation, policy digest, issuer and revocation source. This is a provisioning step, not an implicit authority granted by the runner.
|
||||
4. Request a fresh epoch and execute C5 admission/publication. Rebind evidence to the authority-readback OID; it may differ from the local candidate.
|
||||
5. Run actual build/test/security/canary/rollback providers and their authenticated resolver. Invoke `create_release_intent` against the authority source; read back candidate, acceptance, plan and saga.
|
||||
6. Have the pre-authorized Owner broker sign the exact release plan, and submit it through `submit_owner_approval`. Source-only authorization is insufficient here.
|
||||
7. Call the existing deployment controller with approved durable state. Canary/pre-switch, health, promotion, registry/baseline finalization and rollback follow the existing saga. Verify runtime identity and final consistency.
|
||||
|
||||
Missing live authority or provider composition is a durable blocker, never a reason to lower risk, bypass G0, mint signatures in the client or call production restart directly.
|
||||
@@ -0,0 +1,60 @@
|
||||
# C5 governed unattended release: architecture decision
|
||||
|
||||
Status: selected for isolated implementation; live activation is not implied.
|
||||
Task: TASK-AIOS-C5-GOVERNED-UNATTENDED-RELEASE-PLATFORM-DESIGN-AND-IMPLEMENTATION-20260926-R1
|
||||
|
||||
## Decision
|
||||
|
||||
Select **B, additive v1/v2 epoch consumption**. Verify the producer's original canonical signed bytes and retain the actual schema, issuer, key, policy digest and revocation semantics. Project common typed fields only after verification. A v2 payload is never relabelled v1 and its signature is never copied onto another payload.
|
||||
|
||||
The producer's `policy_digest` hashes the Owner policy. It is not interchangeable with the default Task Admission policy digest. The existing policy factory propagates this digest; the compatibility change preserves that binding. A v2 `canonical_payload_digest` covers the payload before that field is added, while the signature authenticates the complete original canonical payload.
|
||||
|
||||
Option A would either be the same dual-schema verification hidden behind a misleading v1 label, or require a new signature. Option C would change more producers and consumers without resolving the immediate authority/composition gap. B preserves v1 operation and has the smallest rollback surface.
|
||||
|
||||
## One control plane
|
||||
|
||||
The unattended runner is an orchestration client. It does not become an authority issuer, Task database writer, Git writer, approval verifier, release state machine, or production executor.
|
||||
|
||||
| Responsibility | Existing authority/component |
|
||||
|---|---|
|
||||
| Owner signatures and narrowing | `LocalOwnerAuthority`, registered Owner key and configured Owner policy |
|
||||
| Epoch verification | `SignedEpochDirectoryProvider`, `RegistryOwnerApprovalVerifier` |
|
||||
| Exact source-only policy | Structured Owner-signed policy, verified request guard |
|
||||
| Envelope, grant and Task admission | `TaskAdmissionBootstrapService`, `AgentTaskAdmissionPort` |
|
||||
| Claim, principal and WriterLease | Existing Task/lease/governed writer chain |
|
||||
| Source commit proof | Governed commit receipt plus authority Git readback |
|
||||
| Build, provider evidence and candidate | Trusted builder, provider resolver, `SilentReleaseActionService` |
|
||||
| Approval | Existing signed release-plan approval verifier and durable approval store |
|
||||
| Deployment and recovery | `ReleaseSagaStore`, `ReleaseOrchestrator`, `ReleaseRecoveryWorker` |
|
||||
|
||||
The older linear `release_state_machine.py` is not added as another release state authority. The runner consumes durable state from the existing stores. Recovery of an unknown mutation reconciles that state before another write.
|
||||
|
||||
## Least privilege and unattended authority
|
||||
|
||||
The source-only policy binds repository, source commit/tree, base commit, exact target branch, generation, execution digest, file set, capability set, engineering risk, expiry and fresh identity. It cannot authorize main writes, tags, deployment, baseline mutation or historical Task mutation. The global admission ceiling stays `medium`; an exact verified request may use its separately signed engineering policy. A different request must be denied before envelope creation.
|
||||
|
||||
Recognizing the `engineering` rank in the issuer does not raise any configured ceiling. An existing `medium` Owner policy still rejects engineering issuance. The structured policy issuer must narrow its configured Owner policy; it cannot install or activate a policy for itself.
|
||||
|
||||
Each authorization rechecks signature, current key trust, expiry, revocation and scope. No automatic extension of expired epochs is allowed. A controller requests a fresh epoch under an already activated Owner policy. Source-only authority cannot be exchanged for release approval. Automatic release approval requires a separately delegated Owner broker to sign the exact candidate and release plan; missing or revoked delegation stops the workflow.
|
||||
|
||||
“No interaction” is an execution property after governed provisioning. It is not permission to create a trust root or grant the controller new powers. Current production has not been provisioned for this new composition.
|
||||
|
||||
## Retry and recovery
|
||||
|
||||
Task and event identities derive from immutable input. Retries retain them. Policy rejection is terminal until policy/input changes. A timeout is not proof that a write failed: use the admission receipt/official Task readback, publication receipt and authority OID, or existing release saga/operation journal to reconcile. Unknown or conflicting effects remain blocked. Transport calls require bounded timeouts; a controller deadline alone cannot cancel a remote mutation.
|
||||
|
||||
Build and acceptance evidence are obtained through trusted providers, not caller-supplied pass flags. A local simulation artifact is explicitly marked non-authoritative. Its source OID, archive hash and cryptographic checks can be real without proving live publication, runtime acceptance or deployment.
|
||||
|
||||
## Risks and rollout
|
||||
|
||||
1. Schema acceptance alone does not activate a usable policy. Roll out the consumer and exact policy guard together through governed control-plane publication.
|
||||
2. The existing deployed Owner policy does not authorize the C5 scope, and the current caller has no usable authenticated activation/epoch route. Provision the narrowly scoped service identity and policy through the existing governance plane. Do not use direct root file writes as a substitute.
|
||||
3. Signed source policy and epoch must agree on the Owner policy digest. Mixing epochs, digests or request identities fails closed.
|
||||
4. The local source candidate `536a2af0f07962495c88526061ce43fb27994c15` is preserved. Automation support changes are separate commits. Any decision to include them in the release must regenerate the governed execution plan and exact file/source bindings.
|
||||
5. The existing deployment engine contains canary/pre-switch, runtime verification, rollback and post-tag reconciliation logic. Tests establish adapter contracts; they do not prove current production composition or health.
|
||||
|
||||
## Rollback
|
||||
|
||||
Disable the new runner, revoke its source-only policy/epoch and stop new admissions. Preserve journals, receipts, immutable Tasks and candidate records. Revert the consumer/support module deployment to the prior governed control-plane artifact; v1 remains supported throughout migration. Never rewrite already issued signed payloads or historical Task state.
|
||||
|
||||
Production deployment rollback remains the existing signed plan's rollback operation and release saga. A post-tag/finalization uncertainty requires reconciliation; it must not be hidden by an automatic tag rewrite. C4 stays the production baseline until a separately approved C5 deployment completes runtime verification.
|
||||
@@ -0,0 +1,57 @@
|
||||
# C6 主机接入设计与交接边界
|
||||
|
||||
Agent-B 在 `e87c70778aa0911436cac5b59f1aa11080c5ba77` 集成基线上补主机适配。C5 生产冻结与发布由 Agent-A 负责;本补丁不反向合入 C5。持续业务授权和短期执行凭证分离,原 Saga、Mission/Run、动作身份和验签合同保留。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[版本级业务授权与实时撤销状态] --> B[签名 current-record reader]
|
||||
S[Task / scope / lineage 权威记录] --> B
|
||||
B --> C[既有 StandingReleaseAuthorizationBroker]
|
||||
K[目的受限的已登记 release signer] --> C
|
||||
C --> D[短期精确执行凭证]
|
||||
D --> E[既有 Controller / Saga]
|
||||
E --> F[既有 TaskPlane 自动领取与租约]
|
||||
F --> G[精确 RootAction 持久 intent / effect]
|
||||
G --> H[正式 Bridge / WriterLease / 外部 CAS fence]
|
||||
H --> I[真实效果读回]
|
||||
V[独立 verifier 签名回执] --> J[既有三层 closure reducer]
|
||||
P[独立业务 acceptor 签名回执] --> J
|
||||
I --> J
|
||||
J --> A
|
||||
```
|
||||
|
||||
## 信任和执行接口
|
||||
|
||||
1. `release_host_authority.VerifiedCurrentRecordReader` 每次生成随机 challenge,校验响应目的、查询摘要、父授权摘要、时间窗口、已登记密钥与原始 Ed25519 签名。主机配置固定 purpose→key;响应不能带入新信任根。权威端必须每次读真实当前状态后签 challenge,不能给旧 ACTIVE snapshot 重新加时间。
|
||||
2. `HostReleaseAuthorityReaders` 将验签后的精确记录转换为既有 StandingReleaseAuthorization / ReleaseExecutionScope。执行范围绑定同轮父授权记录。broker 在签发前后和 effect 前重读,不用旧签名掩盖撤销、完成、关闭、根或范围变更。
|
||||
3. `RegisteredReleaseSignerCommand` 只向已登记 release approval signer 提交既有 `SignedReleasePlanApproval.canonical_bytes()`;校验 key、算法、消息摘要和 raw Ed25519 签名后返回原合同。SSHSIG armor 不是 raw Ed25519 签名,禁止解码冒充。发布 broker 不代替 TaskPrincipal、WriterLease、promotion/source authority。
|
||||
4. `task_plane.host_patch_executor` 连接现有 C6 pilot seam 与外部持久事务所有者。解析真实 ExecutablePatchV1;claim 不是 WriterLease。实际提交必须在外部事务线性化点检查当前授权代次、租约/fence 和 expected OID CAS。内存 callback、本地进程锁或测试事务不能证明生产跨主机原子性。
|
||||
5. `HostOperationReceiptReaders` 读取独立 verifier 与 business acceptor 的签名挑战响应。主机固定角色身份、不同 key 与 acceptance revision;执行者不能验证/验收自己。输入不得携带 `authenticated` 字段,只有验签和角色绑定成功才在内部观测中派生该值。原 closure reducer 再核对 effect receipt、结果摘要、verification receipt、时效和 acceptance revision。
|
||||
|
||||
RootAction 的 host binding 固定授权引用、授权代次、公钥指纹、Task/Job/claim/fence、补丁、计划与实际提交内容。短期观测的 challenge 和有效窗口单独验证,不成为新动作身份。普通旧式 `run_once` 不得领取这些已绑定 C6 动作;C6 的恢复先枚举既有账本未闭合 intent,再做领取发现,避免任务退出候选列表后遗留未知效果。恢复入口仅允许精确对账,不能重新 acquire/commit。
|
||||
|
||||
动作提交成功与三层验收闭合分别记录:尚缺独立验证或业务验收的已提交动作仍属于重启后的待闭合集合。闭合记录必须从受信回读重新核算精确 receipt 与摘要链,并在原 effect 行保存验收证据摘要;调用方自报成功不能移除待闭合动作。该记录用于恢复调度,不替代原始独立服务回执。
|
||||
|
||||
## 验收回读协议
|
||||
|
||||
`compose_host_release_controller` 提供实际组合入口,分别注入 current-record 与 approval 的目的受限 keyring,并配置 key_id→raw 公钥 SHA256;不同别名不能绕过目的隔离。组合冷启动不读取短期服务,实际签发/执行时才读并核验。内置 `BoundedCommandTransport` 只支持 POSIX 主机,以进程组、总时间和总输出上限管理提供方及子进程;Windows 在启动子进程前拒绝此 transport。字节协议 reader、签名适配及控制器仍可在 Windows 做隔离验证。
|
||||
|
||||
purpose 分别为 `operation-verification`、`operation-acceptance`。query 包含完整 OperationBinding、binding_digest、expected_identity、expected_key_id、expected_key_fingerprint;acceptance 另含 expected_acceptance_revision。响应使用 current-record v1 envelope;record 是原 operation-verification/acceptance-observation v1 的 schema 与对应 observation,省略 authenticated。acceptance 的 expected 身份和 revision 来自主机配置,不能由外部 receipt 自报。两角色的已登记身份、key_id 和 raw Ed25519 公钥 SHA256 均须不同,不能用同一公钥的不同别名绕过独立性检查;keyring 轮换必须显式更新对应主机策略,不能静默换钥。
|
||||
|
||||
公开业务检查、部署/恢复检查由实际独立服务生成。适配器不生成成功回执、不写平台 registry,也不把聊天角色当正式 worker 身份。缺服务、密钥、授权或当前记录时停止相关 effect,仍可继续诊断与其他独立工程。
|
||||
|
||||
## 安装和证据
|
||||
|
||||
`c6_host_lab.py` 验证 immutable artifact、sidecar、组件配置与 unit 字节,拒绝危险归档路径、链接、占位符及摘要漂移。实验服务使用独立目录、非 root UID 和 loopback 端点;必须分别记录进程启动/重启、真实组件加载和权限拒绝,不能把静态 systemd verify 或自定义 health JSON 当生产验收。
|
||||
|
||||
隔离测试使用临时密钥、仓库和事务,证据仅证明实现行为。正式主机仍需已登记 authority/scope/signer、patch worker 与 transaction owner、独立 verifier/acceptor。最终版本必须重新绑定 Agent-A 真实验收的 C5 source/tree/artifact/config/rollback。所有 provisional 制品维持 deployable=false;签名、candidate 或 source 变更必须重建摘要,不能复用旧批准。
|
||||
|
||||
存储迁移只在既有 root-action effect 账本增加可空字段和唯一索引,保留旧状态与未知副作用语义;旧 DB 重开回归独立验证。生产恢复到 C5 还必须关闭 C6 执行调度并使旧消费者不能领取遗留 C6 动作,未知效果保持隔离对账。不能仅切旧代码后继续消费新账本,也不能删除新增列、回灌旧 DB 或丢弃新任务来伪造恢复成功;这属于最终 C5 恢复合同尚需实测的门槛。
|
||||
|
||||
当前 Public Bridge 的 WriterLease 返回合同若没有真实 lease ID 和 fence,精确执行入口拒绝提交;不能由客户端自行生成这些字段。外部授权代次、租约与 OID 的原子校验仍必须由正式写入端兑现。崩溃后的纯对账路径不会因为本地记录为 LEASE_ACQUIRED 就自动重发提交;恢复调用方和正式写入端必须证明可安全续行后才能接通对应恢复能力。
|
||||
|
||||
`deploy/aios-v2/c6-host-lab.requirements.lock` 固定 Linux x86_64 / CPython 3.12 的 27 个 wheel 版本和 SHA256。这是隔离验证环境锁,不声明其他平台兼容。交接 wheelhouse 可用 `python -m pip install --no-index --find-links wheelhouse --require-hashes -r c6-host-lab.requirements.lock` 离线安装到新 venv;本次已实际重建并通过 `pip check` 和 cryptography/FastAPI/Uvicorn/pytest/jsonschema/yaml 导入。不要安装到系统 Python 或修改生产环境。
|
||||
|
||||
## 给 Agent-A 的串行配置边界
|
||||
|
||||
交接包给出固定源码、配置模板、协议、测试和摘要后,由 A 协调正式共享变更窗口。需要核对现有服务身份和 exact signer 协议,注册/配置只能走已有治理能力;不让 Owner 手工拼接签名。B 在正式写权交接前只操作自己的隔离 lab。未观测到可用服务时,缺口按 authority/signer/worker/fence/verifier 分项登记,不简写成“只等 C5”。
|
||||
@@ -0,0 +1,80 @@
|
||||
# V2R2C5 C5 mainline consolidation source record
|
||||
|
||||
## Scope and authority boundary
|
||||
|
||||
This maintenance candidate is reconciled onto current main
|
||||
`e33664f03302fd9de7635afdd8aa9339a50e5f0a` (tree
|
||||
`92ece1e9646ac2d12b4b467c82b1279ac1e11608`) and preserves its V2R2C6 source
|
||||
and security changes. The original reviewed selection was made against
|
||||
`b915386ccbed46a7ae77220887da8ae666649a79`. Current main already contains the
|
||||
product source commit; the remaining five reviewed source commits and isolated
|
||||
fixtures are carried by this candidate. This is a source and test consolidation
|
||||
only. It does not create a production baseline, update the runtime registry,
|
||||
activate a Task or Agent, publish a tag, or authorize a protected merge. The
|
||||
production source, tag, artifact, and existing baseline records remain immutable
|
||||
inputs to their validators.
|
||||
|
||||
| Layer | Commit | Current-main relationship |
|
||||
| --- | --- | --- |
|
||||
| installer / G1 | `7e14abeb6b91978ce16f3d903b6c0714af302991` | integrated by this candidate |
|
||||
| builder | `8fc61bd11213bf22cc795ba933a5f367f8831f82` | integrated by this candidate |
|
||||
| completion | `66530975f52a4d6f1449f18d23af808e0c9339bb` | integrated by this candidate |
|
||||
| deployment | `5443c93f00ce879e1025a53258557788da4dbcec` | integrated by this candidate |
|
||||
| boundary / provider | `74c0eb75348b752c82f45e53759edfff5a47e3ef` | integrated by this candidate |
|
||||
| product | `5f65e458957dd51c9973cfa956a624f3cbf54990` | already an ancestor of current main |
|
||||
|
||||
The reviewed selection manifest contained 41 paths and eight non-identical
|
||||
overlap records. No receipt, journal, live baseline, runtime registry record,
|
||||
or other generated production state was used as source.
|
||||
|
||||
## Per-file decisions
|
||||
|
||||
| Classification | Paths | Decision |
|
||||
| --- | --- | --- |
|
||||
| source | `active-runtime/app/aios_baseline/service.py`; `aios_capability_contract.py`; `aios_task_principal_v3_receipt_provider.py`; `approved_release_executor.py`; `c5_exact_deployment_adapter.py`; `c5_registered_installer_executor.py`; `c5_staging_installation.py`; `governed_publication_client.py`; `release_source_admission.py`; `task_plane/live_governed_writer_adapters.py` | Integrated G1/product behavior and fail-closed validators. |
|
||||
| builder source | `active-runtime/app/aios_trusted_candidate_builder.py` | Kept the later builder contract and integration validation. Replaced historical live policy identity constants with values read from the governed policy. |
|
||||
| completion source | `active-runtime/app/c5_completion_api.py`; `c5_completion_phase.py`; `c5_completion_renewal.py`; `c5_completion_signer.py`; `silent_release_hardening.py` | Retained completion-only successor validation. Authority root, state root, Owner key, predecessor fingerprint, and historical Saga head now require a root-owned digest-bound bindings file. |
|
||||
| shared completion/boundary source | `active-runtime/app/c5_release_execution_host.py` | Retained the completion host and added the boundary integration-policy checks. Source-admission Task identity is now supplied by the digest-bound host configuration. |
|
||||
| deployment source | `active-runtime/app/c5_production_deployment_adapter.py` | Applied the later strict symlink-resolution check to the integrated installer version. |
|
||||
| provider source | `active-runtime/app/trusted_evidence_providers.py` | Retained artifact-to-extracted-tree binding and structured gitleaks JSON handling. |
|
||||
| helper | `scripts/build_c5_live_principal_overlay.py`; `scripts/build_c5_staging_artifact.py`; `scripts/probe_issuer_v3_r2_readonly.py` | Kept deterministic/read-only helper behavior. Overlay creation now requires explicit Task and Agent bindings; the checked-in example uses synthetic fixture identities. The staging artifact producer emits the final consumer's fixed 128-tilde `c5-r14-source.conf` drop-in path. |
|
||||
| fixture | `tests/fixtures/c5-r14-execution-plan.json`; `tests/fixtures/issuer-v3-base/*`; `tests/fixtures/trusted-candidate-builder-policy-c5.json` | Kept only isolated synthetic Task/Agent identities. Moved the C5 builder policy from runtime config to test fixtures. |
|
||||
| tests | `tests/baseline_v1_1/test_claim_heartbeat.py`; `tests/baseline_v1_1/test_loop.py`; `tests/task_plane/test_live_governed_writer_adapters.py`; `tests/test_c5_builder_contract.py`; `tests/test_c5_completion_renewal.py`; `tests/test_c5_exact_deployment_adapter.py`; `tests/test_c5_production_deployment_adapter.py`; `tests/test_c5_registered_installer_executor.py`; `tests/test_c5_release_execution_host.py`; `tests/test_c5_staging_artifact.py`; `tests/test_c5_staging_installation.py`; `tests/test_governed_publication_client.py`; `tests/test_issuer_v3_overlay_builder.py`; `tests/test_issuer_v3_receipt_provider.py`; `tests/test_signer_contract_compatibility.py`; `tests/test_trusted_candidate_builder.py`; `tests/test_trusted_evidence_providers_harness.py`; `tests/test_v2r2c5_capability_contract.py` | Integrated the selected regression coverage and resolved overlap tests in favor of the combined security contract. Execution-enabled heartbeat fixtures explicitly install trusted identity and evidence verifiers. The staging-artifact fixture builds a disposable Git source repository and never reads a live release tree. |
|
||||
|
||||
## Overlap resolution
|
||||
|
||||
1. Builder source and tests use the later builder contract while preserving G1
|
||||
admission and rollback validation.
|
||||
2. Deployment source and tests retain the integrated adapter and add the later
|
||||
strict symlink target checks.
|
||||
3. The release host retains completion-only support and the boundary/provider
|
||||
integration checks; the boundary host was not accepted wholesale because it
|
||||
would remove completion behavior.
|
||||
4. `silent_release_hardening.py` retains the completion dependency and derives
|
||||
its state-root check from the registered completion authority while keeping
|
||||
the C6 standing-authorization guard and credential fencing.
|
||||
5. The six current-main overlaps were reconciled without whole-file selection:
|
||||
the baseline evidence changes were identical, the capability vocabulary kept
|
||||
the C6 digest additions, staging tests kept C6 file-mode fixtures, and the C5
|
||||
completion dependency was combined with C6 authorization guards.
|
||||
6. The unsigned staging producer and the final consumer now share the governed
|
||||
fixed drop-in identity. The consumer's path, mode, archive, hash, and signature
|
||||
validators remain unchanged and fail closed on any other producer path.
|
||||
|
||||
## Excluded ephemeral material
|
||||
|
||||
The candidate contains no live one-time Task or Agent identity, TTL, nonce,
|
||||
approval fingerprint, activation file, or default historical Saga-head binding.
|
||||
It also contains no copied receipt, live journal, baseline, registry projection,
|
||||
signer credential, or production service state. Immutable source/tree/artifact,
|
||||
lineage, hash, expiry, authentication, and compare-and-swap checks remain in the
|
||||
code. Test-only identities are explicitly named `fixture-*` or `FIXTURE-*`.
|
||||
|
||||
## Verification record
|
||||
|
||||
Verification is recorded against the committed candidate and its exported
|
||||
snapshot. Windows results are treated as development evidence only where POSIX
|
||||
ownership, mode, or symlink semantics are unavailable. The authoritative full
|
||||
regression is run from the exported tree in a disposable Linux unit with empty
|
||||
capabilities, private network and temporary directories, a read-only system,
|
||||
and no signer, live configuration, or Task database access.
|
||||
@@ -0,0 +1,99 @@
|
||||
# V2R2C6 acceptance record
|
||||
|
||||
Current review: see [V2R2C6_MAINLINE_VERIFICATION_20261001.md](V2R2C6_MAINLINE_VERIFICATION_20261001.md).
|
||||
The 2026-09-26 facts below are historical. C6 is now based on the accepted
|
||||
`V2R2C5` tag (`5f65e458957dd51c9973cfa956a624f3cbf54990`); source integration,
|
||||
component tests, and isolated Linux proofs have advanced. Production C6
|
||||
acceptance remains unestablished. Do not use the historical base or gate rows
|
||||
below as current deployment facts.
|
||||
|
||||
Status: **DEVELOPMENT CANDIDATE — NOT RELEASE ACCEPTED**.
|
||||
|
||||
This record separates source verification from package, host, governed runtime,
|
||||
and business acceptance. Final commit/tree and artifact identities are recorded
|
||||
in the delivery `SOURCE_MANIFEST.json`; validation commands/logs accompany it.
|
||||
|
||||
## Identity and provenance
|
||||
|
||||
| Item | Recorded fact | Acceptance effect |
|
||||
|---|---|---|
|
||||
| C5 base | local candidate `536a2af0f07962495c88526061ce43fb27994c15` | Pinned development ancestry only. It is not evidence that C5 was authority-published. |
|
||||
| C6 branch | `agent/v2r2c6-autonomy-20260926` | Local development branch; no protected-ref or publication claim. |
|
||||
| Final C6 HEAD | delivery `SOURCE_MANIFEST.json` | Exact committed source is required before merge/release review. |
|
||||
| Remote publication | not performed in this work | No C6 branch, tag, mainline, artifact, or deployment publication is claimed. |
|
||||
|
||||
## Acceptance gates
|
||||
|
||||
| Gate | State | Evidence and remaining requirement |
|
||||
|---|---|---|
|
||||
| A. Requirement/file traceability | LOCAL SOURCE MAPPED | F1-F7, the architecture input and implementation mappings are recorded in the C6 documents. Delivery inventory binds changed files to committed hashes; runtime gaps remain listed below. |
|
||||
| B. Local source behavior | PASS IN RECORDED SCOPE | 734 passed, 3 POSIX skips, 17 subtests passed. Includes the full TaskPlane test directory and selected related C5/C6 suites; it does not prove production behavior. |
|
||||
| C. Repository-wide supported-platform regression | NOT ESTABLISHED | Windows cannot collect all Linux-only modules and absent external dependencies. The successful related-suite command is recorded separately and is not a repository-wide result. |
|
||||
| D. Component artifact/config | NOT ACCEPTED | Component/build-spec code and tests exist. Final immutable archive identity, extracted-package import, service-UID load, actual config digest, governed install receipt, readback and rollback evidence are required. |
|
||||
| E. Host composition | NOT TESTED | C6 is default-off. A real Root reader, pinned registry, registered worker authority, verifier, effect/readback owner, durable-intent owner, governed pilot and WriterLease/CAS adapter have not been bound and observed together. |
|
||||
| F. Governed end-to-end | NOT TESTED | No live admission → claim/heartbeat → TaskPrincipal → WriterLease → durable intent/effect → exact readback → independent verification → authorized acceptance run is recorded. |
|
||||
| G. Linux/restart/rollback | NOT TESTED | Clean Linux install, effective systemd config, service UID, listener owner, restart, stale observation, rollback and post-rollback readback remain required. Windows POSIX no-follow tests were skipped and the implementation fails closed there. |
|
||||
| H. Release/business acceptance | NOT ACCEPTED | C5 publication ancestry, C6 governed publication, independent release review, deployed runtime identity and authorized business acceptance are absent. |
|
||||
|
||||
## Functional acceptance statements
|
||||
|
||||
- Authorization planning is read-only. It may find an existing exact Root path,
|
||||
but cannot manufacture or extend Root authority. Any write still requires the
|
||||
existing governed WriterLease/CAS path.
|
||||
- Automatic claim is authoritative only when the store adapter performs all
|
||||
schedulability and authorization rechecks in the claim transaction. Its claim
|
||||
receipt is not a WriterLease.
|
||||
- Parallelism is accepted only for disjoint local resource scopes. Same-effect
|
||||
and same-branch writes remain serialized by the authoritative effect owner and
|
||||
WriterLease/CAS, including across processes and hosts.
|
||||
- Missing or ambiguous effect evidence stays `UNKNOWN`; it does not authorize a
|
||||
retry. ACK-loss recovery uses exact effect-key readback.
|
||||
- P6 is intentionally narrow: it accepts an exact, unexpired Owner-signed
|
||||
terminal decision and emits an idempotent `NOOP` receipt. It provides no
|
||||
resume, terminal reopen, effect execution, or successor creation path.
|
||||
- ARO Phase A is default-off and read-only across eight dimensions. Its six-value
|
||||
decision output and audit report do not grant authority or mutate task,
|
||||
policy, Git, release, or business state.
|
||||
- Execution recorded, independently verified, and business accepted remain
|
||||
separate gates. A component test, self-report, Shadow result, health endpoint,
|
||||
archive checksum, or verifier `UNKNOWN` cannot close business acceptance.
|
||||
|
||||
## Evidence accepted at this stage
|
||||
|
||||
The following evidence is accepted only as local component evidence:
|
||||
|
||||
- operation/readback/patch focused suite: 24 passed;
|
||||
- ARO/decision focused suite: 28 passed;
|
||||
- auto-claim, schedulability and capability contract workstream suite: 57 passed;
|
||||
- signed terminal-NOOP recovery plus lineage/API workstream suite: 38 passed;
|
||||
- external API security and related legacy tests: 25 passed, 2 Windows skips.
|
||||
|
||||
The skips cover POSIX directory-descriptor/no-follow behavior and require a Linux
|
||||
run. Package, runtime identity, evidence composition, full regression and live
|
||||
host totals are intentionally not inferred or filled from the presence of test
|
||||
files. Final commands, environment, exit codes/counts and artifact hashes
|
||||
accompany the delivery manifest. Source regression ran on Windows 11 / Python
|
||||
3.14.7: 734 passed, 3 skipped, 17 subtests passed in 112.85 seconds (runner wall
|
||||
time 115.27 seconds). Six Windows socket shutdown thread warnings remain in the
|
||||
log. The first aggregate failed six fixture time assertions; observation-time
|
||||
clocks fixed them, then the same complete related suite passed. No production
|
||||
behavior was relaxed to satisfy the fixture.
|
||||
|
||||
## Required release evidence still absent
|
||||
|
||||
1. Authority-published C5 identity and ancestry reconciliation against the local
|
||||
C5 candidate used for development.
|
||||
2. Independent release review and complete CI/test record on supported Linux
|
||||
environments; local HEAD/tree and related-suite inventory are delivered.
|
||||
3. Immutable component archives, build spec/config digests, dependency and
|
||||
rollback identities, governed installation receipt and readback.
|
||||
4. Trusted Linux host observation of loaded module/config/process identities,
|
||||
restart behavior, rollback, and drift detection.
|
||||
5. A real default-off-to-enabled composition with Root/registry/worker/evidence/
|
||||
intent/readback/WriterLease adapters and the registered governed source-patch
|
||||
pilot.
|
||||
6. The complete governed vertical flow, independent verifier result, authorized
|
||||
business acceptance and exact OID/effect reconciliation.
|
||||
|
||||
Until those records exist, the release verdict remains **NOT ACCEPTED**. This
|
||||
does not invalidate the focused source tests; it limits what they prove.
|
||||
@@ -0,0 +1,164 @@
|
||||
# V2R2C6 architecture and compatibility
|
||||
|
||||
2026-09-29 update: `V2R2C6_TWO_LAYER_AUTHORIZATION.md` specifies the concrete
|
||||
standing release broker, signed parent/actor binding, short credential renewal,
|
||||
stable action identity and actual effect guards. It builds on C6 `faf6eb2` and
|
||||
retains the existing Saga, approval store and capability ledger. Local host
|
||||
composition is provided; registered production readers/signer and external
|
||||
write fencing still require host integration and runtime acceptance.
|
||||
|
||||
2026-09-28 update: the branch incorporates reviewed C5 source
|
||||
`2af1dfbc40277ba1d89c8fae8b18098d40e51672` through merge `d54f832`.
|
||||
The release controller, standing-authorization routing and task/event projection
|
||||
changes are described in `V2R2C6_RELEASE_CONTROL_INTEGRATION.md`. These are local
|
||||
source changes; host composition and production acceptance remain separate.
|
||||
|
||||
Original development base: C5 local candidate `536a2af0f07962495c88526061ce43fb27994c15`
|
||||
(tree `9f27f76e7fa5561f90ff8bfe26fd18865b4a5ebb`). This is a source ancestry
|
||||
statement, not proof that C5 has been released. C6 is developed on
|
||||
`agent/v2r2c6-autonomy-20260926`; protected refs and deployed state are outside
|
||||
this development change.
|
||||
|
||||
## Authorization and automation
|
||||
|
||||
The Owner's existing authorization remains the trust boundary. A read-only
|
||||
planner checks the pinned Git capability declaration, exact task/actor/mission,
|
||||
repository/branch/source/file scope, time, budget and revocation. It never signs
|
||||
or extends a Root. `acquire_authorized_writer` re-reads those inputs and delegates
|
||||
only to the existing `GovernedWriterAutomation.acquire_verified` path. A valid
|
||||
Root can support the existing bounded Epoch issuance; an expired or widened
|
||||
Root requires the established Owner authorization flow.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
G[Immutable Git capability registry] --> P[Read-only authorization planner]
|
||||
R[Existing verified Root authority] --> P
|
||||
P --> W[Existing governed writer automation]
|
||||
I[Authenticated registered workers] --> C[C6 host tick / Task API]
|
||||
C --> S[Schedulability proof and atomic recheck]
|
||||
S --> T[Existing TaskPlane SQLite authority]
|
||||
T --> X[Registered source patch pilot]
|
||||
X --> W
|
||||
W --> F[TaskPrincipal / WriterLease / CAS]
|
||||
F --> O[Authoritative effect readback]
|
||||
O --> V[Independent verification]
|
||||
V --> A[Authorized business acceptance]
|
||||
```
|
||||
|
||||
`bind_c6` explicitly binds the existing TaskPlane service to its existing store,
|
||||
registered worker provider and operation-bound worker receipt verifier, pinned
|
||||
capability map, authoritative observation readers, durable intent reader and
|
||||
governed source patch pilot. The default configuration is disabled.
|
||||
The API host owns the optional tick; no second scheduler daemon, authorization
|
||||
database, signer, Task database or ExecutionLease is introduced. An installed
|
||||
module, enabled flag, or healthy HTTP response does not grant execution rights.
|
||||
|
||||
The only execution pilot is `governed_source_patch_apply`. A task must carry its
|
||||
immutable `c6_operation_binding_digest` and generation; that digest binds actor,
|
||||
tenant, task/job, source, branch, capability namespace, input/patch/plan/policy,
|
||||
scope and effect identity. Runtime preflight is read-only and repeated before
|
||||
effect. A pilot must persist an exact execution intent and the host must read
|
||||
that receipt back before execution. The current claim, authorization, freeze
|
||||
and exact effect state are checked again under the host authority guard before
|
||||
the pilot's short write critical section. Planning, model calls and general
|
||||
agent work must occur outside that section. The registered executor remains
|
||||
responsible for write-time Root/revocation/budget/WriterLease/CAS checks and
|
||||
effect-key idempotency at its own linearization point. No executor adapter
|
||||
is synthesized from an agent-provided `live=true` assertion.
|
||||
|
||||
## Concurrency, claims and recovery
|
||||
|
||||
Formal AgentTask capability tokens retain their C5 vocabulary; negotiated
|
||||
clients additionally bind its deterministic schema digest on registration,
|
||||
admission, polling and claim. Legacy clients retain C5 behavior. Generic
|
||||
TaskPlane wildcard capabilities stay in a different namespace.
|
||||
|
||||
The TaskPlane adapter rechecks authorization, scope, worker registration revision,
|
||||
capabilities, heartbeat, task revision/generation, dependencies, freeze and
|
||||
capacity inside the same authority/SQLite transaction that creates the claim.
|
||||
Its v3 receipt contains actual claim/revision/attempt/digest fields. An attempt
|
||||
number is never relabelled as a WriterLease fencing generation. Existing claim
|
||||
renewal is fenced by holder, claim identity and task operation digest. Enabled
|
||||
C6 HTTP heartbeats require the exact v3 claim receipt and use this same scoped
|
||||
controller; missing authority assertions deny renewal.
|
||||
|
||||
`MutationGuard` uses ticket identity and thread ownership. Replayed, forged,
|
||||
foreign or already-finished tickets cannot release another holder's lock.
|
||||
Optional resource scopes permit independent local targets to proceed in parallel;
|
||||
the existing WriterLease/CAS remains authoritative across processes and hosts.
|
||||
|
||||
Unknown effect outcomes are reconciled by the same immutable effect key. A
|
||||
missing reader, 404 or lost ACK does not authorize re-execution. The host keeps a
|
||||
pending unknown operation on readback only. Existing durable intents also block
|
||||
execution after a host restart; readback or formal recovery must resolve them.
|
||||
Durable effect knowledge belongs to
|
||||
the existing Task/Job authorities, not the host's in-memory observation cache.
|
||||
Terminal recovery validates exact signed identity/CAS/nonce before returning a
|
||||
NOOP, and does not rewrite historical tasks.
|
||||
|
||||
## Evidence and closure
|
||||
|
||||
Execution requires explicit finite trusted identities and an injected evidence
|
||||
verifier. Service construction preserves a store's configured verifier and
|
||||
rejects conflicting verifier identities. Legacy unsigned evidence producers
|
||||
receive a migration error before writing; complete signed envelopes bind the
|
||||
task, claim, producer, domain sequence, digest chain and expiry. The included
|
||||
HMAC adapter consumes an externally supplied key resolver; it creates no trust
|
||||
root or persisted key. A deployment must bind its approved verification provider.
|
||||
|
||||
Task/Job execution, independent verification and authorized business acceptance
|
||||
are separate facts. Shadow completion, self-verification, untrusted signatures,
|
||||
stale/cross-scope receipts or absent acceptance cannot become business success.
|
||||
Rejected terminal transitions are audited after the failed mutation transaction
|
||||
rolls back. Each evidence domain rejects duplicate/regressing sequence numbers;
|
||||
there is no invented global sequence across authorities.
|
||||
|
||||
ARO Phase A reads eight dimensions (function, performance, exception, usage,
|
||||
defect, history, deadlock and long-running work). It writes only immutable audit
|
||||
reports and exposes the six decision values. Missing facts remain UNKNOWN. It
|
||||
does not acquire authority, sign, execute, or automatically expand task scope.
|
||||
|
||||
## Components, runtime identity and configuration
|
||||
|
||||
`deploy/aios-v2/c6-components.json` declares formal AgentTask, Bridge, TaskPlane
|
||||
and health component roots. The builder reads dependency bytes from the exact
|
||||
Git commit with replacement objects disabled, resolves only selected local
|
||||
modules, and records every packaged file's digest. Tests import extracted
|
||||
components without a worktree PYTHONPATH. Dynamic/data dependencies must be
|
||||
explicit; production host bindings must be checked separately.
|
||||
|
||||
`BUILD-SPEC.json` freezes nonsecret effective config as a separate hashed build
|
||||
input. It permits credential reference names, not credential values or arbitrary
|
||||
environment/drop-in dictionaries. `COMPONENT-MANIFEST.json` records module,
|
||||
config, explicit per-component data/unit files, source/tree, entrypoint,
|
||||
dependency contract and rollback identities.
|
||||
The detached receipt records the final archive digest, avoiding self-reference.
|
||||
Exclusive publication prevents accidental overwrite; it is not a claim of
|
||||
filesystem immutability. Consumers must verify receipt and file hashes whenever
|
||||
opening an artifact. A source rollback reference is not a tested service rollback.
|
||||
The legacy health-only installer explicitly rejects C6 component deployment.
|
||||
These source packages require an approved component host installation binding.
|
||||
|
||||
Runtime status separates declared identity from trusted observed identity and
|
||||
uses PASS/FAIL/UNKNOWN. A 40-character Git OID is not an artifact SHA-256. Empty
|
||||
inventory, stale/missing observations and self-reported environment/health values
|
||||
cannot produce readiness or independent runtime verification. Liveness remains
|
||||
available. Clean Linux service installation/restart/rollback and independently
|
||||
observed module/config hashes are release gates, not inferred from package tests.
|
||||
|
||||
## Compatibility and later integration
|
||||
|
||||
Explicit-trust fixture/API migration is intentional: callers enabling execution
|
||||
must configure trusted identities and a verifier; unsigned evidence is rejected.
|
||||
C5 formal protocol exports and default legacy capability negotiation remain.
|
||||
Single-segment filenames and nested relative file paths have separate validators;
|
||||
protected path writes use POSIX directory descriptors/no-follow and fail closed
|
||||
on unsupported platforms. Runtime selection and event/context/observability
|
||||
ingress use server-derived identity and tenant scope. Development authorization
|
||||
bypass requires an explicitly isolated staging profile on loopback.
|
||||
|
||||
After C5 publication, verify the released C5 commit against this recorded base.
|
||||
If C5 changed, merge that released ancestry into the C6 branch and rerun affected
|
||||
checks. C6 review, governed repository publication, mainline merge, deployment
|
||||
and runtime/business acceptance remain separate gates. Do not infer any of them
|
||||
from a local source test, archive checksum, or this document.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user