Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/execution-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,13 @@ On resume the runtime reconciles the current configuration, source criteria,
remote receipt and local revisions. Required checks are retained across HEAD
changes; results from another SHA cannot satisfy them. Changed criteria add new
identities rather than reusing old coverage. Command hashes can be rebound only
to currently authorized exact commands. Without recoverable local history the
to currently authorized exact commands. When some stored commands have no
current binding, the authorized ones are still bound, the others stay inert
fingerprints, and every check, obligation and result is kept under `partial`
provenance. The projection's normalized `provenance` and `reason` are not part
of a check's identity, so a restored check is not a conflict with its original
description. See [Verification](verification.md) for identity fields and the
diagnostics that name a conflicting check. Without recoverable local history the
runtime replans with `partial` provenance and accepts no prior check as passed.
This is partial recovery, not a fully verified continuation. Analysis such as
#127 can read this metadata without reading prompts.
Expand Down
33 changes: 33 additions & 0 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,39 @@ commands are `sha256:` fingerprints, does not gain a second
keep the same check identities, results and obligations, and a frozen identity is
never rewritten.

A check's execution identity is its `command`, `executor`, `profile`, `context`
and `level`. `provenance` and `reason` only describe it: the history store
normalizes them to `runtime`, so a report that describes a frozen check
differently, such as the configured test with `provenance: project`, is the same
check and keeps the frozen description. An executor, profile or context the store
kept as a `ref-<sha256>` reference matches the value it was derived from. Any
other difference in an identity field is another check: it needs a new ID, and
its report cannot overwrite the frozen check or record evidence for it.

When a resume cannot bind every stored command, for example because a proposed
command is no longer authorized, the commands that today's policy authorizes are
still bound and the others stay inert fingerprints. Every check, obligation and
earlier result is kept; the plan is recorded as `partial` and each check is
marked `unverified` on the current HEAD. An agent cannot bind a fingerprint by
reporting the command it hides: only today's authorization can, so such a report
is rejected like any other identity change. Existing unverified local checks
stay unverified until evidence for the current HEAD exists; CI never supplies it.

A rejected identity names the check and every differing field. Commands appear
only as a 12-character fingerprint prefix, marked `unbound` while the frozen
command is still a fingerprint; other values that are not plain tokens appear as
`ref-` references. For example:

```text
cannot rewrite frozen check identity: check configured-test-8478ec11cc826f7e differs in command (frozen unbound sha256:8478ec11cc82, proposed sha256:8478ec11cc82)
```

Identical fingerprints with `unbound` on one side mean the command is the same but
has no current authorization. Before dispatch, this text is the `BLOCKED` stop
reason with `stop_reason: readiness_unconfirmed`. In a stage report, it is a
report note while the report stays `not_verified`. An obligation demotion names
the check and what it would lose (`required`, `criteria`).

Hotfix requires `hotfix-reproduction` and `hotfix-regression`, both `required`,
with distinct focused commands proposed by the implementer. The configured
general suite satisfies neither identity. A missing proposal stops with
Expand Down
6 changes: 6 additions & 0 deletions scripts/cycle_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,12 @@ def advance(self: Any, role: str, instruction: str = "", *,
outcome.decision.profile if outcome.decision else "unknown"),))
test_payload, self.state.verification_plan = self.api._normalize_test_payload(
test_payload, self.state.verification_plan)
rejected = (test_payload.get("tests") or {}).get("rejected")
if isinstance(rejected, str):
# The report stays unverified; say which check and field.
# Some rejections quote an agent-chosen check ID verbatim.
self.report.note(f" verification {role} report rejected: "
f"{self.api.readable(rejected, 300)}")
try:
self.record_plan_stage(role, reported, outcome, test_payload)
except ValueError as error:
Expand Down
16 changes: 12 additions & 4 deletions scripts/cycle_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
if TYPE_CHECKING:
from .execution_plan import ExecutionPlan

from verification_plan import persisted_token
from review_contract import (
CLAIM_STATUSES,
RESOLUTION_RUN,
Expand Down Expand Up @@ -238,8 +239,7 @@ def clean(value, key=""):
if key == "command":
return command_digest(value)
if key in {"id", "executor", "profile", "context"} and isinstance(value, str):
return (value if re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value)
else "ref-" + hashlib.sha256(value.encode()).hexdigest())
return persisted_token(value)
if key == "items":
return [f"AC-{i}" for i in range(1, len(value) + 1)]
if key == "cause":
Expand Down Expand Up @@ -273,8 +273,14 @@ def clean(value, key=""):
return {"projection_version": 1, "plan": clean(plan.to_dict())}


