odysseus/src/agent_runtime/effects.py
Alexandre Teixeira 8402c388b4 feat(runtime): claim effects before dispatch and gate completion on them
Dispatcher seam: mark_dispatch, which runs inside the live Wave 3 binding
scope immediately before backend invocation, now durably claims a possible
effect before execution_id is assigned. If the claim cannot be persisted the
action stays undispatched and the dispatcher returns BLOCKED; dispatched()
closes the never-awaited coroutine. record_action appends the outcome
(including cancellation/interruption) and admitted-read observations before
the receipt reduction drops producer facts.

Adapters consume only the bound operations the dispatcher admitted:
filesystem bindings give exact scope and predicates (write_file content digest
after fence unwrapping, apply_patch add/delete, edit existence); bash/python
launches have unknown scope with the launch generation as lineage; job kills
scope the exact job and its processes; owned operations scope their exact
revisioned records; external backends are claimed as external and never
verified by acknowledgement; browser session_info yields session lifecycle
observations only, and a page binding is never effect scope. Complete
read_file re-reads the exact bound source to digest it; offset/limit,
truncation, extraction and listings are partial. Background launches stay
RUNNING until an admitted read of the exact job generation (via a durable
launch index, across continuation runs) reports settlement.

Producer seams: typed job lifecycle facts on manage_bg_jobs reads/kills, a
structured timed_out flag on containment timeouts, and mutation_attempted on
in-place write_file/edit_file failures after truncation.

Completion: the existing EvidenceLedger consumes effect assessments through a
single helper used for the decision, ask_user and prose filtering. A required
artifact is unsettled by a later unresolved effect that may have touched it,
a fresh contradicting readback fails the decision, and partial reads no longer
count as artifact validation. Ordinary conversation and read-only turns are
unchanged; no second completion policy is introduced.
2026-10-02 20:09:35 +01:00

816 lines
39 KiB
Python

