diff --git a/CHANGELOG.md b/CHANGELOG.md index c7cf232..8b95309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v0.8.1 (2026-06-29) + +Contract hardening and verifiable pilot fixtures: + +- **Summary contract split**: `scope_akta_review_summary.schema.json` (status `completed` only) and `scope_akta_review_session_summary.schema.json` (status `session_required` only); consumers branch on `summary.status`; `validate_summary_artifact()` in `scope/akta_review.py` +- **Conditional session grant provenance**: `scope_grant.schema.json` if/then requires full session provenance block when `contributing_identity_assurance_levels` present; runtime check in `scope/session_provenance.py` +- **Reviewer-ID binding**: centralized `resolve_reviewer_id()` used by CLI, REST, and Python API +- **Verifiable pilot fixture pack**: `manifest.json` and `expected_verification.json` per scenario; `scripts/verify_pilot_fixtures.py` for offline validation +- AKTA review contract bumped to `scope-akta-review-v0.8.1` (incompatible summary schema split) + ## v0.8.0 (2026-06-29) Session workflow and provenance release: diff --git a/README.md b/README.md index bd8b5e3..8e008cb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ **Scoped authorization for AI-shaped scientific work** -[![Version](https://img.shields.io/badge/version-0.8.0-blue)](https://github.com/fraware/SCOPE/releases) +[![Version](https://img.shields.io/badge/version-0.8.1-blue)](https://github.com/fraware/SCOPE/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) [![CI](https://github.com/fraware/SCOPE/actions/workflows/ci.yml/badge.svg)](https://github.com/fraware/SCOPE/actions/workflows/ci.yml) diff --git a/docs/akta_review_contract.md b/docs/akta_review_contract.md index 9edc47c..911897d 100644 --- a/docs/akta_review_contract.md +++ b/docs/akta_review_contract.md @@ -1,37 +1,49 @@ # AKTA Review Output Contract -SCOPE v0.8 freezes the `scope akta review` output contract under `out_dir/`. +SCOPE v0.8.1 splits the `scope akta review` output contract by `summary.status`. + +## Branching rule + +Consumers **must** branch on `summary.status`: + +| `summary.status` | Schema | Artifacts | +|------------------|--------|-----------| +| `completed` | `schemas/scope_akta_review_summary.schema.json` | packet, decision, grant, summary | +| `session_required` | `schemas/scope_akta_review_session_summary.schema.json` | packet, summary only | + +The two schemas are mutually exclusive: completed summaries cannot carry session fields (`session_id`, `required_roles`, `message`); session summaries cannot carry grant/decision fields (`decision_path`, `grant_path`, `approved_scope`, IAL/SAL, etc.). + +Contract version constant: `scope-akta-review-v0.8.1` (`scope.integration_versions.AKTA_REVIEW_CONTRACT_VERSION`). ## Artifacts | File | Description | |------|-------------| | `scope_review_packet.json` | Review packet | -| `scope_decision.json` | Signed or unsigned decision (completed path only) | -| `scope_grant.json` | Issued grant (completed path only) | -| `summary.json` | Adapter summary (validated against schema) | +| `scope_decision.json` | Signed or unsigned decision (`completed` only) | +| `scope_grant.json` | Issued grant (`completed` only) | +| `summary.json` | Adapter summary (schema selected by `status`) | ## summary.json contract (completed review) -Contract version constant: `scope-akta-review-v0.8` (`scope.integration_versions.AKTA_REVIEW_CONTRACT_VERSION`). - Required fields: ```json { + "status": "completed", "packet_path": "...", "decision_path": "...", "grant_path": "...", "approved_scope": "...", "requested_scope": "...", - "adapter_contract_version": "scope-akta-review-v0.8", + "adapter_contract_version": "scope-akta-review-v0.8.1", "identity_assurance_level": "IAL0", "signing_assurance_level": "SAL1", "production_mode": false } ``` -Optional fields: `status`, `packet_id`, `decision_id`, `grant_id`, `allowed_tools`, `blocked_tools`, `decision_type`, `scope_trust_root_hash`, `queue_id`. +Optional fields: `packet_id`, `decision_id`, `grant_id`, `allowed_tools`, `blocked_tools`, `decision_type`, `scope_trust_root_hash`, `queue_id`. Schema: `schemas/scope_akta_review_summary.schema.json` @@ -48,7 +60,7 @@ Required fields: "session_id": "SCOPE-SESS-...", "required_roles": ["domain_scientist", "protocol_owner"], "message": "Multi-role review session created; submit votes before grant issue.", - "adapter_contract_version": "scope-akta-review-v0.8", + "adapter_contract_version": "scope-akta-review-v0.8.1", "production_mode": false } ``` @@ -59,6 +71,8 @@ Schema: `schemas/scope_akta_review_session_summary.schema.json` Without `--session`, multi-role packets fail with an explicit error directing operators to re-run with `--session` or use `scope review session create`. +Runtime validation: `scope.akta_review.validate_summary_artifact(summary)` selects the schema from `summary.status`. + ## Session grant provenance When a grant is issued from a multi-reviewer session (`issue_grant_from_session`), provenance includes aggregated session fields: @@ -72,17 +86,24 @@ When a grant is issued from a multi-reviewer session (`issue_grant_from_session` | `veto_roles_applied` | Safety veto roles from quorum policy | | `quorum_policy_hash` | Digest of session quorum policy | -These fields are optional in `schemas/scope_grant.schema.json` (present only on session grants). Single-reviewer grants omit them. +When `contributing_identity_assurance_levels` is present, `schemas/scope_grant.schema.json` requires all session provenance fields. Single-reviewer grants omit them entirely. + +Runtime double-check: `scope.session_provenance.validate_session_grant_provenance`. ## Reviewer ID binding -When `--signing-provider registry` is used, pass `--reviewer-id` to bind the registry lookup. If provided, it must match `reviewer.json` `reviewer_id`; mismatch fails before signing. +All entry points (CLI, REST, Python API) call `scope.akta_review.resolve_reviewer_id()` before signing: + +- When `--signing-provider registry` is used, pass `--reviewer-id` to bind the registry lookup. +- If provided, it must match `reviewer.json` `reviewer_id`; mismatch fails before signing. +- Registry signing cannot proceed without an explicit, validated reviewer identity. ## Acceptance criteria `scope akta review` enforces: -- Summary validates against schema (completed or session mode) +- Summary validates against the schema for its `status` branch +- Completed and session summaries cannot be confused - Overbroad approval fails (approved scope stronger than requested) - Unsigned production grant fails without signing key/provider - Missing reviewer authority fails (two-stage RBAC + SCOPE policy) diff --git a/docs/akta_scope_demo.md b/docs/akta_scope_demo.md index 6aa5bb5..a65ff1e 100644 --- a/docs/akta_scope_demo.md +++ b/docs/akta_scope_demo.md @@ -27,7 +27,7 @@ Outputs in `/tmp/akta_review_out/`: | `scope_grant.json` | Bounded authorization grant | | `summary.json` | Machine-readable contract summary (validated against schema) | -`summary.json` includes `adapter_contract_version` (`scope-akta-review-v0.7`), `identity_assurance_level`, `signing_assurance_level`, and `production_mode`. +`summary.json` includes `adapter_contract_version` (`scope-akta-review-v0.8.1`), branches on `status` (`completed` or `session_required`), and records `identity_assurance_level`, `signing_assurance_level`, and `production_mode` on completed summaries. SCOPE rejects overbroad `--grant-scope` values against the packet's `requested_scope`. diff --git a/docs/external_integration_contracts.md b/docs/external_integration_contracts.md index 2aa67b7..5b8acb9 100644 --- a/docs/external_integration_contracts.md +++ b/docs/external_integration_contracts.md @@ -56,11 +56,18 @@ Evidence vocabulary mapping: [evidence_vocab_mapping.md](evidence_vocab_mapping. | File | Description | |------|-------------| | `scope_review_packet.json` | Review packet | -| `scope_decision.json` | Decision (signed in production mode) | -| `scope_grant.json` | Issued grant | -| `summary.json` | Adapter summary validated against `schemas/scope_akta_review_summary.schema.json` | +| `scope_decision.json` | Decision (signed in production mode; `completed` only) | +| `scope_grant.json` | Issued grant (`completed` only) | +| `summary.json` | Adapter summary; schema selected by `summary.status` | -Contract version: `scope-akta-review-v0.7`. Required `summary.json` fields include `adapter_contract_version`, `identity_assurance_level`, `signing_assurance_level`, and `production_mode`. +Contract version: `scope-akta-review-v0.8.1`. Branch on `summary.status`: + +| `summary.status` | Schema | +|------------------|--------| +| `completed` | `schemas/scope_akta_review_summary.schema.json` (paths, IAL/SAL, approved scope) | +| `session_required` | `schemas/scope_akta_review_session_summary.schema.json` (session_id, required_roles; no decision/grant artifacts) | + +Runtime validation: `scope.akta_review.validate_summary_artifact(summary)`. Full contract: [akta_review_contract.md](akta_review_contract.md). diff --git a/docs/limitations.md b/docs/limitations.md index 9b408f8..7f3e973 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -1,12 +1,21 @@ # Limitations +## Implemented in v0.8.1 + +- Summary contract split: `completed` vs `session_required` schemas; consumers branch on `summary.status` +- `validate_summary_artifact()` on all AKTA review write paths (CLI, REST, Python API) +- Conditional session grant provenance: schema if/then plus runtime check in `scope/session_provenance.py` +- Centralized `resolve_reviewer_id()` for CLI, REST, and Python API +- Verifiable pilot fixtures: per-scenario `manifest.json`, `expected_verification.json`, and `scripts/verify_pilot_fixtures.py` +- AKTA review contract `scope-akta-review-v0.8.1` (incompatible summary schema split; supersedes v0.8.0 contract shape) + ## Implemented in v0.8 - `scope akta review --session` for multi-role packets: session summary schema, explicit failure without `--session` - `--reviewer-id` binding for registry signing (must match reviewer artifact) - Session grant provenance aggregation: contributing IAL/SAL, authority checks, veto roles, quorum policy hash - Pilot fixture pack under `examples/pilot/` (five institutional scenarios) -- Policy bundle `scope-core-v0.8`; AKTA review contract `scope-akta-review-v0.8` +- Policy bundle `scope-core-v0.8` ## Implemented in v0.7 diff --git a/examples/pilot/README.md b/examples/pilot/README.md index b795f00..8c980be 100644 --- a/examples/pilot/README.md +++ b/examples/pilot/README.md @@ -1,6 +1,6 @@ -# SCOPE Pilot Fixture Pack (v0.8) +# SCOPE Pilot Fixture Pack (v0.8.1) -Institutional pilot scenarios generated with SCOPE v0.8 CLI and engine APIs. Each subdirectory is self-contained with review artifacts, queue state where applicable, rendered packet markdown, and a quality report snippet. +Institutional pilot scenarios generated with SCOPE v0.8.1 CLI and engine APIs. Each subdirectory is self-contained with review artifacts, queue state where applicable, rendered packet markdown, and a quality report snippet. | Scenario | Directory | Highlights | |----------|-----------|------------| @@ -10,4 +10,10 @@ Institutional pilot scenarios generated with SCOPE v0.8 CLI and engine APIs. Eac | Needs information flow | [needs_information_flow](needs_information_flow/) | `needs_information` to `in_review` | | Registry-signed decision | [registry_signed_decision](registry_signed_decision/) | `--signing-provider registry --reviewer-id` | -Policy bundle: `scope-core-v0.8`. AKTA review contract: `scope-akta-review-v0.8`. +Policy bundle: `scope-core-v0.8`. AKTA review contract: `scope-akta-review-v0.8.1`. + +Each scenario includes `manifest.json` (artifact inventory and schema versions) and `expected_verification.json` (checksums, status values, queue states). Verify offline: + +```bash +python scripts/verify_pilot_fixtures.py +``` diff --git a/examples/pilot/expired_queue_reopen/expected_verification.json b/examples/pilot/expired_queue_reopen/expected_verification.json new file mode 100644 index 0000000..3304632 --- /dev/null +++ b/examples/pilot/expired_queue_reopen/expected_verification.json @@ -0,0 +1,21 @@ +{ + "checksums": { + "quality_report_snippet.json": "sha256:a6906f08514b0fe2b9e20ff574a388ab6e4298ee627e3c4d8b5313ad94fff778", + "review_queue_expired.json": "sha256:84e4a0b4b1bae21342a03f5beda110d0659cd4b3293cbf5ad7f5ea90c6c8e123", + "review_queue_reopened.json": "sha256:f1a30e848026ca360bcaa0cc5983cb4677a74a04b549967e4a67ad9a913cbfae", + "scope_review_packet.json": "sha256:0d575e95909dad39bd40df93d336acc140495ba812be5aaaec4277359772b4d1" + }, + "quality_report_snippet": { + "policy_version": "scope-core-v0.8" + }, + "queue_states": { + "review_queue_expired.json": { + "packet_id": "SCOPE-PKT-2F8C44", + "status": "expired" + }, + "review_queue_reopened.json": { + "packet_id": "SCOPE-PKT-2F8C44", + "status": "open" + } + } +} diff --git a/examples/pilot/expired_queue_reopen/manifest.json b/examples/pilot/expired_queue_reopen/manifest.json new file mode 100644 index 0000000..cc9eb12 --- /dev/null +++ b/examples/pilot/expired_queue_reopen/manifest.json @@ -0,0 +1,22 @@ +{ + "scenario": "expired_queue_reopen", + "scope_version": "0.8.1", + "policy_version": "scope-core-v0.8", + "artifacts": [ + { + "path": "scope_review_packet.json", + "schema": "scope_packet.schema.json" + }, + { + "path": "review_queue_expired.json", + "schema": "scope_review_queue.schema.json" + }, + { + "path": "review_queue_reopened.json", + "schema": "scope_review_queue.schema.json" + }, + { + "path": "quality_report_snippet.json" + } + ] +} diff --git a/examples/pilot/multi_role_genomics_review/expected_verification.json b/examples/pilot/multi_role_genomics_review/expected_verification.json new file mode 100644 index 0000000..1654c82 --- /dev/null +++ b/examples/pilot/multi_role_genomics_review/expected_verification.json @@ -0,0 +1,17 @@ +{ + "checksums": { + "quality_report_snippet.json": "sha256:c949d9e671baa36c95d6c8bb20fb0f30e571173835dff2bc269a4c9d95da8051", + "scope_review_packet.json": "sha256:654292d3b72dfef1fbd5aa38ed191abdfdab4b3a7244255226fa8e74fb9752ac", + "summary.json": "sha256:9061c4c9d4e517d5ed60ce562fcdbe2bb0fc2d98143ad3d3b9826e090dbda9b3" + }, + "forbidden_files": [ + "scope_grant.json" + ], + "quality_report_snippet": { + "policy_version": "scope-core-v0.8" + }, + "summary": { + "adapter_contract_version": "scope-akta-review-v0.8.1", + "status": "session_required" + } +} diff --git a/examples/pilot/multi_role_genomics_review/manifest.json b/examples/pilot/multi_role_genomics_review/manifest.json new file mode 100644 index 0000000..50bef16 --- /dev/null +++ b/examples/pilot/multi_role_genomics_review/manifest.json @@ -0,0 +1,25 @@ +{ + "scenario": "multi_role_genomics_review", + "scope_version": "0.8.1", + "policy_version": "scope-core-v0.8", + "akta_review_contract_version": "scope-akta-review-v0.8.1", + "artifacts": [ + { + "path": "scope_review_packet.json", + "schema": "scope_packet.schema.json" + }, + { + "path": "summary.json", + "schema": "scope_akta_review_session_summary.schema.json" + }, + { + "path": "decision_protocol_owner.json" + }, + { + "path": "decision_domain_scientist.json" + }, + { + "path": "quality_report_snippet.json" + } + ] +} diff --git a/examples/pilot/multi_role_genomics_review/summary.json b/examples/pilot/multi_role_genomics_review/summary.json index 643da03..62d427e 100644 --- a/examples/pilot/multi_role_genomics_review/summary.json +++ b/examples/pilot/multi_role_genomics_review/summary.json @@ -1,5 +1,5 @@ { - "adapter_contract_version": "scope-akta-review-v0.8", + "adapter_contract_version": "scope-akta-review-v0.8.1", "message": "Multi-role review session created; submit votes before grant issue.", "packet_id": "SCOPE-PKT-42A16F", "packet_path": "examples/pilot/multi_role_genomics_review/scope_review_packet.json", diff --git a/examples/pilot/needs_information_flow/expected_verification.json b/examples/pilot/needs_information_flow/expected_verification.json new file mode 100644 index 0000000..c001a14 --- /dev/null +++ b/examples/pilot/needs_information_flow/expected_verification.json @@ -0,0 +1,21 @@ +{ + "checksums": { + "quality_report_snippet.json": "sha256:a6906f08514b0fe2b9e20ff574a388ab6e4298ee627e3c4d8b5313ad94fff778", + "review_queue_in_review.json": "sha256:a9dd176ba8d1dbe31e904e662a48d42875437136874f16807beb70d098068e14", + "review_queue_needs_information.json": "sha256:287003adb074f06d01f5f76d9c00255ebcf2e10a4a34ab77453011817d75b871", + "scope_review_packet.json": "sha256:fa8aa72047c2bbc29ec2e8b214ac2dfc8ee8b97c080a2286321f746496018960" + }, + "quality_report_snippet": { + "policy_version": "scope-core-v0.8" + }, + "queue_states": { + "review_queue_in_review.json": { + "packet_id": "SCOPE-PKT-92CBAD", + "status": "in_review" + }, + "review_queue_needs_information.json": { + "packet_id": "SCOPE-PKT-92CBAD", + "status": "needs_information" + } + } +} diff --git a/examples/pilot/needs_information_flow/manifest.json b/examples/pilot/needs_information_flow/manifest.json new file mode 100644 index 0000000..67b6abf --- /dev/null +++ b/examples/pilot/needs_information_flow/manifest.json @@ -0,0 +1,22 @@ +{ + "scenario": "needs_information_flow", + "scope_version": "0.8.1", + "policy_version": "scope-core-v0.8", + "artifacts": [ + { + "path": "scope_review_packet.json", + "schema": "scope_packet.schema.json" + }, + { + "path": "review_queue_needs_information.json", + "schema": "scope_review_queue.schema.json" + }, + { + "path": "review_queue_in_review.json", + "schema": "scope_review_queue.schema.json" + }, + { + "path": "quality_report_snippet.json" + } + ] +} diff --git a/examples/pilot/registry_signed_decision/expected_verification.json b/examples/pilot/registry_signed_decision/expected_verification.json new file mode 100644 index 0000000..440d9ac --- /dev/null +++ b/examples/pilot/registry_signed_decision/expected_verification.json @@ -0,0 +1,17 @@ +{ + "checksums": { + "quality_report_snippet.json": "sha256:a6906f08514b0fe2b9e20ff574a388ab6e4298ee627e3c4d8b5313ad94fff778", + "scope_decision.json": "sha256:1627726d073cff8ff14ac149e8ed9be94e3b2a5f55596885925ea4f77e2a74e9", + "scope_grant.json": "sha256:cafe5cccb559769e20c3a5d1ef0242f3f388db66ce80161cffe6a01b5d3bde1c", + "scope_review_packet.json": "sha256:84eba11c3b0632906d74334b4c93b7a6630ddb7ed65441d22f25c14eb2ea1f8e", + "summary.json": "sha256:8b06e3c0832d779ce361ca91e8720e455b7dd291c4fafda0505896920d072132" + }, + "quality_report_snippet": { + "policy_version": "scope-core-v0.8" + }, + "summary": { + "adapter_contract_version": "scope-akta-review-v0.8.1", + "approved_scope": "protocol_draft", + "status": "completed" + } +} diff --git a/examples/pilot/registry_signed_decision/manifest.json b/examples/pilot/registry_signed_decision/manifest.json new file mode 100644 index 0000000..6104f71 --- /dev/null +++ b/examples/pilot/registry_signed_decision/manifest.json @@ -0,0 +1,27 @@ +{ + "scenario": "registry_signed_decision", + "scope_version": "0.8.1", + "policy_version": "scope-core-v0.8", + "akta_review_contract_version": "scope-akta-review-v0.8.1", + "artifacts": [ + { + "path": "scope_review_packet.json", + "schema": "scope_packet.schema.json" + }, + { + "path": "scope_decision.json", + "schema": "scope_decision.schema.json" + }, + { + "path": "scope_grant.json", + "schema": "scope_grant.schema.json" + }, + { + "path": "summary.json", + "schema": "scope_akta_review_summary.schema.json" + }, + { + "path": "quality_report_snippet.json" + } + ] +} diff --git a/examples/pilot/registry_signed_decision/summary.json b/examples/pilot/registry_signed_decision/summary.json index 6ec1ef0..071f9e0 100644 --- a/examples/pilot/registry_signed_decision/summary.json +++ b/examples/pilot/registry_signed_decision/summary.json @@ -1,5 +1,5 @@ { - "adapter_contract_version": "scope-akta-review-v0.8", + "adapter_contract_version": "scope-akta-review-v0.8.1", "allowed_tools": [ "protocol_editor.draft_change" ], diff --git a/examples/pilot/single_reviewer_protocol_draft/expected_verification.json b/examples/pilot/single_reviewer_protocol_draft/expected_verification.json new file mode 100644 index 0000000..64ecdbc --- /dev/null +++ b/examples/pilot/single_reviewer_protocol_draft/expected_verification.json @@ -0,0 +1,18 @@ +{ + "checksums": { + "quality_report_snippet.json": "sha256:a6906f08514b0fe2b9e20ff574a388ab6e4298ee627e3c4d8b5313ad94fff778", + "scope_decision.json": "sha256:720fb6762fbb00e467a3709e6c17ffa4beaa6cd5ad80245d0093b9f2b0305bd5", + "scope_grant.json": "sha256:88414a99bd54bf18697db5002954c4c19dd589ee2683a7026aca75b5e13adf16", + "scope_review_packet.json": "sha256:26ea5cbbedb2ce606fd892a23e97df8528b53fd6a17ca48708f2943914066d7e", + "summary.json": "sha256:deecbf7bc3b462af97bebe1a522e9f6312291077edc5a624cdc450156470b47e" + }, + "forbidden_files": [], + "quality_report_snippet": { + "policy_version": "scope-core-v0.8" + }, + "summary": { + "adapter_contract_version": "scope-akta-review-v0.8.1", + "approved_scope": "protocol_draft", + "status": "completed" + } +} diff --git a/examples/pilot/single_reviewer_protocol_draft/manifest.json b/examples/pilot/single_reviewer_protocol_draft/manifest.json new file mode 100644 index 0000000..15e911b --- /dev/null +++ b/examples/pilot/single_reviewer_protocol_draft/manifest.json @@ -0,0 +1,27 @@ +{ + "scenario": "single_reviewer_protocol_draft", + "scope_version": "0.8.1", + "policy_version": "scope-core-v0.8", + "akta_review_contract_version": "scope-akta-review-v0.8.1", + "artifacts": [ + { + "path": "scope_review_packet.json", + "schema": "scope_packet.schema.json" + }, + { + "path": "scope_decision.json", + "schema": "scope_decision.schema.json" + }, + { + "path": "scope_grant.json", + "schema": "scope_grant.schema.json" + }, + { + "path": "summary.json", + "schema": "scope_akta_review_summary.schema.json" + }, + { + "path": "quality_report_snippet.json" + } + ] +} diff --git a/examples/pilot/single_reviewer_protocol_draft/summary.json b/examples/pilot/single_reviewer_protocol_draft/summary.json index 2a1f20f..c98a2ef 100644 --- a/examples/pilot/single_reviewer_protocol_draft/summary.json +++ b/examples/pilot/single_reviewer_protocol_draft/summary.json @@ -1,5 +1,5 @@ { - "adapter_contract_version": "scope-akta-review-v0.8", + "adapter_contract_version": "scope-akta-review-v0.8.1", "allowed_tools": [ "protocol_editor.draft_change" ], diff --git a/pyproject.toml b/pyproject.toml index 4647cfd..37363ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scope-protocol" -version = "0.8.0" +version = "0.8.1" description = "Scoped Scientific Authorization Protocol for AI-shaped science" readme = "README.md" license = { text = "MIT" } diff --git a/schemas/scope_akta_review_session_summary.schema.json b/schemas/scope_akta_review_session_summary.schema.json index d295bea..cd3f1ea 100644 --- a/schemas/scope_akta_review_session_summary.schema.json +++ b/schemas/scope_akta_review_session_summary.schema.json @@ -2,6 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://scope.dev/schemas/scope_akta_review_session_summary.schema.json", "title": "SCOPE AKTA Review Session Summary", + "description": "Adapter summary when multi-role review requires a session (status=session_required). Completed reviews use scope_akta_review_summary.schema.json.", "type": "object", "required": [ "status", @@ -18,7 +19,8 @@ "session_id": { "type": "string" }, "required_roles": { "type": "array", - "items": { "type": "string" } + "items": { "type": "string" }, + "minItems": 1 }, "message": { "type": "string" }, "requested_scope": { "type": ["string", "null"] }, @@ -26,5 +28,21 @@ "adapter_contract_version": { "type": "string" }, "production_mode": { "type": "boolean" } }, + "not": { + "anyOf": [ + { "required": ["decision_path"] }, + { "required": ["grant_path"] }, + { "required": ["decision_id"] }, + { "required": ["grant_id"] }, + { "required": ["approved_scope"] }, + { "required": ["identity_assurance_level"] }, + { "required": ["signing_assurance_level"] }, + { "required": ["allowed_tools"] }, + { "required": ["blocked_tools"] }, + { "required": ["decision_type"] }, + { "required": ["scope_trust_root_hash"] }, + { "required": ["queue_id"] } + ] + }, "additionalProperties": true } diff --git a/schemas/scope_akta_review_summary.schema.json b/schemas/scope_akta_review_summary.schema.json index 8e66413..057a7c9 100644 --- a/schemas/scope_akta_review_summary.schema.json +++ b/schemas/scope_akta_review_summary.schema.json @@ -1,9 +1,11 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://scope.dev/schemas/scope_akta_review_summary.schema.json", - "title": "SCOPE AKTA Review Summary", + "title": "SCOPE AKTA Review Summary (completed)", + "description": "Adapter summary for completed AKTA reviews only (status=completed). Session summaries use scope_akta_review_session_summary.schema.json.", "type": "object", "required": [ + "status", "packet_path", "decision_path", "grant_path", @@ -15,7 +17,7 @@ "production_mode" ], "properties": { - "status": { "type": "string" }, + "status": { "const": "completed" }, "packet_path": { "type": "string" }, "decision_path": { "type": "string" }, "grant_path": { "type": "string" }, @@ -23,7 +25,7 @@ "decision_id": { "type": "string" }, "grant_id": { "type": "string" }, "approved_scope": { "type": "string" }, - "requested_scope": { "type": "string" }, + "requested_scope": { "type": ["string", "null"] }, "allowed_tools": { "type": "array", "items": { "type": "string" } @@ -49,5 +51,12 @@ }, "queue_id": { "type": "string" } }, + "not": { + "anyOf": [ + { "required": ["session_id"] }, + { "required": ["required_roles"] }, + { "required": ["message"] } + ] + }, "additionalProperties": true } diff --git a/schemas/scope_grant.schema.json b/schemas/scope_grant.schema.json index c1ec9e8..b332c1c 100644 --- a/schemas/scope_grant.schema.json +++ b/schemas/scope_grant.schema.json @@ -103,6 +103,7 @@ }, "contributing_identity_assurance_levels": { "type": "array", + "minItems": 1, "items": { "type": "object", "required": ["decision_id", "reviewer_id", "identity_assurance_level"], @@ -121,6 +122,7 @@ }, "contributing_authority_checks": { "type": "array", + "minItems": 1, "items": { "type": "object", "required": ["decision_id", "reviewer_id", "authority_checks"], @@ -152,5 +154,37 @@ }, "grant_hash": { "type": "string", "pattern": "^sha256:" } }, + "allOf": [ + { + "if": { + "properties": { + "provenance": { + "properties": { + "contributing_identity_assurance_levels": { + "type": "array", + "minItems": 1 + } + }, + "required": ["contributing_identity_assurance_levels"] + } + }, + "required": ["provenance"] + }, + "then": { + "properties": { + "provenance": { + "required": [ + "contributing_identity_assurance_levels", + "contributing_authority_checks", + "minimum_identity_assurance_level", + "minimum_signing_assurance_level", + "veto_roles_applied", + "quorum_policy_hash" + ] + } + } + } + } + ], "additionalProperties": true } diff --git a/scope/__init__.py b/scope/__init__.py index 162b967..0821b65 100644 --- a/scope/__init__.py +++ b/scope/__init__.py @@ -1,4 +1,4 @@ -"""Scoped Scientific Authorization Protocol (SCOPE) v0.8.0.""" +"""Scoped Scientific Authorization Protocol (SCOPE) v0.8.1.""" from __future__ import annotations diff --git a/scope/_version.py b/scope/_version.py index d6977ce..e5ac1c0 100644 --- a/scope/_version.py +++ b/scope/_version.py @@ -1,3 +1,3 @@ """Package version (single source of truth).""" -__version__ = "0.8.0" +__version__ = "0.8.1" diff --git a/scope/akta_review.py b/scope/akta_review.py index 90a7b44..40ca792 100644 --- a/scope/akta_review.py +++ b/scope/akta_review.py @@ -103,18 +103,18 @@ def _resolve_signer( return None -def _resolve_reviewer_id( +def resolve_reviewer_id( reviewer_data: dict[str, Any], reviewer_id: str | None, ) -> str: - """Validate optional CLI reviewer_id against reviewer artifact.""" + """Resolve and validate reviewer identity for all AKTA review entry points.""" artifact_id = reviewer_data.get("reviewer_id") if reviewer_id is not None: if not artifact_id: raise ScopeValidationError("Reviewer artifact missing reviewer_id") if str(reviewer_id) != str(artifact_id): raise ScopeValidationError( - f"--reviewer-id {reviewer_id!r} does not match reviewer artifact " + f"reviewer_id {reviewer_id!r} does not match reviewer artifact " f"reviewer_id {artifact_id!r}" ) return str(reviewer_id) @@ -123,6 +123,19 @@ def _resolve_reviewer_id( return str(artifact_id) +def validate_summary_artifact(summary: dict[str, Any]) -> None: + """Validate summary against the schema for its status branch.""" + status = summary.get("status") + if status == "completed": + validate_artifact(summary, "scope_akta_review_summary.schema.json") + elif status == "session_required": + validate_artifact(summary, "scope_akta_review_session_summary.schema.json") + else: + raise ScopeValidationError( + f"summary.status must be 'completed' or 'session_required', got {status!r}" + ) + + def _write_session_summary( out: Path, *, @@ -144,7 +157,7 @@ def _write_session_summary( "adapter_contract_version": AKTA_REVIEW_CONTRACT_VERSION, "production_mode": is_production_mode(), } - validate_artifact(summary, "scope_akta_review_session_summary.schema.json") + validate_summary_artifact(summary) with summary_path.open("w", encoding="utf-8") as fh: json.dump(summary, fh, indent=2, sort_keys=True) fh.write("\n") @@ -179,7 +192,7 @@ def run_akta_review( with Path(reviewer).open(encoding="utf-8") as fh: reviewer_data = json.load(fh) - resolved_reviewer_id = _resolve_reviewer_id(reviewer_data, reviewer_id) + resolved_reviewer_id = resolve_reviewer_id(reviewer_data, reviewer_id) if session_mode: session = engine.create_review_session(packet) @@ -283,7 +296,7 @@ def run_akta_review( } if queue_entry is not None: summary["queue_id"] = queue_entry.queue_id - validate_artifact(summary, "scope_akta_review_summary.schema.json") + validate_summary_artifact(summary) with summary_path.open("w", encoding="utf-8") as fh: json.dump(summary, fh, indent=2, sort_keys=True) fh.write("\n") diff --git a/scope/grants.py b/scope/grants.py index 6f1ba1e..a4070c2 100644 --- a/scope/grants.py +++ b/scope/grants.py @@ -153,5 +153,9 @@ def check( return True, None, None def validate(self, grant: dict[str, Any]) -> None: + from scope.session_provenance import validate_session_grant_provenance + + provenance = grant.get("provenance") or {} + validate_session_grant_provenance(provenance) if self.schema: jsonschema.validate(instance=grant, schema=self.schema) diff --git a/scope/integration_versions.py b/scope/integration_versions.py index d27e61e..78fc19c 100644 --- a/scope/integration_versions.py +++ b/scope/integration_versions.py @@ -5,4 +5,4 @@ PF_CORE_VERSION = "pf-core-v0.5" PCS_MANIFEST_VERSION = "pcs-v0.5" SCOPE_CORE_VERSION = "scope-core-v0.8" -AKTA_REVIEW_CONTRACT_VERSION = "scope-akta-review-v0.8" +AKTA_REVIEW_CONTRACT_VERSION = "scope-akta-review-v0.8.1" diff --git a/scope/session_provenance.py b/scope/session_provenance.py index fbf8116..046052e 100644 --- a/scope/session_provenance.py +++ b/scope/session_provenance.py @@ -4,6 +4,7 @@ from typing import Any +from scope.errors import GrantValidationError from scope.hash import compute_hash from scope.identity_assurance import IAL0, IAL1, IAL2, IAL3, IAL4 from scope.review_session import ReviewSession @@ -12,6 +13,44 @@ IAL_RANK = {IAL0: 0, IAL1: 1, IAL2: 2, IAL3: 3, IAL4: 4} SAL_RANK = {SAL0: 0, SAL1: 1, SAL2: 2, SAL3: 3, SAL4: 4} +SESSION_PROVENANCE_FIELDS = ( + "contributing_identity_assurance_levels", + "contributing_authority_checks", + "minimum_identity_assurance_level", + "minimum_signing_assurance_level", + "veto_roles_applied", + "quorum_policy_hash", +) + + +def is_session_grant_provenance(provenance: dict[str, Any]) -> bool: + """True when provenance carries session aggregation markers.""" + levels = provenance.get("contributing_identity_assurance_levels") + return isinstance(levels, list) and len(levels) > 0 + + +def validate_session_grant_provenance(provenance: dict[str, Any]) -> None: + """Runtime check: session grants must include full provenance block.""" + if not is_session_grant_provenance(provenance): + return + missing = [field for field in SESSION_PROVENANCE_FIELDS if field not in provenance] + if missing: + raise GrantValidationError( + "Session grant provenance missing required fields: " + + ", ".join(sorted(missing)) + ) + if not provenance["contributing_identity_assurance_levels"]: + raise GrantValidationError( + "Session grant provenance requires non-empty " + "contributing_identity_assurance_levels" + ) + if not provenance["contributing_authority_checks"]: + raise GrantValidationError( + "Session grant provenance requires non-empty contributing_authority_checks" + ) + if not str(provenance["quorum_policy_hash"]).startswith("sha256:"): + raise GrantValidationError("Session grant provenance quorum_policy_hash invalid") + def _minimum_level(levels: list[str], rank: dict[str, int], default: str) -> str: if not levels: diff --git a/scripts/ci.ps1 b/scripts/ci.ps1 index 65b58a7..3fc27c3 100644 --- a/scripts/ci.ps1 +++ b/scripts/ci.ps1 @@ -40,4 +40,5 @@ Invoke-Pip install --editable ".[dev]" ruff check scope tests evals adapters mypy scope pytest -python evals/run_review_cases.py --extended \ No newline at end of file +python evals/run_review_cases.py --extended +python scripts/verify_pilot_fixtures.py \ No newline at end of file diff --git a/scripts/ci.sh b/scripts/ci.sh index 6d746a0..4917800 100644 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -6,4 +6,5 @@ pip install -e ".[dev]" ruff check scope tests evals adapters mypy scope pytest -python evals/run_review_cases.py --extended \ No newline at end of file +python evals/run_review_cases.py --extended +python scripts/verify_pilot_fixtures.py \ No newline at end of file diff --git a/scripts/verify_pilot_fixtures.py b/scripts/verify_pilot_fixtures.py new file mode 100644 index 0000000..096b5fb --- /dev/null +++ b/scripts/verify_pilot_fixtures.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Offline verification of examples/pilot fixture pack (SCOPE-4).""" + +from __future__ import annotations + +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +PILOT = ROOT / "examples" / "pilot" + +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def file_sha256(path: Path) -> str: + data = path.read_bytes() + if path.suffix.lower() == ".json": + data = data.replace(b"\r\n", b"\n") + digest = hashlib.sha256() + digest.update(data) + return f"sha256:{digest.hexdigest()}" +def _load_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _verify_checksums( + scenario_dir: Path, + expected: dict[str, Any], + errors: list[str], +) -> None: + for rel_path, expected_hash in (expected.get("checksums") or {}).items(): + artifact = scenario_dir / rel_path + if not artifact.is_file(): + errors.append(f"{scenario_dir.name}: missing checksum target {rel_path}") + continue + actual = file_sha256(artifact) + if actual != expected_hash: + errors.append( + f"{scenario_dir.name}: checksum mismatch for {rel_path} " + f"(expected {expected_hash}, got {actual})" + ) + + +def _verify_summary(scenario_dir: Path, expected: dict[str, Any], errors: list[str]) -> None: + summary_spec = expected.get("summary") + if not summary_spec: + return + summary_path = scenario_dir / "summary.json" + if not summary_path.is_file(): + errors.append(f"{scenario_dir.name}: summary.json required but missing") + return + summary = _load_json(summary_path) + status = summary_spec.get("status") + if status and summary.get("status") != status: + errors.append( + f"{scenario_dir.name}: summary.status expected {status!r}, " + f"got {summary.get('status')!r}" + ) + for key, value in summary_spec.items(): + if key == "status": + continue + if summary.get(key) != value: + errors.append( + f"{scenario_dir.name}: summary.{key} expected {value!r}, " + f"got {summary.get(key)!r}" + ) + from scope.akta_review import validate_summary_artifact + + try: + validate_summary_artifact(summary) + except Exception as exc: + errors.append(f"{scenario_dir.name}: summary schema validation failed: {exc}") + + +def _verify_quality_snippet( + scenario_dir: Path, + expected: dict[str, Any], + errors: list[str], +) -> None: + snippet_spec = expected.get("quality_report_snippet") + if not snippet_spec: + return + snippet_path = scenario_dir / "quality_report_snippet.json" + if not snippet_path.is_file(): + errors.append(f"{scenario_dir.name}: quality_report_snippet.json missing") + return + snippet = _load_json(snippet_path) + for key, value in snippet_spec.items(): + if snippet.get(key) != value: + errors.append( + f"{scenario_dir.name}: quality_report_snippet.{key} expected " + f"{value!r}, got {snippet.get(key)!r}" + ) + + +def _verify_queue_states( + scenario_dir: Path, + expected: dict[str, Any], + errors: list[str], +) -> None: + from scope.schema_util import validate_artifact + + for rel_path, queue_spec in (expected.get("queue_states") or {}).items(): + queue_path = scenario_dir / rel_path + if not queue_path.is_file(): + errors.append(f"{scenario_dir.name}: queue artifact missing {rel_path}") + continue + queue = _load_json(queue_path) + validate_artifact(queue, "scope_review_queue.schema.json") + for key, value in queue_spec.items(): + if queue.get(key) != value: + errors.append( + f"{scenario_dir.name}: {rel_path}.{key} expected {value!r}, " + f"got {queue.get(key)!r}" + ) + + +def _verify_artifacts( + scenario_dir: Path, + manifest: dict[str, Any], + expected: dict[str, Any], + errors: list[str], +) -> None: + from scope.schema_util import validate_artifact + + forbidden = set(expected.get("forbidden_files") or []) + for rel in forbidden: + if (scenario_dir / rel).exists(): + errors.append(f"{scenario_dir.name}: forbidden artifact present: {rel}") + + for entry in manifest.get("artifacts") or []: + rel_path = entry["path"] + artifact_path = scenario_dir / rel_path + if not artifact_path.is_file(): + errors.append(f"{scenario_dir.name}: missing artifact {rel_path}") + continue + schema = entry.get("schema") + if schema and rel_path.endswith(".json"): + try: + validate_artifact(_load_json(artifact_path), schema) + except Exception as exc: + errors.append( + f"{scenario_dir.name}: schema validation failed for {rel_path}: {exc}" + ) + + +def verify_scenario(scenario_dir: Path) -> list[str]: + errors: list[str] = [] + manifest_path = scenario_dir / "manifest.json" + expected_path = scenario_dir / "expected_verification.json" + if not manifest_path.is_file(): + return [f"{scenario_dir.name}: missing manifest.json"] + if not expected_path.is_file(): + return [f"{scenario_dir.name}: missing expected_verification.json"] + + manifest = _load_json(manifest_path) + expected = _load_json(expected_path) + + if manifest.get("scenario") != scenario_dir.name: + errors.append( + f"{scenario_dir.name}: manifest.scenario mismatch " + f"({manifest.get('scenario')!r})" + ) + + _verify_artifacts(scenario_dir, manifest, expected, errors) + _verify_checksums(scenario_dir, expected, errors) + _verify_summary(scenario_dir, expected, errors) + _verify_quality_snippet(scenario_dir, expected, errors) + _verify_queue_states(scenario_dir, expected, errors) + return errors + + +def main() -> int: + if not PILOT.is_dir(): + print(f"pilot directory not found: {PILOT}", file=sys.stderr) + return 1 + + scenario_dirs = sorted( + path for path in PILOT.iterdir() if path.is_dir() and (path / "manifest.json").exists() + ) + if not scenario_dirs: + print("no pilot scenarios with manifest.json found", file=sys.stderr) + return 1 + + all_errors: list[str] = [] + passed = 0 + for scenario_dir in scenario_dirs: + errors = verify_scenario(scenario_dir) + if errors: + all_errors.extend(errors) + else: + passed += 1 + print(f"OK {scenario_dir.name}") + + if all_errors: + print(f"\n{len(all_errors)} verification error(s):", file=sys.stderr) + for err in all_errors: + print(f" - {err}", file=sys.stderr) + return 1 + + print(f"\nAll {passed} pilot fixture(s) verified.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_akta_golden.py b/tests/test_akta_golden.py index 88b90e4..7824816 100644 --- a/tests/test_akta_golden.py +++ b/tests/test_akta_golden.py @@ -35,4 +35,4 @@ def test_nested_akta_record_golden_mapping(): assert constraints["allowed_next_steps"] == golden["allowed_next_steps"] assert req["requested_scope"] == golden["inferred_requested_scope"] assert req["scope_inference_source"] == golden["scope_inference_source"] - assert packet["packet_version"] == "0.8.0" + assert packet["packet_version"] == "0.8.1" diff --git a/tests/test_akta_review_command.py b/tests/test_akta_review_command.py index b41e2d0..ee5a1d5 100644 --- a/tests/test_akta_review_command.py +++ b/tests/test_akta_review_command.py @@ -54,7 +54,7 @@ def test_akta_review_command_happy_path(tmp_path): assert summary["decision_path"].endswith("scope_decision.json") assert summary["grant_path"].endswith("scope_grant.json") assert summary["approved_scope"] == "protocol_draft" - assert summary["adapter_contract_version"] == "scope-akta-review-v0.8" + assert summary["adapter_contract_version"] == "scope-akta-review-v0.8.1" assert summary["identity_assurance_level"] == "IAL0" assert "signing_assurance_level" in summary assert isinstance(summary["blocked_tools"], list) diff --git a/tests/test_akta_review_contract.py b/tests/test_akta_review_contract.py index 2ff00df..c9a5a5d 100644 --- a/tests/test_akta_review_contract.py +++ b/tests/test_akta_review_contract.py @@ -18,6 +18,7 @@ def test_summary_schema_fields() -> None: summary = { + "status": "completed", "packet_path": "/out/scope_review_packet.json", "decision_path": "/out/scope_decision.json", "grant_path": "/out/scope_grant.json", @@ -58,6 +59,7 @@ def test_akta_review_summary_contract(tmp_path: Path) -> None: assert result.exit_code == 0, result.output summary = json.loads((out_dir / "summary.json").read_text(encoding="utf-8")) validate_artifact(summary, "scope_akta_review_summary.schema.json") + assert summary["status"] == "completed" assert summary["adapter_contract_version"] == AKTA_REVIEW_CONTRACT_VERSION assert summary["identity_assurance_level"] == "IAL0" assert summary["requested_scope"] == "protocol_draft" diff --git a/tests/test_akta_review_reviewer_id_binding.py b/tests/test_akta_review_reviewer_id_binding.py index c316550..5302294 100644 --- a/tests/test_akta_review_reviewer_id_binding.py +++ b/tests/test_akta_review_reviewer_id_binding.py @@ -1,4 +1,4 @@ -"""Tests for --reviewer-id binding in akta review (SCOPE-2).""" +"""Tests for --reviewer-id binding in akta review (SCOPE-3).""" from __future__ import annotations @@ -11,7 +11,7 @@ from click.testing import CliRunner from scope import ScopeEngine -from scope.akta_review import run_akta_review +from scope.akta_review import resolve_reviewer_id, run_akta_review from scope.cli import main from scope.errors import ScopeValidationError from scope.key_registry import register_reviewer_key @@ -35,6 +35,14 @@ def _policy_with_registry_signer(tmp_path: Path) -> tuple[Path, Path]: return policy_dir, key +def test_resolve_reviewer_id_centralized_helper() -> None: + reviewer = {"reviewer_id": "reviewer_001", "role": "protocol_owner"} + assert resolve_reviewer_id(reviewer, None) == "reviewer_001" + assert resolve_reviewer_id(reviewer, "reviewer_001") == "reviewer_001" + with pytest.raises(ScopeValidationError, match="does not match"): + resolve_reviewer_id(reviewer, "wrong_id") + + def test_reviewer_id_mismatch_fails(tmp_path: Path) -> None: engine = ScopeEngine.from_policy_dir(ROOT / "policy") with pytest.raises(ScopeValidationError, match="does not match reviewer artifact"): diff --git a/tests/test_akta_review_session_mode.py b/tests/test_akta_review_session_mode.py index 391ce8a..3ea53a0 100644 --- a/tests/test_akta_review_session_mode.py +++ b/tests/test_akta_review_session_mode.py @@ -74,7 +74,7 @@ def test_multi_role_with_session_creates_session(tmp_path: Path) -> None: on_disk = json.loads((out_dir / "summary.json").read_text(encoding="utf-8")) validate_artifact(on_disk, "scope_akta_review_session_summary.schema.json") - assert on_disk["adapter_contract_version"] == "scope-akta-review-v0.8" + assert on_disk["adapter_contract_version"] == "scope-akta-review-v0.8.1" def test_akta_review_cli_session_flag(tmp_path: Path) -> None: diff --git a/tests/test_akta_review_session_summary.py b/tests/test_akta_review_session_summary.py new file mode 100644 index 0000000..aeb2706 --- /dev/null +++ b/tests/test_akta_review_session_summary.py @@ -0,0 +1,114 @@ +"""Tests for split AKTA review summary schemas (SCOPE-1).""" + +from __future__ import annotations + +import jsonschema +import pytest + +from scope.akta_review import validate_summary_artifact +from scope.errors import ScopeValidationError +from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION +from scope.schema_util import validate_artifact + + +def _completed_summary(**overrides: object) -> dict: + base: dict = { + "status": "completed", + "packet_path": "/out/scope_review_packet.json", + "decision_path": "/out/scope_decision.json", + "grant_path": "/out/scope_grant.json", + "approved_scope": "protocol_draft", + "requested_scope": "protocol_draft", + "adapter_contract_version": AKTA_REVIEW_CONTRACT_VERSION, + "identity_assurance_level": "IAL0", + "signing_assurance_level": "SAL0", + "production_mode": False, + } + base.update(overrides) + return base + + +def _session_summary(**overrides: object) -> dict: + base: dict = { + "status": "session_required", + "packet_id": "SCOPE-PKT-TEST01", + "session_id": "SCOPE-SESS-TEST01", + "required_roles": ["domain_scientist", "protocol_owner"], + "message": "Multi-role review session created; submit votes before grant issue.", + "adapter_contract_version": AKTA_REVIEW_CONTRACT_VERSION, + "production_mode": False, + } + base.update(overrides) + return base + + +def test_completed_summary_schema_accepts_valid_shape() -> None: + validate_artifact(_completed_summary(), "scope_akta_review_summary.schema.json") + validate_summary_artifact(_completed_summary()) + + +def test_session_summary_schema_accepts_valid_shape() -> None: + validate_artifact( + _session_summary(), + "scope_akta_review_session_summary.schema.json", + ) + validate_summary_artifact(_session_summary()) + + +def test_completed_schema_rejects_session_fields() -> None: + with pytest.raises(jsonschema.ValidationError): + validate_artifact( + _completed_summary(session_id="SCOPE-SESS-BAD"), + "scope_akta_review_summary.schema.json", + ) + + +def test_completed_schema_rejects_session_required_status() -> None: + with pytest.raises(jsonschema.ValidationError): + validate_artifact( + _session_summary(), + "scope_akta_review_summary.schema.json", + ) + + +def test_session_schema_rejects_completed_fields() -> None: + with pytest.raises(jsonschema.ValidationError): + validate_artifact( + _session_summary( + decision_path="/out/scope_decision.json", + grant_path="/out/scope_grant.json", + ), + "scope_akta_review_session_summary.schema.json", + ) + + +def test_session_schema_rejects_completed_status() -> None: + with pytest.raises(jsonschema.ValidationError): + validate_artifact( + _completed_summary(), + "scope_akta_review_session_summary.schema.json", + ) + + +def test_validate_summary_artifact_rejects_unknown_status() -> None: + with pytest.raises(ScopeValidationError, match="summary.status must be"): + validate_summary_artifact({"status": "pending"}) + + +def test_validate_summary_artifact_rejects_cross_branch_fields() -> None: + with pytest.raises(jsonschema.ValidationError): + validate_summary_artifact( + _session_summary( + decision_path="/out/scope_decision.json", + grant_path="/out/scope_grant.json", + ) + ) + with pytest.raises(jsonschema.ValidationError): + validate_summary_artifact(_completed_summary(session_id="SCOPE-SESS-BAD")) + + +def test_completed_schema_requires_status_completed() -> None: + summary = _completed_summary() + del summary["status"] + with pytest.raises(jsonschema.ValidationError): + validate_artifact(summary, "scope_akta_review_summary.schema.json") diff --git a/tests/test_institutional_pilot.py b/tests/test_institutional_pilot.py index 7241258..de92219 100644 --- a/tests/test_institutional_pilot.py +++ b/tests/test_institutional_pilot.py @@ -15,7 +15,7 @@ def test_institutional_pilot_packet_regenerates(): trigger = json.loads((PILOT / "review_trigger.json").read_text(encoding="utf-8")) packet = engine.create_packet(record, trigger) assert packet["review_request"]["scientific_action_type"] == "A5_protocol_modification" - assert packet["packet_version"] == "0.8.0" + assert packet["packet_version"] == "0.8.1" fixture = json.loads((PILOT / "scope_packet.json").read_text(encoding="utf-8")) for key in ( "packet_version", diff --git a/tests/test_pilot_fixtures.py b/tests/test_pilot_fixtures.py index 8e4986a..1190a04 100644 --- a/tests/test_pilot_fixtures.py +++ b/tests/test_pilot_fixtures.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import subprocess +import sys from pathlib import Path import pytest @@ -103,7 +105,7 @@ def test_pilot_summary_contract(scenario: str) -> None: spec = SCENARIOS[scenario] summary = json.loads((PILOT / scenario / "summary.json").read_text(encoding="utf-8")) assert summary["status"] == spec["summary_status"] - assert summary["adapter_contract_version"] == "scope-akta-review-v0.8" + assert summary["adapter_contract_version"] == "scope-akta-review-v0.8.1" schema = ( "scope_akta_review_session_summary.schema.json" if spec["summary_status"] == "session_required" @@ -116,3 +118,27 @@ def test_pilot_index_readme() -> None: readme = (PILOT / "README.md").read_text(encoding="utf-8") for scenario in SCENARIOS: assert scenario in readme + + +def test_verify_pilot_fixtures_script() -> None: + """CI verifier script must pass on the committed fixture pack.""" + result = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "verify_pilot_fixtures.py")], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + assert "All 5 pilot fixture(s) verified." in result.stdout + + +@pytest.mark.parametrize("scenario", list(SCENARIOS)) +def test_pilot_manifest_and_verification_files(scenario: str) -> None: + base = PILOT / scenario + assert (base / "manifest.json").is_file(), f"{scenario}: missing manifest.json" + assert (base / "expected_verification.json").is_file(), ( + f"{scenario}: missing expected_verification.json" + ) + manifest = json.loads((base / "manifest.json").read_text(encoding="utf-8")) + assert manifest["scenario"] == scenario diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index 00d14d9..aa0973a 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -406,7 +406,7 @@ def test_akta_review_rest_session_mode(client, tmp_path): summary = resp.json() assert summary["status"] == "session_required" assert summary["session_id"].startswith("SCOPE-SESS-") - assert summary["adapter_contract_version"] == "scope-akta-review-v0.8" + assert summary["adapter_contract_version"] == "scope-akta-review-v0.8.1" validate_artifact(summary, "scope_akta_review_session_summary.schema.json") assert (out_dir / "scope_review_packet.json").exists() assert not (out_dir / "scope_grant.json").exists() diff --git a/tests/test_session_grant_provenance.py b/tests/test_session_grant_provenance.py index c93c9d4..97e2344 100644 --- a/tests/test_session_grant_provenance.py +++ b/tests/test_session_grant_provenance.py @@ -1,4 +1,4 @@ -"""Tests for session grant provenance aggregation (SCOPE-3).""" +"""Tests for session grant provenance aggregation (SCOPE-2).""" from __future__ import annotations @@ -7,7 +7,12 @@ from scope import ScopeEngine from scope.hash import compute_hash from scope.schema_util import validate_artifact -from scope.session_provenance import aggregate_session_grant_provenance +from scope.session_provenance import ( + SESSION_PROVENANCE_FIELDS, + aggregate_session_grant_provenance, + is_session_grant_provenance, + validate_session_grant_provenance, +) ROOT = Path(__file__).resolve().parent.parent @@ -72,16 +77,11 @@ def test_session_grant_includes_aggregated_provenance() -> None: assert prov["quorum_policy_hash"] == compute_hash(session.quorum_policy) assert prov["quorum_policy_hash"].startswith("sha256:") - session_fields = ( - "contributing_identity_assurance_levels", - "contributing_authority_checks", - "minimum_identity_assurance_level", - "minimum_signing_assurance_level", - "veto_roles_applied", - "quorum_policy_hash", - ) + session_fields = SESSION_PROVENANCE_FIELDS for field in session_fields: assert field in prov, f"missing session provenance field: {field}" + assert is_session_grant_provenance(prov) + validate_session_grant_provenance(prov) validate_artifact(grant, "scope_grant.schema.json") diff --git a/tests/test_session_grant_provenance_required.py b/tests/test_session_grant_provenance_required.py new file mode 100644 index 0000000..c7a2a6c --- /dev/null +++ b/tests/test_session_grant_provenance_required.py @@ -0,0 +1,158 @@ +"""Tests for conditional session grant provenance (SCOPE-2).""" + +from __future__ import annotations + +from pathlib import Path + +import jsonschema +import pytest + +from scope import ScopeEngine +from scope.errors import GrantValidationError +from scope.grants import GrantEngine +from scope.schema_util import validate_artifact +from scope.session_provenance import ( + SESSION_PROVENANCE_FIELDS, + validate_session_grant_provenance, +) + +ROOT = Path(__file__).resolve().parent.parent + + +def _a6_packet(engine: ScopeEngine) -> dict: + trigger = { + "akta_admissibility": "review_required", + "scientific_action_type": "A6_experimental_planning", + "requested_action": "plan_validation", + "requested_tool": "experiment_planner.create_validation_plan", + "requested_scope": "single_validation_plan", + "scientific_context": {"protocol_version": "protocol_v1"}, + } + record = {"record_id": "AKTA-A6-PROV-REQ", "scientific_action_type": "A6_experimental_planning"} + return engine.create_packet(record, trigger) + + +def test_single_reviewer_grant_without_session_fields_validates() -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = engine.create_packet( + {"record_id": "AKTA-SINGLE", "scientific_action_type": "A5_protocol_modification"}, + { + "akta_admissibility": "review_required", + "scientific_action_type": "A5_protocol_modification", + "requested_action": "draft_change", + "requested_tool": "protocol_editor.draft_change", + "requested_scope": "protocol_draft", + }, + ) + decision = engine.submit_decision( + packet, + {"reviewer_id": "po1", "role": "protocol_owner"}, + { + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "ok", + }, + ) + grant = engine.issue_grant(packet, decision) + validate_artifact(grant, "scope_grant.schema.json") + validate_session_grant_provenance(grant["provenance"]) + + +def test_session_grant_missing_provenance_fields_fails_runtime() -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = _a6_packet(engine) + session = engine.create_review_session(packet) + d1 = engine.submit_session_decision( + session, + packet, + {"reviewer_id": "ds1", "role": "domain_scientist"}, + { + "type": "approve_narrower_scope", + "approved_scope": "single_validation_plan", + "rationale": "ok", + }, + ) + d2 = engine.submit_session_decision( + session, + packet, + {"reviewer_id": "po1", "role": "protocol_owner"}, + { + "type": "approve_narrower_scope", + "approved_scope": "single_validation_plan", + "rationale": "ok", + }, + ) + grant = engine.issue_grant_from_session(session, packet, [d1, d2]) + prov = dict(grant["provenance"]) + del prov["quorum_policy_hash"] + grant["provenance"] = prov + + grant_engine = GrantEngine(engine.policy, schema=engine._grant_engine.schema) + with pytest.raises(GrantValidationError, match="quorum_policy_hash"): + grant_engine.validate(grant) + + +def test_session_grant_partial_marker_fails_schema() -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = engine.create_packet( + {"record_id": "AKTA-PARTIAL", "scientific_action_type": "A5_protocol_modification"}, + { + "akta_admissibility": "review_required", + "scientific_action_type": "A5_protocol_modification", + "requested_action": "draft_change", + "requested_tool": "protocol_editor.draft_change", + "requested_scope": "protocol_draft", + }, + ) + decision = engine.submit_decision( + packet, + {"reviewer_id": "po1", "role": "protocol_owner"}, + { + "type": "approve_narrower_scope", + "approved_scope": "protocol_draft", + "rationale": "ok", + }, + ) + grant = engine.issue_grant(packet, decision) + grant["provenance"]["contributing_identity_assurance_levels"] = [ + { + "decision_id": decision["decision_id"], + "reviewer_id": "po1", + "identity_assurance_level": "IAL0", + } + ] + + grant_engine = GrantEngine(engine.policy, schema=engine._grant_engine.schema) + with pytest.raises((GrantValidationError, jsonschema.ValidationError)): + grant_engine.validate(grant) + + +def test_session_grant_includes_all_required_provenance_fields() -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + packet = _a6_packet(engine) + session = engine.create_review_session(packet) + d1 = engine.submit_session_decision( + session, + packet, + {"reviewer_id": "ds1", "role": "domain_scientist"}, + { + "type": "approve_narrower_scope", + "approved_scope": "single_validation_plan", + "rationale": "ok", + }, + ) + d2 = engine.submit_session_decision( + session, + packet, + {"reviewer_id": "po1", "role": "protocol_owner"}, + { + "type": "approve_narrower_scope", + "approved_scope": "single_validation_plan", + "rationale": "ok", + }, + ) + grant = engine.issue_grant_from_session(session, packet, [d1, d2]) + prov = grant["provenance"] + for field in SESSION_PROVENANCE_FIELDS: + assert field in prov, f"missing {field}" + validate_artifact(grant, "scope_grant.schema.json")