def restore_plan(record: dict[str, Any], commands: tuple[str, ...]) -> ExecutionPlan:
"""Recover only checks whose command is still explicitly authorized."""
def restore_plan(record: dict[str, Any], commands: tuple[str, ...], *,
partial: bool = False) -> ExecutionPlan:
"""Recover only checks whose command is still explicitly authorized.

With `partial`, a command without a current binding stays its inert
fingerprint instead of failing the whole restore. Every check, obligation
and historical result is kept either way; a fingerprint is never executable.
"""
if not TYPE_CHECKING:
from execution_plan import ExecutionPlan
payload = record["payload"]
Expand All @@ -284,6 +290,8 @@ def restore_plan(record: dict[str, Any], commands: tuple[str, ...]) -> Execution
def bind(value, key=""):
if key == "command":
if value not in bindings:
if partial:
return value
raise HistoryError("stored check has no currently authorized command binding")
return bindings[value]
if isinstance(value, dict):
Expand Down
7 changes: 5 additions & 2 deletions scripts/cycle_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ def prepare_plan(self: Any):
except (HistoryError, ValueError, TypeError):
self.plan_partial = True
if record is not None:
commands: tuple[str, ...] = ()
try:
commands = self.authorized_bindings()
recovered = restore_plan(record, commands)
Expand Down Expand Up @@ -187,9 +188,11 @@ def prepare_plan(self: Any):
# Keep unavailable command identities as obligations. A
# fingerprint is not executable and grants no permission.
# Re-authorizing its exact command permits a later resume.
# Commands authorized today are still bound, so the
# configured check keeps one identity across resumes (#393).
try:
retained = ExecutionPlan.from_dict(record["payload"]["plan"])
except (KeyError, TypeError, ContractError):
retained = restore_plan(record, commands, partial=True)
except (KeyError, TypeError, ContractError, HistoryError):
retained = None
self.state.verification_plan = retained.verification if retained is not None else None
if retained is not None and retained.verification is not None:
Expand Down
5 changes: 4 additions & 1 deletion scripts/run_cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -2694,13 +2694,16 @@ def _normalize_test_payload(
try:
normalized, updated = verification_contract.normalize_tests(
tests, payload.get("head_sha", ""), plan=plan)
except (verification_contract.VerificationError, KeyError, TypeError, ValueError):
except (verification_contract.VerificationError, KeyError, TypeError, ValueError) as error:
# Invalid version/identity/evidence is insufficient verification, not
# an executed test failure and never an exception escaping the driver.
conclusion = "failed" if tests.get("conclusion") == "failed" else "not_verified"
if plan is not None:
conclusion = plan.cap(conclusion, payload.get("head_sha", ""))
normalized, updated = {"conclusion": conclusion, "passed": False}, plan
if isinstance(error, verification_contract.VerificationError):
# Its text names check IDs and field names, never commands (#393).
normalized["rejected"] = str(error)
payload["tests"] = normalized
return payload, updated

Expand Down
71 changes: 66 additions & 5 deletions scripts/verification_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,50 @@ def validate(self, criterion_ids: set[str]) -> None:
raise VerificationError("a criterion-dependent check must be required")


#: What a check executes, where and by whom. Changing any of them is another
#: check: it needs another ID and never inherits this one's evidence.
IDENTITY_FIELDS = ("command", "executor", "profile", "context", "level")
_FINGERPRINT = re.compile(r"sha256:[a-f0-9]{64}")
_SAFE_TOKEN = re.compile(r"[A-Za-z0-9._:-]{1,64}")


def persisted_token(value: str) -> str:
"""How the history store keeps an ID, executor, profile or context value."""
return (value if re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value)
else "ref-" + hashlib.sha256(value.encode()).hexdigest())


def identity_conflict(frozen: Check, proposed: Check) -> tuple[str, ...]:
"""The identity fields in which two checks with one ID differ.

A frozen executor, profile or context restored from history may be the
store's reference for the proposed value; that is the same identity. A
command is never matched by its fingerprint here: only today's
authorization may bind one (`cycle_history.restore_plan`).
"""
def same(field: str, old: Any, new: Any) -> bool:
return old == new or (field in {"executor", "profile", "context"}
and isinstance(new, str) and old == persisted_token(new))
return tuple(field for field in IDENTITY_FIELDS
if not same(field, getattr(frozen, field), getattr(proposed, field)))


def safe_value(field: str, value: Any) -> str:
"""A diagnostic form that never reveals a command, path or free text.

A command is shown as a short fingerprint, marked `unbound` while it is
still the persisted fingerprint, so an operator can tell a missing binding
from a different command without the command itself.
"""
if field == "command":
if isinstance(value, str) and _FINGERPRINT.fullmatch(value):
return "unbound " + value[:19]
return "sha256:" + hashlib.sha256(str(value).encode()).hexdigest()[:12]
if isinstance(value, str) and _SAFE_TOKEN.fullmatch(value):
return value
return "ref-" + hashlib.sha256(repr(value).encode()).hexdigest()[:12]


@dataclass(frozen=True)
class CheckResult:
id: str
Expand Down Expand Up @@ -219,15 +263,32 @@ def validate(self) -> None:
raise VerificationError("result evidence command differs from check")

def extend(self, additions: tuple[Check, ...]) -> VerificationPlan:
"""Freeze identity and obligations. Promotion and additions are allowed."""
"""Freeze identity and obligations. Promotion and additions are allowed.

The execution identity is `IDENTITY_FIELDS`. Provenance and reason are
descriptive: persistence normalizes them, so the frozen values are kept
and a different description of the same check is not a conflict.
"""
checks = {check.id: check for check in self.checks}
for check in additions:
old = checks.get(check.id)
if old is not None:
if replace(check, required=old.required, criteria=old.criteria) != old:
raise VerificationError("cannot rewrite frozen check identity")
if (old.required and not check.required) or not set(old.criteria) <= set(check.criteria):
raise VerificationError("cannot demote a frozen obligation")
differing = identity_conflict(old, check)
if differing:
raise VerificationError(
f"cannot rewrite frozen check identity: check {safe_value('id', old.id)} "
"differs in " + ", ".join(
f"{field} (frozen {safe_value(field, getattr(old, field))}, "
f"proposed {safe_value(field, getattr(check, field))})"
for field in differing))
demoted = [name for name, lost in (
("required", old.required and not check.required),
("criteria", not set(old.criteria) <= set(check.criteria))) if lost]
if demoted:
raise VerificationError(
f"cannot demote a frozen obligation: check {safe_value('id', old.id)} "
"would lose " + ", ".join(demoted))
check = replace(old, required=check.required, criteria=check.criteria)
checks[check.id] = check
plan = replace(self, checks=tuple(checks.values()))
plan.validate()
Expand Down
Loading
Loading