"""Wave 4 effect claims, outcomes, observations and verification.
This module consumes exact Wave 3 resource identities. It never resolves a
selector, discovers an alias, grants an operation or performs I/O. A
``ResourceRef`` can only be built from an already-admitted typed Wave 3 resource
object; names, paths, PIDs, URLs, labels and dictionaries are not accepted.
Facts are kept separate:
* a claim records intent and scope before backend invocation, not dispatch;
* an outcome records what the executor reported, not the resulting state;
* an observation records state seen through an admitted mechanism;
* verification is derived from fresh, relevant, complete observations made
after the effect settled, and never from receipts or acknowledgements.
History is append-only. Invalidation and freshness are computed from the
ordered record history; earlier records are never rewritten. Refresh is a new
observation. Unknown scope is conservative, never "no impact".
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import PurePosixPath
import hashlib
import json
import re
from typing import Any, Iterable, Mapping
def _sha(value: Any) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"),
ensure_ascii=False, default=str).encode()).hexdigest()
def _text(value: Any, label: str, *, optional: bool = False) -> None:
if (not isinstance(value, str) or (not value and not optional)
or any(c in value for c in ("\0", "\n", "\r"))):
raise ValueError(f"Invalid effect {label}")
def _position(value: Any) -> None:
if type(value) is not int or value < 0:
raise ValueError("Effect history position must be a nonnegative integer")
_SHA256 = re.compile(r"[a-f0-9]{64}")
# ---------------------------------------------------------------------------
# Exact resource references (Wave 3 consumption only)
# ---------------------------------------------------------------------------
class ResourceKind(str, Enum):
FILESYSTEM = "filesystem"
PROCESS = "process"
PROCESS_LAUNCH = "process_launch"
BACKGROUND_JOB = "background_job"
OWNED = "owned"
EXTERNAL = "external"
BROWSER_SESSION = "browser_session"
@dataclass(frozen=True)
class ResourceRef:
"""Historical reference to one exact admitted Wave 3 resource.
``location`` identifies where the resource lives (including the identity of
its sealed root/namespace); ``incarnation`` identifies the object observed
there when the reference was taken. Replacement keeps the location and
changes the incarnation, so evidence never transfers to a replacement.
``snapshot_sha256`` digests the full Wave 3 snapshot for audit. A ref is not
authority: it is not accepted by any dispatcher, resolver or grant.
"""
kind: ResourceKind
role: str
location: tuple[str, ...]
incarnation: str
snapshot_sha256: str
def __post_init__(self) -> None:
if not isinstance(self.kind, ResourceKind):
raise ValueError("Unsupported effect resource kind")
_text(self.role, "resource role")
_text(self.incarnation, "resource incarnation", optional=True)
if (not isinstance(self.location, tuple) or len(self.location) < 2
or any(not isinstance(part, str) or any(c in part for c in ("\0", "\n", "\r"))
for part in self.location)
or self.location[0] != self.kind.value):
raise ValueError("Malformed effect resource location")
if not _SHA256.fullmatch(self.snapshot_sha256 or ""):
raise ValueError("Malformed effect resource snapshot digest")
@property
def location_key(self) -> str:
return _sha(list(self.location))
def same_location(self, other: "ResourceRef") -> bool:
return self.kind is other.kind and self.location == other.location
def overlaps(self, other: "ResourceRef") -> bool:
"""Conservative relevance between two exact references.
Filesystem relevance is ancestor-or-self within one sealed root
identity: a mutation of ``d/x`` invalidates a listing of ``d`` and a
replacement of ``d`` invalidates observations of ``d/x``. Other kinds
only overlap at the same exact location. No alias discovery is done.
"""
if self.kind is not other.kind:
return False
if self.kind is ResourceKind.OWNED:
# A collection binding ("*") covers every record it can create,
# list or change; specific records only overlap themselves.
return self.location[:-1] == other.location[:-1] and (
self.location[-1] == other.location[-1] or "*" in (self.location[-1], other.location[-1]))
if self.kind is not ResourceKind.FILESYSTEM:
return self.location == other.location
if self.location[:-1] != other.location[:-1]:
return False
left, right = PurePosixPath(self.location[-1]), PurePosixPath(other.location[-1])
return left == right or left.is_relative_to(right) or right.is_relative_to(left)
def to_dict(self) -> dict[str, Any]:
return {"kind": self.kind.value, "role": self.role, "location": list(self.location),
"incarnation": self.incarnation, "snapshot_sha256": self.snapshot_sha256}
@classmethod
def from_dict(cls, value: Any) -> "ResourceRef":
"""Reload a persisted historical reference. This creates no authority."""
if (not isinstance(value, dict)
or set(value) != {"kind", "role", "location", "incarnation", "snapshot_sha256"}
or not isinstance(value["location"], list)):
raise ValueError("Malformed persisted effect resource reference")
return cls(ResourceKind(value["kind"]), value["role"], tuple(value["location"]),
value["incarnation"], value["snapshot_sha256"])
def resource_ref(resource: Any, role: str) -> ResourceRef:
"""Reference an exact typed Wave 3 resource; anything else is refused.
Browser page/document resources are refused: Wave 3 fails closed for page
authority and Wave 4 must not promote page observations into identity.
"""
from src.agent_runtime import resources as wave3
if isinstance(resource, wave3.BrowserPageResource):
raise TypeError("Browser page resources are not effect-bindable")
if isinstance(resource, wave3.FilesystemResource):
root = resource.root
location = ("filesystem", root.scope.value, root.owner, root.path,
str(root.identity.device), str(root.identity.inode), resource.path)
chain = [[a.path, a.identity.device, a.identity.inode] for a in resource.ancestors]
identity = resource.identity
incarnation = ("absent:" + _sha(chain) if identity is None else
f"{identity.kind}:{identity.device}:{identity.inode}:" + _sha(chain))
return ResourceRef(ResourceKind.FILESYSTEM, role, location, incarnation, _sha(resource.to_dict()))
if isinstance(resource, wave3.ProcessResource):
ident = resource.identity
location = ("process", resource.namespace, resource.owner, resource.request_id, resource.thread_id,
str(ident.pid), ident.start_token, resource.role)
return ResourceRef(ResourceKind.PROCESS, role, location, ident.start_token, _sha(resource.to_dict()))
if isinstance(resource, wave3.ProcessLaunchResource):
# The reservation generation is the exact launch -> job linkage that
# Wave 3 validates in ``job_from_record``.
location = ("process_launch", resource.namespace, resource.owner, resource.request_id,
resource.thread_id, resource.generation)
return ResourceRef(ResourceKind.PROCESS_LAUNCH, role, location, resource.generation,
_sha(resource.to_dict()))
if isinstance(resource, wave3.BackgroundJobResource):
location = ("background_job", resource.namespace, resource.owner, resource.request_id,
resource.thread_id, resource.job_id, resource.generation)
return ResourceRef(ResourceKind.BACKGROUND_JOB, role, location, resource.generation,
_sha(resource.to_dict()))
if isinstance(resource, wave3.OwnedResource):
location = ("owned", resource.namespace, resource.owner, resource.thread_id,
resource.collection, resource.record_id)
return ResourceRef(ResourceKind.OWNED, role, location, resource.revision, _sha(resource.to_dict()))
if isinstance(resource, wave3.ExternalResource):
location = ("external", resource.namespace, resource.owner, resource.endpoint_id,
resource.server_id, resource.tool_id)
return ResourceRef(ResourceKind.EXTERNAL, role, location, resource.incarnation, _sha(resource.to_dict()))
if isinstance(resource, wave3.BrowserSessionResource):
observation = resource.observation
location = ("browser_session", resource.owner, resource.thread_id, observation.session_key)
return ResourceRef(ResourceKind.BROWSER_SESSION, role, location, observation.session_incarnation,
_sha(resource.to_dict()))
raise TypeError("Effect scope requires an exact Wave 3 resource identity")
def bound_filesystem_refs(bound: Any) -> tuple[ResourceRef, ...]:
"""References for an admitted ``BoundFilesystemOperation``'s exact bindings."""
from src.agent_runtime.resource_binding import BoundFilesystemOperation
if not isinstance(bound, BoundFilesystemOperation):
raise TypeError("Filesystem effect scope requires a server-owned bound operation")
return tuple(resource_ref(binding.resource, binding.role) for binding in bound.bindings)
# ---------------------------------------------------------------------------
# Claims
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class OperationRef:
"""Final normalized operation reference; not a second normalization API."""
tool: str
action: str
input_sha256: str
request_id: str = ""
def __post_init__(self) -> None:
_text(self.tool, "operation tool")
_text(self.action, "operation action", optional=True)
_text(self.request_id, "operation request", optional=True)
if not _SHA256.fullmatch(self.input_sha256 or ""):
raise ValueError("Malformed operation input digest")
@classmethod
def from_exact(cls, operation: Any, execution_input: str | None = None, request_id: str = "") -> "OperationRef":
from src.agent_runtime.authority import ExactOperation
if not isinstance(operation, ExactOperation):
raise TypeError("Effect claims require the admitted exact operation")
body = operation.input if execution_input is None else execution_input
return cls(str(operation.tool), str(operation.action or ""), _sha(body), request_id or "")
def to_dict(self) -> dict[str, Any]:
return {"tool": self.tool, "action": self.action, "input_sha256": self.input_sha256,
"request_id": self.request_id}
@classmethod
def from_dict(cls, value: Any) -> "OperationRef":
if not isinstance(value, dict) or set(value) != {"tool", "action", "input_sha256", "request_id"}:
raise ValueError("Malformed persisted operation reference")
return cls(**value)
class Predicate(str, Enum):
EXISTS = "exists"
ABSENT = "absent"
CONTENT_SHA256 = "content_sha256"
# The observed content digest differs from ``expected`` (the pre-state).
CONTENT_CHANGED = "content_changed"
_PREDICATE_KINDS = {
Predicate.EXISTS: {ResourceKind.FILESYSTEM, ResourceKind.OWNED, ResourceKind.EXTERNAL},
Predicate.ABSENT: {ResourceKind.FILESYSTEM, ResourceKind.OWNED, ResourceKind.EXTERNAL},
Predicate.CONTENT_SHA256: {ResourceKind.FILESYSTEM, ResourceKind.OWNED, ResourceKind.EXTERNAL},
Predicate.CONTENT_CHANGED: {ResourceKind.FILESYSTEM, ResourceKind.OWNED, ResourceKind.EXTERNAL},
}
@dataclass(frozen=True)
class Postcondition:
"""An explicit requested post-state predicate on one exact claimed target."""
target: ResourceRef
predicate: Predicate
expected: str = ""
def __post_init__(self) -> None:
if not isinstance(self.target, ResourceRef) or not isinstance(self.predicate, Predicate):
raise ValueError("Malformed postcondition")
if self.target.kind not in _PREDICATE_KINDS[self.predicate]:
raise ValueError("Predicate is not supported for this resource kind")
needs_digest = self.predicate in {Predicate.CONTENT_SHA256, Predicate.CONTENT_CHANGED}
if needs_digest != bool(_SHA256.fullmatch(self.expected or "")) or (not needs_digest and self.expected):
raise ValueError("Malformed postcondition expectation")
def to_dict(self) -> dict[str, Any]:
return {"target": self.target.to_dict(), "predicate": self.predicate.value, "expected": self.expected}
@classmethod
def from_dict(cls, value: Any) -> "Postcondition":
if not isinstance(value, dict) or set(value) != {"target", "predicate", "expected"}:
raise ValueError("Malformed persisted postcondition")
return cls(ResourceRef.from_dict(value["target"]), Predicate(value["predicate"]), value["expected"])
@dataclass(frozen=True)
class EffectClaim:
"""Server-owned claim, persisted before backend invocation.
The claim states intent and scope; it is not evidence that dispatch, the
backend operation, or any mutation happened. ``impact_scope`` holds the
exact admitted bindings the operation may change; empty means unknown
scope, never no impact. ``dependencies`` are resources the predicate
relies on without being mutation targets.
"""
effect_id: str
run_id: str
action_id: str
sequence: int
operation: OperationRef
impact_scope: tuple[ResourceRef, ...] = ()
dependencies: tuple[ResourceRef, ...] = ()
obligations: tuple[Postcondition, ...] = ()
parent_run_id: str = ""
external: bool = False
def __post_init__(self) -> None:
for name in ("effect_id", "run_id", "action_id"):
_text(getattr(self, name), name)
_text(self.parent_run_id, "parent run", optional=True)
_position(self.sequence)
if not isinstance(self.operation, OperationRef) or type(self.external) is not bool:
raise ValueError("Malformed effect claim")
for name in ("impact_scope", "dependencies"):
refs = getattr(self, name)
if not isinstance(refs, tuple) or any(not isinstance(r, ResourceRef) for r in refs):
raise ValueError("Effect scope must be exact resource references")
if (not isinstance(self.obligations, tuple)
or any(not isinstance(o, Postcondition) for o in self.obligations)):
raise ValueError("Malformed effect obligations")
for obligation in self.obligations:
if not any(obligation.target == ref for ref in self.impact_scope):
raise ValueError("Postcondition target must be a claimed impact binding")
@property
def unknown_scope(self) -> bool:
return not self.impact_scope
def to_dict(self) -> dict[str, Any]:
return {"effect_id": self.effect_id, "run_id": self.run_id, "action_id": self.action_id,
"sequence": self.sequence, "operation": self.operation.to_dict(),
"impact_scope": [r.to_dict() for r in self.impact_scope],
"dependencies": [r.to_dict() for r in self.dependencies],
"obligations": [o.to_dict() for o in self.obligations],
"parent_run_id": self.parent_run_id, "external": self.external}
@classmethod
def from_dict(cls, value: Any) -> "EffectClaim":
keys = {"effect_id", "run_id", "action_id", "sequence", "operation", "impact_scope",
"dependencies", "obligations", "parent_run_id", "external"}
if not isinstance(value, dict) or set(value) != keys or any(
not isinstance(value[k], list) for k in ("impact_scope", "dependencies", "obligations")):
raise ValueError("Malformed persisted effect claim")
return cls(value["effect_id"], value["run_id"], value["action_id"], value["sequence"],
OperationRef.from_dict(value["operation"]),
tuple(ResourceRef.from_dict(r) for r in value["impact_scope"]),
tuple(ResourceRef.from_dict(r) for r in value["dependencies"]),
tuple(Postcondition.from_dict(o) for o in value["obligations"]),
value["parent_run_id"], value["external"])
# ---------------------------------------------------------------------------
# Outcomes
# ---------------------------------------------------------------------------
class ExecutionOutcome(str, Enum):
NOT_EXECUTED = "not_executed" # refused before backend invocation
ATTEMPTED = "attempted" # claimed; no settled outcome yet
REPORTED_SUCCESS = "reported_success" # executor reported success; not post-state
FAILED = "failed"
TIMED_OUT = "timed_out"
CANCELLED = "cancelled"
RUNNING = "running" # admitted/background; not completed work
INTERRUPTED = "interrupted" # unknown: lost, crashed or replayed
class Impact(str, Enum):
NONE = "none" # known no-op: the backend was never invoked
POSSIBLE = "possible" # may have changed state, including partially
CHANGED = "changed" # a trusted before/after capture differs
class CleanupState(str, Enum):
NOT_APPLICABLE = "not_applicable"
VERIFIED = "verified"
FAILED = "failed"
UNKNOWN = "unknown"
_SETTLED = {ExecutionOutcome.NOT_EXECUTED, ExecutionOutcome.REPORTED_SUCCESS, ExecutionOutcome.FAILED,
ExecutionOutcome.TIMED_OUT, ExecutionOutcome.CANCELLED, ExecutionOutcome.INTERRUPTED}
@dataclass(frozen=True)
class ProducerFacts:
"""Bounded typed producer facts; arbitrary returned data is never kept.
These are execution/lifecycle facts reported by a server producer. None of
them is a post-state observation.
"""
exit_code: int | None = None
timed_out: bool = False
output_truncated: bool = False
failure_kind: str = ""
job_state: str = ""
remote_acknowledged: bool = False
external: bool = False
# The producer reached its mutation stage before reporting failure.
mutation_attempted: bool = False
def __post_init__(self) -> None:
if self.exit_code is not None and type(self.exit_code) is not int:
raise ValueError("Malformed producer exit code")
for name in ("timed_out", "output_truncated", "remote_acknowledged", "external", "mutation_attempted"):
if type(getattr(self, name)) is not bool:
raise ValueError("Malformed producer flag")
for name in ("failure_kind", "job_state"):
value = getattr(self, name)
_text(value, name, optional=True)
if len(value) > 64 or (value and not re.fullmatch(r"[a-z0-9_.:-]+", value)):
raise ValueError("Malformed producer label")
def to_dict(self) -> dict[str, Any]:
return {"exit_code": self.exit_code, "timed_out": self.timed_out,
"output_truncated": self.output_truncated, "failure_kind": self.failure_kind,
"job_state": self.job_state, "remote_acknowledged": self.remote_acknowledged,
"external": self.external, "mutation_attempted": self.mutation_attempted}
@classmethod
def from_dict(cls, value: Any) -> "ProducerFacts":
if not isinstance(value, dict) or set(value) != set(cls.__dataclass_fields__):
raise ValueError("Malformed persisted producer facts")
return cls(**value)
def _label(value: Any) -> str:
text = value.strip().lower() if isinstance(value, str) else ""
return text if len(text) <= 64 and re.fullmatch(r"[a-z0-9_.:-]+", text) else ""
def producer_facts(result: Any) -> ProducerFacts:
"""Project a dispatcher result into typed facts without trusting its shape.
Only exact scalar types are copied. Anything else becomes the default, so a
forged or malformed dictionary can only lose information, not add trust.
"""
if not isinstance(result, Mapping):
return ProducerFacts()
code = result.get("exit_code")
containment = result.get("containment")
external = isinstance(containment, Mapping) and containment.get("external") is True
job = result.get("status") if isinstance(result.get("job_id"), str) else ""
return ProducerFacts(
exit_code=code if type(code) is int else None,
timed_out=result.get("timed_out") is True or _label(result.get("failure_kind")) == "timeout",
output_truncated=result.get("output_truncated") is True or result.get("truncated") is True,
failure_kind=_label(result.get("failure_kind")),
job_state=_label(job),
external=external,
mutation_attempted=result.get("mutation_attempted") is True,
)
@dataclass(frozen=True)
class EffectOutcome:
"""Append-only execution outcome for one claim.
``impact`` must not claim no change for anything that reached a backend.
``cleanup`` is recorded separately: cleanup success is not business-effect
success and cleanup failure does not erase an achieved effect.
"""
effect_id: str
sequence: int
execution: ExecutionOutcome
impact: Impact
facts: ProducerFacts = ProducerFacts()
cleanup: CleanupState = CleanupState.NOT_APPLICABLE
execution_id: str = ""
replayed: bool = False
def __post_init__(self) -> None:
_text(self.effect_id, "effect identifier")
_text(self.execution_id, "execution identifier", optional=True)
_position(self.sequence)
if (not isinstance(self.execution, ExecutionOutcome) or not isinstance(self.impact, Impact)
or not isinstance(self.facts, ProducerFacts) or not isinstance(self.cleanup, CleanupState)
or type(self.replayed) is not bool):
raise ValueError("Malformed effect outcome")
if self.execution is ExecutionOutcome.ATTEMPTED:
raise ValueError("ATTEMPTED is derived from a claim without an outcome")
if (self.impact is Impact.NONE) != (self.execution is ExecutionOutcome.NOT_EXECUTED):
raise ValueError("Only a refused, never-invoked operation is a known no-op")
if self.execution is ExecutionOutcome.NOT_EXECUTED and self.execution_id:
raise ValueError("A refused operation has no execution identity")
def to_dict(self) -> dict[str, Any]:
return {"effect_id": self.effect_id, "sequence": self.sequence, "execution": self.execution.value,
"impact": self.impact.value, "facts": self.facts.to_dict(), "cleanup": self.cleanup.value,
"execution_id": self.execution_id, "replayed": self.replayed}
@classmethod
def from_dict(cls, value: Any) -> "EffectOutcome":
if not isinstance(value, dict) or set(value) != set(cls.__dataclass_fields__):
raise ValueError("Malformed persisted effect outcome")
return cls(value["effect_id"], value["sequence"], ExecutionOutcome(value["execution"]),
Impact(value["impact"]), ProducerFacts.from_dict(value["facts"]),
CleanupState(value["cleanup"]), value["execution_id"], value["replayed"])
# ---------------------------------------------------------------------------
# Observations
# ---------------------------------------------------------------------------
class ObservationMechanism(str, Enum):
FILESYSTEM_READ = "filesystem_read" # admitted read of the exact binding
OWNED_RECORD_READ = "owned_record_read" # admitted owner-scoped readback
REMOTE_READBACK = "remote_readback" # admitted independent remote query
PROCESS_OWNERSHIP = "process_ownership" # lifecycle owner's verdict
JOB_STATE = "job_state" # background job record transition
BROWSER_SESSION = "browser_session" # session lifecycle metadata only
# The following are never post-state verification.
EXECUTION_RECEIPT = "execution_receipt"
REMOTE_ACKNOWLEDGEMENT = "remote_acknowledgement"
class Coverage(str, Enum):
COMPLETE = "complete"
PARTIAL = "partial"
# Mechanisms able to decide a postcondition for each resource kind. Process,
# job and browser-session observations are lifecycle facts: they can make
# earlier evidence stale but cannot verify a file/record/remote predicate.
_VERIFYING = {
ResourceKind.FILESYSTEM: {ObservationMechanism.FILESYSTEM_READ},
ResourceKind.OWNED: {ObservationMechanism.OWNED_RECORD_READ},
ResourceKind.EXTERNAL: {ObservationMechanism.REMOTE_READBACK},
}
_ADMITTED_READS = {ObservationMechanism.FILESYSTEM_READ, ObservationMechanism.OWNED_RECORD_READ,
ObservationMechanism.REMOTE_READBACK}
@dataclass(frozen=True)
class Observation:
"""State seen through one mechanism for one exact resource.
``exists``/``content_sha256`` are what the mechanism saw; ``None``/empty
means not observed. A PARTIAL observation (offset/limit/truncated read,
listing, existence-only probe) never decides a whole-content predicate.
Admitted reads must name the journal action that performed them.
"""
observation_id: str
sequence: int
resource: ResourceRef
mechanism: ObservationMechanism
coverage: Coverage
source_action_id: str = ""
source_execution_id: str = ""
exists: bool | None = None
content_sha256: str = ""
evidence_event_id: str = ""
def __post_init__(self) -> None:
_text(self.observation_id, "observation identifier")
for name in ("source_action_id", "source_execution_id", "evidence_event_id"):
_text(getattr(self, name), name, optional=True)
_position(self.sequence)
if (not isinstance(self.resource, ResourceRef) or not isinstance(self.mechanism, ObservationMechanism)
or not isinstance(self.coverage, Coverage)
or (self.exists is not None and type(self.exists) is not bool)):
raise ValueError("Malformed observation")
if self.content_sha256 and (not _SHA256.fullmatch(self.content_sha256) or self.exists is not True):
raise ValueError("Malformed observed content digest")
if self.mechanism in _ADMITTED_READS and not self.source_action_id:
raise ValueError("Readback observations require the admitted action that performed them")
def to_dict(self) -> dict[str, Any]:
return {"observation_id": self.observation_id, "sequence": self.sequence,
"resource": self.resource.to_dict(), "mechanism": self.mechanism.value,
"coverage": self.coverage.value, "source_action_id": self.source_action_id,
"source_execution_id": self.source_execution_id, "exists": self.exists,
"content_sha256": self.content_sha256, "evidence_event_id": self.evidence_event_id}
@classmethod
def from_dict(cls, value: Any) -> "Observation":
if not isinstance(value, dict) or set(value) != set(cls.__dataclass_fields__):
raise ValueError("Malformed persisted observation")
return cls(**{**value, "resource": ResourceRef.from_dict(value["resource"]),
"mechanism": ObservationMechanism(value["mechanism"]),
"coverage": Coverage(value["coverage"])})
def predicate_holds(postcondition: Postcondition, observation: Observation) -> bool | None:
"""Decide one predicate from one observation; ``None`` means undecidable.
The check is performed here from the observed state, so no adapter can
attest verification by labelling an unrelated read.
"""
target = postcondition.target
if (not observation.resource.same_location(target)
or observation.mechanism not in _VERIFYING.get(target.kind, set())):
return None
predicate = postcondition.predicate
if predicate is Predicate.ABSENT:
return None if observation.exists is None else not observation.exists
if predicate is Predicate.EXISTS:
return observation.exists
if observation.exists is False:
return False
if observation.coverage is not Coverage.COMPLETE or not observation.content_sha256:
return None
if predicate is Predicate.CONTENT_SHA256:
return observation.content_sha256 == postcondition.expected
return observation.content_sha256 != postcondition.expected
# ---------------------------------------------------------------------------
# History, invalidation and freshness
# ---------------------------------------------------------------------------
class Freshness(str, Enum):
FRESH = "fresh"
STALE = "stale" # a later possible mutation or replacement overlaps
UNSETTLED = "unsettled" # an overlapping effect was still in flight
@dataclass(frozen=True)
class EffectHistory:
"""An immutable, totally ordered view of one effect log.
Sequences are unique positions in one log. Duplicate positions are rejected
rather than ordered arbitrarily.
"""
claims: tuple[EffectClaim, ...] = ()
outcomes: tuple[EffectOutcome, ...] = ()
observations: tuple[Observation, ...] = ()
def __post_init__(self) -> None:
positions = [r.sequence for r in (*self.claims, *self.outcomes, *self.observations)]
if len(positions) != len(set(positions)):
raise ValueError("Effect history positions must be unique")
ids = [c.effect_id for c in self.claims]
if len(ids) != len(set(ids)):
raise ValueError("Effect claims must have unique identifiers")
claim_at = {c.effect_id: c.sequence for c in self.claims}
settled: set[str] = set()
for outcome in sorted(self.outcomes, key=lambda o: o.sequence):
if outcome.effect_id not in claim_at or outcome.sequence <= claim_at[outcome.effect_id]:
raise ValueError("Outcome must follow its claim in one history")
# A RUNNING effect may later settle (background continuation or
# replay interruption); a settled outcome is never replaced.
if outcome.effect_id in settled:
raise ValueError("A settled effect outcome cannot be replaced")
if outcome.execution is not ExecutionOutcome.RUNNING:
settled.add(outcome.effect_id)
def claim(self, effect_id: str) -> EffectClaim | None:
return next((c for c in self.claims if c.effect_id == effect_id), None)
def latest_outcome(self, effect_id: str, before: int | None = None) -> EffectOutcome | None:
matching = [o for o in self.outcomes if o.effect_id == effect_id
and (before is None or o.sequence < before)]
return max(matching, key=lambda o: o.sequence) if matching else None
def execution(self, effect_id: str, before: int | None = None) -> ExecutionOutcome:
outcome = self.latest_outcome(effect_id, before)
return ExecutionOutcome.ATTEMPTED if outcome is None else outcome.execution
def _claim_touches(claim: EffectClaim, resource: ResourceRef) -> bool:
return claim.unknown_scope or any(ref.overlaps(resource) for ref in claim.impact_scope)
def invalidated_by(observation: Observation, history: EffectHistory) -> tuple[str, ...]:
"""Identifiers of later records that make ``observation`` stale.
Any later claim that may touch the resource invalidates it once the claim
exists (it may already be executing), unless it settled as a known no-op.
A later observation of the same location with a different incarnation
reveals replacement. Execution receipts are never invalidated: they remain
historical execution facts.
"""
if observation.mechanism in {ObservationMechanism.EXECUTION_RECEIPT,
ObservationMechanism.REMOTE_ACKNOWLEDGEMENT}:
return ()
reasons: list[str] = []
for claim in history.claims:
if claim.sequence <= observation.sequence or not _claim_touches(claim, observation.resource):
continue
outcome = history.latest_outcome(claim.effect_id)
if outcome is not None and outcome.impact is Impact.NONE:
continue
reasons.append(claim.effect_id)
for later in history.observations:
if (later.sequence > observation.sequence and later.resource.same_location(observation.resource)
and later.resource.incarnation != observation.resource.incarnation):
reasons.append(later.observation_id)
return tuple(dict.fromkeys(reasons))
def freshness(observation: Observation, history: EffectHistory) -> Freshness:
if invalidated_by(observation, history):
return Freshness.STALE
for claim in history.claims:
if claim.sequence < observation.sequence and _claim_touches(claim, observation.resource):
state = history.execution(claim.effect_id, before=observation.sequence)
if state in {ExecutionOutcome.ATTEMPTED, ExecutionOutcome.RUNNING}:
return Freshness.UNSETTLED
return Freshness.FRESH
# ---------------------------------------------------------------------------
# Verification
# ---------------------------------------------------------------------------
class EffectVerdict(str, Enum):
NOT_EXECUTED = "not_executed"
PENDING = "pending" # attempted/running; not settled
VERIFIED = "verified" # reported success + fresh matching post-state
STATE_OBSERVED = "state_observed" # matching post-state; causality unknown
UNVERIFIED = "unverified" # no adequate fresh evidence
CONTRADICTED = "contradicted" # latest fresh check shows the predicate false
FAILED = "failed" # execution failed; never effect success
_VERDICT_RANK = {EffectVerdict.FAILED: 0, EffectVerdict.CONTRADICTED: 1, EffectVerdict.PENDING: 2,
EffectVerdict.UNVERIFIED: 3, EffectVerdict.NOT_EXECUTED: 4,
EffectVerdict.STATE_OBSERVED: 5, EffectVerdict.VERIFIED: 6}
@dataclass(frozen=True)
class EffectAssessment:
effect_id: str
action_id: str
execution: ExecutionOutcome
impact: Impact | None
verdict: EffectVerdict
reason: str
cleanup: CleanupState = CleanupState.NOT_APPLICABLE
observation_ids: tuple[str, ...] = ()
targets: tuple[ResourceRef, ...] = ()
@property
def unresolved_impact(self) -> bool:
"""Resources may have changed in a way no fresh evidence has settled."""
return (self.impact is not Impact.NONE
and self.execution is not ExecutionOutcome.REPORTED_SUCCESS
and self.verdict not in {EffectVerdict.STATE_OBSERVED, EffectVerdict.CONTRADICTED})
def to_dict(self) -> dict[str, Any]:
return {"effect_id": self.effect_id, "action_id": self.action_id, "execution": self.execution.value,
"impact": None if self.impact is None else self.impact.value, "verdict": self.verdict.value,
"reason": self.reason, "cleanup": self.cleanup.value,
"observation_ids": list(self.observation_ids),
"targets": [t.to_dict() for t in self.targets]}
def _assess_obligation(claim: EffectClaim, settled: EffectOutcome, obligation: Postcondition,
history: EffectHistory) -> tuple[EffectVerdict, str, str]:
candidates = [o for o in history.observations
if o.sequence > settled.sequence and o.resource.same_location(obligation.target)
and o.mechanism in _VERIFYING.get(obligation.target.kind, set())]
if not candidates:
return EffectVerdict.UNVERIFIED, "no authorized post-settlement observation of the target", ""
# The newest check wins. A newer partial or failed check never falls back
# to an earlier complete one.
latest = max(candidates, key=lambda o: o.sequence)
state = freshness(latest, history)
if state is not Freshness.FRESH:
return EffectVerdict.UNVERIFIED, f"the latest target observation is {state.value}", latest.observation_id
holds = predicate_holds(obligation, latest)
if holds is None:
return EffectVerdict.UNVERIFIED, "the latest observation does not decide the postcondition", latest.observation_id
if not holds:
return EffectVerdict.CONTRADICTED, "the latest fresh observation contradicts the postcondition", latest.observation_id
execution = settled.execution
if execution is ExecutionOutcome.FAILED:
return EffectVerdict.FAILED, "execution failed; matching state is not attributed to it", latest.observation_id
if execution is ExecutionOutcome.REPORTED_SUCCESS:
return EffectVerdict.VERIFIED, "fresh authorized observation matches the postcondition", latest.observation_id
return (EffectVerdict.STATE_OBSERVED,
"state matches, but this execution's outcome is unknown; causality is not established",
latest.observation_id)
def assess(claim: EffectClaim, history: EffectHistory) -> EffectAssessment:
"""Derive a claim's verdict from the append-only history."""
settled = history.latest_outcome(claim.effect_id)
targets = tuple(o.target for o in claim.obligations)
if settled is None:
return EffectAssessment(claim.effect_id, claim.action_id, ExecutionOutcome.ATTEMPTED, None,
EffectVerdict.PENDING, "no settled execution outcome", targets=targets)
base = dict(effect_id=claim.effect_id, action_id=claim.action_id, execution=settled.execution,
impact=settled.impact, cleanup=settled.cleanup, targets=targets)
if settled.execution is ExecutionOutcome.NOT_EXECUTED:
return EffectAssessment(**base, verdict=EffectVerdict.NOT_EXECUTED, reason="refused before invocation")
if settled.execution is ExecutionOutcome.RUNNING:
return EffectAssessment(**base, verdict=EffectVerdict.PENDING,
reason="background execution has not settled")
if not claim.obligations:
verdict = EffectVerdict.FAILED if settled.execution is ExecutionOutcome.FAILED else EffectVerdict.UNVERIFIED
return EffectAssessment(**base, verdict=verdict, reason="no explicit postcondition obligation")
results = [_assess_obligation(claim, settled, o, history) for o in claim.obligations]
worst = min(results, key=lambda r: _VERDICT_RANK[r[0]])
if settled.execution is ExecutionOutcome.FAILED and worst[0] is not EffectVerdict.CONTRADICTED:
worst = (EffectVerdict.FAILED, worst[1] if worst[0] is EffectVerdict.FAILED else
"execution failed and may have partially changed the target", worst[2])
return EffectAssessment(**base, verdict=worst[0], reason=worst[1],
observation_ids=tuple(dict.fromkeys(r[2] for r in results if r[2])))
def assess_all(history: EffectHistory) -> tuple[EffectAssessment, ...]:
return tuple(assess(claim, history) for claim in sorted(history.claims, key=lambda c: c.sequence))
def replay_interrupted(history: EffectHistory, next_sequence: int) -> tuple[EffectOutcome, ...]:
"""Outcomes to append for claims that never settled before a reload.
Unknown remains unknown: the backend may or may not have been invoked, so
impact is POSSIBLE. Running background effects are left to their own
lifecycle owner and are not converted here.
"""
_position(next_sequence)
pending = [c for c in sorted(history.claims, key=lambda c: c.sequence)
if history.latest_outcome(c.effect_id) is None]
return tuple(EffectOutcome(c.effect_id, next_sequence + i, ExecutionOutcome.INTERRUPTED,
Impact.POSSIBLE, replayed=True) for i, c in enumerate(pending))