From 1f14cbdb7009f7ad468dfd256bd4503fb2b62dd0 Mon Sep 17 00:00:00 2001 From: Andrey V Date: Mon, 31 Aug 2026 22:50:20 +0300 Subject: [PATCH 1/3] feat(workflow): add phase packets and validation ladder --- .agents/plugins/marketplace.json | 2 +- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .cursor-plugin/marketplace.json | 4 +- .cursor-plugin/plugin.json | 2 +- CHANGELOG.md | 12 + README.md | 4 +- adapters/claude/.claude-plugin/plugin.json | 2 +- adapters/codex/.codex-plugin/plugin.json | 2 +- adapters/cursor/.cursor-plugin/plugin.json | 2 +- docs/README.md | 4 +- docs/guides/install-and-first-run.md | 10 +- docs/guides/phase-session-handoff.md | 21 + docs/reference/cli.md | 15 +- docs/reference/phase-packets.md | 65 ++ docs/reference/public-contracts.md | 17 + docs/reference/validation-ladder.md | 54 ++ docs/ru/README.md | 6 +- docs/ru/guides/install-and-first-run.md | 10 +- docs/ru/guides/phase-session-handoff.md | 22 + docs/ru/reference/cli.md | 13 +- docs/ru/reference/phase-packets.md | 64 ++ docs/ru/reference/public-contracts.md | 11 + docs/ru/reference/validation-ladder.md | 52 ++ pyproject.toml | 2 +- src/agent_lifecycle/_version.py | 2 +- src/agent_lifecycle/cli/dispatch_lifecycle.py | 280 ++++++- src/agent_lifecycle/cli/dispatch_planning.py | 120 ++- src/agent_lifecycle/cli/lifecycle_parsers.py | 15 + src/agent_lifecycle/cli/planning_parsers.py | 2 + src/agent_lifecycle/compiler/__init__.py | 3 + src/agent_lifecycle/compiler/phase_packets.py | 474 +++++++++++ .../contracts/phase_packet_schemas.py | 264 ++++++ src/agent_lifecycle/contracts/schemas.py | 4 + .../contracts/validation_ladder_schemas.py | 154 ++++ src/agent_lifecycle/planning/completeness.py | 9 + .../planning/manifest_contract.py | 59 +- src/agent_lifecycle/quality/__init__.py | 22 + .../quality/validation_ladder.py | 790 ++++++++++++++++++ src/agent_lifecycle/workflow/finalization.py | 118 +++ tests/cli/test_specification_plan_commands.py | 223 +++-- tests/cli/test_workflow_commands.py | 301 +++++++ tests/compiler/test_phase_packets.py | 336 ++++++++ tests/contracts/test_contracts.py | 6 + tests/contracts/test_phase_packet_schemas.py | 66 ++ .../test_validation_ladder_schemas.py | 35 + tests/planning/test_completeness.py | 44 +- tests/planning/test_manifest_contract.py | 21 +- tests/quality/test_validation_ladder.py | 260 ++++++ tests/release/test_docs_gates.py | 24 + tests/release/test_publication_adoption.py | 27 + tests/release/test_publication_versions.py | 15 +- tests/workflow/test_finalization.py | 129 ++- tools/release/publication_contract.py | 15 +- tools/release/validate_docs_compat.py | 54 ++ uv.lock | 2 +- 57 files changed, 4174 insertions(+), 104 deletions(-) create mode 100644 docs/reference/phase-packets.md create mode 100644 docs/reference/validation-ladder.md create mode 100644 docs/ru/reference/phase-packets.md create mode 100644 docs/ru/reference/validation-ladder.md create mode 100644 src/agent_lifecycle/compiler/phase_packets.py create mode 100644 src/agent_lifecycle/contracts/phase_packet_schemas.py create mode 100644 src/agent_lifecycle/contracts/validation_ladder_schemas.py create mode 100644 src/agent_lifecycle/quality/validation_ladder.py create mode 100644 tests/compiler/test_phase_packets.py create mode 100644 tests/contracts/test_phase_packet_schemas.py create mode 100644 tests/contracts/test_validation_ladder_schemas.py create mode 100644 tests/quality/test_validation_ladder.py diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 947b047e..fd74a401 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,7 +9,7 @@ "source": { "source": "url", "url": "https://github.com/avksp/agent-lifecycle-kit.git", - "ref": "v2.10.0" + "ref": "v2.11.0" }, "policy": { "installation": "AVAILABLE", diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6a9c487b..ff3a4f93 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,10 +9,10 @@ "source": { "source": "github", "repo": "avksp/agent-lifecycle-kit", - "ref": "v2.10.0" + "ref": "v2.11.0" }, "description": "Reviewed SDD planning, budgeted execution, adapter conformance, implementation audit, and final proof.", - "version": "2.10.0", + "version": "2.11.0", "author": { "name": "Agent Lifecycle Kit contributors" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2b288388..9c18e7f3 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "2.10.0", + "version": "2.11.0", "description": "Provider-neutral lifecycle kit for reviewed SDD planning, budgeted execution, adapter conformance, independent audits, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 57d338f4..06ca61b0 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "2.10.0", + "version": "2.11.0", "description": "Provider-neutral lifecycle kit for reviewed SDD planning, budgeted execution, adapter conformance, independent audits, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors", diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 69d1ddfe..6e46c1df 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Provider-neutral lifecycle kit for reviewed SDD planning, budgeted execution, adapter conformance, independent audits, and final proof.", - "version": "2.10.0" + "version": "2.11.0" }, "plugins": [ { "name": "agent-lifecycle-kit", "source": ".", "description": "Reviewed SDD planning, budgeted execution, adapter conformance, implementation audit, and final proof.", - "version": "2.10.0", + "version": "2.11.0", "author": { "name": "Agent Lifecycle Kit contributors" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index ec46b689..255b81dd 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "2.10.0", + "version": "2.11.0", "description": "Provider-neutral lifecycle kit for reviewed SDD planning, budgeted execution, adapter conformance, independent audits, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/CHANGELOG.md b/CHANGELOG.md index a4bcb27e..5909f79d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ - No changes yet. +## 2.11.0 - 2026-08-31 + +- Added bounded `agent-phase-packet.v1` projections for planning handoff, + implementation, independent task audit and remediation without creating new + workflow authority. +- Added read-only `workflow validation-select` with digest-bound check + catalogs, command-free path profiles, a protected `RELEASE_FULL` floor and + exact full-validation receipt enforcement for opted-in finalization. +- Preserved existing handoff, task snapshot and legacy finalization behavior; + added a separate comparable economics pair and release accounting for + version `2.11.0` without inventing unavailable token telemetry. + ## 2.10.0 - 2026-08-31 - Added bounded multi-transition `workflow continue --input-bundle` execution diff --git a/README.md b/README.md index c0a83c05..da52e802 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ALK provides a verifiable finish for external-agent work with proportionate cont ## Quick start Start with [install ALK and make the first run](docs/guides/install-and-first-run.md). -It includes macOS, Linux and Windows instructions, Python 3.11-3.14, [PyPI](https://pypi.org/project/agent-lifecycle-kit/) installation with `python -m pip install agent-lifecycle-kit==2.10.0`, and common `agent-lifecycle version` errors. +It includes macOS, Linux and Windows instructions, Python 3.11-3.14, [PyPI](https://pypi.org/project/agent-lifecycle-kit/) installation with `python -m pip install agent-lifecycle-kit==2.11.0`, and common `agent-lifecycle version` errors. For a quick start, run: @@ -47,7 +47,7 @@ agent-lifecycle start --adapter --file task.md - The [system architecture](docs/architecture/system-architecture.md) page explains how the project is organized, and [project comparison](docs/reference/project-comparison.md) shows how it differs from similar tools. - Multiple-agent boundaries, workflow configuration, model choice, prompts, timeouts and retries are described in [workflow customization and execution controls](docs/reference/workflow-customization.md). - Create a local project profile with `agent-lifecycle project profile init`; see [project workflow profile](docs/reference/project-workflow-profile.md). Explain the selected profile, plan constraints and adapter capability lineage with [effective configuration](docs/reference/effective-configuration.md), [structured result qualification](docs/reference/structured-result-qualification.md) and [adapter action evidence](docs/reference/adapter-action-evidence.md). Add optional project-owned architecture and dependency evidence with [external verification checks](docs/reference/external-verification-checks.md), run longer specialized adapter work through [bounded external tool jobs](docs/reference/external-tool-jobs.md), track bounded terminology with [project domain language](docs/reference/project-domain-language.md), coordinate several runs with the read-only [multi-run attention view](docs/reference/multi-run-attention-view.md), or use the optional [security analysis profile](docs/reference/security-analysis-profile.md) for untrusted findings and independently verified remediation. -- For long sessions, use [context checkpoints and compaction recovery](docs/reference/context-checkpoints.md). They preserve a bounded local continuation package without changing plan authority. Measure phases and releases with [release accounting](docs/reference/release-accounting.md), and cross session boundaries with the bounded [phase-to-session handoff](docs/guides/phase-session-handoff.md). +- For long sessions, use [context checkpoints and compaction recovery](docs/reference/context-checkpoints.md). They preserve a bounded local continuation package without changing plan authority. Measure phases and releases with [release accounting](docs/reference/release-accounting.md), follow the bounded [phase-to-session handoff](docs/guides/phase-session-handoff.md) with [phase packets](docs/reference/phase-packets.md), and choose focused checks through the read-only [validation ladder](docs/reference/validation-ladder.md). ## What it gives you - A finish-oriented lifecycle: plan, execute, review, and prove the result. - One process for different CLIs: adapter-specific commands stay outside the diff --git a/adapters/claude/.claude-plugin/plugin.json b/adapters/claude/.claude-plugin/plugin.json index 8eda3843..908e8a2c 100644 --- a/adapters/claude/.claude-plugin/plugin.json +++ b/adapters/claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "2.10.0", + "version": "2.11.0", "description": "Lifecycle skills and adapter metadata for reviewed planning, budgeted execution, adapter conformance, audit, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/adapters/codex/.codex-plugin/plugin.json b/adapters/codex/.codex-plugin/plugin.json index 364e9527..c25e152d 100644 --- a/adapters/codex/.codex-plugin/plugin.json +++ b/adapters/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "2.10.0", + "version": "2.11.0", "description": "Provider-neutral lifecycle kit and adapter metadata for reviewed planning, budgeted execution, adapter conformance, audit, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/adapters/cursor/.cursor-plugin/plugin.json b/adapters/cursor/.cursor-plugin/plugin.json index ac36c636..58704d10 100644 --- a/adapters/cursor/.cursor-plugin/plugin.json +++ b/adapters/cursor/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "2.10.0", + "version": "2.11.0", "description": "Agent lifecycle planning, budgeted execution, adapter conformance, audit, and final proof for Cursor.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/docs/README.md b/docs/README.md index 21c7304c..b0a99050 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ that result. It is host-neutral and does not depend on a source project. - [Commands by task](guides/commands-by-task.md) - [Runner migration for 2.x](guides/runner-migration-2.md) - Official PyPI package for Python 3.11-3.14: - `python -m pip install agent-lifecycle-kit==2.10.0` from + `python -m pip install agent-lifecycle-kit==2.11.0` from [agent-lifecycle-kit](https://pypi.org/project/agent-lifecycle-kit/). - One safe entrypoint: `agent-lifecycle start --adapter --file task.md`. - [Quickstart](guides/quickstart.md) @@ -37,6 +37,8 @@ that result. It is host-neutral and does not depend on a source project. - [Context checkpoints and compaction recovery](reference/context-checkpoints.md) - [Release accounting](reference/release-accounting.md) - [Phase-to-session handoff](guides/phase-session-handoff.md) +- [Phase packets](reference/phase-packets.md) +- [Validation ladder](reference/validation-ladder.md) - [Structured result qualification](reference/structured-result-qualification.md) - [Optional adapter lifecycle control](adapters/lifecycle-control.md) - [How ALK works for different tasks](guides/how-alk-works.md) diff --git a/docs/guides/install-and-first-run.md b/docs/guides/install-and-first-run.md index ace1200b..193b7eaf 100644 --- a/docs/guides/install-and-first-run.md +++ b/docs/guides/install-and-first-run.md @@ -77,7 +77,7 @@ Use an isolated environment for a package installation as well. python3 -m venv ~/.venvs/alk source ~/.venvs/alk/bin/activate python -m pip install --upgrade pip -python -m pip install agent-lifecycle-kit==2.10.0 +python -m pip install agent-lifecycle-kit==2.11.0 python -m agent_lifecycle version agent-lifecycle version ``` @@ -88,7 +88,7 @@ agent-lifecycle version py -m venv "$HOME\venvs\alk" & "$HOME\venvs\alk\Scripts\Activate.ps1" python -m pip install --upgrade pip -python -m pip install agent-lifecycle-kit==2.10.0 +python -m pip install agent-lifecycle-kit==2.11.0 python -m agent_lifecycle version agent-lifecycle version ``` @@ -188,7 +188,7 @@ Choose one host and follow its adapter page. The common sequence is: Codex: ```bash -codex plugin marketplace add avksp/agent-lifecycle-kit --ref v2.10.0 +codex plugin marketplace add avksp/agent-lifecycle-kit --ref v2.11.0 codex plugin add agent-lifecycle-kit@agent-lifecycle-kit codex plugin list ``` @@ -227,6 +227,10 @@ no provider client or second lifecycle authority. Release 2.6 adds [release accounting](../reference/release-accounting.md) and a bounded [phase-to-session handoff](phase-session-handoff.md). Missing telemetry stays unavailable, and handoff artifacts do not replace workflow authority. +Release 2.11 adds [bounded phase packets](../reference/phase-packets.md) and a +read-only [validation ladder](../reference/validation-ladder.md). Focused +selection improves feedback latency, while protected and release paths retain +the complete validation floor. Before execution, create a reviewed final lock with `agent-lifecycle plan lock-create --manifest --review `; it fails rather than replacing an existing `plan.lock.json`. See the [CLI diff --git a/docs/guides/phase-session-handoff.md b/docs/guides/phase-session-handoff.md index c5c42f97..cdafa553 100644 --- a/docs/guides/phase-session-handoff.md +++ b/docs/guides/phase-session-handoff.md @@ -18,6 +18,8 @@ agent-lifecycle plan snapshot \ agent-lifecycle plan handoff \ --manifest work/plans/release/plan.manifest.json \ --snapshot work/release/plan-snapshot.json \ + --lock work/plans/release/plan.lock.json \ + --phase-packet-out work/release/planning-phase-packet.json \ --max-workstreams 12 \ --target-tokens 4096 \ --out work/release/plan-handoff.json @@ -68,6 +70,20 @@ transition, such as `workflow task-start`, with expected state and source revision. The worker reads its task packet, writes only owned paths, captures a fresh `workflow task-snapshot`, and submits `workflow task-result`. +A new session can receive a separate bounded implementation packet without +changing the ordinary task-change-set output: + +```bash +agent-lifecycle workflow task-snapshot \ + --state work/release/run.state.json \ + --task WS-01 \ + --manifest work/plans/release/plan.manifest.json \ + --lock work/plans/release/plan.lock.json \ + --phase-packet-purpose IMPLEMENTATION \ + --phase-packet-out work/release/WS-01/implementation-phase-packet.json \ + --out work/release/WS-01/task-change-set.json +``` + ## Independent audit session Give the reviewer the frozen manifest and lock, task packet, fresh change set, @@ -75,6 +91,11 @@ task result and criterion-specific evidence. Do not give the reviewer the worker's hidden reasoning or ask it to infer acceptance from a summary. The reviewer returns a review with its own identity and run id. +After `task-result`, repeat `workflow task-snapshot` with +`--phase-packet-purpose TASK_AUDIT`. ALK projects the immutable change set +bound to the committed result. For a started retry use `REMEDIATION`; the +packet includes prior receipt digests, open finding IDs and remaining attempts. + `workflow task-review-apply`, `workflow task-accept` and `workflow task-rework` are authority-bearing transitions. An operator invokes them only with a valid, independent review bound to the current task attempt and source revision. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e5bb46db..1dee1963 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -28,7 +28,7 @@ Python 3.11-3.14 is supported. Install the exact release from the official [PyPI project](https://pypi.org/project/agent-lifecycle-kit/): ```bash - python -m pip install agent-lifecycle-kit==2.10.0 + python -m pip install agent-lifecycle-kit==2.11.0 ``` ## Task evidence identity @@ -328,7 +328,14 @@ language](project-domain-language.md). - `agent-lifecycle workflow task-start`: open a bounded task attempt. - `agent-lifecycle workflow task-snapshot`: compute the current task-scoped Git file set and content digests without changing workflow state. Put the - returned `claim` object in the task result before `task-result`. + returned `claim` object in the task result before `task-result`. Add the + all-or-none `--manifest`, `--lock`, `--phase-packet-purpose` and + `--phase-packet-out` options to emit a separate bounded + `agent-phase-packet.v1` for implementation, task audit or remediation. +- `agent-lifecycle workflow validation-select --state ... --task ... + --manifest ... --lock ... --snapshot ... [--out ...]`: select frozen check + IDs without executing commands or mutating state. Legacy and protected-path + cases conservatively select `RELEASE_FULL`. - `agent-lifecycle workflow task-result`: submit implementation evidence. - `agent-lifecycle workflow task-rework`: after an independent review or implementation audit returns `REWORK`, archive the current attempt and move @@ -353,7 +360,9 @@ language](project-domain-language.md). completion gate binding is required. Add `--final-implementation-audit ` when final implementation audit is mandatory, and `--review-mesh-quorum ` when an - opted-in plan requires final-audit quorum. + opted-in plan requires final-audit quorum. An opted-in validation-ladder plan + also requires `--release-full-receipt ` with exact current + plan, lock, source, tree and catalog lineage. - `workflow run`, `workflow task-result`, `workflow task-accept`, `workflow task-review-apply`, `workflow final-audit-outcome` and `workflow finalize` are the only workflow commands with managed progress diff --git a/docs/reference/phase-packets.md b/docs/reference/phase-packets.md new file mode 100644 index 00000000..3687cc3a --- /dev/null +++ b/docs/reference/phase-packets.md @@ -0,0 +1,65 @@ +# Phase packets + +Phase packets are bounded, purpose-specific projections for moving exact facts +between model sessions. They reduce repeated context loading without creating a +second workflow state or a second authority path. + +The envelope schema is `agent-phase-packet.v1`. Its purpose is one of: + +- `PLANNING_HANDOFF` for selected workstreams and dependency edges; +- `IMPLEMENTATION` for one task attempt, scope, criteria and evidence; +- `TASK_AUDIT` for the committed result, immutable change set and review facts; +- `REMEDIATION` for the prior result/review, open findings and retry budget. + +Every packet is bound to the current plan, lock, source revision and relevant +state revision. It also binds write scope, acceptance, evidence and active +blockers with canonical digests. The rendered envelope is limited to 64 KiB. +Required facts are never dropped to satisfy that limit. + +## Create packets + +Planning handoff keeps its existing output and writes the optional packet to a +separate path: + +```bash +agent-lifecycle plan handoff \ + --manifest \ + --snapshot \ + --lock \ + --phase-packet-out \ + --out +``` + +Task packets are additional outputs of the read-only task snapshot route: + +```bash +agent-lifecycle workflow task-snapshot \ + --state \ + --task \ + --manifest \ + --lock \ + --phase-packet-purpose IMPLEMENTATION \ + --phase-packet-out \ + --out +``` + +The four packet-specific task-snapshot options are all-or-none. Use +`TASK_AUDIT` after a result is committed and `REMEDIATION` for a started retry. +The ordinary handoff and task-change-set JSON remain unchanged when packet +options are omitted. + +## Safety boundary + +Payloads are closed and recursively reject transcripts, prompts, credentials, +cookies, secrets and authority-like fields. String values pass through the +existing path and secret redaction controls. Failures use stable codes: + +- `phase-packet-required-fact-missing`; +- `phase-packet-forbidden-content`; +- `phase-packet-context-limit-exceeded`. + +Every envelope has `implementationAuthorized: false`, `proofAuthority: none` +and `productionPromotionClaimed: false`. A packet cannot start work, accept a +task, freeze a plan, satisfy a review or promote a release. The current lock, +workflow state and normal authority-bearing transitions remain authoritative. + diff --git a/docs/reference/public-contracts.md b/docs/reference/public-contracts.md index 9048f3f7..68db9611 100644 --- a/docs/reference/public-contracts.md +++ b/docs/reference/public-contracts.md @@ -41,6 +41,15 @@ Stable schema ids: - `agent-goal-record.v1` - `agent-objective-snapshot.v1` - `agent-workflow-state.v4` +- `agent-phase-packet.v1` +- `agent-phase-planning-handoff-payload.v1` +- `agent-phase-implementation-payload.v1` +- `agent-phase-task-audit-payload.v1` +- `agent-phase-remediation-payload.v1` +- `agent-validation-check-catalog.v1` +- `agent-validation-ladder-profile.v1` +- `agent-validation-selection.v1` +- `agent-release-full-validation-receipt.v1` - `agent-workflow-continuation-action.v1` - `agent-workflow-continuation-receipt.v1` - `agent-workflow-continuation-input-bundle.v1` @@ -168,6 +177,14 @@ Plan completeness validation checks the selected SDD tier before audit, so small plans can stay compact while risky S2 work still has requirements, acceptance, evidence routes, ownership, budgets, context limits and final gates. +Phase packets are bounded projections for planning handoff, implementation, +task audit and remediation. They keep exact digest lineage and explicitly carry +no implementation, proof or promotion authority. The validation ladder is an +optional frozen mapping from changed paths to exact catalog check IDs. Its +selector is read-only and command-free; legacy, unmatched and protected-path +cases select `RELEASE_FULL`, and opted-in finalization requires a fresh full +validation receipt. + Planning launch profiles are exact-version, local and fail closed. Profile generation or a version probe does not establish `PLANNING_ONLY_QUALIFIED`; that claim requires accepted live evidence for the diff --git a/docs/reference/validation-ladder.md b/docs/reference/validation-ladder.md new file mode 100644 index 00000000..9b408788 --- /dev/null +++ b/docs/reference/validation-ladder.md @@ -0,0 +1,54 @@ +# Validation ladder + +The validation ladder selects the smallest allowed feedback set for a changed +file set while preserving a complete release gate. Selection is deterministic, +read-only and command-free: ALK returns check IDs but does not execute their +command strings. + +## Frozen inputs + +An opted-in plan declares both optional fields under `validation`: + +- `checkCatalog`: a closed `agent-validation-check-catalog.v1` object whose + records bind a stable check ID to the digest of one exact + `validation.commands` string; +- `validationLadderProfile`: a path and digest for a closed + `agent-validation-ladder-profile.v1` mapping literal repository prefixes to + `TASK_FAST`, `TASK_ACCEPTANCE` or `RELEASE_FULL` check IDs. + +The fields are all-or-none and are covered by the plan lock. Profiles contain +no commands. Glob paths, unknown check IDs, contradictory duplicate mappings, +unreadable or stale profile bytes, and stale plan/lock lineage block selection. + +## Select checks + +```bash +agent-lifecycle workflow validation-select \ + --state \ + --task \ + --manifest \ + --lock \ + --snapshot \ + --out +``` + +The `agent-validation-selection.v1` result contains the selected level and +check IDs with `commandsExecuted: false` and `stateWritten: false`. A host or +operator resolves the IDs against the frozen command catalog, runs those exact +commands, and records ordinary evidence. Selection never accepts a task. + +## Conservative floor + +Plans without the optional profile select `RELEASE_FULL`. A valid profile with +no matching mapping also selects `RELEASE_FULL`. Changes to protected release, +security, architecture, policy, contracts, documentation or publication paths +always select `RELEASE_FULL`; profile additions may widen but cannot reduce +that built-in floor. + +For an opted-in plan, finalization requires a fresh +`agent-release-full-validation-receipt.v1` through +`workflow finalize --release-full-receipt`. Its passed check IDs must exactly +equal the required full set and its plan, lock, source, current-tree and catalog +lineage must match. Focused receipts cannot substitute for it, and the existing +post-merge publication gates remain separate. + diff --git a/docs/ru/README.md b/docs/ru/README.md index 8cc7e159..109393b7 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -18,7 +18,7 @@ OpenInterpreter, Pi, Grok Build и других. Ядро не зависит о подтверждения, а основное время остаётся на исследование, реализацию и проверку продукта. -**Лицензия:** Apache-2.0 · **Версия:** 2.10.0 · Python 3.11-3.14 +**Лицензия:** Apache-2.0 · **Версия:** 2.11.0 · Python 3.11-3.14 Английская документация: [английская версия на GitHub](https://github.com/avksp/agent-lifecycle-kit/blob/main/README.md). @@ -28,7 +28,7 @@ OpenInterpreter, Pi, Grok Build и других. Ядро не зависит о Описание переносимого пакета навыков находится в разделе [переносимый пакет Agent Plugins](reference/agent-plugins.md), проверка установленного пакета - в разделе [проверка Agent Plugins в клиентах](reference/agent-plugin-qualification.md), а необязательный контроль жизненного цикла адаптера - в [отдельном справочнике](adapters/lifecycle-control.md). Сейчас комплектные адаптеры публикуют `GUIDANCE_ONLY` и `NO_RECOMMENDATION`, а управляемый запуск сохраняет статус `WRAPPER_ONLY`. -В нём есть варианты для macOS, Linux и Windows, установка из [PyPI](https://pypi.org/project/agent-lifecycle-kit/) командой `python -m pip install agent-lifecycle-kit==2.10.0` и разбор ошибок команды `agent-lifecycle version`. +В нём есть варианты для macOS, Linux и Windows, установка из [PyPI](https://pypi.org/project/agent-lifecycle-kit/) командой `python -m pip install agent-lifecycle-kit==2.11.0` и разбор ошибок команды `agent-lifecycle version`. Для быстрого старта выполните: @@ -57,7 +57,7 @@ agent-lifecycle start --adapter --file task.md - Границы нескольких агентов, настройка рабочих потоков, выбор модели, промпты, тайм-ауты и повторы собраны в разделе [настройка рабочего процесса и управления выполнением](reference/workflow-customization.md). - Локальный профиль проекта создаётся командой `agent-lifecycle project profile init`; его описание приведено в разделе [профиль рабочего процесса проекта](reference/project-workflow-profile.md). Готовые маршруты можно посмотреть и проверить через [профили рабочего процесса](reference/workflow-presets.md), а принципы и дельты плана — в [отдельном разделе](reference/project-principles-and-plan-deltas.md), [предметном языке проекта](reference/project-domain-language.md), [представлении внимания по нескольким запускам](reference/multi-run-attention-view.md) и [руководстве долгосрочного управления](guides/long-term-project-governance.md). - Выбранные значения, ограничения плана и линию происхождения capability можно проверить через [объяснение эффективной конфигурации](reference/effective-configuration.md) и [подтверждение действий адаптера](reference/adapter-action-evidence.md). Дополнительные подтверждения архитектуры и зависимостей описаны в разделе [внешние проверки проекта](reference/external-verification-checks.md), длительные специализированные процессы адаптера — в разделе [ограниченные задания внешних инструментов](reference/external-tool-jobs.md), а [профиль анализа безопасности](reference/security-analysis-profile.md) предназначен для ограниченных расследований только для чтения и независимой проверки доработок высокой серьёзности; импортированные находки остаются недоверенными. -- Для длинных сессий используйте [снимки контекста и восстановление после сжатия](reference/context-checkpoints.md). Они сохраняют ограниченный локальный пакет продолжения, не меняя полномочия плана. Измеряйте фазы и релизы через [учёт ресурсов релиза](reference/release-accounting.md), а между сессиями переходите по ограниченному [рецепту передачи фаз](guides/phase-session-handoff.md). +- Для длинных сессий используйте [снимки контекста и восстановление после сжатия](reference/context-checkpoints.md). Они сохраняют ограниченный локальный пакет продолжения, не меняя полномочия плана. Измеряйте фазы и релизы через [учёт ресурсов релиза](reference/release-accounting.md), следуйте [рецепту передачи фаз](guides/phase-session-handoff.md) с [пакетами фаз](reference/phase-packets.md), а фокусные проверки выбирайте с помощью read-only [лестницы проверок](reference/validation-ladder.md). ## Что даёт ALK - Процесс ориентирован на результат: план, выполнение, проверка и подтверждение diff --git a/docs/ru/guides/install-and-first-run.md b/docs/ru/guides/install-and-first-run.md index e2b4ab24..78cefb5b 100644 --- a/docs/ru/guides/install-and-first-run.md +++ b/docs/ru/guides/install-and-first-run.md @@ -78,7 +78,7 @@ Set-Location agent-lifecycle-kit python3 -m venv ~/.venvs/alk source ~/.venvs/alk/bin/activate python -m pip install --upgrade pip -python -m pip install agent-lifecycle-kit==2.10.0 +python -m pip install agent-lifecycle-kit==2.11.0 python -m agent_lifecycle version agent-lifecycle version ``` @@ -89,7 +89,7 @@ agent-lifecycle version py -m venv "$HOME\venvs\alk" & "$HOME\venvs\alk\Scripts\Activate.ps1" python -m pip install --upgrade pip -python -m pip install agent-lifecycle-kit==2.10.0 +python -m pip install agent-lifecycle-kit==2.11.0 python -m agent_lifecycle version agent-lifecycle version ``` @@ -187,7 +187,7 @@ agent-lifecycle start \ Codex: ```bash -codex plugin marketplace add avksp/agent-lifecycle-kit --ref v2.10.0 +codex plugin marketplace add avksp/agent-lifecycle-kit --ref v2.11.0 codex plugin add agent-lifecycle-kit@agent-lifecycle-kit codex plugin list ``` @@ -228,6 +228,10 @@ Agent Plugins в клиентах](../reference/agent-plugin-qualification.md). и ограниченный [рецепт передачи фаз между сессиями](phase-session-handoff.md). Отсутствующая телеметрия остаётся недоступной, а handoff-артефакты не заменяют полномочия workflow. +В релизе 2.11 появились [ограниченные пакеты фаз](../reference/phase-packets.md) +и read-only [лестница проверок](../reference/validation-ladder.md). Фокусный +выбор ускоряет обратную связь, а защищённые и релизные пути сохраняют полный +минимум проверок. Перед выполнением создайте финальный рассмотренный lock командой `agent-lifecycle plan lock-create --manifest <путь> --review <путь>`: она завершается отказом вместо замены существующего `plan.lock.json`. См. diff --git a/docs/ru/guides/phase-session-handoff.md b/docs/ru/guides/phase-session-handoff.md index 7dfc4b74..0231039a 100644 --- a/docs/ru/guides/phase-session-handoff.md +++ b/docs/ru/guides/phase-session-handoff.md @@ -18,6 +18,8 @@ agent-lifecycle plan snapshot \ agent-lifecycle plan handoff \ --manifest work/plans/release/plan.manifest.json \ --snapshot work/release/plan-snapshot.json \ + --lock work/plans/release/plan.lock.json \ + --phase-packet-out work/release/planning-phase-packet.json \ --max-workstreams 12 \ --target-tokens 4096 \ --out work/release/plan-handoff.json @@ -68,6 +70,20 @@ none`. Оператор всё равно обязан выполнить тек revision. Исполнитель читает свой пакет, меняет только принадлежащие ему пути, создаёт свежий `workflow task-snapshot` и отправляет `workflow task-result`. +Новая сессия может получить отдельный ограниченный пакет реализации без +изменения обычного task change set: + +```bash +agent-lifecycle workflow task-snapshot \ + --state work/release/run.state.json \ + --task WS-01 \ + --manifest work/plans/release/plan.manifest.json \ + --lock work/plans/release/plan.lock.json \ + --phase-packet-purpose IMPLEMENTATION \ + --phase-packet-out work/release/WS-01/implementation-phase-packet.json \ + --out work/release/WS-01/task-change-set.json +``` + ## Независимая сессия аудита Рецензенту нужны зафиксированные manifest и lock, task packet, свежий change @@ -75,6 +91,12 @@ set, task result и evidence конкретных критериев. Не пе рассуждения исполнителя и не просите вывести приёмку из текстового резюме. Review содержит отдельную идентичность рецензента и его run id. +После `task-result` повторите `workflow task-snapshot` с +`--phase-packet-purpose TASK_AUDIT`: ALK спроецирует неизменяемый change set, +связанный с сохранённым результатом. Для начатой повторной попытки используйте +`REMEDIATION`; пакет содержит digest прошлых receipts, ID открытых находок и +оставшееся число попыток. + `workflow task-review-apply`, `workflow task-accept` и `workflow task-rework` - переходы с полномочиями. Оператор вызывает их только с действительным независимым review, связанным с текущей попыткой и source revision. diff --git a/docs/ru/reference/cli.md b/docs/ru/reference/cli.md index 2f239193..ca73dc6f 100644 --- a/docs/ru/reference/cli.md +++ b/docs/ru/reference/cli.md @@ -27,7 +27,7 @@ ALK и первый запуск](../guides/install-and-first-run.md). Указ [проекта в PyPI](https://pypi.org/project/agent-lifecycle-kit/): ```bash -python -m pip install agent-lifecycle-kit==2.10.0 +python -m pip install agent-lifecycle-kit==2.11.0 ``` ## Идентичность подтверждений задачи @@ -342,6 +342,13 @@ agent-lifecycle project preset render \ - `agent-lifecycle workflow task-snapshot`: без изменения состояния вычисляет текущий набор файлов задачи и отпечатки их содержимого по Git. Объект `claim` из результата нужно поместить в результат задачи перед `task-result`. + Параметры `--manifest`, `--lock`, `--phase-packet-purpose` и + `--phase-packet-out` задаются только вместе и создают отдельный ограниченный + `agent-phase-packet.v1` для реализации, аудита задачи или доработки. +- `agent-lifecycle workflow validation-select --state ... --task ... + --manifest ... --lock ... --snapshot ... [--out ...]`: выбирает ID + зафиксированных проверок без запуска команд и изменения состояния. Для + legacy-плана и защищённых путей консервативно выбирается `RELEASE_FULL`. - `agent-lifecycle workflow task-result`: сохраняет результат реализации. - `agent-lifecycle workflow task-rework`: после решения `REWORK` независимой проверки сохраняет данные текущей попытки и переводит задачу в @@ -367,7 +374,9 @@ agent-lifecycle project preset render \ `--implementation-audit `, а `workflow finalize` принимает `--final-implementation-audit `. Для плана с обязательной групповой проверкой на финальном аудите - `workflow finalize` принимает `--review-mesh-quorum `. + `workflow finalize` принимает `--review-mesh-quorum `. План с + включённой лестницей проверок также требует `--release-full-receipt + ` с точным lineage plan, lock, source, дерева и каталога. - Управляемый вывод прогресса поддерживают только `workflow run`, `workflow task-result`, `workflow task-accept`, `workflow task-review-apply`, `workflow final-audit-outcome` и `workflow finalize`. diff --git a/docs/ru/reference/phase-packets.md b/docs/ru/reference/phase-packets.md new file mode 100644 index 00000000..57ed4d6a --- /dev/null +++ b/docs/ru/reference/phase-packets.md @@ -0,0 +1,64 @@ +# Пакеты фаз + +Пакеты фаз - ограниченные проекции фактов для конкретной цели, которые +передаются между сессиями моделей. Они уменьшают повторную загрузку контекста, +не создавая второе состояние workflow или второй путь полномочий. + +Схема конверта - `agent-phase-packet.v1`. Допустимы четыре назначения: + +- `PLANNING_HANDOFF` для выбранных потоков работ и их зависимостей; +- `IMPLEMENTATION` для одной попытки задачи, области, критериев и evidence; +- `TASK_AUDIT` для сохранённого результата, неизменяемого change set и review; +- `REMEDIATION` для прошлого result/review, открытых находок и бюджета повторов. + +Пакет связан с текущими plan, lock, source revision и соответствующей ревизией +состояния. Отдельные digest связывают область записи, критерии приёмки, +evidence и активные блокеры. Размер конверта ограничен 64 КиБ; обязательные +факты не удаляются ради соблюдения лимита. + +## Создание пакетов + +`plan handoff` сохраняет прежний результат, а необязательный пакет пишет в +отдельный файл: + +```bash +agent-lifecycle plan handoff \ + --manifest \ + --snapshot \ + --lock \ + --phase-packet-out \ + --out +``` + +Пакет задачи создаётся как дополнительный результат read-only снимка: + +```bash +agent-lifecycle workflow task-snapshot \ + --state \ + --task \ + --manifest \ + --lock \ + --phase-packet-purpose IMPLEMENTATION \ + --phase-packet-out \ + --out +``` + +Четыре параметра пакета у `task-snapshot` задаются только вместе. Используйте +`TASK_AUDIT` после сохранения результата и `REMEDIATION` для начатой повторной +попытки. Без новых параметров JSON прежних handoff и task change set не +меняется. + +## Граница безопасности + +Payload закрыт и рекурсивно запрещает переписки, промпты, учётные данные, +cookie, секреты и поля полномочий. Строки проходят существующую редакцию +секретов и локальных путей. Ошибки имеют стабильные коды: + +- `phase-packet-required-fact-missing`; +- `phase-packet-forbidden-content`; +- `phase-packet-context-limit-exceeded`. + +Каждый конверт содержит `implementationAuthorized: false`, `proofAuthority: none` +и `productionPromotionClaimed: false`. Пакет не запускает работу, не +принимает задачу, не фиксирует план, не заменяет review и не продвигает релиз. +Полномочия остаются у текущих lock, workflow state и обычных переходов ALK. diff --git a/docs/ru/reference/public-contracts.md b/docs/ru/reference/public-contracts.md index 54ac4c00..bcb0e310 100644 --- a/docs/ru/reference/public-contracts.md +++ b/docs/ru/reference/public-contracts.md @@ -12,6 +12,17 @@ - `agent-goal-record.v1`: запись цели. - `agent-objective-snapshot.v1`: компактный снимок цели. - `agent-workflow-state.v4`: текущее состояние workflow и его полномочия. +- `agent-phase-packet.v1`: ограниченная проекция фактов между фазами без + полномочий реализации, доказательства или публикации. +- `agent-phase-planning-handoff-payload.v1`: payload передачи планирования. +- `agent-phase-implementation-payload.v1`: payload одной попытки реализации. +- `agent-phase-task-audit-payload.v1`: payload независимого аудита задачи. +- `agent-phase-remediation-payload.v1`: payload ограниченной доработки. +- `agent-validation-check-catalog.v1`: digest-каталог точных команд проверки. +- `agent-validation-ladder-profile.v1`: профиль выбора уровня по буквальным + путям. +- `agent-validation-selection.v1`: read-only результат выбора ID проверок. +- `agent-release-full-validation-receipt.v1`: свежий полный релизный receipt. - `agent-workflow-continuation-action.v1`: вычисленный существующий переход с точными guard-значениями для явного применения. - `agent-workflow-continuation-receipt.v1`: результат read-only проекции или diff --git a/docs/ru/reference/validation-ladder.md b/docs/ru/reference/validation-ladder.md new file mode 100644 index 00000000..ff2610a2 --- /dev/null +++ b/docs/ru/reference/validation-ladder.md @@ -0,0 +1,52 @@ +# Лестница проверок + +Лестница проверок выбирает минимальный разрешённый набор быстрой обратной +связи для изменённых файлов, сохраняя полный релизный гейт. Выбор +детерминирован, работает только для чтения и не содержит команд: ALK возвращает +ID проверок, но не выполняет строки команд. + +## Зафиксированные входы + +План с включённой лестницей объявляет оба необязательных поля в `validation`: + +- `checkCatalog`: закрытый объект `agent-validation-check-catalog.v1`, где + стабильный ID связан с digest одной точной строки `validation.commands`; +- `validationLadderProfile`: путь и digest закрытого + `agent-validation-ladder-profile.v1`, который связывает буквальные префиксы + репозитория с ID уровней `TASK_FAST`, `TASK_ACCEPTANCE` или `RELEASE_FULL`. + +Поля задаются только вместе и входят в plan lock. Профиль не содержит команд. +Glob-пути, неизвестные ID, противоречивые дубликаты, нечитаемые или устаревшие +байты профиля и несовпадающий lineage plan/lock блокируют выбор. + +## Выбор проверок + +```bash +agent-lifecycle workflow validation-select \ + --state \ + --task \ + --manifest \ + --lock \ + --snapshot \ + --out +``` + +Результат `agent-validation-selection.v1` содержит уровень и ID проверок, а +также `commandsExecuted: false` и `stateWritten: false`. Хост или оператор +сопоставляет ID с зафиксированным каталогом, выполняет точные команды и +сохраняет обычное evidence. Сам selector не принимает задачу. + +## Консервативный минимум + +План без необязательного профиля выбирает `RELEASE_FULL`. Валидный профиль без +совпадения также выбирает `RELEASE_FULL`. Изменения защищённых путей релиза, +безопасности, архитектуры, политик, контрактов, документации и публикации +всегда требуют `RELEASE_FULL`; дополнения профиля могут только расширять этот +набор. + +Для плана с включённой лестницей финализация требует свежий +`agent-release-full-validation-receipt.v1`, переданный через `workflow finalize +--release-full-receipt`. Набор пройденных ID должен точно совпадать с полным +обязательным набором, а lineage plan, lock, source, текущего дерева и каталога +должен совпадать. Фокусные receipts его не заменяют, а существующие post-merge +publication gates остаются отдельными. diff --git a/pyproject.toml b/pyproject.toml index e0bfb888..12a32158 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agent-lifecycle-kit" -version = "2.10.0" +version = "2.11.0" description = "Provider-neutral lifecycle control layer that helps coding agents plan, execute, validate, and prove software tasks to completion." readme = "README.md" requires-python = ">=3.11,<3.15" diff --git a/src/agent_lifecycle/_version.py b/src/agent_lifecycle/_version.py index 4f897471..5b0591e8 100644 --- a/src/agent_lifecycle/_version.py +++ b/src/agent_lifecycle/_version.py @@ -1,3 +1,3 @@ """Package version.""" -__version__ = "2.10.0" +__version__ = "2.11.0" diff --git a/src/agent_lifecycle/cli/dispatch_lifecycle.py b/src/agent_lifecycle/cli/dispatch_lifecycle.py index 2aa9700c..5291a437 100644 --- a/src/agent_lifecycle/cli/dispatch_lifecycle.py +++ b/src/agent_lifecycle/cli/dispatch_lifecycle.py @@ -21,7 +21,15 @@ maybe_emit_workflow_progress_hook, validate_workflow_progress_hook_request, ) -from agent_lifecycle.contracts import LifecycleError, read_json_object, write_json_create +from agent_lifecycle.compiler import build_phase_packet +from agent_lifecycle.contracts import LifecycleError, canonical_digest, read_json_object, write_json_create +from agent_lifecycle.contracts.phase_packet_schemas import ( + IMPLEMENTATION_PAYLOAD_SCHEMA, + REMEDIATION_PAYLOAD_SCHEMA, + TASK_AUDIT_PAYLOAD_SCHEMA, +) +from agent_lifecycle.freeze import verify_plan_lock +from agent_lifecycle.quality import build_validation_selection from agent_lifecycle.workflow import ( accept_task, adopt_plan, @@ -92,6 +100,8 @@ def _dispatch_workflow(args: argparse.Namespace) -> dict[str, Any]: return status(state_path, full=args.full) if args.workflow_command == "next": return next_action(status(state_path, full=True)["state"]) + if args.workflow_command == "validation-select": + return _dispatch_validation_select(args, state_path) if args.workflow_command == "run": validate_workflow_progress_hook_request(args, command="workflow run") payload = run_workflow_step( @@ -184,6 +194,7 @@ def _dispatch_workflow(args: argparse.Namespace) -> dict[str, Any]: follow_up_register_path=args.follow_up_register, completion_gate_receipt_path=args.completion_gate_receipt, final_implementation_audit_path=args.final_implementation_audit, + release_full_receipt_path=args.release_full_receipt, review_mesh_quorum_paths=args.review_mesh_quorum, reason=args.reason, ) @@ -194,9 +205,36 @@ def _dispatch_workflow(args: argparse.Namespace) -> dict[str, Any]: def _dispatch_workflow_task(args: argparse.Namespace, state_path: Path) -> dict[str, Any]: if args.workflow_command == "task-snapshot": - payload = build_current_task_change_set(state_path, task_id=args.task) + packet_args = (args.manifest, args.lock, args.phase_packet_purpose, args.phase_packet_out) + packet_requested = any(value is not None for value in packet_args) + if packet_requested and not all(value is not None for value in packet_args): + raise LifecycleError( + "phase-packet-required-fact-missing", + "task snapshot phase packet requires --manifest, --lock, --phase-packet-purpose and --phase-packet-out", + ) + state = read_json_object(state_path, label="workflow state") if packet_requested else None + task = _state_task(state, args.task) if isinstance(state, dict) else None + if args.phase_packet_purpose == "TASK_AUDIT" and isinstance(task, dict) and task.get("status") == "VERIFYING": + assert state is not None + payload = _result_bound_task_change_set(state, task) + else: + payload = build_current_task_change_set(state_path, task_id=args.task) if args.out: write_json_create(Path(args.out), payload) + if packet_requested: + assert state is not None + manifest = read_json_object(Path(args.manifest), label="plan manifest") + lock = read_json_object(Path(args.lock), label="plan lock") + verify_plan_lock(manifest, lock) + packet = _build_task_phase_packet( + manifest=manifest, + lock=lock, + state=state, + task_id=args.task, + purpose=args.phase_packet_purpose, + snapshot=payload, + ) + write_json_create(Path(args.phase_packet_out), packet) return payload if args.workflow_command == "block": return block_run( @@ -323,6 +361,244 @@ def _dispatch_workflow_task(args: argparse.Namespace, state_path: Path) -> dict[ raise LifecycleError("command-not-implemented", "workflow command is not implemented") +def _result_bound_task_change_set(state: dict[str, Any], task: dict[str, Any]) -> dict[str, Any]: + evidence = task.get("resultChangeSetEvidence") + if not isinstance(evidence, dict): + raise LifecycleError("phase-packet-required-fact-missing", "task audit result change-set evidence is missing") + required = ("provider", "baselineSha", "fileSetHash", "diffHash", "snapshotHash") + if any(not isinstance(evidence.get(field), str) or not evidence[field] for field in required): + raise LifecycleError( + "phase-packet-required-fact-missing", "task audit result change-set evidence is incomplete" + ) + return { + **evidence, + "runId": state.get("runId"), + "packageId": state.get("packageId"), + "taskId": task.get("id"), + "attempt": task.get("attempt"), + "planDigest": state.get("planDigest"), + "sourceRevision": state.get("sourceRevision"), + "readOnly": True, + "stateWritten": False, + "modelCallsStarted": False, + "productionPromotionClaimed": False, + "claim": { + "schemaVersion": "agent-task-change-set-claim.v1", + **{key: evidence[key] for key in required}, + }, + } + + +def _dispatch_validation_select(args: argparse.Namespace, state_path: Path) -> dict[str, Any]: + state = read_json_object(state_path, label="workflow state") + task = _state_task(state, args.task) + snapshot = read_json_object(Path(args.snapshot), label="task snapshot") + if snapshot.get("taskId") != task.get("id"): + raise LifecycleError("task-snapshot-lineage-mismatch", "task snapshot does not match the requested task") + payload = build_validation_selection( + manifest=read_json_object(Path(args.manifest), label="plan manifest"), + lock=read_json_object(Path(args.lock), label="plan lock"), + state=state, + snapshot=snapshot, + repository_root=Path.cwd(), + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + + +def _build_task_phase_packet( + *, + manifest: dict[str, Any], + lock: dict[str, Any], + state: dict[str, Any], + task_id: str, + purpose: str, + snapshot: dict[str, Any], +) -> dict[str, Any]: + task = _state_task(state, task_id) + workstream = _manifest_workstream(manifest, task_id) + plan_digest = canonical_digest(manifest) + if state.get("planDigest") != plan_digest: + raise LifecycleError("phase-packet-required-fact-missing", "workflow state plan lineage is stale") + attempt = task.get("attempt") + if not isinstance(attempt, int) or isinstance(attempt, bool) or attempt < 1: + raise LifecycleError("phase-packet-required-fact-missing", "task attempt is required") + writes = _task_scope(manifest, workstream, task, "writes") + read_only = _task_scope(manifest, workstream, task, "readOnly") + forbidden = _task_scope(manifest, workstream, task, "forbiddenWrites") + acceptance = _task_acceptance(manifest, workstream) + evidence = _task_evidence(workstream) + active_blockers = _task_active_blockers(state, task) + payload = _task_phase_payload( + purpose=purpose, + manifest=manifest, + task=task, + task_id=task_id, + attempt=attempt, + snapshot=snapshot, + writes=writes, + read_only=read_only, + forbidden=forbidden, + acceptance=acceptance, + evidence=evidence, + active_blockers=active_blockers, + ) + state_revision = state.get("stateRevision") + source_revision = state.get("sourceRevision") + if not isinstance(state_revision, int) or isinstance(state_revision, bool) or state_revision < 1: + raise LifecycleError("phase-packet-required-fact-missing", "workflow state revision is required") + if not isinstance(source_revision, str) or not source_revision: + raise LifecycleError("phase-packet-required-fact-missing", "workflow source revision is required") + return build_phase_packet( + purpose=purpose, + payload=payload, + plan_digest=plan_digest, + plan_lock_digest=canonical_digest(lock), + state_revision=state_revision, + source_revision=source_revision, + write_scope_digest=canonical_digest({"writes": writes, "readOnly": read_only, "forbiddenWrites": forbidden}), + acceptance_digest=canonical_digest(acceptance), + evidence_digest=canonical_digest(evidence), + active_blocker_ids=active_blockers, + ) + + +def _task_phase_payload( + *, + purpose: str, + manifest: dict[str, Any], + task: dict[str, Any], + task_id: str, + attempt: int, + snapshot: dict[str, Any], + writes: list[str], + read_only: list[str], + forbidden: list[str], + acceptance: list[dict[str, Any]], + evidence: list[dict[str, Any]], + active_blockers: list[str], +) -> dict[str, Any]: + common = { + "taskId": task_id, + "attempt": attempt, + "writes": writes, + "readOnly": read_only, + "forbiddenWrites": forbidden, + "acceptanceCriteria": acceptance, + "activeBlockerIds": active_blockers, + } + if purpose == "IMPLEMENTATION": + packet = task.get("packet") + task_packet_digest = packet.get("sha256") if isinstance(packet, dict) else None + return { + "schemaVersion": IMPLEMENTATION_PAYLOAD_SCHEMA, + **common, + "taskPacketDigest": task_packet_digest, + "evidenceRequirements": evidence, + } + changed_paths = _task_strings(snapshot.get("changedFiles")) + if purpose == "TASK_AUDIT": + result = task.get("result") + result_digest = result.get("sha256") if isinstance(result, dict) else None + return { + "schemaVersion": TASK_AUDIT_PAYLOAD_SCHEMA, + **common, + "resultDigest": result_digest, + "changeSetDigest": snapshot.get("snapshotHash"), + "changedPaths": changed_paths, + "reviewRequirements": { + "independentRequired": True, + "minimumVerdict": "ACCEPTED", + "requiredReviewerIds": _required_reviewer_ids(task), + }, + "evidenceReferences": [item["id"] for item in evidence], + } + if purpose != "REMEDIATION": + raise LifecycleError("phase-packet-required-fact-missing", "phase packet purpose is unsupported") + history = task.get("attemptHistory") + prior = history[-1] if isinstance(history, list) and history and isinstance(history[-1], dict) else None + prior_result = prior.get("result") if isinstance(prior, dict) else None + prior_review = prior.get("review") if isinstance(prior, dict) else None + return { + "schemaVersion": REMEDIATION_PAYLOAD_SCHEMA, + **common, + "priorResultDigest": prior_result.get("sha256") if isinstance(prior_result, dict) else None, + "priorReviewDigest": prior_review.get("sha256") if isinstance(prior_review, dict) else None, + "changedPaths": changed_paths, + "openFindingIds": _task_strings(task.get("remediationFindingIds")), + "remainingAttempts": max(1, _max_task_attempts(manifest) - attempt + 1), + "evidenceRequirements": evidence, + } + + +def _state_task(state: dict[str, Any], task_id: str) -> dict[str, Any]: + tasks = state.get("tasks") + if isinstance(tasks, list): + for task in tasks: + if isinstance(task, dict) and task.get("id") == task_id: + return task + raise LifecycleError("task-not-found", "workflow task does not exist", {"taskId": task_id}) + + +def _manifest_workstream(manifest: dict[str, Any], task_id: str) -> dict[str, Any]: + workstreams = manifest.get("workstreams") + if isinstance(workstreams, list): + for workstream in workstreams: + if isinstance(workstream, dict) and workstream.get("id") == task_id: + return workstream + raise LifecycleError("phase-packet-required-fact-missing", "manifest workstream is missing", {"taskId": task_id}) + + +def _task_acceptance(manifest: dict[str, Any], workstream: dict[str, Any]) -> list[dict[str, Any]]: + acceptance = manifest.get("acceptance") + criteria = acceptance.get("criteria") if isinstance(acceptance, dict) else [] + criteria = criteria if isinstance(criteria, list) else [] + by_id = {item.get("id"): item for item in criteria if isinstance(item, dict) and isinstance(item.get("id"), str)} + return [dict(by_id.get(item, {"id": item})) for item in _task_strings(workstream.get("acceptanceIds"))] + + +def _task_evidence(workstream: dict[str, Any]) -> list[dict[str, Any]]: + return [{"id": item, "required": True} for item in _task_strings(workstream.get("evidenceIds"))] + + +def _task_scope(manifest: dict[str, Any], workstream: dict[str, Any], task: dict[str, Any], field: str) -> list[str]: + return sorted( + set(_task_strings(manifest.get(field)) + _task_strings(workstream.get(field)) + _task_strings(task.get(field))) + ) + + +def _task_active_blockers(state: dict[str, Any], task: dict[str, Any]) -> list[str]: + blockers = _task_strings(task.get("remediationFindingIds")) + blocker = state.get("blocker") + if isinstance(blocker, dict): + value = blocker.get("id") or blocker.get("code") + if isinstance(value, str) and value: + blockers.append(value) + return sorted(set(blockers)) + + +def _required_reviewer_ids(task: dict[str, Any]) -> list[str]: + reviewer = task.get("reviewer") + if isinstance(reviewer, str) and reviewer: + return [reviewer] + if isinstance(reviewer, dict) and isinstance(reviewer.get("id"), str) and reviewer["id"]: + return [reviewer["id"]] + return [] + + +def _max_task_attempts(manifest: dict[str, Any]) -> int: + orchestration = manifest.get("orchestration") + value = orchestration.get("maxTaskAttempts") if isinstance(orchestration, dict) else None + return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else 1 + + +def _task_strings(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return sorted({item for item in value if isinstance(item, str) and item}) + + def _require_args(args: argparse.Namespace, names: list[str], *, mode: str) -> None: missing = [name.replace("_", "-") for name in names if not getattr(args, name, None)] if missing: diff --git a/src/agent_lifecycle/cli/dispatch_planning.py b/src/agent_lifecycle/cli/dispatch_planning.py index 12842a94..02515c52 100644 --- a/src/agent_lifecycle/cli/dispatch_planning.py +++ b/src/agent_lifecycle/cli/dispatch_planning.py @@ -7,9 +7,10 @@ from typing import Any from agent_lifecycle.cli.plan_lock_commands import create_reviewed_plan_lock -from agent_lifecycle.compiler import compile_small_model_packets, compile_task_packets +from agent_lifecycle.compiler import build_phase_packet, compile_small_model_packets, compile_task_packets from agent_lifecycle.contracts import ( LifecycleError, + canonical_digest, read_json_object, write_json_create, ) @@ -18,6 +19,7 @@ validate_finding_check_evidence, validate_finding_check_proposal, ) +from agent_lifecycle.contracts.phase_packet_schemas import PLANNING_HANDOFF_PAYLOAD_SCHEMA from agent_lifecycle.freeze import verify_plan_lock from agent_lifecycle.planning import ( build_plan_delta, @@ -181,6 +183,25 @@ def _dispatch_plan(args: argparse.Namespace) -> dict[str, Any]: ) if args.out: write_json_create(Path(args.out), payload) + if args.phase_packet_out: + if not args.snapshot or not args.lock: + raise LifecycleError( + "phase-packet-required-fact-missing", + "plan handoff phase packet requires --snapshot and --lock", + ) + lock = read_json_object(Path(args.lock), label="plan lock") + verify_plan_lock(manifest, lock) + packet = _build_planning_handoff_phase_packet( + manifest, + lock, + handoff_snapshot, + selected_workstream_ids=[ + item["id"] + for item in payload.get("workstreams", []) + if isinstance(item, dict) and isinstance(item.get("id"), str) + ], + ) + write_json_create(Path(args.phase_packet_out), packet) return payload if args.plan_command == "delta": @@ -215,6 +236,103 @@ def read(value: str | None, label: str) -> dict[str, Any] | None: raise LifecycleError("command-not-implemented", "plan command is not implemented") +def _build_planning_handoff_phase_packet( + manifest: dict[str, Any], + lock: dict[str, Any], + snapshot: dict[str, Any] | None, + *, + selected_workstream_ids: list[str], +) -> dict[str, Any]: + if not isinstance(snapshot, dict): + raise LifecycleError("phase-packet-required-fact-missing", "plan handoff snapshot is required") + all_workstreams = [item for item in manifest.get("workstreams", []) if isinstance(item, dict)] + by_id = {item.get("id"): item for item in all_workstreams if isinstance(item.get("id"), str)} + selected: list[dict[str, Any]] = [] + for workstream_id in selected_workstream_ids: + workstream = by_id.get(workstream_id) + if not isinstance(workstream, dict): + raise LifecycleError( + "phase-packet-required-fact-missing", + "plan handoff selected an unknown workstream", + {"workstreamId": workstream_id}, + ) + acceptance = _phase_acceptance(manifest, workstream) + evidence = _phase_evidence(workstream) + selected.append( + { + "id": workstream_id, + "dependsOn": _string_list(workstream.get("dependsOn")), + "writes": _string_list(workstream.get("writes")), + "readOnly": _scope_list(manifest, workstream, "readOnly"), + "forbiddenWrites": _scope_list(manifest, workstream, "forbiddenWrites"), + "acceptanceCriteria": acceptance, + "evidenceRequirements": evidence, + "activeBlockerIds": _string_list(workstream.get("activeBlockerIds")), + } + ) + payload = { + "schemaVersion": PLANNING_HANDOFF_PAYLOAD_SCHEMA, + "workstreams": selected, + "dependencyEdges": [ + {"from": dependency, "to": item["id"]} for item in selected for dependency in item["dependsOn"] + ], + } + base_revision = manifest.get("baseRevision") + source_revision = None + if isinstance(base_revision, dict): + source_revision = base_revision.get("sha") or base_revision.get("ref") + if not isinstance(source_revision, str) or not source_revision: + source_revision = snapshot.get("sourceDigest") + if not isinstance(source_revision, str) or not source_revision: + raise LifecycleError("phase-packet-required-fact-missing", "plan handoff source revision is required") + acceptance = [criterion for item in selected for criterion in item["acceptanceCriteria"]] + evidence = [requirement for item in selected for requirement in item["evidenceRequirements"]] + blockers = [blocker for item in selected for blocker in item["activeBlockerIds"]] + scopes = [ + { + "id": item["id"], + "writes": item["writes"], + "readOnly": item["readOnly"], + "forbiddenWrites": item["forbiddenWrites"], + } + for item in selected + ] + return build_phase_packet( + purpose="PLANNING_HANDOFF", + payload=payload, + plan_digest=canonical_digest(manifest), + plan_lock_digest=canonical_digest(lock), + state_revision=None, + source_revision=source_revision, + write_scope_digest=canonical_digest(scopes), + acceptance_digest=canonical_digest(acceptance), + evidence_digest=canonical_digest(evidence), + active_blocker_ids=blockers, + ) + + +def _phase_acceptance(manifest: dict[str, Any], workstream: dict[str, Any]) -> list[dict[str, Any]]: + acceptance = manifest.get("acceptance") + criteria = acceptance.get("criteria") if isinstance(acceptance, dict) else [] + criteria = criteria if isinstance(criteria, list) else [] + by_id = {item.get("id"): item for item in criteria if isinstance(item, dict) and isinstance(item.get("id"), str)} + return [dict(by_id.get(item, {"id": item})) for item in _string_list(workstream.get("acceptanceIds"))] + + +def _phase_evidence(workstream: dict[str, Any]) -> list[dict[str, Any]]: + return [{"id": item, "required": True} for item in _string_list(workstream.get("evidenceIds"))] + + +def _scope_list(manifest: dict[str, Any], workstream: dict[str, Any], field: str) -> list[str]: + return sorted(set(_string_list(manifest.get(field)) + _string_list(workstream.get(field)))) + + +def _string_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return sorted({item for item in value if isinstance(item, str) and item}) + + def _dispatch_finding_check(args: argparse.Namespace) -> dict[str, Any]: def read(path: str, label: str) -> dict[str, Any]: return read_json_object(Path(path), label=label) diff --git a/src/agent_lifecycle/cli/lifecycle_parsers.py b/src/agent_lifecycle/cli/lifecycle_parsers.py index 7d90b507..b6503b46 100644 --- a/src/agent_lifecycle/cli/lifecycle_parsers.py +++ b/src/agent_lifecycle/cli/lifecycle_parsers.py @@ -680,6 +680,20 @@ def _add_workflow_task_parsers( workflow_snapshot.add_argument("--state", required=True) workflow_snapshot.add_argument("--task", required=True) workflow_snapshot.add_argument("--out") + workflow_snapshot.add_argument("--manifest") + workflow_snapshot.add_argument("--lock") + workflow_snapshot.add_argument( + "--phase-packet-purpose", + choices=["IMPLEMENTATION", "TASK_AUDIT", "REMEDIATION"], + ) + workflow_snapshot.add_argument("--phase-packet-out") + validation_select = workflow_sub.add_parser("validation-select") + validation_select.add_argument("--state", required=True) + validation_select.add_argument("--task", required=True) + validation_select.add_argument("--manifest", required=True) + validation_select.add_argument("--lock", required=True) + validation_select.add_argument("--snapshot", required=True) + validation_select.add_argument("--out") workflow_budget = workflow_sub.add_parser("budget-decision") workflow_budget.add_argument("--state", required=True) workflow_budget.add_argument("--task", required=True) @@ -764,6 +778,7 @@ def _add_workflow_review_parsers( workflow_finalize.add_argument("--follow-up-register") workflow_finalize.add_argument("--completion-gate-receipt") workflow_finalize.add_argument("--final-implementation-audit") + workflow_finalize.add_argument("--release-full-receipt") workflow_finalize.add_argument("--review-mesh-quorum", action="append", default=[]) workflow_finalize.add_argument("--reason", required=True) add_progress_hook_args(workflow_finalize) diff --git a/src/agent_lifecycle/cli/planning_parsers.py b/src/agent_lifecycle/cli/planning_parsers.py index 76de2953..ea7a497d 100644 --- a/src/agent_lifecycle/cli/planning_parsers.py +++ b/src/agent_lifecycle/cli/planning_parsers.py @@ -51,6 +51,8 @@ def _add_plan_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentPar handoff = plan_sub.add_parser("handoff") handoff.add_argument("--manifest", required=True) handoff.add_argument("--snapshot") + handoff.add_argument("--lock") + handoff.add_argument("--phase-packet-out") handoff.add_argument("--max-workstreams", type=int, default=12) handoff.add_argument("--target-tokens", type=int, default=4096) handoff.add_argument("--out") diff --git a/src/agent_lifecycle/compiler/__init__.py b/src/agent_lifecycle/compiler/__init__.py index e8a925a3..2c49f4f6 100644 --- a/src/agent_lifecycle/compiler/__init__.py +++ b/src/agent_lifecycle/compiler/__init__.py @@ -1,6 +1,7 @@ """Frozen plan to task packet compiler.""" from agent_lifecycle.compiler.output_contract import build_output_contract, validate_output_contract +from agent_lifecycle.compiler.phase_packets import build_phase_packet, validate_phase_packet from agent_lifecycle.compiler.small_model_packets import ( build_small_model_output_contract, build_small_model_packet, @@ -11,10 +12,12 @@ __all__ = [ "build_output_contract", + "build_phase_packet", "build_small_model_output_contract", "build_small_model_packet", "compile_small_model_packets", "compile_task_packets", "validate_output_contract", + "validate_phase_packet", "validate_small_model_output", ] diff --git a/src/agent_lifecycle/compiler/phase_packets.py b/src/agent_lifecycle/compiler/phase_packets.py new file mode 100644 index 00000000..6e375f51 --- /dev/null +++ b/src/agent_lifecycle/compiler/phase_packets.py @@ -0,0 +1,474 @@ +"""Build and validate bounded, non-authoritative cross-phase packets.""" + +from __future__ import annotations + +import re +from copy import deepcopy +from typing import Any, NoReturn + +from agent_lifecycle.contracts import LifecycleError, canonical_bytes, canonical_digest +from agent_lifecycle.contracts.phase_packet_schemas import ( + IMPLEMENTATION_PAYLOAD_SCHEMA, + PHASE_PACKET_SCHEMA, + PLANNING_HANDOFF_PAYLOAD_SCHEMA, + REMEDIATION_PAYLOAD_SCHEMA, + TASK_AUDIT_PAYLOAD_SCHEMA, +) +from agent_lifecycle.contracts.redaction import redact_value + +MAX_PHASE_PACKET_BYTES = 64 * 1024 + +FORBIDDEN_PAYLOAD_KEYS = frozenset( + { + "rawTranscript", + "transcript", + "conversation", + "message", + "messages", + "chatHistory", + "systemPrompt", + "developerPrompt", + "userPrompt", + "prompt", + "secret", + "secrets", + "apiKey", + "apiToken", + "accessToken", + "refreshToken", + "password", + "credential", + "credentials", + "authorization", + "cookie", + "promptAuthority", + "toolAuthority", + "freezeAuthority", + "acceptanceAuthority", + "implementationAuthority", + "implementationAuthorized", + "proofAuthority", + "productionPromotionClaimed", + } +) + +_DIGEST = re.compile(r"^[0-9a-f]{64}$") +_PURPOSE_SCHEMAS = { + "PLANNING_HANDOFF": PLANNING_HANDOFF_PAYLOAD_SCHEMA, + "IMPLEMENTATION": IMPLEMENTATION_PAYLOAD_SCHEMA, + "TASK_AUDIT": TASK_AUDIT_PAYLOAD_SCHEMA, + "REMEDIATION": REMEDIATION_PAYLOAD_SCHEMA, +} + +_ACCEPTANCE_KEYS = { + "id", + "requirementIds", + "evidenceIds", + "independentEvidenceIds", + "independence", + "statement", + "description", + "source", + "priority", +} +_EVIDENCE_KEYS = {"id", "description", "source", "validation", "artifactPath", "required"} +_WORKSTREAM_KEYS = { + "id", + "dependsOn", + "writes", + "readOnly", + "forbiddenWrites", + "acceptanceCriteria", + "evidenceRequirements", + "activeBlockerIds", +} + + +def build_phase_packet( + *, + purpose: str, + payload: dict[str, Any], + plan_digest: str, + plan_lock_digest: str, + state_revision: int | None, + source_revision: str, + write_scope_digest: str, + acceptance_digest: str, + evidence_digest: str, + active_blocker_ids: list[str], + max_context_bytes: int = MAX_PHASE_PACKET_BYTES, +) -> dict[str, Any]: + """Build one closed packet, redacting unsafe string values before digesting.""" + + _require_context_bound(payload, max_context_bytes, "phase packet payload") + _reject_forbidden_keys(payload) + redacted_payload, _changed = redact_value(deepcopy(payload)) + if not isinstance(redacted_payload, dict): + _missing("payload must be an object") + body = { + "schemaVersion": PHASE_PACKET_SCHEMA, + "purpose": purpose, + "planDigest": plan_digest, + "planLockDigest": plan_lock_digest, + "stateRevision": state_revision, + "sourceRevision": source_revision, + "writeScopeDigest": write_scope_digest, + "acceptanceDigest": acceptance_digest, + "evidenceDigest": evidence_digest, + "activeBlockerIds": _canonical_strings(active_blocker_ids, "activeBlockerIds"), + "payload": _normalize_payload(purpose, redacted_payload), + "implementationAuthorized": False, + "proofAuthority": "none", + "productionPromotionClaimed": False, + } + packet = {**body, "packetDigest": canonical_digest(body)} + validate_phase_packet(packet, max_context_bytes=max_context_bytes) + return packet + + +def validate_phase_packet( + packet: dict[str, Any], + *, + max_context_bytes: int = MAX_PHASE_PACKET_BYTES, +) -> dict[str, Any]: + """Validate exact shape, safety, lineage and digest of a phase packet.""" + + _require_context_bound(packet, max_context_bytes, "phase packet") + expected_keys = { + "schemaVersion", + "purpose", + "planDigest", + "planLockDigest", + "stateRevision", + "sourceRevision", + "writeScopeDigest", + "acceptanceDigest", + "evidenceDigest", + "activeBlockerIds", + "payload", + "implementationAuthorized", + "proofAuthority", + "productionPromotionClaimed", + "packetDigest", + } + _closed(packet, expected_keys, expected_keys, "phase packet") + if packet.get("schemaVersion") != PHASE_PACKET_SCHEMA: + _missing("phase packet schemaVersion is invalid") + purpose = packet.get("purpose") + if purpose not in _PURPOSE_SCHEMAS: + _missing("phase packet purpose is invalid") + for field in ( + "planDigest", + "planLockDigest", + "writeScopeDigest", + "acceptanceDigest", + "evidenceDigest", + "packetDigest", + ): + _require_digest(packet.get(field), field) + state_revision = packet.get("stateRevision") + if purpose == "PLANNING_HANDOFF": + if state_revision is not None: + _missing("planning handoff stateRevision must be null") + elif not isinstance(state_revision, int) or isinstance(state_revision, bool) or state_revision < 1: + _missing("phase packet stateRevision must be positive") + if not isinstance(packet.get("sourceRevision"), str) or not packet["sourceRevision"]: + _missing("phase packet sourceRevision is required") + if packet.get("implementationAuthorized") is not False: + _forbidden("phase packet cannot authorize implementation") + if packet.get("proofAuthority") != "none": + _forbidden("phase packet cannot claim proof authority") + if packet.get("productionPromotionClaimed") is not False: + _forbidden("phase packet cannot claim production promotion") + blockers = _canonical_strings(packet.get("activeBlockerIds"), "activeBlockerIds") + if blockers != packet["activeBlockerIds"]: + _missing("phase packet activeBlockerIds must be canonical") + payload = packet.get("payload") + if not isinstance(payload, dict): + _missing("phase packet payload must be an object") + _reject_forbidden_keys(payload) + redacted, changed = redact_value(payload) + if changed or redacted != payload: + _forbidden("phase packet payload contains unredacted sensitive content") + normalized = _normalize_payload(purpose, payload) + if normalized != payload: + _missing("phase packet payload lists must be canonical") + if payload.get("activeBlockerIds") is not None and payload.get("activeBlockerIds") != blockers: + _missing("phase packet blocker lineage does not match its payload") + body = {key: value for key, value in packet.items() if key != "packetDigest"} + if packet["packetDigest"] != canonical_digest(body): + _missing("phase packet digest mismatch") + return packet + + +def _normalize_payload(purpose: str, payload: dict[str, Any]) -> dict[str, Any]: + schema = _PURPOSE_SCHEMAS.get(purpose) + if schema is None or payload.get("schemaVersion") != schema: + _missing("phase packet payload schema does not match purpose") + if purpose == "PLANNING_HANDOFF": + _closed( + payload, + {"schemaVersion", "workstreams", "dependencyEdges"}, + {"schemaVersion", "workstreams", "dependencyEdges"}, + "planning handoff payload", + ) + workstreams = _objects(payload.get("workstreams"), "workstreams") + edges = _objects(payload.get("dependencyEdges"), "dependencyEdges") + normalized_workstreams = [_normalize_workstream(item) for item in workstreams] + normalized_edges = [] + for edge in edges: + _closed(edge, {"from", "to"}, {"from", "to"}, "dependency edge") + normalized_edges.append( + { + "from": _string(edge.get("from"), "dependency edge from"), + "to": _string(edge.get("to"), "dependency edge to"), + } + ) + return { + "schemaVersion": schema, + "workstreams": sorted(normalized_workstreams, key=lambda item: item["id"]), + "dependencyEdges": sorted(normalized_edges, key=lambda item: (item["from"], item["to"])), + } + if purpose == "IMPLEMENTATION": + keys = { + "schemaVersion", + "taskId", + "attempt", + "taskPacketDigest", + "writes", + "readOnly", + "forbiddenWrites", + "acceptanceCriteria", + "evidenceRequirements", + "activeBlockerIds", + } + _closed(payload, keys, keys, "implementation payload") + return { + "schemaVersion": schema, + "taskId": _string(payload.get("taskId"), "taskId"), + "attempt": _positive_int(payload.get("attempt"), "attempt"), + "taskPacketDigest": _digest(payload.get("taskPacketDigest"), "taskPacketDigest"), + "writes": _canonical_strings(payload.get("writes"), "writes"), + "readOnly": _canonical_strings(payload.get("readOnly"), "readOnly"), + "forbiddenWrites": _canonical_strings(payload.get("forbiddenWrites"), "forbiddenWrites"), + "acceptanceCriteria": _normalize_acceptance(payload.get("acceptanceCriteria")), + "evidenceRequirements": _normalize_evidence(payload.get("evidenceRequirements")), + "activeBlockerIds": _canonical_strings(payload.get("activeBlockerIds"), "activeBlockerIds"), + } + if purpose == "TASK_AUDIT": + keys = { + "schemaVersion", + "taskId", + "attempt", + "resultDigest", + "changeSetDigest", + "changedPaths", + "writes", + "readOnly", + "forbiddenWrites", + "reviewRequirements", + "acceptanceCriteria", + "evidenceReferences", + "activeBlockerIds", + } + _closed(payload, keys, keys, "task audit payload") + review = payload.get("reviewRequirements") + if not isinstance(review, dict): + _missing("reviewRequirements must be an object") + review_keys = {"independentRequired", "minimumVerdict", "requiredReviewerIds"} + _closed(review, review_keys, review_keys, "review requirements") + independent = review.get("independentRequired") + if not isinstance(independent, bool): + _missing("independentRequired must be boolean") + return { + "schemaVersion": schema, + "taskId": _string(payload.get("taskId"), "taskId"), + "attempt": _positive_int(payload.get("attempt"), "attempt"), + "resultDigest": _digest(payload.get("resultDigest"), "resultDigest"), + "changeSetDigest": _digest(payload.get("changeSetDigest"), "changeSetDigest"), + "changedPaths": _canonical_strings(payload.get("changedPaths"), "changedPaths"), + "writes": _canonical_strings(payload.get("writes"), "writes"), + "readOnly": _canonical_strings(payload.get("readOnly"), "readOnly"), + "forbiddenWrites": _canonical_strings(payload.get("forbiddenWrites"), "forbiddenWrites"), + "reviewRequirements": { + "independentRequired": independent, + "minimumVerdict": _string(review.get("minimumVerdict"), "minimumVerdict"), + "requiredReviewerIds": _canonical_strings(review.get("requiredReviewerIds"), "requiredReviewerIds"), + }, + "acceptanceCriteria": _normalize_acceptance(payload.get("acceptanceCriteria")), + "evidenceReferences": _canonical_strings(payload.get("evidenceReferences"), "evidenceReferences"), + "activeBlockerIds": _canonical_strings(payload.get("activeBlockerIds"), "activeBlockerIds"), + } + keys = { + "schemaVersion", + "taskId", + "attempt", + "priorResultDigest", + "priorReviewDigest", + "changedPaths", + "openFindingIds", + "remainingAttempts", + "writes", + "readOnly", + "forbiddenWrites", + "acceptanceCriteria", + "evidenceRequirements", + "activeBlockerIds", + } + _closed(payload, keys, keys, "remediation payload") + return { + "schemaVersion": schema, + "taskId": _string(payload.get("taskId"), "taskId"), + "attempt": _positive_int(payload.get("attempt"), "attempt"), + "priorResultDigest": _digest(payload.get("priorResultDigest"), "priorResultDigest"), + "priorReviewDigest": _digest(payload.get("priorReviewDigest"), "priorReviewDigest"), + "changedPaths": _canonical_strings(payload.get("changedPaths"), "changedPaths"), + "openFindingIds": _canonical_strings(payload.get("openFindingIds"), "openFindingIds"), + "remainingAttempts": _positive_int(payload.get("remainingAttempts"), "remainingAttempts"), + "writes": _canonical_strings(payload.get("writes"), "writes"), + "readOnly": _canonical_strings(payload.get("readOnly"), "readOnly"), + "forbiddenWrites": _canonical_strings(payload.get("forbiddenWrites"), "forbiddenWrites"), + "acceptanceCriteria": _normalize_acceptance(payload.get("acceptanceCriteria")), + "evidenceRequirements": _normalize_evidence(payload.get("evidenceRequirements")), + "activeBlockerIds": _canonical_strings(payload.get("activeBlockerIds"), "activeBlockerIds"), + } + + +def _normalize_workstream(value: dict[str, Any]) -> dict[str, Any]: + _closed(value, _WORKSTREAM_KEYS, _WORKSTREAM_KEYS, "workstream") + return { + "id": _string(value.get("id"), "workstream id"), + "dependsOn": _canonical_strings(value.get("dependsOn"), "dependsOn"), + "writes": _canonical_strings(value.get("writes"), "writes"), + "readOnly": _canonical_strings(value.get("readOnly"), "readOnly"), + "forbiddenWrites": _canonical_strings(value.get("forbiddenWrites"), "forbiddenWrites"), + "acceptanceCriteria": _normalize_acceptance(value.get("acceptanceCriteria")), + "evidenceRequirements": _normalize_evidence(value.get("evidenceRequirements")), + "activeBlockerIds": _canonical_strings(value.get("activeBlockerIds"), "activeBlockerIds"), + } + + +def _normalize_acceptance(value: Any) -> list[dict[str, Any]]: + items = _objects(value, "acceptanceCriteria") + normalized = [] + for item in items: + _closed(item, {"id"}, _ACCEPTANCE_KEYS, "acceptance criterion") + result: dict[str, Any] = {"id": _string(item.get("id"), "acceptance criterion id")} + for key in ("requirementIds", "evidenceIds", "independentEvidenceIds"): + if key in item: + result[key] = _canonical_strings(item[key], key) + for key in ("independence", "statement", "description", "source", "priority"): + if key in item: + if key in {"statement", "description"} and not isinstance(item[key], str): + _missing(f"acceptance criterion {key} must be a string") + result[key] = deepcopy(item[key]) + normalized.append(result) + return sorted(normalized, key=lambda item: item["id"]) + + +def _normalize_evidence(value: Any) -> list[dict[str, Any]]: + items = _objects(value, "evidenceRequirements") + normalized = [] + for item in items: + _closed(item, {"id"}, _EVIDENCE_KEYS, "evidence requirement") + result: dict[str, Any] = {"id": _string(item.get("id"), "evidence requirement id")} + for key in ("description", "source", "validation", "artifactPath", "required"): + if key in item: + if key in {"description", "artifactPath"} and not isinstance(item[key], str): + _missing(f"evidence requirement {key} must be a string") + if key == "required" and not isinstance(item[key], bool): + _missing("evidence requirement required must be boolean") + result[key] = deepcopy(item[key]) + normalized.append(result) + return sorted(normalized, key=lambda item: item["id"]) + + +def _reject_forbidden_keys(value: Any, path: tuple[str, ...] = ()) -> None: + if isinstance(value, dict): + for key, item in value.items(): + if not isinstance(key, str): + _forbidden("phase packet payload keys must be strings") + if key in FORBIDDEN_PAYLOAD_KEYS: + _forbidden("phase packet payload contains a forbidden key", path="/".join((*path, key))) + _reject_forbidden_keys(item, (*path, key)) + elif isinstance(value, list): + for index, item in enumerate(value): + _reject_forbidden_keys(item, (*path, str(index))) + + +def _require_context_bound(value: Any, max_context_bytes: int, label: str) -> None: + if ( + not isinstance(max_context_bytes, int) + or isinstance(max_context_bytes, bool) + or not 1 <= max_context_bytes <= MAX_PHASE_PACKET_BYTES + ): + _limit("phase packet context limit is invalid") + try: + byte_count = len(canonical_bytes(value)) + except (LifecycleError, TypeError): + _missing(f"{label} must be JSON-compatible") + if byte_count > max_context_bytes: + _limit(f"{label} exceeds its rendered context limit") + + +def _closed(value: Any, required: set[str], allowed: set[str], label: str) -> None: + if not isinstance(value, dict): + _missing(f"{label} must be an object") + missing = sorted(required - set(value)) + extra = sorted(set(value) - allowed) + if missing or extra: + _missing(f"{label} has missing or unsupported fields", missing=missing, extra=extra) + + +def _objects(value: Any, label: str) -> list[dict[str, Any]]: + if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): + _missing(f"{label} must be an object list") + return value + + +def _canonical_strings(value: Any, label: str) -> list[str]: + if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): + _missing(f"{label} must be a string list") + return sorted(set(value)) + + +def _string(value: Any, label: str) -> str: + if not isinstance(value, str) or not value: + _missing(f"{label} is required") + return value + + +def _positive_int(value: Any, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + _missing(f"{label} must be a positive integer") + return value + + +def _require_digest(value: Any, label: str) -> None: + if not isinstance(value, str) or not _DIGEST.fullmatch(value): + _missing(f"{label} must be a canonical digest") + + +def _digest(value: Any, label: str) -> str: + _require_digest(value, label) + return value + + +def _missing(message: str, **details: Any) -> NoReturn: + raise LifecycleError("phase-packet-required-fact-missing", message, details) + + +def _forbidden(message: str, **details: Any) -> NoReturn: + raise LifecycleError("phase-packet-forbidden-content", message, details) + + +def _limit(message: str) -> NoReturn: + raise LifecycleError("phase-packet-context-limit-exceeded", message) + + +__all__ = [ + "FORBIDDEN_PAYLOAD_KEYS", + "MAX_PHASE_PACKET_BYTES", + "build_phase_packet", + "validate_phase_packet", +] diff --git a/src/agent_lifecycle/contracts/phase_packet_schemas.py b/src/agent_lifecycle/contracts/phase_packet_schemas.py new file mode 100644 index 00000000..9fe3149e --- /dev/null +++ b/src/agent_lifecycle/contracts/phase_packet_schemas.py @@ -0,0 +1,264 @@ +"""Closed schemas for bounded cross-phase context packets.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +PHASE_PACKET_SCHEMA = "agent-phase-packet.v1" +PLANNING_HANDOFF_PAYLOAD_SCHEMA = "agent-phase-planning-handoff-payload.v1" +IMPLEMENTATION_PAYLOAD_SCHEMA = "agent-phase-implementation-payload.v1" +TASK_AUDIT_PAYLOAD_SCHEMA = "agent-phase-task-audit-payload.v1" +REMEDIATION_PAYLOAD_SCHEMA = "agent-phase-remediation-payload.v1" + +_DIGEST = {"type": "string", "pattern": "^[0-9a-f]{64}$"} +_STRING = {"type": "string", "minLength": 1} +_STRING_LIST = { + "type": "array", + "items": _STRING, + "uniqueItems": True, +} + + +def _closed_object(*, required: list[str], properties: dict[str, Any]) -> dict[str, Any]: + return { + "type": "object", + "additionalProperties": False, + "required": required, + "properties": properties, + } + + +def _closed_schema(schema_id: str, *, required: list[str], properties: dict[str, Any]) -> dict[str, Any]: + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": schema_id, + **_closed_object( + required=["schemaVersion", *required], + properties={"schemaVersion": {"const": schema_id}, **properties}, + ), + } + + +_ACCEPTANCE = _closed_object( + required=["id"], + properties={ + "id": _STRING, + "requirementIds": _STRING_LIST, + "evidenceIds": _STRING_LIST, + "independentEvidenceIds": _STRING_LIST, + "independence": {}, + "statement": {"type": "string"}, + "description": {"type": "string"}, + "source": {}, + "priority": {}, + }, +) + +_EVIDENCE = _closed_object( + required=["id"], + properties={ + "id": _STRING, + "description": {"type": "string"}, + "source": {}, + "validation": {}, + "artifactPath": {"type": "string"}, + "required": {"type": "boolean"}, + }, +) + +_WORKSTREAM = _closed_object( + required=[ + "id", + "dependsOn", + "writes", + "readOnly", + "forbiddenWrites", + "acceptanceCriteria", + "evidenceRequirements", + "activeBlockerIds", + ], + properties={ + "id": _STRING, + "dependsOn": _STRING_LIST, + "writes": _STRING_LIST, + "readOnly": _STRING_LIST, + "forbiddenWrites": _STRING_LIST, + "acceptanceCriteria": {"type": "array", "items": _ACCEPTANCE}, + "evidenceRequirements": {"type": "array", "items": _EVIDENCE}, + "activeBlockerIds": _STRING_LIST, + }, +) + +_DEPENDENCY_EDGE = _closed_object( + required=["from", "to"], + properties={"from": _STRING, "to": _STRING}, +) + +_REVIEW_REQUIREMENTS = _closed_object( + required=["independentRequired", "minimumVerdict", "requiredReviewerIds"], + properties={ + "independentRequired": {"type": "boolean"}, + "minimumVerdict": _STRING, + "requiredReviewerIds": _STRING_LIST, + }, +) + +_PLANNING_HANDOFF = _closed_schema( + PLANNING_HANDOFF_PAYLOAD_SCHEMA, + required=["workstreams", "dependencyEdges"], + properties={ + "workstreams": {"type": "array", "items": _WORKSTREAM}, + "dependencyEdges": {"type": "array", "items": _DEPENDENCY_EDGE}, + }, +) + +_IMPLEMENTATION = _closed_schema( + IMPLEMENTATION_PAYLOAD_SCHEMA, + required=[ + "taskId", + "attempt", + "taskPacketDigest", + "writes", + "readOnly", + "forbiddenWrites", + "acceptanceCriteria", + "evidenceRequirements", + "activeBlockerIds", + ], + properties={ + "taskId": _STRING, + "attempt": {"type": "integer", "minimum": 1}, + "taskPacketDigest": _DIGEST, + "writes": _STRING_LIST, + "readOnly": _STRING_LIST, + "forbiddenWrites": _STRING_LIST, + "acceptanceCriteria": {"type": "array", "items": _ACCEPTANCE}, + "evidenceRequirements": {"type": "array", "items": _EVIDENCE}, + "activeBlockerIds": _STRING_LIST, + }, +) + +_TASK_AUDIT = _closed_schema( + TASK_AUDIT_PAYLOAD_SCHEMA, + required=[ + "taskId", + "attempt", + "resultDigest", + "changeSetDigest", + "changedPaths", + "writes", + "readOnly", + "forbiddenWrites", + "reviewRequirements", + "acceptanceCriteria", + "evidenceReferences", + "activeBlockerIds", + ], + properties={ + "taskId": _STRING, + "attempt": {"type": "integer", "minimum": 1}, + "resultDigest": _DIGEST, + "changeSetDigest": _DIGEST, + "changedPaths": _STRING_LIST, + "writes": _STRING_LIST, + "readOnly": _STRING_LIST, + "forbiddenWrites": _STRING_LIST, + "reviewRequirements": _REVIEW_REQUIREMENTS, + "acceptanceCriteria": {"type": "array", "items": _ACCEPTANCE}, + "evidenceReferences": _STRING_LIST, + "activeBlockerIds": _STRING_LIST, + }, +) + +_REMEDIATION = _closed_schema( + REMEDIATION_PAYLOAD_SCHEMA, + required=[ + "taskId", + "attempt", + "priorResultDigest", + "priorReviewDigest", + "changedPaths", + "openFindingIds", + "remainingAttempts", + "writes", + "readOnly", + "forbiddenWrites", + "acceptanceCriteria", + "evidenceRequirements", + "activeBlockerIds", + ], + properties={ + "taskId": _STRING, + "attempt": {"type": "integer", "minimum": 1}, + "priorResultDigest": _DIGEST, + "priorReviewDigest": _DIGEST, + "changedPaths": _STRING_LIST, + "openFindingIds": _STRING_LIST, + "remainingAttempts": {"type": "integer", "minimum": 1}, + "writes": _STRING_LIST, + "readOnly": _STRING_LIST, + "forbiddenWrites": _STRING_LIST, + "acceptanceCriteria": {"type": "array", "items": _ACCEPTANCE}, + "evidenceRequirements": {"type": "array", "items": _EVIDENCE}, + "activeBlockerIds": _STRING_LIST, + }, +) + +PHASE_PACKET_SCHEMAS: dict[str, dict[str, Any]] = { + PLANNING_HANDOFF_PAYLOAD_SCHEMA: _PLANNING_HANDOFF, + IMPLEMENTATION_PAYLOAD_SCHEMA: _IMPLEMENTATION, + TASK_AUDIT_PAYLOAD_SCHEMA: _TASK_AUDIT, + REMEDIATION_PAYLOAD_SCHEMA: _REMEDIATION, + PHASE_PACKET_SCHEMA: _closed_schema( + PHASE_PACKET_SCHEMA, + required=[ + "purpose", + "planDigest", + "planLockDigest", + "stateRevision", + "sourceRevision", + "writeScopeDigest", + "acceptanceDigest", + "evidenceDigest", + "activeBlockerIds", + "payload", + "implementationAuthorized", + "proofAuthority", + "productionPromotionClaimed", + "packetDigest", + ], + properties={ + "purpose": {"enum": ["PLANNING_HANDOFF", "IMPLEMENTATION", "TASK_AUDIT", "REMEDIATION"]}, + "planDigest": _DIGEST, + "planLockDigest": _DIGEST, + "stateRevision": {"type": ["integer", "null"], "minimum": 1}, + "sourceRevision": _STRING, + "writeScopeDigest": _DIGEST, + "acceptanceDigest": _DIGEST, + "evidenceDigest": _DIGEST, + "activeBlockerIds": _STRING_LIST, + "payload": { + "oneOf": [ + deepcopy(_PLANNING_HANDOFF), + deepcopy(_IMPLEMENTATION), + deepcopy(_TASK_AUDIT), + deepcopy(_REMEDIATION), + ] + }, + "implementationAuthorized": {"const": False}, + "proofAuthority": {"const": "none"}, + "productionPromotionClaimed": {"const": False}, + "packetDigest": _DIGEST, + }, + ), +} + +__all__ = [ + "IMPLEMENTATION_PAYLOAD_SCHEMA", + "PHASE_PACKET_SCHEMA", + "PHASE_PACKET_SCHEMAS", + "PLANNING_HANDOFF_PAYLOAD_SCHEMA", + "REMEDIATION_PAYLOAD_SCHEMA", + "TASK_AUDIT_PAYLOAD_SCHEMA", +] diff --git a/src/agent_lifecycle/contracts/schemas.py b/src/agent_lifecycle/contracts/schemas.py index cbd8ce18..fc20772e 100644 --- a/src/agent_lifecycle/contracts/schemas.py +++ b/src/agent_lifecycle/contracts/schemas.py @@ -51,6 +51,7 @@ ) from agent_lifecycle.contracts.metric_schemas import METRIC_SCHEMAS from agent_lifecycle.contracts.multi_run_view_schemas import MULTI_RUN_VIEW_SCHEMAS +from agent_lifecycle.contracts.phase_packet_schemas import PHASE_PACKET_SCHEMAS from agent_lifecycle.contracts.plan_contract_schemas import PLAN_CONTRACT_SCHEMAS from agent_lifecycle.contracts.plan_delta_schemas import PLAN_DELTA_SCHEMAS from agent_lifecycle.contracts.plan_manifest_schemas import PLAN_MANIFEST_SCHEMAS @@ -84,6 +85,7 @@ from agent_lifecycle.contracts.task_template_schemas import TASK_TEMPLATE_SCHEMAS from agent_lifecycle.contracts.thread_bridge_schemas import THREAD_BRIDGE_SCHEMAS from agent_lifecycle.contracts.usage_export_schemas import USAGE_EXPORT_SCHEMAS +from agent_lifecycle.contracts.validation_ladder_schemas import VALIDATION_LADDER_SCHEMAS from agent_lifecycle.contracts.workflow_artifact_schemas import WORKFLOW_ARTIFACT_SCHEMAS from agent_lifecycle.contracts.workflow_authorization_schemas import WORKFLOW_AUTHORIZATION_SCHEMAS from agent_lifecycle.contracts.workflow_continuation_batch_schemas import WORKFLOW_CONTINUATION_BATCH_SCHEMAS @@ -740,6 +742,8 @@ def _strings(*fields: str, max_length: int = 128) -> dict[str, dict[str, Any]]: WORKFLOW_CONTINUATION_SCHEMAS, WORKFLOW_ECONOMICS_SCHEMAS, WORKFLOW_STATE_SCHEMAS, + PHASE_PACKET_SCHEMAS, + VALIDATION_LADDER_SCHEMAS, BUG_FORENSICS_SCHEMAS, SECURITY_ANALYSIS_SCHEMAS, CROSS_CHECK_SCHEMAS, diff --git a/src/agent_lifecycle/contracts/validation_ladder_schemas.py b/src/agent_lifecycle/contracts/validation_ladder_schemas.py new file mode 100644 index 00000000..2069ae35 --- /dev/null +++ b/src/agent_lifecycle/contracts/validation_ladder_schemas.py @@ -0,0 +1,154 @@ +"""Closed schemas for validation selection and release-full evidence.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +VALIDATION_CHECK_CATALOG_SCHEMA = "agent-validation-check-catalog.v1" +VALIDATION_LADDER_PROFILE_SCHEMA = "agent-validation-ladder-profile.v1" +VALIDATION_SELECTION_SCHEMA = "agent-validation-selection.v1" +RELEASE_FULL_VALIDATION_RECEIPT_SCHEMA = "agent-release-full-validation-receipt.v1" + +_DIGEST = {"type": "string", "pattern": "^[0-9a-f]{64}$"} +_STRING = {"type": "string", "minLength": 1} +_STRING_LIST = {"type": "array", "items": _STRING, "uniqueItems": True} +_DIGEST_LIST = {"type": "array", "items": deepcopy(_DIGEST), "uniqueItems": True} + + +def _closed_object(*, required: list[str], properties: dict[str, Any]) -> dict[str, Any]: + return { + "type": "object", + "additionalProperties": False, + "required": required, + "properties": properties, + } + + +def _closed_schema(schema_id: str, *, required: list[str], properties: dict[str, Any]) -> dict[str, Any]: + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": schema_id, + **_closed_object( + required=["schemaVersion", *required], + properties={"schemaVersion": {"const": schema_id}, **properties}, + ), + } + + +_CHECK = _closed_object( + required=["id", "commandDigest"], + properties={"id": _STRING, "commandDigest": _DIGEST}, +) + +_MAPPING = _closed_object( + required=["id", "pathPrefix", "level", "checkIds"], + properties={ + "id": _STRING, + "pathPrefix": _STRING, + "level": {"enum": ["TASK_FAST", "TASK_ACCEPTANCE", "RELEASE_FULL"]}, + "checkIds": _STRING_LIST, + }, +) + +VALIDATION_LADDER_SCHEMAS: dict[str, dict[str, Any]] = { + VALIDATION_CHECK_CATALOG_SCHEMA: _closed_schema( + VALIDATION_CHECK_CATALOG_SCHEMA, + required=["checks", "catalogDigest"], + properties={ + "checks": {"type": "array", "items": _CHECK}, + "catalogDigest": _DIGEST, + }, + ), + VALIDATION_LADDER_PROFILE_SCHEMA: _closed_schema( + VALIDATION_LADDER_PROFILE_SCHEMA, + required=["mappings", "additionalProtectedPathPrefixes", "profileDigest"], + properties={ + "mappings": {"type": "array", "items": _MAPPING}, + "additionalProtectedPathPrefixes": _STRING_LIST, + "profileDigest": _DIGEST, + }, + ), + VALIDATION_SELECTION_SCHEMA: _closed_schema( + VALIDATION_SELECTION_SCHEMA, + required=[ + "status", + "disposition", + "level", + "selectedCheckIds", + "matchedMappingIds", + "reasons", + "planDigest", + "planLockDigest", + "stateRevision", + "sourceRevision", + "currentTreeDigest", + "profileDigest", + "catalogDigest", + "commandsExecuted", + "stateWritten", + "blockers", + "selectionDigest", + ], + properties={ + "status": {"enum": ["PASS", "FAIL"]}, + "disposition": {"enum": ["SELECTED", "BLOCKED"]}, + "level": {"enum": ["TASK_FAST", "TASK_ACCEPTANCE", "RELEASE_FULL", None]}, + "selectedCheckIds": _STRING_LIST, + "matchedMappingIds": _STRING_LIST, + "reasons": _STRING_LIST, + "planDigest": _DIGEST, + "planLockDigest": _DIGEST, + "stateRevision": {"type": "integer", "minimum": 1}, + "sourceRevision": _STRING, + "currentTreeDigest": _DIGEST, + "profileDigest": {"oneOf": [_DIGEST, {"type": "null"}]}, + "catalogDigest": _DIGEST, + "commandsExecuted": {"const": False}, + "stateWritten": {"const": False}, + "blockers": {"type": "array", "items": {"type": "object"}}, + "selectionDigest": _DIGEST, + }, + ), + RELEASE_FULL_VALIDATION_RECEIPT_SCHEMA: _closed_schema( + RELEASE_FULL_VALIDATION_RECEIPT_SCHEMA, + required=[ + "status", + "sourceRevision", + "currentTreeDigest", + "planDigest", + "planLockDigest", + "catalogDigest", + "requiredCheckIds", + "passedCheckIds", + "gateEvidenceDigests", + "completedAt", + "blockers", + "productionPromotionClaimed", + "receiptDigest", + ], + properties={ + "status": {"const": "PASS"}, + "sourceRevision": _STRING, + "currentTreeDigest": _DIGEST, + "planDigest": _DIGEST, + "planLockDigest": _DIGEST, + "catalogDigest": _DIGEST, + "requiredCheckIds": _STRING_LIST, + "passedCheckIds": _STRING_LIST, + "gateEvidenceDigests": _DIGEST_LIST, + "completedAt": _STRING, + "blockers": {"type": "array", "maxItems": 0}, + "productionPromotionClaimed": {"const": False}, + "receiptDigest": _DIGEST, + }, + ), +} + +__all__ = [ + "RELEASE_FULL_VALIDATION_RECEIPT_SCHEMA", + "VALIDATION_CHECK_CATALOG_SCHEMA", + "VALIDATION_LADDER_PROFILE_SCHEMA", + "VALIDATION_LADDER_SCHEMAS", + "VALIDATION_SELECTION_SCHEMA", +] diff --git a/src/agent_lifecycle/planning/completeness.py b/src/agent_lifecycle/planning/completeness.py index c163fa72..aea22f42 100644 --- a/src/agent_lifecycle/planning/completeness.py +++ b/src/agent_lifecycle/planning/completeness.py @@ -11,6 +11,7 @@ from agent_lifecycle.contracts.ownership_paths import authority_paths_overlap, normalize_authority_path from agent_lifecycle.contracts.statistical_evidence_schemas import validate_statistical_evidence_requirement from agent_lifecycle.planning.traceability import validate_plan_traceability +from agent_lifecycle.quality.validation_ladder import validation_ladder_manifest_blockers PROFILE_SCHEMA = "agent-plan-completeness-profile.v1" VALIDATION_SCHEMA = "agent-plan-completeness-validation.v1" @@ -133,6 +134,9 @@ def validate_plan_completeness( _check_traceability(manifest, tier, blockers) _check_path_authority(manifest, tier, blockers) required_checks = [*required_checks, "traceability", "path-authority"] + if _validation_ladder_enabled(manifest): + blockers.extend(validation_ladder_manifest_blockers(manifest)) + required_checks = [*required_checks, "validation-ladder-authority"] body = { "schemaVersion": VALIDATION_SCHEMA, "status": "PASS" if not blockers else "FAIL", @@ -297,6 +301,11 @@ def _canonical_authority_enabled(manifest: dict[str, Any]) -> bool: return isinstance(manifest.get("packageIntegrity"), dict) +def _validation_ladder_enabled(manifest: dict[str, Any]) -> bool: + validation = manifest.get("validation") + return isinstance(validation, dict) and ("checkCatalog" in validation or "validationLadderProfile" in validation) + + def _check_traceability(manifest: dict[str, Any], _tier: str, blockers: list[dict[str, Any]]) -> None: validation = validate_plan_traceability(manifest) blockers.extend(validation.get("blockers", [])) diff --git a/src/agent_lifecycle/planning/manifest_contract.py b/src/agent_lifecycle/planning/manifest_contract.py index cfb4ca27..ae755ce2 100644 --- a/src/agent_lifecycle/planning/manifest_contract.py +++ b/src/agent_lifecycle/planning/manifest_contract.py @@ -118,7 +118,10 @@ "priority", } _EVIDENCE = {"id", "description", "source", "validation", "artifactPath", "required"} -_VALIDATION = {"commands", "extraEvidence"} +_VALIDATION = {"commands", "extraEvidence", "checkCatalog", "validationLadderProfile"} +_VALIDATION_CHECK_CATALOG = {"schemaVersion", "checks", "catalogDigest"} +_VALIDATION_CHECK = {"id", "commandDigest"} +_VALIDATION_LADDER_REFERENCE = {"path", "digest"} _ORCHESTRATION = { "startMode", "remediationMode", @@ -231,6 +234,7 @@ def _validate_nested(manifest: dict[str, Any], blockers: list[dict[str, Any]]) - for index, criterion in enumerate(criteria): _object_keys(criterion, _CRITERION, f"acceptanceCriteria[{index}]", blockers) _object_keys(manifest.get("validation"), _VALIDATION, "validation", blockers) + _validate_validation_ladder_contract(manifest.get("validation"), blockers) orchestration = manifest.get("orchestration") _object_keys(orchestration, _ORCHESTRATION, "orchestration", blockers) _validate_remediation_policy(orchestration, blockers) @@ -276,11 +280,7 @@ def _validate_remediation_policy(value: Any, blockers: list[dict[str, Any]]) -> mode = value.get("remediationMode", "off") attempts = value.get("maxTaskAttempts", 1) review_rounds = value.get("maxPlanReviewRounds", 1) - if ( - not isinstance(review_rounds, int) - or isinstance(review_rounds, bool) - or not 1 <= review_rounds <= 10 - ): + if not isinstance(review_rounds, int) or isinstance(review_rounds, bool) or not 1 <= review_rounds <= 10: blockers.append( _blocker( "plan-review-round-budget-invalid", @@ -323,6 +323,53 @@ def _object_keys(value: Any, allowed: set[str], path: str, blockers: list[dict[s _unknown_keys(value, allowed, path, blockers) +def _validate_validation_ladder_contract(value: Any, blockers: list[dict[str, Any]]) -> None: + if not isinstance(value, dict): + return + catalog = value.get("checkCatalog") + reference = value.get("validationLadderProfile") + if catalog is not None: + _object_keys(catalog, _VALIDATION_CHECK_CATALOG, "validation.checkCatalog", blockers) + if isinstance(catalog, dict): + if catalog.get("schemaVersion") != "agent-validation-check-catalog.v1": + blockers.append( + _blocker("plan-validation-catalog-schema-invalid", "validation catalog schema is invalid") + ) + if not _is_digest(catalog.get("catalogDigest")): + blockers.append( + _blocker("plan-validation-catalog-digest-invalid", "validation catalog digest is invalid") + ) + checks = catalog.get("checks") + if not isinstance(checks, list) or not checks: + blockers.append( + _blocker("plan-validation-catalog-checks-invalid", "validation catalog checks are required") + ) + else: + for index, check in enumerate(checks): + _object_keys(check, _VALIDATION_CHECK, f"validation.checkCatalog.checks[{index}]", blockers) + if not isinstance(check, dict) or not isinstance(check.get("id"), str) or not check["id"]: + blockers.append(_blocker("plan-validation-check-id-invalid", "validation check id is required")) + elif not _is_digest(check.get("commandDigest")): + blockers.append( + _blocker( + "plan-validation-check-digest-invalid", "validation check commandDigest is invalid" + ) + ) + if reference is not None: + _object_keys(reference, _VALIDATION_LADDER_REFERENCE, "validation.validationLadderProfile", blockers) + if isinstance(reference, dict): + if not isinstance(reference.get("path"), str) or not reference["path"]: + blockers.append(_blocker("plan-validation-profile-path-invalid", "validation profile path is required")) + if not _is_digest(reference.get("digest")): + blockers.append( + _blocker("plan-validation-profile-digest-invalid", "validation profile digest is invalid") + ) + + +def _is_digest(value: Any) -> bool: + return isinstance(value, str) and len(value) == 64 and all(character in "0123456789abcdef" for character in value) + + def _unknown_keys(value: dict[str, Any], allowed: set[str], path: str, blockers: list[dict[str, Any]]) -> None: for key in sorted(set(value).difference(allowed)): code = "plan-manifest-authority-field-unknown" if key in _AUTHORITY_MARKERS else "plan-manifest-field-unknown" diff --git a/src/agent_lifecycle/quality/__init__.py b/src/agent_lifecycle/quality/__init__.py index 6b75e999..d0d27555 100644 --- a/src/agent_lifecycle/quality/__init__.py +++ b/src/agent_lifecycle/quality/__init__.py @@ -65,11 +65,25 @@ validate_security_finding, validate_security_verification_assignment, ) +from agent_lifecycle.quality.validation_ladder import ( + BUILT_IN_PROTECTED_PATH_PREFIXES, + LEVELS, + build_validation_check_catalog, + build_validation_ladder_profile, + build_validation_selection, + require_release_full_validation_receipt, + validate_release_full_validation_receipt, + validate_validation_check_catalog, + validate_validation_ladder_profile, + validation_ladder_manifest_blockers, +) __all__ = [ "BUG_FORENSICS_ADVISORY_SCHEMA", "BUG_FORENSICS_PROFILE_ID", + "BUILT_IN_PROTECTED_PATH_PREFIXES", "FAILURE_CLASSES", + "LEVELS", "bug_forensics_recommended", "build_bug_forensics_advisory", "build_bug_forensics_profile", @@ -88,6 +102,9 @@ "build_security_finding", "build_security_profile", "build_security_verification_assignment", + "build_validation_check_catalog", + "build_validation_ladder_profile", + "build_validation_selection", "require_behavior_checks_pass", "require_bug_forensics_profile_pass", "require_bug_forensics_recipe_pass", @@ -98,6 +115,7 @@ "require_hypothesis_ledger_pass", "require_quality_pack_pass", "require_regression_proof_pass", + "require_release_full_validation_receipt", "run_behavior_checks", "security_analysis_acceptance_blocker", "security_analysis_activated", @@ -112,10 +130,14 @@ "validate_hypothesis_ledger", "validate_quality_pack", "validate_regression_proof_receipt", + "validate_release_full_validation_receipt", "validate_security_analysis_audit", "validate_security_analysis_profile", "validate_security_execution_gate_receipt", "validate_security_finding", "validate_security_profile", "validate_security_verification_assignment", + "validate_validation_check_catalog", + "validate_validation_ladder_profile", + "validation_ladder_manifest_blockers", ] diff --git a/src/agent_lifecycle/quality/validation_ladder.py b/src/agent_lifecycle/quality/validation_ladder.py new file mode 100644 index 00000000..c463a9d0 --- /dev/null +++ b/src/agent_lifecycle/quality/validation_ladder.py @@ -0,0 +1,790 @@ +"""Deterministic validation selection without command execution authority.""" + +from __future__ import annotations + +from collections import Counter +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, cast + +from agent_lifecycle.contracts import LifecycleError, canonical_digest, load_json_object +from agent_lifecycle.contracts.ownership_paths import is_under_authority_path, normalize_authority_path +from agent_lifecycle.contracts.paths import normalize_repo_path, read_stable_repository_file +from agent_lifecycle.contracts.validation_ladder_schemas import ( + RELEASE_FULL_VALIDATION_RECEIPT_SCHEMA, + VALIDATION_CHECK_CATALOG_SCHEMA, + VALIDATION_LADDER_PROFILE_SCHEMA, + VALIDATION_SELECTION_SCHEMA, +) + +LEVELS = ("TASK_FAST", "TASK_ACCEPTANCE", "RELEASE_FULL") +LEVEL_RANK = {level: index for index, level in enumerate(LEVELS)} + +BUILT_IN_PROTECTED_PATH_PREFIXES = ( + ".agents/plugins/marketplace.json", + ".claude-plugin", + ".codex-plugin/plugin.json", + ".cursor-plugin", + ".github/workflows", + "CHANGELOG.md", + "README.md", + "adapters/claude/.claude-plugin/plugin.json", + "adapters/codex/.codex-plugin/plugin.json", + "adapters/cursor/.cursor-plugin/plugin.json", + "docs", + "policy", + "profiles", + "pyproject.toml", + "schemas", + "src/agent_lifecycle/_version.py", + "src/agent_lifecycle/audit", + "src/agent_lifecycle/cli", + "src/agent_lifecycle/compiler", + "src/agent_lifecycle/contracts", + "src/agent_lifecycle/freeze", + "src/agent_lifecycle/model_routing", + "src/agent_lifecycle/neutrality", + "src/agent_lifecycle/planning", + "src/agent_lifecycle/quality", + "src/agent_lifecycle/workflow", + "tests/metrics/fixtures", + "tests/release", + "tests/security", + "tools/release", + "uv.lock", +) + +_CATALOG_KEYS = {"schemaVersion", "checks", "catalogDigest"} +_CHECK_KEYS = {"id", "commandDigest"} +_PROFILE_KEYS = {"schemaVersion", "mappings", "additionalProtectedPathPrefixes", "profileDigest"} +_MAPPING_KEYS = {"id", "pathPrefix", "level", "checkIds"} +_PROFILE_REF_KEYS = {"path", "digest"} +_RECEIPT_KEYS = { + "schemaVersion", + "status", + "sourceRevision", + "currentTreeDigest", + "planDigest", + "planLockDigest", + "catalogDigest", + "requiredCheckIds", + "passedCheckIds", + "gateEvidenceDigests", + "completedAt", + "blockers", + "productionPromotionClaimed", + "receiptDigest", +} +_EMPTY_CATALOG_DIGEST = canonical_digest({"schemaVersion": VALIDATION_CHECK_CATALOG_SCHEMA, "checks": []}) + + +def build_validation_check_catalog(commands_by_id: dict[str, str]) -> dict[str, Any]: + """Build a canonical command-digest catalog from stable check IDs.""" + + if not isinstance(commands_by_id, dict) or not commands_by_id: + raise LifecycleError("validation-ladder-check-missing", "validation check catalog cannot be empty") + checks = [] + for check_id, command in sorted(commands_by_id.items()): + if not _non_empty_string(check_id) or not _non_empty_string(command): + raise LifecycleError("validation-ladder-check-missing", "validation check IDs and commands are required") + checks.append({"id": check_id, "commandDigest": canonical_digest(command)}) + body = {"schemaVersion": VALIDATION_CHECK_CATALOG_SCHEMA, "checks": checks} + return {**body, "catalogDigest": canonical_digest(body)} + + +def validate_validation_check_catalog(catalog: dict[str, Any], commands: list[str]) -> dict[str, Any]: + """Validate that every catalog record resolves to exactly one frozen command.""" + + normalized = _normalize_catalog(catalog, commands) + return normalized + + +def build_validation_ladder_profile( + mappings: list[dict[str, Any]], + *, + additional_protected_path_prefixes: list[str] | None = None, +) -> dict[str, Any]: + """Build a normalized command-free validation ladder profile.""" + + body = _normalize_profile_body( + { + "schemaVersion": VALIDATION_LADDER_PROFILE_SCHEMA, + "mappings": mappings, + "additionalProtectedPathPrefixes": additional_protected_path_prefixes or [], + } + ) + return {**body, "profileDigest": canonical_digest(body)} + + +def validate_validation_ladder_profile(profile: dict[str, Any]) -> dict[str, Any]: + """Validate and normalize one command-free ladder profile.""" + + _require_exact_keys(profile, _PROFILE_KEYS, code="validation-ladder-profile-invalid") + body = _normalize_profile_body({key: value for key, value in profile.items() if key != "profileDigest"}) + if not _is_digest(profile.get("profileDigest")) or profile.get("profileDigest") != canonical_digest(body): + raise LifecycleError("validation-ladder-profile-invalid", "validation ladder profileDigest is invalid") + return {**body, "profileDigest": profile["profileDigest"]} + + +def validation_ladder_manifest_blockers(manifest: dict[str, Any]) -> list[dict[str, Any]]: + """Return structural authority blockers without loading profile bytes.""" + + validation = manifest.get("validation") + if not isinstance(validation, dict): + return [] + catalog = validation.get("checkCatalog") + reference = validation.get("validationLadderProfile") + if catalog is None and reference is None: + return [] + blockers: list[dict[str, Any]] = [] + if catalog is None or reference is None: + blockers.append( + _blocker("validation-ladder-authority-missing-peer", "validation ladder fields are all-or-none") + ) + return blockers + commands = validation.get("commands") + try: + if not isinstance(commands, list): + raise LifecycleError("validation-ladder-check-missing", "validation.commands must be an array") + _normalize_catalog(catalog, commands) + except LifecycleError as exc: + blockers.append(_blocker(exc.code, exc.message, exc.details)) + try: + _normalize_profile_reference(reference) + except LifecycleError as exc: + blockers.append(_blocker(exc.code, exc.message, exc.details)) + return blockers + + +def build_validation_selection( + *, + manifest: dict[str, Any], + lock: dict[str, Any], + state: dict[str, Any], + snapshot: dict[str, Any], + repository_root: Path, +) -> dict[str, Any]: + """Select a validation level from frozen plain data without executing commands.""" + + plan_digest = canonical_digest(manifest) + plan_lock_digest = canonical_digest(lock) + state_revision = state.get("stateRevision") + source_revision = state.get("sourceRevision") + current_tree_digest = snapshot.get("snapshotHash") + _require_selection_lineage_shapes(state_revision, source_revision, current_tree_digest) + state_revision = cast(int, state_revision) + source_revision = cast(str, source_revision) + current_tree_digest = cast(str, current_tree_digest) + + validation = manifest.get("validation") + validation = validation if isinstance(validation, dict) else {} + raw_catalog = validation.get("checkCatalog") + raw_reference = validation.get("validationLadderProfile") + catalog_digest = _catalog_digest_or_default(raw_catalog) + profile_digest = _declared_profile_digest( + raw_reference, opted_in=raw_catalog is not None or raw_reference is not None + ) + + if (raw_catalog is None) != (raw_reference is None): + return _blocked_selection( + code="validation-ladder-profile-invalid", + message="validation ladder catalog and profile reference are all-or-none", + plan_digest=plan_digest, + plan_lock_digest=plan_lock_digest, + state_revision=state_revision, + source_revision=source_revision, + current_tree_digest=current_tree_digest, + profile_digest=profile_digest, + catalog_digest=catalog_digest, + ) + + if raw_reference is None: + return _selected( + level="RELEASE_FULL", + selected_check_ids=[], + matched_mapping_ids=[], + reasons=["LEGACY_PROFILE_ABSENT"], + plan_digest=plan_digest, + plan_lock_digest=plan_lock_digest, + state_revision=state_revision, + source_revision=source_revision, + current_tree_digest=current_tree_digest, + profile_digest=None, + catalog_digest=_EMPTY_CATALOG_DIGEST, + ) + + profile: dict[str, Any] + if raw_reference is not None: + try: + reference = _normalize_profile_reference(raw_reference) + except LifecycleError as exc: + return _blocked_from_error( + exc, + plan_digest, + plan_lock_digest, + state_revision, + source_revision, + current_tree_digest, + profile_digest, + catalog_digest, + ) + profile_digest = reference["digest"] + profile_path = repository_root / reference["path"] + try: + raw_bytes = read_stable_repository_file( + repository_root, + reference["path"], + max_bytes=1_048_576, + label="validation ladder profile", + ) + except LifecycleError: + return _blocked_selection( + code="validation-ladder-profile-unreadable", + message="validation ladder profile cannot be read", + plan_digest=plan_digest, + plan_lock_digest=plan_lock_digest, + state_revision=state_revision, + source_revision=source_revision, + current_tree_digest=current_tree_digest, + profile_digest=profile_digest, + catalog_digest=catalog_digest, + context={"path": str(profile_path.relative_to(repository_root))}, + ) + try: + raw_profile = load_json_object(raw_bytes, label="validation ladder profile") + except LifecycleError as exc: + return _blocked_selection( + code="validation-ladder-profile-invalid", + message="validation ladder profile is not valid JSON", + plan_digest=plan_digest, + plan_lock_digest=plan_lock_digest, + state_revision=state_revision, + source_revision=source_revision, + current_tree_digest=current_tree_digest, + profile_digest=profile_digest, + catalog_digest=catalog_digest, + context={"cause": exc.code}, + ) + if canonical_digest(raw_profile) != profile_digest: + return _blocked_selection( + code="validation-ladder-profile-digest-mismatch", + message="validation ladder profile digest does not match its manifest reference", + plan_digest=plan_digest, + plan_lock_digest=plan_lock_digest, + state_revision=state_revision, + source_revision=source_revision, + current_tree_digest=current_tree_digest, + profile_digest=profile_digest, + catalog_digest=catalog_digest, + ) + try: + profile = validate_validation_ladder_profile(raw_profile) + except LifecycleError as exc: + return _blocked_from_error( + exc, + plan_digest, + plan_lock_digest, + state_revision, + source_revision, + current_tree_digest, + profile_digest, + catalog_digest, + ) + + if state.get("planDigest") != plan_digest or lock.get("manifestHash") != plan_digest: + return _blocked_selection( + code="validation-ladder-profile-stale", + message="validation ladder lineage is stale", + plan_digest=plan_digest, + plan_lock_digest=plan_lock_digest, + state_revision=state_revision, + source_revision=source_revision, + current_tree_digest=current_tree_digest, + profile_digest=profile_digest, + catalog_digest=catalog_digest, + ) + + try: + commands = validation.get("commands") + if not isinstance(commands, list): + raise LifecycleError("validation-ladder-check-missing", "validation.commands must be an array") + catalog = _normalize_catalog(raw_catalog, commands) + catalog_digest = catalog["catalogDigest"] + catalog_ids = {item["id"] for item in catalog["checks"]} + for mapping in profile["mappings"]: + missing = sorted(set(mapping["checkIds"]).difference(catalog_ids)) + if missing: + raise LifecycleError( + "validation-ladder-check-missing", + "validation ladder mapping references unknown check IDs", + {"mappingId": mapping["id"], "checkIds": missing}, + ) + except LifecycleError as exc: + return _blocked_from_error( + exc, + plan_digest, + plan_lock_digest, + state_revision, + source_revision, + current_tree_digest, + profile_digest, + catalog_digest, + ) + + try: + changed_paths = _normalized_changed_paths(snapshot) + except LifecycleError as exc: + return _blocked_from_error( + exc, + plan_digest, + plan_lock_digest, + state_revision, + source_revision, + current_tree_digest, + profile_digest, + catalog_digest, + ) + protected = [*BUILT_IN_PROTECTED_PATH_PREFIXES, *profile["additionalProtectedPathPrefixes"]] + if any(is_under_authority_path(path, prefix) for path in changed_paths for prefix in protected): + return _selected( + level="RELEASE_FULL", + selected_check_ids=sorted(catalog_ids), + matched_mapping_ids=[], + reasons=["PROTECTED_PATH"], + plan_digest=plan_digest, + plan_lock_digest=plan_lock_digest, + state_revision=state_revision, + source_revision=source_revision, + current_tree_digest=current_tree_digest, + profile_digest=profile_digest, + catalog_digest=catalog_digest, + ) + + matches = [ + mapping + for mapping in profile["mappings"] + if any(is_under_authority_path(path, mapping["pathPrefix"]) for path in changed_paths) + ] + if not matches: + return _selected( + level="RELEASE_FULL", + selected_check_ids=sorted(catalog_ids), + matched_mapping_ids=[], + reasons=["NO_MAPPING_MATCH"], + plan_digest=plan_digest, + plan_lock_digest=plan_lock_digest, + state_revision=state_revision, + source_revision=source_revision, + current_tree_digest=current_tree_digest, + profile_digest=profile_digest, + catalog_digest=catalog_digest, + ) + level = max((mapping["level"] for mapping in matches), key=LEVEL_RANK.__getitem__) + selected_ids = ( + sorted(catalog_ids) + if level == "RELEASE_FULL" + else sorted({check_id for mapping in matches for check_id in mapping["checkIds"]}) + ) + return _selected( + level=level, + selected_check_ids=selected_ids, + matched_mapping_ids=sorted(mapping["id"] for mapping in matches), + reasons=["MAPPING_MATCH"], + plan_digest=plan_digest, + plan_lock_digest=plan_lock_digest, + state_revision=state_revision, + source_revision=source_revision, + current_tree_digest=current_tree_digest, + profile_digest=profile_digest, + catalog_digest=catalog_digest, + ) + + +def validate_release_full_validation_receipt( + receipt: dict[str, Any], + *, + source_revision: str, + current_tree_digest: str, + plan_digest: str, + plan_lock_digest: str, + catalog_digest: str, + required_check_ids: list[str], +) -> dict[str, Any]: + """Validate a fresh full-validation receipt against exact finalization lineage.""" + + blockers: list[dict[str, Any]] = [] + if not isinstance(receipt, dict) or set(receipt) != _RECEIPT_KEYS: + blockers.append(_blocker("release-full-validation-shape", "release-full receipt fields are not exact")) + else: + expected = { + "schemaVersion": RELEASE_FULL_VALIDATION_RECEIPT_SCHEMA, + "status": "PASS", + "sourceRevision": source_revision, + "currentTreeDigest": current_tree_digest, + "planDigest": plan_digest, + "planLockDigest": plan_lock_digest, + "catalogDigest": catalog_digest, + "productionPromotionClaimed": False, + } + for key, value in expected.items(): + if receipt.get(key) != value: + blockers.append( + _blocker( + "release-full-validation-lineage", + f"release-full receipt {key} mismatch", + {"field": key}, + ) + ) + required = _canonical_string_list(receipt.get("requiredCheckIds"), allow_empty=False) + passed = _canonical_string_list(receipt.get("passedCheckIds"), allow_empty=False) + expected_required = sorted(set(required_check_ids)) + if required != expected_required or passed != expected_required: + blockers.append( + _blocker( + "release-full-validation-checks", + "release-full required and passed check IDs must equal the frozen catalog", + ) + ) + evidence = receipt.get("gateEvidenceDigests") + if ( + not isinstance(evidence, list) + or evidence != sorted(set(evidence)) + or len(evidence) != len(expected_required) + or any(not _is_digest(item) for item in evidence) + ): + blockers.append( + _blocker( + "release-full-validation-evidence", + "release-full receipt requires one canonical evidence digest per check", + ) + ) + if receipt.get("blockers") != []: + blockers.append(_blocker("release-full-validation-blockers", "release-full receipt has blockers")) + if not _is_utc_timestamp(receipt.get("completedAt")): + blockers.append(_blocker("release-full-validation-completed-at", "completedAt is required")) + body = {key: value for key, value in receipt.items() if key != "receiptDigest"} + if not _is_digest(receipt.get("receiptDigest")) or receipt.get("receiptDigest") != canonical_digest(body): + blockers.append(_blocker("release-full-validation-digest", "release-full receiptDigest is invalid")) + result = { + "schemaVersion": "agent-release-full-validation-receipt-validation.v1", + "status": "PASS" if not blockers else "FAIL", + "blockers": blockers, + "productionPromotionClaimed": False, + } + return {**result, "validationDigest": canonical_digest(result)} + + +def require_release_full_validation_receipt( + receipt: dict[str, Any], + **expected: Any, +) -> dict[str, Any]: + validation = validate_release_full_validation_receipt(receipt, **expected) + if validation["status"] != "PASS": + raise LifecycleError( + "release-full-validation-invalid", + "release-full validation receipt failed validation", + {"validation": validation}, + ) + return validation + + +def _normalize_catalog(catalog: Any, commands: list[str]) -> dict[str, Any]: + if not isinstance(catalog, dict): + raise LifecycleError("validation-ladder-check-missing", "validation check catalog must be an object") + _require_exact_keys(catalog, _CATALOG_KEYS, code="validation-ladder-check-missing") + if catalog.get("schemaVersion") != VALIDATION_CHECK_CATALOG_SCHEMA: + raise LifecycleError("validation-ladder-check-missing", "validation check catalog schema is unsupported") + if any(not _non_empty_string(command) for command in commands): + raise LifecycleError("validation-ladder-check-missing", "validation commands must be non-empty strings") + command_digests = [canonical_digest(command) for command in commands] + digest_counts = Counter(command_digests) + raw_checks = catalog.get("checks") + if not isinstance(raw_checks, list) or not raw_checks: + raise LifecycleError("validation-ladder-check-missing", "validation check catalog cannot be empty") + by_id: dict[str, dict[str, str]] = {} + for raw in raw_checks: + if not isinstance(raw, dict): + raise LifecycleError("validation-ladder-check-missing", "validation check record must be an object") + _require_exact_keys(raw, _CHECK_KEYS, code="validation-ladder-check-missing") + check_id = raw.get("id") + digest = raw.get("commandDigest") + if not _non_empty_string(check_id) or not _is_digest(digest): + raise LifecycleError("validation-ladder-check-missing", "validation check record is invalid") + normalized_id = cast(str, check_id) + normalized_digest = cast(str, digest) + record = {"id": normalized_id, "commandDigest": normalized_digest} + previous = by_id.get(normalized_id) + if previous is not None and previous != record: + raise LifecycleError("validation-ladder-check-missing", "validation check ID is retargeted") + by_id[normalized_id] = record + checks = [by_id[check_id] for check_id in sorted(by_id)] + if any(digest_counts[record["commandDigest"]] != 1 for record in checks): + raise LifecycleError( + "validation-ladder-check-missing", + "every catalog command digest must resolve to exactly one validation command", + ) + if set(command_digests) != {record["commandDigest"] for record in checks}: + raise LifecycleError( + "validation-ladder-check-missing", + "every validation command must have exactly one catalog record", + ) + body = {"schemaVersion": VALIDATION_CHECK_CATALOG_SCHEMA, "checks": checks} + if not _is_digest(catalog.get("catalogDigest")) or catalog.get("catalogDigest") != canonical_digest(body): + raise LifecycleError("validation-ladder-check-missing", "validation check catalogDigest is invalid") + return {**body, "catalogDigest": catalog["catalogDigest"]} + + +def _normalize_profile_body(body: dict[str, Any]) -> dict[str, Any]: + if set(body) != {"schemaVersion", "mappings", "additionalProtectedPathPrefixes"}: + raise LifecycleError("validation-ladder-profile-invalid", "validation ladder profile fields are not exact") + if body.get("schemaVersion") != VALIDATION_LADDER_PROFILE_SCHEMA: + raise LifecycleError("validation-ladder-profile-invalid", "validation ladder profile schema is unsupported") + raw_mappings = body.get("mappings") + if not isinstance(raw_mappings, list): + raise LifecycleError("validation-ladder-profile-invalid", "validation ladder mappings must be an array") + by_id: dict[str, dict[str, Any]] = {} + for raw in raw_mappings: + if not isinstance(raw, dict): + raise LifecycleError("validation-ladder-profile-invalid", "validation ladder mapping must be an object") + _require_exact_keys(raw, _MAPPING_KEYS, code="validation-ladder-profile-invalid") + mapping_id = raw.get("id") + path_prefix = raw.get("pathPrefix") + level = raw.get("level") + if not _non_empty_string(mapping_id) or not isinstance(path_prefix, str) or level not in LEVEL_RANK: + raise LifecycleError("validation-ladder-profile-invalid", "validation ladder mapping is invalid") + normalized_id = cast(str, mapping_id) + normalized_level = cast(str, level) + try: + normalized_prefix = normalize_authority_path(path_prefix, label="validation ladder path prefix") + except LifecycleError as exc: + raise LifecycleError( + "validation-ladder-profile-invalid", "validation ladder path prefix is invalid" + ) from exc + check_ids = _canonical_string_list(raw.get("checkIds"), allow_empty=False) + if check_ids is None: + raise LifecycleError("validation-ladder-profile-invalid", "validation ladder checkIds are invalid") + mapping = { + "id": normalized_id, + "pathPrefix": normalized_prefix, + "level": normalized_level, + "checkIds": check_ids, + } + previous = by_id.get(normalized_id) + if previous is not None and previous != mapping: + raise LifecycleError( + "validation-ladder-duplicate-conflict", + "validation ladder mapping ID has contradictory bodies", + {"mappingId": normalized_id}, + ) + by_id[normalized_id] = mapping + additions = _canonical_authority_paths(body.get("additionalProtectedPathPrefixes")) + return { + "schemaVersion": VALIDATION_LADDER_PROFILE_SCHEMA, + "mappings": [by_id[mapping_id] for mapping_id in sorted(by_id)], + "additionalProtectedPathPrefixes": additions, + } + + +def _normalize_profile_reference(reference: Any) -> dict[str, str]: + if not isinstance(reference, dict): + raise LifecycleError("validation-ladder-profile-invalid", "validation ladder profile reference is invalid") + _require_exact_keys(reference, _PROFILE_REF_KEYS, code="validation-ladder-profile-invalid") + raw_path = reference.get("path") + digest = reference.get("digest") + if not isinstance(raw_path, str) or not _is_digest(digest): + raise LifecycleError("validation-ladder-profile-invalid", "validation ladder profile reference is invalid") + try: + path = normalize_authority_path(raw_path, label="validation ladder profile path") + except LifecycleError as exc: + raise LifecycleError("validation-ladder-profile-invalid", "validation ladder profile path is invalid") from exc + return {"path": path, "digest": cast(str, digest)} + + +def _selected( + *, + level: str, + selected_check_ids: list[str], + matched_mapping_ids: list[str], + reasons: list[str], + plan_digest: str, + plan_lock_digest: str, + state_revision: int, + source_revision: str, + current_tree_digest: str, + profile_digest: str | None, + catalog_digest: str, +) -> dict[str, Any]: + body = { + "schemaVersion": VALIDATION_SELECTION_SCHEMA, + "status": "PASS", + "disposition": "SELECTED", + "level": level, + "selectedCheckIds": sorted(set(selected_check_ids)), + "matchedMappingIds": sorted(set(matched_mapping_ids)), + "reasons": sorted(set(reasons)), + "planDigest": plan_digest, + "planLockDigest": plan_lock_digest, + "stateRevision": state_revision, + "sourceRevision": source_revision, + "currentTreeDigest": current_tree_digest, + "profileDigest": profile_digest, + "catalogDigest": catalog_digest, + "commandsExecuted": False, + "stateWritten": False, + "blockers": [], + } + return {**body, "selectionDigest": canonical_digest(body)} + + +def _blocked_selection( + *, + code: str, + message: str, + plan_digest: str, + plan_lock_digest: str, + state_revision: int, + source_revision: str, + current_tree_digest: str, + profile_digest: str | None, + catalog_digest: str, + context: dict[str, Any] | None = None, +) -> dict[str, Any]: + body = { + "schemaVersion": VALIDATION_SELECTION_SCHEMA, + "status": "FAIL", + "disposition": "BLOCKED", + "level": None, + "selectedCheckIds": [], + "matchedMappingIds": [], + "reasons": [code], + "planDigest": plan_digest, + "planLockDigest": plan_lock_digest, + "stateRevision": state_revision, + "sourceRevision": source_revision, + "currentTreeDigest": current_tree_digest, + "profileDigest": profile_digest, + "catalogDigest": catalog_digest, + "commandsExecuted": False, + "stateWritten": False, + "blockers": [_blocker(code, message, context)], + } + return {**body, "selectionDigest": canonical_digest(body)} + + +def _blocked_from_error( + exc: LifecycleError, + plan_digest: str, + plan_lock_digest: str, + state_revision: int, + source_revision: str, + current_tree_digest: str, + profile_digest: str | None, + catalog_digest: str, +) -> dict[str, Any]: + return _blocked_selection( + code=exc.code, + message=exc.message, + plan_digest=plan_digest, + plan_lock_digest=plan_lock_digest, + state_revision=state_revision, + source_revision=source_revision, + current_tree_digest=current_tree_digest, + profile_digest=profile_digest, + catalog_digest=catalog_digest, + context=exc.details, + ) + + +def _normalized_changed_paths(snapshot: dict[str, Any]) -> list[str]: + changed = snapshot.get("changedFiles") + if not isinstance(changed, list): + raise LifecycleError("validation-ladder-profile-invalid", "validation snapshot changedFiles are required") + try: + return sorted({normalize_repo_path(path, label="validation changed path") for path in changed}) + except LifecycleError as exc: + raise LifecycleError("validation-ladder-profile-invalid", "validation snapshot path is invalid") from exc + + +def _require_selection_lineage_shapes(state_revision: Any, source_revision: Any, tree_digest: Any) -> None: + if not isinstance(state_revision, int) or isinstance(state_revision, bool) or state_revision < 1: + raise LifecycleError("validation-ladder-profile-stale", "stateRevision is invalid") + if not _non_empty_string(source_revision) or not _is_digest(tree_digest): + raise LifecycleError("validation-ladder-profile-stale", "validation selection lineage is invalid") + + +def _catalog_digest_or_default(catalog: Any) -> str: + if isinstance(catalog, dict) and _is_digest(catalog.get("catalogDigest")): + return catalog["catalogDigest"] + return _EMPTY_CATALOG_DIGEST + + +def _declared_profile_digest(reference: Any, *, opted_in: bool) -> str | None: + if isinstance(reference, dict) and _is_digest(reference.get("digest")): + return cast(str, reference["digest"]) + return "0" * 64 if opted_in else None + + +def _canonical_authority_paths(value: Any) -> list[str]: + if not isinstance(value, list): + raise LifecycleError( + "validation-ladder-profile-invalid", + "additionalProtectedPathPrefixes must be an array", + ) + try: + paths = [normalize_authority_path(item, label="additional protected path") for item in value] + except LifecycleError as exc: + raise LifecycleError("validation-ladder-profile-invalid", "additional protected path is invalid") from exc + if len(paths) != len(set(paths)): + raise LifecycleError("validation-ladder-profile-invalid", "additional protected paths must not repeat") + return sorted(paths) + + +def _canonical_string_list(value: Any, *, allow_empty: bool) -> list[str] | None: + if not isinstance(value, list) or any(not _non_empty_string(item) for item in value): + return None + normalized = sorted(set(value)) + if len(normalized) != len(value) or (not allow_empty and not normalized): + return None + return normalized + + +def _require_exact_keys(payload: dict[str, Any], expected: set[str], *, code: str) -> None: + if set(payload) != expected: + raise LifecycleError( + code, "contract fields are not exact", {"expected": sorted(expected), "actual": sorted(payload)} + ) + + +def _non_empty_string(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _is_digest(value: Any) -> bool: + return isinstance(value, str) and len(value) == 64 and all(character in "0123456789abcdef" for character in value) + + +def _is_utc_timestamp(value: Any) -> bool: + if not isinstance(value, str) or not value.endswith("Z") or "T" not in value: + return False + try: + return datetime.fromisoformat(value[:-1] + "+00:00").tzinfo == UTC + except ValueError: + return False + + +def _blocker(code: str, message: str, context: dict[str, Any] | None = None) -> dict[str, Any]: + return {"code": code, "message": message, "context": context or {}} + + +__all__ = [ + "BUILT_IN_PROTECTED_PATH_PREFIXES", + "LEVELS", + "build_validation_check_catalog", + "build_validation_ladder_profile", + "build_validation_selection", + "require_release_full_validation_receipt", + "validate_release_full_validation_receipt", + "validate_validation_check_catalog", + "validate_validation_ladder_profile", + "validation_ladder_manifest_blockers", +] diff --git a/src/agent_lifecycle/workflow/finalization.py b/src/agent_lifecycle/workflow/finalization.py index d54b5e34..acd45ed7 100644 --- a/src/agent_lifecycle/workflow/finalization.py +++ b/src/agent_lifecycle/workflow/finalization.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any +from agent_lifecycle.changesets.snapshot import capture_task_change_set from agent_lifecycle.contracts import LifecycleError, canonical_digest, read_json_object, write_json_create from agent_lifecycle.contracts.goal_validation import validate_goal_record from agent_lifecycle.contracts.implementation_audit_validation import ( @@ -20,6 +21,7 @@ lifecycle_control_selection, require_lifecycle_gate_pass, ) +from agent_lifecycle.quality.validation_ladder import require_release_full_validation_receipt from agent_lifecycle.specification import ( require_completion_gate_finalization, validate_completion_check, @@ -279,6 +281,7 @@ def finalize_run( follow_up_register_path: str | None = None, completion_gate_receipt_path: str | None = None, final_implementation_audit_path: str | None = None, + release_full_receipt_path: str | None = None, review_mesh_quorum_paths: list[str] | None = None, reason: str, ) -> dict[str, Any]: @@ -325,6 +328,11 @@ def finalize_run( root, final_implementation_audit_path=final_implementation_audit_path, ) + release_full_validation = _validate_release_full_validation( + state, + root, + release_full_receipt_path=release_full_receipt_path, + ) finalization_gate_receipts = _validate_finalization_gates( state_path, state, @@ -349,6 +357,7 @@ def finalize_run( follow_up_register=follow_up_register, proof_integrity=proof_integrity, final_implementation_audit=final_implementation_audit, + release_full_validation=release_full_validation, finalization_gate_receipts=finalization_gate_receipts, review_mesh_quorum=review_mesh_quorum, lifecycle_control_stop=None, @@ -365,6 +374,7 @@ def finalize_run( follow_up_register=follow_up_register, proof_integrity=proof_integrity, final_implementation_audit=final_implementation_audit, + release_full_validation=release_full_validation, finalization_gate_receipts=finalization_gate_receipts, review_mesh_quorum=review_mesh_quorum, lifecycle_control_stop=lifecycle_control_stop, @@ -386,6 +396,8 @@ def finalize_run( state["proofIntegrityReceipt"] = proof_integrity["receipt"] if final_implementation_audit is not None: state["finalImplementationAudit"] = final_implementation_audit["audit"] + if release_full_validation is not None: + state["releaseFullValidationReceipt"] = release_full_validation["receipt"] if review_mesh_quorum is not None: state["reviewMeshFinalQuorum"] = review_mesh_quorum if lifecycle_control_stop is not None: @@ -404,6 +416,7 @@ def finalize_run( "followUpRegister": follow_up_register, "proofIntegrity": proof_integrity, "finalImplementationAudit": final_implementation_audit, + "releaseFullValidation": release_full_validation, "finalizationGateReceipts": finalization_gate_receipts, "reviewMeshQuorum": review_mesh_quorum, "lifecycleControlStop": lifecycle_control_stop, @@ -660,6 +673,109 @@ def _validate_final_implementation_audit( return {"audit": identity, "validation": validation} +def _validate_release_full_validation( + state: dict[str, Any], + root: Path, + *, + release_full_receipt_path: str | None, +) -> dict[str, Any] | None: + manifest_path = state.get("manifestPath") + if not isinstance(manifest_path, str) or not manifest_path: + if release_full_receipt_path is not None: + raise LifecycleError( + "release-full-validation-not-enabled", + "release-full validation is not enabled for this legacy workflow state", + ) + return None + manifest_rel = normalize_repo_path(manifest_path, label="plan manifest") + manifest = read_json_object(root / manifest_rel, label="plan manifest") + manifest_validation = manifest.get("validation") + opted_in = isinstance(manifest_validation, dict) and ( + "checkCatalog" in manifest_validation or "validationLadderProfile" in manifest_validation + ) + if not opted_in: + if release_full_receipt_path is not None: + raise LifecycleError( + "release-full-validation-not-enabled", + "release-full validation is not enabled by the frozen manifest", + ) + return None + if release_full_receipt_path is None: + raise LifecycleError( + "release-full-validation-required", + "the frozen validation ladder requires a fresh release-full receipt", + ) + if not isinstance(manifest_validation, dict): + raise LifecycleError("release-full-validation-invalid", "manifest validation authority is invalid") + catalog = manifest_validation.get("checkCatalog") + profile_reference = manifest_validation.get("validationLadderProfile") + if not isinstance(catalog, dict) or not isinstance(profile_reference, dict): + raise LifecycleError("release-full-validation-invalid", "validation ladder authority is incomplete") + checks = catalog.get("checks") + if not isinstance(checks, list) or not checks: + raise LifecycleError("release-full-validation-invalid", "validation check catalog is empty") + required_check_ids = sorted( + item["id"] for item in checks if isinstance(item, dict) and isinstance(item.get("id"), str) + ) + if len(required_check_ids) != len(checks): + raise LifecycleError("release-full-validation-invalid", "validation check catalog is malformed") + manifest_digest = canonical_digest(manifest) + if state.get("planDigest") != manifest_digest: + raise LifecycleError("release-full-validation-stale", "workflow plan lineage is stale") + lock_rel = str(Path(manifest_rel).parent / "plan.lock.json") + lock = read_json_object(root / lock_rel, label="plan lock") + if lock.get("manifestHash") != manifest_digest: + raise LifecycleError("release-full-validation-stale", "plan lock lineage is stale") + current = _capture_release_current_tree(root, source_revision=str(state.get("sourceRevision")), state=state) + receipt_rel = normalize_repo_path(release_full_receipt_path, label="release-full validation receipt") + receipt = read_json_object(root / receipt_rel, label="release-full validation receipt") + validation = require_release_full_validation_receipt( + receipt, + source_revision=str(state.get("sourceRevision")), + current_tree_digest=current["currentTreeDigest"], + plan_digest=manifest_digest, + plan_lock_digest=canonical_digest(lock), + catalog_digest=str(catalog.get("catalogDigest")), + required_check_ids=required_check_ids, + ) + identity = artifact_identity(root, receipt_rel, receipt) + return {"receipt": identity, "validation": validation, "currentTree": current} + + +def _capture_release_current_tree( + root: Path, + *, + source_revision: str, + state: dict[str, Any], +) -> dict[str, Any]: + write_paths = sorted( + { + path + for task in state.get("tasks", []) + if isinstance(task, dict) + for path in task.get("writes", []) + if isinstance(path, str) + } + ) + inventory = capture_task_change_set(root, baseline=source_revision, write_paths=write_paths) + changed_paths = inventory["allChangedFiles"] + current = capture_task_change_set(root, baseline=source_revision, write_paths=changed_paths) + if current["allChangedFiles"] != changed_paths or current["changedFiles"] != changed_paths: + raise LifecycleError( + "release-full-validation-tree-race", + "repository changes moved while the release-full tree identity was captured", + ) + body = { + "schemaVersion": "agent-release-current-tree.v1", + "baselineSha": current["baselineSha"], + "changedFiles": current["changedFiles"], + "fileSetHash": current["fileSetHash"], + "diffHash": current["diffHash"], + "snapshotHash": current["snapshotHash"], + } + return {**current, "currentTreeDigest": canonical_digest(body)} + + def _proof_body( state: dict[str, Any], *, @@ -671,6 +787,7 @@ def _proof_body( follow_up_register: dict[str, Any] | None, proof_integrity: dict[str, Any] | None, final_implementation_audit: dict[str, Any] | None, + release_full_validation: dict[str, Any] | None, finalization_gate_receipts: list[dict[str, Any]], review_mesh_quorum: dict[str, Any] | None, lifecycle_control_stop: dict[str, Any] | None, @@ -704,6 +821,7 @@ def _proof_body( "followUpRegister": follow_up_register, "proofIntegrity": proof_integrity, "finalImplementationAudit": final_implementation_audit, + "releaseFullValidation": release_full_validation, "finalizationGateReceipts": finalization_gate_receipts, "reviewMeshQuorum": review_mesh_quorum, "lifecycleControlStop": lifecycle_control_stop, diff --git a/tests/cli/test_specification_plan_commands.py b/tests/cli/test_specification_plan_commands.py index 092fe3a4..e93e79bb 100644 --- a/tests/cli/test_specification_plan_commands.py +++ b/tests/cli/test_specification_plan_commands.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import sys import tempfile import unittest from pathlib import Path @@ -11,16 +10,21 @@ except ImportError: from helpers import * # noqa: F401,F403,E402 +from agent_lifecycle.compiler import validate_phase_packet # noqa: E402 + + class CliSpecificationPlanCommandTests(unittest.TestCase): def test_specification_check_cli(self) -> None: with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "specification.json" path.write_text( - json.dumps({ - "tier": "S1", - "status": "FROZEN", - "requirements": [{"id": "REQ-1", "required": True}], - }), + json.dumps( + { + "tier": "S1", + "status": "FROZEN", + "requirements": [{"id": "REQ-1", "required": True}], + } + ), encoding="utf-8", ) code, payload = _run_cli(["specification", "check", "--specification", str(path)]) @@ -40,10 +44,12 @@ def test_specification_completion_gate_cli_writes_receipt(self) -> None: audit_path.write_text(json.dumps(_final_audit()), encoding="utf-8") input_path = root / "gate-input.json" input_path.write_text( - json.dumps({ - "requiredValidationIds": ["VAL-FULL"], - "validationResults": [{"id": "VAL-FULL", "status": "PASS"}], - }), + json.dumps( + { + "requiredValidationIds": ["VAL-FULL"], + "validationResults": [{"id": "VAL-FULL", "status": "PASS"}], + } + ), encoding="utf-8", ) out_path = root / "completion-gate.json" @@ -76,21 +82,25 @@ def test_plan_check_cli_validates_manifest_and_lock(self) -> None: manifest_path.write_text(json.dumps(manifest), encoding="utf-8") lock_path = root / "plan.lock.json" lock_path.write_text( - json.dumps({ - "schemaVersion": "agent-plan-lock.v1", - "planRevision": manifest["planRevision"], - "manifestHash": canonical_digest(manifest), - }), + json.dumps( + { + "schemaVersion": "agent-plan-lock.v1", + "planRevision": manifest["planRevision"], + "manifestHash": canonical_digest(manifest), + } + ), encoding="utf-8", ) - code, payload = _run_cli([ - "plan", - "check", - "--manifest", - str(manifest_path), - "--lock", - str(lock_path), - ]) + code, payload = _run_cli( + [ + "plan", + "check", + "--manifest", + str(manifest_path), + "--lock", + str(lock_path), + ] + ) self.assertEqual(code, 0) self.assertEqual(payload["schemaVersion"], "agent-plan-check.v1") self.assertEqual(payload["manifest"]["schemaVersion"], "agent-plan-validation.v1") @@ -114,14 +124,16 @@ def test_plan_acceptance_check_cli_validates_markdown_links(self) -> None: "| `AC-1` | `REQ-1` | `EV-1` | checked |\n", encoding="utf-8", ) - code, payload = _run_cli([ - "plan", - "acceptance-check", - "--manifest", - str(manifest_path), - "--acceptance", - str(acceptance_path), - ]) + code, payload = _run_cli( + [ + "plan", + "acceptance-check", + "--manifest", + str(manifest_path), + "--acceptance", + str(acceptance_path), + ] + ) self.assertEqual(code, 0) self.assertEqual(payload["schemaVersion"], "agent-acceptance-checklist-validation.v1") self.assertEqual(payload["status"], "PASS") @@ -144,14 +156,16 @@ def test_plan_acceptance_check_cli_rejects_markdown_mismatch(self) -> None: "| `AC-1` | `REQ-1` | `EV-X` | drifted |\n", encoding="utf-8", ) - code, payload = _run_cli([ - "plan", - "acceptance-check", - "--manifest", - str(manifest_path), - "--acceptance", - str(acceptance_path), - ]) + code, payload = _run_cli( + [ + "plan", + "acceptance-check", + "--manifest", + str(manifest_path), + "--acceptance", + str(acceptance_path), + ] + ) self.assertEqual(code, 2) self.assertEqual(payload["code"], "acceptance-checklist-mismatch") self.assertEqual(payload["details"]["linkMismatches"][0]["id"], "AC-1") @@ -178,29 +192,128 @@ def test_plan_continuity_cli_snapshot_reconcile_and_handoff(self) -> None: self.assertEqual(code, 0) self.assertEqual(refs["schemaVersion"], "agent-plan-reference-validation.v1") - code, snapshot = _run_cli(["plan", "snapshot", "--manifest", str(manifest_path), "--out", str(snapshot_path)]) + code, snapshot = _run_cli( + ["plan", "snapshot", "--manifest", str(manifest_path), "--out", str(snapshot_path)] + ) self.assertEqual(code, 0) self.assertEqual(snapshot["schemaVersion"], "agent-plan-snapshot.v1") self.assertTrue(snapshot_path.exists()) - code, reconciliation = _run_cli(["plan", "reconcile", "--manifest", str(manifest_path), "--snapshot", str(snapshot_path)]) + code, reconciliation = _run_cli( + ["plan", "reconcile", "--manifest", str(manifest_path), "--snapshot", str(snapshot_path)] + ) self.assertEqual(code, 0) self.assertEqual(reconciliation["classification"], "MATCH") - code, handoff = _run_cli([ - "plan", - "handoff", - "--manifest", - str(manifest_path), - "--snapshot", - str(snapshot_path), - "--out", - str(handoff_path), - ]) + code, handoff = _run_cli( + [ + "plan", + "handoff", + "--manifest", + str(manifest_path), + "--snapshot", + str(snapshot_path), + "--out", + str(handoff_path), + ] + ) self.assertEqual(code, 0) self.assertEqual(handoff["schemaVersion"], "agent-plan-handoff.v1") self.assertTrue(handoff_path.exists()) + def test_plan_handoff_emits_separate_bounded_phase_packet(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest_path = root / "plan.manifest.json" + snapshot_path = root / "snapshot.json" + lock_path = root / "plan.lock.json" + legacy_path = root / "legacy-handoff.json" + packet_handoff_path = root / "packet-handoff.json" + packet_path = root / "phase-packet.json" + manifest = _manifest() + manifest["baseRevision"] = {"ref": "v1.0.0", "sha": "a" * 40} + manifest["readOnly"] = ["docs"] + manifest["forbiddenWrites"] = [".git"] + manifest["acceptance"] = { + "criteria": [{"id": "AC-1", "requirementIds": ["REQ-1"], "evidenceIds": ["EV-1"]}] + } + manifest["workstreams"][0].update( + { + "dependsOn": [], + "acceptanceIds": ["AC-1"], + "evidenceIds": ["EV-1"], + } + ) + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + lock = { + "schemaVersion": "agent-plan-lock.v1", + "manifestHash": canonical_digest(manifest), + "planRevision": manifest["planRevision"], + } + lock_path.write_text(json.dumps(lock), encoding="utf-8") + code, _snapshot = _run_cli( + ["plan", "snapshot", "--manifest", str(manifest_path), "--out", str(snapshot_path)] + ) + self.assertEqual(code, 0) + + code, legacy = _run_cli( + [ + "plan", + "handoff", + "--manifest", + str(manifest_path), + "--snapshot", + str(snapshot_path), + "--out", + str(legacy_path), + ] + ) + self.assertEqual(code, 0) + code, packet_handoff = _run_cli( + [ + "plan", + "handoff", + "--manifest", + str(manifest_path), + "--snapshot", + str(snapshot_path), + "--lock", + str(lock_path), + "--phase-packet-out", + str(packet_path), + "--out", + str(packet_handoff_path), + ] + ) + + self.assertEqual(code, 0) + self.assertEqual(packet_handoff, legacy) + self.assertEqual(packet_handoff_path.read_bytes(), legacy_path.read_bytes()) + packet = json.loads(packet_path.read_text(encoding="utf-8")) + self.assertEqual(validate_phase_packet(packet), packet) + self.assertEqual(packet["purpose"], "PLANNING_HANDOFF") + self.assertIsNone(packet["stateRevision"]) + self.assertFalse(packet["implementationAuthorized"]) + self.assertEqual(packet["proofAuthority"], "none") + + for omitted in ("snapshot", "lock"): + with self.subTest(omitted=omitted): + args = [ + "plan", + "handoff", + "--manifest", + str(manifest_path), + "--phase-packet-out", + str(root / f"missing-{omitted}.json"), + ] + if omitted != "snapshot": + args.extend(["--snapshot", str(snapshot_path)]) + if omitted != "lock": + args.extend(["--lock", str(lock_path)]) + code, failure = _run_cli(args) + self.assertEqual(code, 2) + self.assertEqual(failure["code"], "phase-packet-required-fact-missing") + def test_plan_reconcile_cli_fails_on_drift(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -208,12 +321,16 @@ def test_plan_reconcile_cli_fails_on_drift(self) -> None: snapshot_path = root / "snapshot.json" manifest = _manifest() manifest_path.write_text(json.dumps(manifest), encoding="utf-8") - code, _snapshot = _run_cli(["plan", "snapshot", "--manifest", str(manifest_path), "--out", str(snapshot_path)]) + code, _snapshot = _run_cli( + ["plan", "snapshot", "--manifest", str(manifest_path), "--out", str(snapshot_path)] + ) self.assertEqual(code, 0) manifest["planRevision"] = 2 manifest_path.write_text(json.dumps(manifest), encoding="utf-8") - code, payload = _run_cli(["plan", "reconcile", "--manifest", str(manifest_path), "--snapshot", str(snapshot_path)]) + code, payload = _run_cli( + ["plan", "reconcile", "--manifest", str(manifest_path), "--snapshot", str(snapshot_path)] + ) self.assertEqual(code, 2) self.assertEqual(payload["code"], "plan-reconciliation-failed") diff --git a/tests/cli/test_workflow_commands.py b/tests/cli/test_workflow_commands.py index cb4b9af9..e1543c17 100644 --- a/tests/cli/test_workflow_commands.py +++ b/tests/cli/test_workflow_commands.py @@ -5,7 +5,9 @@ import sys import tempfile import unittest +from itertools import combinations from pathlib import Path +from unittest.mock import patch try: from .helpers import * # noqa: F401,F403,E402 @@ -20,6 +22,7 @@ build_receipt_hash_chain, build_root_cause_evidence, ) +from agent_lifecycle.compiler import validate_phase_packet # noqa: E402 def _cli_risk_profile(*, operation_id: str) -> dict: @@ -375,6 +378,245 @@ def test_workflow_task_snapshot_cli_returns_embeddable_claim(self) -> None: self.assertEqual(payload["claim"]["schemaVersion"], "agent-task-change-set-claim.v1") self.assertFalse(payload["stateWritten"]) + def test_workflow_task_snapshot_emits_bounded_phase_packets(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "config", "user.email", "tests@example.invalid"], cwd=root, check=True) + subprocess.run(["git", "config", "user.name", "ALK Tests"], cwd=root, check=True) + (root / ".gitignore").write_text("run.state.json\nevents.jsonl\nwork/\n", encoding="utf-8") + source = root / "src/example.py" + source.parent.mkdir(parents=True) + source.write_text("value = 1\n", encoding="utf-8") + subprocess.run(["git", "add", ".gitignore", "src/example.py"], cwd=root, check=True) + subprocess.run(["git", "commit", "-qm", "baseline"], cwd=root, check=True) + revision = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, check=True, capture_output=True, text=True + ).stdout.strip() + manifest = _phase_packet_manifest(revision) + manifest_path = root / "plan.manifest.json" + lock_path = root / "plan.lock.json" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + lock_path.write_text( + json.dumps( + { + "schemaVersion": "agent-plan-lock.v1", + "manifestHash": canonical_digest(manifest), + "planRevision": 1, + } + ), + encoding="utf-8", + ) + state_path = _write_state(root) + state = json.loads(state_path.read_text(encoding="utf-8")) + state["planDigest"] = canonical_digest(manifest) + state["sourceRevision"] = revision + state["tasks"][0].update( + { + "writes": ["src"], + "readOnly": ["docs"], + "forbiddenWrites": [".github/workflows"], + "reviewer": "independent-reviewer", + } + ) + state_path.write_text(json.dumps(state), encoding="utf-8") + code, _started = _run_cli( + [ + "workflow", + "task-start", + "--state", + str(state_path), + "--task", + "WS-01", + "--operation-id", + "start-op", + "--expected-revision", + "1", + "--source-revision", + revision, + "--reason", + "launch", + ] + ) + self.assertEqual(code, 0) + source.write_text("value = 2\n", encoding="utf-8") + + code, legacy = _run_cli(["workflow", "task-snapshot", "--state", str(state_path), "--task", "WS-01"]) + self.assertEqual(code, 0) + implementation_path = root / "implementation-phase.json" + code, packet_snapshot = _run_cli( + _task_snapshot_packet_args( + state_path, + manifest_path, + lock_path, + purpose="IMPLEMENTATION", + out=implementation_path, + ) + ) + self.assertEqual(code, 0) + self.assertEqual(packet_snapshot, legacy) + implementation = json.loads(implementation_path.read_text(encoding="utf-8")) + validate_phase_packet(implementation) + self.assertEqual(implementation["purpose"], "IMPLEMENTATION") + self.assertFalse(implementation["implementationAuthorized"]) + + optional = { + "manifest": ["--manifest", str(manifest_path)], + "lock": ["--lock", str(lock_path)], + "purpose": ["--phase-packet-purpose", "IMPLEMENTATION"], + "out": ["--phase-packet-out", str(root / "partial.json")], + } + for size in range(1, len(optional)): + for selected in combinations(optional, size): + args = ["workflow", "task-snapshot", "--state", str(state_path), "--task", "WS-01"] + for key in selected: + args.extend(optional[key]) + code, failure = _run_cli(args) + self.assertEqual(code, 2, selected) + self.assertEqual(failure["code"], "phase-packet-required-fact-missing", selected) + + stored_state = json.loads(state_path.read_text(encoding="utf-8")) + task = stored_state["tasks"][0] + task["status"] = "VERIFYING" + task["result"] = {"path": "work/result.json", "sha256": "6" * 64, "bytes": 10} + task["resultChangeSetEvidence"] = { + key: legacy[key] + for key in ("provider", "baselineSha", "fileSetHash", "diffHash", "snapshotHash", "changedFiles") + } + state_path.write_text(json.dumps(stored_state), encoding="utf-8") + audit_path = root / "audit-phase.json" + code, audit_snapshot = _run_cli( + _task_snapshot_packet_args( + state_path, + manifest_path, + lock_path, + purpose="TASK_AUDIT", + out=audit_path, + ) + ) + self.assertEqual(code, 0) + self.assertEqual(audit_snapshot["snapshotHash"], legacy["snapshotHash"]) + audit_packet = json.loads(audit_path.read_text(encoding="utf-8")) + validate_phase_packet(audit_packet) + self.assertEqual(audit_packet["payload"]["resultDigest"], "6" * 64) + + stored_state = json.loads(state_path.read_text(encoding="utf-8")) + task = stored_state["tasks"][0] + task["status"] = "RUNNING" + task["attempt"] = 2 + task["remediationFindingIds"] = ["F-WS211-03"] + task["attemptHistory"] = [ + { + "result": {"sha256": "7" * 64}, + "review": {"sha256": "8" * 64}, + } + ] + state_path.write_text(json.dumps(stored_state), encoding="utf-8") + remediation_path = root / "remediation-phase.json" + code, _remediation_snapshot = _run_cli( + _task_snapshot_packet_args( + state_path, + manifest_path, + lock_path, + purpose="REMEDIATION", + out=remediation_path, + ) + ) + self.assertEqual(code, 0) + remediation = json.loads(remediation_path.read_text(encoding="utf-8")) + validate_phase_packet(remediation) + self.assertEqual(remediation["payload"]["openFindingIds"], ["F-WS211-03"]) + self.assertEqual(remediation["payload"]["remainingAttempts"], 2) + + def test_workflow_validation_select_is_read_only_and_legacy_conservative(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest = _phase_packet_manifest("source") + manifest_path = root / "plan.manifest.json" + lock_path = root / "plan.lock.json" + snapshot_path = root / "snapshot.json" + out_path = root / "selection.json" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + lock_path.write_text( + json.dumps( + { + "schemaVersion": "agent-plan-lock.v1", + "manifestHash": canonical_digest(manifest), + "planRevision": 1, + } + ), + encoding="utf-8", + ) + snapshot_path.write_text( + json.dumps({"taskId": "WS-01", "snapshotHash": "9" * 64, "changedFiles": ["src/example.py"]}), + encoding="utf-8", + ) + state_path = _write_state(root) + state = json.loads(state_path.read_text(encoding="utf-8")) + state["planDigest"] = canonical_digest(manifest) + state_path.write_text(json.dumps(state), encoding="utf-8") + before = state_path.read_bytes() + + code, selection = _run_cli( + [ + "workflow", + "validation-select", + "--state", + str(state_path), + "--task", + "WS-01", + "--manifest", + str(manifest_path), + "--lock", + str(lock_path), + "--snapshot", + str(snapshot_path), + "--out", + str(out_path), + ] + ) + + self.assertEqual(code, 0) + self.assertEqual(selection["status"], "PASS") + self.assertEqual(selection["level"], "RELEASE_FULL") + self.assertEqual(selection["reasons"], ["LEGACY_PROFILE_ABSENT"]) + self.assertFalse(selection["commandsExecuted"]) + self.assertFalse(selection["stateWritten"]) + self.assertEqual(state_path.read_bytes(), before) + self.assertEqual(json.loads(out_path.read_text(encoding="utf-8")), selection) + + @patch("agent_lifecycle.cli.dispatch_lifecycle.finalize_run") + def test_workflow_finalize_cli_forwards_release_full_receipt(self, finalize_mock) -> None: + with tempfile.TemporaryDirectory() as tmp: + state_path = _write_state(Path(tmp)) + finalize_mock.return_value = {"schemaVersion": "agent-workflow-status.v1", "phase": "COMPLETE"} + + code, _payload = _run_cli( + [ + "workflow", + "finalize", + "--state", + str(state_path), + "--operation-id", + "finalize-op", + "--expected-revision", + "1", + "--source-revision", + "source", + "--final-audit", + "final/final-audit.json", + "--proof", + "final/proof.json", + "--release-full-receipt", + "final/release-full.json", + "--reason", + "done", + ] + ) + + self.assertEqual(code, 0) + self.assertEqual(finalize_mock.call_args.kwargs["release_full_receipt_path"], "final/release-full.json") + def test_workflow_task_start_cli_consumes_risk_profile(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -708,6 +950,65 @@ def test_workflow_budget_decision_cli_applies_reroute(self) -> None: self.assertEqual(applied["nextRouteDecisionDigest"], "8" * 64) +def _phase_packet_manifest(source_revision: str) -> dict: + return { + "status": "FROZEN", + "planRevision": 1, + "baseRevision": {"ref": source_revision, "sha": source_revision}, + "package": {"id": "package", "planArtifactRoot": "plans/package"}, + "readOnly": ["docs"], + "forbiddenWrites": [".github/workflows"], + "leadOwned": [], + "orchestration": {"maxTaskAttempts": 3}, + "acceptance": { + "criteria": [ + { + "id": "AC-WS-01", + "statement": "The task behavior is accepted.", + "evidenceIds": ["EV-WS-01"], + } + ] + }, + "workstreams": [ + { + "id": "WS-01", + "dependsOn": [], + "writes": ["src"], + "readOnly": [], + "forbiddenWrites": [], + "acceptanceIds": ["AC-WS-01"], + "evidenceIds": ["EV-WS-01"], + } + ], + } + + +def _task_snapshot_packet_args( + state_path: Path, + manifest_path: Path, + lock_path: Path, + *, + purpose: str, + out: Path, +) -> list[str]: + return [ + "workflow", + "task-snapshot", + "--state", + str(state_path), + "--task", + "WS-01", + "--manifest", + str(manifest_path), + "--lock", + str(lock_path), + "--phase-packet-purpose", + purpose, + "--phase-packet-out", + str(out), + ] + + def _cli_lineage() -> dict: return { "runId": "run", diff --git a/tests/compiler/test_phase_packets.py b/tests/compiler/test_phase_packets.py new file mode 100644 index 00000000..97eda229 --- /dev/null +++ b/tests/compiler/test_phase_packets.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from agent_lifecycle.compiler.phase_packets import ( + FORBIDDEN_PAYLOAD_KEYS, + build_phase_packet, + validate_phase_packet, +) +from agent_lifecycle.contracts import LifecycleError, canonical_digest +from agent_lifecycle.contracts.redaction import redact_text + +_DIGEST = "a" * 64 + + +def _criterion() -> dict[str, object]: + return {"id": "AC-1", "requirementIds": ["R-1"], "evidenceIds": ["EV-1"]} + + +def _evidence() -> dict[str, object]: + return {"id": "EV-1", "description": "focused evidence", "required": True} + + +def _implementation_payload() -> dict[str, object]: + return { + "schemaVersion": "agent-phase-implementation-payload.v1", + "taskId": "WS-1", + "attempt": 1, + "taskPacketDigest": _DIGEST, + "writes": ["src/b.py", "src/a.py", "src/a.py"], + "readOnly": ["docs"], + "forbiddenWrites": [".git"], + "acceptanceCriteria": [_criterion()], + "evidenceRequirements": [_evidence()], + "activeBlockerIds": [], + } + + +def _planning_payload() -> dict[str, object]: + return { + "schemaVersion": "agent-phase-planning-handoff-payload.v1", + "workstreams": [ + { + "id": "WS-1", + "dependsOn": [], + "writes": ["src/a.py"], + "readOnly": ["docs"], + "forbiddenWrites": [".git"], + "acceptanceCriteria": [_criterion()], + "evidenceRequirements": [_evidence()], + "activeBlockerIds": [], + } + ], + "dependencyEdges": [{"from": "WS-0", "to": "WS-1"}], + } + + +def _audit_payload() -> dict[str, object]: + return { + "schemaVersion": "agent-phase-task-audit-payload.v1", + "taskId": "WS-1", + "attempt": 1, + "resultDigest": _DIGEST, + "changeSetDigest": "b" * 64, + "changedPaths": ["src/a.py"], + "writes": ["src/a.py"], + "readOnly": ["docs"], + "forbiddenWrites": [".git"], + "reviewRequirements": { + "independentRequired": True, + "minimumVerdict": "ACCEPTED", + "requiredReviewerIds": ["reviewer-1"], + }, + "acceptanceCriteria": [_criterion()], + "evidenceReferences": ["work/evidence.json"], + "activeBlockerIds": [], + } + + +def _remediation_payload() -> dict[str, object]: + return { + "schemaVersion": "agent-phase-remediation-payload.v1", + "taskId": "WS-1", + "attempt": 2, + "priorResultDigest": _DIGEST, + "priorReviewDigest": "b" * 64, + "changedPaths": ["src/a.py"], + "openFindingIds": ["F-1"], + "remainingAttempts": 1, + "writes": ["src/a.py"], + "readOnly": ["docs"], + "forbiddenWrites": [".git"], + "acceptanceCriteria": [_criterion()], + "evidenceRequirements": [_evidence()], + "activeBlockerIds": [], + } + + +def _build(purpose: str, payload: dict[str, object], *, limit: int = 65536) -> dict[str, object]: + return build_phase_packet( + purpose=purpose, + payload=payload, + plan_digest=_DIGEST, + plan_lock_digest="b" * 64, + state_revision=None if purpose == "PLANNING_HANDOFF" else 2, + source_revision="deadbeef", + write_scope_digest="c" * 64, + acceptance_digest="d" * 64, + evidence_digest="e" * 64, + active_blocker_ids=list(payload.get("activeBlockerIds", [])), + max_context_bytes=limit, + ) + + +class PhasePacketTests(unittest.TestCase): + def test_builds_and_validates_each_purpose(self) -> None: + for purpose, payload in ( + ("PLANNING_HANDOFF", _planning_payload()), + ("IMPLEMENTATION", _implementation_payload()), + ("TASK_AUDIT", _audit_payload()), + ("REMEDIATION", _remediation_payload()), + ): + with self.subTest(purpose=purpose): + packet = _build(purpose, payload) + self.assertEqual(validate_phase_packet(packet), packet) + body = {key: value for key, value in packet.items() if key != "packetDigest"} + self.assertEqual(packet["packetDigest"], canonical_digest(body)) + + def test_normalizes_string_lists_and_redacts_values(self) -> None: + payload = _implementation_payload() + local_path = "/" + "Users/private/source.py" + payload["acceptanceCriteria"] = [{"id": "AC-1", "description": f"error at {local_path}"}] + + packet = _build("IMPLEMENTATION", payload) + + self.assertEqual(packet["payload"]["writes"], ["src/a.py", "src/b.py"]) + self.assertEqual( + packet["payload"]["acceptanceCriteria"][0]["description"], + "error at ", + ) + + def test_rejects_every_forbidden_key_at_any_depth(self) -> None: + self.assertEqual(len(FORBIDDEN_PAYLOAD_KEYS), 29) + for key in sorted(FORBIDDEN_PAYLOAD_KEYS): + for location in ("root", "nested", "deeply-nested"): + payload = _implementation_payload() + if location == "root": + payload[key] = "x" + elif location == "nested": + payload["acceptanceCriteria"] = [{"id": "AC-1", "source": {key: "x"}}] + else: + payload["acceptanceCriteria"] = [{"id": "AC-1", "source": {"nested": {key: "x"}}}] + with self.subTest(key=key, location=location), self.assertRaises(LifecycleError) as raised: + _build("IMPLEMENTATION", payload) + self.assertEqual(raised.exception.code, "phase-packet-forbidden-content") + + def test_rejects_every_required_payload_fact(self) -> None: + fixtures = { + "PLANNING_HANDOFF": _planning_payload, + "IMPLEMENTATION": _implementation_payload, + "TASK_AUDIT": _audit_payload, + "REMEDIATION": _remediation_payload, + } + for purpose, factory in fixtures.items(): + reference = factory() + for field in tuple(reference): + payload = factory() + payload.pop(field) + with self.subTest(purpose=purpose, field=field), self.assertRaises(LifecycleError) as raised: + _build(purpose, payload) + self.assertEqual(raised.exception.code, "phase-packet-required-fact-missing") + + for field in tuple(_planning_payload()["workstreams"][0]): + payload = _planning_payload() + payload["workstreams"][0].pop(field) + with self.subTest(record="workstream", field=field), self.assertRaises(LifecycleError) as raised: + _build("PLANNING_HANDOFF", payload) + self.assertEqual(raised.exception.code, "phase-packet-required-fact-missing") + + for field in ("from", "to"): + payload = _planning_payload() + payload["dependencyEdges"][0].pop(field) + with self.subTest(record="dependencyEdge", field=field), self.assertRaises(LifecycleError) as raised: + _build("PLANNING_HANDOFF", payload) + self.assertEqual(raised.exception.code, "phase-packet-required-fact-missing") + + for purpose, factory in fixtures.items(): + payload = factory() + criteria = ( + payload["workstreams"][0]["acceptanceCriteria"] + if purpose == "PLANNING_HANDOFF" + else payload["acceptanceCriteria"] + ) + criteria[0].pop("id") + with self.subTest(purpose=purpose, record="criterion"), self.assertRaises(LifecycleError) as raised: + _build(purpose, payload) + self.assertEqual(raised.exception.code, "phase-packet-required-fact-missing") + + for purpose, factory in fixtures.items(): + if purpose == "TASK_AUDIT": + continue + payload = factory() + evidence = ( + payload["workstreams"][0]["evidenceRequirements"] + if purpose == "PLANNING_HANDOFF" + else payload["evidenceRequirements"] + ) + evidence[0].pop("id") + with self.subTest(purpose=purpose, record="evidence"), self.assertRaises(LifecycleError) as raised: + _build(purpose, payload) + self.assertEqual(raised.exception.code, "phase-packet-required-fact-missing") + + for field in tuple(_audit_payload()["reviewRequirements"]): + payload = _audit_payload() + payload["reviewRequirements"].pop(field) + with self.subTest(record="review", field=field), self.assertRaises(LifecycleError) as raised: + _build("TASK_AUDIT", payload) + self.assertEqual(raised.exception.code, "phase-packet-required-fact-missing") + + def test_rejects_missing_extra_and_wrong_authority(self) -> None: + missing = _implementation_payload() + missing.pop("taskId") + with self.assertRaises(LifecycleError) as raised: + _build("IMPLEMENTATION", missing) + self.assertEqual(raised.exception.code, "phase-packet-required-fact-missing") + + for field, invalid in ( + ("implementationAuthorized", True), + ("proofAuthority", "claimed"), + ("productionPromotionClaimed", True), + ): + packet = _build("IMPLEMENTATION", _implementation_payload()) + packet[field] = invalid + body = {key: value for key, value in packet.items() if key != "packetDigest"} + packet["packetDigest"] = canonical_digest(body) + with self.subTest(field=field), self.assertRaises(LifecycleError) as raised: + validate_phase_packet(packet) + self.assertEqual(raised.exception.code, "phase-packet-forbidden-content") + + def test_retains_complete_nested_records(self) -> None: + payload = _audit_payload() + payload["acceptanceCriteria"] = [ + { + "id": "AC-1", + "requirementIds": ["R-2", "R-1"], + "evidenceIds": ["EV-2", "EV-1"], + "independentEvidenceIds": ["EV-3"], + "independence": {"required": True}, + "statement": "acceptance statement", + "description": "acceptance description", + "source": {"kind": "manifest"}, + "priority": 1, + } + ] + packet = _build("TASK_AUDIT", payload) + + criterion = packet["payload"]["acceptanceCriteria"][0] + self.assertEqual(set(criterion), set(payload["acceptanceCriteria"][0])) + self.assertEqual(criterion["requirementIds"], ["R-1", "R-2"]) + self.assertEqual(criterion["evidenceIds"], ["EV-1", "EV-2"]) + + def test_runtime_types_match_the_published_nested_schemas(self) -> None: + cases = ( + ("acceptanceCriteria", "statement", 1), + ("acceptanceCriteria", "description", 1), + ("evidenceRequirements", "description", 1), + ("evidenceRequirements", "artifactPath", 1), + ("evidenceRequirements", "required", "yes"), + ) + for collection, field, invalid in cases: + payload = _implementation_payload() + payload[collection] = [{"id": "record-1", field: invalid}] + with self.subTest(collection=collection, field=field), self.assertRaises(LifecycleError) as raised: + _build("IMPLEMENTATION", payload) + self.assertEqual(raised.exception.code, "phase-packet-required-fact-missing") + + def test_bounds_raw_context_before_redaction(self) -> None: + oversized = _implementation_payload() + oversized["acceptanceCriteria"] = [{"id": "AC-1", "description": "x" * 70000}] + with ( + patch( + "agent_lifecycle.compiler.phase_packets.redact_value", + side_effect=AssertionError("redaction must not run"), + ), + self.assertRaises(LifecycleError) as raised, + ): + _build("IMPLEMENTATION", oversized) + self.assertEqual(raised.exception.code, "phase-packet-context-limit-exceeded") + + def test_phase_strings_preserve_shared_redaction_semantics(self) -> None: + samples = ( + "deploy --password=hunter2 staged", + "_secret=abc123", + "1password=x9y8z7", + "..token=abc", + '"password": "hunter2"', + "Bearer abc.def", + ) + for sample in samples: + payload = _implementation_payload() + payload["acceptanceCriteria"] = [{"id": "AC-1", "description": sample}] + packet = _build("IMPLEMENTATION", payload) + expected, _changed = redact_text(sample) + with self.subTest(sample=sample): + self.assertEqual(packet["payload"]["acceptanceCriteria"][0]["description"], expected) + + def test_rejects_stored_unredacted_values_and_digest_drift(self) -> None: + packet = _build("IMPLEMENTATION", _implementation_payload()) + packet["payload"]["acceptanceCriteria"][0]["description"] = "/tmp/private.py" + body = {key: value for key, value in packet.items() if key != "packetDigest"} + packet["packetDigest"] = canonical_digest(body) + with self.assertRaises(LifecycleError) as raised: + validate_phase_packet(packet) + self.assertEqual(raised.exception.code, "phase-packet-forbidden-content") + + packet = _build("IMPLEMENTATION", _implementation_payload()) + packet["sourceRevision"] = "changed" + with self.assertRaises(LifecycleError) as raised: + validate_phase_packet(packet) + self.assertEqual(raised.exception.code, "phase-packet-required-fact-missing") + + def test_rejects_context_overflow_without_truncation(self) -> None: + payload = _implementation_payload() + payload["acceptanceCriteria"] = [{"id": "AC-1", "description": "x" * 2000}] + with self.assertRaises(LifecycleError) as raised: + _build("IMPLEMENTATION", payload, limit=1024) + self.assertEqual(raised.exception.code, "phase-packet-context-limit-exceeded") + + with self.assertRaises(LifecycleError) as raised: + _build("IMPLEMENTATION", _implementation_payload(), limit=65537) + self.assertEqual(raised.exception.code, "phase-packet-context-limit-exceeded") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/contracts/test_contracts.py b/tests/contracts/test_contracts.py index ed756d65..7370fa34 100644 --- a/tests/contracts/test_contracts.py +++ b/tests/contracts/test_contracts.py @@ -388,6 +388,12 @@ def test_schema_registry_is_stable_and_closed(self) -> None: self.assertEqual(get_schema("agent-task-template-library.v1")["properties"]["freezeBlocked"], {"const": True}) self.assertEqual(get_schema("agent-bug-forensics-recipe-library.v1")["properties"]["enabledByDefault"], {"const": False}) self.assertEqual(get_schema("agent-bug-forensics-recipe-library.v1")["properties"]["budgetUnits"], {"const": "tokens-and-resources"}) + self.assertEqual(get_schema("agent-phase-packet.v1")["properties"]["proofAuthority"], {"const": "none"}) + self.assertEqual(get_schema("agent-validation-selection.v1")["properties"]["commandsExecuted"], {"const": False}) + self.assertEqual( + get_schema("agent-release-full-validation-receipt.v1")["properties"]["productionPromotionClaimed"], + {"const": False}, + ) with self.assertRaises(LifecycleError): get_schema("missing.v1") diff --git a/tests/contracts/test_phase_packet_schemas.py b/tests/contracts/test_phase_packet_schemas.py new file mode 100644 index 00000000..01625c28 --- /dev/null +++ b/tests/contracts/test_phase_packet_schemas.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import unittest + +from agent_lifecycle.contracts.phase_packet_schemas import PHASE_PACKET_SCHEMAS + + +class PhasePacketSchemaTests(unittest.TestCase): + def test_all_five_schemas_are_closed(self) -> None: + self.assertEqual(len(PHASE_PACKET_SCHEMAS), 5) + for schema in PHASE_PACKET_SCHEMAS.values(): + self.assertFalse(schema["additionalProperties"]) + + def test_payload_records_are_closed(self) -> None: + planning = PHASE_PACKET_SCHEMAS["agent-phase-planning-handoff-payload.v1"] + workstream = planning["properties"]["workstreams"]["items"] + edge = planning["properties"]["dependencyEdges"]["items"] + implementation = PHASE_PACKET_SCHEMAS["agent-phase-implementation-payload.v1"] + task_audit = PHASE_PACKET_SCHEMAS["agent-phase-task-audit-payload.v1"] + remediation = PHASE_PACKET_SCHEMAS["agent-phase-remediation-payload.v1"] + review = task_audit["properties"]["reviewRequirements"] + + self.assertFalse(workstream["additionalProperties"]) + self.assertFalse(edge["additionalProperties"]) + self.assertFalse(review["additionalProperties"]) + for payload, field in ( + (planning, "workstreams"), + (implementation, "acceptanceCriteria"), + (task_audit, "acceptanceCriteria"), + (remediation, "acceptanceCriteria"), + ): + owner = payload["properties"][field] + criterion = ( + owner["items"]["properties"]["acceptanceCriteria"]["items"] + if field == "workstreams" + else owner["items"] + ) + self.assertFalse(criterion["additionalProperties"]) + for payload, field in ( + (planning, "workstreams"), + (implementation, "evidenceRequirements"), + (remediation, "evidenceRequirements"), + ): + owner = payload["properties"][field] + evidence = ( + owner["items"]["properties"]["evidenceRequirements"]["items"] + if field == "workstreams" + else owner["items"] + ) + self.assertFalse(evidence["additionalProperties"]) + + def test_envelope_authority_and_purpose_are_exact(self) -> None: + schema = PHASE_PACKET_SCHEMAS["agent-phase-packet.v1"] + properties = schema["properties"] + + self.assertEqual(properties["implementationAuthorized"], {"const": False}) + self.assertEqual(properties["proofAuthority"], {"const": "none"}) + self.assertEqual(properties["productionPromotionClaimed"], {"const": False}) + self.assertEqual( + properties["purpose"]["enum"], + ["PLANNING_HANDOFF", "IMPLEMENTATION", "TASK_AUDIT", "REMEDIATION"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/contracts/test_validation_ladder_schemas.py b/tests/contracts/test_validation_ladder_schemas.py new file mode 100644 index 00000000..35af0400 --- /dev/null +++ b/tests/contracts/test_validation_ladder_schemas.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import unittest + +from agent_lifecycle.contracts.validation_ladder_schemas import VALIDATION_LADDER_SCHEMAS + + +class ValidationLadderSchemaTests(unittest.TestCase): + def test_all_schemas_are_closed(self) -> None: + self.assertEqual(len(VALIDATION_LADDER_SCHEMAS), 4) + for schema in VALIDATION_LADDER_SCHEMAS.values(): + self.assertFalse(schema["additionalProperties"]) + + def test_catalog_and_profile_records_are_closed(self) -> None: + catalog = VALIDATION_LADDER_SCHEMAS["agent-validation-check-catalog.v1"] + profile = VALIDATION_LADDER_SCHEMAS["agent-validation-ladder-profile.v1"] + + self.assertFalse(catalog["properties"]["checks"]["items"]["additionalProperties"]) + self.assertFalse(profile["properties"]["mappings"]["items"]["additionalProperties"]) + + def test_selection_has_read_only_constants(self) -> None: + selection = VALIDATION_LADDER_SCHEMAS["agent-validation-selection.v1"] + + self.assertEqual(selection["properties"]["commandsExecuted"], {"const": False}) + self.assertEqual(selection["properties"]["stateWritten"], {"const": False}) + + def test_release_full_receipt_cannot_claim_promotion(self) -> None: + receipt = VALIDATION_LADDER_SCHEMAS["agent-release-full-validation-receipt.v1"] + + self.assertEqual(receipt["properties"]["status"], {"const": "PASS"}) + self.assertEqual(receipt["properties"]["productionPromotionClaimed"], {"const": False}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/planning/test_completeness.py b/tests/planning/test_completeness.py index 00ba79e6..86db0be1 100644 --- a/tests/planning/test_completeness.py +++ b/tests/planning/test_completeness.py @@ -1,6 +1,5 @@ from __future__ import annotations -import copy import sys import unittest from pathlib import Path @@ -9,6 +8,7 @@ sys.path.insert(0, str(ROOT / "src")) from agent_lifecycle.planning.completeness import validate_plan_completeness # noqa: E402 +from agent_lifecycle.quality.validation_ladder import build_validation_check_catalog # noqa: E402 class CanonicalPlanCompletenessTests(unittest.TestCase): @@ -31,14 +31,44 @@ def test_pseudo_glob_path_is_rejected(self) -> None: def test_unordered_cross_workstream_writes_are_rejected(self) -> None: manifest = _manifest() manifest["workstreams"] = [ - {"id": "WS-01", "dependsOn": [], "writes": ["src/shared"], "acceptanceIds": ["AC-01"], "evidenceIds": ["EV-01"]}, - {"id": "WS-02", "dependsOn": [], "writes": ["src/shared/file.py"], "acceptanceIds": ["AC-02"], "evidenceIds": ["EV-02"]}, + { + "id": "WS-01", + "dependsOn": [], + "writes": ["src/shared"], + "acceptanceIds": ["AC-01"], + "evidenceIds": ["EV-01"], + }, + { + "id": "WS-02", + "dependsOn": [], + "writes": ["src/shared/file.py"], + "acceptanceIds": ["AC-02"], + "evidenceIds": ["EV-02"], + }, ] result = validate_plan_completeness(manifest) self.assertIn("authority-write-conflict", _codes(result)) + def test_validation_ladder_authority_is_all_or_none_and_resolved(self) -> None: + manifest = _manifest() + command = manifest["validation"]["commands"][0] + manifest["validation"]["checkCatalog"] = build_validation_check_catalog({"full": command}) + + missing_peer = validate_plan_completeness(manifest) + self.assertIn("validation-ladder-authority", missing_peer["requiredChecks"]) + self.assertIn("validation-ladder-authority-missing-peer", _codes(missing_peer)) + + manifest["validation"]["validationLadderProfile"] = { + "path": "profiles/validation.json", + "digest": "1" * 64, + } + self.assertEqual(validate_plan_completeness(manifest)["status"], "PASS") + + manifest["validation"]["checkCatalog"]["checks"][0]["commandDigest"] = "2" * 64 + self.assertIn("validation-ladder-check-missing", _codes(validate_plan_completeness(manifest))) + def _manifest() -> dict: return { @@ -67,7 +97,13 @@ def _manifest() -> dict: ] }, "workstreams": [ - {"id": "WS-01", "dependsOn": [], "writes": ["src/one.py"], "acceptanceIds": ["AC-01", "AC-02"], "evidenceIds": ["EV-01", "EV-02"]} + { + "id": "WS-01", + "dependsOn": [], + "writes": ["src/one.py"], + "acceptanceIds": ["AC-01", "AC-02"], + "evidenceIds": ["EV-01", "EV-02"], + } ], "validation": {"commands": ["python -m unittest"]}, "budgets": {"maxInvocations": 3}, diff --git a/tests/planning/test_manifest_contract.py b/tests/planning/test_manifest_contract.py index 06c77439..4e4d04eb 100644 --- a/tests/planning/test_manifest_contract.py +++ b/tests/planning/test_manifest_contract.py @@ -4,6 +4,7 @@ import unittest from pathlib import Path +from agent_lifecycle.contracts import canonical_digest from agent_lifecycle.planning.manifest_contract import validate_plan_manifest_contract ROOT = Path(__file__).resolve().parents[2] @@ -21,9 +22,7 @@ def test_plan_review_round_budget_is_bounded_and_boolean_safe(self) -> None: with self.subTest(value=value): manifest = json.loads(FIXTURE.read_text(encoding="utf-8")) manifest["orchestration"] = {"maxPlanReviewRounds": value} - self.assertNotIn( - "plan-review-round-budget-invalid", _codes(validate_plan_manifest_contract(manifest)) - ) + self.assertNotIn("plan-review-round-budget-invalid", _codes(validate_plan_manifest_contract(manifest))) def test_canonical_fixture_passes(self) -> None: result = validate_plan_manifest_contract(json.loads(FIXTURE.read_text(encoding="utf-8"))) @@ -69,6 +68,22 @@ def test_attempt_budget_rejects_boolean_and_unbounded_values(self) -> None: result = validate_plan_manifest_contract(manifest) self.assertIn("plan-task-attempt-budget-invalid", _codes(result)) + def test_validation_ladder_fields_are_closed(self) -> None: + manifest = json.loads(FIXTURE.read_text(encoding="utf-8")) + command = manifest["validation"]["commands"][0] + checks = [{"id": "full", "commandDigest": canonical_digest(command)}] + catalog_body = {"schemaVersion": "agent-validation-check-catalog.v1", "checks": checks} + manifest["validation"].update( + { + "checkCatalog": {**catalog_body, "catalogDigest": canonical_digest(catalog_body)}, + "validationLadderProfile": {"path": "profiles/validation.json", "digest": "1" * 64}, + } + ) + + self.assertEqual(validate_plan_manifest_contract(manifest)["status"], "PASS") + manifest["validation"]["checkCatalog"]["command"] = command + self.assertIn("plan-manifest-field-unknown", _codes(validate_plan_manifest_contract(manifest))) + def _codes(result: dict) -> set[str]: return {item["code"] for item in result["blockers"]} diff --git a/tests/quality/test_validation_ladder.py b/tests/quality/test_validation_ladder.py new file mode 100644 index 00000000..f9f5414a --- /dev/null +++ b/tests/quality/test_validation_ladder.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import ast +import tempfile +import unittest +from pathlib import Path + +from agent_lifecycle.contracts import canonical_digest, write_json_create +from agent_lifecycle.quality import ( + BUILT_IN_PROTECTED_PATH_PREFIXES, + build_validation_check_catalog, + build_validation_ladder_profile, + build_validation_selection, + validate_release_full_validation_receipt, +) + + +class ValidationLadderTests(unittest.TestCase): + def setUp(self) -> None: + self.commands_by_id = { + "acceptance": "python -m unittest tests.acceptance -q", + "fast": "python -m unittest tests.fast -q", + "release": "python -m unittest discover -s tests -t . -q", + } + self.commands = list(reversed(self.commands_by_id.values())) + self.catalog = build_validation_check_catalog(self.commands_by_id) + self.profile = build_validation_ladder_profile( + [ + {"id": "acceptance", "pathPrefix": "src", "level": "TASK_ACCEPTANCE", "checkIds": ["acceptance"]}, + {"id": "fast", "pathPrefix": "src/example", "level": "TASK_FAST", "checkIds": ["fast"]}, + ] + ) + + def test_overlapping_mappings_union_checks_at_strongest_level(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + selection = self._select(Path(tmp), changed_files=["src/example/module.py"]) + + self.assertEqual(selection["status"], "PASS") + self.assertEqual(selection["level"], "TASK_ACCEPTANCE") + self.assertEqual(selection["selectedCheckIds"], ["acceptance", "fast"]) + self.assertEqual(selection["matchedMappingIds"], ["acceptance", "fast"]) + self.assertFalse(selection["commandsExecuted"]) + self.assertFalse(selection["stateWritten"]) + + def test_every_built_in_protected_path_selects_release_full(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for protected in BUILT_IN_PROTECTED_PATH_PREFIXES: + with self.subTest(path=protected): + selection = self._select(root, changed_files=[protected]) + self.assertEqual(selection["level"], "RELEASE_FULL") + self.assertEqual(selection["selectedCheckIds"], ["acceptance", "fast", "release"]) + + def test_no_match_and_legacy_absence_select_release_full(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + no_match = self._select(root, changed_files=["examples/demo.py"]) + legacy = self._select(root, changed_files=["examples/demo.py"], legacy=True) + + self.assertEqual(no_match["level"], "RELEASE_FULL") + self.assertEqual(no_match["reasons"], ["NO_MAPPING_MATCH"]) + self.assertEqual(legacy["level"], "RELEASE_FULL") + self.assertEqual(legacy["reasons"], ["LEGACY_PROFILE_ABSENT"]) + self.assertEqual(legacy["selectedCheckIds"], []) + self.assertIsNone(legacy["profileDigest"]) + + def test_legacy_absence_precedes_opted_in_lineage_checks(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest, lock, state, snapshot = self._inputs(root, ["examples/demo.py"], legacy=True) + lock["manifestHash"] = "e" * 64 + state["planDigest"] = "f" * 64 + selection = build_validation_selection( + manifest=manifest, + lock=lock, + state=state, + snapshot=snapshot, + repository_root=root, + ) + + self.assertEqual(selection["status"], "PASS") + self.assertEqual(selection["level"], "RELEASE_FULL") + self.assertEqual(selection["reasons"], ["LEGACY_PROFILE_ABSENT"]) + self.assertEqual(selection["blockers"], []) + + def test_failure_classes_are_disjoint(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest, lock, state, snapshot = self._inputs(root, ["src/example.py"], write_profile=False) + unreadable = build_validation_selection( + manifest=manifest, lock=lock, state=state, snapshot=snapshot, repository_root=root + ) + self.assertEqual(unreadable["blockers"][0]["code"], "validation-ladder-profile-unreadable") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest, lock, state, snapshot = self._inputs(root, ["src/example.py"]) + changed = dict(self.profile) + changed["additionalProtectedPathPrefixes"] = ["examples"] + (root / "profiles/ladder.json").unlink() + write_json_create(root / "profiles/ladder.json", changed) + mismatch = build_validation_selection( + manifest=manifest, lock=lock, state=state, snapshot=snapshot, repository_root=root + ) + self.assertEqual(mismatch["blockers"][0]["code"], "validation-ladder-profile-digest-mismatch") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + invalid_profile = {**self.profile, "command": "python -m tests"} + invalid = self._select(root, changed_files=["src/example.py"], profile=invalid_profile) + self.assertEqual(invalid["blockers"][0]["code"], "validation-ladder-profile-invalid") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest, lock, state, snapshot = self._inputs(root, ["src/example.py"]) + state["planDigest"] = "f" * 64 + stale = build_validation_selection( + manifest=manifest, lock=lock, state=state, snapshot=snapshot, repository_root=root + ) + self.assertEqual(stale["blockers"][0]["code"], "validation-ladder-profile-stale") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + missing_profile = build_validation_ladder_profile( + [{"id": "missing", "pathPrefix": "src", "level": "TASK_FAST", "checkIds": ["absent"]}] + ) + missing = self._select(root, changed_files=["src/example.py"], profile=missing_profile) + self.assertEqual(missing["blockers"][0]["code"], "validation-ladder-check-missing") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + duplicate_body = { + "schemaVersion": "agent-validation-ladder-profile.v1", + "mappings": [ + {"id": "same", "pathPrefix": "src/a", "level": "TASK_FAST", "checkIds": ["fast"]}, + { + "id": "same", + "pathPrefix": "src/b", + "level": "TASK_ACCEPTANCE", + "checkIds": ["acceptance"], + }, + ], + "additionalProtectedPathPrefixes": [], + } + duplicate_profile = {**duplicate_body, "profileDigest": canonical_digest(duplicate_body)} + duplicate = self._select(root, changed_files=["src/a.py"], profile=duplicate_profile) + self.assertEqual(duplicate["blockers"][0]["code"], "validation-ladder-duplicate-conflict") + + def test_release_full_receipt_requires_exact_fresh_full_evidence(self) -> None: + required = ["acceptance", "fast", "release"] + body = { + "schemaVersion": "agent-release-full-validation-receipt.v1", + "status": "PASS", + "sourceRevision": "source", + "currentTreeDigest": "1" * 64, + "planDigest": "2" * 64, + "planLockDigest": "3" * 64, + "catalogDigest": self.catalog["catalogDigest"], + "requiredCheckIds": required, + "passedCheckIds": required, + "gateEvidenceDigests": ["4" * 64, "5" * 64, "6" * 64], + "completedAt": "2026-08-31T00:00:00Z", + "blockers": [], + "productionPromotionClaimed": False, + } + receipt = {**body, "receiptDigest": canonical_digest(body)} + expected = { + "source_revision": "source", + "current_tree_digest": "1" * 64, + "plan_digest": "2" * 64, + "plan_lock_digest": "3" * 64, + "catalog_digest": self.catalog["catalogDigest"], + "required_check_ids": required, + } + + self.assertEqual(validate_release_full_validation_receipt(receipt, **expected)["status"], "PASS") + for field, replacement in ( + ("status", "FAIL"), + ("currentTreeDigest", "9" * 64), + ("passedCheckIds", ["fast"]), + ("gateEvidenceDigests", ["4" * 64, "5" * 64]), + ("productionPromotionClaimed", True), + ): + with self.subTest(field=field): + changed = {**receipt, field: replacement} + changed_body = {key: value for key, value in changed.items() if key != "receiptDigest"} + changed["receiptDigest"] = canonical_digest(changed_body) + self.assertEqual(validate_release_full_validation_receipt(changed, **expected)["status"], "FAIL") + + def test_selector_and_phase_packet_modules_do_not_import_planning_or_workflow(self) -> None: + root = Path(__file__).resolve().parents[2] + for relative in ( + "src/agent_lifecycle/quality/validation_ladder.py", + "src/agent_lifecycle/compiler/phase_packets.py", + ): + with self.subTest(path=relative): + tree = ast.parse((root / relative).read_text(encoding="utf-8")) + imported = { + node.module + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and isinstance(node.module, str) + } + self.assertFalse(any(name.startswith("agent_lifecycle.planning") for name in imported)) + self.assertFalse(any(name.startswith("agent_lifecycle.workflow") for name in imported)) + + def _select( + self, + root: Path, + *, + changed_files: list[str], + legacy: bool = False, + profile: dict | None = None, + ) -> dict: + manifest, lock, state, snapshot = self._inputs( + root, + changed_files, + legacy=legacy, + profile=profile, + ) + return build_validation_selection( + manifest=manifest, + lock=lock, + state=state, + snapshot=snapshot, + repository_root=root, + ) + + def _inputs( + self, + root: Path, + changed_files: list[str], + *, + legacy: bool = False, + profile: dict | None = None, + write_profile: bool = True, + ) -> tuple[dict, dict, dict, dict]: + active_profile = profile or self.profile + validation = {"commands": self.commands} + if not legacy: + profile_digest = canonical_digest(active_profile) + validation.update( + { + "checkCatalog": self.catalog, + "validationLadderProfile": {"path": "profiles/ladder.json", "digest": profile_digest}, + } + ) + if write_profile: + profile_path = root / "profiles/ladder.json" + if not profile_path.exists(): + write_json_create(profile_path, active_profile) + manifest = {"schemaVersion": "agent-plan-manifest.v1", "validation": validation} + plan_digest = canonical_digest(manifest) + lock = {"schemaVersion": "agent-plan-lock.v2", "manifestHash": plan_digest} + state = {"stateRevision": 7, "sourceRevision": "source", "planDigest": plan_digest} + snapshot = {"snapshotHash": "7" * 64, "changedFiles": changed_files} + return manifest, lock, state, snapshot + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_docs_gates.py b/tests/release/test_docs_gates.py index 29200fc3..9a7e197f 100644 --- a/tests/release/test_docs_gates.py +++ b/tests/release/test_docs_gates.py @@ -68,6 +68,8 @@ def test_root_readmes_are_compact_and_delegate_reference_detail(self) -> None: "docs/reference/review-efficiency.md", "docs/reference/evidence-independence.md", "docs/guides/phase-session-handoff.md", + "docs/reference/phase-packets.md", + "docs/reference/validation-ladder.md", "docs/reference/workflow-continuation.md", ): self.assertIn(required, english) @@ -83,10 +85,14 @@ def test_root_readmes_are_compact_and_delegate_reference_detail(self) -> None: self.assertIn("docs/reference/review-efficiency.md", english) self.assertIn("docs/reference/evidence-independence.md", english) self.assertIn("docs/guides/phase-session-handoff.md", english) + self.assertIn("docs/reference/phase-packets.md", english) + self.assertIn("docs/reference/validation-ladder.md", english) self.assertIn("reference/release-accounting.md", russian) self.assertIn("reference/review-efficiency.md", russian) self.assertIn("reference/evidence-independence.md", russian) self.assertIn("guides/phase-session-handoff.md", russian) + self.assertIn("reference/phase-packets.md", russian) + self.assertIn("reference/validation-ladder.md", russian) self.assertIn("reference/workflow-continuation.md", russian) for adapter in ("Goose", "Grok Build", "OpenInterpreter", "Pi"): self.assertIn(adapter, english) @@ -114,6 +120,8 @@ def test_release_entry_docs_have_resolving_links(self) -> None: "docs/reference/review-efficiency.md", "docs/reference/evidence-independence.md", "docs/guides/phase-session-handoff.md", + "docs/reference/phase-packets.md", + "docs/reference/validation-ladder.md", "docs/guides/quickstart.ru.md", "docs/ru/README.md", "docs/ru/architecture/system-architecture.md", @@ -132,6 +140,8 @@ def test_release_entry_docs_have_resolving_links(self) -> None: "docs/ru/reference/review-efficiency.md", "docs/ru/reference/evidence-independence.md", "docs/ru/guides/phase-session-handoff.md", + "docs/ru/reference/phase-packets.md", + "docs/ru/reference/validation-ladder.md", "docs/ru/reference/public-locators-and-redaction.md", "docs/ru/guides/production-resource-security.md", "docs/ru/guides/reference-task-evaluation.md", @@ -964,6 +974,20 @@ def _write_min_docs(root: Path, *, unsupported_verified_row: bool) -> None: root / "docs/ru/guides/phase-session-handoff.md", handoff + "полный transcript. Не снижайте review, security, architecture или quality gates.\n", # noqa: RUF001 ) + phase_packets = ( + "agent-phase-packet.v1. PLANNING_HANDOFF. IMPLEMENTATION. TASK_AUDIT. REMEDIATION. " + "phase-packet-required-fact-missing. phase-packet-forbidden-content. " + "phase-packet-context-limit-exceeded. implementationAuthorized: false. proofAuthority: none.\n" + ) + _write_text(root / "docs/reference/phase-packets.md", phase_packets) + _write_text(root / "docs/ru/reference/phase-packets.md", phase_packets) + validation_ladder = ( + "workflow validation-select. agent-validation-selection.v1. commandsExecuted: false. " + "stateWritten: false. RELEASE_FULL. agent-release-full-validation-receipt.v1. " + "--release-full-receipt.\n" + ) + _write_text(root / "docs/reference/validation-ladder.md", validation_ladder) + _write_text(root / "docs/ru/reference/validation-ladder.md", validation_ladder) commands = ( "agent-lifecycle start --adapter . agent-lifecycle plan check. " "agent-lifecycle audit package. agent-lifecycle review-mesh recommend. " diff --git a/tests/release/test_publication_adoption.py b/tests/release/test_publication_adoption.py index 92c06de8..afdef6a3 100644 --- a/tests/release/test_publication_adoption.py +++ b/tests/release/test_publication_adoption.py @@ -125,6 +125,33 @@ def test_release_accounting_and_handoff_are_documented_without_authority(self) - ): self.assertIn(marker, text) + def test_phase_packets_and_validation_ladder_are_documented_without_authority(self) -> None: + packets = ( + (ROOT / "docs/reference/phase-packets.md").read_text(encoding="utf-8"), + (ROOT / "docs/ru/reference/phase-packets.md").read_text(encoding="utf-8"), + ) + ladder = ( + (ROOT / "docs/reference/validation-ladder.md").read_text(encoding="utf-8"), + (ROOT / "docs/ru/reference/validation-ladder.md").read_text(encoding="utf-8"), + ) + for text in packets: + for marker in ( + "agent-phase-packet.v1", + "implementationAuthorized: false", + "proofAuthority: none", + "phase-packet-forbidden-content", + ): + self.assertIn(marker, text) + for text in ladder: + for marker in ( + "workflow validation-select", + "commandsExecuted: false", + "stateWritten: false", + "RELEASE_FULL", + "agent-release-full-validation-receipt.v1", + ): + self.assertIn(marker, text) + def test_review_efficiency_and_independence_are_documented_fail_closed(self) -> None: efficiency = ( (ROOT / "docs/reference/review-efficiency.md").read_text(encoding="utf-8"), diff --git a/tests/release/test_publication_versions.py b/tests/release/test_publication_versions.py index 92ec28eb..e05f2610 100644 --- a/tests/release/test_publication_versions.py +++ b/tests/release/test_publication_versions.py @@ -99,6 +99,17 @@ def test_publication_manifest_exposes_review_efficiency_boundaries(self) -> None self.assertFalse(feature["automaticApply"]) self.assertFalse(feature["reviewerTextExecutable"]) + def test_publication_manifest_exposes_phase_packet_validation_boundaries(self) -> None: + manifest = build_publication_manifest(target_version=TARGET_VERSION, target_ref=TARGET_REF) + feature = {item["id"]: item for item in manifest["documentedFeatures"]}[ + "phase-packets-and-validation-ladder" + ] + + self.assertEqual(feature["status"], "OPTIONAL") + self.assertFalse(feature["workflowAuthority"]) + self.assertFalse(feature["commandsExecutedBySelector"]) + self.assertTrue(feature["releaseFullFloorPreserved"]) + def test_publication_manifest_records_successor_adoption_boundary(self) -> None: manifest = build_publication_manifest(target_version=TARGET_VERSION, target_ref=TARGET_REF) @@ -189,7 +200,7 @@ def test_stale_release_accounting_fixture_fails(self) -> None: root = Path(tmp) _write_publication_fixture(root, version=TARGET_VERSION, ref=TARGET_REF) _write_json( - root / "tests/metrics/fixtures/release-2-10-accounting.json", + root / "tests/metrics/fixtures/release-2-11-accounting.json", {"releaseId": "2.8.0"}, ) @@ -274,7 +285,7 @@ def _write_publication_fixture(root: Path, *, version: str, ref: str) -> None: (root / "src/agent_lifecycle/_version.py").write_text(f'__version__ = "{version}"\n', encoding="utf-8") (root / "CHANGELOG.md").write_text(f"## {version} - 2026-01-01\n", encoding="utf-8") _write_json( - root / "tests/metrics/fixtures/release-2-10-accounting.json", + root / "tests/metrics/fixtures/release-2-11-accounting.json", {"releaseId": version}, ) for path in ( diff --git a/tests/workflow/test_finalization.py b/tests/workflow/test_finalization.py index e47c440a..16f31f3d 100644 --- a/tests/workflow/test_finalization.py +++ b/tests/workflow/test_finalization.py @@ -14,10 +14,137 @@ from helpers import * # noqa: F403 from agent_lifecycle.specification import build_completion_gate_receipt # noqa: E402 -from agent_lifecycle.workflow.finalization import _evaluate_lifecycle_control_stop # noqa: E402 +from agent_lifecycle.workflow.finalization import ( # noqa: E402 + _capture_release_current_tree, + _evaluate_lifecycle_control_stop, +) class WorkflowFinalizationTests(unittest.TestCase): + def test_opted_in_validation_ladder_accepts_fresh_release_full_receipt(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state_path = _write_state(root, phase="FINAL_AUDIT") + source_revision = _initialize_managed_git_state(root, state_path) + command = "python -m unittest discover -s tests -t . -q" + checks = [{"id": "release-full", "commandDigest": canonical_digest(command)}] + catalog_body = {"schemaVersion": "agent-validation-check-catalog.v1", "checks": checks} + catalog = {**catalog_body, "catalogDigest": canonical_digest(catalog_body)} + manifest = { + "schemaVersion": "agent-plan-manifest.v1", + "validation": { + "commands": [command], + "checkCatalog": catalog, + "validationLadderProfile": {"path": "profiles/validation.json", "digest": "1" * 64}, + }, + } + manifest_digest = canonical_digest(manifest) + lock = {"schemaVersion": "agent-plan-lock.v2", "manifestHash": manifest_digest} + write_json_create(root / "tasks/package/plan.manifest.json", manifest) + write_json_create(root / "tasks/package/plan.lock.json", lock) + (root / "src/example.py").write_text("value = 2\n", encoding="utf-8") + state = json.loads(state_path.read_text(encoding="utf-8")) + state["manifestPath"] = "tasks/package/plan.manifest.json" + state["planDigest"] = manifest_digest + state["tasks"][0]["status"] = "ACCEPTED" + state["tasks"][0]["attempt"] = 1 + state["tasks"][0]["review"] = { + "path": "work/WS-01/attempt-1/task-review.json", + "sha256": "3" * 64, + "bytes": 10, + } + state_path.write_text(json.dumps(state), encoding="utf-8") + current = _capture_release_current_tree(root, source_revision=source_revision, state=state) + receipt_body = { + "schemaVersion": "agent-release-full-validation-receipt.v1", + "status": "PASS", + "sourceRevision": source_revision, + "currentTreeDigest": current["currentTreeDigest"], + "planDigest": manifest_digest, + "planLockDigest": canonical_digest(lock), + "catalogDigest": catalog["catalogDigest"], + "requiredCheckIds": ["release-full"], + "passedCheckIds": ["release-full"], + "gateEvidenceDigests": ["4" * 64], + "completedAt": "2026-08-31T00:00:00Z", + "blockers": [], + "productionPromotionClaimed": False, + } + write_json_create( + root / "work/final/release-full.json", + {**receipt_body, "receiptDigest": canonical_digest(receipt_body)}, + ) + audit = _final_audit() + audit["planDigest"] = manifest_digest + audit["completionSignal"]["planDigest"] = manifest_digest + audit["completionSignal"]["sourceRevision"] = source_revision + write_json_create(root / "work/final/final-audit.json", audit) + + payload = finalize_run( + state_path, + operation_id="finalize-opted-in-pass", + expected_revision=1, + source_revision=source_revision, + final_audit_path="work/final/final-audit.json", + proof_path="work/final/proof.json", + release_full_receipt_path="work/final/release-full.json", + reason="done", + ) + + self.assertEqual(payload["phase"], "COMPLETE") + proof = json.loads((root / "work/final/proof.json").read_text(encoding="utf-8")) + self.assertEqual(proof["releaseFullValidation"]["receipt"]["path"], "work/final/release-full.json") + + def test_opted_in_validation_ladder_requires_release_full_receipt(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state_path = _write_state(root, phase="FINAL_AUDIT") + command = "python -m unittest discover -s tests -t . -q" + checks = [{"id": "release-full", "commandDigest": canonical_digest(command)}] + catalog_body = {"schemaVersion": "agent-validation-check-catalog.v1", "checks": checks} + manifest = { + "schemaVersion": "agent-plan-manifest.v1", + "validation": { + "commands": [command], + "checkCatalog": {**catalog_body, "catalogDigest": canonical_digest(catalog_body)}, + "validationLadderProfile": {"path": "profiles/validation.json", "digest": "1" * 64}, + }, + } + manifest_digest = canonical_digest(manifest) + write_json_create(root / "tasks/package/plan.manifest.json", manifest) + write_json_create( + root / "tasks/package/plan.lock.json", + {"schemaVersion": "agent-plan-lock.v2", "manifestHash": manifest_digest}, + ) + state = json.loads(state_path.read_text(encoding="utf-8")) + state["manifestPath"] = "tasks/package/plan.manifest.json" + state["planDigest"] = manifest_digest + state["tasks"][0]["status"] = "ACCEPTED" + state["tasks"][0]["attempt"] = 1 + state["tasks"][0]["review"] = { + "path": "work/WS-01/attempt-1/task-review.json", + "sha256": "3" * 64, + "bytes": 10, + } + state_path.write_text(json.dumps(state), encoding="utf-8") + audit = _final_audit() + audit["planDigest"] = manifest_digest + audit["completionSignal"]["planDigest"] = manifest_digest + write_json_create(root / "final/final-audit.json", audit) + + with self.assertRaises(LifecycleError) as raised: + finalize_run( + state_path, + operation_id="finalize-opted-in", + expected_revision=1, + source_revision="source", + final_audit_path="final/final-audit.json", + proof_path="final/proof.json", + reason="done", + ) + + self.assertEqual(raised.exception.code, "release-full-validation-required") + def test_enforced_control_stop_requires_finalize_events(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/tools/release/publication_contract.py b/tools/release/publication_contract.py index e09042d6..7560a5ba 100644 --- a/tools/release/publication_contract.py +++ b/tools/release/publication_contract.py @@ -93,6 +93,18 @@ "reviewerTextExecutable": False, } +PHASE_PACKET_VALIDATION_DOCUMENTATION: dict[str, Any] = { + "id": "phase-packets-and-validation-ladder", + "status": "OPTIONAL", + "englishPacketPath": "docs/reference/phase-packets.md", + "russianPacketPath": "docs/ru/reference/phase-packets.md", + "englishValidationPath": "docs/reference/validation-ladder.md", + "russianValidationPath": "docs/ru/reference/validation-ladder.md", + "workflowAuthority": False, + "commandsExecutedBySelector": False, + "releaseFullFloorPreserved": True, +} + SUCCESSOR_ADOPTION: dict[str, Any] = { "packageId": "release-2-7", "requiredPredecessor": "release-2-6", @@ -127,7 +139,7 @@ }, { "id": "release-accounting-fixture", - "path": "tests/metrics/fixtures/release-2-10-accounting.json", + "path": "tests/metrics/fixtures/release-2-11-accounting.json", "kind": "json-field", "jsonPath": ["releaseId"], "fieldForm": "accounting.release", @@ -299,6 +311,7 @@ def build_publication_manifest(*, target_version: str, target_ref: str) -> dict[ EXTERNAL_TOOL_JOBS_DOCUMENTATION, RELEASE_ACCOUNTING_DOCUMENTATION, REVIEW_EFFICIENCY_DOCUMENTATION, + PHASE_PACKET_VALIDATION_DOCUMENTATION, ], "successorAdoption": SUCCESSOR_ADOPTION, "lastChannelPolicy": LAST_CHANNEL_POLICY, diff --git a/tools/release/validate_docs_compat.py b/tools/release/validate_docs_compat.py index 200e91a2..fd2854cb 100644 --- a/tools/release/validate_docs_compat.py +++ b/tools/release/validate_docs_compat.py @@ -202,6 +202,60 @@ "Не снижайте review, security, architecture или quality gates", # noqa: RUF001 ), ), + ( + "docs/reference/phase-packets.md", + ( + "agent-phase-packet.v1", + "PLANNING_HANDOFF", + "IMPLEMENTATION", + "TASK_AUDIT", + "REMEDIATION", + "phase-packet-required-fact-missing", + "phase-packet-forbidden-content", + "phase-packet-context-limit-exceeded", + "implementationAuthorized: false", + "proofAuthority: none", + ), + ), + ( + "docs/ru/reference/phase-packets.md", + ( + "agent-phase-packet.v1", + "PLANNING_HANDOFF", + "IMPLEMENTATION", + "TASK_AUDIT", + "REMEDIATION", + "phase-packet-required-fact-missing", + "phase-packet-forbidden-content", + "phase-packet-context-limit-exceeded", + "implementationAuthorized: false", + "proofAuthority: none", + ), + ), + ( + "docs/reference/validation-ladder.md", + ( + "workflow validation-select", + "agent-validation-selection.v1", + "commandsExecuted: false", + "stateWritten: false", + "RELEASE_FULL", + "agent-release-full-validation-receipt.v1", + "--release-full-receipt", + ), + ), + ( + "docs/ru/reference/validation-ladder.md", + ( + "workflow validation-select", + "agent-validation-selection.v1", + "commandsExecuted: false", + "stateWritten: false", + "RELEASE_FULL", + "agent-release-full-validation-receipt.v1", + "--release-full-receipt", + ), + ), ( "docs/guides/install-and-first-run.md", ( diff --git a/uv.lock b/uv.lock index 0b600b05..a988ea2f 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11, <3.15" [[package]] name = "agent-lifecycle-kit" -version = "2.10.0" +version = "2.11.0" source = { editable = "." } [package.dev-dependencies] From a611ef87abffed6fee9a24301d12e69f3a5af38f Mon Sep 17 00:00:00 2001 From: Andrey V Date: Mon, 31 Aug 2026 23:39:35 +0300 Subject: [PATCH 2/3] refactor(workflow): satisfy architecture complexity gates --- src/agent_lifecycle/cli/dispatch_lifecycle.py | 66 ++-- src/agent_lifecycle/contracts/schemas.py | 8 +- .../quality/validation_ladder.py | 288 ++++++------------ src/agent_lifecycle/workflow/finalization.py | 139 ++++----- 4 files changed, 180 insertions(+), 321 deletions(-) diff --git a/src/agent_lifecycle/cli/dispatch_lifecycle.py b/src/agent_lifecycle/cli/dispatch_lifecycle.py index 5291a437..da1f9e46 100644 --- a/src/agent_lifecycle/cli/dispatch_lifecycle.py +++ b/src/agent_lifecycle/cli/dispatch_lifecycle.py @@ -205,37 +205,7 @@ def _dispatch_workflow(args: argparse.Namespace) -> dict[str, Any]: def _dispatch_workflow_task(args: argparse.Namespace, state_path: Path) -> dict[str, Any]: if args.workflow_command == "task-snapshot": - packet_args = (args.manifest, args.lock, args.phase_packet_purpose, args.phase_packet_out) - packet_requested = any(value is not None for value in packet_args) - if packet_requested and not all(value is not None for value in packet_args): - raise LifecycleError( - "phase-packet-required-fact-missing", - "task snapshot phase packet requires --manifest, --lock, --phase-packet-purpose and --phase-packet-out", - ) - state = read_json_object(state_path, label="workflow state") if packet_requested else None - task = _state_task(state, args.task) if isinstance(state, dict) else None - if args.phase_packet_purpose == "TASK_AUDIT" and isinstance(task, dict) and task.get("status") == "VERIFYING": - assert state is not None - payload = _result_bound_task_change_set(state, task) - else: - payload = build_current_task_change_set(state_path, task_id=args.task) - if args.out: - write_json_create(Path(args.out), payload) - if packet_requested: - assert state is not None - manifest = read_json_object(Path(args.manifest), label="plan manifest") - lock = read_json_object(Path(args.lock), label="plan lock") - verify_plan_lock(manifest, lock) - packet = _build_task_phase_packet( - manifest=manifest, - lock=lock, - state=state, - task_id=args.task, - purpose=args.phase_packet_purpose, - snapshot=payload, - ) - write_json_create(Path(args.phase_packet_out), packet) - return payload + return _dispatch_task_snapshot(args, state_path) if args.workflow_command == "block": return block_run( state_path, @@ -361,6 +331,40 @@ def _dispatch_workflow_task(args: argparse.Namespace, state_path: Path) -> dict[ raise LifecycleError("command-not-implemented", "workflow command is not implemented") +def _dispatch_task_snapshot(args: argparse.Namespace, state_path: Path) -> dict[str, Any]: + packet_args = (args.manifest, args.lock, args.phase_packet_purpose, args.phase_packet_out) + packet_requested = any(value is not None for value in packet_args) + if packet_requested and not all(value is not None for value in packet_args): + raise LifecycleError( + "phase-packet-required-fact-missing", + "task snapshot phase packet requires --manifest, --lock, --phase-packet-purpose and --phase-packet-out", + ) + state = read_json_object(state_path, label="workflow state") if packet_requested else None + task = _state_task(state, args.task) if isinstance(state, dict) else None + if args.phase_packet_purpose == "TASK_AUDIT" and isinstance(task, dict) and task.get("status") == "VERIFYING": + assert state is not None + payload = _result_bound_task_change_set(state, task) + else: + payload = build_current_task_change_set(state_path, task_id=args.task) + if args.out: + write_json_create(Path(args.out), payload) + if packet_requested: + assert state is not None + manifest = read_json_object(Path(args.manifest), label="plan manifest") + lock = read_json_object(Path(args.lock), label="plan lock") + verify_plan_lock(manifest, lock) + packet = _build_task_phase_packet( + manifest=manifest, + lock=lock, + state=state, + task_id=args.task, + purpose=args.phase_packet_purpose, + snapshot=payload, + ) + write_json_create(Path(args.phase_packet_out), packet) + return payload + + def _result_bound_task_change_set(state: dict[str, Any], task: dict[str, Any]) -> dict[str, Any]: evidence = task.get("resultChangeSetEvidence") if not isinstance(evidence, dict): diff --git a/src/agent_lifecycle/contracts/schemas.py b/src/agent_lifecycle/contracts/schemas.py index fc20772e..dc881a5b 100644 --- a/src/agent_lifecycle/contracts/schemas.py +++ b/src/agent_lifecycle/contracts/schemas.py @@ -9,15 +9,11 @@ from agent_lifecycle.contracts.adapter_event_schemas import ADAPTER_EVENT_SCHEMAS from agent_lifecycle.contracts.adapter_session_schemas import ADAPTER_SESSION_SCHEMAS from agent_lifecycle.contracts.adapter_task_schemas import ADAPTER_TASK_SCHEMAS -from agent_lifecycle.contracts.audit_optimization_schemas import ( - AUDIT_OPTIMIZATION_SCHEMAS, -) +from agent_lifecycle.contracts.audit_optimization_schemas import AUDIT_OPTIMIZATION_SCHEMAS from agent_lifecycle.contracts.audit_schemas import AUDIT_SCHEMAS from agent_lifecycle.contracts.benchmark_schemas import BENCHMARK_SCHEMAS from agent_lifecycle.contracts.bug_forensics_schemas import BUG_FORENSICS_SCHEMAS -from agent_lifecycle.contracts.context_checkpoint_schemas import ( - CONTEXT_CHECKPOINT_SCHEMAS, -) +from agent_lifecycle.contracts.context_checkpoint_schemas import CONTEXT_CHECKPOINT_SCHEMAS from agent_lifecycle.contracts.context_model_schemas import CONTEXT_MODEL_SCHEMAS from agent_lifecycle.contracts.core_schemas import CORE_SCHEMAS from agent_lifecycle.contracts.cross_check_schemas import CROSS_CHECK_SCHEMAS diff --git a/src/agent_lifecycle/quality/validation_ladder.py b/src/agent_lifecycle/quality/validation_ladder.py index c463a9d0..05812569 100644 --- a/src/agent_lifecycle/quality/validation_ladder.py +++ b/src/agent_lifecycle/quality/validation_ladder.py @@ -166,22 +166,13 @@ def build_validation_selection( ) -> dict[str, Any]: """Select a validation level from frozen plain data without executing commands.""" - plan_digest = canonical_digest(manifest) - plan_lock_digest = canonical_digest(lock) - state_revision = state.get("stateRevision") - source_revision = state.get("sourceRevision") - current_tree_digest = snapshot.get("snapshotHash") - _require_selection_lineage_shapes(state_revision, source_revision, current_tree_digest) - state_revision = cast(int, state_revision) - source_revision = cast(str, source_revision) - current_tree_digest = cast(str, current_tree_digest) - + lineage = _selection_lineage(manifest, lock, state, snapshot) validation = manifest.get("validation") validation = validation if isinstance(validation, dict) else {} raw_catalog = validation.get("checkCatalog") raw_reference = validation.get("validationLadderProfile") - catalog_digest = _catalog_digest_or_default(raw_catalog) - profile_digest = _declared_profile_digest( + lineage["catalog_digest"] = _catalog_digest_or_default(raw_catalog) + lineage["profile_digest"] = _declared_profile_digest( raw_reference, opted_in=raw_catalog is not None or raw_reference is not None ) @@ -189,13 +180,7 @@ def build_validation_selection( return _blocked_selection( code="validation-ladder-profile-invalid", message="validation ladder catalog and profile reference are all-or-none", - plan_digest=plan_digest, - plan_lock_digest=plan_lock_digest, - state_revision=state_revision, - source_revision=source_revision, - current_tree_digest=current_tree_digest, - profile_digest=profile_digest, - catalog_digest=catalog_digest, + lineage=lineage, ) if raw_reference is None: @@ -204,104 +189,53 @@ def build_validation_selection( selected_check_ids=[], matched_mapping_ids=[], reasons=["LEGACY_PROFILE_ABSENT"], - plan_digest=plan_digest, - plan_lock_digest=plan_lock_digest, - state_revision=state_revision, - source_revision=source_revision, - current_tree_digest=current_tree_digest, - profile_digest=None, - catalog_digest=_EMPTY_CATALOG_DIGEST, + lineage={**lineage, "profile_digest": None, "catalog_digest": _EMPTY_CATALOG_DIGEST}, ) - profile: dict[str, Any] - if raw_reference is not None: - try: - reference = _normalize_profile_reference(raw_reference) - except LifecycleError as exc: - return _blocked_from_error( - exc, - plan_digest, - plan_lock_digest, - state_revision, - source_revision, - current_tree_digest, - profile_digest, - catalog_digest, - ) - profile_digest = reference["digest"] - profile_path = repository_root / reference["path"] - try: - raw_bytes = read_stable_repository_file( - repository_root, - reference["path"], - max_bytes=1_048_576, - label="validation ladder profile", - ) - except LifecycleError: - return _blocked_selection( - code="validation-ladder-profile-unreadable", - message="validation ladder profile cannot be read", - plan_digest=plan_digest, - plan_lock_digest=plan_lock_digest, - state_revision=state_revision, - source_revision=source_revision, - current_tree_digest=current_tree_digest, - profile_digest=profile_digest, - catalog_digest=catalog_digest, - context={"path": str(profile_path.relative_to(repository_root))}, - ) - try: - raw_profile = load_json_object(raw_bytes, label="validation ladder profile") - except LifecycleError as exc: - return _blocked_selection( - code="validation-ladder-profile-invalid", - message="validation ladder profile is not valid JSON", - plan_digest=plan_digest, - plan_lock_digest=plan_lock_digest, - state_revision=state_revision, - source_revision=source_revision, - current_tree_digest=current_tree_digest, - profile_digest=profile_digest, - catalog_digest=catalog_digest, - context={"cause": exc.code}, - ) - if canonical_digest(raw_profile) != profile_digest: - return _blocked_selection( - code="validation-ladder-profile-digest-mismatch", - message="validation ladder profile digest does not match its manifest reference", - plan_digest=plan_digest, - plan_lock_digest=plan_lock_digest, - state_revision=state_revision, - source_revision=source_revision, - current_tree_digest=current_tree_digest, - profile_digest=profile_digest, - catalog_digest=catalog_digest, - ) - try: - profile = validate_validation_ladder_profile(raw_profile) - except LifecycleError as exc: - return _blocked_from_error( - exc, - plan_digest, - plan_lock_digest, - state_revision, - source_revision, - current_tree_digest, - profile_digest, - catalog_digest, - ) + try: + reference = _normalize_profile_reference(raw_reference) + except LifecycleError as exc: + return _blocked_from_error(exc, lineage) + lineage["profile_digest"] = reference["digest"] + try: + raw_bytes = read_stable_repository_file( + repository_root, + reference["path"], + max_bytes=1_048_576, + label="validation ladder profile", + ) + except LifecycleError: + return _blocked_selection( + code="validation-ladder-profile-unreadable", + message="validation ladder profile cannot be read", + lineage=lineage, + context={"path": reference["path"]}, + ) + try: + raw_profile = load_json_object(raw_bytes, label="validation ladder profile") + except LifecycleError as exc: + return _blocked_selection( + code="validation-ladder-profile-invalid", + message="validation ladder profile is not valid JSON", + lineage=lineage, + context={"cause": exc.code}, + ) + if canonical_digest(raw_profile) != lineage["profile_digest"]: + return _blocked_selection( + code="validation-ladder-profile-digest-mismatch", + message="validation ladder profile digest does not match its manifest reference", + lineage=lineage, + ) + try: + profile = validate_validation_ladder_profile(raw_profile) + except LifecycleError as exc: + return _blocked_from_error(exc, lineage) - if state.get("planDigest") != plan_digest or lock.get("manifestHash") != plan_digest: + if state.get("planDigest") != lineage["plan_digest"] or lock.get("manifestHash") != lineage["plan_digest"]: return _blocked_selection( code="validation-ladder-profile-stale", message="validation ladder lineage is stale", - plan_digest=plan_digest, - plan_lock_digest=plan_lock_digest, - state_revision=state_revision, - source_revision=source_revision, - current_tree_digest=current_tree_digest, - profile_digest=profile_digest, - catalog_digest=catalog_digest, + lineage=lineage, ) try: @@ -309,7 +243,7 @@ def build_validation_selection( if not isinstance(commands, list): raise LifecycleError("validation-ladder-check-missing", "validation.commands must be an array") catalog = _normalize_catalog(raw_catalog, commands) - catalog_digest = catalog["catalogDigest"] + lineage["catalog_digest"] = catalog["catalogDigest"] catalog_ids = {item["id"] for item in catalog["checks"]} for mapping in profile["mappings"]: missing = sorted(set(mapping["checkIds"]).difference(catalog_ids)) @@ -320,30 +254,12 @@ def build_validation_selection( {"mappingId": mapping["id"], "checkIds": missing}, ) except LifecycleError as exc: - return _blocked_from_error( - exc, - plan_digest, - plan_lock_digest, - state_revision, - source_revision, - current_tree_digest, - profile_digest, - catalog_digest, - ) + return _blocked_from_error(exc, lineage) try: changed_paths = _normalized_changed_paths(snapshot) except LifecycleError as exc: - return _blocked_from_error( - exc, - plan_digest, - plan_lock_digest, - state_revision, - source_revision, - current_tree_digest, - profile_digest, - catalog_digest, - ) + return _blocked_from_error(exc, lineage) protected = [*BUILT_IN_PROTECTED_PATH_PREFIXES, *profile["additionalProtectedPathPrefixes"]] if any(is_under_authority_path(path, prefix) for path in changed_paths for prefix in protected): return _selected( @@ -351,13 +267,7 @@ def build_validation_selection( selected_check_ids=sorted(catalog_ids), matched_mapping_ids=[], reasons=["PROTECTED_PATH"], - plan_digest=plan_digest, - plan_lock_digest=plan_lock_digest, - state_revision=state_revision, - source_revision=source_revision, - current_tree_digest=current_tree_digest, - profile_digest=profile_digest, - catalog_digest=catalog_digest, + lineage=lineage, ) matches = [ @@ -371,13 +281,7 @@ def build_validation_selection( selected_check_ids=sorted(catalog_ids), matched_mapping_ids=[], reasons=["NO_MAPPING_MATCH"], - plan_digest=plan_digest, - plan_lock_digest=plan_lock_digest, - state_revision=state_revision, - source_revision=source_revision, - current_tree_digest=current_tree_digest, - profile_digest=profile_digest, - catalog_digest=catalog_digest, + lineage=lineage, ) level = max((mapping["level"] for mapping in matches), key=LEVEL_RANK.__getitem__) selected_ids = ( @@ -390,16 +294,31 @@ def build_validation_selection( selected_check_ids=selected_ids, matched_mapping_ids=sorted(mapping["id"] for mapping in matches), reasons=["MAPPING_MATCH"], - plan_digest=plan_digest, - plan_lock_digest=plan_lock_digest, - state_revision=state_revision, - source_revision=source_revision, - current_tree_digest=current_tree_digest, - profile_digest=profile_digest, - catalog_digest=catalog_digest, + lineage=lineage, ) +def _selection_lineage( + manifest: dict[str, Any], + lock: dict[str, Any], + state: dict[str, Any], + snapshot: dict[str, Any], +) -> dict[str, Any]: + state_revision = state.get("stateRevision") + source_revision = state.get("sourceRevision") + current_tree_digest = snapshot.get("snapshotHash") + _require_selection_lineage_shapes(state_revision, source_revision, current_tree_digest) + return { + "plan_digest": canonical_digest(manifest), + "plan_lock_digest": canonical_digest(lock), + "state_revision": cast(int, state_revision), + "source_revision": cast(str, source_revision), + "current_tree_digest": cast(str, current_tree_digest), + "profile_digest": None, + "catalog_digest": _EMPTY_CATALOG_DIGEST, + } + + def validate_release_full_validation_receipt( receipt: dict[str, Any], *, @@ -606,13 +525,7 @@ def _selected( selected_check_ids: list[str], matched_mapping_ids: list[str], reasons: list[str], - plan_digest: str, - plan_lock_digest: str, - state_revision: int, - source_revision: str, - current_tree_digest: str, - profile_digest: str | None, - catalog_digest: str, + lineage: dict[str, Any], ) -> dict[str, Any]: body = { "schemaVersion": VALIDATION_SELECTION_SCHEMA, @@ -622,13 +535,13 @@ def _selected( "selectedCheckIds": sorted(set(selected_check_ids)), "matchedMappingIds": sorted(set(matched_mapping_ids)), "reasons": sorted(set(reasons)), - "planDigest": plan_digest, - "planLockDigest": plan_lock_digest, - "stateRevision": state_revision, - "sourceRevision": source_revision, - "currentTreeDigest": current_tree_digest, - "profileDigest": profile_digest, - "catalogDigest": catalog_digest, + "planDigest": lineage["plan_digest"], + "planLockDigest": lineage["plan_lock_digest"], + "stateRevision": lineage["state_revision"], + "sourceRevision": lineage["source_revision"], + "currentTreeDigest": lineage["current_tree_digest"], + "profileDigest": lineage["profile_digest"], + "catalogDigest": lineage["catalog_digest"], "commandsExecuted": False, "stateWritten": False, "blockers": [], @@ -640,13 +553,7 @@ def _blocked_selection( *, code: str, message: str, - plan_digest: str, - plan_lock_digest: str, - state_revision: int, - source_revision: str, - current_tree_digest: str, - profile_digest: str | None, - catalog_digest: str, + lineage: dict[str, Any], context: dict[str, Any] | None = None, ) -> dict[str, Any]: body = { @@ -657,13 +564,13 @@ def _blocked_selection( "selectedCheckIds": [], "matchedMappingIds": [], "reasons": [code], - "planDigest": plan_digest, - "planLockDigest": plan_lock_digest, - "stateRevision": state_revision, - "sourceRevision": source_revision, - "currentTreeDigest": current_tree_digest, - "profileDigest": profile_digest, - "catalogDigest": catalog_digest, + "planDigest": lineage["plan_digest"], + "planLockDigest": lineage["plan_lock_digest"], + "stateRevision": lineage["state_revision"], + "sourceRevision": lineage["source_revision"], + "currentTreeDigest": lineage["current_tree_digest"], + "profileDigest": lineage["profile_digest"], + "catalogDigest": lineage["catalog_digest"], "commandsExecuted": False, "stateWritten": False, "blockers": [_blocker(code, message, context)], @@ -671,26 +578,11 @@ def _blocked_selection( return {**body, "selectionDigest": canonical_digest(body)} -def _blocked_from_error( - exc: LifecycleError, - plan_digest: str, - plan_lock_digest: str, - state_revision: int, - source_revision: str, - current_tree_digest: str, - profile_digest: str | None, - catalog_digest: str, -) -> dict[str, Any]: +def _blocked_from_error(exc: LifecycleError, lineage: dict[str, Any]) -> dict[str, Any]: return _blocked_selection( code=exc.code, message=exc.message, - plan_digest=plan_digest, - plan_lock_digest=plan_lock_digest, - state_revision=state_revision, - source_revision=source_revision, - current_tree_digest=current_tree_digest, - profile_digest=profile_digest, - catalog_digest=catalog_digest, + lineage=lineage, context=exc.details, ) diff --git a/src/agent_lifecycle/workflow/finalization.py b/src/agent_lifecycle/workflow/finalization.py index acd45ed7..3f06c93f 100644 --- a/src/agent_lifecycle/workflow/finalization.py +++ b/src/agent_lifecycle/workflow/finalization.py @@ -295,7 +295,11 @@ def finalize_run( raise LifecycleError("final-audit-outcome-required", "v4 finalization requires an applied final-audit outcome") if isinstance(outcome, dict) and outcome.get("verdict") not in {None, "ACCEPTED"}: raise LifecycleError("final-audit-outcome-not-accepted", "final audit outcome does not permit finalization") - missing = _missing_required_acceptance(state) + missing = [ + str(task.get("id")) + for task in state.get("tasks", []) + if task.get("required", True) and task.get("status") != "ACCEPTED" + ] if missing: raise LifecycleError("finalization-precondition-failed", "required tasks are not accepted", {"tasks": missing}) missing_implementation_audits = missing_required_implementation_audits(state_path, state) @@ -344,64 +348,41 @@ def finalize_run( review_mesh_quorum_paths or [], ) proof_rel = normalize_repo_path(proof_path) - lifecycle_control_stop = _evaluate_lifecycle_control_stop( - state, - final_audit=final_audit, - final_proof=_proof_body( - state, - operation_id=operation_id, - final_audit=final_audit_identity, - completion_check_receipt=completion_check_receipt, - completion_gate=completion_gate, - goal_record=goal_record, - follow_up_register=follow_up_register, - proof_integrity=proof_integrity, - final_implementation_audit=final_implementation_audit, - release_full_validation=release_full_validation, - finalization_gate_receipts=finalization_gate_receipts, - review_mesh_quorum=review_mesh_quorum, - lifecycle_control_stop=None, - reason=reason, - ), - ) - proof = _proof_body( - state, - operation_id=operation_id, - final_audit=final_audit_identity, - completion_check_receipt=completion_check_receipt, - completion_gate=completion_gate, - goal_record=goal_record, - follow_up_register=follow_up_register, - proof_integrity=proof_integrity, - final_implementation_audit=final_implementation_audit, - release_full_validation=release_full_validation, - finalization_gate_receipts=finalization_gate_receipts, - review_mesh_quorum=review_mesh_quorum, - lifecycle_control_stop=lifecycle_control_stop, - reason=reason, - ) + proof_inputs: dict[str, Any] = { + "operation_id": operation_id, + "final_audit": final_audit_identity, + "completion_check_receipt": completion_check_receipt, + "completion_gate": completion_gate, + "goal_record": goal_record, + "follow_up_register": follow_up_register, + "proof_integrity": proof_integrity, + "final_implementation_audit": final_implementation_audit, + "release_full_validation": release_full_validation, + "finalization_gate_receipts": finalization_gate_receipts, + "review_mesh_quorum": review_mesh_quorum, + "reason": reason, + } + preview = _proof_body(state, **proof_inputs, lifecycle_control_stop=None) + lifecycle_control_stop = _evaluate_lifecycle_control_stop(state, final_audit=final_audit, final_proof=preview) + proof = _proof_body(state, **proof_inputs, lifecycle_control_stop=lifecycle_control_stop) write_json_create(root / proof_rel, proof) identity = artifact_identity(root, proof_rel, proof) state["finalProof"] = {**identity, "semanticStatus": proof["semanticStatus"]} state["finalAudit"] = final_audit_identity - if completion_check_receipt is not None: - state["completionCheckReceipt"] = completion_check_receipt["receipt"] - if completion_gate is not None: - state["completionGateReceipt"] = completion_gate["receipt"] - if goal_record is not None: - state["goalRecord"] = goal_record["record"] - if follow_up_register is not None: - state["followUpRegister"] = follow_up_register["register"] - if proof_integrity is not None: - state["proofIntegrityReceipt"] = proof_integrity["receipt"] - if final_implementation_audit is not None: - state["finalImplementationAudit"] = final_implementation_audit["audit"] - if release_full_validation is not None: - state["releaseFullValidationReceipt"] = release_full_validation["receipt"] - if review_mesh_quorum is not None: - state["reviewMeshFinalQuorum"] = review_mesh_quorum - if lifecycle_control_stop is not None: - state["lifecycleControlStop"] = lifecycle_control_stop + optional_artifacts = ( + ("completionCheckReceipt", completion_check_receipt, "receipt"), + ("completionGateReceipt", completion_gate, "receipt"), + ("goalRecord", goal_record, "record"), + ("followUpRegister", follow_up_register, "register"), + ("proofIntegrityReceipt", proof_integrity, "receipt"), + ("finalImplementationAudit", final_implementation_audit, "audit"), + ("releaseFullValidationReceipt", release_full_validation, "receipt"), + ("reviewMeshFinalQuorum", review_mesh_quorum, None), + ("lifecycleControlStop", lifecycle_control_stop, None), + ) + for state_key, artifact, artifact_key in optional_artifacts: + if artifact is not None: + state[state_key] = artifact if artifact_key is None else artifact[artifact_key] state["phase"] = "COMPLETE" commit_state( state_path, @@ -427,14 +408,6 @@ def finalize_run( return status(state_path) -def _missing_required_acceptance(state: dict[str, Any]) -> list[str]: - return [ - str(task.get("id")) - for task in state.get("tasks", []) - if task.get("required", True) and task.get("status") != "ACCEPTED" - ] - - def _validate_final_audit(state: dict[str, Any], final_audit: dict[str, Any]) -> None: if final_audit.get("schemaVersion") not in {"agent-final-candidate-audit.v1", "agent-run-final-audit.v1"}: raise LifecycleError("invalid-final-audit", "final audit schemaVersion is unsupported") @@ -681,29 +654,27 @@ def _validate_release_full_validation( ) -> dict[str, Any] | None: manifest_path = state.get("manifestPath") if not isinstance(manifest_path, str) or not manifest_path: - if release_full_receipt_path is not None: - raise LifecycleError( - "release-full-validation-not-enabled", - "release-full validation is not enabled for this legacy workflow state", - ) - return None + if release_full_receipt_path is None: + return None + raise LifecycleError( + "release-full-validation-not-enabled", + "release-full validation is not enabled for this legacy workflow state", + ) manifest_rel = normalize_repo_path(manifest_path, label="plan manifest") manifest = read_json_object(root / manifest_rel, label="plan manifest") manifest_validation = manifest.get("validation") - opted_in = isinstance(manifest_validation, dict) and ( - "checkCatalog" in manifest_validation or "validationLadderProfile" in manifest_validation - ) - if not opted_in: - if release_full_receipt_path is not None: - raise LifecycleError( - "release-full-validation-not-enabled", - "release-full validation is not enabled by the frozen manifest", - ) - return None + if not ( + isinstance(manifest_validation, dict) + and ("checkCatalog" in manifest_validation or "validationLadderProfile" in manifest_validation) + ): + if release_full_receipt_path is None: + return None + raise LifecycleError( + "release-full-validation-not-enabled", "release-full validation is not enabled by the frozen manifest" + ) if release_full_receipt_path is None: raise LifecycleError( - "release-full-validation-required", - "the frozen validation ladder requires a fresh release-full receipt", + "release-full-validation-required", "the frozen validation ladder requires a fresh release-full receipt" ) if not isinstance(manifest_validation, dict): raise LifecycleError("release-full-validation-invalid", "manifest validation authority is invalid") @@ -794,11 +765,7 @@ def _proof_body( reason: str, ) -> dict[str, Any]: accepted = [ - { - "id": task.get("id"), - "attempt": task.get("attempt"), - "review": task.get("review"), - } + {"id": task.get("id"), "attempt": task.get("attempt"), "review": task.get("review")} for task in state.get("tasks", []) if task.get("status") == "ACCEPTED" ] From 728e03c34eb5b5fe0b01fbb536f4310313c9604f Mon Sep 17 00:00:00 2001 From: Andrey V Date: Tue, 1 Sep 2026 00:03:14 +0300 Subject: [PATCH 3/3] test(metrics): add v2.11 economics evidence --- .../test_workflow_economics_schemas.py | 19 +++++- .../fixtures/release-2-11-accounting.json | 1 + .../release-2-11-phase-packet-after.json | 1 + .../release-2-11-phase-packet-before.json | 1 + ...ase-2-11-phase-packet-comparison-pair.json | 1 + tests/metrics/test_phase_resources.py | 52 +++++++++++++---- tests/metrics/test_release_accounting.py | 58 ++++++++++++++++++- 7 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 tests/metrics/fixtures/release-2-11-accounting.json create mode 100644 tests/metrics/fixtures/release-2-11-phase-packet-after.json create mode 100644 tests/metrics/fixtures/release-2-11-phase-packet-before.json create mode 100644 tests/metrics/fixtures/release-2-11-phase-packet-comparison-pair.json diff --git a/tests/contracts/test_workflow_economics_schemas.py b/tests/contracts/test_workflow_economics_schemas.py index bbe5f050..08524be5 100644 --- a/tests/contracts/test_workflow_economics_schemas.py +++ b/tests/contracts/test_workflow_economics_schemas.py @@ -1,6 +1,8 @@ from __future__ import annotations +import json import unittest +from pathlib import Path from agent_lifecycle.contracts import canonical_digest from agent_lifecycle.contracts.schemas import get_schema @@ -12,6 +14,19 @@ class WorkflowEconomicsSchemaTests(unittest.TestCase): + def test_release_2_11_tracked_pair_uses_exact_schema_and_unavailable_tokens(self) -> None: + root = Path(__file__).parents[1] / "metrics/fixtures" + declaration = json.loads((root / "release-2-11-phase-packet-comparison-pair.json").read_text(encoding="utf-8")) + before = json.loads((root / "release-2-11-phase-packet-before.json").read_text(encoding="utf-8")) + after = json.loads((root / "release-2-11-phase-packet-after.json").read_text(encoding="utf-8")) + + validation = validate_workflow_economics_comparison(declaration, before, after) + + self.assertEqual(validation["status"], "PASS", validation["blockers"]) + self.assertEqual(before["tokenUsage"], "UNAVAILABLE") + self.assertEqual(after["tokenUsage"], "UNAVAILABLE") + self.assertEqual(after["selectedLevel"], "TASK_FAST") + def test_exact_pair_validates_and_missing_tokens_remain_unavailable(self) -> None: identity = _identity() before = _implementation("before", "a" * 40, "2.8.0") @@ -79,9 +94,7 @@ def test_identity_declaration_and_measurement_digests_are_recomputed(self) -> No ), ( "declaration", - lambda declaration, _before, _after: declaration["after"].__setitem__( - "sourceRevision", "c" * 40 - ), + lambda declaration, _before, _after: declaration["after"].__setitem__("sourceRevision", "c" * 40), "comparison-pair-digest-invalid", ), ( diff --git a/tests/metrics/fixtures/release-2-11-accounting.json b/tests/metrics/fixtures/release-2-11-accounting.json new file mode 100644 index 00000000..e12f9071 --- /dev/null +++ b/tests/metrics/fixtures/release-2-11-accounting.json @@ -0,0 +1 @@ +{"accountingDigest":"a005d07491d27d55a502cf210ef778b1797299fa3e9c510d6714b9bf5195bb72","blockers":[],"categoryTotals":{"coordination":{"entryCount":0,"metrics":{"computeMs":{"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"status":"UNAVAILABLE","value":null},"steps":{"status":"UNAVAILABLE","value":null},"tokens":{"status":"UNAVAILABLE","value":null}}},"implementation":{"entryCount":6,"metrics":{"computeMs":{"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"status":"TIME_WINDOW_ONLY","value":8376000},"steps":{"status":"UNAVAILABLE","value":null},"tokens":{"status":"UNAVAILABLE","value":null}}},"pipelineCompliance":{"entryCount":2,"metrics":{"computeMs":{"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"status":"PARTIAL","value":128000},"steps":{"status":"PARTIAL","value":19},"tokens":{"status":"UNAVAILABLE","value":null}}},"productValidation":{"entryCount":6,"metrics":{"computeMs":{"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"status":"TIME_WINDOW_ONLY","value":7556000},"steps":{"status":"UNAVAILABLE","value":null},"tokens":{"status":"UNAVAILABLE","value":null}}}},"entries":[{"costCategory":"pipelineCompliance","entryId":"workflow-state-transitions-through-cutoff","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"UNAVAILABLE","value":null},"steps":{"additive":true,"status":"MEASURED","value":19},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"alkProcess"},{"costCategory":"pipelineCompliance","entryId":"plan-adoption-to-execution-start-window","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":128000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"alkProcess"},{"costCategory":"implementation","entryId":"ws211-01-attempt-1-implementation","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":1787000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"implementation"},{"costCategory":"implementation","entryId":"ws211-02-attempt-1-implementation","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":2228000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"implementation"},{"costCategory":"implementation","entryId":"ws211-03-attempt-1-through-independent-audit","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":3387000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"implementation"},{"costCategory":"productValidation","entryId":"ws211-01-attempt-1-audit","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":1539000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"audit"},{"costCategory":"productValidation","entryId":"ws211-01-attempt-2-audit","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":824000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"audit"},{"costCategory":"productValidation","entryId":"ws211-01-attempt-3-audit","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":3598000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"audit"},{"costCategory":"productValidation","entryId":"ws211-02-attempt-1-audit","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":891000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"audit"},{"costCategory":"productValidation","entryId":"ws211-02-attempt-2-audit","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":472000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"audit"},{"costCategory":"productValidation","entryId":"ws211-03-pre-accounting-independent-audit","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":232000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"audit"},{"costCategory":"implementation","entryId":"ws211-01-attempt-2-remediation","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":463000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"postAuditRemediation"},{"costCategory":"implementation","entryId":"ws211-01-attempt-3-remediation","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":273000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"postAuditRemediation"},{"costCategory":"implementation","entryId":"ws211-02-attempt-2-remediation","metrics":{"computeMs":{"additive":true,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":true,"status":"TIME_WINDOW_ONLY","value":238000},"steps":{"additive":true,"status":"UNAVAILABLE","value":null},"tokens":{"additive":true,"status":"UNAVAILABLE","value":null}},"scope":{"additive":true,"id":"release-2-11","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"postAuditRemediation"},{"costCategory":"pipelineCompliance","entryId":"post-cutoff-work-after-20260831t200842z","metrics":{"computeMs":{"additive":false,"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"additive":false,"status":"UNAVAILABLE","value":null},"steps":{"additive":false,"status":"UNAVAILABLE","value":null},"tokens":{"additive":false,"status":"UNAVAILABLE","value":null}},"scope":{"additive":false,"id":"release-2-11-after-20260831t200842z","kind":"release-window"},"sourceArtifactDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","view":"alkProcess"}],"entryCount":15,"exclusions":[{"entryId":"post-cutoff-work-after-20260831t200842z","reason":"NON_ADDITIVE_SCOPE"}],"generatedBy":"agent-lifecycle metrics release-accounting","liveCallsStarted":false,"productionPromotionClaimed":false,"provenance":{"confidencePromotionClaimed":false,"identities":{"controllerVersion":{"declared":"2.8.0","observed":["2.8.0"],"status":"MATCHED"},"coreVersion":{"declared":"2.11.0","observed":["2.11.0"],"status":"MATCHED"},"hostPluginVersion":{"declared":"2.8.0","observed":["2.8.0"],"status":"MATCHED"},"measurementDigest":{"declared":"35a83d9f8162937c100a5c3f3b5fca21a5132176d15957c62b6c160ae1d8d863","observed":["35a83d9f8162937c100a5c3f3b5fca21a5132176d15957c62b6c160ae1d8d863"],"status":"MATCHED"},"runAlkVersion":{"declared":"2.11.0","observed":["2.11.0"],"status":"MATCHED"},"runId":{"declared":"release-2-11-run-1","observed":["release-2-11-run-1"],"status":"MATCHED"},"skillPackageVersion":{"declared":"2.8.0","observed":["2.8.0"],"status":"MATCHED"},"sourceRevision":{"declared":"a611ef87abffed6fee9a24301d12e69f3a5af38f","observed":["a611ef87abffed6fee9a24301d12e69f3a5af38f"],"status":"MATCHED"}},"sourceArtifactDigests":["9c695c0de428f8bebd375103077fafdf502115ba54ff360a714ff0e713b31f78"],"status":"REPORTED"},"releaseId":"2.11.0","schemaVersion":"agent-release-accounting.v1","sourceArtifacts":[{"bytes":9288,"path":"work/release-2-11/evidence/release-accounting-source.json","payloadDigest":"61e8c5e20f29373ce1f800cde64e74d6dcb2228cfb842cb77992ce13339ecbca","schemaVersion":"agent-release-accounting-source.v1","sha256":"9c695c0de428f8bebd375103077fafdf502115ba54ff360a714ff0e713b31f78"}],"status":"PASS","totals":{"entryCount":14,"metrics":{"computeMs":{"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"status":"PARTIAL","value":16060000},"steps":{"status":"PARTIAL","value":19},"tokens":{"status":"UNAVAILABLE","value":null}}},"views":{"alkProcess":{"entryCount":2,"metrics":{"computeMs":{"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"status":"PARTIAL","value":128000},"steps":{"status":"PARTIAL","value":19},"tokens":{"status":"UNAVAILABLE","value":null}}},"audit":{"entryCount":6,"metrics":{"computeMs":{"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"status":"TIME_WINDOW_ONLY","value":7556000},"steps":{"status":"UNAVAILABLE","value":null},"tokens":{"status":"UNAVAILABLE","value":null}}},"implementation":{"entryCount":3,"metrics":{"computeMs":{"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"status":"TIME_WINDOW_ONLY","value":7402000},"steps":{"status":"UNAVAILABLE","value":null},"tokens":{"status":"UNAVAILABLE","value":null}}},"postAuditRemediation":{"entryCount":3,"metrics":{"computeMs":{"status":"UNAVAILABLE","value":null},"elapsedWallMs":{"status":"TIME_WINDOW_ONLY","value":974000},"steps":{"status":"UNAVAILABLE","value":null},"tokens":{"status":"UNAVAILABLE","value":null}}}}} diff --git a/tests/metrics/fixtures/release-2-11-phase-packet-after.json b/tests/metrics/fixtures/release-2-11-phase-packet-after.json new file mode 100644 index 00000000..eda81416 --- /dev/null +++ b/tests/metrics/fixtures/release-2-11-phase-packet-after.json @@ -0,0 +1 @@ +{"commandCount":2,"comparisonPairId":"a0688b7008caab94ca378d88d0260ad885fde733e59d115596a71df9db1cec21","implementation":{"coreVersion":"2.11.0","publicationVersions":{"claudeAdapterPlugin":"2.11.0","claudeMarketplaceVersion":"2.11.0","claudeRootPlugin":"2.11.0","codexAdapterPlugin":"2.11.0","codexMarketplaceRef":"2.11.0","codexRootPlugin":"2.11.0","cursorAdapterPlugin":"2.11.0","cursorMarketplaceVersion":"2.11.0","cursorRootPlugin":"2.11.0","module":"2.11.0","pyproject":"2.11.0","uvLock":"2.11.0"},"role":"after","sourceRevision":"1f14cbdb7009f7ad468dfd256bd4503fb2b62dd0"},"measuredAt":"2026-08-31T19:54:27Z","measurementDigest":"35a83d9f8162937c100a5c3f3b5fca21a5132176d15957c62b6c160ae1d8d863","measurementMethod":{"kind":"in-process-deterministic-projection","modelCalls":false,"networkCalls":false,"sampleCount":31,"statistic":"median"},"outputBytes":2105,"packetBytes":1219,"productionPromotionClaimed":false,"releaseFullFallbackRate":0.0,"returnedToolOutputBytes":2105,"role":"after","schemaVersion":"agent-workflow-economics-measurement.v1","selectedLevel":"TASK_FAST","selectionMode":"digest-bound-path-selection","tokenUsage":"UNAVAILABLE","validationWallSeconds":0.00017120805568993092,"wallSeconds":0.00017120805568993092,"workloadIdentityDigest":"92a15886dff4dc707403e901f01f210d3e69678589d761bc32915b499ae181c9"} diff --git a/tests/metrics/fixtures/release-2-11-phase-packet-before.json b/tests/metrics/fixtures/release-2-11-phase-packet-before.json new file mode 100644 index 00000000..d6b04db1 --- /dev/null +++ b/tests/metrics/fixtures/release-2-11-phase-packet-before.json @@ -0,0 +1 @@ +{"commandCount":2,"comparisonPairId":"a0688b7008caab94ca378d88d0260ad885fde733e59d115596a71df9db1cec21","implementation":{"coreVersion":"2.10.0","publicationVersions":{"claudeAdapterPlugin":"2.10.0","claudeMarketplaceVersion":"2.10.0","claudeRootPlugin":"2.10.0","codexAdapterPlugin":"2.10.0","codexMarketplaceRef":"2.10.0","codexRootPlugin":"2.10.0","cursorAdapterPlugin":"2.10.0","cursorMarketplaceVersion":"2.10.0","cursorRootPlugin":"2.10.0","module":"2.10.0","pyproject":"2.10.0","uvLock":"2.10.0"},"role":"before","sourceRevision":"805d8000ac3b7adb47e3b4c1bcdf5ef2ab897b38"},"measuredAt":"2026-08-31T19:54:26Z","measurementDigest":"d13789026f0e4175ebfaacb9b653fde7f4fe9d30c7883156ebb3956fd93d76f4","measurementMethod":{"kind":"in-process-deterministic-projection","modelCalls":false,"networkCalls":false,"sampleCount":31,"statistic":"median"},"outputBytes":1526,"packetBytes":2140,"productionPromotionClaimed":false,"releaseFullFallbackRate":1.0,"returnedToolOutputBytes":1526,"role":"before","schemaVersion":"agent-workflow-economics-measurement.v1","selectionMode":"legacy-unconditional-release-full","tokenUsage":"UNAVAILABLE","validationWallSeconds":1.125037670135498e-06,"wallSeconds":1.125037670135498e-06,"workloadIdentityDigest":"92a15886dff4dc707403e901f01f210d3e69678589d761bc32915b499ae181c9"} diff --git a/tests/metrics/fixtures/release-2-11-phase-packet-comparison-pair.json b/tests/metrics/fixtures/release-2-11-phase-packet-comparison-pair.json new file mode 100644 index 00000000..6cea6bde --- /dev/null +++ b/tests/metrics/fixtures/release-2-11-phase-packet-comparison-pair.json @@ -0,0 +1 @@ +{"after":{"coreVersion":"2.11.0","publicationVersions":{"claudeAdapterPlugin":"2.11.0","claudeMarketplaceVersion":"2.11.0","claudeRootPlugin":"2.11.0","codexAdapterPlugin":"2.11.0","codexMarketplaceRef":"2.11.0","codexRootPlugin":"2.11.0","cursorAdapterPlugin":"2.11.0","cursorMarketplaceVersion":"2.11.0","cursorRootPlugin":"2.11.0","module":"2.11.0","pyproject":"2.11.0","uvLock":"2.11.0"},"role":"after","sourceRevision":"1f14cbdb7009f7ad468dfd256bd4503fb2b62dd0"},"before":{"coreVersion":"2.10.0","publicationVersions":{"claudeAdapterPlugin":"2.10.0","claudeMarketplaceVersion":"2.10.0","claudeRootPlugin":"2.10.0","codexAdapterPlugin":"2.10.0","codexMarketplaceRef":"2.10.0","codexRootPlugin":"2.10.0","cursorAdapterPlugin":"2.10.0","cursorMarketplaceVersion":"2.10.0","cursorRootPlugin":"2.10.0","module":"2.10.0","pyproject":"2.10.0","uvLock":"2.10.0"},"role":"before","sourceRevision":"805d8000ac3b7adb47e3b4c1bcdf5ef2ab897b38"},"comparisonPairId":"a0688b7008caab94ca378d88d0260ad885fde733e59d115596a71df9db1cec21","declaredAt":"2026-08-31T19:54:25Z","declaredBeforeMeasurements":true,"productionPromotionClaimed":false,"schemaVersion":"agent-workflow-economics-comparison-pair.v1","status":"DECLARED","workloadIdentity":{"environmentDigest":"2e6bfad41b2116e6a9b8add29ffa0df15cbb76f0a427e736c688fefd2adc54b4","fixtureShapeDigest":"3e2749e50cdc4aecb73b7eda85c1c261b9e12703a07f81f24943e6435d26776e","name":"phase-packet-validation-selection-v1","requiredGateFloorDigest":"243d09ebb5974042cbc518f304b55a72c9bcf1dc15c1260975118c1ac70d081d","schemaVersion":"agent-comparable-workload-identity.v1","workloadIdentityDigest":"92a15886dff4dc707403e901f01f210d3e69678589d761bc32915b499ae181c9","workloadInputDigest":"d154810a86652a48b0519efad5fc04dae08e5cecd401d75905b994dfa4e9e038"}} diff --git a/tests/metrics/test_phase_resources.py b/tests/metrics/test_phase_resources.py index ee501026..bd9334da 100644 --- a/tests/metrics/test_phase_resources.py +++ b/tests/metrics/test_phase_resources.py @@ -3,6 +3,7 @@ import json import unittest from copy import deepcopy +from hashlib import sha256 from pathlib import Path from agent_lifecycle.contracts import LifecycleError, canonical_digest @@ -17,6 +18,41 @@ class PhaseResourceTests(unittest.TestCase): + def test_release_2_11_phase_packet_pair_is_predeclared_and_reports_all_deltas(self) -> None: + root = Path(__file__).parent / "fixtures" + declaration = _load(root / "release-2-11-phase-packet-comparison-pair.json") + before = _load(root / "release-2-11-phase-packet-before.json") + after = _load(root / "release-2-11-phase-packet-after.json") + + validation = validate_workflow_economics_comparison(declaration, before, after) + + self.assertEqual(validation["status"], "PASS", validation["blockers"]) + self.assertEqual(declaration["workloadIdentity"]["name"], "phase-packet-validation-selection-v1") + self.assertLess(declaration["declaredAt"], before["measuredAt"]) + self.assertLess(declaration["declaredAt"], after["measuredAt"]) + self.assertEqual(declaration["before"]["sourceRevision"], "805d8000ac3b7adb47e3b4c1bcdf5ef2ab897b38") + self.assertEqual(declaration["after"]["sourceRevision"], "1f14cbdb7009f7ad468dfd256bd4503fb2b62dd0") + self.assertLess(after["packetBytes"], before["packetBytes"]) + self.assertGreater(after["returnedToolOutputBytes"], before["returnedToolOutputBytes"]) + self.assertGreater(after["validationWallSeconds"], before["validationWallSeconds"]) + self.assertEqual(before["releaseFullFallbackRate"], 1.0) + self.assertEqual(after["releaseFullFallbackRate"], 0.0) + self.assertEqual(after["selectedLevel"], "TASK_FAST") + self.assertEqual(before["tokenUsage"], "UNAVAILABLE") + self.assertEqual(after["tokenUsage"], "UNAVAILABLE") + + def test_release_2_10_economics_files_remain_byte_identical(self) -> None: + root = Path(__file__).parent / "fixtures" + expected = { + "release-2-10-continuation-comparison-pair.json": "53ef59560fc136d67c164b41db2d5d67491b7939ecb2627f561a9fa9c34cf70a", + "release-2-8-continuation-baseline.json": "1ff9b4e4a6d391541fec6a2ace773d18bc5b6297ebb9ebdc20aeac7d99ecf6ce", + "release-2-10-continuation-baseline.json": "0c601b01cbda7efbe66259e7694f7b8b4505f6d9d135b931004315f6639a51b1", + } + + for name, digest in expected.items(): + with self.subTest(name=name): + self.assertEqual(sha256((root / name).read_bytes()).hexdigest(), digest) + def test_release_2_10_economics_pair_is_predeclared_exact_and_more_compact(self) -> None: root = Path(__file__).parent / "fixtures" declaration = _load(root / "release-2-10-continuation-comparison-pair.json") @@ -196,34 +232,26 @@ def _refresh_digest(payload: dict[str, object], field: str) -> None: payload[field] = canonical_digest({key: value for key, value in payload.items() if key != field}) -def _mutate_role( - _declaration: dict[str, object], _before: dict[str, object], after: dict[str, object] -) -> None: +def _mutate_role(_declaration: dict[str, object], _before: dict[str, object], after: dict[str, object]) -> None: after["role"] = "before" _refresh_digest(after, "measurementDigest") -def _mutate_source( - _declaration: dict[str, object], _before: dict[str, object], after: dict[str, object] -) -> None: +def _mutate_source(_declaration: dict[str, object], _before: dict[str, object], after: dict[str, object]) -> None: implementation = after["implementation"] assert isinstance(implementation, dict) implementation["sourceRevision"] = "f" * 40 _refresh_digest(after, "measurementDigest") -def _mutate_version( - _declaration: dict[str, object], _before: dict[str, object], after: dict[str, object] -) -> None: +def _mutate_version(_declaration: dict[str, object], _before: dict[str, object], after: dict[str, object]) -> None: implementation = after["implementation"] assert isinstance(implementation, dict) implementation["coreVersion"] = "2.10.1" _refresh_digest(after, "measurementDigest") -def _mutate_gate_floor( - declaration: dict[str, object], _before: dict[str, object], _after: dict[str, object] -) -> None: +def _mutate_gate_floor(declaration: dict[str, object], _before: dict[str, object], _after: dict[str, object]) -> None: identity = declaration["workloadIdentity"] assert isinstance(identity, dict) identity["requiredGateFloorDigest"] = "f" * 64 diff --git a/tests/metrics/test_release_accounting.py b/tests/metrics/test_release_accounting.py index f32169b4..dd228acb 100644 --- a/tests/metrics/test_release_accounting.py +++ b/tests/metrics/test_release_accounting.py @@ -16,6 +16,60 @@ class ReleaseAccountingTests(unittest.TestCase): + def test_release_2_11_fixture_separates_workflow_implementation_audit_and_remediation(self) -> None: + fixture_path = Path(__file__).parent / "fixtures/release-2-11-accounting.json" + accounting = json.loads(fixture_path.read_text(encoding="utf-8")) + + self.assertEqual(validate_release_accounting(accounting)["status"], "PASS") + self.assertEqual(accounting["releaseId"], "2.11.0") + expected_wall = { + "alkProcess": ("PARTIAL", 128_000), + "implementation": ("TIME_WINDOW_ONLY", 7_402_000), + "audit": ("TIME_WINDOW_ONLY", 7_556_000), + "postAuditRemediation": ("TIME_WINDOW_ONLY", 974_000), + } + for view, (status, value) in expected_wall.items(): + with self.subTest(view=view): + self.assertEqual( + accounting["views"][view]["metrics"]["elapsedWallMs"], + {"status": status, "value": value}, + ) + self.assertEqual( + accounting["views"][view]["metrics"]["tokens"], + {"status": "UNAVAILABLE", "value": None}, + ) + self.assertEqual(accounting["views"]["alkProcess"]["metrics"]["steps"]["value"], 19) + self.assertEqual( + accounting["exclusions"], + [ + { + "entryId": "post-cutoff-work-after-20260831t200842z", + "reason": "NON_ADDITIVE_SCOPE", + } + ], + ) + self.assertEqual( + accounting["provenance"]["identities"]["sourceRevision"]["declared"], + "a611ef87abffed6fee9a24301d12e69f3a5af38f", + ) + self.assertEqual(accounting["provenance"]["identities"]["controllerVersion"]["declared"], "2.8.0") + self.assertEqual(accounting["provenance"]["identities"]["coreVersion"]["declared"], "2.11.0") + phase_packet_after = json.loads( + (fixture_path.parent / "release-2-11-phase-packet-after.json").read_text(encoding="utf-8") + ) + self.assertEqual( + accounting["provenance"]["identities"]["measurementDigest"]["declared"], + phase_packet_after["measurementDigest"], + ) + self.assertTrue( + all(identity["status"] == "MATCHED" for identity in accounting["provenance"]["identities"].values()) + ) + self.assertEqual( + accounting["accountingDigest"], + "a005d07491d27d55a502cf210ef778b1797299fa3e9c510d6714b9bf5195bb72", + ) + self.assertFalse(accounting["productionPromotionClaimed"]) + def test_release_2_10_fixture_separates_windows_and_preserves_unavailable_tokens(self) -> None: fixture_path = Path(__file__).parent / "fixtures/release-2-10-accounting.json" accounting = json.loads(fixture_path.read_text(encoding="utf-8")) @@ -362,9 +416,7 @@ def test_recomputed_digest_cannot_hide_duplicate_source_artifact(self) -> None: accounting = build_release_accounting("2.6.0", [Path("source.json")], project_root=root) accounting["sourceArtifacts"].append(dict(accounting["sourceArtifacts"][0])) - accounting["provenance"]["sourceArtifactDigests"].append( - accounting["sourceArtifacts"][0]["sha256"] - ) + accounting["provenance"]["sourceArtifactDigests"].append(accounting["sourceArtifacts"][0]["sha256"]) accounting["accountingDigest"] = canonical_digest( {key: value for key, value in accounting.items() if key != "accountingDigest"} )