Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .github/ISSUE_TEMPLATE/work-item.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
name: Work item
about: Specify a change with executable acceptance criteria
---

## Problem

Describe the observable problem and the intended outcome.

## Acceptance criteria

- Replace this item with an observable acceptance criterion.

## Verification

Describe the commands or boundary checks that can prove the criteria.
43 changes: 40 additions & 3 deletions docs/execution-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ views `cycle.ROLE_CONTRACTS`, `run_cycle.SKILL_FOR_ROLE`, and
keys and values. In particular, `SKILL_FOR_ROLE` remains the five-role adapter
used by existing callers; catalog entries for other skills do not expand it.

## ExecutionPlan version 2
## ExecutionPlan version 3

`ExecutionPlan` serializes the plan contract without copying the existing
findings, verification checks, code-host checks, or evidence contracts. Its
findings or code-host checks. Its
identity includes `plan_id`, `revision`, repository, and work-item ID. It also
stores the objective, execution profile, effective configuration and policy,
plan provenance, review metadata, base/head references, and a metadata extension
Expand Down Expand Up @@ -73,7 +73,7 @@ rereview decision is one rule for every profile
invalidate the review's evidence, and open findings without fixes keep their
blocks rather than being reviewed again.

**Version negotiation.** `from_dict()` reads versions 1 and 2 and rejects every
**Version negotiation.** `from_dict()` reads versions 1, 2 and 3 and rejects every
other value, including `"1"` and `true`; a version 1 payload is upgraded, never
reinterpreted, and may not carry version 2 fields. `to_dict(contract_version=1)`
writes a lossless projection for a version 1 reader: it refuses while any
Expand All @@ -83,6 +83,43 @@ obligations, controls, and stage policies under
envelope is validated and its typed fields restored. A version 1 payload
read and projected back is unchanged.

