479 lines
15 KiB
Python
479 lines
15 KiB
Python
"""Minimal convergence contracts for AIOS V2R2C2.
|
|
|
|
This module deliberately contains no external side effects. It provides the shared
|
|
data contracts and pure evaluation rules used by domain controllers. Domain
|
|
controllers remain authoritative for their own final states.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timezone
|
|
from enum import Enum
|
|
import hashlib
|
|
import json
|
|
from typing import Any, Iterable, Mapping, Sequence
|
|
|
|
|
|
class ConvergenceContractError(ValueError):
|
|
"""Raised when an invalid convergence contract is constructed."""
|
|
|
|
|
|
class EvidenceState(str, Enum):
|
|
PASS = "PASS"
|
|
FAIL = "FAIL"
|
|
UNKNOWN = "UNKNOWN"
|
|
BLOCKED = "BLOCKED"
|
|
NOT_APPLICABLE = "NOT_APPLICABLE"
|
|
|
|
|
|
class AcceptanceDecision(str, Enum):
|
|
ACCEPT = "ACCEPT"
|
|
REJECT = "REJECT"
|
|
BLOCKED = "BLOCKED"
|
|
UNKNOWN = "UNKNOWN"
|
|
|
|
|
|
class ObservedHealth(str, Enum):
|
|
HEALTHY = "HEALTHY"
|
|
DEGRADED = "DEGRADED"
|
|
FAILED = "FAILED"
|
|
UNKNOWN = "UNKNOWN"
|
|
|
|
|
|
class ReconciliationState(str, Enum):
|
|
PENDING = "PENDING"
|
|
OBSERVING = "OBSERVING"
|
|
DIFF_FOUND = "DIFF_FOUND"
|
|
APPLYING = "APPLYING"
|
|
VERIFYING = "VERIFYING"
|
|
CONVERGED = "CONVERGED"
|
|
DEGRADED = "DEGRADED"
|
|
BLOCKED = "BLOCKED"
|
|
|
|
|
|
class ContinuationStopReason(str, Enum):
|
|
CONTEXT_BOUNDARY = "context_boundary"
|
|
WORKER_RESTART = "worker_restart"
|
|
HANDOFF = "handoff"
|
|
AUTHORITY_GATE = "authority_gate"
|
|
WAIT_EXTERNAL = "wait_external"
|
|
|
|
|
|
def _utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
|
|
|
|
def _sha256(value: Mapping[str, Any]) -> str:
|
|
return "sha256:" + hashlib.sha256(_canonical_json(value)).hexdigest()
|
|
|
|
|
|
def _nonempty(value: object, field_name: str) -> str:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
raise ConvergenceContractError(f"{field_name}_required")
|
|
return text
|
|
|
|
|
|
def _generation(value: object, field_name: str = "generation") -> int:
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ConvergenceContractError(f"{field_name}_invalid") from exc
|
|
if parsed < 1:
|
|
raise ConvergenceContractError(f"{field_name}_invalid")
|
|
return parsed
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class StateOwnership:
|
|
object_type: str
|
|
authoritative_service: str
|
|
authoritative_store: str
|
|
final_writer: str | None = None
|
|
projections: tuple[str, ...] = ()
|
|
schema_version: str = "aios.state-ownership.v1"
|
|
|
|
def __post_init__(self) -> None:
|
|
_nonempty(self.object_type, "object_type")
|
|
_nonempty(self.authoritative_service, "authoritative_service")
|
|
_nonempty(self.authoritative_store, "authoritative_store")
|
|
if self.final_writer is not None:
|
|
_nonempty(self.final_writer, "final_writer")
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
DEFAULT_STATE_OWNERSHIP: tuple[StateOwnership, ...] = (
|
|
StateOwnership(
|
|
object_type="goal",
|
|
authoritative_service="goal-kernel",
|
|
authoritative_store="goal-store",
|
|
final_writer="goal-kernel",
|
|
projections=("admin-web", "context-pack"),
|
|
),
|
|
StateOwnership(
|
|
object_type="task_run",
|
|
authoritative_service="task-plane-18940",
|
|
authoritative_store="durable-task-store",
|
|
final_writer="task-plane-18940",
|
|
projections=("admin-web", "context-pack"),
|
|
),
|
|
StateOwnership(
|
|
object_type="release",
|
|
authoritative_service="release-controller",
|
|
authoritative_store="release-registry",
|
|
final_writer="release-controller",
|
|
projections=("baseline-authority", "admin-web"),
|
|
),
|
|
StateOwnership(
|
|
object_type="git_branch",
|
|
authoritative_service="authority-git-executor",
|
|
authoritative_store="authoritative-git",
|
|
final_writer="authority-git-executor",
|
|
projections=("gitea",),
|
|
),
|
|
StateOwnership(
|
|
object_type="external_projection",
|
|
authoritative_service="projection-controller",
|
|
authoritative_store="projection-store",
|
|
final_writer="projection-controller",
|
|
projections=("openapi", "gpt-action", "nginx"),
|
|
),
|
|
)
|
|
|
|
|
|
def ownership_index(
|
|
ownership: Iterable[StateOwnership] = DEFAULT_STATE_OWNERSHIP,
|
|
) -> dict[str, StateOwnership]:
|
|
result: dict[str, StateOwnership] = {}
|
|
for row in ownership:
|
|
if row.object_type in result:
|
|
raise ConvergenceContractError(f"duplicate_state_owner:{row.object_type}")
|
|
result[row.object_type] = row
|
|
return result
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AcceptanceCriterion:
|
|
criterion_id: str
|
|
required: bool
|
|
state: EvidenceState
|
|
verifier_id: str
|
|
evidence_refs: tuple[str, ...] = ()
|
|
|
|
def __post_init__(self) -> None:
|
|
_nonempty(self.criterion_id, "criterion_id")
|
|
_nonempty(self.verifier_id, "verifier_id")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AcceptanceInvariant:
|
|
invariant_id: str
|
|
state: EvidenceState
|
|
evidence_refs: tuple[str, ...] = ()
|
|
|
|
def __post_init__(self) -> None:
|
|
_nonempty(self.invariant_id, "invariant_id")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AcceptanceVector:
|
|
subject_type: str
|
|
subject_id: str
|
|
subject_revision: int
|
|
criteria: tuple[AcceptanceCriterion, ...]
|
|
invariants: tuple[AcceptanceInvariant, ...] = ()
|
|
unresolved_side_effects: tuple[str, ...] = ()
|
|
unresolved_blockers: tuple[str, ...] = ()
|
|
runtime_proofs: tuple[str, ...] = ()
|
|
schema: str = "aios.acceptance-vector.v1"
|
|
|
|
def __post_init__(self) -> None:
|
|
_nonempty(self.subject_type, "subject_type")
|
|
_nonempty(self.subject_id, "subject_id")
|
|
_generation(self.subject_revision, "subject_revision")
|
|
ids = [row.criterion_id for row in self.criteria]
|
|
if len(ids) != len(set(ids)):
|
|
raise ConvergenceContractError("duplicate_acceptance_criterion")
|
|
invariant_ids = [row.invariant_id for row in self.invariants]
|
|
if len(invariant_ids) != len(set(invariant_ids)):
|
|
raise ConvergenceContractError("duplicate_acceptance_invariant")
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
value = asdict(self)
|
|
for row in value["criteria"]:
|
|
row["state"] = row["state"].value
|
|
for row in value["invariants"]:
|
|
row["state"] = row["state"].value
|
|
return value
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AcceptanceResult:
|
|
decision: AcceptanceDecision
|
|
reasons: tuple[str, ...]
|
|
vector_hash: str
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"decision": self.decision.value,
|
|
"reasons": list(self.reasons),
|
|
"vector_hash": self.vector_hash,
|
|
}
|
|
|
|
|
|
def evaluate_acceptance(vector: AcceptanceVector) -> AcceptanceResult:
|
|
"""Pure fail-closed acceptance evaluator.
|
|
|
|
The evaluator never writes a domain final state. The domain owner consumes this
|
|
result and remains the only final writer for Goal/Release/Product state.
|
|
"""
|
|
reasons: list[str] = []
|
|
required = [row for row in vector.criteria if row.required]
|
|
|
|
for row in required:
|
|
if row.state == EvidenceState.FAIL:
|
|
reasons.append(f"criterion_failed:{row.criterion_id}")
|
|
elif row.state == EvidenceState.BLOCKED:
|
|
reasons.append(f"criterion_blocked:{row.criterion_id}")
|
|
elif row.state == EvidenceState.UNKNOWN:
|
|
reasons.append(f"criterion_unknown:{row.criterion_id}")
|
|
|
|
for row in vector.invariants:
|
|
if row.state == EvidenceState.FAIL:
|
|
reasons.append(f"invariant_failed:{row.invariant_id}")
|
|
elif row.state in {EvidenceState.UNKNOWN, EvidenceState.BLOCKED}:
|
|
reasons.append(f"invariant_unknown:{row.invariant_id}")
|
|
|
|
if vector.unresolved_side_effects:
|
|
reasons.append("unresolved_side_effects")
|
|
if vector.unresolved_blockers:
|
|
reasons.append("unresolved_blockers")
|
|
|
|
digest = _sha256(vector.as_dict())
|
|
|
|
if any(reason.startswith(("criterion_failed:", "invariant_failed:")) for reason in reasons):
|
|
decision = AcceptanceDecision.REJECT
|
|
elif any(reason.startswith("criterion_blocked:") for reason in reasons) or vector.unresolved_blockers:
|
|
decision = AcceptanceDecision.BLOCKED
|
|
elif reasons:
|
|
decision = AcceptanceDecision.UNKNOWN
|
|
else:
|
|
decision = AcceptanceDecision.ACCEPT
|
|
|
|
return AcceptanceResult(decision=decision, reasons=tuple(reasons), vector_hash=digest)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class DesiredState:
|
|
resource_id: str
|
|
resource_type: str
|
|
desired_generation: int
|
|
desired_spec: Mapping[str, Any]
|
|
owner_goal_id: str | None = None
|
|
lifecycle: str = "active"
|
|
schema: str = "aios.desired-state.v1"
|
|
|
|
def __post_init__(self) -> None:
|
|
_nonempty(self.resource_id, "resource_id")
|
|
_nonempty(self.resource_type, "resource_type")
|
|
_generation(self.desired_generation, "desired_generation")
|
|
if self.lifecycle not in {"active", "suspended", "deleted"}:
|
|
raise ConvergenceContractError("desired_lifecycle_invalid")
|
|
|
|
def identity(self) -> tuple[str, int]:
|
|
return self.resource_id, self.desired_generation
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ObservedState:
|
|
resource_id: str
|
|
observed_generation: int | None
|
|
observed_spec: Mapping[str, Any]
|
|
health: ObservedHealth
|
|
source_identity: str
|
|
observed_at: str
|
|
evidence_refs: tuple[str, ...] = ()
|
|
schema: str = "aios.observed-state.v1"
|
|
|
|
def __post_init__(self) -> None:
|
|
_nonempty(self.resource_id, "resource_id")
|
|
_nonempty(self.source_identity, "source_identity")
|
|
_nonempty(self.observed_at, "observed_at")
|
|
if self.observed_generation is not None:
|
|
_generation(self.observed_generation, "observed_generation")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ReconciliationPlan:
|
|
resource_id: str
|
|
desired_generation: int
|
|
state: ReconciliationState
|
|
diff: Mapping[str, Any]
|
|
observation_required: bool
|
|
idempotency_key: str
|
|
reason: str
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
value = asdict(self)
|
|
value["state"] = self.state.value
|
|
return value
|
|
|
|
|
|
def plan_reconciliation(
|
|
desired: DesiredState,
|
|
observed: ObservedState | None,
|
|
) -> ReconciliationPlan:
|
|
"""Return a side-effect-free reconciliation decision.
|
|
|
|
UNKNOWN/missing observed state always requires observation and never converges.
|
|
"""
|
|
identity = {
|
|
"resource_id": desired.resource_id,
|
|
"desired_generation": desired.desired_generation,
|
|
"desired_spec": desired.desired_spec,
|
|
}
|
|
key = _sha256(identity)
|
|
|
|
if observed is None:
|
|
return ReconciliationPlan(
|
|
desired.resource_id,
|
|
desired.desired_generation,
|
|
ReconciliationState.OBSERVING,
|
|
{"observed": None},
|
|
True,
|
|
key,
|
|
"observed_state_missing",
|
|
)
|
|
if observed.resource_id != desired.resource_id:
|
|
raise ConvergenceContractError("resource_identity_mismatch")
|
|
if observed.health == ObservedHealth.UNKNOWN:
|
|
return ReconciliationPlan(
|
|
desired.resource_id,
|
|
desired.desired_generation,
|
|
ReconciliationState.OBSERVING,
|
|
{"health": observed.health.value},
|
|
True,
|
|
key,
|
|
"observed_state_unknown",
|
|
)
|
|
|
|
same_generation = observed.observed_generation == desired.desired_generation
|
|
same_spec = dict(observed.observed_spec) == dict(desired.desired_spec)
|
|
if same_generation and same_spec and observed.health == ObservedHealth.HEALTHY:
|
|
return ReconciliationPlan(
|
|
desired.resource_id,
|
|
desired.desired_generation,
|
|
ReconciliationState.CONVERGED,
|
|
{},
|
|
False,
|
|
key,
|
|
"desired_equals_observed",
|
|
)
|
|
|
|
return ReconciliationPlan(
|
|
desired.resource_id,
|
|
desired.desired_generation,
|
|
ReconciliationState.DIFF_FOUND,
|
|
{
|
|
"desired_generation": desired.desired_generation,
|
|
"observed_generation": observed.observed_generation,
|
|
"desired_spec": dict(desired.desired_spec),
|
|
"observed_spec": dict(observed.observed_spec),
|
|
"observed_health": observed.health.value,
|
|
},
|
|
False,
|
|
key,
|
|
"desired_observed_drift",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SideEffectReceipt:
|
|
mutation_id: str
|
|
external_system: str
|
|
state: str
|
|
observed_at: str
|
|
external_request_id: str | None = None
|
|
external_object_id: str | None = None
|
|
observed_result_hash: str | None = None
|
|
evidence_refs: tuple[str, ...] = ()
|
|
schema: str = "aios.side-effect-receipt.v1"
|
|
|
|
def __post_init__(self) -> None:
|
|
_nonempty(self.mutation_id, "mutation_id")
|
|
_nonempty(self.external_system, "external_system")
|
|
_nonempty(self.observed_at, "observed_at")
|
|
if self.state not in {
|
|
"confirmed_applied",
|
|
"confirmed_not_applied",
|
|
"unknown",
|
|
}:
|
|
raise ConvergenceContractError("side_effect_receipt_state_invalid")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ContinuationManifest:
|
|
trace_id: str
|
|
goal_id: str
|
|
goal_revision: int
|
|
task_id: str
|
|
run_id: str
|
|
current_node_id: str
|
|
acceptance_vector_ref: str
|
|
next_action: str
|
|
stop_reason: ContinuationStopReason
|
|
checkpoint_ref: str | None = None
|
|
unresolved_blocker_refs: tuple[str, ...] = ()
|
|
authoritative_decision_refs: tuple[str, ...] = ()
|
|
resource_refs: tuple[str, ...] = ()
|
|
artifact_refs: tuple[str, ...] = ()
|
|
evidence_refs: tuple[str, ...] = ()
|
|
created_at: str = field(default_factory=_utc_now)
|
|
schema: str = "aios.continuation-manifest.v1"
|
|
|
|
def __post_init__(self) -> None:
|
|
for name in (
|
|
"trace_id",
|
|
"goal_id",
|
|
"task_id",
|
|
"run_id",
|
|
"current_node_id",
|
|
"acceptance_vector_ref",
|
|
"next_action",
|
|
"created_at",
|
|
):
|
|
_nonempty(getattr(self, name), name)
|
|
_generation(self.goal_revision, "goal_revision")
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
value = asdict(self)
|
|
value["stop_reason"] = self.stop_reason.value
|
|
return value
|
|
|
|
@property
|
|
def content_hash(self) -> str:
|
|
return _sha256(self.as_dict())
|
|
|
|
|
|
def validate_state_ownership(
|
|
object_type: str,
|
|
writer: str,
|
|
ownership: Iterable[StateOwnership] = DEFAULT_STATE_OWNERSHIP,
|
|
*,
|
|
final_state: bool = False,
|
|
) -> None:
|
|
row = ownership_index(ownership).get(object_type)
|
|
if row is None:
|
|
raise ConvergenceContractError(f"state_owner_missing:{object_type}")
|
|
expected = row.final_writer if final_state and row.final_writer else row.authoritative_service
|
|
if writer != expected:
|
|
raise ConvergenceContractError(
|
|
f"STATE_OWNERSHIP_VIOLATION:{object_type}:{writer}:{expected}"
|
|
)
|