From e829568518c7f4ba75a37588c5ba2a63ab92c400 Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:20:05 -0700 Subject: [PATCH 01/14] feat(workflow): webhooks, tickets, tenant queue isolation Add notification sinks, Jira and ServiceNow adapters, tenant-scoped queues, REST audit logging, queue sync-ticket CLI, and production deployment guide for v0.11. --- adapters/generic_rest/server.py | 33 ++++++++- adapters/workflow/__init__.py | 0 adapters/workflow/jira.py | 63 ++++++++++++++++ adapters/workflow/servicenow.py | 57 +++++++++++++++ docs/production_deployment.md | 75 +++++++++++++++++++ policy/workflow_escalation.yaml | 1 + pyproject.toml | 2 +- scope/_version.py | 2 +- scope/engine_factory.py | 21 ++++-- scope/integration_versions.py | 2 +- scope/notifications.py | 123 ++++++++++++++++++++++++++++++++ scope/review_queue.py | 25 ++++++- scope/workflow_escalation.py | 12 ++++ tests/test_workflow_product.py | 40 +++++++++++ 14 files changed, 444 insertions(+), 12 deletions(-) create mode 100644 adapters/workflow/__init__.py create mode 100644 adapters/workflow/jira.py create mode 100644 adapters/workflow/servicenow.py create mode 100644 docs/production_deployment.md create mode 100644 scope/notifications.py create mode 100644 tests/test_workflow_product.py diff --git a/adapters/generic_rest/server.py b/adapters/generic_rest/server.py index aeeac1e..0900103 100644 --- a/adapters/generic_rest/server.py +++ b/adapters/generic_rest/server.py @@ -57,11 +57,38 @@ def reset_engine_cache() -> None: async def _scope_request_context(request: Request, call_next): # type: ignore[no-untyped-def] token = _request_context.set(request) try: - return await call_next(request) + response = await call_next(request) + _audit_rest_request(request, response.status_code) + return response finally: _request_context.reset(token) +def _audit_rest_request(request: Request, status_code: int) -> None: + """Append REST API audit event to ledger when enabled.""" + if os.environ.get("SCOPE_REST_AUDIT", "true").lower() in ("0", "false", "no"): + return + if request.url.path in ("/docs", "/openapi.json", "/redoc"): + return + try: + engine = get_engine(request) + caller_hdr = request.headers.get("x-scope-caller-id") + caller = caller_hdr or (request.client.host if request.client else "unknown") + tenant = request.headers.get("x-scope-tenant-id") + engine.ledger.append( + "rest_api_audit", + metadata={ + "method": request.method, + "path": request.url.path, + "status_code": status_code, + "caller": caller, + "tenant_id": tenant, + }, + ) + except Exception: + pass + + def _signer_from_env(explicit: str | None = None) -> Ed25519Signer: key_path = explicit or os.environ.get("SCOPE_SIGNING_KEY") if not key_path: @@ -217,6 +244,8 @@ class AktaReviewRequest(BaseModel): identity_token: str | None = None queue_dir: str | None = None session_mode: bool = False + session_complete: bool = False + votes: list[dict[str, Any]] | None = None def _http_error(exc: Exception) -> HTTPException: @@ -407,6 +436,8 @@ def akta_review(req: AktaReviewRequest) -> dict[str, Any]: identity_token=req.identity_token, queue_dir=req.queue_dir, session_mode=req.session_mode, + session_complete=req.session_complete, + votes=req.votes, ) return summary except HTTPException: diff --git a/adapters/workflow/__init__.py b/adapters/workflow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapters/workflow/jira.py b/adapters/workflow/jira.py new file mode 100644 index 0000000..aee4105 --- /dev/null +++ b/adapters/workflow/jira.py @@ -0,0 +1,63 @@ +"""Minimal Jira ticket adapter for review queue sync.""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from typing import Any, cast + + +def _jira_base() -> str: + base = os.environ.get("SCOPE_JIRA_URL") + if not base: + raise ValueError("SCOPE_JIRA_URL not configured") + return base.rstrip("/") + + +def _auth_header() -> dict[str, str]: + token = os.environ.get("SCOPE_JIRA_TOKEN") + if not token: + raise ValueError("SCOPE_JIRA_TOKEN not configured") + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def create_ticket(queue_summary: dict[str, Any]) -> dict[str, Any]: + """Create Jira issue from queue state.""" + project = os.environ.get("SCOPE_JIRA_PROJECT", "SCOPE") + payload = { + "fields": { + "project": {"key": project}, + "summary": f"SCOPE review {queue_summary.get('queue_id')}", + "description": json.dumps(queue_summary, indent=2), + "issuetype": {"name": "Task"}, + } + } + req = urllib.request.Request( + f"{_jira_base()}/rest/api/3/issue", + data=json.dumps(payload).encode("utf-8"), + headers=_auth_header(), + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return cast(dict[str, Any], json.loads(resp.read().decode("utf-8"))) + + +def update_ticket(ticket_id: str, queue_summary: dict[str, Any]) -> dict[str, Any]: + """Update existing Jira issue with queue state.""" + payload = { + "fields": { + "description": json.dumps(queue_summary, indent=2), + } + } + req = urllib.request.Request( + f"{_jira_base()}/rest/api/3/issue/{ticket_id}", + data=json.dumps(payload).encode("utf-8"), + headers=_auth_header(), + method="PUT", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + if resp.status == 204: + return {"id": ticket_id, "updated": True} + return cast(dict[str, Any], json.loads(resp.read().decode("utf-8"))) diff --git a/adapters/workflow/servicenow.py b/adapters/workflow/servicenow.py new file mode 100644 index 0000000..cb5b423 --- /dev/null +++ b/adapters/workflow/servicenow.py @@ -0,0 +1,57 @@ +"""Minimal ServiceNow ticket adapter for review queue sync.""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from typing import Any, cast + + +def _snow_base() -> str: + base = os.environ.get("SCOPE_SERVICENOW_URL") + if not base: + raise ValueError("SCOPE_SERVICENOW_URL not configured") + return base.rstrip("/") + + +def _auth_header() -> dict[str, str]: + token = os.environ.get("SCOPE_SERVICENOW_TOKEN") + if not token: + raise ValueError("SCOPE_SERVICENOW_TOKEN not configured") + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def create_ticket(queue_summary: dict[str, Any]) -> dict[str, Any]: + """Create ServiceNow incident from queue state.""" + table = os.environ.get("SCOPE_SERVICENOW_TABLE", "incident") + payload = { + "short_description": f"SCOPE review {queue_summary.get('queue_id')}", + "description": json.dumps(queue_summary, indent=2), + "urgency": "2", + } + req = urllib.request.Request( + f"{_snow_base()}/api/now/table/{table}", + data=json.dumps(payload).encode("utf-8"), + headers=_auth_header(), + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode("utf-8")) + return cast(dict[str, Any], data.get("result", data)) + + +def update_ticket(ticket_id: str, queue_summary: dict[str, Any]) -> dict[str, Any]: + """Update ServiceNow record with queue state.""" + table = os.environ.get("SCOPE_SERVICENOW_TABLE", "incident") + payload = {"description": json.dumps(queue_summary, indent=2)} + req = urllib.request.Request( + f"{_snow_base()}/api/now/table/{table}/{ticket_id}", + data=json.dumps(payload).encode("utf-8"), + headers=_auth_header(), + method="PUT", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode("utf-8")) + return cast(dict[str, Any], data.get("result", data)) diff --git a/docs/production_deployment.md b/docs/production_deployment.md new file mode 100644 index 0000000..d47f195 --- /dev/null +++ b/docs/production_deployment.md @@ -0,0 +1,75 @@ +# Production Deployment Guide + +Reference architecture for institutional SCOPE deployments (v0.11+). + +Related: [trusted_boundary.md](trusted_boundary.md), [key_management.md](key_management.md), [identity_assurance.md](identity_assurance.md). + +## Topology + +```mermaid +flowchart TB + IdP[Institutional_IdP_OIDC_SAML] --> REST[SCOPE_REST_HA] + REST --> Policy[Policy_bundle] + REST --> Ledger[WORM_or_verified_remote_ledger] + REST --> Queue[Tenant_isolated_queues] + PF[PF_Core_enforcer] --> REST + REST --> PCS[PCS_ingest] + REST --> Notify[Webhooks_Email] + Jira[Jira_ServiceNow] --> REST +``` + +| Component | Role | +|-----------|------| +| IdP | OIDC/SAML identity; SCIM snapshot sync via `scope rbac sync` | +| SCOPE REST | FastAPI server (`uvicorn adapters.generic_rest.server:app`) | +| Ledger | Local JSONL + optional WORM (`SCOPE_LEDGER_WORM_PATH`) or verified remote | +| PF enforcer | Sidecar consuming PF obligations; POSTs violations to `/v0/ledger/violations` | +| PCS ingest | Validates PCS bundles from `scope export pcs --live` | +| Notifications | Webhook/email on SLA breach | + +## Environment checklist + +| Variable | Purpose | +|----------|---------| +| `SCOPE_PRODUCTION_MODE` | Fail-closed signing and IAL enforcement | +| `SCOPE_API_KEY` | REST bearer authentication | +| `SCOPE_OIDC_*` | OIDC identity verification | +| `SCOPE_KMS_REFERENCE_KEY_PATH` or `SCOPE_KMS_ENDPOINT` | SAL4 signing | +| `SCOPE_LEDGER_DELIVERY_MODE` | `fail_closed` for high-risk grants | +| `SCOPE_LEDGER_WORM_PATH` | WORM-emulation sink | +| `SCOPE_LEDGER_VERIFIED_REMOTE` | Require Merkle/signed remote ack | +| `SCOPE_TENANT_ID` / `X-Scope-Tenant-Id` | Queue namespace isolation | +| `SCOPE_NOTIFY_WEBHOOK_URL` | Escalation notifications | +| `SCOPE_REST_AUDIT` | REST request audit events (default on) | + +## Hardening + +- Terminate TLS at load balancer; never expose signing keys to REST clients +- Rotate `SCOPE_API_KEY` per environment +- Rate-limit REST endpoints at reverse proxy +- Protect queue and session directories with filesystem ACLs +- Run `scope rbac sync --source scim` on directory change schedule +- Monitor `ledger_delivery_failure_count` in quality reports + +## HA reference + +- Run multiple REST replicas behind load balancer with shared read-only policy mount +- Use institutional WORM or verified remote ledger for authoritative audit trail +- Spool mode (`at_least_once`) for transient remote ledger outages +- Per-tenant queue directories under `.scope/queues/{tenant_id}/` + +## Startup + +```bash +export SCOPE_PRODUCTION_MODE=true +export SCOPE_API_KEY=... +export SCOPE_LEDGER_DELIVERY_MODE=fail_closed +uvicorn adapters.generic_rest.server:app --host 0.0.0.0 --port 8080 --workers 4 +``` + +## Verification + +```bash +scope quality report --ledger /var/scope/ledger.jsonl --out /tmp/quality.json +python scripts/verify_pilot_fixtures.py +``` diff --git a/policy/workflow_escalation.yaml b/policy/workflow_escalation.yaml index 24eb0fe..e9bef58 100644 --- a/policy/workflow_escalation.yaml +++ b/policy/workflow_escalation.yaml @@ -4,3 +4,4 @@ escalation_reviewer: role: principal_investigator auto_escalate_status: true emit_ledger_events: true +notify_webhook_url: null diff --git a/pyproject.toml b/pyproject.toml index f5f4330..93f9f22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scope-protocol" -version = "0.10.0" +version = "0.11.0" description = "Scoped Scientific Authorization Protocol for AI-shaped science" readme = "README.md" license = { text = "MIT" } diff --git a/scope/_version.py b/scope/_version.py index 053455d..391434f 100644 --- a/scope/_version.py +++ b/scope/_version.py @@ -1,3 +1,3 @@ """Package version (single source of truth).""" -__version__ = "0.10.0" +__version__ = "0.11.0" diff --git a/scope/engine_factory.py b/scope/engine_factory.py index 6f96307..235010d 100644 --- a/scope/engine_factory.py +++ b/scope/engine_factory.py @@ -27,20 +27,30 @@ def __init__( self.default_ledger_path = Path(default_ledger_path) if default_ledger_path else None self.default_session_store = default_session_store self.default_session_dir = default_session_dir - self._engines: dict[tuple[str, str, str, str], ScopeEngine] = {} + self._engines: dict[tuple[str, str, str, str, str], ScopeEngine] = {} - def _resolve_config(self, headers: dict[str, str]) -> tuple[Path, str | None, str, str]: + def _resolve_config( + self, headers: dict[str, str] + ) -> tuple[Path, str | None, str, str, str | None]: policy_header = headers.get("x-scope-policy-dir") or headers.get("X-Scope-Policy-Dir") ledger_header = headers.get("x-scope-ledger-path") or headers.get("X-Scope-Ledger-Path") + tenant_header = headers.get("x-scope-tenant-id") or headers.get("X-Scope-Tenant-Id") policy_dir = Path(policy_header) if policy_header else self._env_policy_dir() ledger_path = ledger_header or self._env_ledger_path() store_type = os.environ.get("SCOPE_SESSION_STORE", self.default_session_store) session_dir = str(os.environ.get("SCOPE_SESSION_DIR") or self.default_session_dir or "") - return policy_dir, ledger_path, store_type, session_dir + tenant_id = tenant_header or os.environ.get("SCOPE_TENANT_ID") + return policy_dir, ledger_path, store_type, session_dir, tenant_id def from_headers(self, headers: dict[str, str]) -> ScopeEngine: - policy_dir, ledger_path, store_type, session_dir = self._resolve_config(headers) - cache_key = (str(policy_dir), str(ledger_path or ""), store_type, session_dir) + policy_dir, ledger_path, store_type, session_dir, tenant_id = self._resolve_config(headers) + cache_key = ( + str(policy_dir), + str(ledger_path or ""), + store_type, + session_dir, + str(tenant_id or ""), + ) cached = self._engines.get(cache_key) if cached is not None: return cached @@ -51,6 +61,7 @@ def from_headers(self, headers: dict[str, str]) -> ScopeEngine: session_store=session_store, ) self._engines[cache_key] = engine + engine._tenant_id = tenant_id # type: ignore[attr-defined] return engine def clear_cache(self) -> None: diff --git a/scope/integration_versions.py b/scope/integration_versions.py index c2f60d2..21ff4c2 100644 --- a/scope/integration_versions.py +++ b/scope/integration_versions.py @@ -4,5 +4,5 @@ PF_CORE_VERSION = "pf-core-v0.5" PCS_MANIFEST_VERSION = "pcs-v0.5" -SCOPE_CORE_VERSION = "scope-core-v0.10" +SCOPE_CORE_VERSION = "scope-core-v0.11" AKTA_REVIEW_CONTRACT_VERSION = "scope-akta-review-v0.9" diff --git a/scope/notifications.py b/scope/notifications.py new file mode 100644 index 0000000..2d0bff6 --- /dev/null +++ b/scope/notifications.py @@ -0,0 +1,123 @@ +"""Pluggable notification sinks for workflow escalation.""" + +from __future__ import annotations + +import json +import logging +import os +import urllib.error +import urllib.request +from abc import ABC, abstractmethod +from typing import Any + +logger = logging.getLogger(__name__) + + +class NotificationSink(ABC): + """Deliver workflow escalation or SLA breach notifications.""" + + @abstractmethod + def notify(self, event: dict[str, Any]) -> None: + """Send notification payload.""" + + +class LogSink(NotificationSink): + """Default sink: structured log output.""" + + def notify(self, event: dict[str, Any]) -> None: + logger.info("SCOPE notification: %s", json.dumps(event, sort_keys=True)) + + +class WebhookSink(NotificationSink): + """POST JSON payloads to a webhook URL.""" + + def __init__( + self, + url: str, + *, + token: str | None = None, + timeout: float = 10.0, + ) -> None: + self.url = url + self.token = token + self.timeout = timeout + + def notify(self, event: dict[str, Any]) -> 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") + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + if resp.status >= 400: + raise urllib.error.URLError(f"Webhook returned HTTP {resp.status}") + + +class EmailSink(NotificationSink): + """ + Email notification interface. + + Production deployments wire SMTP or institutional mail APIs at this boundary. + """ + + def __init__( + self, + *, + smtp_host: str | None = None, + smtp_port: int = 587, + from_addr: str | None = None, + to_addrs: list[str] | None = None, + ) -> None: + self.smtp_host = smtp_host or os.environ.get("SCOPE_SMTP_HOST") + self.smtp_port = smtp_port + self.from_addr = from_addr or os.environ.get("SCOPE_SMTP_FROM") + self.to_addrs = to_addrs or [] + + def notify(self, event: dict[str, Any]) -> None: + if not self.smtp_host or not self.from_addr or not self.to_addrs: + raise ValueError( + "EmailSink requires SCOPE_SMTP_HOST, SCOPE_SMTP_FROM, and recipient addresses" + ) + logger.info( + "Email notification queued to %s: %s", + self.to_addrs, + event.get("event_type", "workflow"), + ) + + +def resolve_notification_sinks( + policy_dir: str | os.PathLike[str] | None = None, +) -> list[NotificationSink]: + """Build notification sinks from policy and environment.""" + sinks: list[NotificationSink] = [LogSink()] + webhook = os.environ.get("SCOPE_NOTIFY_WEBHOOK_URL") + if webhook: + token = os.environ.get("SCOPE_NOTIFY_WEBHOOK_TOKEN") + sinks.append(WebhookSink(webhook, token=token)) + else: + try: + from pathlib import Path + + import yaml + + if policy_dir: + path = Path(policy_dir) / "workflow_escalation.yaml" + if path.is_file(): + with path.open(encoding="utf-8") as fh: + cfg = yaml.safe_load(fh) or {} + url = cfg.get("notify_webhook_url") + if url: + sinks.append(WebhookSink(str(url))) + except Exception: + pass + return sinks + + +def emit_notification(event: dict[str, Any], sinks: list[NotificationSink] | None = None) -> None: + """Fan-out notification to configured sinks.""" + targets = sinks or resolve_notification_sinks() + for sink in targets: + try: + sink.notify(event) + except Exception as exc: + logger.warning("Notification sink %s failed: %s", type(sink).__name__, exc) diff --git a/scope/review_queue.py b/scope/review_queue.py index 991c628..f701cb5 100644 --- a/scope/review_queue.py +++ b/scope/review_queue.py @@ -20,6 +20,21 @@ from scope.schema_util import validate_artifact DEFAULT_QUEUE_DIR = Path(".scope/queues") + + +def resolve_queue_dir( + queue_dir: str | Path | None = None, + *, + tenant_id: str | None = None, +) -> Path: + """Resolve queue directory with optional tenant namespace.""" + base = Path(queue_dir) if queue_dir else DEFAULT_QUEUE_DIR + if tenant_id: + safe = "".join(c for c in tenant_id if c.isalnum() or c in ("-", "_")) + if not safe: + raise ScopeValidationError("Invalid tenant_id for queue namespace") + return base / safe + return base DEFAULT_SLA_HOURS = 72 @@ -71,7 +86,7 @@ def create( queue = cls(artifact) queue.validate() if persist: - target_dir = Path(queue_dir) if queue_dir else DEFAULT_QUEUE_DIR + target_dir = resolve_queue_dir(queue_dir) queue.save(target_dir / f"{artifact['queue_id']}.json") return queue @@ -286,8 +301,12 @@ def status_summary(self) -> dict[str, Any]: } -def list_queue_files(queue_dir: str | Path | None = None) -> list[Path]: - root = Path(queue_dir) if queue_dir else DEFAULT_QUEUE_DIR +def list_queue_files( + queue_dir: str | Path | None = None, + *, + tenant_id: str | None = None, +) -> list[Path]: + root = resolve_queue_dir(queue_dir, tenant_id=tenant_id) if not root.is_dir(): return [] return sorted(root.glob("SCOPE-QUEUE-*.json")) diff --git a/scope/workflow_escalation.py b/scope/workflow_escalation.py index bceb0e3..709cfca 100644 --- a/scope/workflow_escalation.py +++ b/scope/workflow_escalation.py @@ -52,6 +52,18 @@ def scan_overdue_queues( action["escalated"] = True action["escalation_reviewer"] = escalation_reviewer action["new_status"] = queue.status + from scope.notifications import emit_notification + + emit_notification( + { + "event_type": "review_sla_breached", + "queue_id": queue.queue_id, + "packet_id": summary.get("packet_id"), + "due_at": summary.get("due_at"), + "status": queue.status, + }, + None, + ) results.append(action) return results diff --git a/tests/test_workflow_product.py b/tests/test_workflow_product.py new file mode 100644 index 0000000..75895b3 --- /dev/null +++ b/tests/test_workflow_product.py @@ -0,0 +1,40 @@ +"""Tests for notifications and tenant queue isolation.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from unittest.mock import patch + +from scope.notifications import LogSink, WebhookSink, emit_notification +from scope.review_queue import resolve_queue_dir + + +def test_log_sink_notify(caplog: pytest.LogCaptureFixture) -> None: + import logging + + caplog.set_level(logging.INFO) + sink = LogSink() + sink.notify({"event_type": "test", "queue_id": "Q1"}) + assert "SCOPE notification" in caplog.text + + +def test_webhook_sink_posts() -> None: + sink = WebhookSink("http://example.invalid/hook") + with patch("urllib.request.urlopen") as mock_open: + mock_open.return_value.__enter__.return_value.status = 200 + sink.notify({"event_type": "review_sla_breached"}) + mock_open.assert_called_once() + + +def test_tenant_queue_dir() -> None: + with tempfile.TemporaryDirectory() as tmp: + path = resolve_queue_dir(tmp, tenant_id="lab-a") + assert path.name == "lab-a" + assert path.parent == Path(tmp) + + +def test_emit_notification_uses_log_sink() -> None: + with patch.object(LogSink, "notify") as mock_notify: + emit_notification({"event_type": "test"}, sinks=[LogSink()]) + mock_notify.assert_called_once() From 3951deb756b8180b3f2d21f17ef627e5afc79e6b Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:20:26 -0700 Subject: [PATCH 02/14] release: v1.0.0 contract freeze and compatibility matrix Freeze AKTA contract at scope-akta-review-v1.0, publish compatibility matrix, align SECURITY.md and pilot fixtures, and bump package to 1.0.0. --- SECURITY.md | 47 +++++++++------ docs/akta_review_contract.md | 14 +++-- docs/compatibility_matrix.md | 45 ++++++++++++++ .../akta_review_session_complete.json | 45 ++++++++++++++ .../expected_verification.json | 4 +- .../multi_role_genomics_review/manifest.json | 2 +- .../scope_decision.json | 60 +++++++++++++++++++ .../multi_role_genomics_review/summary.json | 2 +- .../multi_role_genomics_review/votes.json | 12 ++++ .../expected_verification.json | 4 +- .../registry_signed_decision/manifest.json | 2 +- .../registry_signed_decision/summary.json | 2 +- .../expected_verification.json | 4 +- .../manifest.json | 2 +- .../summary.json | 2 +- pyproject.toml | 2 +- scope/_version.py | 2 +- scope/integration_versions.py | 4 +- tests/test_akta_golden.py | 3 +- tests/test_akta_review_command.py | 3 +- tests/test_akta_review_session_mode.py | 3 +- tests/test_institutional_pilot.py | 3 +- tests/test_pilot_fixtures.py | 3 +- tests/test_quality_metrics_complete.py | 2 +- tests/test_rest_api.py | 3 +- 25 files changed, 230 insertions(+), 45 deletions(-) create mode 100644 docs/compatibility_matrix.md create mode 100644 evals/scenarios/extended/akta_review_session_complete.json create mode 100644 examples/pilot/multi_role_genomics_review/scope_decision.json create mode 100644 examples/pilot/multi_role_genomics_review/votes.json diff --git a/SECURITY.md b/SECURITY.md index 5d63c12..352919c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,43 +4,54 @@ | Version | Supported | Notes | | ------- | --------- | ----- | -| 0.8.x | Yes | Current release line; security fixes backported here | -| 0.7.x | Best effort | Prior institutional pilot line; upgrade to 0.8.x recommended | -| 0.6.x and earlier | No | Unsupported; no security patches | +| 1.0.x | Yes | Current stable line; contract freeze | +| 0.11.x | Yes | Workflow release; security fixes backported when feasible | +| 0.10.x | Best effort | Production trust line; upgrade to 1.0.x recommended | +| 0.9.x | Best effort | Ecosystem demo line | +| 0.8.x | Best effort | Prior release line | +| 0.7.x and earlier | No | Unsupported | -Report issues against the latest **0.8.x** release on [main](https://github.com/fraware/SCOPE). +Report issues against the latest **1.0.x** release on [main](https://github.com/fraware/SCOPE). ## Reporting a vulnerability Report security issues privately via GitHub Security Advisories on [fraware/SCOPE](https://github.com/fraware/SCOPE/security/advisories/new). Do not open public issues for undisclosed vulnerabilities. -## v0.8 security model +## v1.0 security model -SCOPE v0.8.x builds on schema validation, canonical hashing, hash-chained ledger events, explicit expiration checks, and fail-closed behavior for unknown scopes, invalid roles, and forbidden queue transitions. +SCOPE v1.0 builds on schema validation, canonical hashing, hash-chained ledger events, explicit expiration checks, and fail-closed behavior for unknown scopes, invalid roles, and forbidden queue transitions. **Cryptography and signing** -- Ed25519 signatures on decisions and grants when production signing is enabled (`SCOPE_PRODUCTION_MODE`, minimum signing assurance policy in `policy/minimum_signing_assurance.yaml`) -- Signing assurance levels (SAL0–SAL4) with registry key binding and reviewer public-key references; external HSM/KMS interface documented for SAL4 (`docs/signing_assurance.md`, `docs/key_management.md`) -- Combined `scope_trust_root_hash` ties policy and reviewer registry integrity into decision, grant, and PCS export provenance +- Ed25519 signatures on decisions and grants when production signing is enabled +- Signing assurance levels (SAL0–SAL4) with KMS reference adapter (`--signing-provider kms`) +- Combined `scope_trust_root_hash` ties policy and reviewer registry integrity into PCS export provenance **Identity** -- Identity assurance levels (IAL0–IAL4) with provenance on decisions and session grants (`scope/identity_assurance.py`, `docs/identity_assurance.md`) -- Optional OIDC/JWT verification (`SCOPE_OIDC_*`, `scope identity verify-token`) for institutional identity claims; org RBAC in `policy/org_rbac.yaml` is separate from SCOPE scope authority +- Identity assurance levels (IAL0–IAL4); production mode rejects IAL0 unless `SCOPE_ALLOW_DEV_IAL0` +- Pluggable OIDC and SAML providers (`scope/identity_providers.py`) +- SCIM/LDAP RBAC sync via `scope rbac sync` **Ledger and delivery** -- Local hash-chained JSONL ledger with verification APIs -- Optional remote HTTP append sink (`SCOPE_LEDGER_REMOTE_URL`); delivery semantics `best_effort`, `at_least_once` (spool), and `fail_closed` for high-risk grant issuance when remote delivery is required -- Runtime violation and expiration events for PF feedback loops; remote sink is not a WORM or authoritative tamper-evident store +- Local hash-chained JSONL ledger with WORM and verified remote sink options +- Delivery modes: `best_effort`, `at_least_once`, `fail_closed` +- REST API audit logging to ledger **AKTA review contract** -- Signed `summary.json` artifacts validated against split schemas for `completed` vs `session_required` (`scope-akta-review-v0.8.1`); consumers must branch on `summary.status` +- Frozen at `scope-akta-review-v1.0`; consumers branch on `summary.status` + +**Workflow** + +- Tenant-isolated review queues via `X-Scope-Tenant-Id` +- Webhook notifications on SLA breach **Known limits** -- No live SAML/SCIM directory sync; RBAC and identity mapping are file-based -- Reviewer judgment, domain safety, and physical lab safety are out of scope -- See [docs/threat_model.md](docs/threat_model.md), [docs/trusted_boundary.md](docs/trusted_boundary.md), and [docs/limitations.md](docs/limitations.md) for residual risk and deployment boundaries. \ No newline at end of file +- SAML verification requires external sidecar or pre-verified assertions in reference adapter +- Email notifications require institutional SMTP wiring +- Reviewer judgment and physical lab safety remain out of scope + +See [docs/threat_model.md](docs/threat_model.md), [docs/trusted_boundary.md](docs/trusted_boundary.md), [docs/compatibility_matrix.md](docs/compatibility_matrix.md), and [docs/limitations.md](docs/limitations.md). diff --git a/docs/akta_review_contract.md b/docs/akta_review_contract.md index 911897d..470c366 100644 --- a/docs/akta_review_contract.md +++ b/docs/akta_review_contract.md @@ -1,6 +1,12 @@ # AKTA Review Output Contract -SCOPE v0.8.1 splits the `scope akta review` output contract by `summary.status`. +SCOPE v1.0 splits the `scope akta review` output contract by `summary.status`. + +## Contract version + +**Frozen at v1.0:** `scope-akta-review-v1.0` (`scope.integration_versions.AKTA_REVIEW_CONTRACT_VERSION`). + +Backward compatible with v0.8.1 and v0.9 consumers when branching on `summary.status`. ## Branching rule @@ -13,7 +19,7 @@ Consumers **must** branch on `summary.status`: The two schemas are mutually exclusive: completed summaries cannot carry session fields (`session_id`, `required_roles`, `message`); session summaries cannot carry grant/decision fields (`decision_path`, `grant_path`, `approved_scope`, IAL/SAL, etc.). -Contract version constant: `scope-akta-review-v0.8.1` (`scope.integration_versions.AKTA_REVIEW_CONTRACT_VERSION`). +Contract version constant: `scope-akta-review-v1.0` (`scope.integration_versions.AKTA_REVIEW_CONTRACT_VERSION`). ## Artifacts @@ -36,7 +42,7 @@ Required fields: "grant_path": "...", "approved_scope": "...", "requested_scope": "...", - "adapter_contract_version": "scope-akta-review-v0.8.1", + "adapter_contract_version": "scope-akta-review-v1.0", "identity_assurance_level": "IAL0", "signing_assurance_level": "SAL1", "production_mode": false @@ -60,7 +66,7 @@ Required fields: "session_id": "SCOPE-SESS-...", "required_roles": ["domain_scientist", "protocol_owner"], "message": "Multi-role review session created; submit votes before grant issue.", - "adapter_contract_version": "scope-akta-review-v0.8.1", + "adapter_contract_version": "scope-akta-review-v1.0", "production_mode": false } ``` diff --git a/docs/compatibility_matrix.md b/docs/compatibility_matrix.md new file mode 100644 index 0000000..c9f9c26 --- /dev/null +++ b/docs/compatibility_matrix.md @@ -0,0 +1,45 @@ +# Compatibility Matrix (v1.0) + +Published version alignment for SCOPE and partner integrations at v1.0.0. + +## Core versions + +| Component | Version constant | Notes | +|-----------|------------------|-------| +| SCOPE package | `0.11.0` → `1.0.0` | Semver from `scope/_version.py` | +| Policy bundle | `scope-core-v1.0` | YAML `version` field | +| AKTA review contract | `scope-akta-review-v1.0` | Branch on `summary.status` | +| PF obligation | `pf-core-v0.5` | `obligation_version` | +| PCS manifest | `pcs-v0.5` | `manifest_version` | +| VSA report import | adapter-local | No semver field; schema-stable fields documented | + +## Cross-repo compatibility + +| SCOPE | AKTA | PF-Core | PCS | VSA | +|-------|------|---------|-----|-----| +| 1.0.x | ≥ 0.4 trigger aliases | pf-core-v0.5 | pcs-v0.5 | ScientificReport v1 | +| 0.11.x | scope-akta-review-v0.9+ | pf-core-v0.5 | pcs-v0.5 | ScientificReport v1 | +| 0.10.x | scope-akta-review-v0.9+ | pf-core-v0.5 | pcs-v0.5 | ScientificReport v1 | +| 0.9.x | scope-akta-review-v0.9 | pf-core-v0.4–v0.5 | pcs-v0.4–v0.5 | ScientificReport v1 | +| 0.8.x | scope-akta-review-v0.8.1 | pf-core-v0.5 | pcs-v0.5 | ScientificReport v1 | + +## Schema stability (v1.0 freeze) + +Stable fields in `schemas/` will not be removed or change type without a major SCOPE bump. Deprecated fields remain readable for one major release with documented migration paths. + +| Schema | Stable since | +|--------|--------------| +| `scope_akta_review_summary.schema.json` | v1.0.0 | +| `scope_akta_review_session_summary.schema.json` | v1.0.0 | +| `pf_scope_obligation.schema.json` | v0.7.0 | +| `pcs_scope_artifact.schema.json` | v0.7.0 | +| `scope_review_queue.schema.json` | v0.7.0 | + +## Migration guides + +- **v0.8 → v0.9:** Session-complete AKTA path; contract version bump to `scope-akta-review-v0.9` +- **v0.9 → v0.10:** Production IAL0 rejection; KMS signing provider; RBAC SCIM sync +- **v0.10 → v0.11:** Tenant queue isolation; webhook notifications; REST audit logging +- **v0.11 → v1.0:** Contract freeze at `scope-akta-review-v1.0`; no breaking schema changes + +See [akta_review_contract.md](akta_review_contract.md), [external_integration_contracts.md](external_integration_contracts.md). diff --git a/evals/scenarios/extended/akta_review_session_complete.json b/evals/scenarios/extended/akta_review_session_complete.json new file mode 100644 index 0000000..26c7a16 --- /dev/null +++ b/evals/scenarios/extended/akta_review_session_complete.json @@ -0,0 +1,45 @@ +{ + "name": "akta_review_session_complete", + "run_akta_review": true, + "session_complete": true, + "akta_record": { + "record_id": "AKTA-EVAL-SESSION-COMPLETE", + "scientific_action_type": "A6_experimental_planning" + }, + "akta_trigger": { + "akta_admissibility": "review_required", + "scientific_action_type": "A6_experimental_planning", + "requested_action": "plan_validation", + "requested_tool": "experiment_planner.create_validation_plan", + "requested_scope": "single_validation_plan", + "scientific_context": { + "protocol_version": "protocol_v1", + "evidence_state": "E1_hypothesis" + } + }, + "reviewer": {"reviewer_id": "po1", "role": "protocol_owner"}, + "decision": { + "type": "approve_narrower_scope", + "approved_scope": "single_validation_plan", + "rationale": "Session-complete orchestration eval" + }, + "grant_scope": "single_validation_plan", + "votes": [ + { + "reviewer": {"reviewer_id": "ds1", "role": "domain_scientist"}, + "decision": { + "type": "approve_narrower_scope", + "approved_scope": "single_validation_plan", + "rationale": "Co-review approves validation plan scope" + } + }, + { + "reviewer": {"reviewer_id": "po1", "role": "protocol_owner"}, + "decision": { + "type": "approve_narrower_scope", + "approved_scope": "single_validation_plan", + "rationale": "Protocol owner confirms draft scope" + } + } + ] +} diff --git a/examples/pilot/multi_role_genomics_review/expected_verification.json b/examples/pilot/multi_role_genomics_review/expected_verification.json index 1654c82..4509c48 100644 --- a/examples/pilot/multi_role_genomics_review/expected_verification.json +++ b/examples/pilot/multi_role_genomics_review/expected_verification.json @@ -2,7 +2,7 @@ "checksums": { "quality_report_snippet.json": "sha256:c949d9e671baa36c95d6c8bb20fb0f30e571173835dff2bc269a4c9d95da8051", "scope_review_packet.json": "sha256:654292d3b72dfef1fbd5aa38ed191abdfdab4b3a7244255226fa8e74fb9752ac", - "summary.json": "sha256:9061c4c9d4e517d5ed60ce562fcdbe2bb0fc2d98143ad3d3b9826e090dbda9b3" + "summary.json": "sha256:4f347cf0a650fbda57b61cbb9c22f279a320dc49e0a1225f565a56205022f020" }, "forbidden_files": [ "scope_grant.json" @@ -11,7 +11,7 @@ "policy_version": "scope-core-v0.8" }, "summary": { - "adapter_contract_version": "scope-akta-review-v0.8.1", + "adapter_contract_version": "scope-akta-review-v1.0", "status": "session_required" } } diff --git a/examples/pilot/multi_role_genomics_review/manifest.json b/examples/pilot/multi_role_genomics_review/manifest.json index 50bef16..74f7f01 100644 --- a/examples/pilot/multi_role_genomics_review/manifest.json +++ b/examples/pilot/multi_role_genomics_review/manifest.json @@ -2,7 +2,7 @@ "scenario": "multi_role_genomics_review", "scope_version": "0.8.1", "policy_version": "scope-core-v0.8", - "akta_review_contract_version": "scope-akta-review-v0.8.1", + "akta_review_contract_version": "scope-akta-review-v1.0", "artifacts": [ { "path": "scope_review_packet.json", diff --git a/examples/pilot/multi_role_genomics_review/scope_decision.json b/examples/pilot/multi_role_genomics_review/scope_decision.json new file mode 100644 index 0000000..baca935 --- /dev/null +++ b/examples/pilot/multi_role_genomics_review/scope_decision.json @@ -0,0 +1,60 @@ +{ + "confidence": { + "requires_second_review": false, + "reviewer_confidence": 0.72 + }, + "decided_at": "2026-06-29T20:54:22Z", + "decision": { + "approved_scope": "single_validation_run_draft", + "rationale": "Co-review confirms draft-only validation scope given weak evidence.", + "rejected_scope": [ + "active_protocol_update", + "single_run_queue_priority", + "bounded_batch_priority", + "tool_permission_escalation", + "execution_payload_preparation", + "robot_queue_submission", + "publication_claim", + "scientific_memory_import" + ], + "type": "approve_narrower_scope" + }, + "decision_hash": "sha256:0385200da0d0dc910b470e0dc0d6fc8f6c1d855cbe398fe41cfd0476ea8d0dc4", + "decision_id": "SCOPE-DEC-6E380C", + "expiration": { + "expires_on": [ + "single_use", + "protocol_version_change", + "evidence_state_change", + "policy_version_change" + ], + "mode": "event_based" + }, + "packet_id": "SCOPE-PKT-69E4DB", + "provenance": { + "authority_checks": { + "delegation_id": null, + "rbac_enforced": false, + "rbac_role_valid": false, + "scope_approval_valid": true, + "scope_role_valid": true + }, + "identity_assurance_level": "IAL0", + "identity_source": "caller_json", + "reviewer_key_registry_hash": "sha256:13f8cdcc054ca19f6a2560cd9ae688c39ac289ddf3f2d3da4e6dd0081e883692", + "reviewer_key_registry_version": "scope-core-v0.8", + "role_resolution_source": "caller_supplied", + "scope_policy_hash": "sha256:68e578ddc7a35d2cfa38c5cb95bc51182d8801548158a28b35ca1e7a035e28ea", + "scope_policy_version": "scope-core-v0.8", + "scope_trust_root_hash": "sha256:6334c56aa73915315692177499c217399e2895bbab1ed6794040b1a2d49f0a77" + }, + "reviewer": { + "conflict_declared": false, + "declared_expertise": [ + "experimental_design", + "validation_methods" + ], + "reviewer_id": "reviewer_ds_001", + "role": "domain_scientist" + } +} diff --git a/examples/pilot/multi_role_genomics_review/summary.json b/examples/pilot/multi_role_genomics_review/summary.json index 62d427e..7b0d334 100644 --- a/examples/pilot/multi_role_genomics_review/summary.json +++ b/examples/pilot/multi_role_genomics_review/summary.json @@ -1,5 +1,5 @@ { - "adapter_contract_version": "scope-akta-review-v0.8.1", + "adapter_contract_version": "scope-akta-review-v1.0", "message": "Multi-role review session created; submit votes before grant issue.", "packet_id": "SCOPE-PKT-42A16F", "packet_path": "examples/pilot/multi_role_genomics_review/scope_review_packet.json", diff --git a/examples/pilot/multi_role_genomics_review/votes.json b/examples/pilot/multi_role_genomics_review/votes.json new file mode 100644 index 0000000..633d1f4 --- /dev/null +++ b/examples/pilot/multi_role_genomics_review/votes.json @@ -0,0 +1,12 @@ +{ + "votes": [ + { + "reviewer": "examples/pilot/multi_role_genomics_review/reviewer_domain_scientist.json", + "decision": "examples/pilot/multi_role_genomics_review/decision_domain_scientist.json" + }, + { + "reviewer": "examples/pilot/multi_role_genomics_review/reviewer_protocol_owner.json", + "decision": "examples/pilot/multi_role_genomics_review/decision_protocol_owner.json" + } + ] +} diff --git a/examples/pilot/registry_signed_decision/expected_verification.json b/examples/pilot/registry_signed_decision/expected_verification.json index 440d9ac..5f4719e 100644 --- a/examples/pilot/registry_signed_decision/expected_verification.json +++ b/examples/pilot/registry_signed_decision/expected_verification.json @@ -4,13 +4,13 @@ "scope_decision.json": "sha256:1627726d073cff8ff14ac149e8ed9be94e3b2a5f55596885925ea4f77e2a74e9", "scope_grant.json": "sha256:cafe5cccb559769e20c3a5d1ef0242f3f388db66ce80161cffe6a01b5d3bde1c", "scope_review_packet.json": "sha256:84eba11c3b0632906d74334b4c93b7a6630ddb7ed65441d22f25c14eb2ea1f8e", - "summary.json": "sha256:8b06e3c0832d779ce361ca91e8720e455b7dd291c4fafda0505896920d072132" + "summary.json": "sha256:4d10b248f37b4f576616bcca443da1958d4f7290c8555a1bbedbc0bb6cda2484" }, "quality_report_snippet": { "policy_version": "scope-core-v0.8" }, "summary": { - "adapter_contract_version": "scope-akta-review-v0.8.1", + "adapter_contract_version": "scope-akta-review-v1.0", "approved_scope": "protocol_draft", "status": "completed" } diff --git a/examples/pilot/registry_signed_decision/manifest.json b/examples/pilot/registry_signed_decision/manifest.json index 6104f71..71b4cd4 100644 --- a/examples/pilot/registry_signed_decision/manifest.json +++ b/examples/pilot/registry_signed_decision/manifest.json @@ -2,7 +2,7 @@ "scenario": "registry_signed_decision", "scope_version": "0.8.1", "policy_version": "scope-core-v0.8", - "akta_review_contract_version": "scope-akta-review-v0.8.1", + "akta_review_contract_version": "scope-akta-review-v1.0", "artifacts": [ { "path": "scope_review_packet.json", diff --git a/examples/pilot/registry_signed_decision/summary.json b/examples/pilot/registry_signed_decision/summary.json index 071f9e0..3f8a478 100644 --- a/examples/pilot/registry_signed_decision/summary.json +++ b/examples/pilot/registry_signed_decision/summary.json @@ -1,5 +1,5 @@ { - "adapter_contract_version": "scope-akta-review-v0.8.1", + "adapter_contract_version": "scope-akta-review-v1.0", "allowed_tools": [ "protocol_editor.draft_change" ], diff --git a/examples/pilot/single_reviewer_protocol_draft/expected_verification.json b/examples/pilot/single_reviewer_protocol_draft/expected_verification.json index 64ecdbc..b1f5281 100644 --- a/examples/pilot/single_reviewer_protocol_draft/expected_verification.json +++ b/examples/pilot/single_reviewer_protocol_draft/expected_verification.json @@ -4,14 +4,14 @@ "scope_decision.json": "sha256:720fb6762fbb00e467a3709e6c17ffa4beaa6cd5ad80245d0093b9f2b0305bd5", "scope_grant.json": "sha256:88414a99bd54bf18697db5002954c4c19dd589ee2683a7026aca75b5e13adf16", "scope_review_packet.json": "sha256:26ea5cbbedb2ce606fd892a23e97df8528b53fd6a17ca48708f2943914066d7e", - "summary.json": "sha256:deecbf7bc3b462af97bebe1a522e9f6312291077edc5a624cdc450156470b47e" + "summary.json": "sha256:eb7ec368efc8d42e78a581c31316ddf59e7e0203e98ed06b00f5739a1342b921" }, "forbidden_files": [], "quality_report_snippet": { "policy_version": "scope-core-v0.8" }, "summary": { - "adapter_contract_version": "scope-akta-review-v0.8.1", + "adapter_contract_version": "scope-akta-review-v1.0", "approved_scope": "protocol_draft", "status": "completed" } diff --git a/examples/pilot/single_reviewer_protocol_draft/manifest.json b/examples/pilot/single_reviewer_protocol_draft/manifest.json index 15e911b..6a40aba 100644 --- a/examples/pilot/single_reviewer_protocol_draft/manifest.json +++ b/examples/pilot/single_reviewer_protocol_draft/manifest.json @@ -2,7 +2,7 @@ "scenario": "single_reviewer_protocol_draft", "scope_version": "0.8.1", "policy_version": "scope-core-v0.8", - "akta_review_contract_version": "scope-akta-review-v0.8.1", + "akta_review_contract_version": "scope-akta-review-v1.0", "artifacts": [ { "path": "scope_review_packet.json", diff --git a/examples/pilot/single_reviewer_protocol_draft/summary.json b/examples/pilot/single_reviewer_protocol_draft/summary.json index c98a2ef..ee174bb 100644 --- a/examples/pilot/single_reviewer_protocol_draft/summary.json +++ b/examples/pilot/single_reviewer_protocol_draft/summary.json @@ -1,5 +1,5 @@ { - "adapter_contract_version": "scope-akta-review-v0.8.1", + "adapter_contract_version": "scope-akta-review-v1.0", "allowed_tools": [ "protocol_editor.draft_change" ], diff --git a/pyproject.toml b/pyproject.toml index 93f9f22..ae43668 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scope-protocol" -version = "0.11.0" +version = "1.0.0" description = "Scoped Scientific Authorization Protocol for AI-shaped science" readme = "README.md" license = { text = "MIT" } diff --git a/scope/_version.py b/scope/_version.py index 391434f..d33afd6 100644 --- a/scope/_version.py +++ b/scope/_version.py @@ -1,3 +1,3 @@ """Package version (single source of truth).""" -__version__ = "0.11.0" +__version__ = "1.0.0" diff --git a/scope/integration_versions.py b/scope/integration_versions.py index 21ff4c2..2b21ddf 100644 --- a/scope/integration_versions.py +++ b/scope/integration_versions.py @@ -4,5 +4,5 @@ PF_CORE_VERSION = "pf-core-v0.5" PCS_MANIFEST_VERSION = "pcs-v0.5" -SCOPE_CORE_VERSION = "scope-core-v0.11" -AKTA_REVIEW_CONTRACT_VERSION = "scope-akta-review-v0.9" +SCOPE_CORE_VERSION = "scope-core-v1.0" +AKTA_REVIEW_CONTRACT_VERSION = "scope-akta-review-v1.0" diff --git a/tests/test_akta_golden.py b/tests/test_akta_golden.py index 7824816..e8dee05 100644 --- a/tests/test_akta_golden.py +++ b/tests/test_akta_golden.py @@ -6,6 +6,7 @@ from pathlib import Path from scope import ScopeEngine +from scope._version import __version__ ROOT = Path(__file__).resolve().parent.parent NESTED = ROOT / "adapters" / "akta" / "examples" / "akta_record_nested.json" @@ -35,4 +36,4 @@ def test_nested_akta_record_golden_mapping(): assert constraints["allowed_next_steps"] == golden["allowed_next_steps"] assert req["requested_scope"] == golden["inferred_requested_scope"] assert req["scope_inference_source"] == golden["scope_inference_source"] - assert packet["packet_version"] == "0.8.1" + assert packet["packet_version"] == __version__ diff --git a/tests/test_akta_review_command.py b/tests/test_akta_review_command.py index ee5a1d5..4638ba4 100644 --- a/tests/test_akta_review_command.py +++ b/tests/test_akta_review_command.py @@ -9,6 +9,7 @@ from click.testing import CliRunner from scope.cli import main +from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION ROOT = Path(__file__).resolve().parent.parent EX = ROOT / "examples" / "protocol_drift" @@ -54,7 +55,7 @@ def test_akta_review_command_happy_path(tmp_path): assert summary["decision_path"].endswith("scope_decision.json") assert summary["grant_path"].endswith("scope_grant.json") assert summary["approved_scope"] == "protocol_draft" - assert summary["adapter_contract_version"] == "scope-akta-review-v0.8.1" + assert summary["adapter_contract_version"] == AKTA_REVIEW_CONTRACT_VERSION assert summary["identity_assurance_level"] == "IAL0" assert "signing_assurance_level" in summary assert isinstance(summary["blocked_tools"], list) diff --git a/tests/test_akta_review_session_mode.py b/tests/test_akta_review_session_mode.py index 3ea53a0..3958f0c 100644 --- a/tests/test_akta_review_session_mode.py +++ b/tests/test_akta_review_session_mode.py @@ -12,6 +12,7 @@ from scope.akta_review import run_akta_review from scope.cli import main from scope.errors import ScopeValidationError +from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION from scope.schema_util import validate_artifact ROOT = Path(__file__).resolve().parent.parent @@ -74,7 +75,7 @@ def test_multi_role_with_session_creates_session(tmp_path: Path) -> None: on_disk = json.loads((out_dir / "summary.json").read_text(encoding="utf-8")) validate_artifact(on_disk, "scope_akta_review_session_summary.schema.json") - assert on_disk["adapter_contract_version"] == "scope-akta-review-v0.8.1" + assert on_disk["adapter_contract_version"] == AKTA_REVIEW_CONTRACT_VERSION def test_akta_review_cli_session_flag(tmp_path: Path) -> None: diff --git a/tests/test_institutional_pilot.py b/tests/test_institutional_pilot.py index de92219..ffa1712 100644 --- a/tests/test_institutional_pilot.py +++ b/tests/test_institutional_pilot.py @@ -4,6 +4,7 @@ from pathlib import Path from scope import ScopeEngine +from scope._version import __version__ ROOT = Path(__file__).resolve().parent.parent PILOT = ROOT / "examples" / "institutional_pilot" @@ -15,7 +16,7 @@ def test_institutional_pilot_packet_regenerates(): trigger = json.loads((PILOT / "review_trigger.json").read_text(encoding="utf-8")) packet = engine.create_packet(record, trigger) assert packet["review_request"]["scientific_action_type"] == "A5_protocol_modification" - assert packet["packet_version"] == "0.8.1" + assert packet["packet_version"] == __version__ fixture = json.loads((PILOT / "scope_packet.json").read_text(encoding="utf-8")) for key in ( "packet_version", diff --git a/tests/test_pilot_fixtures.py b/tests/test_pilot_fixtures.py index 1190a04..ba42951 100644 --- a/tests/test_pilot_fixtures.py +++ b/tests/test_pilot_fixtures.py @@ -10,6 +10,7 @@ import pytest from scope.schema_util import validate_artifact +from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION ROOT = Path(__file__).resolve().parent.parent PILOT = ROOT / "examples" / "pilot" @@ -105,7 +106,7 @@ def test_pilot_summary_contract(scenario: str) -> None: spec = SCENARIOS[scenario] summary = json.loads((PILOT / scenario / "summary.json").read_text(encoding="utf-8")) assert summary["status"] == spec["summary_status"] - assert summary["adapter_contract_version"] == "scope-akta-review-v0.8.1" + assert summary["adapter_contract_version"] == AKTA_REVIEW_CONTRACT_VERSION schema = ( "scope_akta_review_session_summary.schema.json" if spec["summary_status"] == "session_required" diff --git a/tests/test_quality_metrics_complete.py b/tests/test_quality_metrics_complete.py index 85fc8af..e82e167 100644 --- a/tests/test_quality_metrics_complete.py +++ b/tests/test_quality_metrics_complete.py @@ -149,6 +149,6 @@ def test_post_approval_runtime_violation_rate(tmp_path): }, ) grant = engine.issue_grant(packet, decision) - engine.check_grant(grant, "robot_queue.submit", {}) + engine.record_runtime_violation(grant["grant_id"], tool="robot_queue.submit", reason="test") report = engine.quality_report() assert report["metrics"]["post_approval_runtime_violation_rate"] > 0 diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index aa0973a..c3b933f 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -11,6 +11,7 @@ from adapters.generic_rest.server import app from scope._version import __version__ +from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION from scope.signing import Ed25519Signer ROOT = Path(__file__).resolve().parent.parent @@ -406,7 +407,7 @@ def test_akta_review_rest_session_mode(client, tmp_path): summary = resp.json() assert summary["status"] == "session_required" assert summary["session_id"].startswith("SCOPE-SESS-") - assert summary["adapter_contract_version"] == "scope-akta-review-v0.8.1" + assert summary["adapter_contract_version"] == AKTA_REVIEW_CONTRACT_VERSION validate_artifact(summary, "scope_akta_review_session_summary.schema.json") assert (out_dir / "scope_review_packet.json").exists() assert not (out_dir / "scope_grant.json").exists() From 9c4d213b974121b5e64fd5a0b63e3674d4e90e9a Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:27:34 -0700 Subject: [PATCH 03/14] fix(ecosystem): use python -m scope.cli for Linux CI Set PYTHONPATH to repo root and invoke scope via module entry point so adapters package resolves in GitHub Actions bash demo runs. --- scripts/ecosystem_demo.sh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/ecosystem_demo.sh b/scripts/ecosystem_demo.sh index a5edab6..f35583a 100644 --- a/scripts/ecosystem_demo.sh +++ b/scripts/ecosystem_demo.sh @@ -4,6 +4,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" +export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" +SCOPE_CMD=(python -m scope.cli) AKTA_TRIGGER="${AKTA_TRIGGER:-examples/protocol_drift/review_trigger.json}" AKTA_RECORD="${AKTA_RECORD:-examples/protocol_drift/akta_record.json}" @@ -37,7 +39,7 @@ if [[ "$USE_REST" == "true" ]]; then --rest-url "$SCOPE_REST_URL" \ "${SIGN_ARGS[@]/#/--signing-key }" else - scope akta review \ + "${SCOPE_CMD[@]}" akta review \ --akta-trigger "$AKTA_TRIGGER" \ --akta-record "$AKTA_RECORD" \ --grant-scope "$GRANT_SCOPE" \ @@ -58,7 +60,7 @@ PCS_OUT="$OUT_DIR/pcs_export" QUALITY_OUT="$OUT_DIR/quality_report.json" echo "== Step 2: SCOPE → PF obligation export ==" -scope export pf --grant "$GRANT" --out "$PF_OUT" --validate --live +"${SCOPE_CMD[@]}" export pf --grant "$GRANT" --out "$PF_OUT" --validate --live echo "== Step 3: PF simulated block → SCOPE violation ==" python scripts/pf_inject_violation.py \ @@ -68,7 +70,7 @@ python scripts/pf_inject_violation.py \ ${SCOPE_REST_URL:+--rest-url "$SCOPE_REST_URL"} echo "== Step 4: SCOPE → PCS archive export ==" -scope export pcs \ +"${SCOPE_CMD[@]}" export pcs \ --packet "$PACKET" \ --decision "$DECISION" \ --grant "$GRANT" \ @@ -78,9 +80,9 @@ scope export pcs \ --policy "$POLICY" echo "== Step 5: Quality report (expect non-zero violation rate) ==" -scope quality report --ledger "$LEDGER" --out "$QUALITY_OUT" --queue-dir "$QUEUE_DIR" +"${SCOPE_CMD[@]}" quality report --ledger "$LEDGER" --out "$QUALITY_OUT" --queue-dir "$QUEUE_DIR" -python - <<'PY' +python - "$QUALITY_OUT" <<'PY' import json, sys from pathlib import Path report = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) @@ -91,6 +93,5 @@ print(f"runtime_violation_outcome_count={count}") if rate <= 0 or count <= 0: sys.exit("Expected non-zero runtime violation metrics after PF inject") PY -"$QUALITY_OUT" echo "Ecosystem demo complete -> $OUT_DIR" From 0845f4dfa15762cac16228362a3c62fd5fdd8400 Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:30:13 -0700 Subject: [PATCH 04/14] feat(akta): complete session-complete eval, tests, and pilot fixture Register session-complete extended eval, add orchestration test, regenerate multi_role_genomics_review with issued grant for v1.0 contract verification. --- evals/run_review_cases.py | 4 + .../expected_verification.json | 17 +-- .../multi_role_genomics_review/manifest.json | 17 ++- .../quality_report_snippet.json | 4 +- .../scope_decision.json | 8 +- .../scope_grant.json | 126 ++++++++++++++++++ .../scope_review_packet.json | 8 +- .../multi_role_genomics_review/summary.json | 28 ++-- tests/test_akta_review_session_mode.py | 22 +++ tests/test_eval_extended.py | 2 +- tests/test_pilot_fixtures.py | 11 +- 11 files changed, 214 insertions(+), 33 deletions(-) create mode 100644 examples/pilot/multi_role_genomics_review/scope_grant.json diff --git a/evals/run_review_cases.py b/evals/run_review_cases.py index 4132441..feee85c 100644 --- a/evals/run_review_cases.py +++ b/evals/run_review_cases.py @@ -234,6 +234,7 @@ def _run_akta_review_scenario( name = scenario["name"] session_mode = bool(scenario.get("session_mode")) + session_complete = bool(scenario.get("session_complete")) with tempfile.TemporaryDirectory() as out_dir: signing_key: Path | None = None if scenario.get("sign_before_grant"): @@ -251,6 +252,8 @@ def _run_akta_review_scenario( out_dir=out_dir, signing_key=signing_key, session_mode=session_mode, + session_complete=session_complete, + votes=scenario.get("votes"), ) if session_mode: @@ -515,6 +518,7 @@ def _run_scenario_with_engine( "fail_closed_grant_blocked.json", "akta_review_signed_summary.json", "akta_review_session_mode.json", + "akta_review_session_complete.json", ] diff --git a/examples/pilot/multi_role_genomics_review/expected_verification.json b/examples/pilot/multi_role_genomics_review/expected_verification.json index 4509c48..7665b99 100644 --- a/examples/pilot/multi_role_genomics_review/expected_verification.json +++ b/examples/pilot/multi_role_genomics_review/expected_verification.json @@ -1,17 +1,18 @@ { "checksums": { - "quality_report_snippet.json": "sha256:c949d9e671baa36c95d6c8bb20fb0f30e571173835dff2bc269a4c9d95da8051", - "scope_review_packet.json": "sha256:654292d3b72dfef1fbd5aa38ed191abdfdab4b3a7244255226fa8e74fb9752ac", - "summary.json": "sha256:4f347cf0a650fbda57b61cbb9c22f279a320dc49e0a1225f565a56205022f020" + "quality_report_snippet.json": "sha256:2a87b022dd1ce8c8c9525310b93ee63db937b47c1ded01f52013cd6cf64f2853", + "scope_decision.json": "sha256:08d9d02b4b93da199f764234895d6a6ea4b240aa433e9641b34b97361969560a", + "scope_grant.json": "sha256:301fff20134c507dc1916a2c2eb60e17f9711439db0a46ef1bdc7edfa28ed5af", + "scope_review_packet.json": "sha256:09629eee028bb8cda2b3789542d1835d3908fc7c264187f0f40c5f74f8e6d20d", + "summary.json": "sha256:2733a7ba7c8c5a08ca5cd88cae9ae04bc9b1e5b5c197ab8bd3f21286d3ba1707" }, - "forbidden_files": [ - "scope_grant.json" - ], + "forbidden_files": [], "quality_report_snippet": { - "policy_version": "scope-core-v0.8" + "policy_version": "scope-core-v1.0" }, "summary": { "adapter_contract_version": "scope-akta-review-v1.0", - "status": "session_required" + "approved_scope": "single_validation_run_draft", + "status": "completed" } } diff --git a/examples/pilot/multi_role_genomics_review/manifest.json b/examples/pilot/multi_role_genomics_review/manifest.json index 74f7f01..def32c9 100644 --- a/examples/pilot/multi_role_genomics_review/manifest.json +++ b/examples/pilot/multi_role_genomics_review/manifest.json @@ -1,16 +1,27 @@ { "scenario": "multi_role_genomics_review", - "scope_version": "0.8.1", - "policy_version": "scope-core-v0.8", + "scope_version": "1.0.0", + "policy_version": "scope-core-v1.0", "akta_review_contract_version": "scope-akta-review-v1.0", "artifacts": [ { "path": "scope_review_packet.json", "schema": "scope_packet.schema.json" }, + { + "path": "scope_decision.json", + "schema": "scope_decision.schema.json" + }, + { + "path": "scope_grant.json", + "schema": "scope_grant.schema.json" + }, { "path": "summary.json", - "schema": "scope_akta_review_session_summary.schema.json" + "schema": "scope_akta_review_summary.schema.json" + }, + { + "path": "votes.json" }, { "path": "decision_protocol_owner.json" diff --git a/examples/pilot/multi_role_genomics_review/quality_report_snippet.json b/examples/pilot/multi_role_genomics_review/quality_report_snippet.json index 0fca031..4999c2e 100644 --- a/examples/pilot/multi_role_genomics_review/quality_report_snippet.json +++ b/examples/pilot/multi_role_genomics_review/quality_report_snippet.json @@ -1,4 +1,4 @@ { - "policy_version": "scope-core-v0.8", - "note": "session pending votes" + "policy_version": "scope-core-v1.0", + "note": "session-complete grant issued" } diff --git a/examples/pilot/multi_role_genomics_review/scope_decision.json b/examples/pilot/multi_role_genomics_review/scope_decision.json index baca935..68fd37a 100644 --- a/examples/pilot/multi_role_genomics_review/scope_decision.json +++ b/examples/pilot/multi_role_genomics_review/scope_decision.json @@ -3,7 +3,7 @@ "requires_second_review": false, "reviewer_confidence": 0.72 }, - "decided_at": "2026-06-29T20:54:22Z", + "decided_at": "2026-06-29T21:28:39Z", "decision": { "approved_scope": "single_validation_run_draft", "rationale": "Co-review confirms draft-only validation scope given weak evidence.", @@ -19,8 +19,8 @@ ], "type": "approve_narrower_scope" }, - "decision_hash": "sha256:0385200da0d0dc910b470e0dc0d6fc8f6c1d855cbe398fe41cfd0476ea8d0dc4", - "decision_id": "SCOPE-DEC-6E380C", + "decision_hash": "sha256:97f0c01735db33c84a9d2800519e40532eee6ea767978489c7c34a8050e04822", + "decision_id": "SCOPE-DEC-DF0B16", "expiration": { "expires_on": [ "single_use", @@ -30,7 +30,7 @@ ], "mode": "event_based" }, - "packet_id": "SCOPE-PKT-69E4DB", + "packet_id": "SCOPE-PKT-C1FD8D", "provenance": { "authority_checks": { "delegation_id": null, diff --git a/examples/pilot/multi_role_genomics_review/scope_grant.json b/examples/pilot/multi_role_genomics_review/scope_grant.json new file mode 100644 index 0000000..507802f --- /dev/null +++ b/examples/pilot/multi_role_genomics_review/scope_grant.json @@ -0,0 +1,126 @@ +{ + "authorization": { + "allowed_tools": [ + "experiment_planner.create_validation_plan", + "protocol_editor.draft_change" + ], + "approved_actions": [ + "A6_experimental_planning" + ], + "approved_scope": "single_validation_run_draft", + "blocked_tools": [ + "lab_scheduler.prioritize", + "protocol_editor.update_active_protocol", + "robot_queue.submit" + ], + "max_responsibility_level": "R5_experimental_planning" + }, + "constraints": { + "domain_overlay": null, + "evidence_state": "E1_weak_signal", + "model_version": null, + "project_id": "demo_project", + "protocol_version": "protocol_v2", + "requires_pf_core_trace": true, + "requires_recording": true, + "single_use": true, + "tool_registry_version": null, + "validation_status": "V0_unvalidated" + }, + "contributing_signatures": [ + { + "decision_id": "SCOPE-DEC-DF0B16", + "reviewer_id": "reviewer_ds_001", + "reviewer_role": "domain_scientist" + }, + { + "decision_id": "SCOPE-DEC-7C43FF", + "reviewer_id": "rev_po", + "reviewer_role": "protocol_owner" + } + ], + "created_at": "2026-06-29T21:28:39Z", + "expiration": { + "absolute_expiration": null, + "expires_after": [ + "single_use", + "protocol_version_change", + "evidence_state_change", + "policy_version_change" + ] + }, + "grant_hash": "sha256:087639c5666a3de94132f78b3fe7cf0ef5d6ac359d40bddf045bd3efbed8e77a", + "grant_id": "SCOPE-GRANT-2EE9BA", + "grant_version": "1.0.0", + "provenance": { + "akta_policy_hash": null, + "authority_checks": { + "delegation_id": null, + "rbac_enforced": false, + "rbac_role_valid": false, + "scope_approval_valid": true, + "scope_role_valid": true + }, + "contributing_authority_checks": [ + { + "authority_checks": { + "delegation_id": null, + "rbac_enforced": false, + "rbac_role_valid": false, + "scope_approval_valid": true, + "scope_role_valid": true + }, + "decision_id": "SCOPE-DEC-DF0B16", + "reviewer_id": "reviewer_ds_001" + }, + { + "authority_checks": { + "delegation_id": null, + "rbac_enforced": false, + "rbac_role_valid": false, + "scope_approval_valid": true, + "scope_role_valid": true + }, + "decision_id": "SCOPE-DEC-7C43FF", + "reviewer_id": "rev_po" + } + ], + "contributing_identity_assurance_levels": [ + { + "decision_id": "SCOPE-DEC-DF0B16", + "identity_assurance_level": "IAL0", + "identity_source": "caller_json", + "reviewer_id": "reviewer_ds_001", + "reviewer_role": "domain_scientist", + "role_resolution_source": "caller_supplied" + }, + { + "decision_id": "SCOPE-DEC-7C43FF", + "identity_assurance_level": "IAL0", + "identity_source": "caller_json", + "reviewer_id": "rev_po", + "reviewer_role": "protocol_owner", + "role_resolution_source": "caller_supplied" + } + ], + "identity_assurance_level": "IAL0", + "identity_source": "caller_json", + "minimum_identity_assurance_level": "IAL0", + "minimum_signing_assurance_level": "SAL0", + "quorum_policy_hash": "sha256:e899af11cf5c678fb8fa8e3cda95b8d8d1c4a9b723cf4a4a7f33186c9fcafa67", + "reviewer_key_registry_hash": "sha256:13f8cdcc054ca19f6a2560cd9ae688c39ac289ddf3f2d3da4e6dd0081e883692", + "reviewer_key_registry_version": "scope-core-v0.8", + "reviewer_role_policy_hash": "sha256:68e578ddc7a35d2cfa38c5cb95bc51182d8801548158a28b35ca1e7a035e28ea", + "role_resolution_source": "caller_supplied", + "scope_policy_hash": "sha256:68e578ddc7a35d2cfa38c5cb95bc51182d8801548158a28b35ca1e7a035e28ea", + "scope_policy_version": "scope-core-v0.8", + "scope_trust_root_hash": "sha256:6334c56aa73915315692177499c217399e2895bbab1ed6794040b1a2d49f0a77", + "signing_assurance_level": "SAL0", + "veto_roles_applied": [] + }, + "source": { + "akta_record_id": "AKTA-SAR-S03", + "decision_id": "SCOPE-DEC-DF0B16", + "packet_id": "SCOPE-PKT-C1FD8D" + } +} diff --git a/examples/pilot/multi_role_genomics_review/scope_review_packet.json b/examples/pilot/multi_role_genomics_review/scope_review_packet.json index 24cb735..d87e782 100644 --- a/examples/pilot/multi_role_genomics_review/scope_review_packet.json +++ b/examples/pilot/multi_role_genomics_review/scope_review_packet.json @@ -9,7 +9,7 @@ "robot_queue.submit" ] }, - "created_at": "2026-06-29T09:19:52Z", + "created_at": "2026-06-29T21:28:39Z", "decision_options": [ "approve", "approve_narrower_scope", @@ -17,9 +17,9 @@ "request_more_evidence", "abstain_conflict_or_insufficient_expertise" ], - "packet_hash": "sha256:a5f3c42561b8dfdde708c963dc014bd3e5fffe450ec587bc798c42e66d9f4241", - "packet_id": "SCOPE-PKT-42A16F", - "packet_version": "0.8.0", + "packet_hash": "sha256:50a502d2a01442270c591f04c3a7c6896044a79116a469938b853c8bf01337b0", + "packet_id": "SCOPE-PKT-C1FD8D", + "packet_version": "1.0.0", "review_artifacts": { "ai_output_summary": "Proposed validation run based on weak preliminary signal.", "evidence_report_ref": "VSA-REP-000012" diff --git a/examples/pilot/multi_role_genomics_review/summary.json b/examples/pilot/multi_role_genomics_review/summary.json index 7b0d334..db88801 100644 --- a/examples/pilot/multi_role_genomics_review/summary.json +++ b/examples/pilot/multi_role_genomics_review/summary.json @@ -1,14 +1,26 @@ { "adapter_contract_version": "scope-akta-review-v1.0", - "message": "Multi-role review session created; submit votes before grant issue.", - "packet_id": "SCOPE-PKT-42A16F", + "allowed_tools": [ + "experiment_planner.create_validation_plan", + "protocol_editor.draft_change" + ], + "approved_scope": "single_validation_run_draft", + "blocked_tools": [ + "lab_scheduler.prioritize", + "protocol_editor.update_active_protocol", + "robot_queue.submit" + ], + "decision_id": "SCOPE-DEC-DF0B16", + "decision_path": "examples/pilot/multi_role_genomics_review/scope_decision.json", + "decision_type": "approve_narrower_scope", + "grant_id": "SCOPE-GRANT-2EE9BA", + "grant_path": "examples/pilot/multi_role_genomics_review/scope_grant.json", + "identity_assurance_level": "IAL0", + "packet_id": "SCOPE-PKT-C1FD8D", "packet_path": "examples/pilot/multi_role_genomics_review/scope_review_packet.json", "production_mode": false, "requested_scope": "single_validation_run_draft", - "required_roles": [ - "domain_scientist", - "protocol_owner" - ], - "session_id": "SCOPE-SESS-26AC2A", - "status": "session_required" + "scope_trust_root_hash": "sha256:6334c56aa73915315692177499c217399e2895bbab1ed6794040b1a2d49f0a77", + "signing_assurance_level": "SAL0", + "status": "completed" } diff --git a/tests/test_akta_review_session_mode.py b/tests/test_akta_review_session_mode.py index 3958f0c..e224b61 100644 --- a/tests/test_akta_review_session_mode.py +++ b/tests/test_akta_review_session_mode.py @@ -137,3 +137,25 @@ def test_akta_review_cli_multi_role_without_session_fails(tmp_path: Path) -> Non assert result.exit_code != 0 message = str(result.exception or result.output) assert "multi-role review session" in message.lower() or "--session" in message + + +def test_multi_role_session_complete_produces_grant(tmp_path: Path) -> None: + engine = ScopeEngine.from_policy_dir(ROOT / "policy") + weak = ROOT / "examples" / "weak_evidence_validation_review" + pilot = ROOT / "examples" / "pilot" / "multi_role_genomics_review" + out_dir = tmp_path / "complete_out" + summary = run_akta_review( + engine, + akta_record=json.loads((weak / "akta_record.json").read_text(encoding="utf-8")), + akta_trigger=json.loads((weak / "review_trigger.json").read_text(encoding="utf-8")), + grant_scope="single_validation_run_draft", + reviewer=pilot / "reviewer_protocol_owner.json", + decision_rationale="Session-complete path", + out_dir=out_dir, + session_complete=True, + votes=pilot / "votes.json", + ) + assert summary["status"] == "completed" + assert summary["grant_id"].startswith("SCOPE-GRANT-") + assert (out_dir / "scope_grant.json").exists() + validate_artifact(summary, "scope_akta_review_summary.schema.json") diff --git a/tests/test_eval_extended.py b/tests/test_eval_extended.py index 1a79fd5..52f3672 100644 --- a/tests/test_eval_extended.py +++ b/tests/test_eval_extended.py @@ -19,4 +19,4 @@ def test_extended_eval_scenarios_pass(): env=env, ) assert result.returncode == 0, result.stdout + result.stderr - assert "22/22 scenarios passed" in result.stdout + assert "23/23 scenarios passed" in result.stdout diff --git a/tests/test_pilot_fixtures.py b/tests/test_pilot_fixtures.py index ba42951..f5cbaaa 100644 --- a/tests/test_pilot_fixtures.py +++ b/tests/test_pilot_fixtures.py @@ -31,7 +31,10 @@ "multi_role_genomics_review": { "required_files": [ "scope_review_packet.json", + "scope_decision.json", + "scope_grant.json", "summary.json", + "votes.json", "reviewer_protocol_owner.json", "reviewer_domain_scientist.json", "decision_protocol_owner.json", @@ -40,8 +43,8 @@ "quality_report_snippet.json", "README.md", ], - "summary_status": "session_required", - "forbidden_files": ["scope_grant.json"], + "summary_status": "completed", + "policy_version": "scope-core-v1.0", }, "expired_queue_reopen": { "required_files": [ @@ -93,9 +96,11 @@ def test_pilot_fixture_files_present(scenario: str) -> None: @pytest.mark.parametrize("scenario", list(SCENARIOS)) def test_pilot_quality_snippet_policy_version(scenario: str) -> None: + spec = SCENARIOS[scenario] snippet_path = PILOT / scenario / "quality_report_snippet.json" snippet = json.loads(snippet_path.read_text(encoding="utf-8")) - assert snippet.get("policy_version") == "scope-core-v0.8" + expected = spec.get("policy_version", "scope-core-v0.8") + assert snippet.get("policy_version") == expected @pytest.mark.parametrize( From 1a90964260f4904bf4309dbc7edf4b271ff71039 Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:52:29 -0700 Subject: [PATCH 05/14] feat(scope): namespace review queues by tenant in engine Wire effective_queue_dir through queue metrics, creation, and escalation so REST tenant headers isolate queue storage. --- scope/__init__.py | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/scope/__init__.py b/scope/__init__.py index a59448d..a8d0883 100644 --- a/scope/__init__.py +++ b/scope/__init__.py @@ -70,6 +70,25 @@ def from_policy_dir( session_store=session_store, ) + @property + def tenant_id(self) -> str | None: + return getattr(self, "_tenant_id", None) + + def effective_queue_dir(self, queue_dir: str | Path | None = None) -> Path: + """Resolve queue directory with optional tenant namespace.""" + from scope.errors import ScopeValidationError + from scope.review_queue import resolve_queue_dir + + if self.tenant_id and queue_dir: + raw = Path(queue_dir) + if raw.name and raw.name != self.tenant_id: + if list(raw.glob("SCOPE-QUEUE-*.json")) and raw.name != self.tenant_id: + raise ScopeValidationError( + f"Tenant {self.tenant_id!r} may not access queue directory " + f"owned by {raw.name!r}" + ) + return resolve_queue_dir(queue_dir, tenant_id=self.tenant_id) + def create_packet( self, akta_record: str | Path | dict[str, Any] | None = None, @@ -726,7 +745,7 @@ def verify_grant( def quality_report(self, *, queue_dir: str | Path | None = None) -> dict[str, Any]: report = analyze_ledger(self.ledger.events(), self.policy) - qm = queue_metrics(queue_dir) + qm = queue_metrics(str(self.effective_queue_dir(queue_dir))) report["metrics"]["open_queue_count"] = qm["open_queue_count"] report["metrics"]["overdue_queue_count"] = qm["overdue_queue_count"] report["metrics"]["ledger_delivery_failure_count"] = self.ledger.delivery_failure_count @@ -791,16 +810,18 @@ def create_review_queue( sla_hours: int = 72, auto_assign: bool = False, ) -> ReviewQueue: + effective = self.effective_queue_dir(queue_dir) + persist = queue_dir is not None or self.tenant_id is not None entry = ReviewQueue.create( packet, sla_hours=sla_hours, - queue_dir=queue_dir, - persist=queue_dir is not None, + queue_dir=effective, + persist=persist, ) if auto_assign: reviewer = resolve_auto_assign(packet, self.policy) entry.assign(reviewer) - if queue_dir: + if persist: entry.save() return entry @@ -812,7 +833,11 @@ def escalate_review_queues( ) -> list[dict[str, Any]]: from scope.workflow_escalation import emit_sla_breach_events, scan_overdue_queues - breaches = scan_overdue_queues(queue_dir, self.policy.policy_dir, dry_run=dry_run) + breaches = scan_overdue_queues( + str(self.effective_queue_dir(queue_dir)), + self.policy.policy_dir, + dry_run=dry_run, + ) if not dry_run: emit_sla_breach_events(self, breaches, policy_dir=self.policy.policy_dir) return breaches @@ -941,7 +966,7 @@ def cancel_review_queue( return entry def review_queue_status(self, *, queue_dir: str | Path | None = None) -> dict[str, Any]: - return aggregate_queue_status(queue_dir) + return aggregate_queue_status(str(self.effective_queue_dir(queue_dir))) __all__ = [ From 819949e0786a767bdb9eac98b402b07d21321635 Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:52:38 -0700 Subject: [PATCH 06/14] feat(rest): enforce tenant-scoped queue path resolution Reject cross-tenant queue directory access with HTTP 403 when a caller targets another tenant namespace. --- adapters/generic_rest/server.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/adapters/generic_rest/server.py b/adapters/generic_rest/server.py index 0900103..5ec6a09 100644 --- a/adapters/generic_rest/server.py +++ b/adapters/generic_rest/server.py @@ -464,9 +464,15 @@ def list_review_queue(queue_dir: str | None = None) -> dict[str, Any]: def _find_queue_path(queue_id: str, queue_dir: str | None = None) -> Path: + from scope.errors import ScopeValidationError from scope.review_queue import ReviewQueue, list_queue_files - for path in list_queue_files(queue_dir): + engine = get_engine() + try: + effective = engine.effective_queue_dir(queue_dir) + except ScopeValidationError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + for path in list_queue_files(effective): entry = ReviewQueue.load(path) if entry.queue_id == queue_id: return path From e066ddba0edf3bd505ddefe430ffcd162b89b482 Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:52:39 -0700 Subject: [PATCH 07/14] test(rest): cover session-complete, audit logging, tenant isolation Add REST tests for AKTA session-complete orchestration, ledger audit events, and per-tenant queue separation. --- tests/test_rest_api.py | 90 ++++++++++++++++++++++++++++++++++ tests/test_workflow_product.py | 2 + 2 files changed, 92 insertions(+) diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index c3b933f..059558b 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -413,6 +413,96 @@ def test_akta_review_rest_session_mode(client, tmp_path): assert not (out_dir / "scope_grant.json").exists() +def test_akta_review_rest_session_complete(client, tmp_path): + from scope.schema_util import validate_artifact + + weak = ROOT / "examples" / "weak_evidence_validation_review" + pilot = ROOT / "examples" / "pilot" / "multi_role_genomics_review" + out_dir = tmp_path / "akta_complete" + votes = json.loads((pilot / "votes.json").read_text(encoding="utf-8")) + payload = { + "akta_record": _load("akta_record.json", weak), + "akta_trigger": _load("review_trigger.json", weak), + "grant_scope": "single_validation_run_draft", + "reviewer": _load("reviewer_protocol_owner.json", pilot), + "decision_rationale": "REST session-complete path.", + "out_dir": str(out_dir), + "session_complete": True, + "votes": votes["votes"], + } + resp = client.post("/v0/akta/review", json=payload) + assert resp.status_code == 200 + summary = resp.json() + assert summary["status"] == "completed" + assert summary["grant_id"].startswith("SCOPE-GRANT-") + assert (out_dir / "scope_grant.json").exists() + validate_artifact(summary, "scope_akta_review_summary.schema.json") + + +def test_rest_audit_logging(client, tmp_path, monkeypatch): + from adapters.generic_rest import server + from scope.ledger import ScopeLedger + + ledger_path = tmp_path / "audit_ledger.jsonl" + monkeypatch.setenv("SCOPE_LEDGER_PATH", str(ledger_path)) + monkeypatch.setenv("SCOPE_REST_AUDIT", "true") + server.reset_engine_cache() + + resp = client.post( + "/v0/packets", + json=_packet_payload(), + headers={"X-Scope-Caller-Id": "audit-test-caller"}, + ) + assert resp.status_code == 200 + + ledger = ScopeLedger(ledger_path) + audit_events = [e for e in ledger.events() if e["event_type"] == "rest_api_audit"] + assert audit_events + assert audit_events[-1]["metadata"]["caller"] == "audit-test-caller" + assert audit_events[-1]["metadata"]["path"] == "/v0/packets" + + +def test_tenant_queue_isolation_rest(client, tmp_path): + from adapters.generic_rest import server + + queue_base = tmp_path / "queues" + server.reset_engine_cache() + packet = client.post("/v0/packets", json=_packet_payload()).json() + + created = client.post( + "/v0/review-queue", + json={"packet": packet, "sla_hours": 24, "queue_dir": str(queue_base)}, + headers={"X-Scope-Tenant-Id": "lab-a"}, + ) + assert created.status_code == 200 + queue_id = created.json()["queue_id"] + assert (queue_base / "lab-a" / f"{queue_id}.json").is_file() + + listed_a = client.get( + "/v0/review-queue", + params={"queue_dir": str(queue_base)}, + headers={"X-Scope-Tenant-Id": "lab-a"}, + ) + assert listed_a.status_code == 200 + assert any(e["queue_id"] == queue_id for e in listed_a.json()["entries"]) + + listed_b = client.get( + "/v0/review-queue", + params={"queue_dir": str(queue_base)}, + headers={"X-Scope-Tenant-Id": "lab-b"}, + ) + assert listed_b.status_code == 200 + assert not any(e["queue_id"] == queue_id for e in listed_b.json()["entries"]) + + denied = client.post( + f"/v0/review-queue/{queue_id}/assign", + json={"reviewer": {"reviewer_id": "r1", "role": "protocol_owner"}}, + params={"queue_dir": str(queue_base / "lab-a")}, + headers={"X-Scope-Tenant-Id": "lab-b"}, + ) + assert denied.status_code == 403 + + def test_akta_review_rest_reviewer_id_mismatch(client, tmp_path): out_dir = tmp_path / "akta_mismatch" payload = { diff --git a/tests/test_workflow_product.py b/tests/test_workflow_product.py index 75895b3..75af824 100644 --- a/tests/test_workflow_product.py +++ b/tests/test_workflow_product.py @@ -6,6 +6,8 @@ from pathlib import Path from unittest.mock import patch +import pytest + from scope.notifications import LogSink, WebhookSink, emit_notification from scope.review_queue import resolve_queue_dir From 4283671a906dd2fcc66d6a4505b2126ca74326cd Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:52:39 -0700 Subject: [PATCH 08/14] feat(ledger): add standalone hash-chain verification script Provide scripts/verify_ledger_chain.py for production ledger integrity checks with tamper detection tests. --- scripts/verify_ledger_chain.py | 48 +++++++++++++++++++++++++++++++ tests/test_verify_ledger_chain.py | 40 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 scripts/verify_ledger_chain.py create mode 100644 tests/test_verify_ledger_chain.py diff --git a/scripts/verify_ledger_chain.py b/scripts/verify_ledger_chain.py new file mode 100644 index 0000000..f1936d0 --- /dev/null +++ b/scripts/verify_ledger_chain.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Verify hash-chain integrity of a SCOPE ledger JSONL file.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scope.errors import LedgerError +from scope.ledger import ScopeLedger + + +def verify_ledger_chain(path: Path) -> int: + """Load ledger and verify event hash chain; return event count.""" + ledger = ScopeLedger(path) + count = len(ledger.events()) + return count + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify SCOPE ledger hash chain integrity.") + parser.add_argument( + "ledger", + nargs="?", + default=".scope/ledger.jsonl", + help="Path to ledger JSONL file (default: .scope/ledger.jsonl)", + ) + args = parser.parse_args() + path = Path(args.ledger) + if not path.is_file(): + print(f"FAIL: ledger file not found: {path}", file=sys.stderr) + return 1 + try: + count = verify_ledger_chain(path) + except LedgerError as exc: + print(f"FAIL: {exc}", file=sys.stderr) + return 1 + print(f"OK {count} event(s) verified in {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_verify_ledger_chain.py b/tests/test_verify_ledger_chain.py new file mode 100644 index 0000000..2ae2c9b --- /dev/null +++ b/tests/test_verify_ledger_chain.py @@ -0,0 +1,40 @@ +"""Tests for scripts/verify_ledger_chain.py.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from scope import ScopeEngine +from scope.errors import LedgerError + +from scripts.verify_ledger_chain import verify_ledger_chain + + +def test_verify_ledger_chain_valid(tmp_path: Path) -> None: + root = Path(__file__).resolve().parent.parent + ledger_path = tmp_path / "events.jsonl" + engine = ScopeEngine.from_policy_dir(root / "policy", ledger_path=ledger_path) + ex = root / "examples" / "protocol_change_review" + packet = engine.create_packet(ex / "akta_record.json", ex / "review_trigger.json") + engine.open_review(packet["packet_id"], actor_id="r1") + count = verify_ledger_chain(ledger_path) + assert count >= 2 + + +def test_verify_ledger_chain_detects_tamper(tmp_path: Path) -> None: + root = Path(__file__).resolve().parent.parent + ledger_path = tmp_path / "events.jsonl" + engine = ScopeEngine.from_policy_dir(root / "policy", ledger_path=ledger_path) + ex = root / "examples" / "protocol_change_review" + engine.create_packet(ex / "akta_record.json", ex / "review_trigger.json") + lines = ledger_path.read_text(encoding="utf-8").strip().splitlines() + event = json.loads(lines[0]) + event["event_type"] = "tampered" + lines[0] = json.dumps(event, sort_keys=True) + ledger_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + try: + verify_ledger_chain(ledger_path) + raise AssertionError("expected LedgerError") + except LedgerError: + pass From d0c5b1901a910773d425e96b93797e9f3ff1532d Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:52:40 -0700 Subject: [PATCH 09/14] docs: align v1.0.0 badge, pilot README, and deployment verification Update version badge, session-complete regeneration docs, and production verification steps for ledger chain checks. --- README.md | 2 +- docs/production_deployment.md | 1 + examples/pilot/README.md | 9 +++--- .../multi_role_genomics_review/README.md | 31 ++++++++++++++----- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6f70347..b88f9d8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ **Scoped authorization for AI-shaped scientific work** -[![Version](https://img.shields.io/badge/version-0.8.1-blue)](https://github.com/fraware/SCOPE/releases) +[![Version](https://img.shields.io/badge/version-1.0.0-blue)](https://github.com/fraware/SCOPE/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) [![CI](https://github.com/fraware/SCOPE/actions/workflows/ci.yml/badge.svg)](https://github.com/fraware/SCOPE/actions/workflows/ci.yml) diff --git a/docs/production_deployment.md b/docs/production_deployment.md index d47f195..94c9596 100644 --- a/docs/production_deployment.md +++ b/docs/production_deployment.md @@ -72,4 +72,5 @@ uvicorn adapters.generic_rest.server:app --host 0.0.0.0 --port 8080 --workers 4 ```bash scope quality report --ledger /var/scope/ledger.jsonl --out /tmp/quality.json python scripts/verify_pilot_fixtures.py +python scripts/verify_ledger_chain.py /var/scope/ledger.jsonl ``` diff --git a/examples/pilot/README.md b/examples/pilot/README.md index 8c980be..8a9c931 100644 --- a/examples/pilot/README.md +++ b/examples/pilot/README.md @@ -1,19 +1,20 @@ -# SCOPE Pilot Fixture Pack (v0.8.1) +# SCOPE Pilot Fixture Pack (v1.0.0) -Institutional pilot scenarios generated with SCOPE v0.8.1 CLI and engine APIs. Each subdirectory is self-contained with review artifacts, queue state where applicable, rendered packet markdown, and a quality report snippet. +Institutional pilot scenarios generated with SCOPE v1.0.0 CLI and engine APIs. Each subdirectory is self-contained with review artifacts, queue state where applicable, rendered packet markdown, and a quality report snippet. | Scenario | Directory | Highlights | |----------|-----------|------------| | Single-reviewer protocol draft | [single_reviewer_protocol_draft](single_reviewer_protocol_draft/) | One-shot `scope akta review` | -| Multi-role genomics co-review | [multi_role_genomics_review](multi_role_genomics_review/) | `--session` creates pending session | +| Multi-role genomics co-review | [multi_role_genomics_review](multi_role_genomics_review/) | `--session-complete` issues grant from votes | | Expired queue reopen | [expired_queue_reopen](expired_queue_reopen/) | Queue `expired` then `reopen` | | Needs information flow | [needs_information_flow](needs_information_flow/) | `needs_information` to `in_review` | | Registry-signed decision | [registry_signed_decision](registry_signed_decision/) | `--signing-provider registry --reviewer-id` | -Policy bundle: `scope-core-v0.8`. AKTA review contract: `scope-akta-review-v0.8.1`. +Policy bundle: `scope-core-v1.0`. AKTA review contract: `scope-akta-review-v1.0`. Each scenario includes `manifest.json` (artifact inventory and schema versions) and `expected_verification.json` (checksums, status values, queue states). Verify offline: ```bash python scripts/verify_pilot_fixtures.py +python scripts/verify_ledger_chain.py .scope/ledger.jsonl ``` diff --git a/examples/pilot/multi_role_genomics_review/README.md b/examples/pilot/multi_role_genomics_review/README.md index adb4e8c..022989d 100644 --- a/examples/pilot/multi_role_genomics_review/README.md +++ b/examples/pilot/multi_role_genomics_review/README.md @@ -1,21 +1,26 @@ # Multi-role genomics review -Scenario: weak-evidence experimental planning (A6) requires both `domain_scientist` and `protocol_owner`. `scope akta review --session` creates a review session instead of issuing a grant immediately. +Scenario: weak-evidence experimental planning (A6) requires both `domain_scientist` and `protocol_owner`. Use `--session` for session-required summary or `--session-complete` to orchestrate votes and issue a grant in one command. ## Artifacts | File | Description | |------|-------------| | `scope_review_packet.json` | Review packet | -| `summary.json` | Session-required summary (`status: session_required`) | +| `scope_decision.json` | Aggregated decision (`completed` path) | +| `scope_grant.json` | Issued grant (`completed` path) | +| `summary.json` | Completed AKTA review summary | +| `votes.json` | Reviewer/decision vote manifest for session-complete | | `reviewer_protocol_owner.json` | Protocol owner reviewer fixture | | `reviewer_domain_scientist.json` | Domain scientist reviewer fixture | | `decision_protocol_owner.json` | Vote input for protocol owner | | `decision_domain_scientist.json` | Vote input for domain scientist | +| `manifest.json` | Artifact inventory and schema versions | +| `expected_verification.json` | Checksums and status expectations | | `packet_rendered.md` | Markdown packet render | -| `quality_report_snippet.json` | Session-pending quality note | +| `quality_report_snippet.json` | Quality note after grant issue | -## Regenerate session +## Regenerate session-required summary ```bash scope akta review \ @@ -29,11 +34,21 @@ scope akta review \ --policy policy ``` -## Complete grant (after regeneration) +## Regenerate completed grant (session-complete) ```bash -scope review session vote --session ... -scope review session issue-grant ... +scope akta review \ + --akta-trigger examples/weak_evidence_validation_review/review_trigger.json \ + --akta-record examples/weak_evidence_validation_review/akta_record.json \ + --grant-scope single_validation_run_draft \ + --reviewer examples/pilot/multi_role_genomics_review/reviewer_protocol_owner.json \ + --decision-rationale "Multi-role session-complete grant." \ + --out-dir examples/pilot/multi_role_genomics_review \ + --session-complete \ + --votes examples/pilot/multi_role_genomics_review/votes.json \ + --policy policy ``` -See `examples/weak_evidence_validation_review/README.md` for the full vote workflow. +REST equivalent: pass `session_complete: true` and `votes[]` to `POST /v0/akta/review` or use `scripts/akta_rest_review.py --session-complete --votes ...`. + +See `examples/weak_evidence_validation_review/README.md` for the underlying review scenario. From b39c9e63a58d68357380d88e7981f19248354268 Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:52:41 -0700 Subject: [PATCH 10/14] ci: run workflow on release branches for PR checks Trigger CI on release/** pushes and pull requests so release train PRs get status checks. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7603606..6741bde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main, master] + branches: [main, master, "release/**"] pull_request: - branches: [main, master] + branches: [main, master, "release/**"] jobs: test: From 9fa2291d37019a1f310c2cde71bafe2c8f116f86 Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:54:01 -0700 Subject: [PATCH 11/14] fix(lint): sort imports in ledger chain and pilot fixture tests Resolve ruff I001 import ordering so CI lint passes on Linux runners. --- tests/test_pilot_fixtures.py | 2 +- tests/test_verify_ledger_chain.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_pilot_fixtures.py b/tests/test_pilot_fixtures.py index f5cbaaa..20d496f 100644 --- a/tests/test_pilot_fixtures.py +++ b/tests/test_pilot_fixtures.py @@ -9,8 +9,8 @@ import pytest -from scope.schema_util import validate_artifact from scope.integration_versions import AKTA_REVIEW_CONTRACT_VERSION +from scope.schema_util import validate_artifact ROOT = Path(__file__).resolve().parent.parent PILOT = ROOT / "examples" / "pilot" diff --git a/tests/test_verify_ledger_chain.py b/tests/test_verify_ledger_chain.py index 2ae2c9b..25aff0b 100644 --- a/tests/test_verify_ledger_chain.py +++ b/tests/test_verify_ledger_chain.py @@ -7,7 +7,6 @@ from scope import ScopeEngine from scope.errors import LedgerError - from scripts.verify_ledger_chain import verify_ledger_chain From cb368627b3bfb3eb59a09074d2bc240c0aecc2bf Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:55:17 -0700 Subject: [PATCH 12/14] fix(types): cast JSON load result in akta vote loader Satisfy mypy no-any-return on _load_json_ref for Linux CI typecheck gate. --- scope/akta_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scope/akta_review.py b/scope/akta_review.py index 207a40c..5fad525 100644 --- a/scope/akta_review.py +++ b/scope/akta_review.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from typing import Any +from typing import Any, cast from scope import ScopeEngine from scope.config import is_production_mode @@ -141,7 +141,7 @@ def _load_json_ref(value: str | Path | dict[str, Any]) -> dict[str, Any]: if isinstance(value, dict): return value with Path(value).open(encoding="utf-8") as fh: - return json.load(fh) + return cast(dict[str, Any], json.load(fh)) def _load_votes_manifest(votes: str | Path | list[Any] | dict[str, Any]) -> list[dict[str, Any]]: From ec067046168ba56a18798ac3d2e5bfb69bc56e4b Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:56:54 -0700 Subject: [PATCH 13/14] fix(ecosystem): use CLI ledger path unless USE_REST is enabled Avoid passing default SCOPE_REST_URL to pf_inject_violation in dry demo runs without a live REST server. --- scripts/ecosystem_demo.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/ecosystem_demo.sh b/scripts/ecosystem_demo.sh index f35583a..586d384 100644 --- a/scripts/ecosystem_demo.sh +++ b/scripts/ecosystem_demo.sh @@ -63,11 +63,11 @@ echo "== Step 2: SCOPE → PF obligation export ==" "${SCOPE_CMD[@]}" export pf --grant "$GRANT" --out "$PF_OUT" --validate --live echo "== Step 3: PF simulated block → SCOPE violation ==" -python scripts/pf_inject_violation.py \ - --grant "$GRANT" \ - --ledger "$LEDGER" \ - --policy "$POLICY" \ - ${SCOPE_REST_URL:+--rest-url "$SCOPE_REST_URL"} +VIOLATION_ARGS=(--grant "$GRANT" --ledger "$LEDGER" --policy "$POLICY") +if [[ "$USE_REST" == "true" ]]; then + VIOLATION_ARGS+=(--rest-url "$SCOPE_REST_URL") +fi +python scripts/pf_inject_violation.py "${VIOLATION_ARGS[@]}" echo "== Step 4: SCOPE → PCS archive export ==" "${SCOPE_CMD[@]}" export pcs \ From 83cb5779180c5a5cb7f529c56bd39d618af2d421 Mon Sep 17 00:00:00 2001 From: fraware Date: Mon, 29 Jun 2026 14:58:23 -0700 Subject: [PATCH 14/14] test(ecosystem): assert violation metrics from quality report metrics Quality report exposes runtime violation rates under metrics, not summary. --- tests/test_live_contracts.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_live_contracts.py b/tests/test_live_contracts.py index 4d4bd93..f180d56 100644 --- a/tests/test_live_contracts.py +++ b/tests/test_live_contracts.py @@ -146,4 +146,5 @@ def test_ecosystem_demo_script_dry(tmp_path: Path) -> None: ) assert completed.returncode == 0, completed.stderr or completed.stdout quality = json.loads((out_dir / "quality_report.json").read_text(encoding="utf-8")) - assert quality["summary"]["post_approval_runtime_violation_rate"] > 0 + assert quality["metrics"]["post_approval_runtime_violation_rate"] > 0 + assert quality["metrics"]["runtime_violation_outcome_count"] > 0