Version 3 (#290) adds `verification`, a `VerificationPlan` from
`scripts/verification_plan.py`. It belongs to the enclosing plan's `plan_id`
and `revision`; it has no parallel identity or store. Older plans upgrade with
`verification: null`. Versions 1 and 2 may still be written when verification
is absent; projecting a plan that carries verification to either version is
rejected instead of dropping obligations or history.

The verification contract contains:

- `criteria`: provider, content SHA-256, evaluation status, ordered criterion
texts and a reason. Runtime parsing assigns `AC-1`, `AC-2`, etc. within this
owning revision. Changed content requires an explicit revision; identifiers
from a previous revision must not be silently assigned new meanings.
- `checks`: stable ID, exact command, executor, profile, execution context,
required flag, criterion IDs, command level, provenance and activation reason.
The configured test command is `configured-test` and required. Every check
linked to a criterion is required. An agent can add checks and promote an
obligation; it cannot remove or demote an existing one.
- `results`: append-only check ID, verified HEAD, `pass|fail|unverified`,
evidence, cause and attempts. Unexecuted results carry an explicit cause;
a null HEAD is permitted only for an unverified result whose target is unknown.
Executed evidence requires integer exit codes and nonnegative counts or null.

`extend_verification()` returns a new owning revision and preserves result
history. `VerificationPlan.extend()` freezes command, executor, profile,
context and provenance, allows additions/promotions, and retains omitted
entries. `record()` appends results; `current()` invalidates an old HEAD without
deleting history. `cap()` never raises the reported conclusion: required fail
caps at `failed`; required unverified, unavailable criteria or an unlinked
criterion cap at `not_verified`. Code-host CI never substitutes for these
results in #290.

The contract and in-cycle normalization are implemented here. Durable revision
and result persistence, execution of the plan, the end-of-cycle
`verification_unverified` gate, and verification-only resume belong to #329.
No provisional store or telemetry stop-reason schema is introduced by #290.

The contract reserves identity, profile, revision, provenance, effective
configuration, policy, and stage activation/omission reasons for later analysis
such as #127. `scripts/execution_profiles.py` builds the plan from the
Expand Down
75 changes: 75 additions & 0 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,81 @@ layers:
This section decides whether output is complete. [Evidence levels](#evidence-levels)
decides who produced it; the two don't overlap.

## Verification plan and tests version 2

New reports use `tests.version: 2` and `tests.checks`, replacing the legacy
top-level `tests.evidence` list. This is separate from the unchanged code-host
`checks.items` object. The enclosing result supplies `head_sha`.

```json
{
"version": 2,
"conclusion": "verified",
"boundary": "not_required",
"checks": [{
"id": "configured-test", "command": "python3 -m unittest",
"executor": "codex", "profile": "deep_coder", "context": "project",
"required": true, "criteria": ["AC-1"], "level": "test",
"provenance": "project", "reason": "configured test command",
"head_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"result": "pass", "cause": "", "attempts": [],
"evidence": [{"exit_code": 0, "executed": 14}]
}]
}
```

Freeze each check before execution. A result names the same check ID, command,
executor/profile and context. The runtime supplies frozen check identities to
later code stages. Copy that identity, including its owning executor/profile,
even when resolve or rereview is dispatched with another profile or executor.
A check requiring a different execution context gets a new ID and does not
replace the existing obligation. Required checks originate in project policy and
criterion links; an agent may promote, never demote. Record an unexecuted check
as `result: unverified`, with `cause`, evidence and actual recovery `attempts`;
do not invent an exit code. Results on another HEAD stay in history but cannot
prove the current change. An uncovered criterion remains open. Required failure
caps `tests.conclusion` at `failed`; required unverified or open criteria cap it
at `not_verified`. These caps never improve an agent conclusion.

Before implementation, the runtime reads the GitHub work-item body once per
cycle and parses one explicit criteria section. Accepted headings are exactly
`Acceptance criteria` and `Criterios de aceptación`, case insensitive, as a
Markdown heading (`#` through `######`) or bold label. A trailing colon or
period is allowed inside a bold label or after it. The section is a flat list
at column zero using `-`, `+`, `*`, `1.` or `1)` markers and optional Markdown
checkboxes; another heading or bold label ends it. Empty, missing, duplicate,
placeholder or unparseable sections produce deterministic `needs_refinement`
before any implementation, even with issue review off or difficulty 1. They
are insufficient specification, never a failed test. Existing issues are not
migrated. `.github/ISSUE_TEMPLATE/work-item.md` supplies the grammar for new work.
IDs are `AC-n` in order, scoped to the execution-plan revision and content hash.
Plane and Jira return `not_evaluable` with reason `criteria reader unavailable`.
Their cycles stop as `BLOCKED` with `stop_reason: readiness_unconfirmed` before
any agent dispatch, regardless of issue-review mode or difficulty. Editing the
work item cannot clear this stop: a provider reader must become available
(Plane reading belongs to #238-A; Jira needs its own reader). This differs from
parsed criteria needing refinement; neither unavailable nor unmet criteria
count as satisfied.

For one release, omitted `tests.version` or version 1 accepts `tests.evidence`.
The compatibility interval starts with the first published release supporting
version 2 and ends at the next published release. With runtime obligations,
the adapter matches exact commands and preserves missing required checks as
explicit unverified results; absence never creates a pass. Legacy standalone
reports retain their historical evidence classification. Version 2 evidence
is normalized into a private compatibility view for `tests_basis`,
`boundary_verified`, telemetry and stats; this view is not a second result
contract. Malformed or unsupported reports cap at `not_verified`.
Stages that omit `tests` or report `tests.reason: no_code_change` without outcome
fields record no new test outcome and preserve the previous cycle-level outcome.
Explicit reports (including `checks`, `evidence`, `conclusion`, `passed` or `ran`)
still receive the deterministic caps, even with `no_code_change`; a required
boundary run can still be recorded as missing for completed code work.

Plan persistence, the final verification gate and verification-only resume are
deferred to #329; see [Execution plan](execution-plan.md). CI substitution is
out of scope: `CI_PASSED` does not turn a local unverified result into pass.

## Evidence levels

Every recorded test outcome has one `tests_basis` token. The functional
Expand Down
42 changes: 38 additions & 4 deletions scripts/execution_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,21 @@

from dataclasses import dataclass, field, replace
from enum import Enum
from typing import Any, Mapping
from typing import Any, Mapping, TYPE_CHECKING

from executors import WorkspacePolicy
from issue_review import ROLE as ISSUE_REVIEW_ROLE, SKILL as ISSUE_REVIEW_SKILL
if TYPE_CHECKING:
from .verification_plan import VerificationPlan, VerificationError, Check
else:
from verification_plan import VerificationPlan, VerificationError, Check


CONTRACT_VERSION = 2
CONTRACT_VERSION = 3

#: Versions `ExecutionPlan.from_dict` reads and `to_dict` can write. Version 1
#: is read by upgrading it and written only as a lossless projection.
SUPPORTED_CONTRACT_VERSIONS = (1, 2)
SUPPORTED_CONTRACT_VERSIONS = (1, 2, 3)


class ContractError(ValueError):
Expand Down Expand Up @@ -414,8 +418,16 @@ class ExecutionPlan:
decisions: tuple[PendingDecision, ...] = ()
obligations: tuple[ConditionalObligation, ...] = ()
controls: tuple[PlannedControl, ...] = ()
verification: VerificationPlan | None = None

def validate(self) -> None:
if self.verification is not None:
if not isinstance(self.verification, VerificationPlan):
raise ContractError("verification must be a verification plan")
try:
self.verification.validate()
except VerificationError as exc:
raise ContractError(str(exc)) from exc
if type(self.contract_version) is not int:
raise ContractError("contract version must be an integer")
if self.contract_version != CONTRACT_VERSION:
Expand Down Expand Up @@ -661,6 +673,18 @@ def _validate_obligations(self, stages: dict[str, PlannedStage],

# -- queries ------------------------------------------------------------

def extend_verification(self, checks: tuple[Check, ...]) -> ExecutionPlan:
"""A new owning-plan revision; never remove a frozen check or its history."""
if self.verification is None:
raise ContractError("initialize verification with runtime criteria first")
try:
verification = self.verification.extend(checks)
except VerificationError as exc:
raise ContractError(str(exc)) from exc
result = replace(self, revision=self.revision + 1, verification=verification)
result.validate()
return result

def stage(self, stage_id: str) -> PlannedStage:
for item in self.stages:
if item.stage_id == stage_id:
Expand Down Expand Up @@ -769,12 +793,16 @@ def to_dict(self, contract_version: int = CONTRACT_VERSION) -> dict[str, Any]:
self.validate()
if type(contract_version) is not int:
raise ContractError(f"unsupported execution-plan contract version: {contract_version!r}")
if contract_version == CONTRACT_VERSION:
if contract_version in (2, CONTRACT_VERSION):
if contract_version == 2 and self.verification is not None:
raise ContractError("version 2 cannot represent verification; negotiate version 3")
return {
**self._common_dict(dict(self.effective_policy), contract_version),
"decisions": [item.to_dict() for item in self.decisions],
"obligations": [item.to_dict() for item in self.obligations],
"controls": [item.to_dict() for item in self.controls],
**({"verification": self.verification.to_dict() if self.verification else None}
if contract_version >= 3 else {}),
}
if contract_version == 1:
return self._project_v1()
Expand Down Expand Up @@ -808,6 +836,8 @@ def _project_v1(self) -> dict[str, Any]:
projection; settled obligations, controls, and stage policies travel
under `effective_policy.contract_v2` rather than disappearing.
"""
if self.verification is not None:
raise ContractError("version 1 cannot represent verification; negotiate version 3")
pending = [f"decision for {item.subject}" for item in self.decisions]
pending += [f"obligation {item.obligation_id}" for item in self.obligations
if item.state is ObligationState.PENDING]
Expand Down Expand Up @@ -840,6 +870,8 @@ def from_dict(cls, payload: Mapping[str, Any]) -> ExecutionPlan:
version = payload["contract_version"]
if type(version) is not int or version not in SUPPORTED_CONTRACT_VERSIONS:
raise ContractError(f"unsupported execution-plan contract version: {version!r}")
if version < 3 and "verification" in payload:
raise ContractError("verification requires execution-plan version 3")
if version == 1 and _V2_PLAN_KEYS & set(payload):
raise ContractError("a version 1 plan cannot carry version 2 fields")
if version == 1:
Expand Down Expand Up @@ -961,6 +993,8 @@ def entries(name: str) -> list[Mapping[str, Any]]:
decisions=decisions,
obligations=obligations,
controls=controls,
verification=(VerificationPlan.from_dict(payload["verification"])
if payload.get("verification") is not None else None),
)
plan.validate()
except (KeyError, TypeError, ValueError, AttributeError, IndexError) as exc:
Expand Down
14 changes: 10 additions & 4 deletions scripts/native_prerequisites.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,10 +384,16 @@ def static_result(spoken: str, commands, evidence: dict[str, Any]) -> str:
return spoken
if not isinstance(payload, dict):
return spoken
payload["tests"] = {"ran": False, "conclusion": "not_verified",
"evidence": [{"command": command, "exit_code": None,
"executed": None, "reason": "native isolation unavailable"}
for command in commands]}
payload["tests"] = {"version": 2, "ran": False, "conclusion": "not_verified",
"checks": [{"id": f"native-{index}", "command": command,
"executor": "native", "profile": "selected",
"context": "native", "required": True, "criteria": [],
"level": "test", "provenance": "runtime",
"reason": "configured verification",
"head_sha": payload.get("head_sha"), "result": "unverified",
"evidence": [], "cause": "native isolation unavailable",
"attempts": []}
for index, command in enumerate(commands, 1)]}
payload["native_sandbox_preflight"] = evidence
return (spoken[:begin] + "ORCHESTRATION_RESULT\n" + json.dumps(payload)
+ "\n" + spoken[end:])
Loading
Loading