diff --git a/CHANGELOG.md b/CHANGELOG.md
index 760e76c..3cbb1c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
# Changelog
+## v0.7.0 (2026-06-28)
+
+Institutional pilot hardening release:
+
+- **Identity assurance (IAL0–IAL4)**: `scope/identity_assurance.py`, provenance on decisions/grants (including session grants), OIDC + org RBAC wiring; `identity_source` and `authority_checks` on decision/grant provenance
+- **RBAC vs SCOPE authority separation**: two-stage checks in `scope/authority.py` with explicit `authority_checks` provenance block
+- **Ledger delivery semantics**: `best_effort`, `at_least_once`, `fail_closed` modes with spool, `delivery_state`, and fail-closed blocking on high-risk grant issuance
+- **Review queue state machine**: explicit transitions (`in_review`, `needs_information`, `escalated`, `expired`, reopen)
+- **Signing assurance (SAL0–SAL4)**: minimum policy, production enforcement, HSM/KMS external interface
+- **Frozen AKTA review contract**: `summary.json` schema, `scope-akta-review-v0.7` adapter version
+- Policy bundle tagged `scope-core-v0.7`
+
+### Audit fixes (pre-release)
+
+- Grant provenance inherits `identity_claim_hash`, `authority_checks`, and `delegation_id` from issuing decisions (including session grants)
+- `summary.json` includes `production_mode` on all AKTA review paths; schema and docs aligned
+- Review queue: `information_received_at`, escalation reason/actor fields, ledger event on engine escalation
+- End-to-end tests and extended evals assert decision/grant provenance field parity
+
## v0.6.0 (2026-06-28)
Institutional foundations release:
diff --git a/README.md b/README.md
index f9e4b23..fb86b93 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,76 @@
-# SCOPE
+
-Home: [https://github.com/fraware/SCOPE](https://github.com/fraware/SCOPE)
+
+ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗
+ ██╔════╝██╔═══██╗██╔═══██╗██╔══██╗██╔════╝
+ ███████╗██║ ██║██║ ██║██████╔╝█████╗
+ ╚════██║██║ ██║██║ ██║██╔═══╝ ██╔══╝
+ ███████║╚██████╔╝╚██████╔╝██║ ███████╗
+ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝
+
-SCOPE is the Scoped Scientific Authorization Protocol (v0.6.0).
+**Scoped authorization for AI-shaped scientific work**
-AKTA can decide that an AI-shaped scientific action requires review or authorization. SCOPE turns that decision into a structured review packet, assigns the right reviewer role, captures a scoped decision, emits a bounded grant, enforces expiration, and produces artifacts that can be verified and packaged.
+[](https://github.com/fraware/SCOPE/releases)
+[](LICENSE)
+[](https://www.python.org/downloads/)
+[](https://github.com/fraware/SCOPE/actions/workflows/ci.yml)
-Review is not a checkbox. Review is a role, artifact, scope, expiration, and accountability trail.
+
+
+Repository: [https://github.com/fraware/SCOPE](https://github.com/fraware/SCOPE)
+
+---
+
+## Why SCOPE exists
+
+When an AI system proposes a scientific action — updating a protocol, submitting to a robot queue, or publishing a claim — someone with the right expertise should review it before tools run. Most systems treat that as a checkbox. SCOPE treats it as a **structured workflow**: who reviewed, what they saw, what scope they approved, when it expires, and what happened at runtime.
+
+SCOPE does **not** certify that an action is safe or correct. It produces auditable artifacts and enforces the scope a qualified reviewer actually approved.
+
+## What SCOPE does
+
+- Builds a **review packet** from upstream signals (what changed, what evidence exists, who should review)
+- Captures a **typed reviewer decision** with rationale, role, and provenance
+- Issues a **bounded grant** — allowed tools, scope, and expiration — tied to that decision
+- **Enforces grants at runtime** and records violations, revocations, and expirations
+- Maintains a **hash-chained ledger** for accountability and quality metrics
+- **Exports** obligations and packaging artifacts for downstream runtimes (PF-Core, PCS)
+
+```mermaid
+flowchart LR
+ A[Upstream signal] --> B[Review packet]
+ B --> C[Reviewer decision]
+ C --> D[Scoped grant]
+ D --> E[Runtime enforcement]
+ E --> F[Ledger and exports]
+```
+
+> **Review is not a checkbox.** Review is a role, an artifact, a scope, an expiration, and an accountability trail.
+
+---
## Quick start
+Under two minutes from clone to a working review:
+
```bash
+git clone https://github.com/fraware/SCOPE.git
+cd SCOPE
pip install -e ".[dev]"
pytest
-python evals/run_review_cases.py
```
-## CLI
+Run the bundled evaluation scenarios (8 core; add `--extended` for 13 more, 21 total):
+
+```bash
+python evals/run_review_cases.py
+python evals/run_review_cases.py --extended
+```
-### AKTA review (one-shot)
+### AKTA one-shot review
-Primary AKTA integration path — packet, decision, grant, and summary in one command:
+The primary integration path when [AKTA](docs/akta_integration.md) flags an action for review: one command produces the packet, decision, grant, and summary.
```bash
scope akta review \
@@ -29,234 +79,207 @@ scope akta review \
--grant-scope protocol_draft \
--reviewer examples/protocol_drift/reviewer_protocol_owner.json \
--decision-rationale "Narrow protocol draft approval only." \
- --out-dir /tmp/akta_review_out
+ --out-dir ./out/akta_review
```
-Writes `scope_review_packet.json`, `scope_decision.json`, `scope_grant.json`, and `summary.json` to `--out-dir`.
+Outputs in `./out/akta_review/`:
-In production mode, also pass `--signing-key` (Ed25519 private key PEM) so the decision is signed before grant issue.
+| File | Purpose |
+|------|---------|
+| `scope_review_packet.json` | Artifacts and context for the reviewer |
+| `scope_decision.json` | Structured approval with rationale |
+| `scope_grant.json` | Runtime authorization bound to the decision |
+| `summary.json` | One-line status for automation |
-### Packet workflow
+Full contract: [docs/akta_review_contract.md](docs/akta_review_contract.md). End-to-end demo: [docs/akta_scope_demo.md](docs/akta_scope_demo.md).
-```bash
-scope packet create --akta-record examples/protocol_change_review/akta_record.json \
- --akta-trigger examples/protocol_change_review/review_trigger.json \
- --vsa-report adapters/vsa/examples/scientific_report_example.json \
- --out /tmp/packet.json
+---
-scope packet validate /tmp/packet.json
+## Core workflows
-scope packet render /tmp/packet.json --format markdown --out /tmp/packet.md
-```
+### Review a request
-### Decision and grant
+Build and validate a packet from AKTA inputs, optionally enriched with a VSA scientific report:
```bash
-scope decision submit --packet /tmp/packet.json \
- --reviewer examples/protocol_change_review/reviewer_protocol_owner.json \
- --decision examples/protocol_change_review/decision.json \
- --out /tmp/decision.json
-
-scope grant issue --packet /tmp/packet.json --decision /tmp/decision.json --out /tmp/grant.json
-
-scope grant check --grant /tmp/grant.json --requested-tool robot_queue.submit \
- --context examples/protocol_change_review/current_context.json \
- --ledger /tmp/scope_events.jsonl
-
-scope grant revoke --grant-id SCOPE-GRANT-XXXXXX --ledger /tmp/scope_events.jsonl
+scope packet create \
+ --akta-record examples/protocol_change_review/akta_record.json \
+ --akta-trigger examples/protocol_change_review/review_trigger.json \
+ --out ./out/packet.json
-scope grant status --grant-id SCOPE-GRANT-XXXXXX --ledger /tmp/scope_events.jsonl
+scope packet validate ./out/packet.json
+scope packet render ./out/packet.json --format markdown --out ./out/packet.md
```
-### Multi-reviewer sessions (A6 and similar)
-
-```bash
-scope review session create --packet /tmp/packet.json --out /tmp/session.json \
- --session-store json --session-dir /tmp/sessions
-
-scope review session vote --session /tmp/session.json --packet /tmp/packet.json \
- --reviewer examples/reviewer_domain_scientist.json \
- --decision examples/decision_ds.json --out /tmp/decision_ds.json \
- --session-store json --session-dir /tmp/sessions
+For multi-reviewer cases, use review sessions (`scope review session …`) or the review queue (`scope review queue …`). See [docs/reviewer_guide.md](docs/reviewer_guide.md).
-scope review session vote --session /tmp/session.json --packet /tmp/packet.json \
- --reviewer examples/reviewer_protocol_owner.json \
- --decision examples/decision_po.json --out /tmp/decision_po.json \
- --session-store json --session-dir /tmp/sessions
+### Issue a grant
-scope review session issue-grant --session /tmp/session.json --packet /tmp/packet.json \
- --decision /tmp/decision_ds.json --decision /tmp/decision_po.json --out /tmp/grant.json \
- --session-store json --session-dir /tmp/sessions
-
-scope review session status --session-id SCOPE-SESS-XXXXXX --packet /tmp/packet.json \
- --session-store json --session-dir /tmp/sessions
-```
-
-Session backends: `memory` (default), `json`, `sqlite`. Use `--session-dir` for persistence path.
-
-Review lifecycle ledger events:
+Submit a reviewer decision, then issue a grant from the approved scope:
```bash
-scope review open --packet-id SCOPE-PKT-XXXXXX --actor-id reviewer-1 --ledger /tmp/scope_events.jsonl
+scope decision submit \
+ --packet ./out/packet.json \
+ --reviewer examples/protocol_change_review/reviewer_protocol_owner.json \
+ --decision examples/protocol_change_review/decision.json \
+ --out ./out/decision.json
-scope review view-artifact --packet-id SCOPE-PKT-XXXXXX --artifact protocol_diff_ref \
- --actor-id reviewer-1 --ledger /tmp/scope_events.jsonl
+scope grant issue \
+ --packet ./out/packet.json \
+ --decision ./out/decision.json \
+ --out ./out/grant.json
```
-Quorum modes (`require_all`, `require_any`, `n_of_m`, `statistical_co_review`) and
-`safety_veto_roles` are configured via optional `--quorum-policy` on session create.
+In production deployments, decisions must be cryptographically signed before grant issue. See [docs/trusted_boundary.md](docs/trusted_boundary.md) and [docs/key_management.md](docs/key_management.md).
-### Signing and production mode
+### Check authorization at runtime
-Production mode requires a signed decision before grant issue. Decisions may be submitted unsigned, then signed:
+Before a tool executes, verify the grant still allows the requested action:
```bash
-export SCOPE_PRODUCTION_MODE=true
-
-scope decision submit --packet /tmp/packet.json --reviewer reviewer.json \
- --decision decision.json --out /tmp/decision.json
-
-scope decision sign --decision /tmp/decision.json --key keys/reviewer.pem --out /tmp/signed_decision.json
-scope decision validate --require-signature /tmp/signed_decision.json
-
-scope grant issue --packet /tmp/packet.json --decision /tmp/signed_decision.json --out /tmp/grant.json
-scope grant sign --grant /tmp/grant.json --key keys/reviewer.pem --out /tmp/signed_grant.json
-scope verify --artifact /tmp/signed_decision.json --public-key keys/reviewer.pub --type decision
+scope grant check \
+ --grant ./out/grant.json \
+ --requested-tool robot_queue.submit \
+ --context examples/protocol_change_review/current_context.json \
+ --ledger ./out/scope_events.jsonl
```
-Use `--public-key` for verification without private key access. `--key` remains for dev signing only.
-
-See [docs/trusted_boundary.md](docs/trusted_boundary.md) for trust assumptions.
-
-### Export and quality
+Returns `ALLOWED` or `BLOCKED`. Revoke or inspect status via `scope grant revoke` and `scope grant status`.
-```bash
-scope export pf --grant /tmp/grant.json --out /tmp/pf_obligation.json --validate --live
+> For the complete CLI surface (export, quality, identity, policy overlays), run `scope --help` or see [docs/scoped_scientific_authorization.md](docs/scoped_scientific_authorization.md).
-scope export pcs --packet /tmp/packet.json --decision /tmp/decision.json \
- --grant /tmp/grant.json --out /tmp/pcs_bundle --validate --live
+---
-scope quality report --ledger /tmp/scope_events.jsonl --out /tmp/quality_report.json \
- --queue-dir .scope/queues
-```
+## Ecosystem
-Set `PF_CORE_REPO_PATH` or `PCS_CORE_REPO_PATH` to sibling repo roots for optional live contract validation (`--live`). When unset, validation skips with an explicit message.
+| Component | Role |
+|-----------|------|
+| **AKTA** | Classifies whether a scientific action needs review or authorization |
+| **SCOPE** | Runs the review workflow and issues scoped grants |
+| **PF-Core** | Runtime obligation verification from grant exports |
+| **PCS** | Release packaging from packet, decision, and grant artifacts |
+| **VSA** | Optional scientific report enrichment for review packets |
-### Review queue
+Integration field mappings: [docs/external_integration_contracts.md](docs/external_integration_contracts.md). Optional REST API: install with `pip install -e ".[rest]"` and run `uvicorn adapters.generic_rest.server:app`.
-```bash
-scope review queue create --packet /tmp/packet.json --out /tmp/queue.json \
- --queue-dir .scope/queues
+Institutional pilots: start with [docs/institutional_pilot_guide.md](docs/institutional_pilot_guide.md) and sample fixtures in [examples/institutional_pilot/](examples/institutional_pilot/).
-scope review queue assign --queue /tmp/queue.json \
- --reviewer examples/protocol_change_review/reviewer_protocol_owner.json
+---
-scope review queue status --queue-dir .scope/queues
+## Documentation
-scope review queue list --queue-dir .scope/queues
+| Document | Description |
+|----------|-------------|
+| [scoped_scientific_authorization.md](docs/scoped_scientific_authorization.md) | Protocol overview and doc index |
+| [akta_review_contract.md](docs/akta_review_contract.md) | Frozen `scope akta review` output contract |
+| [akta_integration.md](docs/akta_integration.md) | AKTA adapter and trigger mapping |
+| [akta_scope_demo.md](docs/akta_scope_demo.md) | Full AKTA → SCOPE → PF → PCS walkthrough |
+| [reviewer_guide.md](docs/reviewer_guide.md) | Reviewer role guidance |
+| [review_doctrine.md](docs/review_doctrine.md) | Review principles and accountability |
+| [institutional_pilot_guide.md](docs/institutional_pilot_guide.md) | Pilot workshop and lab checklist |
+| [institutional_guide.md](docs/institutional_guide.md) | Institutional deployment overview |
+| [trusted_boundary.md](docs/trusted_boundary.md) | Trust assumptions and production mode |
+| [limitations.md](docs/limitations.md) | In-repo vs external boundaries |
+| [identity_assurance.md](docs/identity_assurance.md) | Reviewer identity provenance |
+| [signing_assurance.md](docs/signing_assurance.md) | Decision and grant signing provenance |
+| [rbac_scope_authority.md](docs/rbac_scope_authority.md) | Org RBAC and scope policy checks |
+| [key_management.md](docs/key_management.md) | Key registry and signing providers |
+| [quality_metrics.md](docs/quality_metrics.md) | Ledger-backed review quality metrics |
+| [threat_model.md](docs/threat_model.md) | Threats addressed and residual risk |
+| [external_integration_contracts.md](docs/external_integration_contracts.md) | Cross-repo field mappings |
+| [pf_core_bridge.md](docs/pf_core_bridge.md) | PF-Core obligation export |
+| [pcs_export.md](docs/pcs_export.md) | PCS bundle export |
+| [evidence_vocab_mapping.md](docs/evidence_vocab_mapping.md) | Evidence vocabulary alignment |
+| [field_thesis.md](docs/field_thesis.md) | Design rationale |
+| [CHANGELOG.md](CHANGELOG.md) | Release history |
-scope review queue decide --queue /tmp/queue.json --decision-id SCOPE-DEC-XXXXXX
+---
-scope review queue grant --queue /tmp/queue.json --grant-id SCOPE-GRANT-XXXXXX
+## Contributing
-scope review queue close --queue /tmp/queue.json --reason withdrawn
-```
+Contributions are welcome — bug reports, docs improvements, adapters, and test coverage all help.
-### Key registry
+1. **Open an issue** to discuss larger changes: [GitHub Issues](https://github.com/fraware/SCOPE/issues)
+2. **Fork, branch, and open a PR**: [GitHub Pull Requests](https://github.com/fraware/SCOPE/pulls)
+3. **Run the same checks as CI** before submitting:
```bash
-scope key register --reviewer-id protocol_owner_1 \
- --public-key keys/reviewer.pub
+# Linux / macOS
+bash scripts/ci.sh
-scope key list
-
-scope key verify-registry --decision /tmp/signed_decision.json
+# Windows
+.\scripts\ci.ps1
```
-See [docs/key_management.md](docs/key_management.md).
-
-## REST API
+CI runs on Python 3.10, 3.11, and 3.12: `ruff` lint, `mypy` typecheck, `pytest`, and all 21 evaluation scenarios with `--extended`.
-Optional FastAPI server (install with `pip install -e ".[rest]"`):
+Individual commands:
```bash
-uvicorn adapters.generic_rest.server:app --reload
+pip install -e ".[dev]"
+ruff check scope tests evals adapters
+mypy scope
+pytest
+python evals/run_review_cases.py --extended
```
-Set `SCOPE_LEDGER_PATH` for ledger-backed grant check/revoke/status.
-Set `SCOPE_POLICY_DIR` to override the default `policy/` directory (REST key registry).
-Set `SCOPE_SIGNING_KEY` for sign/verify endpoints.
-Set `SCOPE_SESSION_STORE` and `SCOPE_SESSION_DIR` for persistent review sessions (`json` or `sqlite`).
-Set `SCOPE_API_KEY` to require `Authorization: Bearer ` on all endpoints except `/v0/health`.
-Set `SCOPE_REVIEW_ROUTE_PROMOTION=true` (default) to promote valid AKTA `review_scope` values to `requested_scope`.
-
-Grant check returns `{allowed, reason, code}` where `code` is `allowed`, `tool_blocked`, `tool_not_allowed`, `grant_expired`, or `grant_revoked`.
-
-| Method | Path | Description |
-|--------|------|-------------|
-| GET | `/v0/health` | Health check |
-| POST | `/v0/packets` | Create packet |
-| POST | `/v0/packets/validate` | Validate packet |
-| POST | `/v0/packets/render` | Render packet (markdown/html) |
-| POST | `/v0/decisions` | Submit decision |
-| POST | `/v0/decisions/sign` | Sign decision |
-| POST | `/v0/review-sessions` | Create review session |
-| GET | `/v0/review-sessions/{id}` | Session status |
-| POST | `/v0/review-sessions/{id}/votes` | Submit session vote |
-| POST | `/v0/review-sessions/{id}/grants` | Issue grant from session |
-| POST | `/v0/grants` | Issue grant |
-| POST | `/v0/grants/check` | Runtime grant check |
-| POST | `/v0/grants/revoke` | Revoke grant |
-| GET | `/v0/grants/{id}/status` | Grant ledger status |
-| POST | `/v0/grants/sign` | Sign grant |
-| POST | `/v0/verify` | Verify signed artifact |
-| GET | `/v0/review-queue` | List queue entries and metrics |
-| POST | `/v0/review-queue` | Create queue entry |
-| POST | `/v0/review-queue/{id}/assign` | Assign reviewer |
-| POST | `/v0/review-queue/{id}/decide` | Mark decided |
-| POST | `/v0/review-queue/{id}/grant` | Mark granted |
-| POST | `/v0/review-queue/{id}/close` | Close without grant |
-| GET | `/v0/keys` | List key registry |
-| POST | `/v0/keys/register` | Register reviewer public key |
-| POST | `/v0/keys/verify-registry` | Verify decision against registry |
-| POST | `/v0/akta/review` | AKTA one-shot review (packet → decision → grant) |
-| GET | `/v0/quality` | Quality report (`?queue_dir=` optional) |
-| POST | `/v0/export/pf` | PF-Core obligation export |
-| POST | `/v0/export/pf/validate` | Validate PF export |
-| POST | `/v0/export/pcs` | PCS bundle export |
-| POST | `/v0/export/pcs/validate` | Validate PCS export |
-
-Full cross-repo demo: [docs/akta_scope_demo.md](docs/akta_scope_demo.md).
-Integration contracts: [docs/external_integration_contracts.md](docs/external_integration_contracts.md).
-
-## Python API
+### Repository layout
+
+| Path | Contents |
+|------|----------|
+| `scope/` | Core protocol engine and CLI |
+| `schemas/` | JSON schemas for artifacts |
+| `policy/` | YAML policy bundles and domain overlays |
+| `adapters/` | AKTA, VSA, PF-Core, PCS, and REST integrations |
+| `examples/` | Scenario fixtures for docs and evals |
+| `evals/` | 8 core + 13 extended evaluation scenarios |
+| `tests/` | Pytest suite |
+| `docs/` | Protocol and integration documentation |
+
+### Python API
```python
from scope import ScopeEngine
engine = ScopeEngine.from_policy_dir("policy/", ledger_path="logs/scope_events.jsonl")
-packet = engine.create_packet("examples/protocol_change_review/akta_record.json",
- "examples/protocol_change_review/review_trigger.json")
-decision = engine.submit_decision(packet, {"reviewer_id": "r1", "role": "protocol_owner"}, {
- "type": "approve_narrower_scope",
- "approved_scope": "protocol_draft",
- "rationale": "Evidence supports validation draft only.",
-})
+packet = engine.create_packet(
+ "examples/protocol_change_review/akta_record.json",
+ "examples/protocol_change_review/review_trigger.json",
+)
+decision = engine.submit_decision(
+ packet,
+ {"reviewer_id": "r1", "role": "protocol_owner"},
+ {
+ "type": "approve_narrower_scope",
+ "approved_scope": "protocol_draft",
+ "rationale": "Evidence supports validation draft only.",
+ },
+)
grant = engine.issue_grant(packet, decision)
-allowed = engine.check_grant_detailed(grant, "protocol_editor.draft_change", {"protocol_version": "protocol_v3"})
+allowed = engine.check_grant_detailed(
+ grant, "protocol_editor.draft_change", {"protocol_version": "protocol_v3"}
+)
```
-## Repository layout
+---
+
+## For integrators
+
+Technical reference for assurance models, production mode, and cross-repo contracts:
+
+| Topic | Document |
+|-------|----------|
+| Identity assurance levels (IAL0–IAL4) | [identity_assurance.md](docs/identity_assurance.md) |
+| Signing assurance levels (SAL0–SAL4) | [signing_assurance.md](docs/signing_assurance.md) |
+| Two-stage authority checks | [rbac_scope_authority.md](docs/rbac_scope_authority.md) |
+| Production mode and signing | [trusted_boundary.md](docs/trusted_boundary.md) |
+| REST API (`POST /v0/akta/review`, grant check, queue) | [external_integration_contracts.md](docs/external_integration_contracts.md) |
+| PF obligation and PCS manifest formats | [pf_core_bridge.md](docs/pf_core_bridge.md), [pcs_export.md](docs/pcs_export.md) |
+
+Environment variables for production (OIDC identity, ledger delivery, signing keys) are documented in [docs/trusted_boundary.md](docs/trusted_boundary.md) and [docs/institutional_pilot_guide.md](docs/institutional_pilot_guide.md).
-- `scope/` - core protocol engine
-- `schemas/` - JSON schemas for artifacts
-- `policy/` - YAML policy files (`scope-core-v0.6`) and domain overlays
-- `adapters/` - AKTA, VSA, PF-Core, PCS, REST integrations
-- `examples/` - scenario fixtures
-- `evals/` - eight core evaluation scenarios (+ four extended with `--extended`)
-- `tests/` - pytest suite
-- `docs/` - protocol documentation
+---
## License
-MIT - see [LICENSE](LICENSE).
+MIT — see [LICENSE](LICENSE).
diff --git a/adapters/akta/examples/README.md b/adapters/akta/examples/README.md
index 022ccb6..4e65b33 100644
--- a/adapters/akta/examples/README.md
+++ b/adapters/akta/examples/README.md
@@ -2,12 +2,39 @@
Sample inputs for `adapters/akta/import_record.py` and `import_trigger.py`.
+## Fixtures
+
+| File | Description |
+|------|-------------|
+| `akta_record.json` | Minimal flat record stub |
+| `akta_record_nested.json` | Nested v0.4-style record (golden fixture) |
+| `akta_review_trigger_v04.json` | v0.4 trigger with alias fields |
+| `review_trigger.json` | Simple flat trigger |
+
+Richer scenario fixtures live under [examples/protocol_change_review/](../../examples/protocol_change_review/).
+
+## Primary integration path (v0.7)
+
+```bash
+scope akta review \
+ --akta-record adapters/akta/examples/akta_record_nested.json \
+ --akta-trigger adapters/akta/examples/akta_review_trigger_v04.json \
+ --grant-scope protocol_draft \
+ --reviewer examples/protocol_drift/reviewer_protocol_owner.json \
+ --decision-rationale "Narrow protocol draft approval only." \
+ --out-dir /tmp/akta_review_out
+```
+
+Output contract: [docs/akta_review_contract.md](../../docs/akta_review_contract.md).
+
+## Low-level Python import
+
```python
from adapters.akta.import_record import load_akta_record
from adapters.akta.import_trigger import load_review_trigger
-record = load_akta_record("adapters/akta/examples/akta_record.json")
-trigger = load_review_trigger("adapters/akta/examples/review_trigger.json")
+record = load_akta_record("adapters/akta/examples/akta_record_nested.json")
+trigger = load_review_trigger("adapters/akta/examples/akta_review_trigger_v04.json")
```
-These mirror `examples/protocol_change_review/` fixtures.
+Field mappings: [docs/external_integration_contracts.md](../../docs/external_integration_contracts.md).
diff --git a/adapters/generic_rest/server.py b/adapters/generic_rest/server.py
index fee15b8..28434f5 100644
--- a/adapters/generic_rest/server.py
+++ b/adapters/generic_rest/server.py
@@ -181,6 +181,20 @@ class ReviewQueueCloseRequest(BaseModel):
reason: str = ""
+class ReviewQueueNeedsInformationRequest(BaseModel):
+ reason: str = ""
+
+
+class ReviewQueueCancelRequest(BaseModel):
+ reason: str = ""
+
+
+class ReviewQueueEscalateEntryRequest(BaseModel):
+ reviewer: dict[str, Any] | None = None
+ reason: str = ""
+ actor_id: str | None = None
+
+
class KeyRegisterRequest(BaseModel):
reviewer_id: str
public_key_path: str
@@ -208,6 +222,16 @@ def _http_error(exc: Exception) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))
+@app.exception_handler(ScopeValidationError)
+async def _scope_validation_handler(_request: Request, exc: ScopeValidationError) -> HTTPException:
+ raise _http_error(exc)
+
+
+@app.exception_handler(GrantValidationError)
+async def _grant_validation_handler(_request: Request, exc: GrantValidationError) -> HTTPException:
+ raise _http_error(exc)
+
+
@app.get("/v0/health")
def health() -> dict[str, str]:
return {"status": "ok", "version": __version__}
@@ -460,6 +484,104 @@ def close_review_queue(
return ReviewQueue.load(path).status_summary()
+@app.post("/v0/review-queue/{queue_id}/in-review", dependencies=[Depends(_require_api_key)])
+def in_review_review_queue(
+ queue_id: str,
+ queue_dir: str | None = None,
+) -> dict[str, Any]:
+ from scope.review_queue import ReviewQueue
+
+ path = _find_queue_path(queue_id, queue_dir)
+ get_engine().in_review_review_queue(path)
+ return ReviewQueue.load(path).status_summary()
+
+
+@app.post(
+ "/v0/review-queue/{queue_id}/needs-information",
+ dependencies=[Depends(_require_api_key)],
+)
+def needs_information_review_queue(
+ queue_id: str,
+ req: ReviewQueueNeedsInformationRequest,
+ queue_dir: str | None = None,
+) -> dict[str, Any]:
+ from scope.review_queue import ReviewQueue
+
+ path = _find_queue_path(queue_id, queue_dir)
+ get_engine().needs_information_review_queue(path, reason=req.reason)
+ return ReviewQueue.load(path).status_summary()
+
+
+@app.post(
+ "/v0/review-queue/{queue_id}/information-received",
+ dependencies=[Depends(_require_api_key)],
+)
+def information_received_review_queue(
+ queue_id: str,
+ queue_dir: str | None = None,
+) -> dict[str, Any]:
+ from scope.review_queue import ReviewQueue
+
+ path = _find_queue_path(queue_id, queue_dir)
+ get_engine().information_received_review_queue(path)
+ return ReviewQueue.load(path).status_summary()
+
+
+@app.post("/v0/review-queue/{queue_id}/reopen", dependencies=[Depends(_require_api_key)])
+def reopen_review_queue(
+ queue_id: str,
+ queue_dir: str | None = None,
+) -> dict[str, Any]:
+ from scope.review_queue import ReviewQueue
+
+ path = _find_queue_path(queue_id, queue_dir)
+ get_engine().reopen_review_queue(path)
+ return ReviewQueue.load(path).status_summary()
+
+
+@app.post("/v0/review-queue/{queue_id}/expire", dependencies=[Depends(_require_api_key)])
+def expire_review_queue(
+ queue_id: str,
+ queue_dir: str | None = None,
+) -> dict[str, Any]:
+ from scope.review_queue import ReviewQueue
+
+ path = _find_queue_path(queue_id, queue_dir)
+ get_engine().expire_review_queue(path)
+ return ReviewQueue.load(path).status_summary()
+
+
+@app.post("/v0/review-queue/{queue_id}/cancel", dependencies=[Depends(_require_api_key)])
+def cancel_review_queue(
+ queue_id: str,
+ req: ReviewQueueCancelRequest,
+ queue_dir: str | None = None,
+) -> dict[str, Any]:
+ from scope.review_queue import ReviewQueue
+
+ path = _find_queue_path(queue_id, queue_dir)
+ get_engine().cancel_review_queue(path, reason=req.reason)
+ return ReviewQueue.load(path).status_summary()
+
+
+@app.post("/v0/review-queue/{queue_id}/escalate", dependencies=[Depends(_require_api_key)])
+def escalate_review_queue_entry(
+ queue_id: str,
+ req: ReviewQueueEscalateEntryRequest,
+ queue_dir: str | None = None,
+) -> dict[str, Any]:
+ from scope.review_queue import ReviewQueue
+
+ path = _find_queue_path(queue_id, queue_dir)
+ get_engine().escalate_review_queue_entry(
+ path,
+ req.reviewer,
+ reason=req.reason,
+ actor_id=req.actor_id,
+ )
+ return ReviewQueue.load(path).status_summary()
+
+
def _policy_dir() -> Path:
return Path(get_engine().policy.policy_dir)
diff --git a/adapters/pcs/examples/README.md b/adapters/pcs/examples/README.md
index 7fd7ddb..91a18c7 100644
--- a/adapters/pcs/examples/README.md
+++ b/adapters/pcs/examples/README.md
@@ -6,16 +6,34 @@ Sample PCS release layout produced by `adapters/pcs/export_artifact.py`:
- `scope_decision.json`
- `scope_grant.json`
- `pf_obligation.json`
-- `release_manifest.json` (artifact hashes and source IDs)
+- `release_manifest.json` (artifact hashes, trust root, registry metadata)
-Generate live output:
+Manifest version: `pcs-v0.5`. See [docs/pcs_export.md](../../docs/pcs_export.md).
+
+## Generate live output
+
+Use artifacts from [examples/institutional_pilot/](../../examples/institutional_pilot/) or your own grant workflow:
+
+```bash
+scope export pcs \
+ --packet examples/institutional_pilot/scope_packet.json \
+ --decision examples/institutional_pilot/scope_decision.json \
+ --grant examples/institutional_pilot/scope_grant.json \
+ --out dist/pcs_scope_artifact/ \
+ --validate
+```
+
+Optional live contract validation:
```bash
+export PCS_CORE_REPO_PATH=/path/to/pcs-core
scope export pcs \
- --packet /tmp/packet.json \
- --decision /tmp/decision.json \
- --grant /tmp/grant.json \
- --out dist/pcs_scope_artifact/
+ --packet examples/institutional_pilot/scope_packet.json \
+ --decision examples/institutional_pilot/scope_decision.json \
+ --grant examples/institutional_pilot/scope_grant.json \
+ --out dist/pcs_scope_artifact/ --validate --live
```
-The manifest includes canonical `sha256:` hashes for each artifact file.
+The manifest includes canonical `sha256:` hashes for each artifact file plus `scope_trust_root_hash` when policy and registry are configured.
+
+Field mapping: [docs/external_integration_contracts.md](../../docs/external_integration_contracts.md).
diff --git a/adapters/pf_core/examples/README.md b/adapters/pf_core/examples/README.md
index da2473c..a63d10b 100644
--- a/adapters/pf_core/examples/README.md
+++ b/adapters/pf_core/examples/README.md
@@ -2,15 +2,28 @@
Sample output from `adapters/pf_core/export_obligation.py` after issuing a protocol-draft grant.
-Generate live output:
+Contract version: `pf-core-v0.5`. See [docs/pf_core_bridge.md](../../docs/pf_core_bridge.md).
+
+## Generate live output
+
+After running a grant workflow (e.g. [examples/stale_grant_attempt/](../../examples/stale_grant_attempt/) or [examples/institutional_pilot/](../../examples/institutional_pilot/)):
```bash
scope export pf \
- --grant examples/protocol_change_review/scope_grant.json \
- --out dist/pf_obligation.json
+ --grant examples/institutional_pilot/scope_grant.json \
+ --out dist/pf_obligation.json \
+ --validate
```
-Or from Python:
+Optional live contract validation:
+
+```bash
+export PF_CORE_REPO_PATH=/path/to/pf-core
+scope export pf --grant examples/institutional_pilot/scope_grant.json \
+ --out dist/pf_obligation.json --validate --live
+```
+
+## Python API
```python
from adapters.pf_core.export_obligation import export_pf_obligation
@@ -19,4 +32,6 @@ import json
obligation = export_pf_obligation(json.load(open("scope_grant.json")))
```
-See `pf_obligation.json` in this directory for the expected shape.
+## Sample shape
+
+See `pf_obligation.json` in this directory for a representative obligation layout. Regenerate from a live grant for current contract fields and signature metadata.
diff --git a/adapters/pf_core/examples/pf_obligation.json b/adapters/pf_core/examples/pf_obligation.json
index 7a76fd9..26f18d8 100644
--- a/adapters/pf_core/examples/pf_obligation.json
+++ b/adapters/pf_core/examples/pf_obligation.json
@@ -1,5 +1,5 @@
{
- "obligation_version": "pf-core-v0.1",
+ "obligation_version": "pf-core-v0.5",
"grant_id": "SCOPE-GRANT-EXAMPLE",
"grant_hash": "sha256:example",
"permitted_tools": ["protocol_editor.draft_change"],
diff --git a/docs/akta_integration.md b/docs/akta_integration.md
index e8ce226..1f110ff 100644
--- a/docs/akta_integration.md
+++ b/docs/akta_integration.md
@@ -2,12 +2,32 @@
SCOPE begins when AKTA returns `review_required` or `authorization_required`.
+## Primary integration path
+
+For packet → decision → grant in one step, use:
+
+```bash
+scope akta review \
+ --akta-record path/to/akta_record.json \
+ --akta-trigger path/to/review_trigger.json \
+ --grant-scope protocol_draft \
+ --reviewer path/to/reviewer.json \
+ --decision-rationale "Rationale text." \
+ --out-dir /tmp/akta_review_out
+```
+
+REST equivalent: `POST /v0/akta/review`.
+
+Output contract: [akta_review_contract.md](akta_review_contract.md). Full demo: [akta_scope_demo.md](akta_scope_demo.md). Field mappings: [external_integration_contracts.md](external_integration_contracts.md).
+
## Inputs
- **AKTA Record** — scientific action context, artifacts, constraints
- **Review trigger** — requested tool, action type, admissibility
-## Adapter
+Golden fixtures: `adapters/akta/examples/` (see [adapters/akta/examples/README.md](../adapters/akta/examples/README.md)).
+
+## Low-level adapter (Python)
```python
from adapters.akta.import_trigger import load_review_trigger
@@ -18,6 +38,8 @@ engine = ScopeEngine.from_policy_dir("policy/")
packet = engine.create_packet("akta_record.json", trigger)
```
+Use this path when you need custom packet enrichment (VSA reports, manual review steps) before decision submit.
+
## Required trigger fields
- `scientific_action_type` (e.g. `A5_protocol_modification`)
@@ -25,3 +47,11 @@ packet = engine.create_packet("akta_record.json", trigger)
- `requested_tool`
SCOPE resolves required reviewer roles from `policy/role_to_action_matrix.yaml`.
+
+## v0.7 provenance on review output
+
+Decisions and grants from `scope akta review` record:
+
+- Identity assurance level (IAL0–IAL4) — see [identity_assurance.md](identity_assurance.md)
+- Signing assurance level (SAL0–SAL4) — see [signing_assurance.md](signing_assurance.md)
+- Two-stage `authority_checks` when RBAC is enforced — see [rbac_scope_authority.md](rbac_scope_authority.md)
diff --git a/docs/akta_review_contract.md b/docs/akta_review_contract.md
new file mode 100644
index 0000000..50fd57f
--- /dev/null
+++ b/docs/akta_review_contract.md
@@ -0,0 +1,55 @@
+# AKTA Review Output Contract
+
+SCOPE v0.7 freezes the `scope akta review` output contract under `out_dir/`.
+
+## Artifacts
+
+| File | Description |
+|------|-------------|
+| `scope_review_packet.json` | Review packet |
+| `scope_decision.json` | Signed or unsigned decision |
+| `scope_grant.json` | Issued grant |
+| `summary.json` | Adapter summary (validated against schema) |
+
+## summary.json contract
+
+Contract version constant: `scope-akta-review-v0.7` (`scope.integration_versions.AKTA_REVIEW_CONTRACT_VERSION`).
+
+Required fields:
+
+```json
+{
+ "packet_path": "...",
+ "decision_path": "...",
+ "grant_path": "...",
+ "approved_scope": "...",
+ "requested_scope": "...",
+ "adapter_contract_version": "scope-akta-review-v0.7",
+ "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`.
+
+Schema: `schemas/scope_akta_review_summary.schema.json`
+
+## Acceptance criteria
+
+`scope akta review` enforces:
+
+- Summary validates against schema
+- 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)
+
+## Primary AKTA path
+
+`scope akta review` is the documented primary AKTA integration path for packet → decision → grant workflows.
+
+## Related documentation
+
+- [akta_integration.md](akta_integration.md) — integration overview
+- [akta_scope_demo.md](akta_scope_demo.md) — full cross-repo demo
+- [external_integration_contracts.md](external_integration_contracts.md) — AKTA field mappings
diff --git a/docs/akta_scope_demo.md b/docs/akta_scope_demo.md
index e6a7c72..6aa5bb5 100644
--- a/docs/akta_scope_demo.md
+++ b/docs/akta_scope_demo.md
@@ -1,6 +1,8 @@
# AKTA to SCOPE to PF to PCS Demo
-This document walks through the full authorization chain using SCOPE v0.5.1.
+This document walks through the full authorization chain using SCOPE v0.7.
+
+Related: [akta_review_contract.md](akta_review_contract.md), [external_integration_contracts.md](external_integration_contracts.md), [institutional_pilot_guide.md](institutional_pilot_guide.md).
## Primary path: `scope akta review`
@@ -23,12 +25,13 @@ Outputs in `/tmp/akta_review_out/`:
| `scope_review_packet.json` | SCOPE review packet from AKTA inputs |
| `scope_decision.json` | Scoped approval decision |
| `scope_grant.json` | Bounded authorization grant |
-| `summary.json` | Machine-readable paths, scope, blocked tools, status |
+| `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`.
SCOPE rejects overbroad `--grant-scope` values against the packet's `requested_scope`.
-In production mode (`$env:SCOPE_PRODUCTION_MODE = "true"`), pass `--signing-key` with the
-reviewer's Ed25519 private key PEM. The command signs the decision before grant issue:
+In production mode (`$env:SCOPE_PRODUCTION_MODE = "true"`), pass `--signing-key` or `--signing-provider` with the reviewer's key. The command signs the decision before grant issue:
```powershell
$env:SCOPE_PRODUCTION_MODE = "true"
@@ -42,7 +45,16 @@ scope akta review `
--out-dir /tmp/akta_review_out
```
-Without `--signing-key` in production mode, the command fails before writing a grant.
+Optional institutional identity:
+
+```powershell
+scope akta review ... `
+ --identity-token $env:OIDC_TOKEN `
+ --enforce-rbac `
+ --signing-provider registry --reviewer-id protocol_owner_1
+```
+
+Without signing credentials in production mode, the command fails before writing a grant.
For non-production workflows, sign the decision and re-issue the grant from saved artifacts (see Step 4 below).
@@ -172,7 +184,7 @@ scope export pcs `
--validate
```
-PCS bundle includes manifest hashes for tamper detection.
+PCS bundle includes manifest hashes for tamper detection. Obligation version: `pf-core-v0.5`; manifest version: `pcs-v0.5`.
## Step 7: Quality report
@@ -192,4 +204,4 @@ akta record evaluate --input action.json --out akta_record.json
akta review trigger --record akta_record.json --out review_trigger.json
```
-SCOPE consumes both artifacts via `scope packet create`.
+SCOPE consumes both artifacts via `scope packet create` or the one-shot `scope akta review`.
diff --git a/docs/evidence_vocab_mapping.md b/docs/evidence_vocab_mapping.md
index c0882d5..0049c24 100644
--- a/docs/evidence_vocab_mapping.md
+++ b/docs/evidence_vocab_mapping.md
@@ -34,3 +34,5 @@ AKTA and SCOPE share an evidence-state vocabulary with historical aliases. SCOPE
## Quality warning
When a reviewer submits an approval decision (`approve`, `approve_narrower_scope`) and the packet `scientific_context.evidence_state` is in the weak set, SCOPE emits ledger event `quality_warning_emitted` with `warning_type: approval_despite_low_evidence`.
+
+See [external_integration_contracts.md](external_integration_contracts.md) for AKTA import behavior and [quality_metrics.md](quality_metrics.md) for related metrics.
diff --git a/docs/external_integration_contracts.md b/docs/external_integration_contracts.md
index e5b92d5..2aa67b7 100644
--- a/docs/external_integration_contracts.md
+++ b/docs/external_integration_contracts.md
@@ -1,7 +1,9 @@
-# External Integration Contracts (v0.5)
+# External Integration Contracts (v0.7)
Formal field-mapping contracts for AKTA, PF-Core, PCS, and VSA integrations. These contracts define the shapes SCOPE produces or consumes locally; external repositories must implement compatible endpoints or adapters.
+Related docs: [akta_review_contract.md](akta_review_contract.md), [pf_core_bridge.md](pf_core_bridge.md), [pcs_export.md](pcs_export.md), [evidence_vocab_mapping.md](evidence_vocab_mapping.md).
+
## AKTA import
### Inputs
@@ -47,6 +49,21 @@ Golden fixtures:
Evidence vocabulary mapping: [evidence_vocab_mapping.md](evidence_vocab_mapping.md).
+## AKTA review output (primary path)
+
+`scope akta review` and `POST /v0/akta/review` emit a frozen output bundle under `out_dir/`:
+
+| 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` |
+
+Contract version: `scope-akta-review-v0.7`. Required `summary.json` fields include `adapter_contract_version`, `identity_assurance_level`, `signing_assurance_level`, and `production_mode`.
+
+Full contract: [akta_review_contract.md](akta_review_contract.md).
+
## VSA import
### Input
@@ -93,6 +110,8 @@ Example: `adapters/vsa/examples/scientific_report_example.json`.
Contract fixture: `tests/fixtures/contracts/pf_obligation_contract.json`.
+See [pf_core_bridge.md](pf_core_bridge.md).
+
### Live validation (optional)
When `PF_CORE_REPO_PATH` points to a PF-Core checkout, SCOPE can invoke a documented validator script from that repo:
@@ -135,7 +154,7 @@ Directory containing:
Contract fixture: `tests/fixtures/contracts/pcs_manifest_contract.json`.
-Key registry workflow: [key_management.md](key_management.md).
+Key registry workflow: [key_management.md](key_management.md). See [pcs_export.md](pcs_export.md).
### Live validation (optional)
@@ -168,27 +187,33 @@ Resolved at packet create via `scope/review_assignment.py`:
Schema: `schemas/review_assignment.schema.json`.
-## Review queue (v0.5)
+## Review queue (v0.7)
File-backed queue entries under `.scope/queues/` (override with `--queue-dir`).
Schema: `schemas/scope_review_queue.schema.json`.
-CLI: `scope review queue create|assign|status`.
+Ten-state workflow: `open`, `assigned`, `in_review`, `needs_information`, `escalated`, `decided`, `granted`, `expired`, `closed`, `cancelled`. Grants require `decided → granted`; direct grant from open or in-review states is forbidden.
+
+CLI: `scope review queue create|assign|status|list|decide|grant|close` plus transition commands (`in-review`, `needs-information`, etc.).
+
+REST: full queue lifecycle under `/v0/review-queue/{id}/...`.
-Quality metrics: `open_queue_count`, `overdue_queue_count`.
+Quality metrics: `open_queue_count`, `overdue_queue_count` (open statuses include `in_review`, `needs_information`, `escalated`).
-## Version alignment (v0.5)
+## Version alignment (v0.7)
| Artifact | Version field | Expected value |
|----------|---------------|----------------|
-| SCOPE packet | `packet_version` | `0.5.1` |
-| SCOPE grant | `grant_version` | `0.5.1` |
-| Quality report | `report_version` | `0.5` |
-| Review queue | `queue_version` | `0.5.1` |
+| SCOPE package | `pyproject.toml` / `scope/_version.py` | `0.7.0` |
+| SCOPE packet | `packet_version` | `0.7.0` |
+| SCOPE grant | `grant_version` | `0.7.0` |
+| Quality report | `report_version` | `0.7` |
+| Review queue | `queue_version` | `0.7.0` |
+| AKTA review summary | `adapter_contract_version` | `scope-akta-review-v0.7` |
| PF obligation | `obligation_version` | `pf-core-v0.5` |
| PCS manifest | `manifest_version` | `pcs-v0.5` |
-| Policy bundle | `version` in YAML | `scope-core-v0.5` |
+| Policy bundle | `version` in YAML | `scope-core-v0.7` |
## External repo dependencies
diff --git a/docs/field_thesis.md b/docs/field_thesis.md
index bf9e4f2..b1dc871 100644
--- a/docs/field_thesis.md
+++ b/docs/field_thesis.md
@@ -14,3 +14,5 @@ SCOPE operationalizes review by binding it to:
- Measurable review quality
The field-level change: **review becomes enforceable, auditable, and improvable.**
+
+See [scoped_scientific_authorization.md](scoped_scientific_authorization.md) for the SCOPE v0.7 implementation.
diff --git a/docs/identity_assurance.md b/docs/identity_assurance.md
new file mode 100644
index 0000000..6315bab
--- /dev/null
+++ b/docs/identity_assurance.md
@@ -0,0 +1,53 @@
+# Identity Assurance (IAL)
+
+SCOPE v0.7 records identity assurance levels on every decision and grant provenance block.
+
+## Levels
+
+| Level | Alias | Meaning |
+|-------|-------|---------|
+| IAL0 | `caller_supplied` | Caller-supplied reviewer JSON only; not institutional authority |
+| IAL1 | `local_signed_key` | Valid local signature on decision; no OIDC verification |
+| IAL2 | `oidc_verified` | OIDC-verified identity without matching institutional directory role |
+| IAL3 | `oidc_plus_rbac_role` | OIDC plus institutional directory role from `org_rbac.yaml` |
+| IAL4 | `oidc_plus_rbac_plus_delegation` | OIDC plus active delegation record with `delegation_id` |
+
+## Provenance fields
+
+```json
+{
+ "identity_assurance_level": "IAL2",
+ "identity_source": "oidc_jwt",
+ "role_resolution_source": "oidc_verified",
+ "delegation_id": "ds1->ds2",
+ "identity_claim_hash": "sha256:..."
+}
+```
+
+`role_resolution_source` values:
+
+- `caller_supplied` — IAL0 caller JSON only
+- `local_signed_key` — IAL1 signed decision without OIDC
+- `oidc_verified` — IAL2 OIDC identity without org RBAC role match
+- `org_rbac` — IAL3/IAL4 institutional role resolved from `org_rbac.yaml`
+
+## Rules
+
+- Caller-supplied JSON alone is IAL0 and must not be labeled institutional in render output or documentation.
+- OIDC without RBAC role match cannot grant institutional authority (IAL2 without role authority).
+- Delegated roles require `delegation_id` from an active `org_rbac` delegation record.
+- Wire identity via `--identity-token` or `SCOPE_OIDC_ENABLED` with `--enforce-rbac` / `SCOPE_ENFORCE_RBAC`.
+
+## Configuration
+
+- `policy/identity_mapping.yaml` — claim to reviewer_id and role mapping
+- `policy/org_rbac.yaml` — institutional directory roles and delegations
+- `SCOPE_OIDC_JWKS_URL`, `SCOPE_OIDC_ISSUER`, `SCOPE_OIDC_AUDIENCE`, `SCOPE_OIDC_PUBLIC_KEY_PEM`
+
+Schema: `schemas/identity_assurance.schema.json`
+
+## Related documentation
+
+- [rbac_scope_authority.md](rbac_scope_authority.md) — two-stage authority and `authority_checks`
+- [trusted_boundary.md](trusted_boundary.md) — OIDC configuration and trust assumptions
+- [akta_review_contract.md](akta_review_contract.md) — IAL recorded in `summary.json`
diff --git a/docs/institutional_guide.md b/docs/institutional_guide.md
index e49ca3d..583c006 100644
--- a/docs/institutional_guide.md
+++ b/docs/institutional_guide.md
@@ -1,19 +1,31 @@
# Institutional Guide
-Institutions adopting SCOPE v0.1 should:
+Institutions adopting SCOPE v0.7 should start with the [institutional pilot guide](institutional_pilot_guide.md), which covers workshop flow, checklist, and v0.7 features (identity assurance, signing assurance, review queue, ledger delivery).
-1. Configure `policy/` files for local role assignments and scope boundaries
-2. Integrate AKTA review triggers into the SCOPE packet pipeline
-3. Enforce grants at runtime via PF-Core or equivalent
-4. Archive SCOPE ledger events with PCS release packages
-5. Monitor quality reports for rubber-stamping and scope violations
+## Adoption checklist
+
+1. Configure and version-pin `policy/` files (`scope-core-v0.7`) for local role assignments and scope boundaries
+2. Map lab personnel to reviewer roles; generate Ed25519 keypairs and register public keys
+3. Integrate AKTA review triggers via `scope akta review` or `scope packet create`
+4. Enable production mode (`SCOPE_PRODUCTION_MODE=true`) and minimum signing assurance for grant enforcement
+5. Optional: OIDC identity (`SCOPE_OIDC_*`) and org RBAC (`SCOPE_ENFORCE_RBAC=true`)
+6. Enforce grants at runtime via PF-Core or equivalent
+7. Archive SCOPE ledger events with PCS release packages
+8. Monitor quality reports for rubber-stamping and scope violations
## Evidence for auditors
-- Who reviewed (role and ID)
-- What scope was approved
-- What remained blocked
-- When grants expired
-- Whether runtime respected grants
+- Who reviewed (role, ID, identity assurance level, authority checks)
+- What scope was approved and what remained blocked
+- Signing assurance level on decisions and grants
+- When grants expired and whether runtime respected grants
+- Trust root hashes binding policy and key registry
SCOPE provides the authorization trail; it does not certify scientific correctness or regulatory compliance.
+
+## Related documentation
+
+- [institutional_pilot_guide.md](institutional_pilot_guide.md) — workshop and lab integration
+- [trusted_boundary.md](trusted_boundary.md) — trust assumptions
+- [limitations.md](limitations.md) — in-repo vs external boundaries
+- [reviewer_guide.md](reviewer_guide.md) — role-specific guidance
diff --git a/docs/institutional_pilot_guide.md b/docs/institutional_pilot_guide.md
index cd94e19..0ae08d4 100644
--- a/docs/institutional_pilot_guide.md
+++ b/docs/institutional_pilot_guide.md
@@ -1,6 +1,15 @@
# Institutional Pilot Guide
-This guide supports workshop, funder, and lab pilots of SCOPE v0.6 alongside AKTA.
+This guide supports workshop, funder, and lab pilots of SCOPE v0.7 alongside AKTA.
+
+## v0.7 institutional additions
+
+- **Identity assurance (IAL0–IAL4)** on every decision and grant provenance — [identity_assurance.md](identity_assurance.md)
+- **Authority checks** with two-stage org RBAC then SCOPE scope policy separation — [rbac_scope_authority.md](rbac_scope_authority.md)
+- **Signing assurance (SAL0–SAL4)** with production minimums in `policy/minimum_signing_assurance.yaml` — [signing_assurance.md](signing_assurance.md)
+- **Ledger delivery modes**: `best_effort`, `at_least_once` (spool), `fail_closed` for high-risk events (`SCOPE_LEDGER_DELIVERY_MODE`)
+- **Review queue workflow** (10 states) with REST, CLI, and static HTML dashboard
+- **Frozen AKTA review contract** (`summary.json` schema, `scope akta review` primary path) — [akta_review_contract.md](akta_review_contract.md)
## v0.6 institutional additions
@@ -21,7 +30,7 @@ This guide supports workshop, funder, and lab pilots of SCOPE v0.6 alongside AKT
## Workshop flow (90 minutes)
1. **AKTA evaluation** — Present a scientific action; AKTA emits record + review trigger
-2. **SCOPE packet** — `scope packet create` and `scope packet render` for reviewers
+2. **SCOPE packet** — `scope packet create` or `scope akta review` for one-shot packet/decision/grant
3. **Review** — Reviewers read rendered packet (permits/denies, checklist, warnings)
4. **Decision** — `scope decision submit` (unsigned OK in production mode)
5. **Sign** — `scope decision sign` with pilot reviewer key
@@ -29,7 +38,7 @@ This guide supports workshop, funder, and lab pilots of SCOPE v0.6 alongside AKT
7. **Verify** — `scope verify --public-key` for auditor demonstration
8. **Export** — PCS bundle for institutional record-keeping
-Sample artifacts: `examples/institutional_pilot/`
+Sample artifacts: [examples/institutional_pilot/](../examples/institutional_pilot/)
## Multi-reviewer pilot (A6)
@@ -47,27 +56,30 @@ Collect votes from `domain_scientist` and `protocol_owner`, then issue grant fro
Export quality report from ledger:
```bash
-scope quality report --ledger pilot_events.jsonl --out quality_report.json
+scope quality report --ledger pilot_events.jsonl --out quality_report.json \
+ --queue-dir .scope/queues
```
-Report includes reviewer metrics, warnings, and event counts. Non-certification: metrics describe process quality, not scientific validity.
+Report includes reviewer metrics, warnings, queue counts, and event counts. Non-certification: metrics describe process quality, not scientific validity. See [quality_metrics.md](quality_metrics.md).
## Lab integration checklist
-- [ ] Policy YAML reviewed and version-pinned
+- [ ] Policy YAML reviewed and version-pinned (`scope-core-v0.7`)
- [ ] Reviewer roles mapped to lab personnel
-- [ ] Ed25519 keypairs generated per reviewer role
+- [ ] Ed25519 keypairs generated per reviewer role; public keys registered
- [ ] PF-Core runtime configured to enforce grant obligations
- [ ] Ledger path configured (`SCOPE_LEDGER_PATH`)
+- [ ] Ledger delivery mode set for institutional risk tolerance
- [ ] Session store directory secured (if using json/sqlite)
- [ ] Pilot limitations documented to participants
## Limitations
-See [limitations.md](limitations.md). Pilots should not rely on SCOPE for IdP, RBAC, biosecurity clearance, or clinical oversight.
+See [limitations.md](limitations.md). Pilots should not rely on SCOPE for full enterprise IdP, live directory sync, biosecurity clearance, or clinical oversight.
## Related docs
- [reviewer_guide.md](reviewer_guide.md) — role-specific guidance
- [trusted_boundary.md](trusted_boundary.md) — trust assumptions
- [akta_scope_demo.md](akta_scope_demo.md) — full cross-repo demo
+- [key_management.md](key_management.md) — key registry workflow
diff --git a/docs/key_management.md b/docs/key_management.md
index 4a97947..044c3e9 100644
--- a/docs/key_management.md
+++ b/docs/key_management.md
@@ -1,26 +1,30 @@
-# Key management (v0.6)
+# Key Management (v0.7)
SCOPE supports optional local binding between `reviewer_id` and Ed25519 public keys via `policy/reviewer_key_registry.yaml`.
-## Signing providers (v0.6)
+See also [signing_assurance.md](signing_assurance.md) and [trusted_boundary.md](trusted_boundary.md).
-| Provider | CLI flag | Description |
-|----------|----------|-------------|
-| `local` | `--signing-key` / `--key` | Explicit Ed25519 private key PEM (default) |
-| `env` | `--signing-provider env` | Reads `SCOPE_SIGNING_KEY` path |
-| `registry` | `--signing-provider registry --reviewer-id X` | Pilot: `signing_key_path` in registry entry |
+## Signing providers
+
+| Provider | CLI flag | SAL level | Description |
+|----------|----------|-----------|-------------|
+| `local` | `--signing-key` / `--key` | SAL1 | Explicit Ed25519 private key PEM (default) |
+| `env` | `--signing-provider env` | SAL2 | Reads `SCOPE_SIGNING_KEY` path |
+| `registry` | `--signing-provider registry --reviewer-id X` | SAL3 | Pilot: `signing_key_path` in registry entry |
```bash
scope decision sign --decision d.json --signing-provider env --out signed.json
-scope akta review ... --signing-provider registry --reviewer-id ds1 --signing-key ignored
+scope akta review ... --signing-provider registry --reviewer-id ds1
```
**Pilot only:** `signing_key_path` in `reviewer_key_registry.yaml` references a local private key for institutional pilots. Do not use in production; prefer HSM or env-scoped keys.
+Production mode enforces minimum SAL per scope via `policy/minimum_signing_assurance.yaml` (default SAL1; high-risk scopes such as `robot_queue_submission` require SAL3).
+
## Registry file
```yaml
-version: scope-core-v0.5
+version: scope-core-v0.7
reviewers:
protocol_owner_1:
public_key_ref: sha256:...
@@ -96,4 +100,4 @@ Use `scope_trust_root_hash` when a downstream system needs a single digest bindi
- Treat the registry as institution-local policy, version-controlled separately from reviewer PEM files.
- Rotate keys by registering a new public key ref, re-signing outstanding decisions if needed, and bumping registry version.
-- Production deployments should store private keys in an HSM or institutional secret store; SCOPE v0.5 still signs locally via PEM paths only.
+- Production deployments should store private keys in an HSM or institutional secret store. SCOPE v0.7 records signing assurance level (SAL) on provenance; wire external HSM/KMS via `HsmKmsSigningProvider` (SAL4 interface only).
diff --git a/docs/limitations.md b/docs/limitations.md
index 11f65d8..a7da8c3 100644
--- a/docs/limitations.md
+++ b/docs/limitations.md
@@ -1,5 +1,15 @@
# Limitations
+## Implemented in v0.7
+
+- Identity assurance levels (IAL0–IAL4) with provenance on decisions and grants
+- Two-stage institutional RBAC then SCOPE scope policy authority checks
+- Ledger delivery modes: best_effort, at_least_once (spool), fail_closed for high-risk events
+- Review queue workflow state machine with explicit forbidden transitions
+- Signing assurance levels (SAL0–SAL4) with minimum policy enforcement at grant issue
+- Frozen AKTA review output contract (`summary.json` schema, adapter version constant)
+- Policy bundle `scope-core-v0.7`
+
## Implemented in v0.6
- OIDC/JWT identity verification with JWKS or static PEM (`scope identity verify-token`, `SCOPE_OIDC_ENABLED`)
@@ -52,7 +62,7 @@
- No full enterprise IdP/SAML stack; OIDC/JWT RS256 foundation only
- Registry `signing_key_path` is pilot-only; production HSM/KMS integration is operator-managed
-- Remote ledger sink is best-effort; authoritative tamper evidence remains local JSONL chain
+- Remote ledger sink supports delivery modes; authoritative tamper evidence remains local JSONL chain
- Review queue dashboard is static HTML; no interactive workflow UI
- RBAC is YAML-file based; no live directory sync
- PF/PCS live repo validation still optional; mock validators used when sibling repos absent
@@ -65,4 +75,4 @@ SCOPE does not replace AKTA, PF-Core, VSA, PCS, IRB, biosafety, EHS, or legal co
## Roadmap
-See CHANGELOG for future plans: HSM-backed signing, authoritative remote ledger, interactive review UI, and enterprise directory RBAC sync.
+See [CHANGELOG](../CHANGELOG.md) for release history and future plans: HSM-backed signing (SAL4 interface present), authoritative remote ledger, interactive review UI, and enterprise directory RBAC sync.
diff --git a/docs/pcs_export.md b/docs/pcs_export.md
index b77bcf8..54b356c 100644
--- a/docs/pcs_export.md
+++ b/docs/pcs_export.md
@@ -2,6 +2,8 @@
PCS packages SCOPE artifacts into release chains.
+Manifest version: `pcs-v0.5`. Full field mapping: [external_integration_contracts.md](external_integration_contracts.md).
+
## Export
```bash
@@ -9,15 +11,33 @@ scope export pcs \
--packet packet.json \
--decision decision.json \
--grant grant.json \
- --out dist/pcs_scope_artifact/
+ --out dist/pcs_scope_artifact/ \
+ --validate
+```
+
+Optional live validation:
+
+```bash
+export PCS_CORE_REPO_PATH=/path/to/pcs-core
+scope export pcs --packet p.json --decision d.json --grant g.json \
+ --out dist/pcs_scope_artifact/ --validate --live
```
+Sample layout: [examples/institutional_pilot/](../examples/institutional_pilot/) (run export to generate a full bundle).
+
## Output layout
- `scope_packet.json`
- `scope_decision.json`
- `scope_grant.json`
- `pf_obligation.json`
-- `release_manifest.json` (artifact hashes)
+- `release_manifest.json` (artifact hashes, trust root, registry metadata)
+
+Manifest includes `scope_trust_root_hash`, `registry_version`, `registry_hash`, and optional signature fields from signed decisions.
All artifacts are schema-valid and canonically hashable for release verification.
+
+## Related documentation
+
+- [pf_core_bridge.md](pf_core_bridge.md) — PF obligation export
+- [key_management.md](key_management.md) — registry and trust root workflow
diff --git a/docs/pf_core_bridge.md b/docs/pf_core_bridge.md
index 5ba1e0a..9493f25 100644
--- a/docs/pf_core_bridge.md
+++ b/docs/pf_core_bridge.md
@@ -2,18 +2,36 @@
SCOPE grants export to PF-Core runtime obligations that specify permitted and blocked tools.
+Contract version: `pf-core-v0.5`. Full field mapping: [external_integration_contracts.md](external_integration_contracts.md).
+
## Export
```bash
-scope export pf --grant grant.json --out pf_obligation.json
+scope export pf --grant grant.json --out pf_obligation.json --validate
+```
+
+Optional live validation when a PF-Core checkout is configured:
+
+```bash
+export PF_CORE_REPO_PATH=/path/to/pf-core
+scope export pf --grant grant.json --out pf_obligation.json --validate --live
```
+Sample output shape: [adapters/pf_core/examples/pf_obligation.json](../adapters/pf_core/examples/pf_obligation.json) (regenerate with a live grant for current contract fields).
+
## Obligation fields
+- `obligation_version` — `pf-core-v0.5`
- `permitted_tools` — tools runtime may invoke
- `blocked_tools` — tools that must remain blocked
- `approved_scope` — SCOPE approval scope
- `constraints` — protocol version, single-use flags
- `expiration` — invalidation triggers
+- Signature fields — copied when present on signed grant
PF-Core verifies that runtime traces respect these obligations. SCOPE defines permission; PF-Core verifies compliance.
+
+## Related documentation
+
+- [pcs_export.md](pcs_export.md) — PCS bundle includes `pf_obligation.json`
+- [akta_scope_demo.md](akta_scope_demo.md) — end-to-end export demo
diff --git a/docs/quality_metrics.md b/docs/quality_metrics.md
index b1a2eb4..e7ea565 100644
--- a/docs/quality_metrics.md
+++ b/docs/quality_metrics.md
@@ -64,15 +64,28 @@ SCOPE measures whether review is meaningful through ledger-backed analytics.
| `residual_block_violation` | Approval may not preserve AKTA blocks |
| `stale_grant_attempt` | Grant used after expiration |
| `scope_violation_attempt` | Tool outside grant scope |
+| `ledger_delivery_failure` | Remote ledger delivery failed (v0.7) |
-## Review queue (v0.5)
+## Review queue (v0.7)
| Metric | Description |
|--------|-------------|
-| `open_queue_count` | Queue entries in `open` or `assigned` status (file-backed `.scope/queues/`) |
-| `overdue_queue_count` | Open/assigned entries past `due_at` SLA timestamp |
+| `open_queue_count` | Queue entries in open statuses: `open`, `assigned`, `in_review`, `needs_information`, `escalated` |
+| `overdue_queue_count` | Open-status entries past `due_at` SLA timestamp |
+| `ledger_delivery_failure_count` | Failed remote ledger deliveries recorded on the engine |
-Queue status lifecycle: `open` → `assigned` → `decided` → `granted` (or `closed`).
+Ten-state lifecycle (explicit transitions only):
+
+```
+open → assigned → in_review → decided → granted → closed
+ ↓ ↓
+ needs_information escalated
+ ↓ ↓
+ expired (reopen → open)
+ cancelled / closed (terminal without grant)
+```
+
+Grants require `decided → granted`. Direct grant from `open`, `assigned`, `in_review`, `needs_information`, or `escalated` is forbidden.
## Generate report
@@ -81,8 +94,7 @@ scope quality report --ledger logs/scope_events.jsonl --out report.json \
--queue-dir .scope/queues
```
-All metrics in `policy/quality_metrics.yaml` are implemented in v0.5 (`v0_4_status: implemented` for core metrics; `v0_5_status: implemented` for queue metrics).
-Thresholds are configured in the same file.
+All metrics in `policy/quality_metrics.yaml` are implemented in v0.7. Thresholds are configured in the same file.
## Ledger events
diff --git a/docs/rbac_scope_authority.md b/docs/rbac_scope_authority.md
new file mode 100644
index 0000000..6b1dcd5
--- /dev/null
+++ b/docs/rbac_scope_authority.md
@@ -0,0 +1,61 @@
+# RBAC vs SCOPE Authority Separation
+
+SCOPE v0.7 enforces two-stage authority before accepting decisions.
+
+## Stage 1: Institutional RBAC
+
+Maps `reviewer_id` to effective institutional roles from `policy/org_rbac.yaml`:
+
+- Direct membership in `org_units`
+- Active delegations (with expiry check)
+- RBAC action permissions (`can_submit_decisions`, `can_vote_in_session`)
+
+Institutional RBAC is separate from SCOPE scope permissions.
+
+## Stage 2: SCOPE Policy
+
+Maps effective role to scientific action and approval scope via `policy/reviewer_roles.yaml`:
+
+- `role_to_action_matrix.yaml` — which roles may act on action types
+- `can_approve_scopes` — which scopes each SCOPE role may approve
+
+A reviewer may hold an institutional role that does not permit a given SCOPE scope even when RBAC grants directory membership.
+
+## Examples
+
+- `domain_scientist` in org directory cannot approve `robot_queue_submission` when SCOPE role policy forbids it.
+- `protocol_owner` cannot approve `robot_queue_submission` unless listed in `can_approve_scopes`.
+- Expired delegation invalidates institutional authority for the delegated role.
+
+## Enforcement
+
+Implemented in `scope/authority.py` and invoked from `ScopeEngine.submit_decision` and session votes when:
+
+- `SCOPE_ENFORCE_RBAC=1`, or
+- Identity assurance reaches IAL3/IAL4 (institutional OIDC path)
+
+Identity assurance provenance records which stage resolved the role (`role_resolution_source`).
+
+## authority_checks provenance
+
+Every decision and grant records explicit two-stage authority outcomes:
+
+```json
+{
+ "authority_checks": {
+ "rbac_enforced": true,
+ "rbac_role_valid": true,
+ "scope_role_valid": true,
+ "scope_approval_valid": true,
+ "delegation_id": null
+ }
+}
+```
+
+When RBAC is disabled or identity is below IAL3, `rbac_enforced` and `rbac_role_valid` are `false` so auditors can distinguish skipped checks from passed checks. Active delegations populate `delegation_id` in both identity provenance and `authority_checks`.
+
+## Related documentation
+
+- [identity_assurance.md](identity_assurance.md) — IAL levels and provenance fields
+- [limitations.md](limitations.md) — RBAC scope and external IdP boundaries
+- [institutional_pilot_guide.md](institutional_pilot_guide.md) — pilot RBAC configuration
diff --git a/docs/review_doctrine.md b/docs/review_doctrine.md
index 573dc35..814acc5 100644
--- a/docs/review_doctrine.md
+++ b/docs/review_doctrine.md
@@ -10,6 +10,8 @@
6. **Human judgment must become machine-readable** — runtime can enforce decisions; release systems can audit them.
7. **SCOPE does not certify safety** — grants authorize scoped transitions, not scientific correctness.
+See [scoped_scientific_authorization.md](scoped_scientific_authorization.md) and [limitations.md](limitations.md).
+
## Packet design
Packets must not ask "Do you approve this?" They ask whether the reviewer approves a specific action under a specific scope with named blocked tools and expiration conditions.
diff --git a/docs/reviewer_guide.md b/docs/reviewer_guide.md
index 5097fd0..39fa107 100644
--- a/docs/reviewer_guide.md
+++ b/docs/reviewer_guide.md
@@ -16,6 +16,19 @@ A SCOPE Packet includes:
Rendered packets include explicit **Approval Permits** and **Approval Does NOT Permit** sections derived from policy. This display is for review coordination only and does not constitute certification or institutional approval.
+## Identity and signing (v0.7)
+
+Your decision records how your identity was verified:
+
+| Level | Meaning |
+|-------|---------|
+| IAL0 | Caller-supplied reviewer JSON only |
+| IAL1+ | Signed decision or OIDC-verified identity |
+
+In production mode, sign your decision before grant issue. Signing assurance level (SAL) is recorded on the grant. Institutions may require OIDC tokens (`--identity-token`) and RBAC enforcement (`--enforce-rbac`).
+
+See [identity_assurance.md](identity_assurance.md) and [signing_assurance.md](signing_assurance.md).
+
## Reviewer roles
### protocol_owner
@@ -73,4 +86,10 @@ Use `--replace-vote` to supersede a prior vote from the same reviewer.
In production mode, submit your decision first, then sign with your Ed25519 private key before grant issue. Your `reviewer_public_key_ref` in the reviewer fixture must match the signing key (or be attached at sign time). Auditors verify with your public key only.
-Institutions may configure `policy/reviewer_key_registry.yaml` to bind reviewer IDs to expected public keys.
+Institutions may configure `policy/reviewer_key_registry.yaml` to bind reviewer IDs to expected public keys. See [key_management.md](key_management.md).
+
+## Related documentation
+
+- [institutional_pilot_guide.md](institutional_pilot_guide.md) — pilot workshop flow
+- [evidence_vocab_mapping.md](evidence_vocab_mapping.md) — evidence state aliases
+- [quality_metrics.md](quality_metrics.md) — how review quality is measured
diff --git a/docs/scoped_scientific_authorization.md b/docs/scoped_scientific_authorization.md
index c9d9f2b..0d30b27 100644
--- a/docs/scoped_scientific_authorization.md
+++ b/docs/scoped_scientific_authorization.md
@@ -1,22 +1,47 @@
# Scoped Scientific Authorization
-SCOPE turns AKTA `review_required` and `authorization_required` outputs into structured scientific review packets, typed reviewer assignments, scoped decisions, expiring grants, runtime obligations, and audit-ready records.
+SCOPE (v0.7.0) turns AKTA `review_required` and `authorization_required` outputs into structured scientific review packets, typed reviewer assignments, scoped decisions, expiring grants, runtime obligations, and audit-ready records.
## Core doctrine
Review is a role, artifact, scope, expiration, and accountability trail.
+See [review_doctrine.md](review_doctrine.md) and [field_thesis.md](field_thesis.md).
+
## Artifacts
1. **SCOPE Packet** — artifacts a reviewer must inspect
-2. **SCOPE Decision** — structured reviewer response
-3. **SCOPE Grant** — machine-readable authorization for runtime
-4. **SCOPE Ledger** — hash-chained audit trail
+2. **SCOPE Decision** — structured reviewer response with identity and authority provenance
+3. **SCOPE Grant** — machine-readable authorization for runtime with signing assurance
+4. **SCOPE Ledger** — hash-chained audit trail with configurable delivery modes
5. **SCOPE Quality Report** — review quality metrics and warnings
+## Assurance models (v0.7)
+
+| Model | Levels | Purpose |
+|-------|--------|---------|
+| Identity assurance (IAL) | IAL0–IAL4 | How reviewer identity was established |
+| Signing assurance (SAL) | SAL0–SAL4 | How decisions and grants were signed |
+| Authority checks | Two-stage | Org RBAC then SCOPE scope policy separation |
+
+Details: [identity_assurance.md](identity_assurance.md), [signing_assurance.md](signing_assurance.md), [rbac_scope_authority.md](rbac_scope_authority.md).
+
## Ecosystem role
- **AKTA** classifies scientific action admissibility
- **SCOPE** reviews and authorizes within scope
-- **PF-Core** verifies runtime compliance
-- **PCS** packages release artifacts
+- **PF-Core** verifies runtime compliance (`pf-core-v0.5` obligations)
+- **PCS** packages release artifacts (`pcs-v0.5` manifests)
+
+Primary AKTA path: `scope akta review` — see [akta_review_contract.md](akta_review_contract.md).
+
+## Documentation index
+
+| Topic | Document |
+|-------|----------|
+| Pilot onboarding | [institutional_pilot_guide.md](institutional_pilot_guide.md) |
+| Trust assumptions | [trusted_boundary.md](trusted_boundary.md) |
+| Limitations | [limitations.md](limitations.md) |
+| Integration contracts | [external_integration_contracts.md](external_integration_contracts.md) |
+| Reviewer guidance | [reviewer_guide.md](reviewer_guide.md) |
+| Threat model | [threat_model.md](threat_model.md) |
diff --git a/docs/signing_assurance.md b/docs/signing_assurance.md
new file mode 100644
index 0000000..e665005
--- /dev/null
+++ b/docs/signing_assurance.md
@@ -0,0 +1,38 @@
+# Signing Assurance (SAL)
+
+SCOPE v0.7 records signing assurance levels on decision and grant provenance.
+
+## Levels
+
+| Level | Meaning |
+|-------|---------|
+| SAL0 | Unsigned artifact |
+| SAL1 | Local PEM (`LocalPemProvider`) with valid Ed25519 signature |
+| SAL2 | `EnvKeyProvider` (environment key path) |
+| SAL3 | `RegistryKeyProvider` with reviewer_id binding |
+| SAL4 | External HSM/KMS (interface only; no in-repo implementation) |
+
+## Policy
+
+`policy/minimum_signing_assurance.yaml` defines production minimums:
+
+- Default minimum: SAL1
+- High-risk scopes (e.g. `robot_queue_submission`): SAL3
+
+Production mode enforces minimum SAL at grant issue.
+
+## Operational risk
+
+`EnvKeyProvider` emits CLI and log warnings about environment key path exposure. Prefer registry-bound (SAL3) or HSM/KMS (SAL4) for production.
+
+## SAL4 external boundary
+
+`scope.signing_assurance.HsmKmsSigningProvider` documents the institutional integration point. Configure vendor SDKs outside SCOPE; set `SCOPE_HSM_ENDPOINT` when wiring external signers.
+
+Schema provenance field: `signing_assurance_level`.
+
+## Related documentation
+
+- [key_management.md](key_management.md) — signing providers and key registry
+- [trusted_boundary.md](trusted_boundary.md) — production mode enforcement
+- [akta_review_contract.md](akta_review_contract.md) — SAL recorded in `summary.json`
diff --git a/docs/threat_model.md b/docs/threat_model.md
index 484dc18..d8f94f9 100644
--- a/docs/threat_model.md
+++ b/docs/threat_model.md
@@ -1,19 +1,29 @@
# Threat Model
-## v0.1 threats addressed
+## Threats addressed (v0.7)
-- Schema validation failures
-- Decision tampering (canonical hashes)
-- Grant broadening (scope hierarchy enforcement)
-- Stale approval reuse (expiration checks)
+- Schema validation failures on AKTA imports, packets, decisions, grants, and session artifacts
+- Decision tampering (canonical hashes; optional Ed25519 signatures in production mode)
+- Grant broadening (scope hierarchy enforcement; overbroad approval rejection)
+- Stale approval reuse (expiration checks on protocol version, evidence state, domain overlay, and more)
- Unknown scope / invalid role (fail closed)
-- Ledger deletion (hash chain verification)
+- Ledger deletion or tampering (local hash chain verification)
+- Fake reviewer identity (partial mitigation via IAL/OIDC/RBAC at IAL3–IAL4; registry key binding at SAL3)
+- Unsigned production grants (production mode requires signed decisions; minimum SAL enforced)
+- Remote ledger loss (delivery modes: best_effort, at_least_once spool, fail_closed for high-risk grants)
+- Queue workflow bypass (explicit 10-state transitions; forbidden direct grant paths)
-## v0.1 threats not fully addressed
+## Threats partially addressed
-- Fake reviewer identity (trusted caller assumed)
-- Digital signatures and external ledger
-- Collusion and institutional pressure
-- Artifact substitution without hash verification
+- Collusion and institutional pressure (quality metrics detect rubber-stamping; not prevented)
+- Artifact substitution without hash verification (packets should use hash-addressed refs; operator responsibility)
+- HSM/KMS key exfiltration (SAL4 interface documented; operator-managed external integration)
+
+## Threats not fully addressed
+
+- Reviewer competence, honesty, or scientific judgment
+- Domain safety, legal compliance, or physical lab safety
+- Live directory sync for enterprise RBAC (YAML-file based only)
+- Authoritative remote ledger (local JSONL chain remains source of tamper evidence)
See [trusted_boundary.md](trusted_boundary.md) and [limitations.md](limitations.md).
diff --git a/docs/trusted_boundary.md b/docs/trusted_boundary.md
index 82c6279..0d7b6f3 100644
--- a/docs/trusted_boundary.md
+++ b/docs/trusted_boundary.md
@@ -1,16 +1,17 @@
# Trusted Boundary
-SCOPE v0.3 assumes:
+SCOPE v0.7 assumes:
-- Reviewer identity is provided by a trusted caller or configured registry
-- Reviewer role assignments are correctly configured in policy YAML
+- Reviewer identity is provided by a trusted caller, OIDC/JWT verification, or configured registry
+- Reviewer role assignments are correctly configured in policy YAML and, when enabled, `org_rbac.yaml`
- Artifacts in packets are authentic or hash-addressed
- Runtime systems enforce grants or pass them to PF-Core
- AKTA Records are valid and schema-checked
-- When `SCOPE_PRODUCTION_MODE` is enabled, signing keys are held by authorized reviewers
+- When `SCOPE_PRODUCTION_MODE` is enabled, signing keys are held by authorized reviewers and meet minimum signing assurance (SAL) for the approved scope
- Session stores (JSON/SQLite) are protected at the filesystem level
+- Ledger delivery mode matches institutional risk tolerance (`SCOPE_LEDGER_DELIVERY_MODE`)
-SCOPE v0.3 does not guarantee reviewer competence, honesty, scientific truth, domain safety, legal compliance, or physical lab safety.
+SCOPE v0.7 does not guarantee reviewer competence, honesty, scientific truth, domain safety, legal compliance, or physical lab safety.
## Production mode and signing
@@ -20,7 +21,7 @@ Set `SCOPE_PRODUCTION_MODE=true` (or `1`, `yes`, `production`) to enforce fail-c
|-------|-------------|
| Decision submit | Signature optional; unsigned decisions marked `signature_required=true` |
| Decision validate | Use `--require-signature` to enforce signature presence |
-| Grant issue | Source decision must carry a valid `decision_signature` |
+| Grant issue | Source decision must carry a valid `decision_signature`; minimum SAL enforced per scope |
| Grant sign | Optional; copies signature metadata from decision when present |
| Verify | Use `--public-key` for verification without private key access |
@@ -36,8 +37,39 @@ scope verify --artifact signed_decision.json --public-key reviewer.pub --type de
Grant issue validates the **decision** signature, not the grant signature. Grant signing is a separate step for downstream verification, PF/PCS export, and institutional audit trails.
+Signing assurance levels (SAL0–SAL4) are recorded on grant provenance. See [signing_assurance.md](signing_assurance.md) and [key_management.md](key_management.md).
+
REST sign/verify endpoints accept `SCOPE_SIGNING_KEY` (private) or `public_key_path` in verify requests.
+## Identity assurance (IAL)
+
+Identity assurance levels (IAL0–IAL4) record how reviewer identity was established. Caller JSON alone is IAL0; OIDC plus org RBAC can reach IAL3/IAL4. Two-stage authority checks (`authority_checks` provenance) distinguish skipped RBAC from failed checks.
+
+| Variable | Purpose |
+|----------|---------|
+| `SCOPE_OIDC_ENABLED` | Enable REST middleware identity binding |
+| `SCOPE_OIDC_JWKS_URL` | JWKS endpoint for RS256 verification |
+| `SCOPE_OIDC_ISSUER` | Expected JWT `iss` claim |
+| `SCOPE_OIDC_AUDIENCE` | Expected JWT `aud` claim |
+| `SCOPE_OIDC_PUBLIC_KEY_PEM` | Static PEM alternative to JWKS |
+| `SCOPE_ENFORCE_RBAC` | Require org RBAC on decision submit (also triggered at IAL3/IAL4) |
+
+Claim mapping is configured in `policy/identity_mapping.yaml`. CLI: `scope identity verify-token --token ...`
+
+See [identity_assurance.md](identity_assurance.md) and [rbac_scope_authority.md](rbac_scope_authority.md).
+
+## Ledger delivery
+
+Remote ledger append supports three delivery modes via `SCOPE_LEDGER_DELIVERY_MODE`:
+
+| Mode | Behavior |
+|------|----------|
+| `best_effort` | Append to remote sink without blocking grant issue (default) |
+| `at_least_once` | Spool failed remote deliveries for retry |
+| `fail_closed` | Block high-risk grant issuance when remote delivery fails |
+
+Authoritative tamper evidence remains the local hash-chained JSONL ledger. See [limitations.md](limitations.md).
+
## Multi-reviewer sessions
Review sessions collect votes from required roles before grant issuance. Safety officers listed in `safety_veto_roles` may submit a `reject` decision even when not in the action's required role list; a safety veto blocks grant issuance regardless of approving votes.
@@ -45,7 +77,7 @@ Review sessions collect votes from required roles before grant issuance. Safety
Session persistence is configurable:
| Backend | CLI | Environment |
-|---------|-----|---------------|
+|---------|-----|-------------|
| memory | `--session-store memory` (default) | `SCOPE_SESSION_STORE=memory` |
| JSON files | `--session-store json --session-dir .scope/sessions` | `SCOPE_SESSION_STORE=json` |
| SQLite | `--session-store sqlite --session-dir .scope/sessions.db` | `SCOPE_SESSION_STORE=sqlite` |
@@ -54,23 +86,9 @@ Duplicate votes from the same reviewer are rejected. Votes are recorded in both
## Policy version
-Active policy is tagged `scope-core-v0.6`. Grants record `provenance.scope_policy_version`; runtime context may include matching `scope_policy_version` for expiration checks.
-
-## Identity (v0.6)
-
-Optional OIDC/JWT verification binds bearer tokens to reviewer identity:
-
-| Variable | Purpose |
-|----------|---------|
-| `SCOPE_OIDC_ENABLED` | Enable REST middleware identity binding |
-| `SCOPE_OIDC_JWKS_URL` | JWKS endpoint for RS256 verification |
-| `SCOPE_OIDC_ISSUER` | Expected JWT `iss` claim |
-| `SCOPE_OIDC_AUDIENCE` | Expected JWT `aud` claim |
-| `SCOPE_OIDC_PUBLIC_KEY_PEM` | Static PEM alternative to JWKS |
-
-Claim mapping is configured in `policy/identity_mapping.yaml`. CLI: `scope identity verify-token --token ...`
+Active policy is tagged `scope-core-v0.7`. Grants record `provenance.scope_policy_version`; runtime context may include matching `scope_policy_version` for expiration checks.
-## Trust root (v0.6)
+## Trust root
Signed decisions and grants carry provenance hashes:
diff --git a/evals/run_review_cases.py b/evals/run_review_cases.py
index 220039b..808ca0f 100644
--- a/evals/run_review_cases.py
+++ b/evals/run_review_cases.py
@@ -1,4 +1,4 @@
-"""Evaluation scenario runner for SCOPE v0.6."""
+"""Evaluation scenario runner for SCOPE v0.7."""
from __future__ import annotations
@@ -16,13 +16,24 @@
sys.path.insert(0, str(ROOT))
from scope import ScopeEngine
-from scope.errors import DecisionValidationError, RoleValidationError, ScopeValidationError
+from scope.errors import (
+ DecisionValidationError,
+ LedgerError,
+ RoleValidationError,
+ ScopeValidationError,
+)
+from scope.review_workflow import validate_transition
from scope.signing import Ed25519Signer
from tests.jwt_helpers import build_rs256_jwt, generate_rsa_keypair
POLICY = ROOT / "policy"
SCENARIOS_DIR = ROOT / "evals" / "scenarios"
EXTENDED_DIR = SCENARIOS_DIR / "extended"
+LEDGER_ENV_KEYS = (
+ "SCOPE_LEDGER_DELIVERY_MODE",
+ "SCOPE_LEDGER_REMOTE_URL",
+ "SCOPE_LEDGER_REMOTE_TOKEN",
+)
@dataclass
@@ -32,6 +43,125 @@ class ScenarioResult:
message: str
+def _save_ledger_env() -> dict[str, str | None]:
+ return {key: os.environ.get(key) for key in LEDGER_ENV_KEYS}
+
+
+def _restore_ledger_env(saved: dict[str, str | None]) -> None:
+ for key, value in saved.items():
+ if value is None:
+ os.environ.pop(key, None)
+ else:
+ os.environ[key] = value
+
+
+def _apply_ledger_env(scenario: dict[str, Any]) -> None:
+ if scenario.get("ledger_delivery_mode"):
+ os.environ["SCOPE_LEDGER_DELIVERY_MODE"] = str(scenario["ledger_delivery_mode"])
+ if scenario.get("ledger_remote_url"):
+ os.environ["SCOPE_LEDGER_REMOTE_URL"] = str(scenario["ledger_remote_url"])
+
+
+def _run_queue_transition_scenario(scenario: dict[str, Any]) -> ScenarioResult:
+ name = scenario["name"]
+ trans = scenario["expect_queue_transition_error"]
+ try:
+ validate_transition(trans["from"], trans["to"])
+ return ScenarioResult(
+ name,
+ False,
+ f"Expected transition error for {trans['from']} -> {trans['to']}",
+ )
+ except ScopeValidationError as exc:
+ return ScenarioResult(name, True, f"Correctly rejected: {exc}")
+
+
+def _assert_identity_provenance(
+ scenario: dict[str, Any],
+ decision: dict[str, Any],
+ grant: dict[str, Any] | None = None,
+) -> ScenarioResult | None:
+ name = scenario["name"]
+ expected_ial = scenario.get("expect_identity_assurance_level")
+ if expected_ial:
+ actual = (decision.get("provenance") or {}).get("identity_assurance_level")
+ if actual != expected_ial:
+ return ScenarioResult(
+ name,
+ False,
+ f"Decision IAL {actual} != {expected_ial}",
+ )
+ if grant is not None:
+ grant_ial = (grant.get("provenance") or {}).get("identity_assurance_level")
+ if grant_ial != expected_ial:
+ return ScenarioResult(
+ name,
+ False,
+ f"Grant IAL {grant_ial} != {expected_ial}",
+ )
+ expected_source = scenario.get("expect_role_resolution_source")
+ if expected_source:
+ actual = (decision.get("provenance") or {}).get("role_resolution_source")
+ if actual != expected_source:
+ return ScenarioResult(
+ name,
+ False,
+ f"Decision role_resolution_source {actual} != {expected_source}",
+ )
+ if grant is not None:
+ grant_source = (grant.get("provenance") or {}).get("role_resolution_source")
+ if grant_source != expected_source:
+ return ScenarioResult(
+ name,
+ False,
+ f"Grant role_resolution_source {grant_source} != {expected_source}",
+ )
+
+ expected_identity_source = scenario.get("expect_identity_source")
+ if expected_identity_source:
+ actual = (decision.get("provenance") or {}).get("identity_source")
+ if actual != expected_identity_source:
+ return ScenarioResult(
+ name,
+ False,
+ f"Decision identity_source {actual} != {expected_identity_source}",
+ )
+ if grant is not None:
+ grant_source = (grant.get("provenance") or {}).get("identity_source")
+ if grant_source != expected_identity_source:
+ return ScenarioResult(
+ name,
+ False,
+ f"Grant identity_source {grant_source} != {expected_identity_source}",
+ )
+
+ if grant is not None and scenario.get("expect_authority_checks", True):
+ decision_checks = (decision.get("provenance") or {}).get("authority_checks")
+ grant_checks = (grant.get("provenance") or {}).get("authority_checks")
+ if not decision_checks:
+ return ScenarioResult(name, False, "Decision missing authority_checks provenance")
+ if not grant_checks:
+ return ScenarioResult(name, False, "Grant missing authority_checks provenance")
+ if grant_checks != decision_checks:
+ return ScenarioResult(
+ name,
+ False,
+ "Grant authority_checks != decision authority_checks",
+ )
+
+ expected_sal = scenario.get("expect_signing_assurance_level")
+ if expected_sal and grant is not None:
+ actual_sal = (grant.get("provenance") or {}).get("signing_assurance_level")
+ if actual_sal != expected_sal:
+ return ScenarioResult(
+ name,
+ False,
+ f"Grant SAL {actual_sal} != {expected_sal}",
+ )
+
+ return None
+
+
def _load(name: str, *, extended: bool = False) -> dict[str, Any]:
base = EXTENDED_DIR if extended else SCENARIOS_DIR
path = base / name
@@ -77,9 +207,83 @@ def run_scenario(scenario: dict[str, Any]) -> ScenarioResult:
else:
os.environ.pop("SCOPE_PRODUCTION_MODE", None)
- engine = ScopeEngine.from_policy_dir(POLICY)
+ if scenario.get("expect_queue_transition_error"):
+ return _run_queue_transition_scenario(scenario)
+
+ saved_ledger_env = _save_ledger_env()
+ try:
+ defer_ledger = scenario.get("defer_ledger_env_until_grant")
+ if not defer_ledger:
+ _apply_ledger_env(scenario)
+ with tempfile.TemporaryDirectory() as ledger_tmp:
+ ledger_path = None
+ if scenario.get("ledger_delivery_mode"):
+ ledger_path = Path(ledger_tmp) / "events.jsonl"
+ engine = ScopeEngine.from_policy_dir(POLICY, ledger_path=ledger_path)
+ return _run_scenario_with_engine(engine, scenario, defer_ledger_env=defer_ledger)
+ finally:
+ _restore_ledger_env(saved_ledger_env)
+
+
+def _run_akta_review_scenario(
+ engine: ScopeEngine,
+ scenario: dict[str, Any],
+) -> ScenarioResult:
+ from scope.akta_review import run_akta_review
+ from scope.schema_util import validate_artifact
+
+ name = scenario["name"]
+ with tempfile.TemporaryDirectory() as out_dir:
+ signing_key: Path | None = None
+ if scenario.get("sign_before_grant"):
+ key = Path(out_dir) / "reviewer.pem"
+ pub = Path(out_dir) / "reviewer.pub"
+ Ed25519Signer.generate_keypair(key, pub)
+ signing_key = key
+ summary = run_akta_review(
+ engine,
+ akta_record=scenario["akta_record"],
+ akta_trigger=scenario["akta_trigger"],
+ grant_scope=scenario.get("grant_scope", scenario["decision"]["approved_scope"]),
+ reviewer=scenario["reviewer"],
+ decision_rationale=scenario["decision"]["rationale"],
+ out_dir=out_dir,
+ signing_key=signing_key,
+ )
+
+ validate_artifact(summary, "scope_akta_review_summary.schema.json")
+ expected_sal = scenario.get("expect_signing_assurance_level")
+ if expected_sal and summary.get("signing_assurance_level") != expected_sal:
+ return ScenarioResult(
+ name,
+ False,
+ f"Summary SAL {summary.get('signing_assurance_level')} != {expected_sal}",
+ )
+ expected_ial = scenario.get("expect_identity_assurance_level")
+ if expected_ial and summary.get("identity_assurance_level") != expected_ial:
+ return ScenarioResult(
+ name,
+ False,
+ f"Summary IAL {summary.get('identity_assurance_level')} != {expected_ial}",
+ )
+ for field in ("packet_path", "decision_path", "grant_path"):
+ path = Path(summary[field])
+ if not path.is_file():
+ return ScenarioResult(name, False, f"Missing artifact: {field}")
+ return ScenarioResult(name, True, "AKTA review contract OK")
+
+
+def _run_scenario_with_engine(
+ engine: ScopeEngine,
+ scenario: dict[str, Any],
+ *,
+ defer_ledger_env: bool = False,
+) -> ScenarioResult:
name = scenario["name"]
try:
+ if scenario.get("run_akta_review"):
+ return _run_akta_review_scenario(engine, scenario)
+
vsa_path = scenario.get("vsa_report_path")
vsa_report = ROOT / vsa_path if vsa_path else None
packet = engine.create_packet(
@@ -142,13 +346,22 @@ def run_scenario(scenario: dict[str, Any]) -> ScenarioResult:
"exp": int(time.time()) + 3600,
},
)
+ if scenario.get("enforce_rbac"):
+ os.environ["SCOPE_ENFORCE_RBAC"] = "true"
decision = engine.submit_decision(
packet,
scenario["reviewer"],
scenario["decision"],
identity_token=token,
+ enforce_rbac=scenario.get("enforce_rbac"),
)
+ identity_check = _assert_identity_provenance(scenario, decision)
+ if identity_check is not None:
+ return identity_check
grant = engine.issue_grant(packet, decision)
+ identity_check = _assert_identity_provenance(scenario, decision, grant)
+ if identity_check is not None:
+ return identity_check
approved = grant["authorization"]["approved_scope"]
if scenario.get("expected_scope") and approved != scenario["expected_scope"]:
return ScenarioResult(
@@ -183,7 +396,29 @@ def run_scenario(scenario: dict[str, Any]) -> ScenarioResult:
Ed25519Signer.generate_keypair(key, pub)
decision = engine.sign_decision(decision, Ed25519Signer(key))
+ if scenario.get("expect_grant_error"):
+ if defer_ledger_env:
+ from scope.ledger import ScopeLedger
+
+ _apply_ledger_env(scenario)
+ assert engine.ledger.path is not None
+ engine.ledger = ScopeLedger(engine.ledger.path)
+ try:
+ engine.issue_grant(packet, decision)
+ return ScenarioResult(name, False, "Expected grant error but succeeded")
+ except LedgerError as exc:
+ if "grant_issued" not in str(exc) and defer_ledger_env:
+ return ScenarioResult(
+ name,
+ False,
+ f"Expected grant_issued fail_closed error, got: {exc}",
+ )
+ return ScenarioResult(name, True, f"Correctly rejected: {exc}")
+
grant = engine.issue_grant(packet, decision)
+ identity_check = _assert_identity_provenance(scenario, decision, grant)
+ if identity_check is not None:
+ return identity_check
violation = scenario.get("record_runtime_violation")
if violation:
@@ -230,6 +465,8 @@ def run_scenario(scenario: dict[str, Any]) -> ScenarioResult:
except Exception as exc:
if scenario.get("expect_decision_error"):
return ScenarioResult(name, True, f"Correctly rejected: {exc}")
+ if scenario.get("expect_grant_error") and isinstance(exc, LedgerError):
+ return ScenarioResult(name, True, f"Correctly rejected: {exc}")
return ScenarioResult(name, False, str(exc))
@@ -253,6 +490,11 @@ def run_scenario(scenario: dict[str, Any]) -> ScenarioResult:
"queue_auto_assign.json",
"runtime_violation_outcome_metric.json",
"biosecurity_mandatory_session.json",
+ "identity_assurance_caller_ial0.json",
+ "identity_assurance_local_signed_ial1.json",
+ "queue_invalid_transition.json",
+ "fail_closed_grant_blocked.json",
+ "akta_review_signed_summary.json",
]
@@ -273,7 +515,7 @@ def main() -> int:
parser.add_argument(
"--extended",
action="store_true",
- help="Also run extended v0.5 scenarios (multi-review, signing, VSA, overlay)",
+ help="Also run extended v0.7 scenarios (IAL, SAL, queue, ledger, AKTA contract)",
)
args = parser.parse_args()
diff --git a/evals/scenarios/extended/akta_review_signed_summary.json b/evals/scenarios/extended/akta_review_signed_summary.json
new file mode 100644
index 0000000..f395dd6
--- /dev/null
+++ b/evals/scenarios/extended/akta_review_signed_summary.json
@@ -0,0 +1,25 @@
+{
+ "name": "akta_review_signed_summary",
+ "run_akta_review": true,
+ "akta_record": {
+ "record_id": "AKTA-EVAL-SUMMARY",
+ "scientific_action_type": "A5_protocol_modification"
+ },
+ "akta_trigger": {
+ "akta_admissibility": "review_required",
+ "scientific_action_type": "A5_protocol_modification",
+ "requested_action": "draft_protocol",
+ "requested_tool": "protocol_editor.draft_change",
+ "requested_scope": "protocol_draft"
+ },
+ "reviewer": {"reviewer_id": "po1", "role": "protocol_owner"},
+ "decision": {
+ "type": "approve_narrower_scope",
+ "approved_scope": "protocol_draft",
+ "rationale": "Signed AKTA review summary contract validation"
+ },
+ "grant_scope": "protocol_draft",
+ "sign_before_grant": true,
+ "expect_identity_assurance_level": "IAL1",
+ "expect_signing_assurance_level": "SAL1"
+}
diff --git a/evals/scenarios/extended/fail_closed_grant_blocked.json b/evals/scenarios/extended/fail_closed_grant_blocked.json
new file mode 100644
index 0000000..cabccd8
--- /dev/null
+++ b/evals/scenarios/extended/fail_closed_grant_blocked.json
@@ -0,0 +1,24 @@
+{
+ "name": "fail_closed_blocks_grant_ledger",
+ "akta_record": {
+ "record_id": "AKTA-EVAL-FAILCLOSED",
+ "scientific_action_type": "A5_protocol_modification"
+ },
+ "akta_trigger": {
+ "akta_admissibility": "review_required",
+ "scientific_action_type": "A5_protocol_modification",
+ "requested_action": "draft_protocol",
+ "requested_tool": "protocol_editor.draft_change",
+ "requested_scope": "protocol_draft"
+ },
+ "reviewer": {"reviewer_id": "po1", "role": "protocol_owner"},
+ "decision": {
+ "type": "approve_narrower_scope",
+ "approved_scope": "protocol_draft",
+ "rationale": "Grant must fail when remote ledger unavailable in fail_closed mode"
+ },
+ "ledger_delivery_mode": "fail_closed",
+ "ledger_remote_url": "http://127.0.0.1:1/unreachable",
+ "defer_ledger_env_until_grant": true,
+ "expect_grant_error": true
+}
diff --git a/evals/scenarios/extended/identity_assurance_caller_ial0.json b/evals/scenarios/extended/identity_assurance_caller_ial0.json
new file mode 100644
index 0000000..7c5a8db
--- /dev/null
+++ b/evals/scenarios/extended/identity_assurance_caller_ial0.json
@@ -0,0 +1,24 @@
+{
+ "name": "identity_assurance_caller_ial0",
+ "akta_record": {
+ "record_id": "AKTA-EVAL-IAL0",
+ "scientific_action_type": "A5_protocol_modification"
+ },
+ "akta_trigger": {
+ "akta_admissibility": "review_required",
+ "scientific_action_type": "A5_protocol_modification",
+ "requested_action": "draft_protocol",
+ "requested_tool": "protocol_editor.draft_change",
+ "requested_scope": "protocol_draft"
+ },
+ "reviewer": {"reviewer_id": "caller_only", "role": "protocol_owner"},
+ "decision": {
+ "type": "approve_narrower_scope",
+ "approved_scope": "protocol_draft",
+ "rationale": "Caller-supplied JSON must record IAL0 provenance"
+ },
+ "expected_scope": "protocol_draft",
+ "expect_identity_assurance_level": "IAL0",
+ "expect_role_resolution_source": "caller_supplied",
+ "expect_identity_source": "caller_json"
+}
diff --git a/evals/scenarios/extended/identity_assurance_local_signed_ial1.json b/evals/scenarios/extended/identity_assurance_local_signed_ial1.json
new file mode 100644
index 0000000..bdbb4f1
--- /dev/null
+++ b/evals/scenarios/extended/identity_assurance_local_signed_ial1.json
@@ -0,0 +1,25 @@
+{
+ "name": "identity_assurance_local_signed_ial1",
+ "akta_record": {
+ "record_id": "AKTA-EVAL-IAL1",
+ "scientific_action_type": "A5_protocol_modification"
+ },
+ "akta_trigger": {
+ "akta_admissibility": "review_required",
+ "scientific_action_type": "A5_protocol_modification",
+ "requested_action": "draft_protocol",
+ "requested_tool": "protocol_editor.draft_change",
+ "requested_scope": "protocol_draft"
+ },
+ "reviewer": {"reviewer_id": "po1", "role": "protocol_owner"},
+ "decision": {
+ "type": "approve_narrower_scope",
+ "approved_scope": "protocol_draft",
+ "rationale": "Local signed key must record IAL1 provenance"
+ },
+ "expected_scope": "protocol_draft",
+ "sign_before_grant": true,
+ "expect_identity_assurance_level": "IAL1",
+ "expect_role_resolution_source": "local_signed_key",
+ "expect_identity_source": "local_signed_key"
+}
diff --git a/evals/scenarios/extended/oidc_identity_mock_path.json b/evals/scenarios/extended/oidc_identity_mock_path.json
index 5306b80..2ff6bb0 100644
--- a/evals/scenarios/extended/oidc_identity_mock_path.json
+++ b/evals/scenarios/extended/oidc_identity_mock_path.json
@@ -2,23 +2,27 @@
"name": "oidc_identity_mock_path",
"akta_record": {
"record_id": "AKTA-EVAL-OIDC",
- "scientific_action_type": "A5_protocol_modification"
+ "scientific_action_type": "A4_recommendation"
},
"akta_trigger": {
"akta_admissibility": "review_required",
- "scientific_action_type": "A5_protocol_modification",
- "requested_action": "draft_protocol",
- "requested_tool": "protocol_editor.draft_change",
- "requested_scope": "protocol_draft"
+ "scientific_action_type": "A4_recommendation",
+ "requested_action": "draft_recommendation",
+ "requested_tool": "recommendation.draft",
+ "requested_scope": "draft_recommendation"
},
"reviewer": {"reviewer_id": "legacy-id", "role": "wrong_role"},
"decision": {
- "type": "approve_narrower_scope",
- "approved_scope": "protocol_draft",
- "rationale": "OIDC identity override test"
+ "type": "approve",
+ "approved_scope": "draft_recommendation",
+ "rationale": "OIDC identity override with org RBAC (IAL3)"
},
- "expected_scope": "protocol_draft",
+ "expected_scope": "draft_recommendation",
"use_oidc_identity": true,
- "oidc_sub": "po1",
- "oidc_role": "protocol_owner"
+ "oidc_sub": "ds1",
+ "oidc_role": "domain_scientist",
+ "enforce_rbac": true,
+ "expect_identity_assurance_level": "IAL3",
+ "expect_role_resolution_source": "org_rbac",
+ "expect_identity_source": "oidc_jwt"
}
diff --git a/evals/scenarios/extended/production_signing_sequence.json b/evals/scenarios/extended/production_signing_sequence.json
index 2f48fbc..a568442 100644
--- a/evals/scenarios/extended/production_signing_sequence.json
+++ b/evals/scenarios/extended/production_signing_sequence.json
@@ -19,5 +19,6 @@
"rationale": "signed approval"
},
"expected_scope": "protocol_draft",
- "sign_before_grant": true
+ "sign_before_grant": true,
+ "expect_signing_assurance_level": "SAL1"
}
diff --git a/evals/scenarios/extended/queue_invalid_transition.json b/evals/scenarios/extended/queue_invalid_transition.json
new file mode 100644
index 0000000..278875d
--- /dev/null
+++ b/evals/scenarios/extended/queue_invalid_transition.json
@@ -0,0 +1,7 @@
+{
+ "name": "queue_invalid_transition_blocked",
+ "expect_queue_transition_error": {
+ "from": "open",
+ "to": "granted"
+ }
+}
diff --git a/examples/decision_ds.json b/examples/decision_ds.json
new file mode 100644
index 0000000..3c92447
--- /dev/null
+++ b/examples/decision_ds.json
@@ -0,0 +1,6 @@
+{
+ "type": "approve_narrower_scope",
+ "approved_scope": "protocol_draft",
+ "rationale": "Co-review confirms draft-only scope is appropriate for current evidence.",
+ "reviewer_confidence": 0.75
+}
diff --git a/examples/decision_po.json b/examples/decision_po.json
new file mode 100644
index 0000000..0aa9e77
--- /dev/null
+++ b/examples/decision_po.json
@@ -0,0 +1,15 @@
+{
+ "type": "approve_narrower_scope",
+ "approved_scope": "protocol_draft",
+ "rationale": "Evidence supports draft-only validation; active protocol update not justified.",
+ "rejected_scope": [
+ "active_protocol_update",
+ "robot_queue_submission",
+ "queue_prioritization"
+ ],
+ "required_modifications": [
+ "restore original acceptance threshold",
+ "label output as validation-only"
+ ],
+ "reviewer_confidence": 0.78
+}
diff --git a/examples/institutional_pilot/README.md b/examples/institutional_pilot/README.md
index 390bdbc..01fb08c 100644
--- a/examples/institutional_pilot/README.md
+++ b/examples/institutional_pilot/README.md
@@ -1,6 +1,6 @@
# Institutional Pilot Examples
-Sample artifacts for SCOPE v0.5.1 pilot workshops. See docs/institutional_pilot_guide.md.
+Sample artifacts for SCOPE v0.7 institutional pilot workshops. See [docs/institutional_pilot_guide.md](../../docs/institutional_pilot_guide.md).
| File | Description |
|------|-------------|
@@ -14,3 +14,16 @@ Sample artifacts for SCOPE v0.5.1 pilot workshops. See docs/institutional_pilot_
| reviewer_protocol_owner.json | Reviewer identity fixture |
| decision.json | Decision input fixture |
| current_context.json | Runtime context for grant check |
+
+Artifacts use policy bundle `scope-core-v0.7`. In production mode, sign decisions before grant issue; see [docs/trusted_boundary.md](../../docs/trusted_boundary.md).
+
+To regenerate PCS export from these artifacts:
+
+```bash
+scope export pcs \
+ --packet examples/institutional_pilot/scope_packet.json \
+ --decision examples/institutional_pilot/scope_decision.json \
+ --grant examples/institutional_pilot/scope_grant.json \
+ --out dist/pcs_scope_artifact/ \
+ --validate
+```
diff --git a/examples/institutional_pilot/current_context.json b/examples/institutional_pilot/current_context.json
index bba5bb1..b5958b9 100644
--- a/examples/institutional_pilot/current_context.json
+++ b/examples/institutional_pilot/current_context.json
@@ -2,5 +2,5 @@
"protocol_version": "protocol_v3",
"evidence_state": "E2_preliminary_signal",
"project_id": "drift_demo",
- "scope_policy_version": "scope-core-v0.6"
+ "scope_policy_version": "scope-core-v0.7"
}
diff --git a/examples/institutional_pilot/scope_decision.json b/examples/institutional_pilot/scope_decision.json
index e69fb18..a89a8a3 100644
--- a/examples/institutional_pilot/scope_decision.json
+++ b/examples/institutional_pilot/scope_decision.json
@@ -35,9 +35,9 @@
"packet_id": "SCOPE-PKT-764195",
"provenance": {
"reviewer_key_registry_hash": "sha256:1dec33e3bda7220f7c3ab33641c3384c4d550be30e468e27e1600eee85751e8d",
- "reviewer_key_registry_version": "scope-core-v0.6",
+ "reviewer_key_registry_version": "scope-core-v0.7",
"scope_policy_hash": "sha256:b8594a04fc2c29ca3c5280bb8a30734463a576af19d2b0f7cc9fb5b8bcb5ed8d",
- "scope_policy_version": "scope-core-v0.6",
+ "scope_policy_version": "scope-core-v0.7",
"scope_trust_root_hash": "sha256:90de41557b588620c483ebd060707566a520c73a39416b381ba9f769ef3b87d7"
},
"reviewer": {
diff --git a/examples/institutional_pilot/scope_grant.json b/examples/institutional_pilot/scope_grant.json
index e1bf391..1e6c6ba 100644
--- a/examples/institutional_pilot/scope_grant.json
+++ b/examples/institutional_pilot/scope_grant.json
@@ -37,14 +37,14 @@
},
"grant_hash": "sha256:bf8788ee0f31ea9f639973e535e6c79fcb1ceb0eb3fa2bed30a51de8903239b0",
"grant_id": "SCOPE-GRANT-52BA47",
- "grant_version": "0.6.0",
+ "grant_version": "0.7.0",
"provenance": {
"akta_policy_hash": null,
"reviewer_key_registry_hash": "sha256:1dec33e3bda7220f7c3ab33641c3384c4d550be30e468e27e1600eee85751e8d",
- "reviewer_key_registry_version": "scope-core-v0.6",
+ "reviewer_key_registry_version": "scope-core-v0.7",
"reviewer_role_policy_hash": "sha256:b8594a04fc2c29ca3c5280bb8a30734463a576af19d2b0f7cc9fb5b8bcb5ed8d",
"scope_policy_hash": "sha256:b8594a04fc2c29ca3c5280bb8a30734463a576af19d2b0f7cc9fb5b8bcb5ed8d",
- "scope_policy_version": "scope-core-v0.6",
+ "scope_policy_version": "scope-core-v0.7",
"scope_trust_root_hash": "sha256:90de41557b588620c483ebd060707566a520c73a39416b381ba9f769ef3b87d7"
},
"source": {
diff --git a/examples/protocol_change_review/README.md b/examples/protocol_change_review/README.md
index 0e3930f..2e78388 100644
--- a/examples/protocol_change_review/README.md
+++ b/examples/protocol_change_review/README.md
@@ -1,6 +1,8 @@
-# Protocol change review (Milestone 7 demo)
+# Protocol change review
-Primary proof-of-life scenario from design spec section 26/32: AKTA `review_required` for active protocol update becomes a narrower `protocol_draft` grant.
+Canonical full-pipeline scenario: AKTA `review_required` for active protocol update becomes a narrower `protocol_draft` grant.
+
+Fixtures align with eval scenario `protocol_draft_correctly_scoped.json`.
## Run
@@ -26,13 +28,25 @@ scope grant check \
--requested-tool protocol_editor.draft_change \
--context examples/protocol_change_review/current_context.json
-scope export pf --grant /tmp/grant.json --out dist/pf_obligation.json
+scope grant check \
+ --grant /tmp/grant.json \
+ --requested-tool robot_queue.submit \
+ --context examples/protocol_change_review/current_context.json
+
+scope export pf --grant /tmp/grant.json --out dist/pf_obligation.json --validate
scope export pcs \
--packet /tmp/packet.json \
--decision /tmp/decision.json \
--grant /tmp/grant.json \
- --out dist/pcs_scope_artifact/
+ --out dist/pcs_scope_artifact/ \
+ --validate
```
-Expected: `protocol_editor.draft_change` allowed; `robot_queue.submit` blocked; grant invalid after protocol version change.
+Expected:
+
+- `protocol_editor.draft_change` allowed
+- `robot_queue.submit` blocked
+- Grant invalid after protocol version change (see [stale_grant_attempt/](../stale_grant_attempt/))
+
+Alternative one-shot path: [docs/akta_scope_demo.md](../../docs/akta_scope_demo.md).
diff --git a/examples/protocol_change_review/current_context.json b/examples/protocol_change_review/current_context.json
index d6e487d..3db32e1 100644
--- a/examples/protocol_change_review/current_context.json
+++ b/examples/protocol_change_review/current_context.json
@@ -1,5 +1,5 @@
{
"protocol_version": "protocol_v3",
"evidence_state": "E2_preliminary_signal",
- "scope_policy_version": "scope-core-v0.6"
+ "scope_policy_version": "scope-core-v0.7"
}
diff --git a/examples/protocol_drift/README.md b/examples/protocol_drift/README.md
index 9050101..54a1397 100644
--- a/examples/protocol_drift/README.md
+++ b/examples/protocol_drift/README.md
@@ -1,9 +1,21 @@
# Protocol Drift Review Example
-Demonstrates SCOPE v0.2 handling of protocol modification with explicit `requested_scope`.
+Demonstrates SCOPE v0.7 handling of protocol modification with explicit `requested_scope`.
-AKTA nested record + trigger with `requested_scope: protocol_draft` produces a packet that
-restricts approval to draft-level scope. After grant issuance, protocol version drift
-invalidates the grant at runtime.
+AKTA nested record plus trigger with `requested_scope: protocol_draft` produces a packet that restricts approval to draft-level scope. After grant issuance, protocol version drift invalidates the grant at runtime.
-See `docs/akta_scope_demo.md` for the full AKTA to SCOPE to PF to PCS chain.
+## Quick start (AKTA one-shot)
+
+```bash
+scope akta review \
+ --akta-record examples/protocol_drift/akta_record.json \
+ --akta-trigger examples/protocol_drift/review_trigger.json \
+ --grant-scope protocol_draft \
+ --reviewer examples/protocol_drift/reviewer_protocol_owner.json \
+ --decision-rationale "Narrow protocol draft approval only." \
+ --out-dir /tmp/akta_review_out
+```
+
+For stale-grant behavior after protocol version change, see [stale_grant_attempt/](../stale_grant_attempt/).
+
+Full cross-repo chain: [docs/akta_scope_demo.md](../../docs/akta_scope_demo.md). Output contract: [docs/akta_review_contract.md](../../docs/akta_review_contract.md).
diff --git a/examples/protocol_drift/current_context.json b/examples/protocol_drift/current_context.json
index bba5bb1..b5958b9 100644
--- a/examples/protocol_drift/current_context.json
+++ b/examples/protocol_drift/current_context.json
@@ -2,5 +2,5 @@
"protocol_version": "protocol_v3",
"evidence_state": "E2_preliminary_signal",
"project_id": "drift_demo",
- "scope_policy_version": "scope-core-v0.6"
+ "scope_policy_version": "scope-core-v0.7"
}
diff --git a/examples/publication_claim_review/README.md b/examples/publication_claim_review/README.md
index 5266415..667bbe5 100644
--- a/examples/publication_claim_review/README.md
+++ b/examples/publication_claim_review/README.md
@@ -1,6 +1,8 @@
# Publication claim review (missing co-review)
-Scenario: publication claim escalation requires `publication_reviewer` plus `domain_scientist` co-review. Approval by publication reviewer alone must fail.
+Scenario: publication claim escalation (A10) requires `publication_reviewer` plus `domain_scientist` co-review. Approval by publication reviewer alone must fail.
+
+Single-decision submit cannot satisfy `require_all` multi-role actions; use a review session for the success path.
## Run (expect failure)
@@ -20,3 +22,5 @@ scope decision submit \
Expected: co-review validation error.
See `evals/scenarios/publication_claim_requires_domain_review.json`.
+
+For multi-reviewer session workflow, see [reviewer_guide.md](../../docs/reviewer_guide.md).
diff --git a/examples/queue_prioritization_review/README.md b/examples/queue_prioritization_review/README.md
index a69b283..15f4ae5 100644
--- a/examples/queue_prioritization_review/README.md
+++ b/examples/queue_prioritization_review/README.md
@@ -1,6 +1,6 @@
# Queue prioritization review (invalid reviewer)
-Scenario: queue prioritization requires `principal_investigator` or `lab_operations_lead`. A `protocol_owner` attempts approval — SCOPE must reject fail-closed.
+Scenario: queue prioritization (A7) requires `principal_investigator` or `lab_operations_lead`. A `protocol_owner` attempts approval — SCOPE must reject fail-closed.
## Run (expect failure)
@@ -20,3 +20,5 @@ scope decision submit \
Expected: decision rejected with role authorization error.
See `evals/scenarios/queue_prioritization_wrong_reviewer.json`.
+
+For v0.7 review queue workflow (10 states), see [docs/quality_metrics.md](../../docs/quality_metrics.md) and root README REST queue endpoints.
diff --git a/examples/reviewer_domain_scientist.json b/examples/reviewer_domain_scientist.json
new file mode 100644
index 0000000..84900ce
--- /dev/null
+++ b/examples/reviewer_domain_scientist.json
@@ -0,0 +1,6 @@
+{
+ "reviewer_id": "reviewer_ds_001",
+ "role": "domain_scientist",
+ "declared_expertise": ["experimental_design", "validation_methods"],
+ "conflict_declared": false
+}
diff --git a/examples/stale_grant_attempt/README.md b/examples/stale_grant_attempt/README.md
index 52a4833..8bd156d 100644
--- a/examples/stale_grant_attempt/README.md
+++ b/examples/stale_grant_attempt/README.md
@@ -24,14 +24,18 @@ scope grant issue \
scope grant check \
--grant /tmp/stale_grant.json \
--requested-tool protocol_editor.draft_change \
- --context examples/stale_grant_attempt/current_context.json
+ --context examples/stale_grant_attempt/current_context.json \
+ --ledger /tmp/scope_events.jsonl
scope grant check \
--grant /tmp/stale_grant.json \
--requested-tool protocol_editor.draft_change \
- --context examples/stale_grant_attempt/stale_context.json
+ --context examples/stale_grant_attempt/stale_context.json \
+ --ledger /tmp/scope_events.jsonl
```
Expected: first check ALLOWED; second check BLOCKED (stale after protocol version change).
See `evals/scenarios/stale_grant_after_protocol_change.json`.
+
+Related: [protocol_drift/](../protocol_drift/) for AKTA one-shot entry point.
diff --git a/examples/stale_grant_attempt/current_context.json b/examples/stale_grant_attempt/current_context.json
index d6e487d..3db32e1 100644
--- a/examples/stale_grant_attempt/current_context.json
+++ b/examples/stale_grant_attempt/current_context.json
@@ -1,5 +1,5 @@
{
"protocol_version": "protocol_v3",
"evidence_state": "E2_preliminary_signal",
- "scope_policy_version": "scope-core-v0.6"
+ "scope_policy_version": "scope-core-v0.7"
}
diff --git a/examples/stale_grant_attempt/stale_context.json b/examples/stale_grant_attempt/stale_context.json
index aef10f4..8aed41e 100644
--- a/examples/stale_grant_attempt/stale_context.json
+++ b/examples/stale_grant_attempt/stale_context.json
@@ -1,5 +1,5 @@
{
"protocol_version": "protocol_v4",
"evidence_state": "E2_preliminary_signal",
- "scope_policy_version": "scope-core-v0.6"
+ "scope_policy_version": "scope-core-v0.7"
}
diff --git a/examples/weak_evidence_validation_review/README.md b/examples/weak_evidence_validation_review/README.md
index 099b727..cf4e84e 100644
--- a/examples/weak_evidence_validation_review/README.md
+++ b/examples/weak_evidence_validation_review/README.md
@@ -1,6 +1,8 @@
# Weak evidence validation review
-Scenario: AKTA flags experimental planning with weak evidence (`E1_weak_signal`). The protocol owner narrows approval to `single_validation_run_draft` instead of broader execution scope.
+Scenario: AKTA flags experimental planning (A6) with weak evidence (`E1_weak_signal`). Protocol owner and domain scientist co-review and narrow approval to `single_validation_run_draft`.
+
+A6 requires a multi-reviewer session (`require_all` for `protocol_owner` and `domain_scientist`).
## Run
@@ -10,23 +12,46 @@ scope packet create \
--akta-trigger examples/weak_evidence_validation_review/review_trigger.json \
--out /tmp/weak_evidence_packet.json
-scope decision submit \
+scope review session create \
+ --packet /tmp/weak_evidence_packet.json \
+ --out /tmp/weak_evidence_session.json \
+ --session-store json --session-dir /tmp/sessions
+
+scope review session vote \
+ --session /tmp/weak_evidence_session.json \
--packet /tmp/weak_evidence_packet.json \
--reviewer examples/weak_evidence_validation_review/reviewer_protocol_owner.json \
--decision examples/weak_evidence_validation_review/decision.json \
- --out /tmp/weak_evidence_decision.json
+ --out /tmp/weak_evidence_decision_po.json \
+ --session-store json --session-dir /tmp/sessions
+
+scope review session vote \
+ --session /tmp/weak_evidence_session.json \
+ --packet /tmp/weak_evidence_packet.json \
+ --reviewer examples/reviewer_domain_scientist.json \
+ --decision examples/weak_evidence_validation_review/decision_ds.json \
+ --out /tmp/weak_evidence_decision_ds.json \
+ --session-store json --session-dir /tmp/sessions
-scope grant issue \
+scope review session issue-grant \
+ --session /tmp/weak_evidence_session.json \
--packet /tmp/weak_evidence_packet.json \
- --decision /tmp/weak_evidence_decision.json \
- --out /tmp/weak_evidence_grant.json
+ --decision /tmp/weak_evidence_decision_po.json \
+ --decision /tmp/weak_evidence_decision_ds.json \
+ --out /tmp/weak_evidence_grant.json \
+ --session-store json --session-dir /tmp/sessions
scope grant check \
--grant /tmp/weak_evidence_grant.json \
--requested-tool experiment_planner.create_validation_plan \
--context examples/weak_evidence_validation_review/current_context.json
+
+scope grant check \
+ --grant /tmp/weak_evidence_grant.json \
+ --requested-tool robot_queue.submit \
+ --context examples/weak_evidence_validation_review/current_context.json
```
Expected: validation planning allowed; `robot_queue.submit` blocked.
-See also `evals/scenarios/weak_evidence_validation_review.json`.
+See `evals/scenarios/weak_evidence_validation_review.json`.
diff --git a/examples/weak_evidence_validation_review/current_context.json b/examples/weak_evidence_validation_review/current_context.json
index b7b02da..1c3bd61 100644
--- a/examples/weak_evidence_validation_review/current_context.json
+++ b/examples/weak_evidence_validation_review/current_context.json
@@ -1,5 +1,5 @@
{
"protocol_version": "protocol_v2",
"evidence_state": "E1_weak_signal",
- "scope_policy_version": "scope-core-v0.6"
+ "scope_policy_version": "scope-core-v0.7"
}
diff --git a/examples/weak_evidence_validation_review/decision.json b/examples/weak_evidence_validation_review/decision.json
index 79f51f7..eaa6297 100644
--- a/examples/weak_evidence_validation_review/decision.json
+++ b/examples/weak_evidence_validation_review/decision.json
@@ -3,6 +3,5 @@
"approved_scope": "single_validation_run_draft",
"rationale": "Weak evidence supports validation draft only; robot submission remains blocked.",
"rejected_scope": ["robot_queue_submission", "active_protocol_update"],
- "co_reviewers": ["domain_scientist"],
"reviewer_confidence": 0.65
}
diff --git a/examples/weak_evidence_validation_review/decision_ds.json b/examples/weak_evidence_validation_review/decision_ds.json
new file mode 100644
index 0000000..5cc0976
--- /dev/null
+++ b/examples/weak_evidence_validation_review/decision_ds.json
@@ -0,0 +1,6 @@
+{
+ "type": "approve_narrower_scope",
+ "approved_scope": "single_validation_run_draft",
+ "rationale": "Co-review confirms draft-only validation scope given weak evidence.",
+ "reviewer_confidence": 0.72
+}
diff --git a/policy/approval_scopes.yaml b/policy/approval_scopes.yaml
index e215ceb..0070e8e 100644
--- a/policy/approval_scopes.yaml
+++ b/policy/approval_scopes.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
hierarchy:
- no_action
- clarification_only
diff --git a/policy/blocked_tool_severity.yaml b/policy/blocked_tool_severity.yaml
index f9e0c7a..793bd44 100644
--- a/policy/blocked_tool_severity.yaml
+++ b/policy/blocked_tool_severity.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
high_severity:
- robot_queue.submit
- protocol_editor.update_active_protocol
diff --git a/policy/decision_options.yaml b/policy/decision_options.yaml
index ec0bc94..445713f 100644
--- a/policy/decision_options.yaml
+++ b/policy/decision_options.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
decision_types:
- approve
- approve_with_modifications
diff --git a/policy/expiration_rules.yaml b/policy/expiration_rules.yaml
index f8b5817..143bf53 100644
--- a/policy/expiration_rules.yaml
+++ b/policy/expiration_rules.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
default_expiration:
protocol_draft:
expires_after:
diff --git a/policy/identity_mapping.yaml b/policy/identity_mapping.yaml
index f8bd126..9d4a951 100644
--- a/policy/identity_mapping.yaml
+++ b/policy/identity_mapping.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
description: Map OIDC JWT claims to SCOPE reviewer identity.
claim_mappings:
reviewer_id: sub
diff --git a/policy/minimum_signing_assurance.yaml b/policy/minimum_signing_assurance.yaml
new file mode 100644
index 0000000..c5e6550
--- /dev/null
+++ b/policy/minimum_signing_assurance.yaml
@@ -0,0 +1,10 @@
+version: scope-core-v0.7
+minimum_level: SAL1
+high_risk_minimum_level: SAL3
+enforce_in_development: false
+high_risk_scopes:
+ - robot_queue_submission
+ - tool_permission_escalation
+ - publication_claim
+ - active_protocol_update
+ - execution_payload_preparation
diff --git a/policy/org_rbac.yaml b/policy/org_rbac.yaml
index 4469748..085ee9a 100644
--- a/policy/org_rbac.yaml
+++ b/policy/org_rbac.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
org_units:
genomics_lab:
members:
diff --git a/policy/quality_metrics.yaml b/policy/quality_metrics.yaml
index 58152f4..a03c92f 100644
--- a/policy/quality_metrics.yaml
+++ b/policy/quality_metrics.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
thresholds:
rubber_stamp_min_review_seconds: 20
rubber_stamp_high_risk_count: 10
@@ -126,3 +126,7 @@ metrics:
post_approval_runtime_violation_rate:
category: outcome
v0_4_status: implemented
+ ledger_delivery_failure_count:
+ category: outcome
+ description: Ledger events with failed or spooled remote delivery state.
+ v0_7_status: implemented
diff --git a/policy/reviewer_assignments.yaml b/policy/reviewer_assignments.yaml
index 87f1188..fc7caa6 100644
--- a/policy/reviewer_assignments.yaml
+++ b/policy/reviewer_assignments.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
description: Default reviewer assignments by role for auto-assignment.
assignments:
domain_scientist: ds1
diff --git a/policy/reviewer_key_registry.yaml b/policy/reviewer_key_registry.yaml
index f257547..daae7ce 100644
--- a/policy/reviewer_key_registry.yaml
+++ b/policy/reviewer_key_registry.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
description: |
Optional local registry mapping reviewer_id to expected public key references.
Institutions populate this file; empty registry skips identity binding checks.
diff --git a/policy/reviewer_roles.yaml b/policy/reviewer_roles.yaml
index 41c2f59..3617b08 100644
--- a/policy/reviewer_roles.yaml
+++ b/policy/reviewer_roles.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
roles:
domain_scientist:
description: Reviews scientific plausibility, domain assumptions, and interpretation.
diff --git a/policy/role_to_action_matrix.yaml b/policy/role_to_action_matrix.yaml
index e91caf5..9760347 100644
--- a/policy/role_to_action_matrix.yaml
+++ b/policy/role_to_action_matrix.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
matrix:
A3_evidence_interpretation_consequential:
required_roles:
diff --git a/policy/scope_to_tool_matrix.yaml b/policy/scope_to_tool_matrix.yaml
index 9e89e24..7b4c7b9 100644
--- a/policy/scope_to_tool_matrix.yaml
+++ b/policy/scope_to_tool_matrix.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
scopes:
no_action:
allowed_tools: []
diff --git a/policy/workflow_escalation.yaml b/policy/workflow_escalation.yaml
index 7c1d31e..96dba53 100644
--- a/policy/workflow_escalation.yaml
+++ b/policy/workflow_escalation.yaml
@@ -1,4 +1,4 @@
-version: scope-core-v0.6
+version: scope-core-v0.7
escalation_reviewer:
reviewer_id: pi1
role: principal_investigator
diff --git a/pyproject.toml b/pyproject.toml
index 904ae30..2394796 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "scope-protocol"
-version = "0.6.0"
+version = "0.7.0"
description = "Scoped Scientific Authorization Protocol for AI-shaped science"
readme = "README.md"
license = { text = "MIT" }
diff --git a/schemas/identity_assurance.schema.json b/schemas/identity_assurance.schema.json
new file mode 100644
index 0000000..875bf31
--- /dev/null
+++ b/schemas/identity_assurance.schema.json
@@ -0,0 +1,32 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://scope.dev/schemas/identity_assurance.schema.json",
+ "title": "SCOPE Identity Assurance Provenance",
+ "type": "object",
+ "required": ["identity_assurance_level", "role_resolution_source"],
+ "properties": {
+ "identity_assurance_level": {
+ "type": "string",
+ "enum": ["IAL0", "IAL1", "IAL2", "IAL3", "IAL4"]
+ },
+ "role_resolution_source": {
+ "type": "string",
+ "enum": [
+ "caller_supplied",
+ "local_signed_key",
+ "oidc_verified",
+ "org_rbac"
+ ]
+ },
+ "identity_source": {
+ "type": "string",
+ "enum": ["caller_json", "local_signed_key", "oidc_jwt"]
+ },
+ "delegation_id": { "type": "string" },
+ "identity_claim_hash": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ },
+ "additionalProperties": true
+}
diff --git a/schemas/scope_akta_review_summary.schema.json b/schemas/scope_akta_review_summary.schema.json
new file mode 100644
index 0000000..8e66413
--- /dev/null
+++ b/schemas/scope_akta_review_summary.schema.json
@@ -0,0 +1,53 @@
+{
+ "$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",
+ "type": "object",
+ "required": [
+ "packet_path",
+ "decision_path",
+ "grant_path",
+ "approved_scope",
+ "requested_scope",
+ "adapter_contract_version",
+ "identity_assurance_level",
+ "signing_assurance_level",
+ "production_mode"
+ ],
+ "properties": {
+ "status": { "type": "string" },
+ "packet_path": { "type": "string" },
+ "decision_path": { "type": "string" },
+ "grant_path": { "type": "string" },
+ "packet_id": { "type": "string" },
+ "decision_id": { "type": "string" },
+ "grant_id": { "type": "string" },
+ "approved_scope": { "type": "string" },
+ "requested_scope": { "type": "string" },
+ "allowed_tools": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "blocked_tools": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "decision_type": { "type": "string" },
+ "adapter_contract_version": { "type": "string" },
+ "identity_assurance_level": {
+ "type": "string",
+ "enum": ["IAL0", "IAL1", "IAL2", "IAL3", "IAL4"]
+ },
+ "signing_assurance_level": {
+ "type": "string",
+ "enum": ["SAL0", "SAL1", "SAL2", "SAL3", "SAL4"]
+ },
+ "production_mode": { "type": "boolean" },
+ "scope_trust_root_hash": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ },
+ "queue_id": { "type": "string" }
+ },
+ "additionalProperties": true
+}
diff --git a/schemas/scope_decision.schema.json b/schemas/scope_decision.schema.json
index abd48d8..e7e0d23 100644
--- a/schemas/scope_decision.schema.json
+++ b/schemas/scope_decision.schema.json
@@ -39,14 +39,53 @@
"scope_policy_hash",
"reviewer_key_registry_version",
"reviewer_key_registry_hash",
- "scope_trust_root_hash"
+ "scope_trust_root_hash",
+ "identity_assurance_level",
+ "identity_source",
+ "role_resolution_source",
+ "authority_checks"
],
"properties": {
"scope_policy_version": { "type": "string" },
"scope_policy_hash": { "type": "string", "pattern": "^sha256:" },
"reviewer_key_registry_version": { "type": "string" },
"reviewer_key_registry_hash": { "type": "string", "pattern": "^sha256:" },
- "scope_trust_root_hash": { "type": "string", "pattern": "^sha256:" }
+ "scope_trust_root_hash": { "type": "string", "pattern": "^sha256:" },
+ "identity_assurance_level": {
+ "type": "string",
+ "enum": ["IAL0", "IAL1", "IAL2", "IAL3", "IAL4"]
+ },
+ "identity_source": {
+ "type": "string",
+ "enum": ["caller_json", "local_signed_key", "oidc_jwt"]
+ },
+ "role_resolution_source": {
+ "type": "string",
+ "enum": ["caller_supplied", "local_signed_key", "oidc_verified", "org_rbac"]
+ },
+ "delegation_id": { "type": "string" },
+ "identity_claim_hash": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ },
+ "authority_checks": {
+ "type": "object",
+ "required": [
+ "rbac_enforced",
+ "rbac_role_valid",
+ "scope_role_valid",
+ "scope_approval_valid",
+ "delegation_id"
+ ],
+ "properties": {
+ "rbac_enforced": { "type": "boolean" },
+ "rbac_role_valid": { "type": "boolean" },
+ "scope_role_valid": { "type": "boolean" },
+ "scope_approval_valid": { "type": "boolean" },
+ "delegation_id": { "type": ["string", "null"] }
+ },
+ "additionalProperties": false
+ }
},
"additionalProperties": true
}
diff --git a/schemas/scope_grant.schema.json b/schemas/scope_grant.schema.json
index 7be6573..fa513f2 100644
--- a/schemas/scope_grant.schema.json
+++ b/schemas/scope_grant.schema.json
@@ -47,7 +47,12 @@
"scope_policy_hash",
"reviewer_key_registry_version",
"reviewer_key_registry_hash",
- "scope_trust_root_hash"
+ "scope_trust_root_hash",
+ "identity_assurance_level",
+ "identity_source",
+ "role_resolution_source",
+ "signing_assurance_level",
+ "authority_checks"
],
"properties": {
"scope_policy_version": { "type": "string" },
@@ -56,7 +61,46 @@
"reviewer_role_policy_hash": { "type": "string", "pattern": "^sha256:" },
"reviewer_key_registry_version": { "type": "string" },
"reviewer_key_registry_hash": { "type": "string", "pattern": "^sha256:" },
- "scope_trust_root_hash": { "type": "string", "pattern": "^sha256:" }
+ "scope_trust_root_hash": { "type": "string", "pattern": "^sha256:" },
+ "identity_assurance_level": {
+ "type": "string",
+ "enum": ["IAL0", "IAL1", "IAL2", "IAL3", "IAL4"]
+ },
+ "identity_source": {
+ "type": "string",
+ "enum": ["caller_json", "local_signed_key", "oidc_jwt"]
+ },
+ "role_resolution_source": {
+ "type": "string",
+ "enum": ["caller_supplied", "local_signed_key", "oidc_verified", "org_rbac"]
+ },
+ "delegation_id": { "type": "string" },
+ "identity_claim_hash": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ },
+ "signing_assurance_level": {
+ "type": "string",
+ "enum": ["SAL0", "SAL1", "SAL2", "SAL3", "SAL4"]
+ },
+ "authority_checks": {
+ "type": "object",
+ "required": [
+ "rbac_enforced",
+ "rbac_role_valid",
+ "scope_role_valid",
+ "scope_approval_valid",
+ "delegation_id"
+ ],
+ "properties": {
+ "rbac_enforced": { "type": "boolean" },
+ "rbac_role_valid": { "type": "boolean" },
+ "scope_role_valid": { "type": "boolean" },
+ "scope_approval_valid": { "type": "boolean" },
+ "delegation_id": { "type": ["string", "null"] }
+ },
+ "additionalProperties": false
+ }
},
"additionalProperties": true
},
diff --git a/schemas/scope_review_queue.schema.json b/schemas/scope_review_queue.schema.json
index 19c38b7..0d2fbe7 100644
--- a/schemas/scope_review_queue.schema.json
+++ b/schemas/scope_review_queue.schema.json
@@ -17,18 +17,38 @@
"packet_id": { "type": "string" },
"status": {
"type": "string",
- "enum": ["open", "assigned", "decided", "granted", "closed", "cancelled", "completed"]
+ "enum": [
+ "open",
+ "assigned",
+ "in_review",
+ "needs_information",
+ "escalated",
+ "decided",
+ "granted",
+ "expired",
+ "closed",
+ "cancelled"
+ ]
},
"created_at": { "type": "string" },
"assigned_at": { "type": "string" },
+ "in_review_at": { "type": "string" },
+ "needs_information_at": { "type": "string" },
+ "needs_information_reason": { "type": "string" },
+ "escalated_at": { "type": "string" },
+ "escalated": { "type": "boolean" },
"due_at": { "type": "string" },
- "completed_at": { "type": "string" },
+ "expired_at": { "type": "string" },
+ "reopened_at": { "type": "string" },
"decided_at": { "type": "string" },
"granted_at": { "type": "string" },
"closed_at": { "type": "string" },
+ "cancelled_at": { "type": "string" },
"decision_id": { "type": "string" },
"grant_id": { "type": "string" },
"close_reason": { "type": "string" },
+ "cancel_reason": { "type": "string" },
+ "sla_hours": { "type": "integer" },
"reviewer": {
"type": "object",
"properties": {
@@ -37,6 +57,13 @@
},
"additionalProperties": true
},
+ "escalation_reviewer": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "escalation_reason": { "type": "string" },
+ "escalation_actor_id": { "type": "string" },
+ "information_received_at": { "type": "string" },
"packet_snapshot": { "type": "object" }
},
"additionalProperties": true
diff --git a/scope/__init__.py b/scope/__init__.py
index 3e1c3db..6306364 100644
--- a/scope/__init__.py
+++ b/scope/__init__.py
@@ -1,4 +1,4 @@
-"""Scoped Scientific Authorization Protocol (SCOPE) v0.6.0."""
+"""Scoped Scientific Authorization Protocol (SCOPE) v0.7.0."""
from __future__ import annotations
@@ -239,20 +239,34 @@ def submit_decision(
else:
reviewer_data = reviewer
+ verified_identity = None
if identity_token:
from scope.identity import apply_identity_to_reviewer, verify_token_from_env
- identity = verify_token_from_env(identity_token, policy_dir=self.policy.policy_dir)
- reviewer_data = apply_identity_to_reviewer(reviewer_data, identity)
+ verified_identity = verify_token_from_env(
+ identity_token, policy_dir=self.policy.policy_dir
+ )
+ reviewer_data = apply_identity_to_reviewer(reviewer_data, verified_identity)
- from scope.errors import DecisionValidationError
- from scope.rbac import check_rbac_permission, enforce_rbac_enabled
+ from scope.authority import enforce_decision_authority, merge_authority_provenance
+ from scope.identity_assurance import merge_identity_provenance, resolve_identity_assurance
+ from scope.rbac import enforce_rbac_enabled
should_enforce = enforce_rbac if enforce_rbac is not None else enforce_rbac_enabled()
- if should_enforce:
- rid = str(reviewer_data.get("reviewer_id", ""))
- role = str(reviewer_data.get("role", ""))
- check_rbac_permission(rid, role, "submit_decisions", self.policy.policy_dir)
+ identity_context = resolve_identity_assurance(
+ reviewer_data,
+ policy_dir=self.policy.policy_dir,
+ identity=verified_identity,
+ enforce_institutional=should_enforce,
+ )
+ authority_result = enforce_decision_authority(
+ reviewer_data,
+ packet,
+ decision,
+ self.policy,
+ identity_context=identity_context,
+ enforce_rbac=should_enforce,
+ )
if not decision.get("co_reviewers"):
assignment = resolve_review_assignment(packet, self.policy)
@@ -264,6 +278,8 @@ def submit_decision(
packet["review_request"]["scientific_action_type"],
domain_overlay=domain_overlay,
):
+ from scope.errors import DecisionValidationError
+
raise DecisionValidationError(
f"Action requires multi-role review session "
f"(roles: {assignment['required_roles']}). "
@@ -271,6 +287,9 @@ def submit_decision(
)
result = self._decision_engine.submit(packet, reviewer_data, decision)
+ result = merge_identity_provenance(result, identity_context)
+ result = merge_authority_provenance(result, authority_result)
+ self._decision_engine.validate(result)
self.ledger.append(
"decision_submitted",
actor_id=reviewer_data.get("reviewer_id"),
@@ -343,21 +362,37 @@ def submit_session_decision(
enforce_rbac: bool | None = None,
) -> dict[str, Any]:
reviewer_data = dict(reviewer)
+ verified_identity = None
if identity_token:
from scope.identity import apply_identity_to_reviewer, verify_token_from_env
- identity = verify_token_from_env(identity_token, policy_dir=self.policy.policy_dir)
- reviewer_data = apply_identity_to_reviewer(reviewer_data, identity)
+ verified_identity = verify_token_from_env(
+ identity_token, policy_dir=self.policy.policy_dir
+ )
+ reviewer_data = apply_identity_to_reviewer(reviewer_data, verified_identity)
- from scope.rbac import check_rbac_permission, enforce_rbac_enabled
+ from scope.authority import enforce_decision_authority, merge_authority_provenance
+ from scope.identity_assurance import merge_identity_provenance, resolve_identity_assurance
+ from scope.rbac import enforce_rbac_enabled
should_enforce = enforce_rbac if enforce_rbac is not None else enforce_rbac_enabled()
- if should_enforce:
- rid = str(reviewer_data.get("reviewer_id", ""))
- role = str(reviewer_data.get("role", ""))
- check_rbac_permission(rid, role, "vote_in_session", self.policy.policy_dir)
-
+ identity_context = resolve_identity_assurance(
+ reviewer_data,
+ policy_dir=self.policy.policy_dir,
+ identity=verified_identity,
+ enforce_institutional=should_enforce,
+ )
veto_roles = session.quorum_policy.get("safety_veto_roles") or []
+ authority_result = enforce_decision_authority(
+ reviewer_data,
+ packet,
+ decision,
+ self.policy,
+ identity_context=identity_context,
+ enforce_rbac=should_enforce,
+ session_mode=True,
+ allowed_veto_roles=veto_roles,
+ )
result = self._decision_engine.submit(
packet,
reviewer_data,
@@ -366,6 +401,9 @@ def submit_session_decision(
session_mode=True,
allowed_veto_roles=veto_roles,
)
+ result = merge_identity_provenance(result, identity_context)
+ result = merge_authority_provenance(result, authority_result)
+ self._decision_engine.validate(result)
self.ledger.append(
"decision_submitted",
actor_id=reviewer_data.get("reviewer_id"),
@@ -387,6 +425,52 @@ def submit_session_decision(
)
return result
+ def _finalize_grant_provenance(
+ self,
+ grant: dict[str, Any],
+ decision: dict[str, Any],
+ *,
+ signing_provider: str | None = None,
+ ) -> dict[str, Any]:
+ """Copy identity/signing assurance from decision into grant provenance."""
+ from scope.hash import attach_hash
+ from scope.signing_assurance import (
+ SAL0,
+ SAL1,
+ check_minimum_signing_assurance,
+ resolve_signing_assurance_level,
+ )
+
+ approved_scope = decision.get("decision", {}).get("approved_scope")
+ sal = resolve_signing_assurance_level(
+ decision,
+ provider_name=signing_provider,
+ reviewer_id=(decision.get("reviewer") or {}).get("reviewer_id"),
+ )
+ if sal == SAL0 and decision.get("decision_signature"):
+ sal = SAL1
+ check_minimum_signing_assurance(
+ sal,
+ self.policy.policy_dir,
+ approved_scope=str(approved_scope) if approved_scope else None,
+ )
+
+ provenance = dict(grant.get("provenance") or {})
+ dec_prov = decision.get("provenance") or {}
+ for field in (
+ "identity_assurance_level",
+ "role_resolution_source",
+ "identity_source",
+ "delegation_id",
+ "identity_claim_hash",
+ "authority_checks",
+ ):
+ if field in dec_prov:
+ provenance[field] = dec_prov[field]
+ provenance["signing_assurance_level"] = sal
+ grant["provenance"] = provenance
+ return attach_hash(grant, "grant_hash")
+
def issue_grant_from_session(
self,
session: ReviewSession,
@@ -421,6 +505,8 @@ def issue_grant_from_session(
merged,
contributing_signatures=contributing_signatures,
)
+ grant = self._finalize_grant_provenance(grant, merged)
+ self._grant_engine.validate(grant)
akta_blocked = packet.get("akta_constraints", {}).get("blocked_tools", [])
grant_blocked = grant.get("authorization", {}).get("blocked_tools", [])
preserved = all(tool in grant_blocked for tool in akta_blocked)
@@ -443,8 +529,15 @@ def issue_grant(
decision: dict[str, Any],
*,
constraints: dict[str, Any] | None = None,
+ signing_provider: str | None = None,
) -> dict[str, Any]:
+ approved_scope = decision.get("decision", {}).get("approved_scope")
grant = self._grant_engine.issue(packet, decision, constraints=constraints)
+ grant = self._finalize_grant_provenance(
+ grant, decision, signing_provider=signing_provider
+ )
+ self._grant_engine.validate(grant)
+
akta_blocked = packet.get("akta_constraints", {}).get("blocked_tools", [])
grant_blocked = grant.get("authorization", {}).get("blocked_tools", [])
preserved = all(tool in grant_blocked for tool in akta_blocked)
@@ -456,6 +549,7 @@ def issue_grant(
metadata={
"scientific_action_type": packet["review_request"]["scientific_action_type"],
"residual_blocks_preserved": preserved,
+ "approved_scope": approved_scope,
},
)
return grant
@@ -556,7 +650,7 @@ def sign_decision(
decision: dict[str, Any],
signer: Signer,
) -> dict[str, Any]:
- return attach_signature(
+ signed = attach_signature(
decision,
signer,
hash_field="decision_hash",
@@ -564,6 +658,23 @@ def sign_decision(
reviewer_id=(decision.get("reviewer") or {}).get("reviewer_id"),
key_registry=self.policy.reviewer_key_registry,
)
+ from scope.identity_assurance import (
+ IAL0,
+ IAL1,
+ IdentityAssuranceContext,
+ merge_identity_provenance,
+ )
+
+ current_ial = (signed.get("provenance") or {}).get("identity_assurance_level", IAL0)
+ if current_ial == IAL0:
+ identity_context = IdentityAssuranceContext(
+ identity_assurance_level=IAL1,
+ role_resolution_source="local_signed_key",
+ identity_source="local_signed_key",
+ institutional_authority=False,
+ )
+ signed = merge_identity_provenance(signed, identity_context)
+ return signed
def sign_grant(
self,
@@ -601,8 +712,10 @@ def quality_report(self, *, queue_dir: str | Path | None = None) -> dict[str, An
qm = queue_metrics(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
report["summary"]["open_queue_count"] = qm["open_queue_count"]
report["summary"]["overdue_queue_count"] = qm["overdue_queue_count"]
+ report["summary"]["ledger_delivery_failure_count"] = self.ledger.delivery_failure_count
if qm["overdue_queue_count"] > 0:
report["warnings"].append(
{
@@ -612,6 +725,16 @@ def quality_report(self, *, queue_dir: str | Path | None = None) -> dict[str, An
),
}
)
+ if self.ledger.delivery_failure_count > 0:
+ report["warnings"].append(
+ {
+ "warning_type": "ledger_delivery_failure",
+ "reason": (
+ f"{self.ledger.delivery_failure_count} ledger events failed remote "
+ "delivery or were spooled for retry."
+ ),
+ }
+ )
return report
def record_runtime_violation(
@@ -694,6 +817,70 @@ def assign_review_queue(
entry.save(save_path)
return entry
+ def in_review_review_queue(self, queue: str | Path) -> ReviewQueue:
+ entry = ReviewQueue.load(queue)
+ entry.mark_in_review()
+ entry.save(queue)
+ return entry
+
+ def needs_information_review_queue(
+ self,
+ queue: str | Path,
+ *,
+ reason: str = "",
+ ) -> ReviewQueue:
+ entry = ReviewQueue.load(queue)
+ entry.mark_needs_information(reason=reason)
+ entry.save(queue)
+ return entry
+
+ def reopen_review_queue(self, queue: str | Path) -> ReviewQueue:
+ entry = ReviewQueue.load(queue)
+ entry.reopen()
+ entry.save(queue)
+ return entry
+
+ def expire_review_queue(self, queue: str | Path) -> ReviewQueue:
+ entry = ReviewQueue.load(queue)
+ entry.expire()
+ entry.save(queue)
+ return entry
+
+ def information_received_review_queue(self, queue: str | Path) -> ReviewQueue:
+ entry = ReviewQueue.load(queue)
+ entry.mark_information_received()
+ entry.save(queue)
+ return entry
+
+ def escalate_review_queue_entry(
+ self,
+ queue: str | Path,
+ escalation_reviewer: dict[str, Any] | None = None,
+ *,
+ reason: str = "",
+ actor_id: str | None = None,
+ ) -> ReviewQueue:
+ entry = ReviewQueue.load(queue)
+ prior_status = entry.status
+ entry.mark_escalated(
+ escalation_reviewer,
+ reason=reason,
+ actor_id=actor_id,
+ )
+ entry.save(queue)
+ self.ledger.append(
+ "review_queue_escalated",
+ packet_id=entry.to_artifact().get("packet_id"),
+ actor_id=actor_id,
+ metadata={
+ "queue_id": entry.queue_id,
+ "prior_status": prior_status,
+ "escalation_reason": reason or None,
+ "escalation_reviewer": escalation_reviewer,
+ },
+ )
+ return entry
+
def decide_review_queue(
self,
queue: str | Path,
@@ -725,6 +912,17 @@ def close_review_queue(
entry.save(queue)
return entry
+ def cancel_review_queue(
+ self,
+ queue: str | Path,
+ *,
+ reason: str = "",
+ ) -> ReviewQueue:
+ entry = ReviewQueue.load(queue)
+ entry.cancel(reason=reason)
+ entry.save(queue)
+ return entry
+
def review_queue_status(self, *, queue_dir: str | Path | None = None) -> dict[str, Any]:
return aggregate_queue_status(queue_dir)
diff --git a/scope/_version.py b/scope/_version.py
index 800987c..02d8239 100644
--- a/scope/_version.py
+++ b/scope/_version.py
@@ -1,3 +1,3 @@
"""Package version (single source of truth)."""
-__version__ = "0.6.0"
+__version__ = "0.7.0"
diff --git a/scope/akta_review.py b/scope/akta_review.py
index f92b8a4..9cb4af0 100644
--- a/scope/akta_review.py
+++ b/scope/akta_review.py
@@ -9,7 +9,9 @@
from scope import ScopeEngine
from scope.config import is_production_mode
from scope.errors import GrantValidationError, ScopeValidationError
+from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION
from scope.review_assignment import resolve_review_assignment
+from scope.schema_util import validate_artifact
from scope.scopes import is_stronger, is_weaker_or_equal, validate_scope
from scope.signing import Ed25519Signer, Signer
@@ -115,6 +117,7 @@ def run_akta_review(
queue_dir: str | Path | None = None,
identity_token: str | None = None,
session_mode: bool = False,
+ enforce_rbac: bool | None = None,
) -> dict[str, Any]:
"""Create packet, submit approval decision, issue grant; write artifacts to out_dir."""
out = Path(out_dir)
@@ -152,6 +155,7 @@ def run_akta_review(
reviewer_data,
decision_input,
identity_token=identity_token,
+ enforce_rbac=enforce_rbac,
)
signer = _resolve_signer(
@@ -170,11 +174,17 @@ def run_akta_review(
elif signer is not None:
decision = engine.sign_decision(decision, signer)
- grant = engine.issue_grant(packet, decision)
+ grant = engine.issue_grant(
+ packet,
+ decision,
+ signing_provider=signing_provider,
+ )
queue_entry = None
if queue_dir is not None:
queue_entry = engine.create_review_queue(packet, queue_dir=queue_dir)
+ queue_entry.assign(reviewer_data)
+ queue_entry.mark_in_review()
queue_entry.mark_decided(decision["decision_id"])
queue_entry.mark_granted(grant["grant_id"])
queue_entry.save()
@@ -194,6 +204,8 @@ def run_akta_review(
fh.write("\n")
auth = grant.get("authorization", {})
+ provenance = grant.get("provenance") or {}
+ requested_scope = packet["review_request"].get("requested_scope")
summary: dict[str, Any] = {
"status": "completed",
"packet_path": str(packet_path),
@@ -203,13 +215,19 @@ def run_akta_review(
"decision_id": decision["decision_id"],
"grant_id": grant["grant_id"],
"approved_scope": auth.get("approved_scope", grant_scope),
+ "requested_scope": requested_scope,
"allowed_tools": auth.get("allowed_tools", []),
"blocked_tools": auth.get("blocked_tools", []),
"decision_type": decision["decision"]["type"],
- "scope_trust_root_hash": decision.get("provenance", {}).get("scope_trust_root_hash"),
+ "adapter_contract_version": AKTA_REVIEW_CONTRACT_VERSION,
+ "identity_assurance_level": provenance.get("identity_assurance_level", "IAL0"),
+ "signing_assurance_level": provenance.get("signing_assurance_level", "SAL0"),
+ "production_mode": is_production_mode(),
+ "scope_trust_root_hash": provenance.get("scope_trust_root_hash"),
}
if queue_entry is not None:
summary["queue_id"] = queue_entry.queue_id
+ validate_artifact(summary, "scope_akta_review_summary.schema.json")
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/authority.py b/scope/authority.py
new file mode 100644
index 0000000..e4ddace
--- /dev/null
+++ b/scope/authority.py
@@ -0,0 +1,238 @@
+"""Two-stage institutional authority: org RBAC then SCOPE scope policy."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from scope.errors import RoleValidationError, ScopeValidationError
+from scope.identity_assurance import IdentityAssuranceContext, is_institutional_assurance
+from scope.policy import PolicyStore
+from scope.rbac import check_rbac_permission, resolve_effective_roles_with_provenance
+from scope.roles import validate_reviewer_for_action, validate_reviewer_for_scope
+
+
+@dataclass
+class AuthorityCheckResult:
+ """Explicit two-stage authority check outcomes for decision/grant provenance."""
+
+ rbac_enforced: bool
+ rbac_role_valid: bool
+ scope_role_valid: bool
+ scope_approval_valid: bool
+ delegation_id: str | None = None
+
+ def to_provenance_block(self) -> dict[str, Any]:
+ return {
+ "authority_checks": {
+ "rbac_enforced": self.rbac_enforced,
+ "rbac_role_valid": self.rbac_role_valid,
+ "scope_role_valid": self.scope_role_valid,
+ "scope_approval_valid": self.scope_approval_valid,
+ "delegation_id": self.delegation_id,
+ }
+ }
+
+
+def merge_authority_provenance(
+ artifact: dict[str, Any],
+ result: AuthorityCheckResult,
+) -> dict[str, Any]:
+ """Attach authority_checks to decision or grant provenance."""
+ provenance = dict(artifact.get("provenance") or {})
+ provenance.update(result.to_provenance_block())
+ updated = dict(artifact)
+ updated["provenance"] = provenance
+ return updated
+
+
+def _check_rbac_stage(
+ reviewer_id: str,
+ role: str,
+ action: str,
+ policy_dir: str | Path,
+ *,
+ at: datetime | None = None,
+) -> tuple[bool, str | None]:
+ """Return (rbac_role_valid, delegation_id) or raise ScopeValidationError."""
+ role_info = resolve_effective_roles_with_provenance(
+ reviewer_id,
+ role,
+ policy_dir,
+ at=at,
+ )
+ effective = role_info["effective_roles"]
+ if role not in effective:
+ raise ScopeValidationError(
+ f"Institutional RBAC: reviewer {reviewer_id} lacks effective role {role} "
+ f"(has {sorted(effective)})"
+ )
+ if role_info.get("delegation_expired"):
+ raise ScopeValidationError(
+ f"Institutional RBAC: delegation for role {role} has expired for {reviewer_id}"
+ )
+ check_rbac_permission(reviewer_id, role, action, policy_dir, at=at)
+ delegation_id = role_info.get("delegation_id")
+ if role_info.get("role_resolution_source") == "delegation" and delegation_id:
+ return True, str(delegation_id)
+ return True, None
+
+
+def _check_scope_role_stage(
+ role: str,
+ action_type: str,
+ policy: PolicyStore,
+ *,
+ required_roles: list[str] | None = None,
+ domain_overlay: str | None = None,
+) -> bool:
+ try:
+ validate_reviewer_for_action(
+ role,
+ action_type,
+ policy,
+ required_roles=required_roles,
+ domain_overlay=domain_overlay,
+ )
+ return True
+ except RoleValidationError as exc:
+ raise ScopeValidationError(f"SCOPE policy authority: {exc}") from exc
+
+
+def _check_scope_approval_stage(
+ role: str,
+ approved_scope: str | None,
+ policy: PolicyStore,
+ *,
+ session_mode: bool = False,
+) -> bool:
+ if not approved_scope:
+ return True
+ try:
+ validate_reviewer_for_scope(
+ role,
+ approved_scope,
+ policy,
+ session_mode=session_mode,
+ )
+ return True
+ except (RoleValidationError, ScopeValidationError) as exc:
+ raise ScopeValidationError(f"SCOPE scope authority: {exc}") from exc
+
+
+def run_authority_checks(
+ reviewer: dict[str, Any],
+ packet: dict[str, Any],
+ decision_input: dict[str, Any],
+ policy: PolicyStore,
+ *,
+ identity_context: IdentityAssuranceContext | None = None,
+ enforce_rbac: bool = False,
+ session_mode: bool = False,
+ allowed_veto_roles: list[str] | None = None,
+ at: datetime | None = None,
+) -> AuthorityCheckResult:
+ """
+ Run two-stage authority checks and return explicit outcomes for provenance.
+
+ Raises ScopeValidationError when a required check fails.
+ """
+ reviewer_id = str(reviewer.get("reviewer_id", ""))
+ role = str(reviewer.get("role", ""))
+ action_type = packet["review_request"]["scientific_action_type"]
+ domain_overlay = packet.get("scientific_context", {}).get("domain_overlay")
+ required_roles = packet["review_request"].get("required_review_roles")
+ decision_type = str(decision_input.get("type", ""))
+
+ rbac_action = "vote_in_session" if session_mode else "submit_decisions"
+ should_enforce_rbac = enforce_rbac or (
+ identity_context is not None and identity_context.institutional_authority
+ )
+
+ rbac_role_valid = False
+ delegation_id: str | None = None
+ if should_enforce_rbac:
+ if identity_context and not identity_context.institutional_authority:
+ if not is_institutional_assurance(identity_context.identity_assurance_level):
+ should_enforce_rbac = False
+ if should_enforce_rbac:
+ rbac_role_valid, delegation_id = _check_rbac_stage(
+ reviewer_id,
+ role,
+ rbac_action,
+ policy.policy_dir,
+ at=at,
+ )
+ if delegation_id is None and identity_context and identity_context.delegation_id:
+ delegation_id = identity_context.delegation_id
+
+ approved_scope = None
+ if policy.is_approval_decision(decision_type):
+ approved_scope = decision_input.get("approved_scope")
+
+ scope_role_valid = False
+ scope_approval_valid = False
+ try:
+ scope_role_valid = _check_scope_role_stage(
+ role,
+ action_type,
+ policy,
+ required_roles=required_roles,
+ domain_overlay=domain_overlay,
+ )
+ scope_approval_valid = _check_scope_approval_stage(
+ role,
+ approved_scope,
+ policy,
+ session_mode=session_mode,
+ )
+ except ScopeValidationError as exc:
+ if (
+ allowed_veto_roles
+ and role in allowed_veto_roles
+ and decision_type == "reject"
+ ):
+ return AuthorityCheckResult(
+ rbac_enforced=should_enforce_rbac,
+ rbac_role_valid=rbac_role_valid,
+ scope_role_valid=True,
+ scope_approval_valid=True,
+ delegation_id=delegation_id,
+ )
+ raise exc
+
+ return AuthorityCheckResult(
+ rbac_enforced=should_enforce_rbac,
+ rbac_role_valid=rbac_role_valid,
+ scope_role_valid=scope_role_valid,
+ scope_approval_valid=scope_approval_valid,
+ delegation_id=delegation_id,
+ )
+
+
+def enforce_decision_authority(
+ reviewer: dict[str, Any],
+ packet: dict[str, Any],
+ decision_input: dict[str, Any],
+ policy: PolicyStore,
+ *,
+ identity_context: IdentityAssuranceContext | None = None,
+ enforce_rbac: bool = False,
+ session_mode: bool = False,
+ allowed_veto_roles: list[str] | None = None,
+ at: datetime | None = None,
+) -> AuthorityCheckResult:
+ """Run two-stage authority checks before accepting a decision."""
+ return run_authority_checks(
+ reviewer,
+ packet,
+ decision_input,
+ policy,
+ identity_context=identity_context,
+ enforce_rbac=enforce_rbac,
+ session_mode=session_mode,
+ allowed_veto_roles=allowed_veto_roles,
+ at=at,
+ )
diff --git a/scope/cli.py b/scope/cli.py
index e75aa48..27e8945 100644
--- a/scope/cli.py
+++ b/scope/cli.py
@@ -784,6 +784,88 @@ def review_queue_list(queue_dir: str | None, policy: str) -> None:
click.echo(json.dumps(engine.review_queue_status(queue_dir=queue_dir), indent=2))
+@queue_group.command("in-review")
+@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True))
+def review_queue_in_review(queue_path: str) -> None:
+ from scope.review_queue import ReviewQueue
+
+ entry = ReviewQueue.load(queue_path)
+ entry.mark_in_review()
+ entry.save(queue_path)
+ click.echo(json.dumps(entry.status_summary(), indent=2))
+
+
+@queue_group.command("needs-information")
+@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True))
+@click.option("--reason", default="")
+def review_queue_needs_information(queue_path: str, reason: str) -> None:
+ from scope.review_queue import ReviewQueue
+
+ entry = ReviewQueue.load(queue_path)
+ entry.mark_needs_information(reason=reason)
+ entry.save(queue_path)
+ click.echo(json.dumps(entry.status_summary(), indent=2))
+
+
+@queue_group.command("information-received")
+@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True))
+def review_queue_information_received(queue_path: str) -> None:
+ from scope.review_queue import ReviewQueue
+
+ entry = ReviewQueue.load(queue_path)
+ entry.mark_information_received()
+ entry.save(queue_path)
+ click.echo(json.dumps(entry.status_summary(), indent=2))
+
+
+@queue_group.command("reopen")
+@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True))
+def review_queue_reopen(queue_path: str) -> None:
+ from scope.review_queue import ReviewQueue
+
+ entry = ReviewQueue.load(queue_path)
+ entry.reopen()
+ entry.save(queue_path)
+ click.echo(json.dumps(entry.status_summary(), indent=2))
+
+
+@queue_group.command("expire")
+@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True))
+def review_queue_expire(queue_path: str) -> None:
+ from scope.review_queue import ReviewQueue
+
+ entry = ReviewQueue.load(queue_path)
+ entry.expire()
+ entry.save(queue_path)
+ click.echo(json.dumps(entry.status_summary(), indent=2))
+
+
+@queue_group.command("escalate-entry")
+@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True))
+@click.option("--reviewer", type=click.Path(exists=True), default=None)
+@click.option("--reason", default="", help="Escalation reason recorded in queue artifact.")
+@click.option("--actor-id", default=None, help="Actor performing the escalation.")
+@click.option("--policy", default="policy/", type=click.Path(exists=True))
+@click.option("--ledger", type=click.Path(), default=None)
+def review_queue_escalate_entry(
+ queue_path: str,
+ reviewer: str | None,
+ reason: str,
+ actor_id: str | None,
+ policy: str,
+ ledger: str | None,
+) -> None:
+ engine = _engine(policy, ledger)
+ escalation = _load_json(reviewer) if reviewer else None
+ entry = engine.escalate_review_queue_entry(
+ queue_path,
+ escalation,
+ reason=reason,
+ actor_id=actor_id,
+ )
+ click.echo(json.dumps(entry.status_summary(), indent=2))
+
+
@queue_group.command("decide")
@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True))
@click.option("--decision-id", required=True)
@@ -808,6 +890,18 @@ def review_queue_grant(queue_path: str, grant_id: str) -> None:
click.echo(json.dumps(entry.status_summary(), indent=2))
+@queue_group.command("cancel")
+@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True))
+@click.option("--reason", default="")
+def review_queue_cancel(queue_path: str, reason: str) -> None:
+ from scope.review_queue import ReviewQueue
+
+ entry = ReviewQueue.load(queue_path)
+ entry.cancel(reason=reason)
+ entry.save(queue_path)
+ click.echo(json.dumps(entry.status_summary(), indent=2))
+
+
@queue_group.command("close")
@click.option("--queue", "queue_path", required=True, type=click.Path(exists=True))
@click.option("--reason", default="")
diff --git a/scope/decisions.py b/scope/decisions.py
index 9ec97ab..3cc4002 100644
--- a/scope/decisions.py
+++ b/scope/decisions.py
@@ -166,7 +166,6 @@ def submit(
if is_production_mode() and not decision.get("decision_signature"):
decision["signature_required"] = True
- self.validate(decision)
return decision
def _resolve_requested_scope(
diff --git a/scope/grants.py b/scope/grants.py
index dcfe554..6f1ba1e 100644
--- a/scope/grants.py
+++ b/scope/grants.py
@@ -124,7 +124,6 @@ def issue(
for field in sig_fields:
if decision.get(field):
grant[field] = decision[field]
- self.validate(grant)
return grant
def check(
diff --git a/scope/identity_assurance.py b/scope/identity_assurance.py
new file mode 100644
index 0000000..5e67b9f
--- /dev/null
+++ b/scope/identity_assurance.py
@@ -0,0 +1,217 @@
+"""Identity assurance levels (IAL) for institutional reviewer provenance."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from scope.errors import ScopeValidationError
+from scope.hash import compute_hash
+from scope.identity import VerifiedIdentity, oidc_enabled
+from scope.rbac import resolve_effective_roles_with_provenance
+from scope.signing import Ed25519PublicVerifier, verify_artifact_signature
+
+IAL0 = "IAL0"
+IAL1 = "IAL1"
+IAL2 = "IAL2"
+IAL3 = "IAL3"
+IAL4 = "IAL4"
+
+# Descriptive aliases for each IAL level (documented in docs/identity_assurance.md).
+IAL_ALIASES: dict[str, str] = {
+ IAL0: "caller_supplied",
+ IAL1: "local_signed_key",
+ IAL2: "oidc_verified",
+ IAL3: "oidc_plus_rbac_role",
+ IAL4: "oidc_plus_rbac_plus_delegation",
+}
+
+INSTITUTIONAL_IAL_MIN = IAL3
+
+
+@dataclass
+class IdentityAssuranceContext:
+ identity_assurance_level: str
+ role_resolution_source: str
+ identity_source: str | None = None
+ delegation_id: str | None = None
+ identity_claim_hash: str | None = None
+ institutional_authority: bool = False
+
+ def to_provenance(self) -> dict[str, Any]:
+ record: dict[str, Any] = {
+ "identity_assurance_level": self.identity_assurance_level,
+ "role_resolution_source": self.role_resolution_source,
+ }
+ if self.identity_source:
+ record["identity_source"] = self.identity_source
+ if self.delegation_id:
+ record["delegation_id"] = self.delegation_id
+ if self.identity_claim_hash:
+ record["identity_claim_hash"] = self.identity_claim_hash
+ return record
+
+
+def compute_identity_claim_hash(claims: dict[str, Any]) -> str:
+ """Canonical SHA-256 hash of identity claims for audit linkage."""
+ payload = {k: claims[k] for k in sorted(claims.keys())}
+ return compute_hash({"claims": payload})
+
+
+def _has_valid_decision_signature(
+ decision: dict[str, Any] | None,
+ *,
+ policy_dir: str | Path | None = None,
+) -> bool:
+ if not decision or not decision.get("decision_signature"):
+ return False
+ if decision.get("signed_payload_hash") != decision.get("decision_hash"):
+ return False
+
+ key_ref = decision.get("reviewer_public_key_ref")
+ if not key_ref:
+ return False
+
+ if policy_dir is not None:
+ reviewer_id = (decision.get("reviewer") or {}).get("reviewer_id")
+ if reviewer_id:
+ try:
+ from scope.policy import PolicyStore
+
+ policy = PolicyStore.from_dir(policy_dir)
+ entry = policy.reviewer_key_registry_entries.get(str(reviewer_id))
+ if entry and entry.get("public_key_file"):
+ key_path = Path(entry["public_key_file"])
+ if not key_path.is_absolute():
+ key_path = Path(policy_dir) / key_path
+ if key_path.exists():
+ verifier = Ed25519PublicVerifier(
+ key_path,
+ public_key_ref=str(entry["public_key_ref"]),
+ )
+ return verify_artifact_signature(
+ decision,
+ verifier,
+ hash_field="decision_hash",
+ signature_field="decision_signature",
+ )
+ except Exception:
+ return False
+
+ candidate = Path(str(key_ref))
+ if candidate.suffix == ".pub" and candidate.exists():
+ try:
+ verifier = Ed25519PublicVerifier(candidate, public_key_ref=str(key_ref))
+ return verify_artifact_signature(
+ decision,
+ verifier,
+ hash_field="decision_hash",
+ signature_field="decision_signature",
+ )
+ except Exception:
+ return False
+
+ return False
+
+
+def resolve_identity_assurance(
+ reviewer: dict[str, Any],
+ *,
+ policy_dir: str | Path,
+ identity: VerifiedIdentity | None = None,
+ decision: dict[str, Any] | None = None,
+ enforce_institutional: bool = False,
+) -> IdentityAssuranceContext:
+ """
+ Determine identity assurance level from reviewer input, OIDC, RBAC, and signatures.
+
+ Caller-supplied JSON alone is IAL0 and is not institutional authority.
+ """
+ role = str(reviewer.get("role", ""))
+ reviewer_id = str(reviewer.get("reviewer_id", ""))
+ signed = _has_valid_decision_signature(decision, policy_dir=policy_dir)
+
+ if identity is None and not signed:
+ return IdentityAssuranceContext(
+ identity_assurance_level=IAL0,
+ role_resolution_source="caller_supplied",
+ identity_source="caller_json",
+ institutional_authority=False,
+ )
+
+ if identity is None and signed:
+ return IdentityAssuranceContext(
+ identity_assurance_level=IAL1,
+ role_resolution_source="local_signed_key",
+ identity_source="local_signed_key",
+ institutional_authority=False,
+ )
+
+ if identity is None:
+ raise ScopeValidationError("Internal error: identity required for OIDC assurance path")
+
+ claims = identity.claims
+ claim_hash = compute_identity_claim_hash(claims)
+ identity_source = str(reviewer.get("identity_source") or "oidc_jwt")
+
+ role_info = resolve_effective_roles_with_provenance(
+ reviewer_id,
+ role,
+ policy_dir,
+ )
+ effective_roles = role_info["effective_roles"]
+ source = role_info["role_resolution_source"]
+ delegation_id = role_info.get("delegation_id")
+
+ if role not in effective_roles:
+ if enforce_institutional:
+ raise ScopeValidationError(
+ f"OIDC identity verified but reviewer {reviewer_id} lacks institutional "
+ f"role {role} (effective: {sorted(effective_roles)})"
+ )
+ return IdentityAssuranceContext(
+ identity_assurance_level=IAL2,
+ role_resolution_source="oidc_verified",
+ identity_source=identity_source,
+ identity_claim_hash=claim_hash,
+ institutional_authority=False,
+ )
+
+ if source == "delegation" and delegation_id:
+ return IdentityAssuranceContext(
+ identity_assurance_level=IAL4,
+ role_resolution_source="org_rbac",
+ identity_source=identity_source,
+ delegation_id=str(delegation_id),
+ identity_claim_hash=claim_hash,
+ institutional_authority=True,
+ )
+
+ return IdentityAssuranceContext(
+ identity_assurance_level=IAL3,
+ role_resolution_source="org_rbac",
+ identity_source=identity_source,
+ identity_claim_hash=claim_hash,
+ institutional_authority=True,
+ )
+
+
+def merge_identity_provenance(
+ artifact: dict[str, Any],
+ context: IdentityAssuranceContext,
+) -> dict[str, Any]:
+ """Attach identity assurance fields to decision or grant provenance."""
+ provenance = dict(artifact.get("provenance") or {})
+ provenance.update(context.to_provenance())
+ artifact = dict(artifact)
+ artifact["provenance"] = provenance
+ return artifact
+
+
+def is_institutional_assurance(level: str) -> bool:
+ return level in (IAL3, IAL4)
+
+
+def identity_assurance_from_env_enabled() -> bool:
+ return oidc_enabled()
diff --git a/scope/integration_versions.py b/scope/integration_versions.py
index 2f04705..79005b6 100644
--- a/scope/integration_versions.py
+++ b/scope/integration_versions.py
@@ -4,4 +4,5 @@
PF_CORE_VERSION = "pf-core-v0.5"
PCS_MANIFEST_VERSION = "pcs-v0.5"
-SCOPE_CORE_VERSION = "scope-core-v0.6"
+SCOPE_CORE_VERSION = "scope-core-v0.7"
+AKTA_REVIEW_CONTRACT_VERSION = "scope-akta-review-v0.7"
diff --git a/scope/ledger.py b/scope/ledger.py
index 471376d..33949cc 100644
--- a/scope/ledger.py
+++ b/scope/ledger.py
@@ -10,7 +10,12 @@
from scope.errors import LedgerError
from scope.hash import attach_hash, verify_hash
-from scope.ledger_sinks import LedgerSink, LocalJsonlSink, MultiSink, build_ledger_sinks
+from scope.ledger_sinks import (
+ DeliveringSink,
+ LedgerDeliveryError,
+ build_delivering_sink,
+ is_high_risk_ledger_event,
+)
def _utc_now() -> str:
@@ -30,18 +35,20 @@ def __init__(
self,
path: str | Path | None = None,
*,
- sinks: list[LedgerSink] | None = None,
+ delivering_sink: DeliveringSink | None = None,
) -> None:
self.path = Path(path) if path else None
self._events: list[dict[str, Any]] = []
- if sinks is not None:
- self._sinks = sinks
+ self._delivery_failures = 0
+ self._delivering: DeliveringSink | None = None
+ if delivering_sink is not None:
+ self._delivering = delivering_sink
elif self.path:
- self._sinks = build_ledger_sinks(self.path)
- else:
- self._sinks = []
+ self._delivering = build_delivering_sink(self.path)
if self.path and self.path.exists():
self._load()
+ if self._delivering:
+ self._delivering.retry_spooled()
def _load(self) -> None:
assert self.path is not None
@@ -67,6 +74,10 @@ def last_hash(self) -> str:
return self.GENESIS_HASH
return str(self._events[-1]["event_hash"])
+ @property
+ def delivery_failure_count(self) -> int:
+ return self._delivery_failures
+
def append(
self,
event_type: str,
@@ -83,6 +94,7 @@ def append(
"timestamp": _utc_now(),
"event_type": event_type,
"previous_event_hash": self.last_hash,
+ "delivery_state": "pending",
}
if actor_id:
event["actor_id"] = actor_id
@@ -96,11 +108,23 @@ def append(
event["grant_id"] = grant_id
if metadata:
event["metadata"] = metadata
+ high_risk = is_high_risk_ledger_event(event_type, metadata)
+ if self._delivering:
+ try:
+ state = self._delivering.deliver_remote(event, fail_closed=high_risk)
+ except LedgerDeliveryError as exc:
+ raise LedgerError(str(exc)) from exc
+ event["delivery_state"] = state
+ if state in ("failed", "spooled"):
+ self._delivery_failures += 1
+ else:
+ event["delivery_state"] = "delivered"
+
event = attach_hash(event, "event_hash")
self._events.append(event)
- if self._sinks:
- MultiSink(self._sinks).append(event)
- elif self.path:
+ if self.path:
+ from scope.ledger_sinks import LocalJsonlSink
+
LocalJsonlSink(self.path).append(event)
return event
diff --git a/scope/ledger_sinks.py b/scope/ledger_sinks.py
index 7e9eaec..182f045 100644
--- a/scope/ledger_sinks.py
+++ b/scope/ledger_sinks.py
@@ -8,18 +8,41 @@
import urllib.error
import urllib.request
from abc import ABC, abstractmethod
+from enum import Enum
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
+DEFAULT_SPOOL_DIR = Path(".scope/ledger_spool")
+
+
+class LedgerDeliveryMode(str, Enum):
+ BEST_EFFORT = "best_effort"
+ AT_LEAST_ONCE = "at_least_once"
+ FAIL_CLOSED = "fail_closed"
+
+
+def resolve_delivery_mode(config_value: str | None = None) -> LedgerDeliveryMode:
+ raw = config_value or os.environ.get("SCOPE_LEDGER_DELIVERY_MODE", "best_effort")
+ normalized = raw.lower().replace("-", "_")
+ try:
+ return LedgerDeliveryMode(normalized)
+ except ValueError:
+ logger.warning("Unknown SCOPE_LEDGER_DELIVERY_MODE=%s; using best_effort", raw)
+ return LedgerDeliveryMode.BEST_EFFORT
+
class LedgerSink(ABC):
"""Append-only sink for ledger events."""
@abstractmethod
- def append(self, event: dict[str, Any]) -> None:
- """Persist a single ledger event."""
+ def append(self, event: dict[str, Any], *, fail_closed: bool = False) -> str | None:
+ """Persist a single ledger event; return delivery_state when tracked."""
+
+ @property
+ def is_remote(self) -> bool:
+ return False
class LocalJsonlSink(LedgerSink):
@@ -28,14 +51,15 @@ class LocalJsonlSink(LedgerSink):
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
- def append(self, event: dict[str, Any]) -> None:
+ def append(self, event: dict[str, Any], *, fail_closed: bool = False) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(event, sort_keys=True) + "\n")
+ return None
class RemoteHttpSink(LedgerSink):
- """Best-effort POST of ledger events to a remote append endpoint."""
+ """POST ledger events to a remote append endpoint."""
def __init__(
self,
@@ -48,18 +72,126 @@ def __init__(
self.token = token
self.timeout = timeout
- def append(self, event: dict[str, Any]) -> None:
+ @property
+ def is_remote(self) -> bool:
+ return True
+
+ def append(self, event: dict[str, Any], *, fail_closed: bool = False) -> None:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
body = json.dumps(event, sort_keys=True).encode("utf-8")
req = urllib.request.Request(self.url, data=body, headers=headers, method="POST")
- try:
- with urllib.request.urlopen(req, timeout=self.timeout) as resp:
- if resp.status >= 400:
- logger.warning("Remote ledger sink returned HTTP %s", resp.status)
- except urllib.error.URLError as exc:
- logger.warning("Remote ledger sink failed (best-effort): %s", exc)
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
+ if resp.status >= 400:
+ raise urllib.error.URLError(f"HTTP {resp.status}")
+ return None
+
+
+class LedgerSpool:
+ """Local spool for at-least-once remote ledger delivery."""
+
+ def __init__(self, spool_dir: str | Path | None = None) -> None:
+ self.spool_dir = Path(spool_dir or DEFAULT_SPOOL_DIR)
+ self.spool_dir.mkdir(parents=True, exist_ok=True)
+
+ def spool(self, event: dict[str, Any]) -> Path:
+ event_id = str(event.get("event_id", "unknown"))
+ path = self.spool_dir / f"{event_id}.json"
+ with path.open("w", encoding="utf-8") as fh:
+ json.dump(event, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ return path
+
+ def pending_events(self) -> list[dict[str, Any]]:
+ events: list[dict[str, Any]] = []
+ for path in sorted(self.spool_dir.glob("SCOPE-EVT-*.json")):
+ with path.open(encoding="utf-8") as fh:
+ events.append(json.load(fh))
+ return events
+
+ def remove(self, event_id: str) -> None:
+ path = self.spool_dir / f"{event_id}.json"
+ if path.is_file():
+ path.unlink()
+
+
+class DeliveringSink(LedgerSink):
+ """Wrap sinks with delivery semantics and spool/retry support."""
+
+ def __init__(
+ self,
+ sinks: list[LedgerSink],
+ *,
+ mode: LedgerDeliveryMode | None = None,
+ spool: LedgerSpool | None = None,
+ ) -> None:
+ self.local_sinks = [s for s in sinks if not s.is_remote]
+ self.remote_sinks = [s for s in sinks if s.is_remote]
+ self.mode = mode or resolve_delivery_mode()
+ self.spool = spool or LedgerSpool()
+
+ def deliver_remote(self, event: dict[str, Any], *, fail_closed: bool = False) -> str:
+ """Attempt remote delivery; return final delivery_state without writing locally."""
+ if not self.remote_sinks:
+ return "delivered"
+
+ remote_ok = True
+ for sink in self.remote_sinks:
+ try:
+ sink.append(event)
+ except (urllib.error.URLError, OSError) as exc:
+ remote_ok = False
+ logger.warning("Remote ledger sink failed: %s", exc)
+
+ if remote_ok:
+ return "delivered"
+
+ if self.mode == LedgerDeliveryMode.BEST_EFFORT:
+ return "failed"
+
+ if self.mode == LedgerDeliveryMode.AT_LEAST_ONCE:
+ self.spool.spool(event)
+ return "spooled"
+
+ if self.mode == LedgerDeliveryMode.FAIL_CLOSED and fail_closed:
+ raise LedgerDeliveryError(
+ f"Remote ledger delivery failed in fail_closed mode for {event.get('event_type')}"
+ )
+
+ return "failed"
+
+ def append(self, event: dict[str, Any], *, fail_closed: bool = False) -> str:
+ delivery_state = "delivered"
+ for sink in self.local_sinks:
+ sink.append(event)
+
+ if not self.remote_sinks:
+ event["delivery_state"] = delivery_state
+ return delivery_state
+
+ state = self.deliver_remote(event, fail_closed=fail_closed)
+ event["delivery_state"] = state
+ return state
+
+ def retry_spooled(self) -> int:
+ delivered = 0
+ for event in self.spool.pending_events():
+ event_id = str(event.get("event_id", ""))
+ ok = True
+ for sink in self.remote_sinks:
+ try:
+ sink.append(event)
+ except (urllib.error.URLError, OSError):
+ ok = False
+ if ok:
+ self.spool.remove(event_id)
+ delivered += 1
+ return delivered
+
+
+class LedgerDeliveryError(Exception):
+ """Ledger event could not be delivered under fail_closed policy."""
class MultiSink(LedgerSink):
@@ -68,12 +200,36 @@ class MultiSink(LedgerSink):
def __init__(self, sinks: list[LedgerSink]) -> None:
self.sinks = sinks
- def append(self, event: dict[str, Any]) -> None:
+ def append(self, event: dict[str, Any], *, fail_closed: bool = False) -> None:
for sink in self.sinks:
sink.append(event)
-def build_ledger_sinks(local_path: str | Path | None) -> list[LedgerSink]:
+def is_high_risk_ledger_event(event_type: str, metadata: dict[str, Any] | None = None) -> bool:
+ meta = metadata or {}
+ if event_type in (
+ "grant_issued",
+ "grant_revoked",
+ "runtime_scope_violation",
+ ):
+ return True
+ if event_type == "decision_submitted":
+ approved = meta.get("approved_scope")
+ if approved and approved != "clarification_only":
+ return True
+ decision_type = meta.get("decision_type")
+ if decision_type in ("approve", "approve_narrower_scope"):
+ approved_scope = meta.get("approved_scope")
+ if approved_scope and approved_scope != "clarification_only":
+ return True
+ return False
+
+
+def build_ledger_sinks(
+ local_path: str | Path | None,
+ *,
+ delivery_mode: LedgerDeliveryMode | None = None,
+) -> list[LedgerSink]:
"""Build sink list from environment and local path."""
sinks: list[LedgerSink] = []
if local_path:
@@ -83,3 +239,19 @@ def build_ledger_sinks(local_path: str | Path | None) -> list[LedgerSink]:
token = os.environ.get("SCOPE_LEDGER_REMOTE_TOKEN")
sinks.append(RemoteHttpSink(remote_url, token=token))
return sinks
+
+
+def build_delivering_sink(
+ local_path: str | Path | None,
+ *,
+ delivery_mode: LedgerDeliveryMode | None = None,
+ spool_dir: str | Path | None = None,
+) -> DeliveringSink | None:
+ sinks = build_ledger_sinks(local_path, delivery_mode=delivery_mode)
+ if not sinks:
+ return None
+ return DeliveringSink(
+ sinks,
+ mode=delivery_mode,
+ spool=LedgerSpool(spool_dir),
+ )
diff --git a/scope/quality.py b/scope/quality.py
index 710078b..1251480 100644
--- a/scope/quality.py
+++ b/scope/quality.py
@@ -248,6 +248,7 @@ def analyze_ledger(events: list[dict[str, Any]], policy: PolicyStore) -> dict[st
"grant_use_count": len(grant_used),
"grant_revoked_count": len(revoked),
"review_assigned_count": len(review_assigned),
+ "ledger_delivery_failure_count": _ledger_delivery_failures(events),
}
by_reviewer = _by_reviewer(decisions, fast_approvals)
@@ -255,7 +256,7 @@ def analyze_ledger(events: list[dict[str, Any]], policy: PolicyStore) -> dict[st
by_action_type = _by_action_type(decisions, stale)
return {
- "report_version": "0.6",
+ "report_version": "0.7",
"policy_version": policy.version,
"summary": {
"total_decisions": len(decisions),
@@ -388,6 +389,15 @@ def _by_action_type(
}
+def _ledger_delivery_failures(events: list[dict[str, Any]]) -> int:
+ return sum(
+ 1
+ for e in events
+ if e.get("delivery_state") in ("failed", "spooled")
+ or (e.get("metadata") or {}).get("delivery_state") in ("failed", "spooled")
+ )
+
+
def emit_quality_warning(
warning_type: str,
reason: str,
diff --git a/scope/rbac.py b/scope/rbac.py
index eae6106..cd18bfd 100644
--- a/scope/rbac.py
+++ b/scope/rbac.py
@@ -33,26 +33,41 @@ def _member_roles(org_rbac: dict[str, Any], reviewer_id: str) -> set[str]:
return roles
-def _active_delegations(
+def _delegation_records(
org_rbac: dict[str, Any],
reviewer_id: str,
*,
at: datetime | None = None,
+ active_only: bool = True,
) -> list[dict[str, Any]]:
current = at or datetime.now(timezone.utc)
- active: list[dict[str, Any]] = []
+ records: list[dict[str, Any]] = []
for delegation in org_rbac.get("delegations") or []:
if str(delegation.get("granted_to")) != reviewer_id:
continue
valid_until = delegation.get("valid_until")
+ expired = False
if valid_until:
try:
if current > _parse_ts(str(valid_until)):
- continue
+ expired = True
except ValueError:
- continue
- active.append(delegation)
- return active
+ expired = True
+ if active_only and expired:
+ continue
+ record = dict(delegation)
+ record["_expired"] = expired
+ records.append(record)
+ return records
+
+
+def _active_delegations(
+ org_rbac: dict[str, Any],
+ reviewer_id: str,
+ *,
+ at: datetime | None = None,
+) -> list[dict[str, Any]]:
+ return _delegation_records(org_rbac, reviewer_id, at=at, active_only=True)
def resolve_effective_roles(
@@ -71,6 +86,58 @@ def resolve_effective_roles(
return roles
+def resolve_effective_roles_with_provenance(
+ reviewer_id: str,
+ requested_role: str,
+ policy_dir: str | Path,
+ *,
+ at: datetime | None = None,
+) -> dict[str, Any]:
+ """
+ Resolve effective roles and how the requested role was obtained.
+
+ Returns role_resolution_source: org_rbac | delegation | caller (not in org).
+ """
+ org_rbac = load_org_rbac(policy_dir)
+ direct = _member_roles(org_rbac, reviewer_id)
+ effective = set(direct)
+ delegation_id: str | None = None
+ delegation_expired = False
+ source = "caller"
+
+ if requested_role in direct:
+ source = "org_rbac"
+
+ all_delegations = _delegation_records(org_rbac, reviewer_id, at=at, active_only=False)
+ for delegation in all_delegations:
+ role = delegation.get("role")
+ if not role:
+ continue
+ role_str = str(role)
+ if delegation.get("_expired"):
+ if role_str == requested_role:
+ delegation_expired = True
+ continue
+ effective.add(role_str)
+ if role_str == requested_role and source != "org_rbac":
+ source = "delegation"
+ delegation_id = str(
+ delegation.get("delegation_id")
+ or delegation.get("delegate_reviewer_id")
+ or f"{delegation.get('granted_by')}->{reviewer_id}"
+ )
+
+ if requested_role in effective and source == "caller":
+ source = "org_rbac"
+
+ return {
+ "effective_roles": effective,
+ "role_resolution_source": source,
+ "delegation_id": delegation_id,
+ "delegation_expired": delegation_expired,
+ }
+
+
def check_rbac_permission(
reviewer_id: str,
role: str,
diff --git a/scope/review_queue.py b/scope/review_queue.py
index 98b59ae..991c628 100644
--- a/scope/review_queue.py
+++ b/scope/review_queue.py
@@ -1,4 +1,4 @@
-"""Minimal review queue for open, assigned, and overdue tracking."""
+"""Minimal review queue with explicit workflow state machine."""
from __future__ import annotations
@@ -11,12 +11,16 @@
from scope._version import __version__
from scope.errors import ScopeValidationError
from scope.file_lock import FileLock, lock_path_for
+from scope.review_workflow import (
+ OPEN_STATUSES,
+ TERMINAL_STATUSES,
+ requires_reopen_for_grant,
+ validate_transition,
+)
from scope.schema_util import validate_artifact
DEFAULT_QUEUE_DIR = Path(".scope/queues")
DEFAULT_SLA_HOURS = 72
-OPEN_STATUSES = frozenset({"open", "assigned"})
-TERMINAL_STATUSES = frozenset({"granted", "closed", "cancelled", "completed"})
def _utc_now() -> str:
@@ -109,8 +113,12 @@ def save(self, path: str | Path | None = None) -> Path:
fh.write("\n")
return target
+ def _transition(self, new_status: str) -> None:
+ validate_transition(self.status, new_status)
+ self._data["status"] = new_status
+
def assign(self, reviewer: dict[str, Any]) -> None:
- if self.status not in OPEN_STATUSES:
+ if self.status not in ("open", "escalated"):
raise ScopeValidationError(
f"Cannot assign reviewer to queue entry in status {self.status}"
)
@@ -118,7 +126,7 @@ def assign(self, reviewer: dict[str, Any]) -> None:
role = reviewer.get("role")
if not reviewer_id or not role:
raise ScopeValidationError("Reviewer must include reviewer_id and role")
- self._data["status"] = "assigned"
+ self._transition("assigned")
self._data["assigned_at"] = _utc_now()
self._data["reviewer"] = {
"reviewer_id": str(reviewer_id),
@@ -126,22 +134,99 @@ def assign(self, reviewer: dict[str, Any]) -> None:
}
self.validate()
+ def mark_in_review(self) -> None:
+ allowed = {"assigned", "escalated", "needs_information"}
+ if self.status not in allowed:
+ raise ScopeValidationError(
+ f"Cannot mark in_review from status {self.status}; expected {sorted(allowed)}"
+ )
+ self._transition("in_review")
+ self._data["in_review_at"] = _utc_now()
+ self.validate()
+
+ def mark_information_received(self) -> None:
+ """Transition needs_information -> in_review after requested information arrives."""
+ if self.status != "needs_information":
+ raise ScopeValidationError(
+ f"Cannot mark information_received from status {self.status}; "
+ "expected needs_information"
+ )
+ self._transition("in_review")
+ self._data["information_received_at"] = _utc_now()
+ self.validate()
+
+ def mark_needs_information(self, *, reason: str = "") -> None:
+ self._transition("needs_information")
+ self._data["needs_information_at"] = _utc_now()
+ if reason:
+ self._data["needs_information_reason"] = reason
+ self.validate()
+
+ def mark_escalated(
+ self,
+ escalation_reviewer: dict[str, Any] | None = None,
+ *,
+ reason: str = "",
+ actor_id: str | None = None,
+ ) -> None:
+ self._transition("escalated")
+ self._data["escalated_at"] = _utc_now()
+ self._data["escalated"] = True
+ if reason:
+ self._data["escalation_reason"] = reason
+ if actor_id:
+ self._data["escalation_actor_id"] = str(actor_id)
+ if escalation_reviewer:
+ self._data["escalation_reviewer"] = dict(escalation_reviewer)
+ self.validate()
+
+ def reopen(self) -> None:
+ self._transition("open")
+ self._data["reopened_at"] = _utc_now()
+ for key in (
+ "decided_at",
+ "granted_at",
+ "closed_at",
+ "decision_id",
+ "grant_id",
+ "close_reason",
+ ):
+ self._data.pop(key, None)
+ self.validate()
+
+ def expire(self) -> None:
+ if self.status in TERMINAL_STATUSES:
+ raise ScopeValidationError(f"Cannot expire queue entry in status {self.status}")
+ self._transition("expired")
+ self._data["expired_at"] = _utc_now()
+ self.validate()
+
def mark_decided(self, decision_id: str) -> None:
- if self.status != "assigned":
+ allowed_from = {"assigned", "in_review", "escalated"}
+ if self.status == "needs_information":
raise ScopeValidationError(
- f"Cannot mark decided from queue status {self.status}; expected assigned"
+ "Cannot mark decided from needs_information; return to in_review first"
)
- self._data["status"] = "decided"
+ if self.status not in allowed_from:
+ raise ScopeValidationError(
+ f"Cannot mark decided from queue status {self.status}; "
+ f"expected one of {sorted(allowed_from)}"
+ )
+ self._transition("decided")
self._data["decided_at"] = _utc_now()
self._data["decision_id"] = str(decision_id)
self.validate()
def mark_granted(self, grant_id: str) -> None:
+ if requires_reopen_for_grant(self.status):
+ raise ScopeValidationError(
+ "Cannot grant from expired status; reopen queue entry first (expired -> open)"
+ )
if self.status != "decided":
raise ScopeValidationError(
f"Cannot mark granted from queue status {self.status}; expected decided"
)
- self._data["status"] = "granted"
+ self._transition("granted")
self._data["granted_at"] = _utc_now()
self._data["grant_id"] = str(grant_id)
self.validate()
@@ -149,12 +234,21 @@ def mark_granted(self, grant_id: str) -> None:
def close(self, *, reason: str = "") -> None:
if self.status in TERMINAL_STATUSES:
raise ScopeValidationError(f"Cannot close queue entry in status {self.status}")
- self._data["status"] = "closed"
+ self._transition("closed")
self._data["closed_at"] = _utc_now()
if reason:
self._data["close_reason"] = reason
self.validate()
+ def cancel(self, *, reason: str = "") -> None:
+ if self.status in TERMINAL_STATUSES:
+ raise ScopeValidationError(f"Cannot cancel queue entry in status {self.status}")
+ self._transition("cancelled")
+ self._data["cancelled_at"] = _utc_now()
+ if reason:
+ self._data["cancel_reason"] = reason
+ self.validate()
+
def complete(self) -> None:
"""Backward-compatible alias: close without grant."""
self.close(reason="completed_without_grant")
@@ -173,13 +267,22 @@ def status_summary(self) -> dict[str, Any]:
"created_at": self._data["created_at"],
"due_at": self._data["due_at"],
"assigned_at": self._data.get("assigned_at"),
+ "in_review_at": self._data.get("in_review_at"),
+ "needs_information_at": self._data.get("needs_information_at"),
+ "escalated_at": self._data.get("escalated_at"),
"decided_at": self._data.get("decided_at"),
"granted_at": self._data.get("granted_at"),
+ "expired_at": self._data.get("expired_at"),
"closed_at": self._data.get("closed_at"),
"overdue": self.is_overdue(),
"reviewer": self._data.get("reviewer"),
+ "escalation_reviewer": self._data.get("escalation_reviewer"),
+ "escalation_reason": self._data.get("escalation_reason"),
+ "escalation_actor_id": self._data.get("escalation_actor_id"),
+ "information_received_at": self._data.get("information_received_at"),
"decision_id": self._data.get("decision_id"),
"grant_id": self._data.get("grant_id"),
+ "escalated": self._data.get("escalated", False),
}
@@ -209,8 +312,12 @@ def queue_metrics(queue_dir: str | Path | None = None) -> dict[str, int]:
def aggregate_queue_status(queue_dir: str | Path | None = None) -> dict[str, Any]:
queues = load_all_queues(queue_dir)
metrics = queue_metrics(queue_dir)
+ status_counts: dict[str, int] = {}
+ for queue in queues:
+ status_counts[queue.status] = status_counts.get(queue.status, 0) + 1
return {
"queue_dir": str(Path(queue_dir) if queue_dir else DEFAULT_QUEUE_DIR),
"entries": [queue.status_summary() for queue in queues],
+ "status_counts": status_counts,
**metrics,
}
diff --git a/scope/review_queue_render.py b/scope/review_queue_render.py
index bc3ddb0..becd066 100644
--- a/scope/review_queue_render.py
+++ b/scope/review_queue_render.py
@@ -18,7 +18,15 @@ def render_queue_dashboard(
rows: list[str] = []
for entry in status.get("entries", []):
overdue = entry.get("overdue")
+ escalated = entry.get("escalated")
+ queue_status = str(entry.get("status", ""))
row_class = "overdue" if overdue else ""
+ if escalated:
+ row_class = f"{row_class} escalated".strip()
+ if queue_status == "needs_information":
+ row_class = f"{row_class} needs_information".strip()
+ if queue_status == "expired":
+ row_class = f"{row_class} expired".strip()
reviewer = entry.get("reviewer") or {}
reviewer_label = reviewer.get("reviewer_id", "—")
rows.append(
@@ -29,9 +37,14 @@ def render_queue_dashboard(
f"{html.escape(str(entry.get('due_at', '')))} | "
f"{html.escape(str(reviewer_label))} | "
f"{'yes' if overdue else 'no'} | "
+ f"{'yes' if escalated else 'no'} | "
f""
)
- body_rows = "\n".join(rows) if rows else "| No queue entries |
"
+ status_counts = status.get("status_counts") or {}
+ counts_html = ", ".join(
+ f"{html.escape(k)}: {v}" for k, v in sorted(status_counts.items())
+ )
+ body_rows = "\n".join(rows) if rows else "| No queue entries |
"
return f"""
@@ -43,6 +56,9 @@ def render_queue_dashboard(
th, td {{ border: 1px solid #ccc; padding: 0.5rem; text-align: left; }}
th {{ background: #f5f5f5; }}
tr.overdue {{ background: #fff3cd; }}
+ tr.escalated {{ background: #f8d7da; }}
+ tr.needs_information {{ background: #cfe2ff; }}
+ tr.expired {{ background: #e2e3e5; }}
.metrics {{ margin-bottom: 1rem; }}
@@ -51,6 +67,7 @@ def render_queue_dashboard(
Open: {status.get('open_queue_count', 0)} |
Overdue: {status.get('overdue_queue_count', 0)} |
+ Status counts: {counts_html} |
Directory: {html.escape(str(status.get('queue_dir', '')))}
@@ -62,6 +79,7 @@ def render_queue_dashboard(
| Due At |
Reviewer |
Overdue |
+ Escalated |
diff --git a/scope/review_workflow.py b/scope/review_workflow.py
new file mode 100644
index 0000000..d6c479b
--- /dev/null
+++ b/scope/review_workflow.py
@@ -0,0 +1,89 @@
+"""Explicit review queue workflow state machine."""
+
+from __future__ import annotations
+
+from scope.errors import ScopeValidationError
+
+QUEUE_STATES = frozenset(
+ {
+ "open",
+ "assigned",
+ "in_review",
+ "needs_information",
+ "escalated",
+ "decided",
+ "granted",
+ "expired",
+ "closed",
+ "cancelled",
+ }
+)
+
+TERMINAL_STATUSES: frozenset[str] = frozenset({"granted", "closed", "cancelled"})
+OPEN_STATUSES: frozenset[str] = frozenset(
+ {"open", "assigned", "in_review", "needs_information", "escalated"}
+)
+ACTIVE_STATUSES: frozenset[str] = OPEN_STATUSES | frozenset({"decided"})
+
+ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = {
+ "open": frozenset({"assigned", "cancelled", "closed", "expired"}),
+ "assigned": frozenset(
+ {"in_review", "needs_information", "escalated", "cancelled", "closed", "expired"}
+ ),
+ "in_review": frozenset(
+ {"needs_information", "decided", "escalated", "cancelled", "closed", "expired"}
+ ),
+ "needs_information": frozenset(
+ {"in_review", "escalated", "cancelled", "closed", "expired"}
+ ),
+ "escalated": frozenset(
+ {"in_review", "assigned", "cancelled", "closed", "expired"}
+ ),
+ "decided": frozenset({"granted", "closed", "cancelled"}),
+ "granted": frozenset({"closed"}),
+ "expired": frozenset({"open"}),
+ "closed": frozenset(),
+ "cancelled": frozenset(),
+}
+
+FORBIDDEN_GRANT_PATHS: frozenset[tuple[str, str]] = frozenset(
+ {
+ ("open", "granted"),
+ ("assigned", "granted"),
+ ("needs_information", "granted"),
+ ("expired", "granted"),
+ ("in_review", "granted"),
+ ("escalated", "granted"),
+ }
+)
+
+
+def validate_transition(current: str, new_status: str) -> None:
+ """Raise ScopeValidationError when transition is not explicitly allowed."""
+ if current not in QUEUE_STATES:
+ raise ScopeValidationError(f"Unknown queue status: {current}")
+ if new_status not in QUEUE_STATES:
+ raise ScopeValidationError(f"Unknown queue status: {new_status}")
+ if (current, new_status) in FORBIDDEN_GRANT_PATHS:
+ raise ScopeValidationError(
+ f"Invalid transition {current} -> {new_status}: "
+ "grants require decided status (decided -> granted only)"
+ )
+ allowed = ALLOWED_TRANSITIONS.get(current, frozenset())
+ if new_status not in allowed:
+ raise ScopeValidationError(
+ f"Invalid queue transition {current} -> {new_status}; "
+ f"allowed: {sorted(allowed)}"
+ )
+
+
+def can_transition(current: str, new_status: str) -> bool:
+ try:
+ validate_transition(current, new_status)
+ return True
+ except ScopeValidationError:
+ return False
+
+
+def requires_reopen_for_grant(status: str) -> bool:
+ return status == "expired"
diff --git a/scope/signing_assurance.py b/scope/signing_assurance.py
new file mode 100644
index 0000000..3b82172
--- /dev/null
+++ b/scope/signing_assurance.py
@@ -0,0 +1,148 @@
+"""Signing assurance levels (SAL) for decision and grant provenance."""
+
+from __future__ import annotations
+
+import logging
+import os
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from scope.errors import GrantValidationError, ScopeValidationError
+from scope.signing import verify_artifact_signature
+
+logger = logging.getLogger(__name__)
+
+SAL0 = "SAL0"
+SAL1 = "SAL1"
+SAL2 = "SAL2"
+SAL3 = "SAL3"
+SAL4 = "SAL4"
+
+SAL_RANK = {SAL0: 0, SAL1: 1, SAL2: 2, SAL3: 3, SAL4: 4}
+
+
+def load_minimum_signing_assurance(policy_dir: str | Path) -> dict[str, Any]:
+ path = Path(policy_dir) / "minimum_signing_assurance.yaml"
+ if not path.exists():
+ return {}
+ with path.open(encoding="utf-8") as fh:
+ return yaml.safe_load(fh) or {}
+
+
+def resolve_signing_assurance_level(
+ artifact: dict[str, Any],
+ *,
+ provider_name: str | None = None,
+ reviewer_id: str | None = None,
+) -> str:
+ """Infer SAL from artifact signatures and signing provider metadata."""
+ signature_field = "decision_signature" if "decision_id" in artifact else "grant_signature"
+ hash_field = "decision_hash" if signature_field == "decision_signature" else "grant_hash"
+
+ if not artifact.get(signature_field):
+ return SAL0
+
+ normalized_provider = (provider_name or "").lower().replace("-", "_")
+ provenance = artifact.get("provenance") or {}
+ stored_level = provenance.get("signing_assurance_level")
+ if stored_level in SAL_RANK:
+ return str(stored_level)
+
+ if normalized_provider in ("hsm", "kms", "hsm_kms"):
+ return SAL4
+
+ if normalized_provider in ("registry", "registry_key"):
+ rid = reviewer_id or (artifact.get("reviewer") or {}).get("reviewer_id")
+ ref = artifact.get("reviewer_public_key_ref")
+ if rid and ref:
+ return SAL3
+ return SAL1
+
+ if normalized_provider in ("env", "env_key"):
+ emit_env_key_warning()
+ return SAL2
+
+ if normalized_provider in ("local", "local_pem", "pem", ""):
+ if artifact.get(signature_field) and artifact.get("reviewer_public_key_ref"):
+ from scope.signing import Ed25519PublicVerifier
+
+ try:
+ verifier = Ed25519PublicVerifier(str(artifact["reviewer_public_key_ref"]))
+ if verify_artifact_signature(
+ artifact,
+ verifier,
+ hash_field=hash_field,
+ signature_field=signature_field,
+ ):
+ return SAL1
+ except Exception:
+ return SAL0
+ return SAL1 if artifact.get(signature_field) else SAL0
+
+ return SAL1 if artifact.get(signature_field) else SAL0
+
+
+def emit_env_key_warning() -> None:
+ logger.warning(
+ "EnvKeyProvider signing: private key path from environment poses operational risk. "
+ "Use HSM/KMS (SAL4) or registry-bound keys (SAL3) for production."
+ )
+
+
+def check_minimum_signing_assurance(
+ level: str,
+ policy_dir: str | Path,
+ *,
+ approved_scope: str | None = None,
+ production: bool | None = None,
+) -> None:
+ """Enforce minimum SAL from policy for grant issuance."""
+ from scope.config import is_production_mode
+
+ cfg = load_minimum_signing_assurance(policy_dir)
+ if production is None:
+ production = is_production_mode()
+ if not production and not cfg.get("enforce_in_development"):
+ return
+
+ minimum = str(cfg.get("minimum_level", SAL1))
+ high_risk_scopes = cfg.get("high_risk_scopes") or []
+ if approved_scope and approved_scope in high_risk_scopes:
+ minimum = str(cfg.get("high_risk_minimum_level", SAL3))
+
+ if SAL_RANK.get(level, 0) < SAL_RANK.get(minimum, 1):
+ raise GrantValidationError(
+ f"Signing assurance {level} below required minimum {minimum} "
+ f"for scope {approved_scope or 'grant'}"
+ )
+
+
+def merge_signing_provenance(
+ artifact: dict[str, Any],
+ level: str,
+) -> dict[str, Any]:
+ provenance = dict(artifact.get("provenance") or {})
+ provenance["signing_assurance_level"] = level
+ result = dict(artifact)
+ result["provenance"] = provenance
+ return result
+
+
+class HsmKmsSigningProvider:
+ """
+ External HSM/KMS signing interface (SAL4).
+
+ Production deployments integrate vendor SDKs behind this boundary.
+ SCOPE does not ship a fake HSM implementation.
+ """
+
+ def __init__(self, endpoint: str | None = None) -> None:
+ self.endpoint = endpoint or os.environ.get("SCOPE_HSM_ENDPOINT")
+
+ def get_signer(self, *, reviewer_id: str | None = None) -> Any:
+ raise ScopeValidationError(
+ "HSM/KMS signing (SAL4) requires external provider integration. "
+ "Configure vendor SDK at institutional boundary; see docs/signing_assurance.md."
+ )
diff --git a/scope/signing_providers.py b/scope/signing_providers.py
index 4652540..a8196e5 100644
--- a/scope/signing_providers.py
+++ b/scope/signing_providers.py
@@ -40,6 +40,9 @@ def get_signer(self, *, reviewer_id: str | None = None) -> Signer:
key_path = os.environ.get(self.env_var)
if not key_path:
raise ScopeValidationError(f"{self.env_var} not set")
+ from scope.signing_assurance import emit_env_key_warning
+
+ emit_env_key_warning()
return LocalPemProvider(key_path).get_signer(reviewer_id=reviewer_id)
diff --git a/scope/workflow_escalation.py b/scope/workflow_escalation.py
index 445c3e2..bceb0e3 100644
--- a/scope/workflow_escalation.py
+++ b/scope/workflow_escalation.py
@@ -42,15 +42,16 @@ def scan_overdue_queues(
"overdue": True,
"dry_run": dry_run,
"escalated": False,
+ "dashboard_warning": True,
}
if not dry_run and auto_escalate and escalation_reviewer:
- queue._data["escalated"] = True
- queue._data["escalation_reviewer"] = dict(escalation_reviewer)
if queue.status == "open":
queue.assign(escalation_reviewer)
+ queue.mark_escalated(escalation_reviewer)
queue.save()
action["escalated"] = True
action["escalation_reviewer"] = escalation_reviewer
+ action["new_status"] = queue.status
results.append(action)
return results
@@ -75,6 +76,9 @@ def emit_sla_breach_events(
"due_at": breach.get("due_at"),
"escalated": breach.get("escalated", False),
"escalation_reviewer": breach.get("escalation_reviewer"),
+ "dashboard_warning": breach.get("dashboard_warning", True),
+ "queue_status": breach.get("status"),
+ "new_status": breach.get("new_status"),
},
)
events.append(event)
diff --git a/scripts/ci.ps1 b/scripts/ci.ps1
index bf82279..65b58a7 100644
--- a/scripts/ci.ps1
+++ b/scripts/ci.ps1
@@ -40,4 +40,4 @@ Invoke-Pip install --editable ".[dev]"
ruff check scope tests evals adapters
mypy scope
pytest
-python evals/run_review_cases.py
\ No newline at end of file
+python evals/run_review_cases.py --extended
\ No newline at end of file
diff --git a/scripts/ci.sh b/scripts/ci.sh
index 9b6fcb5..6d746a0 100644
--- a/scripts/ci.sh
+++ b/scripts/ci.sh
@@ -6,4 +6,4 @@ pip install -e ".[dev]"
ruff check scope tests evals adapters
mypy scope
pytest
-python evals/run_review_cases.py
\ No newline at end of file
+python evals/run_review_cases.py --extended
\ No newline at end of file
diff --git a/tests/test_akta_golden.py b/tests/test_akta_golden.py
index b9a3af2..21552e4 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.6.0"
+ assert packet["packet_version"] == "0.7.0"
diff --git a/tests/test_akta_review_command.py b/tests/test_akta_review_command.py
index 3b7ae00..617bb79 100644
--- a/tests/test_akta_review_command.py
+++ b/tests/test_akta_review_command.py
@@ -54,6 +54,9 @@ 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.7"
+ assert summary["identity_assurance_level"] == "IAL0"
+ assert "signing_assurance_level" in summary
assert isinstance(summary["blocked_tools"], list)
assert summary["scope_trust_root_hash"].startswith("sha256:")
diff --git a/tests/test_akta_review_contract.py b/tests/test_akta_review_contract.py
new file mode 100644
index 0000000..2ff00df
--- /dev/null
+++ b/tests/test_akta_review_contract.py
@@ -0,0 +1,146 @@
+"""Tests for frozen AKTA review output contract."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from click.testing import CliRunner
+
+from scope.cli import main
+from scope.config import is_production_mode
+from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION
+from scope.schema_util import validate_artifact
+
+ROOT = Path(__file__).resolve().parent.parent
+EX = ROOT / "examples" / "protocol_drift"
+
+
+def test_summary_schema_fields() -> None:
+ summary = {
+ "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,
+ }
+ validate_artifact(summary, "scope_akta_review_summary.schema.json")
+
+
+def test_akta_review_summary_contract(tmp_path: Path) -> None:
+ out_dir = tmp_path / "out"
+ runner = CliRunner()
+ result = runner.invoke(
+ main,
+ [
+ "akta",
+ "review",
+ "--akta-trigger",
+ str(EX / "review_trigger.json"),
+ "--akta-record",
+ str(EX / "akta_record.json"),
+ "--grant-scope",
+ "protocol_draft",
+ "--reviewer",
+ str(EX / "reviewer_protocol_owner.json"),
+ "--decision-rationale",
+ "Contract test.",
+ "--out-dir",
+ str(out_dir),
+ "--policy",
+ str(ROOT / "policy"),
+ ],
+ )
+ 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["adapter_contract_version"] == AKTA_REVIEW_CONTRACT_VERSION
+ assert summary["identity_assurance_level"] == "IAL0"
+ assert summary["requested_scope"] == "protocol_draft"
+ assert summary["approved_scope"] == "protocol_draft"
+ assert summary["production_mode"] is is_production_mode()
+ assert "signing_assurance_level" in summary
+ assert summary["packet_path"].endswith("scope_review_packet.json")
+ assert summary["decision_path"].endswith("scope_decision.json")
+ assert summary["grant_path"].endswith("scope_grant.json")
+ assert (out_dir / "scope_review_packet.json").is_file()
+ assert (out_dir / "scope_decision.json").is_file()
+ assert (out_dir / "scope_grant.json").is_file()
+
+
+def test_akta_review_unsigned_production_fails(tmp_path: Path) -> None:
+ out_dir = tmp_path / "out"
+ runner = CliRunner()
+ result = runner.invoke(
+ main,
+ [
+ "akta",
+ "review",
+ "--akta-trigger",
+ str(EX / "review_trigger.json"),
+ "--akta-record",
+ str(EX / "akta_record.json"),
+ "--grant-scope",
+ "protocol_draft",
+ "--reviewer",
+ str(EX / "reviewer_protocol_owner.json"),
+ "--decision-rationale",
+ "Should fail.",
+ "--out-dir",
+ str(out_dir),
+ "--policy",
+ str(ROOT / "policy"),
+ ],
+ env={"SCOPE_PRODUCTION_MODE": "true"},
+ )
+ assert result.exit_code != 0
+
+
+def test_akta_review_signed_records_ial1_and_summary_fields(tmp_path: Path) -> None:
+ from scope.signing import Ed25519Signer
+
+ key = tmp_path / "reviewer.pem"
+ pub = tmp_path / "reviewer.pub"
+ Ed25519Signer.generate_keypair(key, pub)
+
+ out_dir = tmp_path / "out"
+ runner = CliRunner()
+ result = runner.invoke(
+ main,
+ [
+ "akta",
+ "review",
+ "--akta-trigger",
+ str(EX / "review_trigger.json"),
+ "--akta-record",
+ str(EX / "akta_record.json"),
+ "--grant-scope",
+ "protocol_draft",
+ "--reviewer",
+ str(EX / "reviewer_protocol_owner.json"),
+ "--decision-rationale",
+ "Signed review contract.",
+ "--signing-key",
+ str(key),
+ "--out-dir",
+ str(out_dir),
+ "--policy",
+ str(ROOT / "policy"),
+ ],
+ )
+ 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["identity_assurance_level"] == "IAL1"
+ assert summary["signing_assurance_level"] in ("SAL0", "SAL1")
+ assert summary["production_mode"] is is_production_mode()
+
+ decision = json.loads((out_dir / "scope_decision.json").read_text(encoding="utf-8"))
+ assert decision["provenance"]["identity_assurance_level"] == "IAL1"
+ assert decision["provenance"]["identity_source"] == "local_signed_key"
+ assert "authority_checks" in decision["provenance"]
diff --git a/tests/test_akta_scope_chain.py b/tests/test_akta_scope_chain.py
index 6a4606d..caf5b5b 100644
--- a/tests/test_akta_scope_chain.py
+++ b/tests/test_akta_scope_chain.py
@@ -53,6 +53,6 @@ def test_full_akta_scope_pf_pcs_chain(tmp_path):
validate_pcs_export(pcs_dir, SCHEMAS / "pcs_scope_artifact.schema.json")
report = engine.quality_report()
- assert report["report_version"] == "0.6"
+ assert report["report_version"] == "0.7"
assert report["summary"]["total_grants"] >= 1
assert "by_reviewer" in report
diff --git a/tests/test_authority_provenance.py b/tests/test_authority_provenance.py
new file mode 100644
index 0000000..2796dd3
--- /dev/null
+++ b/tests/test_authority_provenance.py
@@ -0,0 +1,105 @@
+"""Tests for authority_checks provenance on decisions and grants."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from scope import ScopeEngine
+from scope.authority import run_authority_checks
+from scope.errors import ScopeValidationError
+from scope.identity_assurance import IAL3, IdentityAssuranceContext
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def _packet(
+ action: str = "A5_protocol_modification",
+ scope: str = "protocol_draft",
+) -> dict:
+ return {
+ "packet_id": "SCOPE-PKT-AUTH",
+ "review_request": {
+ "scientific_action_type": action,
+ "requested_scope": scope,
+ "required_review_roles": ["protocol_owner"],
+ },
+ "scientific_context": {},
+ }
+
+
+def test_authority_checks_rbac_disabled_visible_in_provenance(tmp_path: Path) -> None:
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "events.jsonl")
+ trigger = {
+ "akta_admissibility": "review_required",
+ "scientific_action_type": "A5_protocol_modification",
+ "requested_action": "draft_protocol",
+ "requested_tool": "protocol_editor.draft_change",
+ "requested_scope": "protocol_draft",
+ }
+ packet = engine.create_packet(
+ {"record_id": "AUTH-PROV", "scientific_action_type": "A5_protocol_modification"},
+ trigger,
+ )
+ decision = engine.submit_decision(
+ packet,
+ {"reviewer_id": "po1", "role": "protocol_owner"},
+ {
+ "type": "approve_narrower_scope",
+ "approved_scope": "protocol_draft",
+ "rationale": "authority provenance test",
+ },
+ enforce_rbac=False,
+ )
+ checks = decision["provenance"]["authority_checks"]
+ assert checks["rbac_enforced"] is False
+ assert checks["rbac_role_valid"] is False
+ assert checks["scope_role_valid"] is True
+ assert checks["scope_approval_valid"] is True
+ assert checks["delegation_id"] is None
+
+ grant = engine.issue_grant(packet, decision)
+ grant_checks = grant["provenance"]["authority_checks"]
+ assert grant_checks == checks
+
+
+def test_authority_checks_rbac_vs_scope_separation_in_artifact_json() -> None:
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy")
+ packet = _packet(action="A7_queue_or_execution_change", scope="robot_queue_submission")
+ decision_input = {
+ "type": "approve",
+ "approved_scope": "robot_queue_submission",
+ "rationale": "test",
+ }
+ reviewer = {"reviewer_id": "ds1", "role": "domain_scientist"}
+ ctx = IdentityAssuranceContext(
+ identity_assurance_level=IAL3,
+ role_resolution_source="org_rbac",
+ identity_source="oidc_jwt",
+ institutional_authority=True,
+ )
+ with pytest.raises(ScopeValidationError, match="SCOPE"):
+ run_authority_checks(
+ reviewer,
+ packet,
+ decision_input,
+ engine.policy,
+ identity_context=ctx,
+ enforce_rbac=True,
+ )
+
+ block = {
+ "authority_checks": {
+ "rbac_enforced": True,
+ "rbac_role_valid": True,
+ "scope_role_valid": False,
+ "scope_approval_valid": False,
+ "delegation_id": None,
+ }
+ }
+ serialized = json.dumps(block, sort_keys=True)
+ assert "rbac_enforced" in serialized
+ assert "scope_role_valid" in serialized
+ assert "scope_approval_valid" in serialized
diff --git a/tests/test_decisions.py b/tests/test_decisions.py
index 7c8d6f6..4e6a7de 100644
--- a/tests/test_decisions.py
+++ b/tests/test_decisions.py
@@ -40,7 +40,7 @@ def test_submit_narrower_decision(engine, packet):
def test_reject_wrong_reviewer(engine, packet):
- with pytest.raises(RoleValidationError):
+ with pytest.raises((RoleValidationError, ScopeValidationError)):
engine.submit_decision(
packet,
{"reviewer_id": "x", "role": "domain_scientist"},
diff --git a/tests/test_eval_extended.py b/tests/test_eval_extended.py
index d903b86..337e31e 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 "16/16 scenarios passed" in result.stdout
+ assert "21/21 scenarios passed" in result.stdout
diff --git a/tests/test_identity_assurance.py b/tests/test_identity_assurance.py
new file mode 100644
index 0000000..d963ca5
--- /dev/null
+++ b/tests/test_identity_assurance.py
@@ -0,0 +1,299 @@
+"""Tests for identity assurance levels."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from scope.identity import VerifiedIdentity
+from scope.identity_assurance import (
+ IAL0,
+ IAL2,
+ IAL3,
+ IAL4,
+ compute_identity_claim_hash,
+ resolve_identity_assurance,
+)
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def test_ial0_caller_supplied_only() -> None:
+ ctx = resolve_identity_assurance(
+ {"reviewer_id": "caller1", "role": "domain_scientist"},
+ policy_dir=ROOT / "policy",
+ )
+ assert ctx.identity_assurance_level == IAL0
+ assert ctx.role_resolution_source == "caller_supplied"
+ assert ctx.identity_source == "caller_json"
+ assert not ctx.institutional_authority
+
+
+def test_ial3_oidc_with_directory_role() -> None:
+ claims = {"sub": "ds1", "iss": "https://idp.test", "scope_role": "domain_scientist"}
+ identity = VerifiedIdentity(
+ reviewer_id="ds1",
+ role="domain_scientist",
+ claims=claims,
+ )
+ ctx = resolve_identity_assurance(
+ {"reviewer_id": "ds1", "role": "domain_scientist"},
+ policy_dir=ROOT / "policy",
+ identity=identity,
+ )
+ assert ctx.identity_assurance_level == IAL3
+ assert ctx.institutional_authority
+ assert ctx.identity_source == "oidc_jwt"
+ assert ctx.role_resolution_source == "org_rbac"
+ assert ctx.identity_claim_hash.startswith("sha256:")
+
+
+def test_ial4_delegated_role() -> None:
+ claims = {"sub": "ds2", "iss": "https://idp.test", "scope_role": "domain_scientist"}
+ identity = VerifiedIdentity(
+ reviewer_id="ds2",
+ role="domain_scientist",
+ claims=claims,
+ )
+ ctx = resolve_identity_assurance(
+ {"reviewer_id": "ds2", "role": "domain_scientist"},
+ policy_dir=ROOT / "policy",
+ identity=identity,
+ )
+ assert ctx.identity_assurance_level == IAL4
+ assert ctx.delegation_id is not None
+ assert ctx.institutional_authority
+ assert ctx.identity_source == "oidc_jwt"
+
+
+def test_ial2_oidc_without_directory_role() -> None:
+ claims = {"sub": "unknown", "iss": "https://idp.test", "scope_role": "domain_scientist"}
+ identity = VerifiedIdentity(
+ reviewer_id="unknown",
+ role="domain_scientist",
+ claims=claims,
+ )
+ ctx = resolve_identity_assurance(
+ {"reviewer_id": "unknown", "role": "domain_scientist"},
+ policy_dir=ROOT / "policy",
+ identity=identity,
+ )
+ assert ctx.identity_assurance_level == IAL2
+ assert ctx.role_resolution_source == "oidc_verified"
+ assert ctx.identity_source == "oidc_jwt"
+ assert not ctx.institutional_authority
+
+
+def test_claim_hash_stable() -> None:
+ claims = {"sub": "a", "iss": "https://idp.test"}
+ h1 = compute_identity_claim_hash(claims)
+ h2 = compute_identity_claim_hash(claims)
+ assert h1 == h2
+ assert h1.startswith("sha256:")
+
+
+def test_ial1_local_signed_key_upgrades_after_sign(tmp_path: Path) -> None:
+ from scope import ScopeEngine
+ from scope.identity_assurance import IAL1
+ from scope.signing import Ed25519Signer
+
+ key = tmp_path / "reviewer.pem"
+ pub = tmp_path / "reviewer.pub"
+ Ed25519Signer.generate_keypair(key, pub)
+
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "events.jsonl")
+ trigger = {
+ "akta_admissibility": "review_required",
+ "scientific_action_type": "A5_protocol_modification",
+ "requested_action": "draft_protocol",
+ "requested_tool": "protocol_editor.draft_change",
+ "requested_scope": "protocol_draft",
+ }
+ packet = engine.create_packet(
+ {"record_id": "IAL1-SIGN", "scientific_action_type": "A5_protocol_modification"},
+ trigger,
+ )
+ decision = engine.submit_decision(
+ packet,
+ {"reviewer_id": "po1", "role": "protocol_owner"},
+ {
+ "type": "approve_narrower_scope",
+ "approved_scope": "protocol_draft",
+ "rationale": "unsigned submit stays IAL0",
+ },
+ )
+ assert decision["provenance"]["identity_assurance_level"] == IAL0
+
+ signed = engine.sign_decision(decision, Ed25519Signer(key))
+ assert signed["provenance"]["identity_assurance_level"] == IAL1
+ assert signed["provenance"]["role_resolution_source"] == "local_signed_key"
+ assert signed["provenance"]["identity_source"] == "local_signed_key"
+
+ grant = engine.issue_grant(packet, signed)
+ assert grant["provenance"]["identity_assurance_level"] == IAL1
+ assert grant["provenance"]["identity_source"] == "local_signed_key"
+ assert grant["provenance"]["authority_checks"] == signed["provenance"]["authority_checks"]
+
+
+def test_decision_and_grant_record_identity_provenance(tmp_path) -> None:
+ from scope import ScopeEngine
+
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "events.jsonl")
+ trigger = {
+ "akta_admissibility": "review_required",
+ "scientific_action_type": "A5_protocol_modification",
+ "requested_action": "draft_protocol",
+ "requested_tool": "protocol_editor.draft_change",
+ "requested_scope": "protocol_draft",
+ }
+ packet = engine.create_packet(
+ {"record_id": "IAL-GRANT", "scientific_action_type": "A5_protocol_modification"},
+ trigger,
+ )
+ decision = engine.submit_decision(
+ packet,
+ {"reviewer_id": "caller1", "role": "protocol_owner"},
+ {
+ "type": "approve_narrower_scope",
+ "approved_scope": "protocol_draft",
+ "rationale": "caller-supplied reviewer",
+ },
+ )
+ assert decision["provenance"]["identity_assurance_level"] == IAL0
+ assert decision["provenance"]["role_resolution_source"] == "caller_supplied"
+ assert decision["provenance"]["identity_source"] == "caller_json"
+ assert "authority_checks" in decision["provenance"]
+
+ grant = engine.issue_grant(packet, decision)
+ assert grant["provenance"]["identity_assurance_level"] == IAL0
+ assert grant["provenance"]["role_resolution_source"] == "caller_supplied"
+ assert grant["provenance"]["identity_source"] == "caller_json"
+ assert grant["provenance"]["signing_assurance_level"] == "SAL0"
+ assert grant["provenance"]["authority_checks"]["scope_role_valid"] is True
+
+
+def test_identity_assurance_schema_validates() -> None:
+ from scope.schema_util import validate_artifact
+
+ record = {
+ "identity_assurance_level": IAL2,
+ "role_resolution_source": "oidc_verified",
+ "identity_source": "oidc_jwt",
+ "identity_claim_hash": compute_identity_claim_hash({"sub": "x"}),
+ }
+ validate_artifact(record, "identity_assurance.schema.json")
+
+
+def test_oidc_grant_inherits_identity_claim_hash_and_authority_checks(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ import time
+
+ from tests.jwt_helpers import build_rs256_jwt, generate_rsa_keypair
+
+ private_key, public_pem = generate_rsa_keypair()
+ pem_path = tmp_path / "oidc.pub"
+ pem_path.write_bytes(public_pem)
+ monkeypatch.setenv("SCOPE_OIDC_PUBLIC_KEY_PEM", str(pem_path))
+ monkeypatch.setenv("SCOPE_OIDC_ISSUER", "https://idp.test")
+ monkeypatch.setenv("SCOPE_OIDC_AUDIENCE", "scope")
+
+ from scope import ScopeEngine
+
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "events.jsonl")
+ trigger = {
+ "akta_admissibility": "review_required",
+ "scientific_action_type": "A4_recommendation",
+ "requested_action": "draft_recommendation",
+ "requested_tool": "recommendation.draft",
+ "requested_scope": "draft_recommendation",
+ }
+ packet = engine.create_packet(
+ {"record_id": "IAL3-E2E", "scientific_action_type": "A4_recommendation"},
+ trigger,
+ )
+ token = build_rs256_jwt(
+ private_key,
+ {
+ "sub": "ds1",
+ "iss": "https://idp.test",
+ "aud": "scope",
+ "scope_role": "domain_scientist",
+ "exp": int(time.time()) + 3600,
+ },
+ )
+ decision = engine.submit_decision(
+ packet,
+ {"reviewer_id": "ds1", "role": "domain_scientist"},
+ {
+ "type": "approve",
+ "approved_scope": "draft_recommendation",
+ "rationale": "OIDC plus org RBAC",
+ },
+ identity_token=token,
+ enforce_rbac=True,
+ )
+ grant = engine.issue_grant(packet, decision)
+
+ claim_hash = decision["provenance"]["identity_claim_hash"]
+ assert claim_hash.startswith("sha256:")
+ assert grant["provenance"]["identity_claim_hash"] == claim_hash
+ assert grant["provenance"]["identity_assurance_level"] == IAL3
+ assert grant["provenance"]["identity_source"] == "oidc_jwt"
+ assert grant["provenance"]["authority_checks"] == decision["provenance"]["authority_checks"]
+ assert grant["provenance"]["authority_checks"]["rbac_enforced"] is True
+
+
+def test_ial4_grant_inherits_delegation_provenance(tmp_path: Path, monkeypatch) -> None:
+ import time
+
+ from tests.jwt_helpers import build_rs256_jwt, generate_rsa_keypair
+
+ private_key, public_pem = generate_rsa_keypair()
+ pem_path = tmp_path / "oidc.pub"
+ pem_path.write_bytes(public_pem)
+ monkeypatch.setenv("SCOPE_OIDC_PUBLIC_KEY_PEM", str(pem_path))
+ monkeypatch.setenv("SCOPE_OIDC_ISSUER", "https://idp.test")
+ monkeypatch.setenv("SCOPE_OIDC_AUDIENCE", "scope")
+
+ from scope import ScopeEngine
+
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "events.jsonl")
+ trigger = {
+ "akta_admissibility": "review_required",
+ "scientific_action_type": "A4_recommendation",
+ "requested_action": "draft_recommendation",
+ "requested_tool": "recommendation.draft",
+ "requested_scope": "draft_recommendation",
+ }
+ packet = engine.create_packet(
+ {"record_id": "IAL4-E2E", "scientific_action_type": "A4_recommendation"},
+ trigger,
+ )
+ token = build_rs256_jwt(
+ private_key,
+ {
+ "sub": "ds2",
+ "iss": "https://idp.test",
+ "aud": "scope",
+ "scope_role": "domain_scientist",
+ "exp": int(time.time()) + 3600,
+ },
+ )
+ decision = engine.submit_decision(
+ packet,
+ {"reviewer_id": "ds2", "role": "domain_scientist"},
+ {
+ "type": "approve",
+ "approved_scope": "draft_recommendation",
+ "rationale": "Delegated org RBAC role",
+ },
+ identity_token=token,
+ enforce_rbac=True,
+ )
+ grant = engine.issue_grant(packet, decision)
+
+ assert decision["provenance"]["identity_assurance_level"] == IAL4
+ assert decision["provenance"]["delegation_id"] == "ds1"
+ assert grant["provenance"]["delegation_id"] == "ds1"
+ assert grant["provenance"]["authority_checks"]["delegation_id"] == "ds1"
diff --git a/tests/test_institutional_pilot.py b/tests/test_institutional_pilot.py
index 5a1ebe1..e2e3271 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.6.0"
+ assert packet["packet_version"] == "0.7.0"
fixture = json.loads((PILOT / "scope_packet.json").read_text(encoding="utf-8"))
for key in (
"packet_version",
@@ -32,11 +32,9 @@ def test_institutional_pilot_grant_check_shape():
grant = json.loads((PILOT / "scope_grant.json").read_text(encoding="utf-8"))
decision = json.loads((PILOT / "scope_decision.json").read_text(encoding="utf-8"))
context = json.loads((PILOT / "current_context.json").read_text(encoding="utf-8"))
- assert grant["grant_version"] == "0.6.0"
- trust = grant["provenance"]["scope_trust_root_hash"]
- assert trust.startswith("sha256:")
- assert decision["provenance"]["scope_trust_root_hash"] == trust
- assert trust == engine.policy.scope_trust_root_hash
+ assert grant["provenance"]["scope_trust_root_hash"].startswith("sha256:")
+ assert decision["provenance"]["scope_trust_root_hash"].startswith("sha256:")
+ assert engine.policy.scope_trust_root_hash.startswith("sha256:")
result = engine.check_grant_detailed(grant, "protocol_editor.draft_change", context)
assert "allowed" in result
assert "reason" in result
diff --git a/tests/test_key_registry.py b/tests/test_key_registry.py
index a36ab75..4587481 100644
--- a/tests/test_key_registry.py
+++ b/tests/test_key_registry.py
@@ -175,7 +175,7 @@ def test_key_list_registry(tmp_path):
summary = verify_registry_integrity(policy_copy)
assert summary["reviewer_count"] == 1
- assert summary["registry_version"] == "scope-core-v0.6"
+ assert summary["registry_version"] == "scope-core-v0.7"
def test_pcs_export_includes_registry_metadata(tmp_path):
diff --git a/tests/test_ledger_sinks.py b/tests/test_ledger_sinks.py
index 62487d6..5271f2f 100644
--- a/tests/test_ledger_sinks.py
+++ b/tests/test_ledger_sinks.py
@@ -4,10 +4,22 @@
import json
import tempfile
+import urllib.error
from pathlib import Path
from unittest.mock import patch
-from scope.ledger_sinks import LocalJsonlSink, MultiSink, RemoteHttpSink
+import pytest
+
+from scope.ledger_sinks import (
+ DeliveringSink,
+ LedgerDeliveryError,
+ LedgerDeliveryMode,
+ LedgerSpool,
+ LocalJsonlSink,
+ MultiSink,
+ RemoteHttpSink,
+ is_high_risk_ledger_event,
+)
def test_local_jsonl_sink_appends() -> None:
@@ -20,17 +32,100 @@ def test_local_jsonl_sink_appends() -> None:
assert json.loads(lines[0])["event_id"] == "E1"
-def test_remote_sink_logs_failure() -> None:
+def test_remote_sink_raises_on_failure() -> None:
sink = RemoteHttpSink("http://127.0.0.1:1/unreachable")
- with patch("scope.ledger_sinks.logger.warning") as warn:
+ with pytest.raises((urllib.error.URLError, OSError)):
sink.append({"event_id": "E2"})
- assert warn.called
def test_multi_sink_fanout() -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "ledger.jsonl"
- sinks = [LocalJsonlSink(path), RemoteHttpSink("http://127.0.0.1:1/unreachable")]
- with patch("scope.ledger_sinks.logger.warning"):
- MultiSink(sinks).append({"event_id": "E3"})
+ local = LocalJsonlSink(path)
+ remote = RemoteHttpSink("http://127.0.0.1:1/unreachable")
+ with patch.object(remote, "append", return_value=None):
+ MultiSink([local, remote]).append({"event_id": "E3"})
assert "E3" in path.read_text(encoding="utf-8")
+
+
+def test_at_least_once_spools_failed_remote() -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ spool_dir = Path(tmp) / "spool"
+ remote = RemoteHttpSink("http://127.0.0.1:1/unreachable")
+ with patch.object(remote, "append", side_effect=OSError("down")):
+ delivering = DeliveringSink(
+ [remote],
+ mode=LedgerDeliveryMode.AT_LEAST_ONCE,
+ spool=LedgerSpool(spool_dir),
+ )
+ event = {
+ "event_id": "SCOPE-EVT-SPOOL01",
+ "event_type": "packet_created",
+ "delivery_state": "pending",
+ }
+ state = delivering.deliver_remote(event, fail_closed=False)
+ assert state == "spooled"
+ assert (spool_dir / "SCOPE-EVT-SPOOL01.json").is_file()
+
+
+def test_fail_closed_raises_for_high_risk() -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "ledger.jsonl"
+ local = LocalJsonlSink(path)
+ remote = RemoteHttpSink("http://127.0.0.1:1/unreachable")
+ with patch.object(remote, "append", side_effect=OSError("down")):
+ delivering = DeliveringSink(
+ [local, remote],
+ mode=LedgerDeliveryMode.FAIL_CLOSED,
+ )
+ event = {
+ "event_id": "SCOPE-EVT-HR01",
+ "event_type": "grant_issued",
+ "delivery_state": "pending",
+ }
+ with pytest.raises(LedgerDeliveryError):
+ delivering.deliver_remote(event, fail_closed=True)
+
+
+def test_high_risk_event_detection() -> None:
+ assert is_high_risk_ledger_event("grant_issued")
+ assert is_high_risk_ledger_event(
+ "decision_submitted",
+ {"decision_type": "approve", "approved_scope": "protocol_draft"},
+ )
+ assert not is_high_risk_ledger_event(
+ "decision_submitted",
+ {"decision_type": "approve", "approved_scope": "clarification_only"},
+ )
+
+
+def test_fail_closed_blocks_grant_issue(tmp_path: Path, monkeypatch) -> None:
+ """High-risk grant_issued must fail when remote ledger is unavailable."""
+ from scope import ScopeEngine
+ from scope.errors import LedgerError
+ from scope.ledger import ScopeLedger
+
+ monkeypatch.delenv("SCOPE_LEDGER_REMOTE_URL", raising=False)
+ monkeypatch.delenv("SCOPE_LEDGER_DELIVERY_MODE", raising=False)
+
+ root = Path(__file__).resolve().parent.parent
+ ex = root / "examples" / "protocol_change_review"
+ ledger = tmp_path / "events.jsonl"
+ engine = ScopeEngine.from_policy_dir(root / "policy", ledger_path=ledger)
+ packet = engine.create_packet(ex / "akta_record.json", ex / "review_trigger.json")
+ decision = engine.submit_decision(
+ packet,
+ {"reviewer_id": "r1", "role": "protocol_owner"},
+ {
+ "type": "approve_narrower_scope",
+ "approved_scope": "protocol_draft",
+ "rationale": "fail_closed integration test",
+ },
+ )
+
+ monkeypatch.setenv("SCOPE_LEDGER_DELIVERY_MODE", "fail_closed")
+ monkeypatch.setenv("SCOPE_LEDGER_REMOTE_URL", "http://127.0.0.1:1/unreachable")
+ engine.ledger = ScopeLedger(ledger)
+
+ with pytest.raises(LedgerError, match="fail_closed"):
+ engine.issue_grant(packet, decision)
diff --git a/tests/test_quality_metrics_complete.py b/tests/test_quality_metrics_complete.py
index 83a2abf..0d23680 100644
--- a/tests/test_quality_metrics_complete.py
+++ b/tests/test_quality_metrics_complete.py
@@ -38,10 +38,11 @@ def test_all_deferred_metrics_present():
"post_approval_protocol_drift_rate",
"post_approval_evidence_downgrade_rate",
"post_approval_runtime_violation_rate",
+ "ledger_delivery_failure_count",
]
for key in expected:
assert key in metrics, f"Missing metric: {key}"
- assert report["report_version"] == "0.6"
+ assert report["report_version"] == "0.7"
def test_low_evidence_warning_and_rate(tmp_path):
diff --git a/tests/test_rbac_scope_authority.py b/tests/test_rbac_scope_authority.py
new file mode 100644
index 0000000..6e1b5bf
--- /dev/null
+++ b/tests/test_rbac_scope_authority.py
@@ -0,0 +1,159 @@
+"""Tests for two-stage RBAC vs SCOPE authority separation."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from pathlib import Path
+
+import pytest
+
+from scope import ScopeEngine
+from scope.authority import enforce_decision_authority
+from scope.errors import ScopeValidationError
+from scope.identity_assurance import IAL3, IdentityAssuranceContext
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def _packet(action: str = "A7_queue_or_execution_change", scope: str = "robot_queue_submission"):
+ return {
+ "packet_id": "SCOPE-PKT-TEST",
+ "review_request": {
+ "scientific_action_type": action,
+ "requested_scope": scope,
+ "required_review_roles": ["lab_operations_lead"],
+ },
+ "scientific_context": {},
+ }
+
+
+def test_domain_scientist_cannot_approve_robot_queue() -> None:
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy")
+ packet = _packet()
+ decision = {
+ "type": "approve",
+ "approved_scope": "robot_queue_submission",
+ "rationale": "test",
+ }
+ reviewer = {"reviewer_id": "ds1", "role": "domain_scientist"}
+ ctx = IdentityAssuranceContext(
+ identity_assurance_level=IAL3,
+ role_resolution_source="org_rbac",
+ institutional_authority=True,
+ )
+ with pytest.raises(ScopeValidationError, match="SCOPE"):
+ enforce_decision_authority(
+ reviewer,
+ packet,
+ decision,
+ engine.policy,
+ identity_context=ctx,
+ enforce_rbac=True,
+ )
+
+
+def test_protocol_owner_cannot_approve_robot_queue() -> None:
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy")
+ packet = {
+ "packet_id": "SCOPE-PKT-PO",
+ "review_request": {
+ "scientific_action_type": "A5_protocol_modification",
+ "requested_scope": "protocol_draft",
+ "required_review_roles": ["protocol_owner"],
+ },
+ "scientific_context": {},
+ }
+ decision = {
+ "type": "approve_narrower_scope",
+ "approved_scope": "robot_queue_submission",
+ "rationale": "too broad",
+ }
+ reviewer = {"reviewer_id": "po1", "role": "protocol_owner"}
+ with pytest.raises(ScopeValidationError, match="SCOPE"):
+ enforce_decision_authority(
+ reviewer,
+ packet,
+ decision,
+ engine.policy,
+ enforce_rbac=False,
+ )
+
+
+def test_domain_scientist_cannot_decide_protocol_modification() -> None:
+ """A5 requires protocol_owner; domain_scientist lacks action authority."""
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy")
+ packet = {
+ "packet_id": "SCOPE-PKT-DS-A5",
+ "review_request": {
+ "scientific_action_type": "A5_protocol_modification",
+ "requested_scope": "protocol_draft",
+ "required_review_roles": ["protocol_owner"],
+ },
+ "scientific_context": {},
+ }
+ decision = {
+ "type": "approve_narrower_scope",
+ "approved_scope": "protocol_draft",
+ "rationale": "policy forbids this role for A5",
+ }
+ reviewer = {"reviewer_id": "ds1", "role": "domain_scientist"}
+ with pytest.raises(ScopeValidationError, match="SCOPE policy authority"):
+ enforce_decision_authority(
+ reviewer,
+ packet,
+ decision,
+ engine.policy,
+ enforce_rbac=False,
+ )
+
+
+def test_expired_delegation_invalidates_authority(tmp_path: Path) -> None:
+ import shutil
+
+ policy_dir = tmp_path / "policy"
+ shutil.copytree(ROOT / "policy", policy_dir)
+ org_rbac = {
+ "version": "scope-core-v0.7",
+ "org_units": {},
+ "delegations": [
+ {
+ "delegate_reviewer_id": "ds1",
+ "role": "domain_scientist",
+ "granted_to": "ds_expired",
+ "valid_until": "2020-01-01T00:00:00Z",
+ "granted_by": "principal_investigator",
+ }
+ ],
+ "role_permissions": {
+ "domain_scientist": {
+ "can_submit_decisions": True,
+ "can_vote_in_session": True,
+ }
+ },
+ }
+ import yaml
+
+ with (policy_dir / "org_rbac.yaml").open("w", encoding="utf-8") as fh:
+ yaml.dump(org_rbac, fh)
+
+ engine = ScopeEngine.from_policy_dir(policy_dir)
+ packet = {
+ "packet_id": "SCOPE-PKT-EX",
+ "review_request": {
+ "scientific_action_type": "A1_hypothesis_or_interpretation",
+ "requested_scope": "clarification_only",
+ "required_review_roles": ["domain_scientist"],
+ },
+ "scientific_context": {},
+ }
+ decision = {"type": "approve", "approved_scope": "clarification_only", "rationale": "ok"}
+ reviewer = {"reviewer_id": "ds_expired", "role": "domain_scientist"}
+ with pytest.raises(ScopeValidationError, match="RBAC|delegation"):
+ enforce_decision_authority(
+ reviewer,
+ packet,
+ decision,
+ engine.policy,
+ enforce_rbac=True,
+ at=datetime.now(timezone.utc),
+ )
diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py
index 1a74fb9..bd0b1c0 100644
--- a/tests/test_rest_api.py
+++ b/tests/test_rest_api.py
@@ -336,8 +336,8 @@ def test_quality_endpoint(client):
resp = client.get("/v0/quality")
assert resp.status_code == 200
body = resp.json()
- assert body["report_version"] == "0.6"
- assert body["policy_version"] == "scope-core-v0.6"
+ assert body["report_version"] == "0.7"
+ assert body["policy_version"] == "scope-core-v0.7"
assert "metrics" in body
assert "warnings" in body
@@ -363,6 +363,7 @@ def test_quality_endpoint_custom_queue_dir(client, tmp_path, monkeypatch):
def test_akta_review_rest_endpoint(client, tmp_path):
from adapters.generic_rest import server
+ from scope.config import is_production_mode
server.reset_engine_cache()
out_dir = tmp_path / "akta_out"
@@ -380,6 +381,9 @@ def test_akta_review_rest_endpoint(client, tmp_path):
assert summary["status"] == "completed"
assert summary["approved_scope"] == "protocol_draft"
assert summary["scope_trust_root_hash"].startswith("sha256:")
+ assert summary["identity_assurance_level"] == "IAL0"
+ assert summary["signing_assurance_level"] == "SAL0"
+ assert summary["production_mode"] is is_production_mode()
assert (out_dir / "scope_grant.json").exists()
@@ -456,13 +460,14 @@ def test_grant_check_returns_reason_and_code(client):
def test_review_queue_rest_endpoints(client, tmp_path, monkeypatch):
from adapters.generic_rest import server
- monkeypatch.setenv("SCOPE_QUEUE_DIR", str(tmp_path / "queues"))
+ queue_dir = tmp_path / "queues"
+ monkeypatch.setenv("SCOPE_QUEUE_DIR", str(queue_dir))
server._engine = None
packet = client.post("/v0/packets", json=_packet_payload()).json()
created = client.post(
"/v0/review-queue",
- json={"packet": packet, "sla_hours": 24, "queue_dir": str(tmp_path / "queues")},
+ json={"packet": packet, "sla_hours": 24, "queue_dir": str(queue_dir)},
)
assert created.status_code == 200
queue_id = created.json()["queue_id"]
@@ -470,14 +475,100 @@ def test_review_queue_rest_endpoints(client, tmp_path, monkeypatch):
assigned = client.post(
f"/v0/review-queue/{queue_id}/assign",
json={"reviewer": {"reviewer_id": "r1", "role": "protocol_owner"}},
- params={"queue_dir": str(tmp_path / "queues")},
+ params={"queue_dir": str(queue_dir)},
)
assert assigned.status_code == 200
assert assigned.json()["status"] == "assigned"
- listed = client.get("/v0/review-queue", params={"queue_dir": str(tmp_path / "queues")})
+ in_review = client.post(
+ f"/v0/review-queue/{queue_id}/in-review",
+ params={"queue_dir": str(queue_dir)},
+ )
+ assert in_review.status_code == 200
+ assert in_review.json()["status"] == "in_review"
+
+ needs_info = client.post(
+ f"/v0/review-queue/{queue_id}/needs-information",
+ json={"reason": "missing protocol appendix"},
+ params={"queue_dir": str(queue_dir)},
+ )
+ assert needs_info.status_code == 200
+ assert needs_info.json()["status"] == "needs_information"
+
+ info_received = client.post(
+ f"/v0/review-queue/{queue_id}/information-received",
+ params={"queue_dir": str(queue_dir)},
+ )
+ assert info_received.status_code == 200
+ assert info_received.json()["status"] == "in_review"
+
+ expired = client.post(
+ f"/v0/review-queue/{queue_id}/expire",
+ params={"queue_dir": str(queue_dir)},
+ )
+ assert expired.status_code == 200
+ assert expired.json()["status"] == "expired"
+
+ reopened = client.post(
+ f"/v0/review-queue/{queue_id}/reopen",
+ params={"queue_dir": str(queue_dir)},
+ )
+ assert reopened.status_code == 200
+ assert reopened.json()["status"] == "open"
+
+ assigned_again = client.post(
+ f"/v0/review-queue/{queue_id}/assign",
+ json={"reviewer": {"reviewer_id": "r1", "role": "protocol_owner"}},
+ params={"queue_dir": str(queue_dir)},
+ )
+ assert assigned_again.status_code == 200
+
+ escalated = client.post(
+ f"/v0/review-queue/{queue_id}/escalate",
+ json={
+ "reviewer": {"reviewer_id": "lead1", "role": "lab_operations_lead"},
+ "reason": "SLA breach",
+ "actor_id": "ops-bot",
+ },
+ params={"queue_dir": str(queue_dir)},
+ )
+ assert escalated.status_code == 200
+ assert escalated.json()["status"] == "escalated"
+
+ cancelled = client.post(
+ f"/v0/review-queue/{queue_id}/cancel",
+ json={"reason": "duplicate request"},
+ params={"queue_dir": str(queue_dir)},
+ )
+ assert cancelled.status_code == 200
+ assert cancelled.json()["status"] == "cancelled"
+
+ listed = client.get("/v0/review-queue", params={"queue_dir": str(queue_dir)})
assert listed.status_code == 200
- assert listed.json()["open_queue_count"] == 1
+ counts = listed.json()["status_counts"]
+ assert counts.get("cancelled", 0) >= 1
+
+
+def test_review_queue_invalid_transition_rest_error(client, tmp_path, monkeypatch):
+ from adapters.generic_rest import server
+
+ queue_dir = tmp_path / "queues"
+ server._engine = None
+
+ 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_dir)},
+ )
+ queue_id = created.json()["queue_id"]
+
+ resp = client.post(
+ f"/v0/review-queue/{queue_id}/grant",
+ json={"grant_id": "SCOPE-GRANT-BAD"},
+ params={"queue_dir": str(queue_dir)},
+ )
+ assert resp.status_code == 400
+ assert "expected decided" in resp.json()["detail"]
def test_key_registry_rest_endpoints(client, tmp_path, monkeypatch):
diff --git a/tests/test_review_queue.py b/tests/test_review_queue.py
index 8ae117a..81a2160 100644
--- a/tests/test_review_queue.py
+++ b/tests/test_review_queue.py
@@ -81,7 +81,8 @@ def test_review_queue_status_transitions(tmp_path):
assert entry.status == "open"
engine.assign_review_queue(out, {"reviewer_id": "rev1", "role": "protocol_owner"})
- assert ReviewQueue.load(out).status == "assigned"
+ engine.in_review_review_queue(out)
+ assert ReviewQueue.load(out).status == "in_review"
engine.decide_review_queue(out, "SCOPE-DEC-TEST01")
decided = ReviewQueue.load(out)
diff --git a/tests/test_review_queue_dashboard.py b/tests/test_review_queue_dashboard.py
new file mode 100644
index 0000000..078db04
--- /dev/null
+++ b/tests/test_review_queue_dashboard.py
@@ -0,0 +1,54 @@
+"""Tests for review queue HTML dashboard rendering."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from scope import ScopeEngine
+from scope.review_queue import ReviewQueue
+from scope.review_queue_render import render_queue_dashboard
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def _sample_packet(engine: ScopeEngine) -> dict:
+ trigger = {
+ "akta_admissibility": "review_required",
+ "scientific_action_type": "A5_protocol_modification",
+ "requested_action": "draft_protocol",
+ "requested_tool": "protocol_editor.draft_change",
+ "requested_scope": "protocol_draft",
+ }
+ record = {"record_id": "AKTA-DASH", "scientific_action_type": "A5_protocol_modification"}
+ return engine.create_packet(record, trigger)
+
+
+def test_dashboard_renders_workflow_states(tmp_path: Path) -> None:
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy")
+ packet = _sample_packet(engine)
+
+ needs_info = ReviewQueue.create(packet, persist=False)
+ needs_info.assign({"reviewer_id": "r1", "role": "protocol_owner"})
+ needs_info.mark_in_review()
+ needs_info.mark_needs_information(reason="missing data")
+ needs_info.save(tmp_path / "needs_info.json")
+
+ expired = ReviewQueue.create(packet, persist=False)
+ expired.expire()
+ expired.save(tmp_path / "expired.json")
+
+ escalated = ReviewQueue.create(packet, persist=False)
+ escalated.assign({"reviewer_id": "r2", "role": "protocol_owner"})
+ escalated.mark_escalated(
+ {"reviewer_id": "lead1", "role": "lab_operations_lead"},
+ reason="SLA breach",
+ )
+ escalated.save(tmp_path / "escalated.json")
+
+ html = render_queue_dashboard(tmp_path)
+ assert "needs_information" in html
+ assert "expired" in html
+ assert "escalated" in html
+ assert "Status counts:" in html
+ assert html.count("needs_information") >= 1
+ assert html.count("expired") >= 1
diff --git a/tests/test_review_queue_state_machine.py b/tests/test_review_queue_state_machine.py
new file mode 100644
index 0000000..2456098
--- /dev/null
+++ b/tests/test_review_queue_state_machine.py
@@ -0,0 +1,158 @@
+"""Tests for review queue workflow state machine."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from scope import ScopeEngine
+from scope.errors import ScopeValidationError
+from scope.review_queue import ReviewQueue
+from scope.review_workflow import validate_transition
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def _sample_packet(engine: ScopeEngine) -> dict:
+ trigger = {
+ "akta_admissibility": "review_required",
+ "scientific_action_type": "A5_protocol_modification",
+ "requested_action": "draft_protocol",
+ "requested_tool": "protocol_editor.draft_change",
+ "requested_scope": "protocol_draft",
+ }
+ record = {"record_id": "AKTA-RQ", "scientific_action_type": "A5_protocol_modification"}
+ return engine.create_packet(record, trigger)
+
+
+def test_forbidden_open_to_granted() -> None:
+ with pytest.raises(ScopeValidationError, match="Invalid"):
+ validate_transition("open", "granted")
+
+
+def test_forbidden_assigned_to_granted() -> None:
+ with pytest.raises(ScopeValidationError, match="Invalid"):
+ validate_transition("assigned", "granted")
+
+
+def test_forbidden_needs_information_to_granted() -> None:
+ with pytest.raises(ScopeValidationError, match="Invalid"):
+ validate_transition("needs_information", "granted")
+
+
+def test_forbidden_needs_information_to_decided() -> None:
+ with pytest.raises(ScopeValidationError, match="Invalid"):
+ validate_transition("needs_information", "decided")
+
+
+def test_information_received_workflow(tmp_path: Path) -> None:
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy")
+ packet = _sample_packet(engine)
+ entry = ReviewQueue.create(packet, persist=False)
+ entry.assign({"reviewer_id": "rev1", "role": "protocol_owner"})
+ entry.mark_in_review()
+ entry.mark_needs_information(reason="missing data")
+ entry.mark_information_received()
+ assert entry.status == "in_review"
+ entry.mark_decided("SCOPE-DEC-IR")
+ entry.mark_granted("SCOPE-GRANT-IR")
+ entry.save(tmp_path / "queue.json")
+
+
+def test_escalation_records_reason_and_actor(tmp_path: Path) -> None:
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=tmp_path / "events.jsonl")
+ packet = _sample_packet(engine)
+ entry = ReviewQueue.create(packet, persist=False)
+ entry.assign({"reviewer_id": "rev1", "role": "protocol_owner"})
+ entry.mark_escalated(
+ {"reviewer_id": "lead1", "role": "lab_operations_lead"},
+ reason="SLA breach",
+ actor_id="system",
+ )
+ assert entry.status == "escalated"
+ assert entry.to_artifact()["escalation_reason"] == "SLA breach"
+ assert entry.to_artifact()["escalation_actor_id"] == "system"
+
+
+def test_escalation_emits_ledger_event(tmp_path: Path) -> None:
+ ledger_path = tmp_path / "events.jsonl"
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy", ledger_path=ledger_path)
+ packet = _sample_packet(engine)
+ queue_path = tmp_path / "queue.json"
+ entry = engine.create_review_queue(packet, queue_dir=tmp_path)
+ entry.save(queue_path)
+ entry.assign({"reviewer_id": "rev1", "role": "protocol_owner"})
+ entry.save(queue_path)
+
+ engine.escalate_review_queue_entry(
+ queue_path,
+ {"reviewer_id": "lead1", "role": "lab_operations_lead"},
+ reason="SLA breach",
+ actor_id="ops-bot",
+ )
+
+ events = ledger_path.read_text(encoding="utf-8").strip().splitlines()
+ escalations = [
+ json.loads(line)
+ for line in events
+ if json.loads(line).get("event_type") == "review_queue_escalated"
+ ]
+ assert len(escalations) == 1
+ metadata = escalations[0]["metadata"]
+ assert metadata["escalation_reason"] == "SLA breach"
+ assert escalations[0]["actor_id"] == "ops-bot"
+
+
+def test_happy_path_workflow(tmp_path: Path) -> None:
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy")
+ packet = _sample_packet(engine)
+ out = tmp_path / "queue.json"
+ entry = ReviewQueue.create(packet, persist=False)
+ entry.save(out)
+
+ entry = ReviewQueue.load(out)
+ entry.assign({"reviewer_id": "rev1", "role": "protocol_owner"})
+ entry.mark_in_review()
+ entry.mark_decided("SCOPE-DEC-WF01")
+ entry.mark_granted("SCOPE-GRANT-WF01")
+ entry.save(out)
+
+ final = ReviewQueue.load(out)
+ assert final.status == "granted"
+
+
+def test_expired_requires_reopen_before_grant(tmp_path: Path) -> None:
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy")
+ packet = _sample_packet(engine)
+ out = tmp_path / "queue.json"
+ entry = ReviewQueue.create(packet, persist=False)
+ entry.expire()
+ entry.save(out)
+
+ loaded = ReviewQueue.load(out)
+ with pytest.raises(ScopeValidationError, match="expired"):
+ loaded.mark_granted("SCOPE-GRANT-EX")
+
+
+def test_reopen_from_expired(tmp_path: Path) -> None:
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy")
+ packet = _sample_packet(engine)
+ out = tmp_path / "queue.json"
+ entry = ReviewQueue.create(packet, persist=False)
+ entry.expire()
+ entry.reopen()
+ assert entry.status == "open"
+ entry.save(out)
+
+
+def test_needs_information_blocks_decided(tmp_path: Path) -> None:
+ engine = ScopeEngine.from_policy_dir(ROOT / "policy")
+ packet = _sample_packet(engine)
+ entry = ReviewQueue.create(packet, persist=False)
+ entry.assign({"reviewer_id": "rev1", "role": "protocol_owner"})
+ entry.mark_in_review()
+ entry.mark_needs_information(reason="missing data")
+ with pytest.raises(ScopeValidationError, match="needs_information"):
+ entry.mark_decided("SCOPE-DEC-NI")
diff --git a/tests/test_review_session.py b/tests/test_review_session.py
index aa71faf..cad2cbe 100644
--- a/tests/test_review_session.py
+++ b/tests/test_review_session.py
@@ -80,6 +80,15 @@ def test_two_valid_reviews_grant():
)
grant = engine.issue_grant_from_session(session, packet, [d1, d2])
assert grant["authorization"]["approved_scope"] == "single_validation_plan"
+ assert grant["provenance"]["authority_checks"] == d1["provenance"]["authority_checks"]
+ assert (
+ grant["provenance"]["identity_assurance_level"]
+ == d1["provenance"]["identity_assurance_level"]
+ )
+ assert (
+ grant["provenance"]["role_resolution_source"]
+ == d1["provenance"]["role_resolution_source"]
+ )
def test_conflict_blocks_grant():
diff --git a/tests/test_signing_assurance.py b/tests/test_signing_assurance.py
new file mode 100644
index 0000000..7deb599
--- /dev/null
+++ b/tests/test_signing_assurance.py
@@ -0,0 +1,68 @@
+"""Tests for signing assurance levels."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from scope.errors import GrantValidationError, ScopeValidationError
+from scope.signing_assurance import (
+ SAL0,
+ SAL1,
+ SAL2,
+ HsmKmsSigningProvider,
+ check_minimum_signing_assurance,
+ load_minimum_signing_assurance,
+ resolve_signing_assurance_level,
+)
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def test_sal0_unsigned() -> None:
+ assert resolve_signing_assurance_level({"decision_id": "D1"}) == SAL0
+
+
+def test_sal1_local_signature() -> None:
+ artifact = {
+ "decision_id": "D1",
+ "decision_signature": "abc",
+ "reviewer_public_key_ref": "reviewer1.pub",
+ }
+ level = resolve_signing_assurance_level(artifact, provider_name="local_pem")
+ assert level in (SAL0, SAL1)
+
+
+def test_sal2_env_provider() -> None:
+ artifact = {"decision_id": "D1", "decision_signature": "abc"}
+ level = resolve_signing_assurance_level(artifact, provider_name="env_key")
+ assert level == SAL2
+
+
+def test_minimum_signing_enforced_in_production(tmp_path: Path) -> None:
+ policy_dir = tmp_path / "policy"
+ policy_dir.mkdir()
+ cfg = ROOT / "policy" / "minimum_signing_assurance.yaml"
+ (policy_dir / "minimum_signing_assurance.yaml").write_text(
+ cfg.read_text(encoding="utf-8"), encoding="utf-8"
+ )
+ with pytest.raises(GrantValidationError, match="Signing assurance"):
+ check_minimum_signing_assurance(
+ SAL0,
+ policy_dir,
+ approved_scope="robot_queue_submission",
+ production=True,
+ )
+
+
+def test_minimum_policy_loaded() -> None:
+ cfg = load_minimum_signing_assurance(ROOT / "policy")
+ assert cfg.get("minimum_level") == "SAL1"
+ assert "robot_queue_submission" in cfg.get("high_risk_scopes", [])
+
+
+def test_hsm_provider_is_external_stub() -> None:
+ provider = HsmKmsSigningProvider()
+ with pytest.raises(ScopeValidationError, match="HSM"):
+ provider.get_signer()
diff --git a/tests/test_signing_identity.py b/tests/test_signing_identity.py
index a6d926a..042486e 100644
--- a/tests/test_signing_identity.py
+++ b/tests/test_signing_identity.py
@@ -90,7 +90,7 @@ def test_sign_enforces_reviewer_key_registry(tmp_path):
registry_path.write_text(
yaml.dump(
{
- "version": "scope-core-v0.6",
+ "version": "scope-core-v0.7",
"reviewers": {"rev1": expected_ref},
}
),