diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7603606..6741bde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main, master] + branches: [main, master, "release/**"] pull_request: - branches: [main, master] + branches: [main, master, "release/**"] jobs: test: diff --git a/README.md b/README.md index 6f70347..b88f9d8 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.1-blue)](https://github.com/fraware/SCOPE/releases) +[![Version](https://img.shields.io/badge/version-1.0.0-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/SECURITY.md b/SECURITY.md index 5d63c12..352919c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,43 +4,54 @@ | Version | Supported | Notes | | ------- | --------- | ----- | -| 0.8.x | Yes | Current release line; security fixes backported here | -| 0.7.x | Best effort | Prior institutional pilot line; upgrade to 0.8.x recommended | -| 0.6.x and earlier | No | Unsupported; no security patches | +| 1.0.x | Yes | Current stable line; contract freeze | +| 0.11.x | Yes | Workflow release; security fixes backported when feasible | +| 0.10.x | Best effort | Production trust line; upgrade to 1.0.x recommended | +| 0.9.x | Best effort | Ecosystem demo line | +| 0.8.x | Best effort | Prior release line | +| 0.7.x and earlier | No | Unsupported | -Report issues against the latest **0.8.x** release on [main](https://github.com/fraware/SCOPE). +Report issues against the latest **1.0.x** release on [main](https://github.com/fraware/SCOPE). ## Reporting a vulnerability Report security issues privately via GitHub Security Advisories on [fraware/SCOPE](https://github.com/fraware/SCOPE/security/advisories/new). Do not open public issues for undisclosed vulnerabilities. -## v0.8 security model +## v1.0 security model -SCOPE v0.8.x builds on schema validation, canonical hashing, hash-chained ledger events, explicit expiration checks, and fail-closed behavior for unknown scopes, invalid roles, and forbidden queue transitions. +SCOPE v1.0 builds on schema validation, canonical hashing, hash-chained ledger events, explicit expiration checks, and fail-closed behavior for unknown scopes, invalid roles, and forbidden queue transitions. **Cryptography and signing** -- Ed25519 signatures on decisions and grants when production signing is enabled (`SCOPE_PRODUCTION_MODE`, minimum signing assurance policy in `policy/minimum_signing_assurance.yaml`) -- Signing assurance levels (SAL0–SAL4) with registry key binding and reviewer public-key references; external HSM/KMS interface documented for SAL4 (`docs/signing_assurance.md`, `docs/key_management.md`) -- Combined `scope_trust_root_hash` ties policy and reviewer registry integrity into decision, grant, and PCS export provenance +- Ed25519 signatures on decisions and grants when production signing is enabled +- Signing assurance levels (SAL0–SAL4) with KMS reference adapter (`--signing-provider kms`) +- Combined `scope_trust_root_hash` ties policy and reviewer registry integrity into PCS export provenance **Identity** -- Identity assurance levels (IAL0–IAL4) with provenance on decisions and session grants (`scope/identity_assurance.py`, `docs/identity_assurance.md`) -- Optional OIDC/JWT verification (`SCOPE_OIDC_*`, `scope identity verify-token`) for institutional identity claims; org RBAC in `policy/org_rbac.yaml` is separate from SCOPE scope authority +- Identity assurance levels (IAL0–IAL4); production mode rejects IAL0 unless `SCOPE_ALLOW_DEV_IAL0` +- Pluggable OIDC and SAML providers (`scope/identity_providers.py`) +- SCIM/LDAP RBAC sync via `scope rbac sync` **Ledger and delivery** -- Local hash-chained JSONL ledger with verification APIs -- Optional remote HTTP append sink (`SCOPE_LEDGER_REMOTE_URL`); delivery semantics `best_effort`, `at_least_once` (spool), and `fail_closed` for high-risk grant issuance when remote delivery is required -- Runtime violation and expiration events for PF feedback loops; remote sink is not a WORM or authoritative tamper-evident store +- Local hash-chained JSONL ledger with WORM and verified remote sink options +- Delivery modes: `best_effort`, `at_least_once`, `fail_closed` +- REST API audit logging to ledger **AKTA review contract** -- Signed `summary.json` artifacts validated against split schemas for `completed` vs `session_required` (`scope-akta-review-v0.8.1`); consumers must branch on `summary.status` +- Frozen at `scope-akta-review-v1.0`; consumers branch on `summary.status` + +**Workflow** + +- Tenant-isolated review queues via `X-Scope-Tenant-Id` +- Webhook notifications on SLA breach **Known limits** -- No live SAML/SCIM directory sync; RBAC and identity mapping are file-based -- Reviewer judgment, domain safety, and physical lab safety are out of scope -- See [docs/threat_model.md](docs/threat_model.md), [docs/trusted_boundary.md](docs/trusted_boundary.md), and [docs/limitations.md](docs/limitations.md) for residual risk and deployment boundaries. \ No newline at end of file +- SAML verification requires external sidecar or pre-verified assertions in reference adapter +- Email notifications require institutional SMTP wiring +- Reviewer judgment and physical lab safety remain out of scope + +See [docs/threat_model.md](docs/threat_model.md), [docs/trusted_boundary.md](docs/trusted_boundary.md), [docs/compatibility_matrix.md](docs/compatibility_matrix.md), and [docs/limitations.md](docs/limitations.md). diff --git a/adapters/generic_rest/server.py b/adapters/generic_rest/server.py index 0900103..5ec6a09 100644 --- a/adapters/generic_rest/server.py +++ b/adapters/generic_rest/server.py @@ -464,9 +464,15 @@ def list_review_queue(queue_dir: str | None = None) -> dict[str, Any]: def _find_queue_path(queue_id: str, queue_dir: str | None = None) -> Path: + from scope.errors import ScopeValidationError from scope.review_queue import ReviewQueue, list_queue_files - for path in list_queue_files(queue_dir): + engine = get_engine() + try: + effective = engine.effective_queue_dir(queue_dir) + except ScopeValidationError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + for path in list_queue_files(effective): entry = ReviewQueue.load(path) if entry.queue_id == queue_id: return path diff --git a/docs/akta_review_contract.md b/docs/akta_review_contract.md index 911897d..470c366 100644 --- a/docs/akta_review_contract.md +++ b/docs/akta_review_contract.md @@ -1,6 +1,12 @@ # AKTA Review Output Contract -SCOPE v0.8.1 splits the `scope akta review` output contract by `summary.status`. +SCOPE v1.0 splits the `scope akta review` output contract by `summary.status`. + +## Contract version + +**Frozen at v1.0:** `scope-akta-review-v1.0` (`scope.integration_versions.AKTA_REVIEW_CONTRACT_VERSION`). + +Backward compatible with v0.8.1 and v0.9 consumers when branching on `summary.status`. ## Branching rule @@ -13,7 +19,7 @@ Consumers **must** branch on `summary.status`: 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`). +Contract version constant: `scope-akta-review-v1.0` (`scope.integration_versions.AKTA_REVIEW_CONTRACT_VERSION`). ## Artifacts @@ -36,7 +42,7 @@ Required fields: "grant_path": "...", "approved_scope": "...", "requested_scope": "...", - "adapter_contract_version": "scope-akta-review-v0.8.1", + "adapter_contract_version": "scope-akta-review-v1.0", "identity_assurance_level": "IAL0", "signing_assurance_level": "SAL1", "production_mode": false @@ -60,7 +66,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.1", + "adapter_contract_version": "scope-akta-review-v1.0", "production_mode": false } ``` diff --git a/docs/compatibility_matrix.md b/docs/compatibility_matrix.md new file mode 100644 index 0000000..c9f9c26 --- /dev/null +++ b/docs/compatibility_matrix.md @@ -0,0 +1,45 @@ +# Compatibility Matrix (v1.0) + +Published version alignment for SCOPE and partner integrations at v1.0.0. + +## Core versions + +| Component | Version constant | Notes | +|-----------|------------------|-------| +| SCOPE package | `0.11.0` → `1.0.0` | Semver from `scope/_version.py` | +| Policy bundle | `scope-core-v1.0` | YAML `version` field | +| AKTA review contract | `scope-akta-review-v1.0` | Branch on `summary.status` | +| PF obligation | `pf-core-v0.5` | `obligation_version` | +| PCS manifest | `pcs-v0.5` | `manifest_version` | +| VSA report import | adapter-local | No semver field; schema-stable fields documented | + +## Cross-repo compatibility + +| SCOPE | AKTA | PF-Core | PCS | VSA | +|-------|------|---------|-----|-----| +| 1.0.x | ≥ 0.4 trigger aliases | pf-core-v0.5 | pcs-v0.5 | ScientificReport v1 | +| 0.11.x | scope-akta-review-v0.9+ | pf-core-v0.5 | pcs-v0.5 | ScientificReport v1 | +| 0.10.x | scope-akta-review-v0.9+ | pf-core-v0.5 | pcs-v0.5 | ScientificReport v1 | +| 0.9.x | scope-akta-review-v0.9 | pf-core-v0.4–v0.5 | pcs-v0.4–v0.5 | ScientificReport v1 | +| 0.8.x | scope-akta-review-v0.8.1 | pf-core-v0.5 | pcs-v0.5 | ScientificReport v1 | + +## Schema stability (v1.0 freeze) + +Stable fields in `schemas/` will not be removed or change type without a major SCOPE bump. Deprecated fields remain readable for one major release with documented migration paths. + +| Schema | Stable since | +|--------|--------------| +| `scope_akta_review_summary.schema.json` | v1.0.0 | +| `scope_akta_review_session_summary.schema.json` | v1.0.0 | +| `pf_scope_obligation.schema.json` | v0.7.0 | +| `pcs_scope_artifact.schema.json` | v0.7.0 | +| `scope_review_queue.schema.json` | v0.7.0 | + +## Migration guides + +- **v0.8 → v0.9:** Session-complete AKTA path; contract version bump to `scope-akta-review-v0.9` +- **v0.9 → v0.10:** Production IAL0 rejection; KMS signing provider; RBAC SCIM sync +- **v0.10 → v0.11:** Tenant queue isolation; webhook notifications; REST audit logging +- **v0.11 → v1.0:** Contract freeze at `scope-akta-review-v1.0`; no breaking schema changes + +See [akta_review_contract.md](akta_review_contract.md), [external_integration_contracts.md](external_integration_contracts.md). diff --git a/docs/production_deployment.md b/docs/production_deployment.md index d47f195..94c9596 100644 --- a/docs/production_deployment.md +++ b/docs/production_deployment.md @@ -72,4 +72,5 @@ uvicorn adapters.generic_rest.server:app --host 0.0.0.0 --port 8080 --workers 4 ```bash scope quality report --ledger /var/scope/ledger.jsonl --out /tmp/quality.json python scripts/verify_pilot_fixtures.py +python scripts/verify_ledger_chain.py /var/scope/ledger.jsonl ``` diff --git a/evals/run_review_cases.py b/evals/run_review_cases.py index 4132441..feee85c 100644 --- a/evals/run_review_cases.py +++ b/evals/run_review_cases.py @@ -234,6 +234,7 @@ def _run_akta_review_scenario( name = scenario["name"] session_mode = bool(scenario.get("session_mode")) + session_complete = bool(scenario.get("session_complete")) with tempfile.TemporaryDirectory() as out_dir: signing_key: Path | None = None if scenario.get("sign_before_grant"): @@ -251,6 +252,8 @@ def _run_akta_review_scenario( out_dir=out_dir, signing_key=signing_key, session_mode=session_mode, + session_complete=session_complete, + votes=scenario.get("votes"), ) if session_mode: @@ -515,6 +518,7 @@ def _run_scenario_with_engine( "fail_closed_grant_blocked.json", "akta_review_signed_summary.json", "akta_review_session_mode.json", + "akta_review_session_complete.json", ] diff --git a/evals/scenarios/extended/akta_review_session_complete.json b/evals/scenarios/extended/akta_review_session_complete.json new file mode 100644 index 0000000..26c7a16 --- /dev/null +++ b/evals/scenarios/extended/akta_review_session_complete.json @@ -0,0 +1,45 @@ +{ + "name": "akta_review_session_complete", + "run_akta_review": true, + "session_complete": true, + "akta_record": { + "record_id": "AKTA-EVAL-SESSION-COMPLETE", + "scientific_action_type": "A6_experimental_planning" + }, + "akta_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", + "evidence_state": "E1_hypothesis" + } + }, + "reviewer": {"reviewer_id": "po1", "role": "protocol_owner"}, + "decision": { + "type": "approve_narrower_scope", + "approved_scope": "single_validation_plan", + "rationale": "Session-complete orchestration eval" + }, + "grant_scope": "single_validation_plan", + "votes": [ + { + "reviewer": {"reviewer_id": "ds1", "role": "domain_scientist"}, + "decision": { + "type": "approve_narrower_scope", + "approved_scope": "single_validation_plan", + "rationale": "Co-review approves validation plan scope" + } + }, + { + "reviewer": {"reviewer_id": "po1", "role": "protocol_owner"}, + "decision": { + "type": "approve_narrower_scope", + "approved_scope": "single_validation_plan", + "rationale": "Protocol owner confirms draft scope" + } + } + ] +} diff --git a/examples/pilot/README.md b/examples/pilot/README.md index 8c980be..8a9c931 100644 --- a/examples/pilot/README.md +++ b/examples/pilot/README.md @@ -1,19 +1,20 @@ -# SCOPE Pilot Fixture Pack (v0.8.1) +# SCOPE Pilot Fixture Pack (v1.0.0) -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. +Institutional pilot scenarios generated with SCOPE v1.0.0 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 | |----------|-----------|------------| | Single-reviewer protocol draft | [single_reviewer_protocol_draft](single_reviewer_protocol_draft/) | One-shot `scope akta review` | -| Multi-role genomics co-review | [multi_role_genomics_review](multi_role_genomics_review/) | `--session` creates pending session | +| Multi-role genomics co-review | [multi_role_genomics_review](multi_role_genomics_review/) | `--session-complete` issues grant from votes | | Expired queue reopen | [expired_queue_reopen](expired_queue_reopen/) | Queue `expired` then `reopen` | | 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.1`. +Policy bundle: `scope-core-v1.0`. AKTA review contract: `scope-akta-review-v1.0`. 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 +python scripts/verify_ledger_chain.py .scope/ledger.jsonl ``` diff --git a/examples/pilot/multi_role_genomics_review/README.md b/examples/pilot/multi_role_genomics_review/README.md index adb4e8c..022989d 100644 --- a/examples/pilot/multi_role_genomics_review/README.md +++ b/examples/pilot/multi_role_genomics_review/README.md @@ -1,21 +1,26 @@ # Multi-role genomics review -Scenario: weak-evidence experimental planning (A6) requires both `domain_scientist` and `protocol_owner`. `scope akta review --session` creates a review session instead of issuing a grant immediately. +Scenario: weak-evidence experimental planning (A6) requires both `domain_scientist` and `protocol_owner`. Use `--session` for session-required summary or `--session-complete` to orchestrate votes and issue a grant in one command. ## Artifacts | File | Description | |------|-------------| | `scope_review_packet.json` | Review packet | -| `summary.json` | Session-required summary (`status: session_required`) | +| `scope_decision.json` | Aggregated decision (`completed` path) | +| `scope_grant.json` | Issued grant (`completed` path) | +| `summary.json` | Completed AKTA review summary | +| `votes.json` | Reviewer/decision vote manifest for session-complete | | `reviewer_protocol_owner.json` | Protocol owner reviewer fixture | | `reviewer_domain_scientist.json` | Domain scientist reviewer fixture | | `decision_protocol_owner.json` | Vote input for protocol owner | | `decision_domain_scientist.json` | Vote input for domain scientist | +| `manifest.json` | Artifact inventory and schema versions | +| `expected_verification.json` | Checksums and status expectations | | `packet_rendered.md` | Markdown packet render | -| `quality_report_snippet.json` | Session-pending quality note | +| `quality_report_snippet.json` | Quality note after grant issue | -## Regenerate session +## Regenerate session-required summary ```bash scope akta review \ @@ -29,11 +34,21 @@ scope akta review \ --policy policy ``` -## Complete grant (after regeneration) +## Regenerate completed grant (session-complete) ```bash -scope review session vote --session ... -scope review session issue-grant ... +scope akta review \ + --akta-trigger examples/weak_evidence_validation_review/review_trigger.json \ + --akta-record examples/weak_evidence_validation_review/akta_record.json \ + --grant-scope single_validation_run_draft \ + --reviewer examples/pilot/multi_role_genomics_review/reviewer_protocol_owner.json \ + --decision-rationale "Multi-role session-complete grant." \ + --out-dir examples/pilot/multi_role_genomics_review \ + --session-complete \ + --votes examples/pilot/multi_role_genomics_review/votes.json \ + --policy policy ``` -See `examples/weak_evidence_validation_review/README.md` for the full vote workflow. +REST equivalent: pass `session_complete: true` and `votes[]` to `POST /v0/akta/review` or use `scripts/akta_rest_review.py --session-complete --votes ...`. + +See `examples/weak_evidence_validation_review/README.md` for the underlying review scenario. diff --git a/examples/pilot/multi_role_genomics_review/expected_verification.json b/examples/pilot/multi_role_genomics_review/expected_verification.json index 1654c82..7665b99 100644 --- a/examples/pilot/multi_role_genomics_review/expected_verification.json +++ b/examples/pilot/multi_role_genomics_review/expected_verification.json @@ -1,17 +1,18 @@ { "checksums": { - "quality_report_snippet.json": "sha256:c949d9e671baa36c95d6c8bb20fb0f30e571173835dff2bc269a4c9d95da8051", - "scope_review_packet.json": "sha256:654292d3b72dfef1fbd5aa38ed191abdfdab4b3a7244255226fa8e74fb9752ac", - "summary.json": "sha256:9061c4c9d4e517d5ed60ce562fcdbe2bb0fc2d98143ad3d3b9826e090dbda9b3" + "quality_report_snippet.json": "sha256:2a87b022dd1ce8c8c9525310b93ee63db937b47c1ded01f52013cd6cf64f2853", + "scope_decision.json": "sha256:08d9d02b4b93da199f764234895d6a6ea4b240aa433e9641b34b97361969560a", + "scope_grant.json": "sha256:301fff20134c507dc1916a2c2eb60e17f9711439db0a46ef1bdc7edfa28ed5af", + "scope_review_packet.json": "sha256:09629eee028bb8cda2b3789542d1835d3908fc7c264187f0f40c5f74f8e6d20d", + "summary.json": "sha256:2733a7ba7c8c5a08ca5cd88cae9ae04bc9b1e5b5c197ab8bd3f21286d3ba1707" }, - "forbidden_files": [ - "scope_grant.json" - ], + "forbidden_files": [], "quality_report_snippet": { - "policy_version": "scope-core-v0.8" + "policy_version": "scope-core-v1.0" }, "summary": { - "adapter_contract_version": "scope-akta-review-v0.8.1", - "status": "session_required" + "adapter_contract_version": "scope-akta-review-v1.0", + "approved_scope": "single_validation_run_draft", + "status": "completed" } } diff --git a/examples/pilot/multi_role_genomics_review/manifest.json b/examples/pilot/multi_role_genomics_review/manifest.json index 50bef16..def32c9 100644 --- a/examples/pilot/multi_role_genomics_review/manifest.json +++ b/examples/pilot/multi_role_genomics_review/manifest.json @@ -1,16 +1,27 @@ { "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", + "scope_version": "1.0.0", + "policy_version": "scope-core-v1.0", + "akta_review_contract_version": "scope-akta-review-v1.0", "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_session_summary.schema.json" + "schema": "scope_akta_review_summary.schema.json" + }, + { + "path": "votes.json" }, { "path": "decision_protocol_owner.json" diff --git a/examples/pilot/multi_role_genomics_review/quality_report_snippet.json b/examples/pilot/multi_role_genomics_review/quality_report_snippet.json index 0fca031..4999c2e 100644 --- a/examples/pilot/multi_role_genomics_review/quality_report_snippet.json +++ b/examples/pilot/multi_role_genomics_review/quality_report_snippet.json @@ -1,4 +1,4 @@ { - "policy_version": "scope-core-v0.8", - "note": "session pending votes" + "policy_version": "scope-core-v1.0", + "note": "session-complete grant issued" } diff --git a/examples/pilot/multi_role_genomics_review/scope_decision.json b/examples/pilot/multi_role_genomics_review/scope_decision.json new file mode 100644 index 0000000..68fd37a --- /dev/null +++ b/examples/pilot/multi_role_genomics_review/scope_decision.json @@ -0,0 +1,60 @@ +{ + "confidence": { + "requires_second_review": false, + "reviewer_confidence": 0.72 + }, + "decided_at": "2026-06-29T21:28:39Z", + "decision": { + "approved_scope": "single_validation_run_draft", + "rationale": "Co-review confirms draft-only validation scope given weak evidence.", + "rejected_scope": [ + "active_protocol_update", + "single_run_queue_priority", + "bounded_batch_priority", + "tool_permission_escalation", + "execution_payload_preparation", + "robot_queue_submission", + "publication_claim", + "scientific_memory_import" + ], + "type": "approve_narrower_scope" + }, + "decision_hash": "sha256:97f0c01735db33c84a9d2800519e40532eee6ea767978489c7c34a8050e04822", + "decision_id": "SCOPE-DEC-DF0B16", + "expiration": { + "expires_on": [ + "single_use", + "protocol_version_change", + "evidence_state_change", + "policy_version_change" + ], + "mode": "event_based" + }, + "packet_id": "SCOPE-PKT-C1FD8D", + "provenance": { + "authority_checks": { + "delegation_id": null, + "rbac_enforced": false, + "rbac_role_valid": false, + "scope_approval_valid": true, + "scope_role_valid": true + }, + "identity_assurance_level": "IAL0", + "identity_source": "caller_json", + "reviewer_key_registry_hash": "sha256:13f8cdcc054ca19f6a2560cd9ae688c39ac289ddf3f2d3da4e6dd0081e883692", + "reviewer_key_registry_version": "scope-core-v0.8", + "role_resolution_source": "caller_supplied", + "scope_policy_hash": "sha256:68e578ddc7a35d2cfa38c5cb95bc51182d8801548158a28b35ca1e7a035e28ea", + "scope_policy_version": "scope-core-v0.8", + "scope_trust_root_hash": "sha256:6334c56aa73915315692177499c217399e2895bbab1ed6794040b1a2d49f0a77" + }, + "reviewer": { + "conflict_declared": false, + "declared_expertise": [ + "experimental_design", + "validation_methods" + ], + "reviewer_id": "reviewer_ds_001", + "role": "domain_scientist" + } +} diff --git a/examples/pilot/multi_role_genomics_review/scope_grant.json b/examples/pilot/multi_role_genomics_review/scope_grant.json new file mode 100644 index 0000000..507802f --- /dev/null +++ b/examples/pilot/multi_role_genomics_review/scope_grant.json @@ -0,0 +1,126 @@ +{ + "authorization": { + "allowed_tools": [ + "experiment_planner.create_validation_plan", + "protocol_editor.draft_change" + ], + "approved_actions": [ + "A6_experimental_planning" + ], + "approved_scope": "single_validation_run_draft", + "blocked_tools": [ + "lab_scheduler.prioritize", + "protocol_editor.update_active_protocol", + "robot_queue.submit" + ], + "max_responsibility_level": "R5_experimental_planning" + }, + "constraints": { + "domain_overlay": null, + "evidence_state": "E1_weak_signal", + "model_version": null, + "project_id": "demo_project", + "protocol_version": "protocol_v2", + "requires_pf_core_trace": true, + "requires_recording": true, + "single_use": true, + "tool_registry_version": null, + "validation_status": "V0_unvalidated" + }, + "contributing_signatures": [ + { + "decision_id": "SCOPE-DEC-DF0B16", + "reviewer_id": "reviewer_ds_001", + "reviewer_role": "domain_scientist" + }, + { + "decision_id": "SCOPE-DEC-7C43FF", + "reviewer_id": "rev_po", + "reviewer_role": "protocol_owner" + } + ], + "created_at": "2026-06-29T21:28:39Z", + "expiration": { + "absolute_expiration": null, + "expires_after": [ + "single_use", + "protocol_version_change", + "evidence_state_change", + "policy_version_change" + ] + }, + "grant_hash": "sha256:087639c5666a3de94132f78b3fe7cf0ef5d6ac359d40bddf045bd3efbed8e77a", + "grant_id": "SCOPE-GRANT-2EE9BA", + "grant_version": "1.0.0", + "provenance": { + "akta_policy_hash": null, + "authority_checks": { + "delegation_id": null, + "rbac_enforced": false, + "rbac_role_valid": false, + "scope_approval_valid": true, + "scope_role_valid": true + }, + "contributing_authority_checks": [ + { + "authority_checks": { + "delegation_id": null, + "rbac_enforced": false, + "rbac_role_valid": false, + "scope_approval_valid": true, + "scope_role_valid": true + }, + "decision_id": "SCOPE-DEC-DF0B16", + "reviewer_id": "reviewer_ds_001" + }, + { + "authority_checks": { + "delegation_id": null, + "rbac_enforced": false, + "rbac_role_valid": false, + "scope_approval_valid": true, + "scope_role_valid": true + }, + "decision_id": "SCOPE-DEC-7C43FF", + "reviewer_id": "rev_po" + } + ], + "contributing_identity_assurance_levels": [ + { + "decision_id": "SCOPE-DEC-DF0B16", + "identity_assurance_level": "IAL0", + "identity_source": "caller_json", + "reviewer_id": "reviewer_ds_001", + "reviewer_role": "domain_scientist", + "role_resolution_source": "caller_supplied" + }, + { + "decision_id": "SCOPE-DEC-7C43FF", + "identity_assurance_level": "IAL0", + "identity_source": "caller_json", + "reviewer_id": "rev_po", + "reviewer_role": "protocol_owner", + "role_resolution_source": "caller_supplied" + } + ], + "identity_assurance_level": "IAL0", + "identity_source": "caller_json", + "minimum_identity_assurance_level": "IAL0", + "minimum_signing_assurance_level": "SAL0", + "quorum_policy_hash": "sha256:e899af11cf5c678fb8fa8e3cda95b8d8d1c4a9b723cf4a4a7f33186c9fcafa67", + "reviewer_key_registry_hash": "sha256:13f8cdcc054ca19f6a2560cd9ae688c39ac289ddf3f2d3da4e6dd0081e883692", + "reviewer_key_registry_version": "scope-core-v0.8", + "reviewer_role_policy_hash": "sha256:68e578ddc7a35d2cfa38c5cb95bc51182d8801548158a28b35ca1e7a035e28ea", + "role_resolution_source": "caller_supplied", + "scope_policy_hash": "sha256:68e578ddc7a35d2cfa38c5cb95bc51182d8801548158a28b35ca1e7a035e28ea", + "scope_policy_version": "scope-core-v0.8", + "scope_trust_root_hash": "sha256:6334c56aa73915315692177499c217399e2895bbab1ed6794040b1a2d49f0a77", + "signing_assurance_level": "SAL0", + "veto_roles_applied": [] + }, + "source": { + "akta_record_id": "AKTA-SAR-S03", + "decision_id": "SCOPE-DEC-DF0B16", + "packet_id": "SCOPE-PKT-C1FD8D" + } +} diff --git a/examples/pilot/multi_role_genomics_review/scope_review_packet.json b/examples/pilot/multi_role_genomics_review/scope_review_packet.json index 24cb735..d87e782 100644 --- a/examples/pilot/multi_role_genomics_review/scope_review_packet.json +++ b/examples/pilot/multi_role_genomics_review/scope_review_packet.json @@ -9,7 +9,7 @@ "robot_queue.submit" ] }, - "created_at": "2026-06-29T09:19:52Z", + "created_at": "2026-06-29T21:28:39Z", "decision_options": [ "approve", "approve_narrower_scope", @@ -17,9 +17,9 @@ "request_more_evidence", "abstain_conflict_or_insufficient_expertise" ], - "packet_hash": "sha256:a5f3c42561b8dfdde708c963dc014bd3e5fffe450ec587bc798c42e66d9f4241", - "packet_id": "SCOPE-PKT-42A16F", - "packet_version": "0.8.0", + "packet_hash": "sha256:50a502d2a01442270c591f04c3a7c6896044a79116a469938b853c8bf01337b0", + "packet_id": "SCOPE-PKT-C1FD8D", + "packet_version": "1.0.0", "review_artifacts": { "ai_output_summary": "Proposed validation run based on weak preliminary signal.", "evidence_report_ref": "VSA-REP-000012" diff --git a/examples/pilot/multi_role_genomics_review/summary.json b/examples/pilot/multi_role_genomics_review/summary.json index 62d427e..db88801 100644 --- a/examples/pilot/multi_role_genomics_review/summary.json +++ b/examples/pilot/multi_role_genomics_review/summary.json @@ -1,14 +1,26 @@ { - "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", + "adapter_contract_version": "scope-akta-review-v1.0", + "allowed_tools": [ + "experiment_planner.create_validation_plan", + "protocol_editor.draft_change" + ], + "approved_scope": "single_validation_run_draft", + "blocked_tools": [ + "lab_scheduler.prioritize", + "protocol_editor.update_active_protocol", + "robot_queue.submit" + ], + "decision_id": "SCOPE-DEC-DF0B16", + "decision_path": "examples/pilot/multi_role_genomics_review/scope_decision.json", + "decision_type": "approve_narrower_scope", + "grant_id": "SCOPE-GRANT-2EE9BA", + "grant_path": "examples/pilot/multi_role_genomics_review/scope_grant.json", + "identity_assurance_level": "IAL0", + "packet_id": "SCOPE-PKT-C1FD8D", "packet_path": "examples/pilot/multi_role_genomics_review/scope_review_packet.json", "production_mode": false, "requested_scope": "single_validation_run_draft", - "required_roles": [ - "domain_scientist", - "protocol_owner" - ], - "session_id": "SCOPE-SESS-26AC2A", - "status": "session_required" + "scope_trust_root_hash": "sha256:6334c56aa73915315692177499c217399e2895bbab1ed6794040b1a2d49f0a77", + "signing_assurance_level": "SAL0", + "status": "completed" } diff --git a/examples/pilot/multi_role_genomics_review/votes.json b/examples/pilot/multi_role_genomics_review/votes.json new file mode 100644 index 0000000..633d1f4 --- /dev/null +++ b/examples/pilot/multi_role_genomics_review/votes.json @@ -0,0 +1,12 @@ +{ + "votes": [ + { + "reviewer": "examples/pilot/multi_role_genomics_review/reviewer_domain_scientist.json", + "decision": "examples/pilot/multi_role_genomics_review/decision_domain_scientist.json" + }, + { + "reviewer": "examples/pilot/multi_role_genomics_review/reviewer_protocol_owner.json", + "decision": "examples/pilot/multi_role_genomics_review/decision_protocol_owner.json" + } + ] +} diff --git a/examples/pilot/registry_signed_decision/expected_verification.json b/examples/pilot/registry_signed_decision/expected_verification.json index 440d9ac..5f4719e 100644 --- a/examples/pilot/registry_signed_decision/expected_verification.json +++ b/examples/pilot/registry_signed_decision/expected_verification.json @@ -4,13 +4,13 @@ "scope_decision.json": "sha256:1627726d073cff8ff14ac149e8ed9be94e3b2a5f55596885925ea4f77e2a74e9", "scope_grant.json": "sha256:cafe5cccb559769e20c3a5d1ef0242f3f388db66ce80161cffe6a01b5d3bde1c", "scope_review_packet.json": "sha256:84eba11c3b0632906d74334b4c93b7a6630ddb7ed65441d22f25c14eb2ea1f8e", - "summary.json": "sha256:8b06e3c0832d779ce361ca91e8720e455b7dd291c4fafda0505896920d072132" + "summary.json": "sha256:4d10b248f37b4f576616bcca443da1958d4f7290c8555a1bbedbc0bb6cda2484" }, "quality_report_snippet": { "policy_version": "scope-core-v0.8" }, "summary": { - "adapter_contract_version": "scope-akta-review-v0.8.1", + "adapter_contract_version": "scope-akta-review-v1.0", "approved_scope": "protocol_draft", "status": "completed" } diff --git a/examples/pilot/registry_signed_decision/manifest.json b/examples/pilot/registry_signed_decision/manifest.json index 6104f71..71b4cd4 100644 --- a/examples/pilot/registry_signed_decision/manifest.json +++ b/examples/pilot/registry_signed_decision/manifest.json @@ -2,7 +2,7 @@ "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", + "akta_review_contract_version": "scope-akta-review-v1.0", "artifacts": [ { "path": "scope_review_packet.json", diff --git a/examples/pilot/registry_signed_decision/summary.json b/examples/pilot/registry_signed_decision/summary.json index 071f9e0..3f8a478 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.1", + "adapter_contract_version": "scope-akta-review-v1.0", "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 index 64ecdbc..b1f5281 100644 --- a/examples/pilot/single_reviewer_protocol_draft/expected_verification.json +++ b/examples/pilot/single_reviewer_protocol_draft/expected_verification.json @@ -4,14 +4,14 @@ "scope_decision.json": "sha256:720fb6762fbb00e467a3709e6c17ffa4beaa6cd5ad80245d0093b9f2b0305bd5", "scope_grant.json": "sha256:88414a99bd54bf18697db5002954c4c19dd589ee2683a7026aca75b5e13adf16", "scope_review_packet.json": "sha256:26ea5cbbedb2ce606fd892a23e97df8528b53fd6a17ca48708f2943914066d7e", - "summary.json": "sha256:deecbf7bc3b462af97bebe1a522e9f6312291077edc5a624cdc450156470b47e" + "summary.json": "sha256:eb7ec368efc8d42e78a581c31316ddf59e7e0203e98ed06b00f5739a1342b921" }, "forbidden_files": [], "quality_report_snippet": { "policy_version": "scope-core-v0.8" }, "summary": { - "adapter_contract_version": "scope-akta-review-v0.8.1", + "adapter_contract_version": "scope-akta-review-v1.0", "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 index 15e911b..6a40aba 100644 --- a/examples/pilot/single_reviewer_protocol_draft/manifest.json +++ b/examples/pilot/single_reviewer_protocol_draft/manifest.json @@ -2,7 +2,7 @@ "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", + "akta_review_contract_version": "scope-akta-review-v1.0", "artifacts": [ { "path": "scope_review_packet.json", diff --git a/examples/pilot/single_reviewer_protocol_draft/summary.json b/examples/pilot/single_reviewer_protocol_draft/summary.json index c98a2ef..ee174bb 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.1", + "adapter_contract_version": "scope-akta-review-v1.0", "allowed_tools": [ "protocol_editor.draft_change" ], diff --git a/pyproject.toml b/pyproject.toml index 93f9f22..ae43668 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scope-protocol" -version = "0.11.0" +version = "1.0.0" description = "Scoped Scientific Authorization Protocol for AI-shaped science" readme = "README.md" license = { text = "MIT" } diff --git a/scope/__init__.py b/scope/__init__.py index a59448d..a8d0883 100644 --- a/scope/__init__.py +++ b/scope/__init__.py @@ -70,6 +70,25 @@ def from_policy_dir( session_store=session_store, ) + @property + def tenant_id(self) -> str | None: + return getattr(self, "_tenant_id", None) + + def effective_queue_dir(self, queue_dir: str | Path | None = None) -> Path: + """Resolve queue directory with optional tenant namespace.""" + from scope.errors import ScopeValidationError + from scope.review_queue import resolve_queue_dir + + if self.tenant_id and queue_dir: + raw = Path(queue_dir) + if raw.name and raw.name != self.tenant_id: + if list(raw.glob("SCOPE-QUEUE-*.json")) and raw.name != self.tenant_id: + raise ScopeValidationError( + f"Tenant {self.tenant_id!r} may not access queue directory " + f"owned by {raw.name!r}" + ) + return resolve_queue_dir(queue_dir, tenant_id=self.tenant_id) + def create_packet( self, akta_record: str | Path | dict[str, Any] | None = None, @@ -726,7 +745,7 @@ def verify_grant( def quality_report(self, *, queue_dir: str | Path | None = None) -> dict[str, Any]: report = analyze_ledger(self.ledger.events(), self.policy) - qm = queue_metrics(queue_dir) + qm = queue_metrics(str(self.effective_queue_dir(queue_dir))) report["metrics"]["open_queue_count"] = qm["open_queue_count"] report["metrics"]["overdue_queue_count"] = qm["overdue_queue_count"] report["metrics"]["ledger_delivery_failure_count"] = self.ledger.delivery_failure_count @@ -791,16 +810,18 @@ def create_review_queue( sla_hours: int = 72, auto_assign: bool = False, ) -> ReviewQueue: + effective = self.effective_queue_dir(queue_dir) + persist = queue_dir is not None or self.tenant_id is not None entry = ReviewQueue.create( packet, sla_hours=sla_hours, - queue_dir=queue_dir, - persist=queue_dir is not None, + queue_dir=effective, + persist=persist, ) if auto_assign: reviewer = resolve_auto_assign(packet, self.policy) entry.assign(reviewer) - if queue_dir: + if persist: entry.save() return entry @@ -812,7 +833,11 @@ def escalate_review_queues( ) -> list[dict[str, Any]]: from scope.workflow_escalation import emit_sla_breach_events, scan_overdue_queues - breaches = scan_overdue_queues(queue_dir, self.policy.policy_dir, dry_run=dry_run) + breaches = scan_overdue_queues( + str(self.effective_queue_dir(queue_dir)), + self.policy.policy_dir, + dry_run=dry_run, + ) if not dry_run: emit_sla_breach_events(self, breaches, policy_dir=self.policy.policy_dir) return breaches @@ -941,7 +966,7 @@ def cancel_review_queue( return entry def review_queue_status(self, *, queue_dir: str | Path | None = None) -> dict[str, Any]: - return aggregate_queue_status(queue_dir) + return aggregate_queue_status(str(self.effective_queue_dir(queue_dir))) __all__ = [ diff --git a/scope/_version.py b/scope/_version.py index 391434f..d33afd6 100644 --- a/scope/_version.py +++ b/scope/_version.py @@ -1,3 +1,3 @@ """Package version (single source of truth).""" -__version__ = "0.11.0" +__version__ = "1.0.0" diff --git a/scope/akta_review.py b/scope/akta_review.py index 207a40c..5fad525 100644 --- a/scope/akta_review.py +++ b/scope/akta_review.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from typing import Any +from typing import Any, cast from scope import ScopeEngine from scope.config import is_production_mode @@ -141,7 +141,7 @@ def _load_json_ref(value: str | Path | dict[str, Any]) -> dict[str, Any]: if isinstance(value, dict): return value with Path(value).open(encoding="utf-8") as fh: - return json.load(fh) + return cast(dict[str, Any], json.load(fh)) def _load_votes_manifest(votes: str | Path | list[Any] | dict[str, Any]) -> list[dict[str, Any]]: diff --git a/scope/integration_versions.py b/scope/integration_versions.py index 21ff4c2..2b21ddf 100644 --- a/scope/integration_versions.py +++ b/scope/integration_versions.py @@ -4,5 +4,5 @@ PF_CORE_VERSION = "pf-core-v0.5" PCS_MANIFEST_VERSION = "pcs-v0.5" -SCOPE_CORE_VERSION = "scope-core-v0.11" -AKTA_REVIEW_CONTRACT_VERSION = "scope-akta-review-v0.9" +SCOPE_CORE_VERSION = "scope-core-v1.0" +AKTA_REVIEW_CONTRACT_VERSION = "scope-akta-review-v1.0" diff --git a/scripts/ecosystem_demo.sh b/scripts/ecosystem_demo.sh index a5edab6..586d384 100644 --- a/scripts/ecosystem_demo.sh +++ b/scripts/ecosystem_demo.sh @@ -4,6 +4,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" +export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" +SCOPE_CMD=(python -m scope.cli) AKTA_TRIGGER="${AKTA_TRIGGER:-examples/protocol_drift/review_trigger.json}" AKTA_RECORD="${AKTA_RECORD:-examples/protocol_drift/akta_record.json}" @@ -37,7 +39,7 @@ if [[ "$USE_REST" == "true" ]]; then --rest-url "$SCOPE_REST_URL" \ "${SIGN_ARGS[@]/#/--signing-key }" else - scope akta review \ + "${SCOPE_CMD[@]}" akta review \ --akta-trigger "$AKTA_TRIGGER" \ --akta-record "$AKTA_RECORD" \ --grant-scope "$GRANT_SCOPE" \ @@ -58,17 +60,17 @@ PCS_OUT="$OUT_DIR/pcs_export" QUALITY_OUT="$OUT_DIR/quality_report.json" echo "== Step 2: SCOPE → PF obligation export ==" -scope export pf --grant "$GRANT" --out "$PF_OUT" --validate --live +"${SCOPE_CMD[@]}" export pf --grant "$GRANT" --out "$PF_OUT" --validate --live echo "== Step 3: PF simulated block → SCOPE violation ==" -python scripts/pf_inject_violation.py \ - --grant "$GRANT" \ - --ledger "$LEDGER" \ - --policy "$POLICY" \ - ${SCOPE_REST_URL:+--rest-url "$SCOPE_REST_URL"} +VIOLATION_ARGS=(--grant "$GRANT" --ledger "$LEDGER" --policy "$POLICY") +if [[ "$USE_REST" == "true" ]]; then + VIOLATION_ARGS+=(--rest-url "$SCOPE_REST_URL") +fi +python scripts/pf_inject_violation.py "${VIOLATION_ARGS[@]}" echo "== Step 4: SCOPE → PCS archive export ==" -scope export pcs \ +"${SCOPE_CMD[@]}" export pcs \ --packet "$PACKET" \ --decision "$DECISION" \ --grant "$GRANT" \ @@ -78,9 +80,9 @@ scope export pcs \ --policy "$POLICY" echo "== Step 5: Quality report (expect non-zero violation rate) ==" -scope quality report --ledger "$LEDGER" --out "$QUALITY_OUT" --queue-dir "$QUEUE_DIR" +"${SCOPE_CMD[@]}" quality report --ledger "$LEDGER" --out "$QUALITY_OUT" --queue-dir "$QUEUE_DIR" -python - <<'PY' +python - "$QUALITY_OUT" <<'PY' import json, sys from pathlib import Path report = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) @@ -91,6 +93,5 @@ print(f"runtime_violation_outcome_count={count}") if rate <= 0 or count <= 0: sys.exit("Expected non-zero runtime violation metrics after PF inject") PY -"$QUALITY_OUT" echo "Ecosystem demo complete -> $OUT_DIR" diff --git a/scripts/verify_ledger_chain.py b/scripts/verify_ledger_chain.py new file mode 100644 index 0000000..f1936d0 --- /dev/null +++ b/scripts/verify_ledger_chain.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Verify hash-chain integrity of a SCOPE ledger JSONL file.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scope.errors import LedgerError +from scope.ledger import ScopeLedger + + +def verify_ledger_chain(path: Path) -> int: + """Load ledger and verify event hash chain; return event count.""" + ledger = ScopeLedger(path) + count = len(ledger.events()) + return count + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify SCOPE ledger hash chain integrity.") + parser.add_argument( + "ledger", + nargs="?", + default=".scope/ledger.jsonl", + help="Path to ledger JSONL file (default: .scope/ledger.jsonl)", + ) + args = parser.parse_args() + path = Path(args.ledger) + if not path.is_file(): + print(f"FAIL: ledger file not found: {path}", file=sys.stderr) + return 1 + try: + count = verify_ledger_chain(path) + except LedgerError as exc: + print(f"FAIL: {exc}", file=sys.stderr) + return 1 + print(f"OK {count} event(s) verified in {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_akta_golden.py b/tests/test_akta_golden.py index 7824816..e8dee05 100644 --- a/tests/test_akta_golden.py +++ b/tests/test_akta_golden.py @@ -6,6 +6,7 @@ from pathlib import Path from scope import ScopeEngine +from scope._version import __version__ ROOT = Path(__file__).resolve().parent.parent NESTED = ROOT / "adapters" / "akta" / "examples" / "akta_record_nested.json" @@ -35,4 +36,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.1" + assert packet["packet_version"] == __version__ diff --git a/tests/test_akta_review_command.py b/tests/test_akta_review_command.py index ee5a1d5..4638ba4 100644 --- a/tests/test_akta_review_command.py +++ b/tests/test_akta_review_command.py @@ -9,6 +9,7 @@ from click.testing import CliRunner from scope.cli import main +from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION ROOT = Path(__file__).resolve().parent.parent EX = ROOT / "examples" / "protocol_drift" @@ -54,7 +55,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.1" + assert summary["adapter_contract_version"] == AKTA_REVIEW_CONTRACT_VERSION 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_session_mode.py b/tests/test_akta_review_session_mode.py index 3ea53a0..e224b61 100644 --- a/tests/test_akta_review_session_mode.py +++ b/tests/test_akta_review_session_mode.py @@ -12,6 +12,7 @@ from scope.akta_review import run_akta_review from scope.cli import main from scope.errors import ScopeValidationError +from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION from scope.schema_util import validate_artifact ROOT = Path(__file__).resolve().parent.parent @@ -74,7 +75,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.1" + assert on_disk["adapter_contract_version"] == AKTA_REVIEW_CONTRACT_VERSION def test_akta_review_cli_session_flag(tmp_path: Path) -> None: @@ -136,3 +137,25 @@ def test_akta_review_cli_multi_role_without_session_fails(tmp_path: Path) -> Non assert result.exit_code != 0 message = str(result.exception or result.output) assert "multi-role review session" in message.lower() or "--session" in message + + +def test_multi_role_session_complete_produces_grant(tmp_path: Path) -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + weak = ROOT / "examples" / "weak_evidence_validation_review" + pilot = ROOT / "examples" / "pilot" / "multi_role_genomics_review" + out_dir = tmp_path / "complete_out" + summary = run_akta_review( + engine, + akta_record=json.loads((weak / "akta_record.json").read_text(encoding="utf-8")), + akta_trigger=json.loads((weak / "review_trigger.json").read_text(encoding="utf-8")), + grant_scope="single_validation_run_draft", + reviewer=pilot / "reviewer_protocol_owner.json", + decision_rationale="Session-complete path", + out_dir=out_dir, + session_complete=True, + votes=pilot / "votes.json", + ) + assert summary["status"] == "completed" + assert summary["grant_id"].startswith("SCOPE-GRANT-") + assert (out_dir / "scope_grant.json").exists() + validate_artifact(summary, "scope_akta_review_summary.schema.json") diff --git a/tests/test_eval_extended.py b/tests/test_eval_extended.py index 1a79fd5..52f3672 100644 --- a/tests/test_eval_extended.py +++ b/tests/test_eval_extended.py @@ -19,4 +19,4 @@ def test_extended_eval_scenarios_pass(): env=env, ) assert result.returncode == 0, result.stdout + result.stderr - assert "22/22 scenarios passed" in result.stdout + assert "23/23 scenarios passed" in result.stdout diff --git a/tests/test_institutional_pilot.py b/tests/test_institutional_pilot.py index de92219..ffa1712 100644 --- a/tests/test_institutional_pilot.py +++ b/tests/test_institutional_pilot.py @@ -4,6 +4,7 @@ from pathlib import Path from scope import ScopeEngine +from scope._version import __version__ ROOT = Path(__file__).resolve().parent.parent PILOT = ROOT / "examples" / "institutional_pilot" @@ -15,7 +16,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.1" + assert packet["packet_version"] == __version__ fixture = json.loads((PILOT / "scope_packet.json").read_text(encoding="utf-8")) for key in ( "packet_version", diff --git a/tests/test_live_contracts.py b/tests/test_live_contracts.py index 4d4bd93..f180d56 100644 --- a/tests/test_live_contracts.py +++ b/tests/test_live_contracts.py @@ -146,4 +146,5 @@ def test_ecosystem_demo_script_dry(tmp_path: Path) -> None: ) assert completed.returncode == 0, completed.stderr or completed.stdout quality = json.loads((out_dir / "quality_report.json").read_text(encoding="utf-8")) - assert quality["summary"]["post_approval_runtime_violation_rate"] > 0 + assert quality["metrics"]["post_approval_runtime_violation_rate"] > 0 + assert quality["metrics"]["runtime_violation_outcome_count"] > 0 diff --git a/tests/test_pilot_fixtures.py b/tests/test_pilot_fixtures.py index 1190a04..20d496f 100644 --- a/tests/test_pilot_fixtures.py +++ b/tests/test_pilot_fixtures.py @@ -9,6 +9,7 @@ import pytest +from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION from scope.schema_util import validate_artifact ROOT = Path(__file__).resolve().parent.parent @@ -30,7 +31,10 @@ "multi_role_genomics_review": { "required_files": [ "scope_review_packet.json", + "scope_decision.json", + "scope_grant.json", "summary.json", + "votes.json", "reviewer_protocol_owner.json", "reviewer_domain_scientist.json", "decision_protocol_owner.json", @@ -39,8 +43,8 @@ "quality_report_snippet.json", "README.md", ], - "summary_status": "session_required", - "forbidden_files": ["scope_grant.json"], + "summary_status": "completed", + "policy_version": "scope-core-v1.0", }, "expired_queue_reopen": { "required_files": [ @@ -92,9 +96,11 @@ def test_pilot_fixture_files_present(scenario: str) -> None: @pytest.mark.parametrize("scenario", list(SCENARIOS)) def test_pilot_quality_snippet_policy_version(scenario: str) -> None: + spec = SCENARIOS[scenario] snippet_path = PILOT / scenario / "quality_report_snippet.json" snippet = json.loads(snippet_path.read_text(encoding="utf-8")) - assert snippet.get("policy_version") == "scope-core-v0.8" + expected = spec.get("policy_version", "scope-core-v0.8") + assert snippet.get("policy_version") == expected @pytest.mark.parametrize( @@ -105,7 +111,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.1" + assert summary["adapter_contract_version"] == AKTA_REVIEW_CONTRACT_VERSION schema = ( "scope_akta_review_session_summary.schema.json" if spec["summary_status"] == "session_required" diff --git a/tests/test_quality_metrics_complete.py b/tests/test_quality_metrics_complete.py index 85fc8af..e82e167 100644 --- a/tests/test_quality_metrics_complete.py +++ b/tests/test_quality_metrics_complete.py @@ -149,6 +149,6 @@ def test_post_approval_runtime_violation_rate(tmp_path): }, ) grant = engine.issue_grant(packet, decision) - engine.check_grant(grant, "robot_queue.submit", {}) + engine.record_runtime_violation(grant["grant_id"], tool="robot_queue.submit", reason="test") report = engine.quality_report() assert report["metrics"]["post_approval_runtime_violation_rate"] > 0 diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index aa0973a..059558b 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -11,6 +11,7 @@ from adapters.generic_rest.server import app from scope._version import __version__ +from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION from scope.signing import Ed25519Signer ROOT = Path(__file__).resolve().parent.parent @@ -406,12 +407,102 @@ 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.1" + assert summary["adapter_contract_version"] == AKTA_REVIEW_CONTRACT_VERSION 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() +def test_akta_review_rest_session_complete(client, tmp_path): + from scope.schema_util import validate_artifact + + weak = ROOT / "examples" / "weak_evidence_validation_review" + pilot = ROOT / "examples" / "pilot" / "multi_role_genomics_review" + out_dir = tmp_path / "akta_complete" + votes = json.loads((pilot / "votes.json").read_text(encoding="utf-8")) + payload = { + "akta_record": _load("akta_record.json", weak), + "akta_trigger": _load("review_trigger.json", weak), + "grant_scope": "single_validation_run_draft", + "reviewer": _load("reviewer_protocol_owner.json", pilot), + "decision_rationale": "REST session-complete path.", + "out_dir": str(out_dir), + "session_complete": True, + "votes": votes["votes"], + } + resp = client.post("/v0/akta/review", json=payload) + assert resp.status_code == 200 + summary = resp.json() + assert summary["status"] == "completed" + assert summary["grant_id"].startswith("SCOPE-GRANT-") + assert (out_dir / "scope_grant.json").exists() + validate_artifact(summary, "scope_akta_review_summary.schema.json") + + +def test_rest_audit_logging(client, tmp_path, monkeypatch): + from adapters.generic_rest import server + from scope.ledger import ScopeLedger + + ledger_path = tmp_path / "audit_ledger.jsonl" + monkeypatch.setenv("SCOPE_LEDGER_PATH", str(ledger_path)) + monkeypatch.setenv("SCOPE_REST_AUDIT", "true") + server.reset_engine_cache() + + resp = client.post( + "/v0/packets", + json=_packet_payload(), + headers={"X-Scope-Caller-Id": "audit-test-caller"}, + ) + assert resp.status_code == 200 + + ledger = ScopeLedger(ledger_path) + audit_events = [e for e in ledger.events() if e["event_type"] == "rest_api_audit"] + assert audit_events + assert audit_events[-1]["metadata"]["caller"] == "audit-test-caller" + assert audit_events[-1]["metadata"]["path"] == "/v0/packets" + + +def test_tenant_queue_isolation_rest(client, tmp_path): + from adapters.generic_rest import server + + queue_base = tmp_path / "queues" + server.reset_engine_cache() + packet = client.post("/v0/packets", json=_packet_payload()).json() + + created = client.post( + "/v0/review-queue", + json={"packet": packet, "sla_hours": 24, "queue_dir": str(queue_base)}, + headers={"X-Scope-Tenant-Id": "lab-a"}, + ) + assert created.status_code == 200 + queue_id = created.json()["queue_id"] + assert (queue_base / "lab-a" / f"{queue_id}.json").is_file() + + listed_a = client.get( + "/v0/review-queue", + params={"queue_dir": str(queue_base)}, + headers={"X-Scope-Tenant-Id": "lab-a"}, + ) + assert listed_a.status_code == 200 + assert any(e["queue_id"] == queue_id for e in listed_a.json()["entries"]) + + listed_b = client.get( + "/v0/review-queue", + params={"queue_dir": str(queue_base)}, + headers={"X-Scope-Tenant-Id": "lab-b"}, + ) + assert listed_b.status_code == 200 + assert not any(e["queue_id"] == queue_id for e in listed_b.json()["entries"]) + + denied = client.post( + f"/v0/review-queue/{queue_id}/assign", + json={"reviewer": {"reviewer_id": "r1", "role": "protocol_owner"}}, + params={"queue_dir": str(queue_base / "lab-a")}, + headers={"X-Scope-Tenant-Id": "lab-b"}, + ) + assert denied.status_code == 403 + + def test_akta_review_rest_reviewer_id_mismatch(client, tmp_path): out_dir = tmp_path / "akta_mismatch" payload = { diff --git a/tests/test_verify_ledger_chain.py b/tests/test_verify_ledger_chain.py new file mode 100644 index 0000000..25aff0b --- /dev/null +++ b/tests/test_verify_ledger_chain.py @@ -0,0 +1,39 @@ +"""Tests for scripts/verify_ledger_chain.py.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from scope import ScopeEngine +from scope.errors import LedgerError +from scripts.verify_ledger_chain import verify_ledger_chain + + +def test_verify_ledger_chain_valid(tmp_path: Path) -> None: + root = Path(__file__).resolve().parent.parent + ledger_path = tmp_path / "events.jsonl" + engine = ScopeEngine.from_policy_dir(root / "policy", ledger_path=ledger_path) + ex = root / "examples" / "protocol_change_review" + packet = engine.create_packet(ex / "akta_record.json", ex / "review_trigger.json") + engine.open_review(packet["packet_id"], actor_id="r1") + count = verify_ledger_chain(ledger_path) + assert count >= 2 + + +def test_verify_ledger_chain_detects_tamper(tmp_path: Path) -> None: + root = Path(__file__).resolve().parent.parent + ledger_path = tmp_path / "events.jsonl" + engine = ScopeEngine.from_policy_dir(root / "policy", ledger_path=ledger_path) + ex = root / "examples" / "protocol_change_review" + engine.create_packet(ex / "akta_record.json", ex / "review_trigger.json") + lines = ledger_path.read_text(encoding="utf-8").strip().splitlines() + event = json.loads(lines[0]) + event["event_type"] = "tampered" + lines[0] = json.dumps(event, sort_keys=True) + ledger_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + try: + verify_ledger_chain(ledger_path) + raise AssertionError("expected LedgerError") + except LedgerError: + pass diff --git a/tests/test_workflow_product.py b/tests/test_workflow_product.py index 75895b3..75af824 100644 --- a/tests/test_workflow_product.py +++ b/tests/test_workflow_product.py @@ -6,6 +6,8 @@ from pathlib import Path from unittest.mock import patch +import pytest + from scope.notifications import LogSink, WebhookSink, emit_notification from scope.review_queue import resolve_queue_dir