377 lines
13 KiB
Python
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")
|