137 lines
4.2 KiB
Python
137 lines
4.2 KiB
Python
"""ACT DAG validation for Phase 2 execution records.
|
|
|
|
Validates the directed acyclic graph formed by par (parent) references
|
|
in Phase 2 ACTs, ensuring uniqueness, parent existence, temporal ordering,
|
|
acyclicity, and capability consistency.
|
|
|
|
Reference: ACT §7 (DAG Structure and Causal Ordering).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Protocol
|
|
|
|
from .errors import ACTCapabilityError, ACTDAGError
|
|
from .token import ACTRecord
|
|
|
|
# Maximum ancestor traversal limit for cycle detection — ACT §7.1 step 4.
|
|
MAX_TRAVERSAL_LIMIT: int = 10_000
|
|
|
|
# Clock skew tolerance for temporal ordering — ACT §7.1 step 3.
|
|
DAG_CLOCK_SKEW_TOLERANCE: int = 30
|
|
|
|
|
|
class ACTStore(Protocol):
|
|
"""Protocol for an ACT store used in DAG validation.
|
|
|
|
Any object implementing get() and has() can serve as the store.
|
|
The ACTLedger in ledger.py implements this protocol.
|
|
"""
|
|
|
|
def get(self, jti: str) -> ACTRecord | None:
|
|
"""Retrieve a Phase 2 ACT record by jti."""
|
|
...
|
|
|
|
|
|
def validate_dag(
|
|
record: ACTRecord,
|
|
store: ACTStore,
|
|
*,
|
|
clock_skew_tolerance: int = DAG_CLOCK_SKEW_TOLERANCE,
|
|
) -> None:
|
|
"""Validate the DAG constraints for a Phase 2 execution record.
|
|
|
|
Performs all five DAG validation checks defined in ACT §7.1:
|
|
1. jti uniqueness within wid scope (or globally)
|
|
2. Parent existence in store
|
|
3. Temporal ordering with clock skew tolerance
|
|
4. Acyclicity (max traversal limit)
|
|
5. Capability consistency (exec_act matches cap[].action)
|
|
|
|
Reference: ACT §7.1 (DAG Validation).
|
|
|
|
Args:
|
|
record: The Phase 2 ACTRecord to validate.
|
|
store: An ACT store providing get() for parent lookup.
|
|
clock_skew_tolerance: Seconds of allowed clock skew (default 30).
|
|
|
|
Raises:
|
|
ACTDAGError: If any DAG constraint is violated.
|
|
ACTCapabilityError: If exec_act does not match cap actions.
|
|
"""
|
|
# Step 1: jti uniqueness — ACT §7.1 step 1
|
|
existing = store.get(record.jti)
|
|
if existing is not None:
|
|
raise ACTDAGError(
|
|
f"Duplicate jti {record.jti!r} already exists in store"
|
|
)
|
|
|
|
# Step 5: Capability consistency — ACT §7.1 step 5
|
|
cap_actions = {c.action for c in record.cap}
|
|
if record.exec_act not in cap_actions:
|
|
raise ACTCapabilityError(
|
|
f"exec_act {record.exec_act!r} does not match any "
|
|
f"cap[].action: {sorted(cap_actions)}"
|
|
)
|
|
|
|
# Step 2 & 3: Parent existence and temporal ordering
|
|
for parent_jti in record.par:
|
|
parent = store.get(parent_jti)
|
|
if parent is None:
|
|
raise ACTDAGError(
|
|
f"Parent jti {parent_jti!r} not found in store"
|
|
)
|
|
|
|
# Temporal ordering: parent.exec_ts < child.exec_ts + tolerance
|
|
if parent.exec_ts >= record.exec_ts + clock_skew_tolerance:
|
|
raise ACTDAGError(
|
|
f"Temporal ordering violation: parent {parent_jti!r} "
|
|
f"exec_ts={parent.exec_ts} >= child exec_ts="
|
|
f"{record.exec_ts} + tolerance={clock_skew_tolerance}"
|
|
)
|
|
|
|
# Step 4: Acyclicity — ACT §7.1 step 4
|
|
_check_acyclicity(record.jti, record.par, store)
|
|
|
|
|
|
def _check_acyclicity(
|
|
current_jti: str,
|
|
parent_jtis: list[str],
|
|
store: ACTStore,
|
|
) -> None:
|
|
"""Check that following par references does not lead back to current_jti.
|
|
|
|
Uses breadth-first traversal with a maximum node limit.
|
|
|
|
Reference: ACT §7.1 step 4.
|
|
|
|
Raises:
|
|
ACTDAGError: If a cycle is detected or traversal limit exceeded.
|
|
"""
|
|
visited: set[str] = set()
|
|
queue: list[str] = list(parent_jtis)
|
|
nodes_visited = 0
|
|
|
|
while queue:
|
|
if nodes_visited >= MAX_TRAVERSAL_LIMIT:
|
|
raise ACTDAGError(
|
|
f"DAG traversal limit ({MAX_TRAVERSAL_LIMIT}) exceeded; "
|
|
f"possible cycle or excessively deep DAG"
|
|
)
|
|
|
|
jti = queue.pop(0)
|
|
if jti == current_jti:
|
|
raise ACTDAGError(
|
|
f"DAG cycle detected: jti {current_jti!r} appears in "
|
|
f"its own ancestor chain"
|
|
)
|
|
|
|
if jti in visited:
|
|
continue
|
|
visited.add(jti)
|
|
nodes_visited += 1
|
|
|
|
parent = store.get(jti)
|
|
if parent is not None:
|
|
queue.extend(parent.par)
|