Files
aios/services/aios-autonomous-release-candidate/aios_autonomous_release_candidate/models.py
T

377 lines
13 KiB
Python

"""Typed contracts for the dormant autonomous-release candidate.
This package is dependency-free by design so it can be audited and exercised in
an isolated candidate worktree before any production service is installed.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
import hashlib
import json
from typing import Any, Mapping, Sequence
def utc_now() -> datetime:
"""Return an aware UTC timestamp."""
return datetime.now(timezone.utc)
def canonical_json(value: Any) -> str:
"""Serialize *value* deterministically for signatures and evidence hashes."""
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def sha256_json(value: Any) -> str:
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
def is_sha256(value: str) -> bool:
return len(value) == 64 and all(ch in "0123456789abcdef" for ch in value)
def is_git_oid(value: str) -> bool:
return len(value) == 40 and all(ch in "0123456789abcdef" for ch in value)
class RiskClass(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class TaskState(str, Enum):
CREATED = "CREATED"
PLANNED = "PLANNED"
READY = "READY"
RUNNING = "RUNNING"
BLOCKED = "BLOCKED"
FAILED = "FAILED"
COMPLETED = "COMPLETED"
READY_FOR_OWNER_APPROVAL = "READY_FOR_OWNER_APPROVAL"
class StepState(str, Enum):
PENDING = "PENDING"
READY = "READY"
CLAIMED = "CLAIMED"
RUNNING = "RUNNING"
BLOCKED = "BLOCKED"
FAILED = "FAILED"
COMPLETED = "COMPLETED"
class CredentialState(str, Enum):
REQUESTED = "REQUESTED"
ISSUED = "ISSUED"
PROJECTED = "PROJECTED"
REVOKED = "REVOKED"
EXPIRED = "EXPIRED"
@dataclass(frozen=True, slots=True)
class ResourceBinding:
repository: str
branch: str
base_oid: str
allowed_paths: tuple[str, ...] = ()
def __post_init__(self) -> None:
if not self.repository or "/" not in self.repository:
raise ValueError("repository must be owner/name")
if self.branch == "main" or not self.branch.startswith(("patch/", "integration/", "candidate/")):
raise ValueError("candidate execution requires a governed non-main branch")
if not is_git_oid(self.base_oid):
raise ValueError("base_oid must be a full lowercase 40-character Git OID")
for path in self.allowed_paths:
if not path or path.startswith("/") or ".." in path.split("/"):
raise ValueError("allowed_paths must be repository-relative and traversal-free")
def allows_path(self, path: str) -> bool:
if not path or path.startswith("/") or ".." in path.split("/"):
return False
return any(path == prefix or path.startswith(prefix.rstrip("/") + "/") for prefix in self.allowed_paths)
@dataclass(frozen=True, slots=True)
class ApprovalEnvelope:
approval_plan_id: str
task_id: str
task_plan_sha256: str
binding: ResourceBinding
capabilities: tuple[str, ...]
risk: RiskClass
not_before: datetime
expires_at: datetime
nonce: str
signing_key_id: str
signature: str
def __post_init__(self) -> None:
if self.expires_at <= self.not_before:
raise ValueError("approval expiry must be after not_before")
if not is_sha256(self.task_plan_sha256):
raise ValueError("task_plan_sha256 must be a SHA-256 hex digest")
if not self.approval_plan_id or not self.task_id or not self.nonce:
raise ValueError("approval identifiers and nonce are required")
def payload(self) -> Mapping[str, Any]:
return {
"approval_plan_id": self.approval_plan_id,
"task_id": self.task_id,
"task_plan_sha256": self.task_plan_sha256,
"repository": self.binding.repository,
"branch": self.binding.branch,
"base_oid": self.binding.base_oid,
"allowed_paths": list(self.binding.allowed_paths),
"capabilities": list(self.capabilities),
"risk": self.risk.value,
"not_before": self.not_before.astimezone(timezone.utc).isoformat(),
"expires_at": self.expires_at.astimezone(timezone.utc).isoformat(),
"nonce": self.nonce,
"signing_key_id": self.signing_key_id,
}
@dataclass(frozen=True, slots=True)
class WorkflowStep:
step_id: str
worker_role: str
required_capabilities: tuple[str, ...]
depends_on: tuple[str, ...] = ()
max_attempts: int = 1
timeout_seconds: int = 900
allowed_paths: tuple[str, ...] = ()
def __post_init__(self) -> None:
if not self.step_id or not self.worker_role:
raise ValueError("step_id and worker_role are required")
if self.max_attempts < 1 or self.max_attempts > 5:
raise ValueError("max_attempts must be between 1 and 5")
if self.timeout_seconds < 1 or self.timeout_seconds > 86_400:
raise ValueError("timeout_seconds must be between 1 and 86400")
if self.step_id in self.depends_on:
raise ValueError("a step cannot depend on itself")
@dataclass(frozen=True, slots=True)
class TaskPlan:
task_id: str
intent: str
binding: ResourceBinding
risk: RiskClass
steps: tuple[WorkflowStep, ...]
evidence_requirements: tuple[str, ...]
production_side_effects_allowed: bool = False
created_at: datetime = field(default_factory=utc_now)
def __post_init__(self) -> None:
if not self.task_id or not self.intent:
raise ValueError("task_id and intent are required")
if self.production_side_effects_allowed:
raise ValueError("candidate TaskPlan must not allow production side effects")
ids = [step.step_id for step in self.steps]
if len(ids) != len(set(ids)):
raise ValueError("step IDs must be unique")
known = set(ids)
for step in self.steps:
missing = set(step.depends_on) - known
if missing:
raise ValueError(f"step {step.step_id} has unknown dependencies: {sorted(missing)}")
self._validate_acyclic()
def _validate_acyclic(self) -> None:
dependencies = {step.step_id: set(step.depends_on) for step in self.steps}
pending = set(dependencies)
while pending:
ready = {step_id for step_id in pending if not (dependencies[step_id] & pending)}
if not ready:
raise ValueError("workflow contains a dependency cycle")
pending -= ready
def digest(self) -> str:
return sha256_json(
{
"task_id": self.task_id,
"intent": self.intent,
"repository": self.binding.repository,
"branch": self.binding.branch,
"base_oid": self.binding.base_oid,
"allowed_paths": list(self.binding.allowed_paths),
"risk": self.risk.value,
"steps": [
{
"step_id": step.step_id,
"worker_role": step.worker_role,
"required_capabilities": list(step.required_capabilities),
"depends_on": list(step.depends_on),
"max_attempts": step.max_attempts,
"timeout_seconds": step.timeout_seconds,
"allowed_paths": list(step.allowed_paths),
}
for step in self.steps
],
"evidence_requirements": list(self.evidence_requirements),
"production_side_effects_allowed": self.production_side_effects_allowed,
"created_at": self.created_at.astimezone(timezone.utc).isoformat(),
}
)
@dataclass(frozen=True, slots=True)
class WorkerDescriptor:
worker_id: str
role: str
capabilities: frozenset[str]
trust_zone: str
endpoint_id: str
online: bool = True
current_load: int = 0
max_concurrency: int = 1
def can_accept(self, required: Sequence[str], trust_zone: str) -> bool:
return (
self.online
and self.trust_zone == trust_zone
and self.current_load < self.max_concurrency
and set(required).issubset(self.capabilities)
)
@dataclass(frozen=True, slots=True)
class CredentialRequest:
request_id: str
task_id: str
session_id: str
worker_id: str
audience: str
consumer_service: str
binding: ResourceBinding
operations: tuple[str, ...]
provider: str
runtime_user: str
approval_plan_id: str
not_before: datetime
expires_at: datetime
nonce: str
ttl_seconds: int
max_uses: int
def __post_init__(self) -> None:
if self.ttl_seconds < 1 or self.ttl_seconds > 3600:
raise ValueError("credential TTL must be between 1 and 3600 seconds")
if self.max_uses < 1 or self.max_uses > 1000:
raise ValueError("credential max_uses must be between 1 and 1000")
if not self.operations:
raise ValueError("credential operations cannot be empty")
required = (
self.request_id,
self.task_id,
self.session_id,
self.worker_id,
self.audience,
self.consumer_service,
self.provider,
self.runtime_user,
self.approval_plan_id,
self.nonce,
)
if any(not item or item != item.strip() for item in required):
raise ValueError("credential identity, scope and approval fields are required")
if len(set(self.operations)) != len(self.operations):
raise ValueError("credential operations must be unique")
if self.not_before.tzinfo is None or self.expires_at.tzinfo is None:
raise ValueError("credential time bounds must be timezone aware")
if self.expires_at <= self.not_before:
raise ValueError("credential expires_at must follow not_before")
if (self.expires_at - self.not_before).total_seconds() > self.ttl_seconds:
raise ValueError("credential approval window exceeds requested TTL")
@dataclass(frozen=True, slots=True)
class CredentialProjection:
credential_id: str
reference: str
request: CredentialRequest
state: CredentialState
issued_at: datetime
expires_at: datetime
plaintext_returned: bool = False
def __post_init__(self) -> None:
if self.plaintext_returned:
raise ValueError("credential plaintext must never be returned")
if self.expires_at <= self.issued_at:
raise ValueError("credential expiry must be after issuance")
@dataclass(frozen=True, slots=True)
class ResultEnvelope:
task_id: str
step_id: str
worker_id: str
session_id: str
claim_id: str
operation_digest: str
result_digest: str
evidence_digests: tuple[str, ...]
completed_at: datetime = field(default_factory=utc_now)
def __post_init__(self) -> None:
if not is_sha256(self.operation_digest) or not is_sha256(self.result_digest):
raise ValueError("operation_digest and result_digest must be SHA-256 values")
if any(not is_sha256(digest) for digest in self.evidence_digests):
raise ValueError("all evidence digests must be SHA-256 values")
@dataclass(frozen=True, slots=True)
class ReleasePlan:
task_id: str
repository: str
branch: str
version: str
target_oid: str
tree_oid: str
artifact_sha256: str
manifest_sha256: str
package_closure_sha256: str
evidence_closure_oid: str
approval_plan_id: str
rollback_approval_plan_id: str
rollback_artifact_sha256: str
expected_tag_oid: str
expected_target_oid: str
production_config_sha256: str
def __post_init__(self) -> None:
for name in ("target_oid", "tree_oid", "evidence_closure_oid", "expected_target_oid"):
if not is_git_oid(getattr(self, name)):
raise ValueError(f"{name} must be a full lowercase 40-character Git OID")
for name in (
"artifact_sha256",
"manifest_sha256",
"package_closure_sha256",
"rollback_artifact_sha256",
"production_config_sha256",
):
if not is_sha256(getattr(self, name)):
raise ValueError(f"{name} must be a SHA-256 hex digest")
if not is_git_oid(self.expected_tag_oid):
raise ValueError("expected_tag_oid must be a full lowercase 40-character Git OID")
if (
not self.task_id
or "/" not in self.repository
or not self.branch.startswith("integration/")
or not self.version
or not self.approval_plan_id
or not self.rollback_approval_plan_id
):
raise ValueError("release identifiers are required")
if self.approval_plan_id == self.rollback_approval_plan_id:
raise ValueError("deployment and rollback approvals must be distinct")
if self.target_oid != self.expected_target_oid:
raise ValueError("target_oid must equal expected_target_oid")