diff --git a/CHANGELOG.md b/CHANGELOG.md index d5040ca..7f5b37b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to AKTA are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.5.0] - 2026-06-28 + +### Added + +- Real SCOPE adapter modes: `simulated`, `python-import` (`SCOPE_REPO_PATH`), `cli` (`SCOPE_CLI`) with `scope packet create`, `scope decision submit`, `scope grant issue` +- PCS v0.5 full-chain export: `scope_decision.json`, `scope_grant.json`, `pf_obligation.json`, per-file `file_hashes`, tamper validation (`validate_pcs_bundle`) +- Production policy integrity: `AKTA_PRODUCTION_MODE=1` requires manifest, deployment HMAC key; rejects dev key +- LLM classifier trust boundary: tool registry always overrides LLM; `llm_advisory` metadata in decisions; `docs/classifier_trust_boundary.md` +- Overlay governance tiers: `core_reference`, `experimental_domain_overlay`, `expert_reviewed_domain_overlay`, `institutional_deployment_overlay` +- Policy file versioning: `policy_file_version` per YAML; `policy_bundle_version` in decision provenance +- Contract tests: `test_scope_import_mode_contract.py`, `test_real_scope_cli_contract.py`, `test_pcs_full_chain_tamper.py` + +### Changed + +- Policy bundle version `akta-core-v0.5`; package version `0.5.0` +- PCS manifest `schema_version` → `akta-record-v0.5` +- SCOPE adapter mode labels: `simulated` | `python-import` | `cli` (replaces `subprocess`) +- Biology/chemistry/clinical overlays marked `experimental_domain_overlay` (not deployment-ready in production) + ## [0.4.0] - 2026-06-28 ### Added diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 1d9ec6b..355526e 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -8,11 +8,11 @@ AKTA is an open protocol with a reference implementation. It is stewarded as a s - **Protocol changes** (schemas, ontologies, deployment profiles): require design review and version bump. - **Reference kernel changes**: follow normal pull request review. -- **Domain overlays**: operational overlays include generic lab, materials, computational science, biology, chemistry, and clinical (v0.4). Domain-specific hazard triggers and reviewer roles are maintained in-repo; institutional deployment still requires local governance review. +- **Domain overlays**: core reference overlays (generic lab, materials, computational science) are operational. Biology, chemistry, and clinical overlays are experimental (v0.5) and not deployment-ready without institutional governance. ## Versioning -AKTA follows semantic versioning for the reference implementation package (`akta-protocol`). Schema versions are embedded in artifact metadata (e.g., `akta-core-v0.4`, `akta-record-v0.4`). +AKTA follows semantic versioning for the reference implementation package (`akta-protocol`). Schema versions are embedded in artifact metadata (e.g., `akta-core-v0.5`, `akta-record-v0.5`). ## Non-certification diff --git a/README.md b/README.md index 2e32ebc..917040d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ If AI changes what science does next, there should be an AKTA Record. ```bash pip install -e ".[dev]" -# Run the weak-evidence gate demo (AKTA/PF/PCS only — no SCOPE chain) +# Dev mode (default): policy manifest optional; experimental overlays allowed akta gate \ --output examples/weak_evidence/ai_output.json \ --tool lab_scheduler.prioritize \ @@ -21,43 +21,44 @@ akta gate \ --context examples/weak_evidence/context.json \ --out examples/weak_evidence/akta_decision.json -akta record \ - --decision examples/weak_evidence/akta_decision.json \ - --out examples/weak_evidence/akta_record.json +# Production mode: requires policy manifest + deployment HMAC key +# $env:AKTA_PRODUCTION_MODE = "1" +# $env:AKTA_POLICY_HMAC_KEY = "" +# Regenerate manifest after policy edits: +python scripts/regenerate_policy_manifest.py -pytest tests/ -v -akta eval --scenarios scenarios/canonical_5.jsonl --expected scenarios/expected_decisions.jsonl -akta eval --scenarios scenarios/public_100.jsonl --expected scenarios/expected_decisions.jsonl +# SCOPE adapter modes (see docs/scope_bridge.md) +# simulated (default) | python-import ($env:SCOPE_REPO_PATH) | cli ($env:SCOPE_CLI) +python scripts/demo_akta_scope_protocol_drift.py -# Oracle-independent eval (hand-written labels, not gate-derived) -python evals/run_oracle_independent.py -# or: akta-oracle-eval +# PCS v0.5 full-chain export (10 artifacts + file_hashes) +akta export pcs --record examples/weak_evidence/akta_record.json \ + --decision examples/weak_evidence/akta_decision.json \ + --out dist/pcs_bundle/ --validate -# Transition eval (SCOPE grant -> re-gate) -python -c "from evals.transition_runner import run_transition; import json; ..." +pytest tests/ -v +make ci +``` -# MCP stdio server (JSON-RPC over stdin/stdout) -python -m adapters.mcp.server +### REST API (OpenAPI v0.5) -# REST API (OpenAPI v0.4) +```bash akta-rest --host 127.0.0.1 --port 8765 +# GET /v0/health, /v0/policy; POST /v0/evaluate, /v0/export/pcs, /v0/export/pf +``` -# Export adapters (v0.2) -akta export pcs --record examples/weak_evidence/akta_record.json \ - --decision examples/weak_evidence/akta_decision.json \ - --out dist/pcs_bundle/ --validate +### Additional commands + +```bash +akta record --decision examples/weak_evidence/akta_decision.json --out examples/weak_evidence/akta_record.json +akta eval --scenarios scenarios/canonical_5.jsonl --expected scenarios/expected_decisions.jsonl +akta eval --scenarios scenarios/public_100.jsonl --expected scenarios/expected_decisions.jsonl +python evals/run_oracle_independent.py +python -m adapters.mcp.server akta export pf --record examples/weak_evidence/akta_record.json \ - --decision examples/weak_evidence/akta_decision.json \ - --out dist/pf_obligations/ --validate + --decision examples/weak_evidence/akta_decision.json --out dist/pf_obligations/ --validate akta review-trigger export --decision decision.json --out review_trigger.json - -# Integrated weak-evidence demo (AKTA/PF/PCS; no SCOPE simulation) python scripts/demo_integrated_weak_evidence.py -# or: make demo-akta-weak-evidence - -# Integrated AKTA x SCOPE protocol-drift demo (canonical AKTA×SCOPE chain) -python scripts/demo_akta_scope_protocol_drift.py -# or: make demo-akta-scope-protocol-drift ``` ## Architecture @@ -118,7 +119,7 @@ record = decision.to_record() - [Review integration](docs/review_integration.md) - [SCOPE bridge](docs/scope_bridge.md) - [AKTA v0.3 integration](docs/akta_v03_integration.md) -- [Threat model (v0.4)](docs/threat_model.md) +- [Threat model](docs/threat_model.md) - [PF-Core bridge](docs/pf_core_bridge.md) - [PCS export](docs/pcs_export.md) - [VSA import](docs/vsa_import.md) @@ -139,11 +140,23 @@ akta-rest --host 127.0.0.1 --port 8765 # GET /v0/policy, /v0/health ``` +## v0.5 acceptance status + +| Criterion | Status | +|-----------|--------| +| SCOPE adapter (simulated / python-import / cli) | Pass | +| PCS full-chain export with file_hashes + tamper validation | Pass | +| Production policy integrity (dev vs production HMAC) | Pass | +| LLM classifier trust boundary (registry overrides LLM) | Pass | +| Overlay governance tiers + production refusal | Pass | +| Policy file versioning in decision provenance | Pass | +| 210+ tests; `make ci` green | Pass | + ## v0.4 acceptance status | Criterion | Status | |-----------|--------| -| Operational domain overlays (biology, chemistry, clinical) | Pass | +| Experimental overlays (biology, chemistry, clinical) | Pass (not operational; refused in production) | | Policy manifest HMAC verification | Pass | | Review lifecycle (F12/F14, prior records) | Pass | | Structured classification + negation guard | Pass | @@ -205,4 +218,4 @@ akta-rest --host 127.0.0.1 --port 8765 ## Status -AKTA v0.4 is a reference implementation. It is not a safety certification. Deployment profile P7 (fully autonomous scientific operator) is defined for taxonomy only and is not supported. +AKTA v0.5 is a reference implementation. It is not a safety certification. Biology, chemistry, and clinical overlays are experimental and not deployment-ready without institutional governance. Deployment profile P7 (fully autonomous scientific operator) is defined for taxonomy only and is not supported. diff --git a/SECURITY.md b/SECURITY.md index 949bf7e..99eecdc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,7 @@ | Version | Supported | |---------|-----------| +| 0.5.x | Yes | | 0.4.x | Yes | | 0.3.x | Yes | @@ -13,11 +14,37 @@ Report security issues privately to the repository maintainers via GitHub Securi Do not open public issues for undisclosed vulnerabilities. -## Security model (v0.4) +## Security model (v0.5) AKTA is part of the agent supply chain. Threats include policy tampering, fake AKTA Records, domain overlay manipulation, tool registry poisoning, review trigger spoofing, stale review reuse, unknown tool confusion, and downgrade attacks. -### v0.4 controls +### Dev vs production policy integrity + +| Mode | Environment variables | Manifest | HMAC key | Overlay policy | +|------|----------------------|----------|----------|----------------| +| Dev (default) | none required | optional | dev key accepted with warning | experimental overlays allowed | +| Verify | `AKTA_VERIFY_POLICY=1` | required | deployment key required | experimental overlays allowed | +| Production | `AKTA_PRODUCTION_MODE=1` | required | deployment key required; dev key rejected | experimental overlays refused | + +Regenerate manifest after policy edits: `python scripts/regenerate_policy_manifest.py` + +See [docs/policy_integrity.md](docs/policy_integrity.md). + +### v0.5 controls + +- JSON schema validation on all artifacts +- Canonical SHA-256 hashing of policy bundle, domain overlays, and tool registry +- Dev vs production policy integrity (`AKTA_PRODUCTION_MODE=1`, `AKTA_VERIFY_POLICY=1`) +- Production requires deployment-specific `AKTA_POLICY_HMAC_KEY`; rejects in-repo dev key +- PCS bundle per-file hash manifest with tamper detection +- Overlay governance tiers; production refuses experimental high-risk overlays +- LLM classifier advisory-only; tool registry overrides LLM output +- Policy hash, domain overlay hash, and tool registry hash in every decision +- Record hash on every AKTA Record +- Review context enforcement (F14 stale review, F12 disclaimer boundary) +- Unknown mutating tools blocked by default (abstain_insufficient_context) + +### v0.4 controls (retained) - JSON schema validation on all artifacts - Canonical SHA-256 hashing of policy bundle, domain overlays, and tool registry @@ -28,10 +55,10 @@ AKTA is part of the agent supply chain. Threats include policy tampering, fake A - Unknown mutating tools blocked by default (abstain_insufficient_context) - Explicit version fields on policy and schemas -### Out of scope for v0.4 +### Out of scope for v0.5 -- Ed25519 signed policy releases in production (HMAC dev key only in-repo) -- Live SCOPE/PF/PCS runtime enforcement (delegated to external repos) +- Ed25519 signed policy releases (HMAC deployment key supported; public-key verification stub only) +- Live SCOPE/PF/PCS runtime enforcement (delegated to external repos; AKTA adapters simulate or bridge) - Model truthfulness guarantees See [docs/threat_model.md](docs/threat_model.md) for the full threat model. diff --git a/adapters/generic_rest/openapi.yaml b/adapters/generic_rest/openapi.yaml index 9faea4e..bba3aaa 100644 --- a/adapters/generic_rest/openapi.yaml +++ b/adapters/generic_rest/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: AKTA REST API - version: 0.4.0 + version: 0.5.0 description: Scientific action admissibility gate REST interface paths: /v0/health: diff --git a/adapters/generic_rest/server.py b/adapters/generic_rest/server.py index b8d1270..2592dd9 100644 --- a/adapters/generic_rest/server.py +++ b/adapters/generic_rest/server.py @@ -69,11 +69,11 @@ def do_GET(self) -> None: self._send_json(HTTPStatus.OK, { "status": "ok", "version": pkg_version("akta-protocol"), - "api_version": "v0.4", + "api_version": "v0.5", }) elif path == "/v0/openapi": spec_path = Path(__file__).resolve().parent / "openapi.yaml" - self._send_json(HTTPStatus.OK, {"openapi_path": str(spec_path), "api_version": "v0.4"}) + self._send_json(HTTPStatus.OK, {"openapi_path": str(spec_path), "api_version": "v0.5"}) elif path == "/v0/policy": gate = self._gate() self._send_json( diff --git a/adapters/pcs/export_artifact.py b/adapters/pcs/export_artifact.py index 6d66492..7a171f8 100644 --- a/adapters/pcs/export_artifact.py +++ b/adapters/pcs/export_artifact.py @@ -1,4 +1,4 @@ -"""Export PCS-compatible AKTA artifact bundle.""" +"""Export PCS-compatible AKTA artifact bundle (v0.5 full chain).""" from __future__ import annotations @@ -6,56 +6,120 @@ from pathlib import Path from typing import Any -from akta.hash import hash_object +from akta.hash import hash_file_content, hash_object from akta.records import validate_against_schema +PCS_SCHEMA_VERSION = "akta-record-v0.5" + +CORE_FILES = [ + "akta_record.json", + "akta_decision.json", + "policy_hash.txt", + "domain_overlay_hash.txt", + "tool_registry_hash.txt", +] + + def build_pcs_manifest( record: dict[str, Any], decision: dict[str, Any] | None = None, *, + file_hashes: dict[str, str] | None = None, + include_review_trigger: bool = False, include_scope_packet: bool = False, + include_scope_decision: bool = False, + include_scope_grant: bool = False, + include_pf_obligation: bool = False, ) -> dict[str, Any]: - """Build PCS manifest from record and optional full decision.""" + """Build PCS manifest v0.5 from record and optional chain artifacts.""" provenance = record.get("provenance", {}) - files = [ - "akta_record.json", - "akta_decision.json", - "policy_hash.txt", - "domain_overlay_hash.txt", - "tool_registry_hash.txt", - ] - if record.get("review_trigger"): + files = list(CORE_FILES) + if include_review_trigger: files.append("review_trigger.json") if include_scope_packet: files.append("scope_review_packet.json") - - manifest = { + if include_scope_decision: + files.append("scope_decision.json") + if include_scope_grant: + files.append("scope_grant.json") + if include_pf_obligation: + files.append("pf_obligation.json") + + manifest: dict[str, Any] = { "artifact_type": "akta_scientific_action_record", - "schema_version": "akta-record-v0.4", + "schema_version": PCS_SCHEMA_VERSION, "record_hash": record.get("record_hash"), "policy_hash": provenance.get("policy_hash"), "domain_overlay_hash": provenance.get("domain_overlay_hash"), "tool_registry_hash": provenance.get("tool_registry_hash"), "decision_id": (decision or {}).get("decision_id") or record.get("record_id", "").replace("SAR", "DEC"), - "files": files + ["manifest.json"], + "files": sorted(files + ["manifest.json"]), } + if file_hashes: + manifest["file_hashes"] = {k: v for k, v in sorted(file_hashes.items()) if k != "manifest.json"} + manifest["manifest_hash"] = hash_object( - {k: v for k, v in manifest.items() if k not in ("manifest_hash", "files")} + {k: v for k, v in manifest.items() if k not in ("manifest_hash", "file_hashes")} | {"files": sorted(files)} + | ({"file_hashes": manifest.get("file_hashes")} if manifest.get("file_hashes") else {}) ) return manifest +def validate_pcs_bundle(bundle_dir: str | Path) -> None: + """Verify PCS bundle file hashes match manifest (tamper detection).""" + bundle_dir = Path(bundle_dir) + manifest_path = bundle_dir / "manifest.json" + if not manifest_path.exists(): + raise ValueError(f"PCS bundle missing manifest.json: {bundle_dir}") + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + file_hashes = manifest.get("file_hashes") + if not file_hashes: + raise ValueError("PCS manifest missing file_hashes (v0.5 required)") + + listed = set(manifest.get("files", [])) + if listed != set(file_hashes.keys()) | {"manifest.json"}: + raise ValueError("PCS manifest files list does not match file_hashes keys") + + for fname, expected_hash in file_hashes.items(): + fpath = bundle_dir / fname + if not fpath.exists(): + raise ValueError(f"PCS bundle missing listed artifact: {fname}") + actual = hash_file_content(fpath.read_text(encoding="utf-8")) + if actual != expected_hash: + raise ValueError( + f"PCS bundle tamper detected for {fname}: expected {expected_hash}, got {actual}" + ) + + stored_manifest_hash = manifest.get("manifest_hash") + recomputed = build_pcs_manifest( + json.loads((bundle_dir / "akta_record.json").read_text(encoding="utf-8")), + json.loads((bundle_dir / "akta_decision.json").read_text(encoding="utf-8")), + file_hashes=file_hashes, + include_review_trigger=(bundle_dir / "review_trigger.json").exists(), + include_scope_packet=(bundle_dir / "scope_review_packet.json").exists(), + include_scope_decision=(bundle_dir / "scope_decision.json").exists(), + include_scope_grant=(bundle_dir / "scope_grant.json").exists(), + include_pf_obligation=(bundle_dir / "pf_obligation.json").exists(), + ) + if stored_manifest_hash != recomputed["manifest_hash"]: + raise ValueError("PCS manifest_hash does not match recomputed hash") + + def export_pcs_bundle( record: Any, out_dir: str | Path, *, decision: dict[str, Any] | None = None, scope_review_packet: dict[str, Any] | None = None, + scope_decision: dict[str, Any] | None = None, + scope_grant: dict[str, Any] | None = None, + pf_obligation: dict[str, Any] | None = None, validate: bool = True, ) -> Path: - """Export AKTA Record as PCS-compatible artifact bundle.""" + """Export AKTA Record as PCS-compatible artifact bundle with full chain.""" from akta.records import AKTARecord data = record.data if isinstance(record, AKTARecord) else record @@ -77,32 +141,67 @@ def export_pcs_bundle( if validate and not provenance.get("policy_hash", "").startswith("sha256:"): raise ValueError("PCS export requires valid policy_hash on record provenance") - manifest = build_pcs_manifest( - data, decision_payload, include_scope_packet=scope_review_packet is not None - ) + if validate and scope_grant is not None: + from akta.scope_contract import validate_approval_grant - (out_dir / "akta_record.json").write_text(json.dumps(data, indent=2), encoding="utf-8") - (out_dir / "akta_decision.json").write_text(json.dumps(decision_payload, indent=2), encoding="utf-8") - (out_dir / "policy_hash.txt").write_text(provenance.get("policy_hash", ""), encoding="utf-8") - (out_dir / "domain_overlay_hash.txt").write_text( - provenance.get("domain_overlay_hash") or "", encoding="utf-8" - ) - (out_dir / "tool_registry_hash.txt").write_text( - provenance.get("tool_registry_hash", ""), encoding="utf-8" - ) + granted = scope_grant.get("granted_scope", "") + requested = scope_grant.get("requested_scope") or data.get("review_trigger", {}).get( + "requested_scope", "" + ) + if granted and requested: + try: + validate_approval_grant(granted_scope=granted, requested_scope=requested) + except ValueError as exc: + raise ValueError(f"Invalid SCOPE grant blocks PCS export: {exc}") from exc + + artifacts: dict[str, str] = {} + + def _write(name: str, content: str) -> None: + (out_dir / name).write_text(content, encoding="utf-8") + artifacts[name] = hash_file_content(content) + + _write("akta_record.json", json.dumps(data, indent=2)) + _write("akta_decision.json", json.dumps(decision_payload, indent=2)) + _write("policy_hash.txt", provenance.get("policy_hash", "")) + _write("domain_overlay_hash.txt", provenance.get("domain_overlay_hash") or "") + _write("tool_registry_hash.txt", provenance.get("tool_registry_hash", "")) review_trigger = data.get("review_trigger") + has_review_trigger = review_trigger is not None if review_trigger: - (out_dir / "review_trigger.json").write_text(json.dumps(review_trigger, indent=2), encoding="utf-8") + _write("review_trigger.json", json.dumps(review_trigger, indent=2)) + has_scope_packet = scope_review_packet is not None if scope_review_packet is not None: - (out_dir / "scope_review_packet.json").write_text( - json.dumps(scope_review_packet, indent=2), encoding="utf-8" - ) + _write("scope_review_packet.json", json.dumps(scope_review_packet, indent=2)) - (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + has_scope_decision = scope_decision is not None + if scope_decision is not None: + _write("scope_decision.json", json.dumps(scope_decision, indent=2)) + + has_scope_grant = scope_grant is not None + if scope_grant is not None: + _write("scope_grant.json", json.dumps(scope_grant, indent=2)) + + has_pf = pf_obligation is not None + if pf_obligation is not None: + _write("pf_obligation.json", json.dumps(pf_obligation, indent=2)) + + manifest = build_pcs_manifest( + data, + decision_payload, + file_hashes=artifacts, + include_review_trigger=has_review_trigger, + include_scope_packet=has_scope_packet, + include_scope_decision=has_scope_decision, + include_scope_grant=has_scope_grant, + include_pf_obligation=has_pf, + ) + manifest_content = json.dumps(manifest, indent=2) + (out_dir / "manifest.json").write_text(manifest_content, encoding="utf-8") if validate: validate_against_schema(manifest, "pcs_akta_artifact.schema.json") + validate_pcs_bundle(out_dir) return out_dir diff --git a/adapters/pcs_bench/export_suite.py b/adapters/pcs_bench/export_suite.py index 64f65bf..5e78d11 100644 --- a/adapters/pcs_bench/export_suite.py +++ b/adapters/pcs_bench/export_suite.py @@ -11,7 +11,7 @@ def export_scenario_suite( scenarios: list[dict[str, Any]], out_path: str | Path, *, - suite_id: str = "akta-pcs-bench-v0.4", + suite_id: str = "akta-pcs-bench-v0.5", include_expected: dict[str, dict[str, Any]] | None = None, ) -> Path: """Write PCS-Bench consumable JSONL scenario suite.""" diff --git a/adapters/scope/client.py b/adapters/scope/client.py index f5d1131..b4aa609 100644 --- a/adapters/scope/client.py +++ b/adapters/scope/client.py @@ -1,16 +1,25 @@ -"""SCOPE adapter — subprocess or simulated contract client.""" +"""SCOPE adapter — simulated, python-import, or real CLI client (v0.5).""" from __future__ import annotations +import importlib.util import json +import logging import os import subprocess +import tempfile from dataclasses import dataclass from pathlib import Path from typing import Any from akta.scope_contract import assemble_review_packet, validate_approval_grant +logger = logging.getLogger(__name__) + +ADAPTER_MODE_SIMULATED = "simulated" +ADAPTER_MODE_PYTHON_IMPORT = "python-import" +ADAPTER_MODE_CLI = "cli" + @dataclass class ScopeAdapterResult: @@ -21,89 +30,296 @@ class ScopeAdapterResult: error: str | None = None -def _adapter_mode() -> str: - if os.environ.get("SCOPE_CLI") or os.environ.get("SCOPE_REPO_PATH"): - return "subprocess" - return "simulated" +def detect_adapter_mode() -> str: + """Auto-detect SCOPE adapter mode from environment.""" + if os.environ.get("SCOPE_REPO_PATH"): + return ADAPTER_MODE_PYTHON_IMPORT + if os.environ.get("SCOPE_CLI"): + return ADAPTER_MODE_CLI + return ADAPTER_MODE_SIMULATED -def submit_review_trigger( +def _discover_scope_engine_class(repo_path: Path) -> type[Any]: + """Import ScopeEngine from SCOPE repo or discover equivalent class.""" + import importlib.util + + scope_file = repo_path / "scope.py" + scope_pkg = repo_path / "scope" / "__init__.py" + if scope_file.is_file(): + spec = importlib.util.spec_from_file_location("akta_scope_bridge", scope_file) + elif scope_pkg.is_file(): + spec = importlib.util.spec_from_file_location( + "akta_scope_bridge", + scope_pkg, + submodule_search_locations=[str(repo_path / "scope")], + ) + else: + raise ImportError(f"No scope.py or scope/ package found in {repo_path}") + + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load scope module from {repo_path}") + + scope_mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(scope_mod) + + if hasattr(scope_mod, "ScopeEngine"): + return scope_mod.ScopeEngine + for attr in ("Scope", "ReviewEngine", "ScopeReviewEngine"): + if hasattr(scope_mod, attr): + return getattr(scope_mod, attr) + raise ImportError(f"No ScopeEngine-compatible class found in {repo_path}") + + +def _python_import_scope( trigger: dict[str, Any], - *, - grant_scope: str | None = None, - reviewer_id: str = "scope_reviewer", + record: dict[str, Any] | None, + granted: str, + reviewer_id: str, ) -> ScopeAdapterResult: - """Submit AKTA review trigger to SCOPE or simulate approval flow.""" - mode = _adapter_mode() - requested_scope = trigger.get("requested_scope", "protocol_draft") - granted = grant_scope or requested_scope + repo_path = Path(os.environ["SCOPE_REPO_PATH"]) + if not repo_path.is_dir(): + return ScopeAdapterResult( + adapter_mode=ADAPTER_MODE_PYTHON_IMPORT, + error=f"SCOPE_REPO_PATH is not a directory: {repo_path}", + ) + try: + engine_cls = _discover_scope_engine_class(repo_path) + engine = engine_cls() + except ImportError as exc: + return ScopeAdapterResult(adapter_mode=ADAPTER_MODE_PYTHON_IMPORT, error=str(exc)) - if mode == "subprocess": - return _subprocess_scope(trigger, granted, reviewer_id) + try: + if hasattr(engine, "create_packet"): + packet = engine.create_packet(trigger, record=record) + elif hasattr(engine, "packet_create"): + packet = engine.packet_create(trigger, record) + else: + packet = assemble_review_packet(trigger, record) - packet = assemble_review_packet(trigger) - grant = { - "grant_id": f"SCOPE-GRANT-{trigger.get('review_trigger_id', 'UNKNOWN')}", - "granted_scope": granted, - "requested_scope": requested_scope, - "reviewer_id": reviewer_id, - "review_trigger_id": trigger.get("review_trigger_id"), - } + if hasattr(engine, "submit_decision"): + decision = engine.submit_decision( + packet, granted_scope=granted, reviewer_id=reviewer_id + ) + elif hasattr(engine, "decision_submit"): + decision = engine.decision_submit(packet, granted, reviewer_id) + else: + decision = {"status": "granted", "granted_scope": granted, "reviewer_id": reviewer_id} + + if hasattr(engine, "issue_grant"): + grant = engine.issue_grant(decision, trigger=trigger) + elif hasattr(engine, "grant_issue"): + grant = engine.grant_issue(decision, trigger) + else: + grant = { + "grant_id": f"SCOPE-GRANT-{trigger.get('review_trigger_id', 'UNKNOWN')}", + "granted_scope": granted, + "requested_scope": trigger.get("requested_scope"), + "reviewer_id": reviewer_id, + "review_trigger_id": trigger.get("review_trigger_id"), + } + except Exception as exc: + return ScopeAdapterResult(adapter_mode=ADAPTER_MODE_PYTHON_IMPORT, error=str(exc)) + + requested_scope = trigger.get("requested_scope", "protocol_draft") try: - validate_approval_grant( - granted_scope=granted, - requested_scope=requested_scope, - ) + validate_approval_grant(granted_scope=granted, requested_scope=requested_scope) except ValueError as exc: return ScopeAdapterResult( - adapter_mode=mode, + adapter_mode=ADAPTER_MODE_PYTHON_IMPORT, review_packet=packet, grant=grant, + decision=decision, error=str(exc), ) + return ScopeAdapterResult( - adapter_mode=mode, + adapter_mode=ADAPTER_MODE_PYTHON_IMPORT, review_packet=packet, grant=grant, - decision={"status": "granted", "granted_scope": granted}, + decision=decision, ) -def _subprocess_scope( +def _cli_scope( trigger: dict[str, Any], + record: dict[str, Any] | None, granted: str, reviewer_id: str, ) -> ScopeAdapterResult: cli = os.environ.get("SCOPE_CLI", "scope") - repo = os.environ.get("SCOPE_REPO_PATH", "") - payload = json.dumps({"trigger": trigger, "grant_scope": granted, "reviewer_id": reviewer_id}) - cmd = [cli, "review", "--stdin"] - if repo: - cmd.extend(["--repo", repo]) + with tempfile.TemporaryDirectory(prefix="akta-scope-") as tmp: + tmp_path = Path(tmp) + trigger_path = tmp_path / "review_trigger.json" + trigger_path.write_text(json.dumps(trigger, indent=2), encoding="utf-8") + packet_path = tmp_path / "scope_review_packet.json" + decision_path = tmp_path / "scope_decision.json" + grant_path = tmp_path / "scope_grant.json" + + packet_cmd = [ + cli, + "packet", + "create", + "--trigger", + str(trigger_path), + "--out", + str(packet_path), + ] + if record is not None: + record_path = tmp_path / "akta_record.json" + record_path.write_text(json.dumps(record, indent=2), encoding="utf-8") + packet_cmd.extend(["--record", str(record_path)]) + + try: + proc = subprocess.run( + packet_cmd, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + return ScopeAdapterResult(adapter_mode=ADAPTER_MODE_CLI, error=str(exc)) + + if proc.returncode != 0: + return ScopeAdapterResult( + adapter_mode=ADAPTER_MODE_CLI, + error=proc.stderr or f"scope packet create exited {proc.returncode}", + ) + + decision_cmd = [ + cli, + "decision", + "submit", + "--packet", + str(packet_path), + "--grant-scope", + granted, + "--reviewer", + reviewer_id, + "--out", + str(decision_path), + ] + try: + proc = subprocess.run( + decision_cmd, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + return ScopeAdapterResult(adapter_mode=ADAPTER_MODE_CLI, error=str(exc)) + + if proc.returncode != 0: + return ScopeAdapterResult( + adapter_mode=ADAPTER_MODE_CLI, + error=proc.stderr or f"scope decision submit exited {proc.returncode}", + ) + + grant_cmd = [ + cli, + "grant", + "issue", + "--decision", + str(decision_path), + "--out", + str(grant_path), + ] + try: + proc = subprocess.run( + grant_cmd, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + return ScopeAdapterResult(adapter_mode=ADAPTER_MODE_CLI, error=str(exc)) + + if proc.returncode != 0: + return ScopeAdapterResult( + adapter_mode=ADAPTER_MODE_CLI, + error=proc.stderr or f"scope grant issue exited {proc.returncode}", + ) + + try: + packet = json.loads(packet_path.read_text(encoding="utf-8")) + decision = json.loads(decision_path.read_text(encoding="utf-8")) + grant = json.loads(grant_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + return ScopeAdapterResult(adapter_mode=ADAPTER_MODE_CLI, error=str(exc)) + + requested_scope = trigger.get("requested_scope", "protocol_draft") try: - proc = subprocess.run( - cmd, - input=payload, - capture_output=True, - text=True, - timeout=60, - check=False, + validate_approval_grant(granted_scope=granted, requested_scope=requested_scope) + except ValueError as exc: + return ScopeAdapterResult( + adapter_mode=ADAPTER_MODE_CLI, + review_packet=packet, + grant=grant, + decision=decision, + error=str(exc), ) - except (FileNotFoundError, subprocess.TimeoutExpired) as exc: - return ScopeAdapterResult(adapter_mode="subprocess", error=str(exc)) - if proc.returncode != 0: + return ScopeAdapterResult( + adapter_mode=ADAPTER_MODE_CLI, + review_packet=packet, + grant=grant, + decision=decision, + ) + + +def _simulated_scope( + trigger: dict[str, Any], + granted: str, + reviewer_id: str, +) -> ScopeAdapterResult: + logger.info("SCOPE adapter mode: simulated (contract-simulation only)") + requested_scope = trigger.get("requested_scope", "protocol_draft") + packet = assemble_review_packet(trigger) + grant = { + "grant_id": f"SCOPE-GRANT-{trigger.get('review_trigger_id', 'UNKNOWN')}", + "granted_scope": granted, + "requested_scope": requested_scope, + "reviewer_id": reviewer_id, + "review_trigger_id": trigger.get("review_trigger_id"), + } + decision = {"status": "granted", "granted_scope": granted, "reviewer_id": reviewer_id} + try: + validate_approval_grant(granted_scope=granted, requested_scope=requested_scope) + except ValueError as exc: return ScopeAdapterResult( - adapter_mode="subprocess", - error=proc.stderr or f"SCOPE exited {proc.returncode}", + adapter_mode=ADAPTER_MODE_SIMULATED, + review_packet=packet, + grant=grant, + decision=decision, + error=str(exc), ) - try: - result = json.loads(proc.stdout) - except json.JSONDecodeError: - return ScopeAdapterResult(adapter_mode="subprocess", error="Invalid SCOPE JSON output") return ScopeAdapterResult( - adapter_mode="subprocess", - review_packet=result.get("review_packet"), - grant=result.get("grant"), - decision=result.get("decision"), + adapter_mode=ADAPTER_MODE_SIMULATED, + review_packet=packet, + grant=grant, + decision=decision, ) + + +def submit_review_trigger( + trigger: dict[str, Any], + *, + record: dict[str, Any] | None = None, + grant_scope: str | None = None, + reviewer_id: str = "scope_reviewer", +) -> ScopeAdapterResult: + """Submit AKTA review trigger to SCOPE (simulated, python-import, or CLI).""" + mode = detect_adapter_mode() + requested_scope = trigger.get("requested_scope", "protocol_draft") + granted = grant_scope or requested_scope + + if mode == ADAPTER_MODE_PYTHON_IMPORT: + logger.info("SCOPE adapter mode: python-import (SCOPE_REPO_PATH)") + return _python_import_scope(trigger, record, granted, reviewer_id) + if mode == ADAPTER_MODE_CLI: + logger.info("SCOPE adapter mode: cli (SCOPE_CLI=%s)", os.environ.get("SCOPE_CLI", "scope")) + return _cli_scope(trigger, record, granted, reviewer_id) + + return _simulated_scope(trigger, granted, reviewer_id) diff --git a/adapters/scope/engine_protocol.py b/adapters/scope/engine_protocol.py new file mode 100644 index 0000000..545d33d --- /dev/null +++ b/adapters/scope/engine_protocol.py @@ -0,0 +1,49 @@ +"""Expected SCOPE engine interface for python-import adapter mode (v0.5). + +When ``SCOPE_REPO_PATH`` is set, ``adapters.scope.client`` discovers a class from the +sibling SCOPE repository and invokes the methods below. Discovery order: + +1. ``scope.ScopeEngine`` (preferred) +2. ``Scope``, ``ReviewEngine``, ``ScopeReviewEngine`` (compat aliases) + +If a method is missing, the adapter falls back to AKTA contract simulation for that step. +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class ScopeEngineProtocol(Protocol): + """Minimal SCOPE engine surface consumed by AKTA's python-import adapter.""" + + def create_packet( + self, + trigger: dict[str, Any], + record: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Build a SCOPE review packet from an AKTA review trigger and optional record.""" + + def submit_decision( + self, + packet: dict[str, Any], + granted_scope: str, + reviewer_id: str, + ) -> dict[str, Any]: + """Record reviewer decision for a review packet.""" + + def issue_grant( + self, + decision: dict[str, Any], + trigger: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Issue a scoped approval grant from a SCOPE decision.""" + + +# Alternate method names accepted by ``adapters.scope.client`` (same semantics). +COMPAT_METHOD_ALIASES: dict[str, tuple[str, ...]] = { + "create_packet": ("packet_create",), + "submit_decision": ("decision_submit",), + "issue_grant": ("grant_issue",), +} diff --git a/akta/__init__.py b/akta/__init__.py index 52ad6db..132b865 100644 --- a/akta/__init__.py +++ b/akta/__init__.py @@ -4,7 +4,7 @@ from akta.gate import AKTAGate from akta.records import AKTADecision, AKTARecord -__version__ = "0.4.0" +__version__ = "0.5.0" __all__ = [ "AKTAGate", diff --git a/akta/classifier_plugins.py b/akta/classifier_plugins.py index 55d31b9..39d5af3 100644 --- a/akta/classifier_plugins.py +++ b/akta/classifier_plugins.py @@ -1,11 +1,13 @@ """Optional classifier plugins for AKTA (disabled by default). Deterministic classification is the default path. Plugins extend classification when -deterministic rules cannot resolve an action type. +deterministic rules cannot resolve an action type. LLM output is advisory only and +never overrides known tool-registry mappings. """ from __future__ import annotations +import hashlib import json import os import urllib.error @@ -29,6 +31,7 @@ class PluginClassification: alternates: list[str] = field(default_factory=list) source: str = "plugin" uncertainty_flags: list[str] = field(default_factory=list) + llm_metadata: dict[str, Any] | None = None class ClassifierPlugin(ABC): @@ -116,12 +119,20 @@ def classify( "additionalProperties": False, } +LLM_LOW_CONFIDENCE_THRESHOLD = 0.7 + + +def _prompt_hash(system_prompt: str, user_text: str) -> str: + payload = json.dumps({"system": system_prompt, "user": user_text[:4000]}, sort_keys=True) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + class OptionalLLMClassifierPlugin(ClassifierPlugin): """Optional OpenAI-compatible LLM classifier with strict JSON schema output. Enabled only when ``AKTA_LLM_CLASSIFIER`` is set AND ``OPENAI_API_KEY`` is present. Fails closed (returns None) without API key even if env flag is set. + Advisory only — known tool-registry mappings override LLM output in ``classify()``. """ ENV_VAR = "AKTA_LLM_CLASSIFIER" @@ -148,6 +159,8 @@ def classify( ) -> PluginClassification | None: if not self.is_enabled(): return None + if tool_spec.known: + return None action_types = list( policy.action_ontology.get("action_types", {}).keys() @@ -160,9 +173,11 @@ def classify( "rationale, optional alternate_action_types." ) text = ai_output if isinstance(ai_output, str) else str(ai_output or requested_action) + model = os.environ.get(self.MODEL_ENV, self.DEFAULT_MODEL) + phash = _prompt_hash(prompt, text) try: - result = self._call_openai(prompt, text) + result = self._call_openai(prompt, text, model) except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, ValueError): return None @@ -170,18 +185,26 @@ def classify( if action_type not in action_types: return None + confidence = float(result.get("confidence", 0.65)) + llm_meta = { + "model": model, + "prompt_hash": phash, + "schema": "akta_classification_v0.5", + "confidence": confidence, + } + return PluginClassification( action_type=action_type, - confidence=float(result.get("confidence", 0.65)), + confidence=confidence, rationale=str(result.get("rationale", "LLM classification")), alternates=list(result.get("alternate_action_types") or []), source="llm_classifier", - uncertainty_flags=["llm_assisted"], + uncertainty_flags=["llm_advisory"], + llm_metadata=llm_meta, ) - def _call_openai(self, system_prompt: str, user_text: str) -> dict[str, Any]: + def _call_openai(self, system_prompt: str, user_text: str, model: str) -> dict[str, Any]: api_key = os.environ[self.API_KEY_ENV] - model = os.environ.get(self.MODEL_ENV, self.DEFAULT_MODEL) payload = { "model": model, "messages": [ diff --git a/akta/classify.py b/akta/classify.py index d356528..e62f16d 100644 --- a/akta/classify.py +++ b/akta/classify.py @@ -68,7 +68,8 @@ class ClassificationResult: matched_source: Provenance of the primary classification (tool_registry, requested_action, plugin, etc.). matched_evidence: Short pointer to the signal used (tool name, action text, etc.). uncertainty_flags: Taxonomy flags such as ``nl_tool_mismatch`` or ``model_assisted_fallback``. - classifier_mode: ``deterministic`` or ``model_assisted`` when a plugin contributed. + classifier_mode: ``deterministic`` or ``llm_advisory`` when a plugin contributed. + llm_metadata: Model, prompt_hash, schema, confidence when LLM advisory path used. """ action_type: str @@ -80,6 +81,7 @@ class ClassificationResult: matched_evidence: str = "" uncertainty_flags: list[str] = field(default_factory=list) classifier_mode: str = "deterministic" + llm_metadata: dict[str, Any] | None = None @property def primary_action_type(self) -> str: @@ -228,6 +230,17 @@ def classify( rationale_parts.append(f"{structured_source} specifies {structured_action}") confidence = 0.99 + plugin_result = None + if not structured_action: + plugin_result = run_plugin_classification( + policy, + requested_tool, + requested_action, + tool_spec, + context, + ai_output=ai_output, + ) + if tool_spec.known and not structured_action: action_type = tool_spec.action_type matched_source = "tool_registry" @@ -242,6 +255,12 @@ def classify( rationale_parts.append( f"NL text suggests {nl_action} but tool registry overrides to {action_type}" ) + if plugin_result is not None and plugin_result.action_type != action_type: + alternates.append(plugin_result.action_type) + uncertainty_flags.append("llm_overridden_by_tool_registry") + rationale_parts.append( + f"LLM suggested {plugin_result.action_type} but tool registry overrides" + ) else: if not action_type: nl_action, nl_alts = classify_from_action_text(requested_action) @@ -267,24 +286,18 @@ def classify( rationale_parts.append("inferred from ai_output text") confidence = 0.75 - if not action_type: - plugin_result = run_plugin_classification( - policy, - requested_tool, - requested_action, - tool_spec, - context, - ai_output=ai_output, - ) - if plugin_result is not None: - classifier_mode = plugin_result.source if plugin_result.source == "llm_classifier" else "plugin_assisted" - action_type = plugin_result.action_type - confidence = plugin_result.confidence - alternates.extend(plugin_result.alternates) - uncertainty_flags.extend(plugin_result.uncertainty_flags) - matched_source = plugin_result.source - matched_evidence = f"plugin={plugin_result.source}" - rationale_parts.append(plugin_result.rationale) + if not action_type and plugin_result is not None: + classifier_mode = "llm_advisory" if plugin_result.source == "llm_classifier" else "plugin_assisted" + action_type = plugin_result.action_type + confidence = plugin_result.confidence + alternates.extend(plugin_result.alternates) + uncertainty_flags.extend(plugin_result.uncertainty_flags) + matched_source = plugin_result.source + matched_evidence = f"plugin={plugin_result.source}" + rationale_parts.append(plugin_result.rationale) + llm_metadata = plugin_result.llm_metadata + else: + llm_metadata = None if not action_type: action_type = "A8_tool_or_workflow_mutation" @@ -332,6 +345,7 @@ def classify( matched_evidence=matched_evidence, uncertainty_flags=list(dict.fromkeys(uncertainty_flags)), classifier_mode=classifier_mode, + llm_metadata=llm_metadata if classifier_mode == "llm_advisory" else None, ) diff --git a/akta/gate.py b/akta/gate.py index d3a0763..180c969 100644 --- a/akta/gate.py +++ b/akta/gate.py @@ -169,6 +169,7 @@ def evaluate( "consequentiality_reason": evaluation.consequentiality_reason, "policy_version": self.policy.version, "policy_hash": self.policy.policy_hash, + "policy_file_versions": dict(self.policy.policy_file_versions), "domain_overlay_version": overlay_obj.version if overlay_obj else None, "domain_overlay_hash": overlay_obj.overlay_hash if overlay_obj else None, "tool_registry_hash": self.policy.tool_registry_hash, @@ -182,6 +183,7 @@ def evaluate( "uncertainty_flags": classification.uncertainty_flags, "classifier_mode": classification.classifier_mode, }, + "llm_advisory": classification.llm_metadata, "ai_output_summary": ( ai_output if isinstance(ai_output, str) else (ai_output.get("summary") if isinstance(ai_output, dict) else str(ai_output)) diff --git a/akta/overlays.py b/akta/overlays.py index 278e5a1..62a47c1 100644 --- a/akta/overlays.py +++ b/akta/overlays.py @@ -1,4 +1,4 @@ -"""Domain overlay loading and constraints.""" +"""Domain overlay loading and constraints (v0.5 governance).""" from __future__ import annotations @@ -10,6 +10,7 @@ from akta.errors import PolicyError from akta.hash import hash_file_content +from akta.policy_integrity import is_production_mode OVERLAY_ALIASES = { @@ -25,6 +26,20 @@ "clinical_placeholder": "clinical_v0.yaml", } +OVERLAY_TIERS = frozenset({ + "core_reference", + "experimental_domain_overlay", + "expert_reviewed_domain_overlay", + "institutional_deployment_overlay", +}) + +HIGH_RISK_DOMAINS = frozenset({"biology", "chemistry", "clinical"}) +PRODUCTION_APPROVED_TIERS = frozenset({ + "core_reference", + "expert_reviewed_domain_overlay", + "institutional_deployment_overlay", +}) + @dataclass class DomainOverlay: @@ -39,6 +54,10 @@ class DomainOverlay: def version(self) -> str: return self.data.get("version", self.name) + @property + def tier(self) -> str: + return str(self.data.get("tier", "experimental_domain_overlay")) + @property def operational(self) -> bool: return bool(self.data.get("operational", True)) @@ -64,7 +83,26 @@ def load(cls, overlay_name: str, overlays_dir: str | Path | None = None) -> Doma raise PolicyError(f"Domain overlay not found: {overlay_name}") content = path.read_text(encoding="utf-8") data = yaml.safe_load(content) - return cls(name=overlay_name, data=data, overlay_hash=hash_file_content(content), path=path) + overlay = cls(name=overlay_name, data=data, overlay_hash=hash_file_content(content), path=path) + overlay.enforce_production_governance() + return overlay + + def enforce_production_governance(self) -> None: + """Refuse high-risk overlays in production unless expert-reviewed tier.""" + if not is_production_mode(): + return + tier = self.tier + if tier not in OVERLAY_TIERS: + raise PolicyError( + f"Domain overlay {self.name} missing or invalid tier for production: {tier}" + ) + if self.domain in HIGH_RISK_DOMAINS or tier == "experimental_domain_overlay": + if tier not in PRODUCTION_APPROVED_TIERS: + raise PolicyError( + f"Production mode refuses overlay {self.name} (tier={tier}). " + "High-risk domain overlays require expert_reviewed_domain_overlay " + "or institutional_deployment_overlay tier." + ) def blocked_actions(self) -> list[str]: return list(self.data.get("blocked_actions", [])) diff --git a/akta/policy.py b/akta/policy.py index e8a3df6..22797ea 100644 --- a/akta/policy.py +++ b/akta/policy.py @@ -42,9 +42,10 @@ class PolicyBundle: evidence_to_action_rules: dict[str, Any] tool_registry: dict[str, Any] tool_to_requested_scope: dict[str, Any] - version: str = "akta-core-v0.4" + version: str = "akta-core-v0.5" policy_hash: str = "" tool_registry_hash: str = "" + policy_file_versions: dict[str, str] = field(default_factory=dict, repr=False) _raw_files: dict[str, str] = field(default_factory=dict, repr=False) @classmethod @@ -59,13 +60,16 @@ def from_dir( loaded: dict[str, Any] = {} raw_files: dict[str, str] = {} + file_versions: dict[str, str] = {} for name in POLICY_FILES: path = policy_dir / name if not path.exists(): raise PolicyError(f"Missing policy file: {path}") content = path.read_text(encoding="utf-8") raw_files[name] = content - loaded[name.replace(".yaml", "")] = yaml.safe_load(content) + parsed = yaml.safe_load(content) + loaded[name.replace(".yaml", "")] = parsed + file_versions[name] = parsed.get("policy_file_version", parsed.get("version", "unknown")) registry_path = Path(tool_registry_path) if tool_registry_path else policy_dir / "default_tool_registry.yaml" if not registry_path.exists(): @@ -73,6 +77,9 @@ def from_dir( registry_content = registry_path.read_text(encoding="utf-8") raw_files["default_tool_registry.yaml"] = registry_content tool_registry = yaml.safe_load(registry_content) + file_versions["default_tool_registry.yaml"] = tool_registry.get( + "policy_file_version", tool_registry.get("version", "unknown") + ) scope_path = policy_dir / "tool_to_requested_scope.yaml" if not scope_path.exists(): @@ -80,8 +87,14 @@ def from_dir( scope_content = scope_path.read_text(encoding="utf-8") raw_files["tool_to_requested_scope.yaml"] = scope_content tool_to_requested_scope = yaml.safe_load(scope_content) + file_versions["tool_to_requested_scope.yaml"] = tool_to_requested_scope.get( + "policy_file_version", tool_to_requested_scope.get("version", "unknown") + ) - version = loaded["action_ontology"].get("version", "akta-core-v0.4") + version = loaded["action_ontology"].get( + "policy_bundle_version", + loaded["action_ontology"].get("version", "akta-core-v0.5"), + ) policy_hash = hash_object({ k: raw_files[k] for k in sorted(raw_files) @@ -107,6 +120,7 @@ def from_dir( version=version, policy_hash=policy_hash, tool_registry_hash=tool_registry_hash, + policy_file_versions=file_versions, _raw_files=raw_files, ) diff --git a/akta/policy_integrity.py b/akta/policy_integrity.py index 08e7a4d..680e860 100644 --- a/akta/policy_integrity.py +++ b/akta/policy_integrity.py @@ -1,9 +1,10 @@ -"""Optional policy bundle signature verification (v0.4).""" +"""Optional policy bundle signature verification (v0.5).""" from __future__ import annotations import hashlib import hmac +import logging import os from pathlib import Path from typing import Any @@ -13,9 +14,18 @@ from akta.errors import PolicyError from akta.hash import hash_file_content +logger = logging.getLogger(__name__) MANIFEST_FILENAME = "policy_manifest.yaml" DEFAULT_HMAC_KEY = b"akta-dev-policy-integrity-v0.4-test-key" +DEV_HMAC_KEY_LABEL = "akta-dev-policy-integrity" + + +def is_production_mode() -> bool: + """True when strict production policy verification is required.""" + prod = os.environ.get("AKTA_PRODUCTION_MODE", "").lower() in ("1", "true", "yes") + verify = os.environ.get("AKTA_VERIFY_POLICY", "").lower() in ("1", "true", "yes") + return prod or verify def _load_manifest(policy_dir: Path) -> dict[str, Any] | None: @@ -44,6 +54,32 @@ def verify_manifest_hashes(policy_dir: Path, manifest: dict[str, Any]) -> None: ) +def _resolve_hmac_key(*, production: bool) -> bytes | None: + """Resolve HMAC key; reject dev key in production.""" + key_env = os.environ.get("AKTA_POLICY_HMAC_KEY") + if key_env: + if production and DEV_HMAC_KEY_LABEL in key_env: + raise PolicyError( + "Production mode rejects in-repo dev HMAC key; " + "set AKTA_POLICY_HMAC_KEY to a deployment-specific secret" + ) + return key_env.encode("utf-8") + + pub_key = os.environ.get("AKTA_POLICY_PUBLIC_KEY") + if pub_key and production: + raise PolicyError( + "Ed25519 public-key verification not yet implemented; " + "use AKTA_POLICY_HMAC_KEY in production" + ) + + if production: + raise PolicyError( + "Production mode requires AKTA_POLICY_HMAC_KEY or AKTA_POLICY_PUBLIC_KEY" + ) + + return DEFAULT_HMAC_KEY + + def verify_hmac_signature(manifest: dict[str, Any], key: bytes) -> None: """Verify HMAC-SHA256 signature over manifest file hashes.""" sig_block = manifest.get("signature", {}) @@ -59,14 +95,15 @@ def verify_hmac_signature(manifest: dict[str, Any], key: bytes) -> None: def verify_policy_integrity(policy_dir: str | Path, *, required: bool | None = None) -> bool: - """Verify policy manifest when present or when AKTA_VERIFY_POLICY=1.""" + """Verify policy manifest when present or when production/verify mode is active.""" policy_dir = Path(policy_dir) + production = is_production_mode() if required is None: - required = os.environ.get("AKTA_VERIFY_POLICY", "").lower() in ("1", "true", "yes") + required = production manifest = _load_manifest(policy_dir) if manifest is None: - if required: + if required or production: raise PolicyError(f"Policy verification required but no {MANIFEST_FILENAME} found") return False @@ -74,10 +111,15 @@ def verify_policy_integrity(policy_dir: str | Path, *, required: bool | None = N sig = manifest.get("signature") if sig: - key_env = os.environ.get("AKTA_POLICY_HMAC_KEY") - key = key_env.encode("utf-8") if key_env else DEFAULT_HMAC_KEY + key = _resolve_hmac_key(production=production) + if key is None: + raise PolicyError("Policy manifest signature present but no verification key configured") verify_hmac_signature(manifest, key) - elif required: + if not production and key == DEFAULT_HMAC_KEY: + logger.warning( + "Policy HMAC verified with dev key; set AKTA_POLICY_HMAC_KEY for production" + ) + elif production: raise PolicyError("Policy manifest missing signature block") return True diff --git a/docs/classifier_trust_boundary.md b/docs/classifier_trust_boundary.md new file mode 100644 index 0000000..b36b1bf --- /dev/null +++ b/docs/classifier_trust_boundary.md @@ -0,0 +1,45 @@ +# Classifier Trust Boundary (v0.5) + +AKTA treats deterministic classification as authoritative. Optional LLM classifiers operate in an **advisory-only** trust zone and never override known tool-registry mappings. + +## Trust zones + +| Zone | Source | Authority | +|------|--------|-----------| +| **Deterministic** | Tool registry, structured action, requested-action keywords | Authoritative for admissibility | +| **LLM advisory** | `OptionalLLMClassifierPlugin` when enabled | Proposes action type only when registry cannot resolve | +| **Conservative fallback** | `ConservativeFallbackClassifierPlugin` | Default when all else fails | + +## Tool registry override + +When `tool_spec.known` is true, the tool registry mapping **always** wins over LLM output. The classifier records `llm_overridden_by_tool_registry` in uncertainty flags when an LLM plugin would have suggested a different type. + +## LLM advisory metadata + +When `classifier_mode` is `llm_advisory`, the AKTA Decision includes: + +- `classification.classifier_mode`: `llm_advisory` +- `llm_advisory.model`: model identifier +- `llm_advisory.prompt_hash`: SHA-256 of system prompt + user text +- `llm_advisory.schema`: `akta_classification_v0.5` +- `llm_advisory.confidence`: model-reported confidence + +## Fail-closed for mutating and external tools + +If LLM advisory confidence is below 0.7 and the requested tool is mutating, external-effect, or unknown, admissibility resolves to `abstain_insufficient_context`. + +## Enabling LLM classifier + +```bash +export AKTA_LLM_CLASSIFIER=1 +export OPENAI_API_KEY=sk-... +# optional: export AKTA_LLM_MODEL=gpt-4o-mini +``` + +Without `OPENAI_API_KEY`, the LLM plugin is disabled (fail-closed). + +## Production guidance + +- Do not treat LLM classification as policy authority. +- Pin policy bundle version and verify integrity (`AKTA_PRODUCTION_MODE=1`). +- Audit `llm_advisory` fields in AKTA Records when LLM path is enabled. diff --git a/docs/integration_guide.md b/docs/integration_guide.md index 02de4bd..9c95528 100644 --- a/docs/integration_guide.md +++ b/docs/integration_guide.md @@ -1,7 +1,33 @@ -# AKTA Integration Guide (v0.4) +# AKTA Integration Guide (v0.5) AKTA integrates with adjacent systems in the AI-for-science trust stack. +## v0.5 integration additions + +| Component | Path | Purpose | +|-----------|------|---------| +| SCOPE adapter | `adapters/scope/client.py` | Simulated, python-import (`SCOPE_REPO_PATH`), or CLI (`SCOPE_CLI`) | +| SCOPE engine protocol | `adapters/scope/engine_protocol.py` | Expected methods for python-import mode | +| PCS v0.5 full chain | `adapters/pcs/export_artifact.py` | 10 artifacts, `file_hashes`, tamper validation | +| Production policy integrity | `akta/policy_integrity.py` | Dev vs production HMAC; manifest required in production | +| Overlay governance | `akta/overlays.py` | Tiers; production refuses experimental overlays | +| LLM trust boundary | `docs/classifier_trust_boundary.md` | Tool registry overrides LLM; advisory metadata only | + +```bash +# Production mode +export AKTA_PRODUCTION_MODE=1 +export AKTA_POLICY_HMAC_KEY="" +python scripts/regenerate_policy_manifest.py # after policy edits + +# SCOPE modes +export SCOPE_REPO_PATH=/path/to/SCOPE # python-import +export SCOPE_CLI=scope # CLI subprocess +python scripts/demo_akta_scope_protocol_drift.py + +# PCS full-chain validate +akta export pcs --record akta_record.json --decision akta_decision.json --out pcs_bundle/ --validate +``` + ## v0.4 integration additions | Component | Path | Purpose | @@ -22,7 +48,7 @@ python evals/run_oracle_independent.py --out evals/reports/oracle_independent.js # MCP server (stdio) python -m adapters.mcp.server -# REST API v0.4 +# REST API v0.5 akta-rest --host 127.0.0.1 --port 8765 ``` @@ -76,7 +102,7 @@ See [scope_bridge.md](scope_bridge.md) and [review_integration.md](review_integr | Consequentiality | Decision `consequentiality` / `consequentiality_reason` | | Rich classifier | Decision `classification` audit block | | PF obligation v0.2 | `enforcement_mode`, `required_runtime_behavior` | -| PCS bundle v0.4 | `schema_version: akta-record-v0.4` | +| PCS bundle v0.5 | `schema_version: akta-record-v0.5` | ## Python API diff --git a/docs/policy_integrity.md b/docs/policy_integrity.md index 9848e9e..2015f5a 100644 --- a/docs/policy_integrity.md +++ b/docs/policy_integrity.md @@ -21,7 +21,8 @@ Every AKTA Decision and Record includes: ```json { - "policy_version": "akta-core-v0.4", + "policy_version": "akta-core-v0.5", + "policy_file_versions": { "action_ontology.yaml": "action_ontology-v0.5", "...": "..." }, "policy_hash": "sha256:...", "tool_registry_hash": "sha256:..." } @@ -47,7 +48,16 @@ If policy files are modified without updating references: 2. PF-Core obligations become invalid 3. PCS manifest hash files will not match record provenance -v0.4 includes optional HMAC-SHA256 manifest verification via `policy/policy_manifest.yaml`. Enable with `AKTA_VERIFY_POLICY=1` or pass `required=True` to `verify_policy_integrity()`. Production deployments should pin policy versions and verify hashes (and signatures when configured) at startup. +v0.5 separates dev and production verification: + +| Mode | Env | Behavior | +|------|-----|----------| +| Dev (default) | — | Verify hashes when manifest exists; dev HMAC key OK with warning | +| Production | `AKTA_PRODUCTION_MODE=1` or `AKTA_VERIFY_POLICY=1` | Requires manifest + deployment `AKTA_POLICY_HMAC_KEY`; rejects dev key | + +Regenerate manifest after policy edits: `python scripts/regenerate_policy_manifest.py` + +v0.4 included optional HMAC-SHA256 manifest verification via `policy/policy_manifest.yaml`. ## Tool registry integrity diff --git a/docs/scope_bridge.md b/docs/scope_bridge.md index 8924b9b..6b76525 100644 --- a/docs/scope_bridge.md +++ b/docs/scope_bridge.md @@ -77,41 +77,33 @@ akta gate --output ai_output.json --tool protocol_editor.update_active_protocol akta review-trigger export --decision decision.json --out review_trigger.json ``` -## SCOPE adapter (v0.4) +## SCOPE adapter (v0.5) -`adapters/scope/client.py` supports two modes: +`adapters/scope/client.py` supports three modes (auto-detected): | Mode | When | Behavior | |------|------|----------| -| **simulated** | Default (no env vars) | Uses `akta/scope_contract.py` to assemble packets and validate grants | -| **subprocess** | `SCOPE_CLI` or `SCOPE_REPO_PATH` set | Invokes external SCOPE CLI | +| **simulated** | Default (no env vars) | Contract simulation via `akta/scope_contract.py` | +| **python-import** | `SCOPE_REPO_PATH` set | Imports `scope.ScopeEngine` from sibling repo | +| **cli** | `SCOPE_CLI` set (no repo path) | `scope packet create`, `scope decision submit`, `scope grant issue` | -### Subprocess setup +Priority: `SCOPE_REPO_PATH` → python-import; else `SCOPE_CLI` → cli; else simulated. -```powershell -$env:SCOPE_CLI = "scope" # CLI on PATH, or full path -$env:SCOPE_REPO_PATH = "C:\path\to\SCOPE" # optional repo root - -python -c " -from adapters.scope.client import submit_review_trigger -trigger = {'review_trigger_id': 'AKTA-REVTRIG-DEMO', 'requested_scope': 'protocol_draft'} -print(submit_review_trigger(trigger)) -" -``` - -stdin JSON to SCOPE: +### CLI setup -```json -{"trigger": {...}, "grant_scope": "protocol_draft", "reviewer_id": "scope_reviewer"} +```powershell +$env:SCOPE_CLI = "scope" +python scripts/demo_akta_scope_protocol_drift.py ``` -Expected stdout JSON: +### Python import setup -```json -{"review_packet": {...}, "grant": {...}, "decision": {"status": "granted", "granted_scope": "..."}} +```powershell +$env:SCOPE_REPO_PATH = "C:\path\to\SCOPE" +python scripts/demo_akta_scope_protocol_drift.py ``` -See [tests/contracts/README.md](../tests/contracts/README.md) for cross-repo contract test instructions. +Invalid grants fail before PCS export. See [tests/contracts/README.md](../tests/contracts/README.md) and `adapters/scope/engine_protocol.py` for the python-import engine interface. ## PCS bundle inclusion diff --git a/examples/integrated_protocol_drift/akta_decision_active_update.json b/examples/integrated_protocol_drift/akta_decision_active_update.json index ce14308..48e55e6 100644 --- a/examples/integrated_protocol_drift/akta_decision_active_update.json +++ b/examples/integrated_protocol_drift/akta_decision_active_update.json @@ -24,11 +24,24 @@ "authorization_required": false, "consequentiality": true, "consequentiality_reason": "mutating tool; external effect; action type A5_protocol_modification; active protocol mutation", - "policy_version": "akta-core-v0.4", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", + "policy_version": "akta-core-v0.5", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "policy_file_versions": { + "action_ontology.yaml": "action_ontology-v0.5", + "responsibility_levels.yaml": "responsibility_levels-v0.5", + "evidence_states.yaml": "evidence_states-v0.5", + "validation_statuses.yaml": "validation_statuses-v0.5", + "verification_statuses.yaml": "verification_statuses-v0.5", + "deployment_profiles.yaml": "deployment_profiles-v0.5", + "admissibility_matrix.yaml": "admissibility_matrix-v0.5", + "evidence_to_action_matrix.yaml": "evidence_to_action_matrix-v0.5", + "evidence_to_action_rules.yaml": "evidence_to_action_rules-v0.5", + "default_tool_registry.yaml": "default_tool_registry-v0.5", + "tool_to_requested_scope.yaml": "tool_to_requested_scope-v0.5" + }, "domain_overlay_version": "generic_lab_v0.2", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "classifier_confidence": 0.98, "classification_rationale": "tool registry maps protocol_editor.update_active_protocol to A5_protocol_modification", "classification": { @@ -39,6 +52,7 @@ "uncertainty_flags": [], "classifier_mode": "deterministic" }, + "llm_advisory": null, "ai_output_summary": "Change temperature threshold from 37C to 39C in the active protocol.", "review_trigger": { "review_trigger_id": "AKTA-REVTRIG-DRIFT0001", @@ -74,9 +88,9 @@ "allowed_next_steps": [], "approval_effect": "Allows scoped next step only; not global permission.", "default_expiration": "single_run", - "expires_at": "2026-06-28T08:52:42Z", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "expires_at": "2026-06-28T13:24:48Z", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "classifier_confidence": 0.98, "classification_rationale": "tool registry maps protocol_editor.update_active_protocol to A5_protocol_modification", "consequentiality": true, @@ -87,7 +101,7 @@ "system_id": "protocol_drift_demo" }, "review_route": "active_protocol_review", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "review_trigger_hash": "sha256:f9fdc8310cda27a293dbb502ee0db7d866a9451f86f5e3d82167d46bc10db071" + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "review_trigger_hash": "sha256:3826696bfbff31c2d34eaf28011b5b9d88f2ef7c6e4efe2aab45382694e550e0" } } \ No newline at end of file diff --git a/examples/integrated_protocol_drift/akta_decision_draft_only.json b/examples/integrated_protocol_drift/akta_decision_draft_only.json index 0f68261..3bd640c 100644 --- a/examples/integrated_protocol_drift/akta_decision_draft_only.json +++ b/examples/integrated_protocol_drift/akta_decision_draft_only.json @@ -28,11 +28,24 @@ "authorization_required": false, "consequentiality": true, "consequentiality_reason": "action type A5_protocol_modification", - "policy_version": "akta-core-v0.4", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", + "policy_version": "akta-core-v0.5", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "policy_file_versions": { + "action_ontology.yaml": "action_ontology-v0.5", + "responsibility_levels.yaml": "responsibility_levels-v0.5", + "evidence_states.yaml": "evidence_states-v0.5", + "validation_statuses.yaml": "validation_statuses-v0.5", + "verification_statuses.yaml": "verification_statuses-v0.5", + "deployment_profiles.yaml": "deployment_profiles-v0.5", + "admissibility_matrix.yaml": "admissibility_matrix-v0.5", + "evidence_to_action_matrix.yaml": "evidence_to_action_matrix-v0.5", + "evidence_to_action_rules.yaml": "evidence_to_action_rules-v0.5", + "default_tool_registry.yaml": "default_tool_registry-v0.5", + "tool_to_requested_scope.yaml": "tool_to_requested_scope-v0.5" + }, "domain_overlay_version": "generic_lab_v0.2", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "classifier_confidence": 0.98, "classification_rationale": "tool registry maps protocol_editor.draft_change to A5_protocol_modification", "classification": { @@ -43,5 +56,6 @@ "uncertainty_flags": [], "classifier_mode": "deterministic" }, + "llm_advisory": null, "ai_output_summary": "Draft protocol threshold change for owner review." } \ No newline at end of file diff --git a/examples/integrated_protocol_drift/akta_record_active_update.json b/examples/integrated_protocol_drift/akta_record_active_update.json index df0bb39..cc4c8b8 100644 --- a/examples/integrated_protocol_drift/akta_record_active_update.json +++ b/examples/integrated_protocol_drift/akta_record_active_update.json @@ -52,11 +52,11 @@ "consequentiality_reason": "mutating tool; external effect; action type A5_protocol_modification; active protocol mutation" }, "provenance": { - "policy_version": "akta-core-v0.4", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", + "policy_version": "akta-core-v0.5", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", "domain_overlay_version": "generic_lab_v0.2", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c" + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a" }, "integrations": { "vsa_report_ref": null, @@ -98,9 +98,9 @@ "allowed_next_steps": [], "approval_effect": "Allows scoped next step only; not global permission.", "default_expiration": "single_run", - "expires_at": "2026-06-28T08:52:42Z", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "expires_at": "2026-06-28T13:24:48Z", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "classifier_confidence": 0.98, "classification_rationale": "tool registry maps protocol_editor.update_active_protocol to A5_protocol_modification", "consequentiality": true, @@ -111,8 +111,8 @@ "system_id": "protocol_drift_demo" }, "review_route": "active_protocol_review", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "review_trigger_hash": "sha256:f9fdc8310cda27a293dbb502ee0db7d866a9451f86f5e3d82167d46bc10db071" + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "review_trigger_hash": "sha256:3826696bfbff31c2d34eaf28011b5b9d88f2ef7c6e4efe2aab45382694e550e0" }, - "record_hash": "sha256:cc2fdfe1aa6c243dcfbacac9f46e24cfc3d30f65f5431d82326eecd5481ff614" + "record_hash": "sha256:3ac8a1b29ee653b79afe83420a82f2bbeb2153b4a2f4714b75cfba9c3cc90c05" } \ No newline at end of file diff --git a/examples/integrated_protocol_drift/pcs_bundle/akta_decision.json b/examples/integrated_protocol_drift/pcs_bundle/akta_decision.json index ce14308..48e55e6 100644 --- a/examples/integrated_protocol_drift/pcs_bundle/akta_decision.json +++ b/examples/integrated_protocol_drift/pcs_bundle/akta_decision.json @@ -24,11 +24,24 @@ "authorization_required": false, "consequentiality": true, "consequentiality_reason": "mutating tool; external effect; action type A5_protocol_modification; active protocol mutation", - "policy_version": "akta-core-v0.4", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", + "policy_version": "akta-core-v0.5", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "policy_file_versions": { + "action_ontology.yaml": "action_ontology-v0.5", + "responsibility_levels.yaml": "responsibility_levels-v0.5", + "evidence_states.yaml": "evidence_states-v0.5", + "validation_statuses.yaml": "validation_statuses-v0.5", + "verification_statuses.yaml": "verification_statuses-v0.5", + "deployment_profiles.yaml": "deployment_profiles-v0.5", + "admissibility_matrix.yaml": "admissibility_matrix-v0.5", + "evidence_to_action_matrix.yaml": "evidence_to_action_matrix-v0.5", + "evidence_to_action_rules.yaml": "evidence_to_action_rules-v0.5", + "default_tool_registry.yaml": "default_tool_registry-v0.5", + "tool_to_requested_scope.yaml": "tool_to_requested_scope-v0.5" + }, "domain_overlay_version": "generic_lab_v0.2", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "classifier_confidence": 0.98, "classification_rationale": "tool registry maps protocol_editor.update_active_protocol to A5_protocol_modification", "classification": { @@ -39,6 +52,7 @@ "uncertainty_flags": [], "classifier_mode": "deterministic" }, + "llm_advisory": null, "ai_output_summary": "Change temperature threshold from 37C to 39C in the active protocol.", "review_trigger": { "review_trigger_id": "AKTA-REVTRIG-DRIFT0001", @@ -74,9 +88,9 @@ "allowed_next_steps": [], "approval_effect": "Allows scoped next step only; not global permission.", "default_expiration": "single_run", - "expires_at": "2026-06-28T08:52:42Z", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "expires_at": "2026-06-28T13:24:48Z", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "classifier_confidence": 0.98, "classification_rationale": "tool registry maps protocol_editor.update_active_protocol to A5_protocol_modification", "consequentiality": true, @@ -87,7 +101,7 @@ "system_id": "protocol_drift_demo" }, "review_route": "active_protocol_review", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "review_trigger_hash": "sha256:f9fdc8310cda27a293dbb502ee0db7d866a9451f86f5e3d82167d46bc10db071" + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "review_trigger_hash": "sha256:3826696bfbff31c2d34eaf28011b5b9d88f2ef7c6e4efe2aab45382694e550e0" } } \ No newline at end of file diff --git a/examples/integrated_protocol_drift/pcs_bundle/akta_record.json b/examples/integrated_protocol_drift/pcs_bundle/akta_record.json index df0bb39..cc4c8b8 100644 --- a/examples/integrated_protocol_drift/pcs_bundle/akta_record.json +++ b/examples/integrated_protocol_drift/pcs_bundle/akta_record.json @@ -52,11 +52,11 @@ "consequentiality_reason": "mutating tool; external effect; action type A5_protocol_modification; active protocol mutation" }, "provenance": { - "policy_version": "akta-core-v0.4", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", + "policy_version": "akta-core-v0.5", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", "domain_overlay_version": "generic_lab_v0.2", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c" + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a" }, "integrations": { "vsa_report_ref": null, @@ -98,9 +98,9 @@ "allowed_next_steps": [], "approval_effect": "Allows scoped next step only; not global permission.", "default_expiration": "single_run", - "expires_at": "2026-06-28T08:52:42Z", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "expires_at": "2026-06-28T13:24:48Z", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "classifier_confidence": 0.98, "classification_rationale": "tool registry maps protocol_editor.update_active_protocol to A5_protocol_modification", "consequentiality": true, @@ -111,8 +111,8 @@ "system_id": "protocol_drift_demo" }, "review_route": "active_protocol_review", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "review_trigger_hash": "sha256:f9fdc8310cda27a293dbb502ee0db7d866a9451f86f5e3d82167d46bc10db071" + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "review_trigger_hash": "sha256:3826696bfbff31c2d34eaf28011b5b9d88f2ef7c6e4efe2aab45382694e550e0" }, - "record_hash": "sha256:cc2fdfe1aa6c243dcfbacac9f46e24cfc3d30f65f5431d82326eecd5481ff614" + "record_hash": "sha256:3ac8a1b29ee653b79afe83420a82f2bbeb2153b4a2f4714b75cfba9c3cc90c05" } \ No newline at end of file diff --git a/examples/integrated_protocol_drift/pcs_bundle/domain_overlay_hash.txt b/examples/integrated_protocol_drift/pcs_bundle/domain_overlay_hash.txt index 9a8dd5d..f854a5f 100644 --- a/examples/integrated_protocol_drift/pcs_bundle/domain_overlay_hash.txt +++ b/examples/integrated_protocol_drift/pcs_bundle/domain_overlay_hash.txt @@ -1 +1 @@ -sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab \ No newline at end of file +sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117 \ No newline at end of file diff --git a/examples/integrated_protocol_drift/pcs_bundle/manifest.json b/examples/integrated_protocol_drift/pcs_bundle/manifest.json index 5c4eca1..20d6e3b 100644 --- a/examples/integrated_protocol_drift/pcs_bundle/manifest.json +++ b/examples/integrated_protocol_drift/pcs_bundle/manifest.json @@ -1,20 +1,35 @@ { "artifact_type": "akta_scientific_action_record", - "schema_version": "akta-record-v0.4", - "record_hash": "sha256:cc2fdfe1aa6c243dcfbacac9f46e24cfc3d30f65f5431d82326eecd5481ff614", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "schema_version": "akta-record-v0.5", + "record_hash": "sha256:3ac8a1b29ee653b79afe83420a82f2bbeb2153b4a2f4714b75cfba9c3cc90c05", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "decision_id": "AKTA-DEC-DRIFT0001", "files": [ - "akta_record.json", "akta_decision.json", - "policy_hash.txt", + "akta_record.json", "domain_overlay_hash.txt", - "tool_registry_hash.txt", + "manifest.json", + "pf_obligation.json", + "policy_hash.txt", "review_trigger.json", + "scope_decision.json", + "scope_grant.json", "scope_review_packet.json", - "manifest.json" + "tool_registry_hash.txt" ], - "manifest_hash": "sha256:17b3eadfa5a4043243b665f31561786db1dcba82eee658145b1cab4474bb0e00" + "file_hashes": { + "akta_decision.json": "sha256:5bfd0b359c63421f2369251037c8823cc12f23d7e183c819ae86a9330dae9767", + "akta_record.json": "sha256:6e07f468fb7149d9646d6dae8cdf3cf86f4dccceb6c4c6dfd3611b47733967e6", + "domain_overlay_hash.txt": "sha256:aff97a5c94f9f99549b9f4043617ba8dcfce973312fde13d63101d173bcb0860", + "pf_obligation.json": "sha256:9c534e59645bbb43efb388dcaf5fb7c44c49aa3a128f32bcb9e69ed863881e11", + "policy_hash.txt": "sha256:918d683b234533a1c7da8178e581eff636b2786f9b7cc373873ea805994b15ff", + "review_trigger.json": "sha256:5d53e1970c5e390b2d65698b00fcb490941ff1c5afc1d4d8137d48c4ab3e8e4b", + "scope_decision.json": "sha256:f4cbdd8608cf0cc49c9b139fe31ef1504ec9d17e3d5dde9d95a0c4d28f841088", + "scope_grant.json": "sha256:8025e247844ffb17efd9a41755319b011a1aae7463d86df416134da816597334", + "scope_review_packet.json": "sha256:860ad06369f4920b24a52bfbcf68ea6b6f187a6442e96646ec0f084e3664b801", + "tool_registry_hash.txt": "sha256:6375791d242c7aad891c23def053d895a7ab6ac579524866cf45fa5a2e7e9863" + }, + "manifest_hash": "sha256:6d490365f8e19f5f29d26bb2e6f5aa0edd511cef3ec1ac3008b2fb16644fce94" } \ No newline at end of file diff --git a/examples/integrated_protocol_drift/pcs_bundle/pf_obligation.json b/examples/integrated_protocol_drift/pcs_bundle/pf_obligation.json new file mode 100644 index 0000000..42a0d8c --- /dev/null +++ b/examples/integrated_protocol_drift/pcs_bundle/pf_obligation.json @@ -0,0 +1,35 @@ +{ + "obligation_id": "PF-OBL-AKTA-SAR-DRIFT0001", + "obligation_type": "tool_review", + "source": "AKTA", + "source_record_id": "AKTA-SAR-DRIFT0001", + "decision_id": "AKTA-DEC-DRIFT0001", + "decision": "review_required", + "decision_reason_hash": "sha256:24c91bb03ef39f5f31307b9c0e338df1ae28965f62984ca015674f4d9ef01635", + "blocked_tools": [ + "protocol_editor.update_active_protocol" + ], + "allowed_tools": [], + "max_responsibility_level": "R4_methodological_modification", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "scope_grant_ref": null, + "review_trigger_id": "AKTA-REVTRIG-DRIFT0001", + "expires_at": "2026-06-28T13:24:48Z", + "enforcement_mode": "review_gate", + "required_runtime_behavior": { + "block_execution": false, + "require_review_before_tool_call": true, + "require_authorization_before_tool_call": false, + "blocked_tools": [ + "protocol_editor.update_active_protocol" + ], + "allowed_tools": [], + "log_all_tool_calls": false + }, + "next_admissible_steps": [], + "required_review_role": "protocol_owner", + "consequentiality": true, + "obligation_hash": "sha256:f910bae52fca3e99ea9bea1c36b98a35bc8ae3a6645c3d36df699a8acb439d9f" +} \ No newline at end of file diff --git a/examples/integrated_protocol_drift/pcs_bundle/policy_hash.txt b/examples/integrated_protocol_drift/pcs_bundle/policy_hash.txt index 829966d..0e38d7d 100644 --- a/examples/integrated_protocol_drift/pcs_bundle/policy_hash.txt +++ b/examples/integrated_protocol_drift/pcs_bundle/policy_hash.txt @@ -1 +1 @@ -sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d \ No newline at end of file +sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464 \ No newline at end of file diff --git a/examples/integrated_protocol_drift/pcs_bundle/review_trigger.json b/examples/integrated_protocol_drift/pcs_bundle/review_trigger.json index 3de591f..8961ed6 100644 --- a/examples/integrated_protocol_drift/pcs_bundle/review_trigger.json +++ b/examples/integrated_protocol_drift/pcs_bundle/review_trigger.json @@ -32,9 +32,9 @@ "allowed_next_steps": [], "approval_effect": "Allows scoped next step only; not global permission.", "default_expiration": "single_run", - "expires_at": "2026-06-28T08:52:42Z", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "expires_at": "2026-06-28T13:24:48Z", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "classifier_confidence": 0.98, "classification_rationale": "tool registry maps protocol_editor.update_active_protocol to A5_protocol_modification", "consequentiality": true, @@ -45,6 +45,6 @@ "system_id": "protocol_drift_demo" }, "review_route": "active_protocol_review", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "review_trigger_hash": "sha256:f9fdc8310cda27a293dbb502ee0db7d866a9451f86f5e3d82167d46bc10db071" + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "review_trigger_hash": "sha256:3826696bfbff31c2d34eaf28011b5b9d88f2ef7c6e4efe2aab45382694e550e0" } \ No newline at end of file diff --git a/examples/integrated_protocol_drift/pcs_bundle/scope_decision.json b/examples/integrated_protocol_drift/pcs_bundle/scope_decision.json new file mode 100644 index 0000000..00d3c4d --- /dev/null +++ b/examples/integrated_protocol_drift/pcs_bundle/scope_decision.json @@ -0,0 +1,5 @@ +{ + "status": "granted", + "granted_scope": "protocol_draft", + "reviewer_id": "protocol_owner" +} \ No newline at end of file diff --git a/examples/integrated_protocol_drift/pcs_bundle/scope_grant.json b/examples/integrated_protocol_drift/pcs_bundle/scope_grant.json new file mode 100644 index 0000000..70e4b8b --- /dev/null +++ b/examples/integrated_protocol_drift/pcs_bundle/scope_grant.json @@ -0,0 +1,7 @@ +{ + "grant_id": "SCOPE-GRANT-AKTA-REVTRIG-DRIFT0001", + "granted_scope": "protocol_draft", + "requested_scope": "active_protocol_update", + "reviewer_id": "protocol_owner", + "review_trigger_id": "AKTA-REVTRIG-DRIFT0001" +} \ No newline at end of file diff --git a/examples/integrated_protocol_drift/pcs_bundle/scope_review_packet.json b/examples/integrated_protocol_drift/pcs_bundle/scope_review_packet.json index 8c6ffb9..4d2df1a 100644 --- a/examples/integrated_protocol_drift/pcs_bundle/scope_review_packet.json +++ b/examples/integrated_protocol_drift/pcs_bundle/scope_review_packet.json @@ -12,8 +12,8 @@ "protocol_editor.update_active_protocol" ], "allowed_next_steps": [], - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c" + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a" }, "packet_mode": "trigger_only" } \ No newline at end of file diff --git a/examples/integrated_protocol_drift/pcs_bundle/tool_registry_hash.txt b/examples/integrated_protocol_drift/pcs_bundle/tool_registry_hash.txt index 04103ee..e5c3a2c 100644 --- a/examples/integrated_protocol_drift/pcs_bundle/tool_registry_hash.txt +++ b/examples/integrated_protocol_drift/pcs_bundle/tool_registry_hash.txt @@ -1 +1 @@ -sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c \ No newline at end of file +sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a \ No newline at end of file diff --git a/examples/integrated_protocol_drift/pf_obligation.json b/examples/integrated_protocol_drift/pf_obligation.json index 8704d46..42a0d8c 100644 --- a/examples/integrated_protocol_drift/pf_obligation.json +++ b/examples/integrated_protocol_drift/pf_obligation.json @@ -11,12 +11,12 @@ ], "allowed_tools": [], "max_responsibility_level": "R4_methodological_modification", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", "scope_grant_ref": null, "review_trigger_id": "AKTA-REVTRIG-DRIFT0001", - "expires_at": "2026-06-28T08:52:42Z", + "expires_at": "2026-06-28T13:24:48Z", "enforcement_mode": "review_gate", "required_runtime_behavior": { "block_execution": false, @@ -31,5 +31,5 @@ "next_admissible_steps": [], "required_review_role": "protocol_owner", "consequentiality": true, - "obligation_hash": "sha256:738bb0b50387dfe4ce1edefd0dc72c826e35a4e17a8da43100f5f71b1e55a41a" + "obligation_hash": "sha256:f910bae52fca3e99ea9bea1c36b98a35bc8ae3a6645c3d36df699a8acb439d9f" } \ No newline at end of file diff --git a/examples/integrated_protocol_drift/review_trigger.json b/examples/integrated_protocol_drift/review_trigger.json index 3de591f..8961ed6 100644 --- a/examples/integrated_protocol_drift/review_trigger.json +++ b/examples/integrated_protocol_drift/review_trigger.json @@ -32,9 +32,9 @@ "allowed_next_steps": [], "approval_effect": "Allows scoped next step only; not global permission.", "default_expiration": "single_run", - "expires_at": "2026-06-28T08:52:42Z", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "expires_at": "2026-06-28T13:24:48Z", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "classifier_confidence": 0.98, "classification_rationale": "tool registry maps protocol_editor.update_active_protocol to A5_protocol_modification", "consequentiality": true, @@ -45,6 +45,6 @@ "system_id": "protocol_drift_demo" }, "review_route": "active_protocol_review", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "review_trigger_hash": "sha256:f9fdc8310cda27a293dbb502ee0db7d866a9451f86f5e3d82167d46bc10db071" + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "review_trigger_hash": "sha256:3826696bfbff31c2d34eaf28011b5b9d88f2ef7c6e4efe2aab45382694e550e0" } \ No newline at end of file diff --git a/examples/integrated_protocol_drift/scope_decision.json b/examples/integrated_protocol_drift/scope_decision.json new file mode 100644 index 0000000..00d3c4d --- /dev/null +++ b/examples/integrated_protocol_drift/scope_decision.json @@ -0,0 +1,5 @@ +{ + "status": "granted", + "granted_scope": "protocol_draft", + "reviewer_id": "protocol_owner" +} \ No newline at end of file diff --git a/examples/integrated_protocol_drift/scope_review_packet.json b/examples/integrated_protocol_drift/scope_review_packet.json index 8c6ffb9..4d2df1a 100644 --- a/examples/integrated_protocol_drift/scope_review_packet.json +++ b/examples/integrated_protocol_drift/scope_review_packet.json @@ -12,8 +12,8 @@ "protocol_editor.update_active_protocol" ], "allowed_next_steps": [], - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c" + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a" }, "packet_mode": "trigger_only" } \ No newline at end of file diff --git a/examples/integrated_weak_evidence/akta_decision.json b/examples/integrated_weak_evidence/akta_decision.json index 46f9bd7..a4bcfc0 100644 --- a/examples/integrated_weak_evidence/akta_decision.json +++ b/examples/integrated_weak_evidence/akta_decision.json @@ -32,11 +32,24 @@ "authorization_required": false, "consequentiality": true, "consequentiality_reason": "mutating tool; external effect; action type A7_resource_or_queue_prioritization; resource allocation; next-run or prioritization language", - "policy_version": "akta-core-v0.4", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", + "policy_version": "akta-core-v0.5", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "policy_file_versions": { + "action_ontology.yaml": "action_ontology-v0.5", + "responsibility_levels.yaml": "responsibility_levels-v0.5", + "evidence_states.yaml": "evidence_states-v0.5", + "validation_statuses.yaml": "validation_statuses-v0.5", + "verification_statuses.yaml": "verification_statuses-v0.5", + "deployment_profiles.yaml": "deployment_profiles-v0.5", + "admissibility_matrix.yaml": "admissibility_matrix-v0.5", + "evidence_to_action_matrix.yaml": "evidence_to_action_matrix-v0.5", + "evidence_to_action_rules.yaml": "evidence_to_action_rules-v0.5", + "default_tool_registry.yaml": "default_tool_registry-v0.5", + "tool_to_requested_scope.yaml": "tool_to_requested_scope-v0.5" + }, "domain_overlay_version": "generic_lab_v0.2", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "classifier_confidence": 0.98, "classification_rationale": "tool registry maps lab_scheduler.prioritize to A7_resource_or_queue_prioritization", "classification": { @@ -47,5 +60,6 @@ "uncertainty_flags": [], "classifier_mode": "deterministic" }, + "llm_advisory": null, "ai_output_summary": "The agent recommended prioritizing condition B based on a preliminary signal." } \ No newline at end of file diff --git a/examples/integrated_weak_evidence/akta_record.json b/examples/integrated_weak_evidence/akta_record.json index 5a30ebc..43e7447 100644 --- a/examples/integrated_weak_evidence/akta_record.json +++ b/examples/integrated_weak_evidence/akta_record.json @@ -60,11 +60,11 @@ "consequentiality_reason": "mutating tool; external effect; action type A7_resource_or_queue_prioritization; resource allocation; next-run or prioritization language" }, "provenance": { - "policy_version": "akta-core-v0.4", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", + "policy_version": "akta-core-v0.5", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", "domain_overlay_version": "generic_lab_v0.2", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c" + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a" }, "integrations": { "vsa_report_ref": "VSA-REPORT-INTEGRATED-001", @@ -72,5 +72,5 @@ "pcs_artifact_ref": null, "prior_akta_records": [] }, - "record_hash": "sha256:03e1b5ce977bdd5c16124bb3ee4da60912c15f6d3ed1451b52ab7c847689054d" + "record_hash": "sha256:af72014eeffef8190c9ca548facb7143e37d2555abed52655aabd818c35b65e8" } \ No newline at end of file diff --git a/examples/integrated_weak_evidence/pcs_bundle/akta_decision.json b/examples/integrated_weak_evidence/pcs_bundle/akta_decision.json index 46f9bd7..a4bcfc0 100644 --- a/examples/integrated_weak_evidence/pcs_bundle/akta_decision.json +++ b/examples/integrated_weak_evidence/pcs_bundle/akta_decision.json @@ -32,11 +32,24 @@ "authorization_required": false, "consequentiality": true, "consequentiality_reason": "mutating tool; external effect; action type A7_resource_or_queue_prioritization; resource allocation; next-run or prioritization language", - "policy_version": "akta-core-v0.4", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", + "policy_version": "akta-core-v0.5", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "policy_file_versions": { + "action_ontology.yaml": "action_ontology-v0.5", + "responsibility_levels.yaml": "responsibility_levels-v0.5", + "evidence_states.yaml": "evidence_states-v0.5", + "validation_statuses.yaml": "validation_statuses-v0.5", + "verification_statuses.yaml": "verification_statuses-v0.5", + "deployment_profiles.yaml": "deployment_profiles-v0.5", + "admissibility_matrix.yaml": "admissibility_matrix-v0.5", + "evidence_to_action_matrix.yaml": "evidence_to_action_matrix-v0.5", + "evidence_to_action_rules.yaml": "evidence_to_action_rules-v0.5", + "default_tool_registry.yaml": "default_tool_registry-v0.5", + "tool_to_requested_scope.yaml": "tool_to_requested_scope-v0.5" + }, "domain_overlay_version": "generic_lab_v0.2", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "classifier_confidence": 0.98, "classification_rationale": "tool registry maps lab_scheduler.prioritize to A7_resource_or_queue_prioritization", "classification": { @@ -47,5 +60,6 @@ "uncertainty_flags": [], "classifier_mode": "deterministic" }, + "llm_advisory": null, "ai_output_summary": "The agent recommended prioritizing condition B based on a preliminary signal." } \ No newline at end of file diff --git a/examples/integrated_weak_evidence/pcs_bundle/akta_record.json b/examples/integrated_weak_evidence/pcs_bundle/akta_record.json index 5a30ebc..43e7447 100644 --- a/examples/integrated_weak_evidence/pcs_bundle/akta_record.json +++ b/examples/integrated_weak_evidence/pcs_bundle/akta_record.json @@ -60,11 +60,11 @@ "consequentiality_reason": "mutating tool; external effect; action type A7_resource_or_queue_prioritization; resource allocation; next-run or prioritization language" }, "provenance": { - "policy_version": "akta-core-v0.4", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", + "policy_version": "akta-core-v0.5", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", "domain_overlay_version": "generic_lab_v0.2", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c" + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a" }, "integrations": { "vsa_report_ref": "VSA-REPORT-INTEGRATED-001", @@ -72,5 +72,5 @@ "pcs_artifact_ref": null, "prior_akta_records": [] }, - "record_hash": "sha256:03e1b5ce977bdd5c16124bb3ee4da60912c15f6d3ed1451b52ab7c847689054d" + "record_hash": "sha256:af72014eeffef8190c9ca548facb7143e37d2555abed52655aabd818c35b65e8" } \ No newline at end of file diff --git a/examples/integrated_weak_evidence/pcs_bundle/domain_overlay_hash.txt b/examples/integrated_weak_evidence/pcs_bundle/domain_overlay_hash.txt index 9a8dd5d..f854a5f 100644 --- a/examples/integrated_weak_evidence/pcs_bundle/domain_overlay_hash.txt +++ b/examples/integrated_weak_evidence/pcs_bundle/domain_overlay_hash.txt @@ -1 +1 @@ -sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab \ No newline at end of file +sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117 \ No newline at end of file diff --git a/examples/integrated_weak_evidence/pcs_bundle/manifest.json b/examples/integrated_weak_evidence/pcs_bundle/manifest.json index 2dee135..352abbc 100644 --- a/examples/integrated_weak_evidence/pcs_bundle/manifest.json +++ b/examples/integrated_weak_evidence/pcs_bundle/manifest.json @@ -1,18 +1,25 @@ { "artifact_type": "akta_scientific_action_record", - "schema_version": "akta-record-v0.4", - "record_hash": "sha256:03e1b5ce977bdd5c16124bb3ee4da60912c15f6d3ed1451b52ab7c847689054d", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", + "schema_version": "akta-record-v0.5", + "record_hash": "sha256:af72014eeffef8190c9ca548facb7143e37d2555abed52655aabd818c35b65e8", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", "decision_id": "AKTA-DEC-DEMO0001", "files": [ - "akta_record.json", "akta_decision.json", - "policy_hash.txt", + "akta_record.json", "domain_overlay_hash.txt", - "tool_registry_hash.txt", - "manifest.json" + "manifest.json", + "policy_hash.txt", + "tool_registry_hash.txt" ], - "manifest_hash": "sha256:ee5a0865491a7aad736e4c39270099fef82b0d43420ae88d43b350bd39f7daa9" + "file_hashes": { + "akta_decision.json": "sha256:7cf397c72b9fc4a2e57df8d2bf11e5df211c02f914399f2ffa960aaf6cc9caa2", + "akta_record.json": "sha256:3606fda9ad6f2078d3d35aab06dd001e54f74c03b3534d352abca38fea624789", + "domain_overlay_hash.txt": "sha256:aff97a5c94f9f99549b9f4043617ba8dcfce973312fde13d63101d173bcb0860", + "policy_hash.txt": "sha256:918d683b234533a1c7da8178e581eff636b2786f9b7cc373873ea805994b15ff", + "tool_registry_hash.txt": "sha256:6375791d242c7aad891c23def053d895a7ab6ac579524866cf45fa5a2e7e9863" + }, + "manifest_hash": "sha256:7589b9b3894bdb73900c7a8a2a80c8fd498d8501ec3f7afbb677177bd81be9db" } \ No newline at end of file diff --git a/examples/integrated_weak_evidence/pcs_bundle/policy_hash.txt b/examples/integrated_weak_evidence/pcs_bundle/policy_hash.txt index 829966d..0e38d7d 100644 --- a/examples/integrated_weak_evidence/pcs_bundle/policy_hash.txt +++ b/examples/integrated_weak_evidence/pcs_bundle/policy_hash.txt @@ -1 +1 @@ -sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d \ No newline at end of file +sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464 \ No newline at end of file diff --git a/examples/integrated_weak_evidence/pcs_bundle/tool_registry_hash.txt b/examples/integrated_weak_evidence/pcs_bundle/tool_registry_hash.txt index 04103ee..e5c3a2c 100644 --- a/examples/integrated_weak_evidence/pcs_bundle/tool_registry_hash.txt +++ b/examples/integrated_weak_evidence/pcs_bundle/tool_registry_hash.txt @@ -1 +1 @@ -sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c \ No newline at end of file +sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a \ No newline at end of file diff --git a/examples/integrated_weak_evidence/pf_obligation.json b/examples/integrated_weak_evidence/pf_obligation.json index 0bc8f2b..2bee53b 100644 --- a/examples/integrated_weak_evidence/pf_obligation.json +++ b/examples/integrated_weak_evidence/pf_obligation.json @@ -14,9 +14,9 @@ ], "allowed_tools": [], "max_responsibility_level": "R6_resource_allocation", - "policy_hash": "sha256:e0951ca2ff63b3fce9b3c841b324c3bf23d0f2e4b062c0c3dd5a057f7af5944d", - "tool_registry_hash": "sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c", - "domain_overlay_hash": "sha256:67d13db14ede89da9ffbd5592e7f081b209d25a03bc3942e7a7f1feac1d4eaab", + "policy_hash": "sha256:b2878971b3236b33c5011d6e12ed37ed85582f16996b9d538b584733d9f65464", + "tool_registry_hash": "sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a", + "domain_overlay_hash": "sha256:9043be6ab410404da7d6b98e28db21395884745b29372310d24310714f8fe117", "scope_grant_ref": null, "review_trigger_id": null, "expires_at": null, @@ -42,5 +42,5 @@ ], "required_review_role": null, "consequentiality": true, - "obligation_hash": "sha256:1f1eddd6704343cf22e04aa0ace464e3c807cec65aea16f087a622cb20c88882" + "obligation_hash": "sha256:91c8045f20a687506be850aee36d6c1790d8b2c91f1d0f44ccb65065f41de200" } \ No newline at end of file diff --git a/overlays/README.md b/overlays/README.md index bde8ac7..ae1b774 100644 --- a/overlays/README.md +++ b/overlays/README.md @@ -2,23 +2,35 @@ Domain overlays modify AKTA-Core rules for specific scientific domains. -## Operational overlays (v0.4) +## Governance tiers (v0.5) -| Overlay | Domain | -|---------|--------| -| `generic_lab_v0.yaml` | Generic lab default | -| `materials_v0.yaml` | Materials science labs | -| `computational_science_v0.yaml` | Computational science workflows | -| `biology_v0.yaml` | Biology / biosafety | -| `chemistry_v0.yaml` | Chemistry / chemical safety | -| `clinical_v0.yaml` | Clinical / IRB-governed research | +| Tier | Deployment in production | +|------|--------------------------| +| `core_reference` | Allowed | +| `experimental_domain_overlay` | Refused (high-risk domains) | +| `expert_reviewed_domain_overlay` | Allowed when institutionally approved | +| `institutional_deployment_overlay` | Allowed | -Each operational overlay includes: +When `AKTA_PRODUCTION_MODE=1`, biology, chemistry, and clinical overlays (experimental tier) are refused unless upgraded to expert-reviewed or institutional tier. +Each overlay includes: + +- `tier`, `maintainer`, `review_status`, `review_date`, `non_certification_statement` - `minimum_evidence_for` (A5–A10 action families) - `hazard_triggers` with decision escalation - `requested_scope_overrides` for SCOPE mapping - `required_review_roles` per action family - `tool_restrictions` for domain-specific tools -Legacy aliases `biology_placeholder`, `chemistry_placeholder`, and `clinical_placeholder` resolve to the v0.4 operational files for scenario compatibility. +## Operational overlays (v0.5) + +| Overlay | Domain | Tier | +|---------|--------|------| +| `generic_lab_v0.yaml` | Generic lab default | core_reference | +| `materials_v0.yaml` | Materials science labs | core_reference | +| `computational_science_v0.yaml` | Computational science workflows | core_reference | +| `biology_v0.yaml` | Biology / biosafety | experimental_domain_overlay | +| `chemistry_v0.yaml` | Chemistry / chemical safety | experimental_domain_overlay | +| `clinical_v0.yaml` | Clinical / IRB-governed research | experimental_domain_overlay | + +Legacy aliases `biology_placeholder`, `chemistry_placeholder`, and `clinical_placeholder` resolve to the experimental overlay files for scenario compatibility. diff --git a/overlays/biology_v0.yaml b/overlays/biology_v0.yaml index 8305e4b..1b5570a 100644 --- a/overlays/biology_v0.yaml +++ b/overlays/biology_v0.yaml @@ -1,7 +1,12 @@ domain: biology -version: biology_v0.4 -operational: true - +version: biology_v0.5 +tier: experimental_domain_overlay +maintainer: AKTA Contributors +review_status: experimental_not_deployment_ready +review_date: '2026-06-28' +non_certification_statement: Experimental biology overlay; NOT deployment-ready without + expert review and institutional governance. +operational: false minimum_evidence_for: recommendation: E4_internally_consistent_evidence experimental_planning: E4_internally_consistent_evidence @@ -10,48 +15,42 @@ minimum_evidence_for: tool_or_workflow_mutation: E5_internally_replicated_evidence execution_adjacent: E6_independently_reproduced_evidence publication_claim: E5_internally_replicated_evidence - required_review_roles: protocol_modification: - - protocol_owner - - biosafety_officer + - protocol_owner + - biosafety_officer queue_prioritization: - - principal_investigator - - lab_operations_lead + - principal_investigator + - lab_operations_lead tool_or_workflow_mutation: - - workflow_owner - - biosafety_officer + - workflow_owner + - biosafety_officer execution_adjacent: - - biosafety_officer - - principal_investigator + - biosafety_officer + - principal_investigator publication_claim: - - domain_scientist - - communications_lead - + - domain_scientist + - communications_lead requested_scope_overrides: robot_queue.submit: robot_queue_submission cell_culture.schedule: single_validation_plan gene_editor.draft_change: protocol_draft - hazard_triggers: - - condition: bsl3_pathogen_present - decision: authorization_required - - condition: recombinant_dna_unregistered - decision: blocked - - condition: live_virus_culture - decision: authorization_required - - condition: antibiotic_resistance_marker - decision: review_required - +- condition: bsl3_pathogen_present + decision: authorization_required +- condition: recombinant_dna_unregistered + decision: blocked +- condition: live_virus_culture + decision: authorization_required +- condition: antibiotic_resistance_marker + decision: review_required blocked_actions: - - A9_execution_adjacent_or_external_action - +- A9_execution_adjacent_or_external_action metadata_requirements: - - biosafety_level - - strain_lineage_id - - protocol_version - - contamination_check_status - +- biosafety_level +- strain_lineage_id +- protocol_version +- contamination_check_status tool_restrictions: robot_queue.submit: decision: authorization_required diff --git a/overlays/chemistry_v0.yaml b/overlays/chemistry_v0.yaml index ca50066..e922276 100644 --- a/overlays/chemistry_v0.yaml +++ b/overlays/chemistry_v0.yaml @@ -1,7 +1,12 @@ domain: chemistry -version: chemistry_v0.4 -operational: true - +version: chemistry_v0.5 +tier: experimental_domain_overlay +maintainer: AKTA Contributors +review_status: experimental_not_deployment_ready +review_date: '2026-06-28' +non_certification_statement: Experimental chemistry overlay; NOT deployment-ready + without expert review and institutional governance. +operational: false minimum_evidence_for: recommendation: E4_internally_consistent_evidence experimental_planning: E4_internally_consistent_evidence @@ -10,46 +15,40 @@ minimum_evidence_for: tool_or_workflow_mutation: E5_internally_replicated_evidence execution_adjacent: E6_independently_reproduced_evidence publication_claim: E5_internally_replicated_evidence - required_review_roles: protocol_modification: - - protocol_owner - - chemical_safety_officer + - protocol_owner + - chemical_safety_officer queue_prioritization: - - principal_investigator - - lab_operations_lead + - principal_investigator + - lab_operations_lead tool_or_workflow_mutation: - - workflow_owner + - workflow_owner execution_adjacent: - - chemical_safety_officer - - principal_investigator + - chemical_safety_officer + - principal_investigator publication_claim: - - domain_scientist - - communications_lead - + - domain_scientist + - communications_lead requested_scope_overrides: robot_queue.submit: robot_queue_submission synthesis_planner.schedule: single_validation_plan reagent_inventory.allocate: single_run_queue_priority - hazard_triggers: - - condition: pyrophoric_reagent_present - decision: authorization_required - - condition: explosive_precursor - decision: blocked - - condition: toxic_gas_generation - decision: authorization_required - - condition: peroxide_forming_solvent - decision: review_required - +- condition: pyrophoric_reagent_present + decision: authorization_required +- condition: explosive_precursor + decision: blocked +- condition: toxic_gas_generation + decision: authorization_required +- condition: peroxide_forming_solvent + decision: review_required blocked_actions: [] - metadata_requirements: - - sds_reference - - reaction_scale - - fume_hood_assignment - - waste_stream_id - +- sds_reference +- reaction_scale +- fume_hood_assignment +- waste_stream_id tool_restrictions: robot_queue.submit: decision: authorization_required diff --git a/overlays/clinical_v0.yaml b/overlays/clinical_v0.yaml index e31ffe2..09ff160 100644 --- a/overlays/clinical_v0.yaml +++ b/overlays/clinical_v0.yaml @@ -1,7 +1,12 @@ domain: clinical -version: clinical_v0.4 -operational: true - +version: clinical_v0.5 +tier: experimental_domain_overlay +maintainer: AKTA Contributors +review_status: experimental_not_deployment_ready +review_date: '2026-06-28' +non_certification_statement: Experimental clinical overlay; NOT deployment-ready without + expert review and institutional governance. +operational: false minimum_evidence_for: recommendation: E5_internally_replicated_evidence experimental_planning: E5_internally_replicated_evidence @@ -10,48 +15,42 @@ minimum_evidence_for: tool_or_workflow_mutation: E6_independently_reproduced_evidence execution_adjacent: E7_deployment_validated_evidence publication_claim: E6_independently_reproduced_evidence - required_review_roles: protocol_modification: - - clinical_protocol_owner - - irb_liaison + - clinical_protocol_owner + - irb_liaison queue_prioritization: - - principal_investigator - - clinical_operations_lead + - principal_investigator + - clinical_operations_lead tool_or_workflow_mutation: - - workflow_owner - - regulatory_affairs + - workflow_owner + - regulatory_affairs execution_adjacent: - - clinical_safety_officer - - principal_investigator + - clinical_safety_officer + - principal_investigator publication_claim: - - domain_scientist - - regulatory_affairs - + - domain_scientist + - regulatory_affairs requested_scope_overrides: ehr.draft_note: protocol_draft trial_scheduler.enroll: single_validation_plan patient_data.export: scientific_memory_import - hazard_triggers: - - condition: phi_exposure_risk - decision: blocked - - condition: unapproved_intervention - decision: blocked - - condition: off_label_use_proposed - decision: authorization_required - - condition: adverse_event_signal - decision: authorization_required - +- condition: phi_exposure_risk + decision: blocked +- condition: unapproved_intervention + decision: blocked +- condition: off_label_use_proposed + decision: authorization_required +- condition: adverse_event_signal + decision: authorization_required blocked_actions: - - A9_execution_adjacent_or_external_action - +- A9_execution_adjacent_or_external_action metadata_requirements: - - irb_protocol_id - - consent_status - - patient_cohort_id - - regulatory_jurisdiction - +- irb_protocol_id +- consent_status +- patient_cohort_id +- regulatory_jurisdiction tool_restrictions: ehr.draft_note: decision: draft_only diff --git a/overlays/computational_science_v0.yaml b/overlays/computational_science_v0.yaml index 3a7da7f..d83bc28 100644 --- a/overlays/computational_science_v0.yaml +++ b/overlays/computational_science_v0.yaml @@ -1,7 +1,12 @@ domain: computational_science version: computational_science_v0.2 +tier: core_reference +maintainer: AKTA Contributors +review_status: reference_implementation +review_date: '2026-06-28' +non_certification_statement: Reference overlay for computational science; not a certification + of deployment readiness. operational: true - minimum_evidence_for: recommendation: E3_noisy_or_conflicting_evidence experimental_planning: E4_internally_consistent_evidence @@ -10,33 +15,27 @@ minimum_evidence_for: tool_or_workflow_mutation: E4_internally_consistent_evidence execution_adjacent: E5_internally_replicated_evidence publication_claim: E4_internally_consistent_evidence - required_review_roles: protocol_modification: - - compute_lead + - compute_lead queue_prioritization: - - project_lead + - project_lead tool_or_workflow_mutation: - - compute_lead + - compute_lead execution_adjacent: - - project_lead + - project_lead publication_claim: - - compute_lead - + - compute_lead requested_scope_overrides: workflow.update_state: execution_payload_preparation - hazard_triggers: - - condition: production_cluster_mutation - decision: authorization_required - +- condition: production_cluster_mutation + decision: authorization_required blocked_actions: [] - metadata_requirements: - - code_version - - environment_hash - - reproducibility_seed - +- code_version +- environment_hash +- reproducibility_seed tool_restrictions: workflow.update_state: decision: review_required diff --git a/overlays/generic_lab_v0.yaml b/overlays/generic_lab_v0.yaml index 3009899..55871a5 100644 --- a/overlays/generic_lab_v0.yaml +++ b/overlays/generic_lab_v0.yaml @@ -1,7 +1,12 @@ domain: generic_lab version: generic_lab_v0.2 +tier: core_reference +maintainer: AKTA Contributors +review_status: reference_implementation +review_date: '2026-06-28' +non_certification_statement: Reference overlay for generic lab workflows; not a certification + of deployment readiness. operational: true - minimum_evidence_for: recommendation: E4_internally_consistent_evidence experimental_planning: E4_internally_consistent_evidence @@ -10,32 +15,26 @@ minimum_evidence_for: tool_or_workflow_mutation: E5_internally_replicated_evidence execution_adjacent: E5_internally_replicated_evidence publication_claim: E4_internally_consistent_evidence - required_review_roles: protocol_modification: - - protocol_owner + - protocol_owner queue_prioritization: - - domain_scientist + - domain_scientist tool_or_workflow_mutation: - - workflow_owner + - workflow_owner execution_adjacent: - - lab_safety_officer + - lab_safety_officer publication_claim: - - domain_scientist - + - domain_scientist requested_scope_overrides: {} - hazard_triggers: - - condition: pyrophoric_reagent_present - decision: authorization_required - - condition: uncapped_pressure_vessel - decision: blocked - +- condition: pyrophoric_reagent_present + decision: authorization_required +- condition: uncapped_pressure_vessel + decision: blocked blocked_actions: [] - metadata_requirements: - - protocol_version - - evidence_state - - validation_status - +- protocol_version +- evidence_state +- validation_status tool_restrictions: {} diff --git a/overlays/materials_v0.yaml b/overlays/materials_v0.yaml index cb112fb..2069b4b 100644 --- a/overlays/materials_v0.yaml +++ b/overlays/materials_v0.yaml @@ -1,7 +1,12 @@ domain: materials version: materials_v0.2 +tier: core_reference +maintainer: AKTA Contributors +review_status: reference_implementation +review_date: '2026-06-28' +non_certification_statement: Reference overlay for materials science; not a certification + of deployment readiness. operational: true - minimum_evidence_for: recommendation: E4_internally_consistent_evidence experimental_planning: E4_internally_consistent_evidence @@ -10,42 +15,36 @@ minimum_evidence_for: tool_or_workflow_mutation: E5_internally_replicated_evidence execution_adjacent: E6_independently_reproduced_evidence publication_claim: E5_internally_replicated_evidence - required_review_roles: protocol_modification: - - protocol_owner + - protocol_owner queue_prioritization: - - principal_investigator - - lab_operations_lead + - principal_investigator + - lab_operations_lead tool_or_workflow_mutation: - - workflow_owner + - workflow_owner execution_adjacent: - - lab_safety_officer - - principal_investigator + - lab_safety_officer + - principal_investigator publication_claim: - - domain_scientist - - communications_lead - + - domain_scientist + - communications_lead requested_scope_overrides: robot_queue.submit: robot_queue_submission - hazard_triggers: - - condition: pyrophoric_reagent_present - decision: authorization_required - - condition: reactive_metal_batch - decision: authorization_required - - condition: uncapped_pressure_vessel - decision: blocked - +- condition: pyrophoric_reagent_present + decision: authorization_required +- condition: reactive_metal_batch + decision: authorization_required +- condition: uncapped_pressure_vessel + decision: blocked blocked_actions: - - A9_execution_adjacent_or_external_action - +- A9_execution_adjacent_or_external_action metadata_requirements: - - instrument_calibration_status - - sample_batch_id - - protocol_version - - uncertainty_estimate - +- instrument_calibration_status +- sample_batch_id +- protocol_version +- uncertainty_estimate tool_restrictions: robot_queue.submit: decision: authorization_required diff --git a/policy/action_ontology.yaml b/policy/action_ontology.yaml index e42b58c..ced48c4 100644 --- a/policy/action_ontology.yaml +++ b/policy/action_ontology.yaml @@ -1,14 +1,16 @@ -version: akta-core-v0.4 +version: akta-core-v0.5 action_types: A0_explanation: id: A0 name: explanation - description: AI explains a concept, method, term, paper, dataset, or background fact. + description: AI explains a concept, method, term, paper, dataset, or background + fact. default_decision: allowed A1_retrieval_or_summary: id: A1 name: retrieval_or_summary - description: AI retrieves, summarizes, or compares literature, data, prior results, or records. + description: AI retrieves, summarizes, or compares literature, data, prior results, + or records. default_decision: allowed_with_logging A2_hypothesis_generation: id: A2 @@ -18,7 +20,8 @@ action_types: A3_evidence_interpretation: id: A3 name: evidence_interpretation - description: AI interprets data, measurements, experimental results, or scientific evidence. + description: AI interprets data, measurements, experimental results, or scientific + evidence. default_decision: allowed_with_logging A4_recommendation: id: A4 @@ -28,7 +31,8 @@ action_types: A5_protocol_modification: id: A5 name: protocol_modification - description: AI edits, changes, or mutates a protocol, threshold, parameter, or method. + description: AI edits, changes, or mutates a protocol, threshold, parameter, or + method. default_decision: draft_only A6_experimental_planning: id: A6 @@ -38,7 +42,8 @@ action_types: A7_resource_or_queue_prioritization: id: A7 name: resource_or_queue_prioritization - description: AI allocates lab resources, prioritizes experiments, or changes a queue. + description: AI allocates lab resources, prioritizes experiments, or changes a + queue. default_decision: review_required A8_tool_or_workflow_mutation: id: A8 @@ -53,5 +58,8 @@ action_types: A10_publication_or_claim_escalation: id: A10 name: publication_or_claim_escalation - description: AI inserts or strengthens a claim for publication or institutional memory. + description: AI inserts or strengthens a claim for publication or institutional + memory. default_decision: review_required +policy_file_version: action_ontology-v0.5 +policy_bundle_version: akta-core-v0.5 diff --git a/policy/admissibility_matrix.yaml b/policy/admissibility_matrix.yaml index 2fb5591..f1ef81f 100644 --- a/policy/admissibility_matrix.yaml +++ b/policy/admissibility_matrix.yaml @@ -1,7 +1,4 @@ -version: akta-core-v0.1 -# Decision strictness order (lowest to highest): -# allowed < allowed_with_logging < draft_only < review_required < authorization_required < blocked < abstain_insufficient_context - +version: akta-core-v0.5 decision_aliases: allowed: allowed allowed_log: allowed_with_logging @@ -14,7 +11,6 @@ decision_aliases: authorization_required: authorization_required blocked: blocked blocked_or_review: review_required - matrix: A0_explanation: P0_no_action_assistant: allowed @@ -104,3 +100,4 @@ matrix: P4_protocol_drafting_assistant: review_required P5_review_gated_experimental_planner: review_required P6_authorized_closed_loop_lab_agent: review_required +policy_file_version: admissibility_matrix-v0.5 diff --git a/policy/default_tool_registry.yaml b/policy/default_tool_registry.yaml index c7772a7..1c4f0e9 100644 --- a/policy/default_tool_registry.yaml +++ b/policy/default_tool_registry.yaml @@ -1,4 +1,4 @@ -version: akta-tool-registry-v0.4 +version: akta-tool-registry-v0.5 tools: literature_search.query: action_type: A1_retrieval_or_summary @@ -6,185 +6,160 @@ tools: external_effect: false default_permission: allowed_with_logging action_family: retrieval - notebook.run_analysis: action_type: A3_evidence_interpretation mutates_state: false external_effect: false default_permission: allowed_with_logging action_family: analysis - notebook.export_figure: action_type: A3_evidence_interpretation mutates_state: false external_effect: false default_permission: allowed_with_logging action_family: analysis - protocol_editor.draft_change: action_type: A5_protocol_modification mutates_state: false external_effect: false default_permission: draft_only action_family: protocol - protocol_editor.update_active_protocol: action_type: A5_protocol_modification mutates_state: true external_effect: true default_permission: review_required action_family: protocol - experiment_planner.create_run_plan: action_type: A6_experimental_planning mutates_state: false external_effect: false default_permission: review_required action_family: planning - experiment_planner.create_validation_draft: action_type: A6_experimental_planning mutates_state: false external_effect: false default_permission: draft_only action_family: planning - experiment_planner.activate_plan: action_type: A6_experimental_planning mutates_state: true external_effect: true default_permission: review_required action_family: planning - lab_scheduler.prioritize: action_type: A7_resource_or_queue_prioritization mutates_state: true external_effect: true default_permission: review_required action_family: queue - lab_scheduler.defer: action_type: A7_resource_or_queue_prioritization mutates_state: true external_effect: false default_permission: review_required action_family: queue - robot_queue.submit: action_type: A9_execution_adjacent_or_external_action mutates_state: true external_effect: true default_permission: authorization_required action_family: execution - robot_queue.preview: action_type: A9_execution_adjacent_or_external_action mutates_state: false external_effect: false default_permission: draft_only action_family: execution - workflow.update_state: action_type: A8_tool_or_workflow_mutation mutates_state: true external_effect: false default_permission: authorization_required action_family: workflow - workflow.rollback: action_type: A8_tool_or_workflow_mutation mutates_state: true external_effect: false default_permission: authorization_required action_family: workflow - publication.draft_claim: action_type: A10_publication_or_claim_escalation mutates_state: false external_effect: false default_permission: review_required action_family: publication - publication.submit_manuscript: action_type: A10_publication_or_claim_escalation mutates_state: true external_effect: true default_permission: authorization_required action_family: publication - scientific_memory.import: action_type: A8_tool_or_workflow_mutation mutates_state: true external_effect: false default_permission: review_required action_family: memory - scientific_memory.query: action_type: A1_retrieval_or_summary mutates_state: false external_effect: false default_permission: allowed_with_logging action_family: memory - cell_culture.schedule: action_type: A6_experimental_planning mutates_state: true external_effect: true default_permission: review_required action_family: biology - gene_editor.draft_change: action_type: A5_protocol_modification mutates_state: false external_effect: false default_permission: draft_only action_family: biology - synthesis_planner.schedule: action_type: A6_experimental_planning mutates_state: true external_effect: true default_permission: review_required action_family: chemistry - reagent_inventory.allocate: action_type: A7_resource_or_queue_prioritization mutates_state: true external_effect: true default_permission: review_required action_family: chemistry - ehr.draft_note: action_type: A5_protocol_modification mutates_state: false external_effect: false default_permission: draft_only action_family: clinical - trial_scheduler.enroll: action_type: A9_execution_adjacent_or_external_action mutates_state: true external_effect: true default_permission: authorization_required action_family: clinical - patient_data.export: action_type: A8_tool_or_workflow_mutation mutates_state: true external_effect: true default_permission: authorization_required action_family: clinical - compute_cluster.submit_job: action_type: A6_experimental_planning mutates_state: true external_effect: false default_permission: review_required action_family: compute - data_catalog.register: action_type: A8_tool_or_workflow_mutation mutates_state: true external_effect: false default_permission: review_required action_family: workflow +policy_file_version: default_tool_registry-v0.5 diff --git a/policy/deployment_profiles.yaml b/policy/deployment_profiles.yaml index d536a2f..28cb7c6 100644 --- a/policy/deployment_profiles.yaml +++ b/policy/deployment_profiles.yaml @@ -1,4 +1,4 @@ -version: akta-core-v0.4 +version: akta-core-v0.5 profiles: P0_no_action_assistant: id: P0 @@ -32,4 +32,7 @@ profiles: id: P7 name: fully_autonomous_scientific_operator supported: false - disclaimer: "P7 is defined for future taxonomy only. AKTA does not support or certify fully autonomous scientific operation. This is a permanent non-goal unless the AKTA specification explicitly adds P7 runtime support." + disclaimer: P7 is defined for future taxonomy only. AKTA does not support or certify + fully autonomous scientific operation. This is a permanent non-goal unless the + AKTA specification explicitly adds P7 runtime support. +policy_file_version: deployment_profiles-v0.5 diff --git a/policy/evidence_states.yaml b/policy/evidence_states.yaml index 2290248..55a8853 100644 --- a/policy/evidence_states.yaml +++ b/policy/evidence_states.yaml @@ -1,4 +1,4 @@ -version: akta-core-v0.1 +version: akta-core-v0.5 evidence_states: E0_no_evidence: id: E0 @@ -46,3 +46,4 @@ evidence_states: max_action_without_review: A9_execution_adjacent_or_external_action max_action_mode: authorization_required default_constraint: authorization_required +policy_file_version: evidence_states-v0.5 diff --git a/policy/evidence_to_action_matrix.yaml b/policy/evidence_to_action_matrix.yaml index 3b1daca..c675efc 100644 --- a/policy/evidence_to_action_matrix.yaml +++ b/policy/evidence_to_action_matrix.yaml @@ -1,5 +1,4 @@ -version: akta-core-v0.1 -# Evidence-to-action constraints. Stricter decision wins when combined with profile matrix. +version: akta-core-v0.5 constraints: E0_no_evidence: max_action: A2_hypothesis_generation @@ -31,3 +30,4 @@ constraints: max_action: A9_execution_adjacent_or_external_action max_action_mode: authorization_required violation_decision: authorization_required +policy_file_version: evidence_to_action_matrix-v0.5 diff --git a/policy/evidence_to_action_rules.yaml b/policy/evidence_to_action_rules.yaml index 8cb94c1..54c7948 100644 --- a/policy/evidence_to_action_rules.yaml +++ b/policy/evidence_to_action_rules.yaml @@ -1,9 +1,4 @@ -version: akta-core-v0.2 -# Per (evidence_state, action_type) constraints. Stricter decision wins when composed with profile matrix. -# Special decision tokens resolved at evaluation time: -# draft_only_or_review_required — profile matrix picks draft_only vs review_required (strictest wins) -# draft_validation_only — draft_only for validation-draft tools; blocked otherwise -# allowed_log_or_review — consequentiality layer resolves (see admissibility_matrix decision_aliases) +version: akta-core-v0.5 rules: E0_no_evidence: A0_explanation: allowed @@ -17,7 +12,6 @@ rules: A8_tool_or_workflow_mutation: blocked A9_execution_adjacent_or_external_action: blocked A10_publication_or_claim_escalation: blocked - E1_anecdotal_or_informal_observation: A0_explanation: allowed A1_retrieval_or_summary: allowed_with_logging @@ -30,7 +24,6 @@ rules: A8_tool_or_workflow_mutation: blocked A9_execution_adjacent_or_external_action: blocked A10_publication_or_claim_escalation: blocked - E2_preliminary_signal: A0_explanation: allowed A1_retrieval_or_summary: allowed_with_logging @@ -43,7 +36,6 @@ rules: A8_tool_or_workflow_mutation: blocked A9_execution_adjacent_or_external_action: blocked A10_publication_or_claim_escalation: blocked - E3_noisy_or_conflicting_evidence: A0_explanation: allowed A1_retrieval_or_summary: allowed_with_logging @@ -56,7 +48,6 @@ rules: A8_tool_or_workflow_mutation: blocked A9_execution_adjacent_or_external_action: blocked A10_publication_or_claim_escalation: review_required - E4_internally_consistent_evidence: A0_explanation: allowed A1_retrieval_or_summary: allowed_with_logging @@ -69,7 +60,6 @@ rules: A8_tool_or_workflow_mutation: review_required A9_execution_adjacent_or_external_action: blocked A10_publication_or_claim_escalation: review_required - E5_internally_replicated_evidence: A0_explanation: allowed A1_retrieval_or_summary: allowed_with_logging @@ -82,7 +72,6 @@ rules: A8_tool_or_workflow_mutation: review_required A9_execution_adjacent_or_external_action: authorization_required A10_publication_or_claim_escalation: review_required - E6_independently_reproduced_evidence: A0_explanation: allowed A1_retrieval_or_summary: allowed_with_logging @@ -95,7 +84,6 @@ rules: A8_tool_or_workflow_mutation: authorization_required A9_execution_adjacent_or_external_action: authorization_required A10_publication_or_claim_escalation: review_required - E7_deployment_validated_evidence: A0_explanation: allowed A1_retrieval_or_summary: allowed_with_logging @@ -108,3 +96,4 @@ rules: A8_tool_or_workflow_mutation: authorization_required A9_execution_adjacent_or_external_action: authorization_required A10_publication_or_claim_escalation: review_required +policy_file_version: evidence_to_action_rules-v0.5 diff --git a/policy/policy_manifest.yaml b/policy/policy_manifest.yaml index 4faeec2..06d15e1 100644 --- a/policy/policy_manifest.yaml +++ b/policy/policy_manifest.yaml @@ -1,17 +1,17 @@ -version: akta-policy-manifest-v0.4 -bundle_version: akta-core-v0.4 +version: akta-policy-manifest-v0.5 +bundle_version: akta-core-v0.5 files: - action_ontology.yaml: sha256:e2617ec6fa354523b373d8024cd5706476c6e577464bebba84758d6d51dd6875 - responsibility_levels.yaml: sha256:d935ab8f59cae4ac4383520b226372d4cb62d3e2b0ccc3ee96ed994068959f16 - evidence_states.yaml: sha256:180a38a32d8c02ecfd58e73513787fdc8d3569f4d2bb9aeb2c4568fa06f5802d - validation_statuses.yaml: sha256:6b0333e571e274736fc5ef9a6bfd1e3128bde74db6ced0c31d75dfe2bb9409aa - verification_statuses.yaml: sha256:ae38f15f21e199644042beb2b3735e00f3f73fb43c82c341033f8ccb58c0ee92 - deployment_profiles.yaml: sha256:4db5ab39429799225e460ee5548ca119f3b9b30102551aacf9ef6d9e300f2915 - admissibility_matrix.yaml: sha256:60e7515a7d4a770b1b1b23d7522c91dbebb86ae4a15d525258563b4e8de04a7d - evidence_to_action_matrix.yaml: sha256:2aeb759832066fec1391948d11d678b1341f5bc3d5273c583a8ffe103e7a4f5b - evidence_to_action_rules.yaml: sha256:fba5212cc191fa7fb92068b074649c41502fd6c075c3ed9fa215a8643c1b849b - default_tool_registry.yaml: sha256:106f428d42b42d5f0f459d8e65da25f1f9ee237efb6a3b27fe880f83d7bc604c - tool_to_requested_scope.yaml: sha256:c46431af11da57194791293144c17bcb8e8ea88f636b1f333022a39e2f2a2cbb + action_ontology.yaml: sha256:c2ac6ac345552737b9bb8e0786f0886319419ac218e5162267a337d33568e6fa + responsibility_levels.yaml: sha256:e98d3c5488575364f4b5ac9cbf48754f873475235843c5c8405473444e1ac461 + evidence_states.yaml: sha256:80439bb5819f324d8a86635115b5c4e58906e98d23950e43a222ef61bfa6934c + validation_statuses.yaml: sha256:1cdadb65eb0f5697ab0a70984b3f1fd0b6c26286e16665b45ede620ae1c4548c + verification_statuses.yaml: sha256:3b09dcc6c66a52a756c3dd054bc7f7e073bd78187606ed2c8855e1348d6a2128 + deployment_profiles.yaml: sha256:16e6046f802989ec6706747bf553a5b763bcb7de7abc65ec791ca7e20b6906b7 + admissibility_matrix.yaml: sha256:0b06d1574bb82f9dc39d2860cb805a585994d0487d93a81d659e695ee56971e0 + evidence_to_action_matrix.yaml: sha256:5e8916ceff5c8e29f49e4ebc0edee3382a67c1be156b9cfd9fcc7e667bbfe15f + evidence_to_action_rules.yaml: sha256:72175f5f4cfd6f5cd713098515af9bbf17cb6c6bbc258b5593d1a6b0e12197e2 + default_tool_registry.yaml: sha256:90746b4b61b236802bf9762b6755e966a676c6294e83fd3606ce0826f1aff50a + tool_to_requested_scope.yaml: sha256:c85beea0595b98fc913b8e5b90d22155bf74afb08937edf8147472c6749873cc signature: algorithm: HMAC-SHA256 - value: 1c5dd780958c82e5b519813c729506301b7788e99d242cc00b64b7702d696244 + value: 738460a0c413a5f092c0eb4cf16f8263c6be429ce398fc5efdfab22bd0155c50 diff --git a/policy/responsibility_levels.yaml b/policy/responsibility_levels.yaml index d2da079..39e593e 100644 --- a/policy/responsibility_levels.yaml +++ b/policy/responsibility_levels.yaml @@ -1,4 +1,4 @@ -version: akta-core-v0.1 +version: akta-core-v0.5 responsibility_levels: R0_informational_assistance: id: R0 @@ -40,7 +40,6 @@ responsibility_levels: id: R9 name: publication_or_institutional_claim description: Moves a claim into a paper, report, grant, or institutional memory. - action_to_responsibility: A0_explanation: R0_informational_assistance A1_retrieval_or_summary: R1_epistemic_assistance @@ -53,3 +52,4 @@ action_to_responsibility: A8_tool_or_workflow_mutation: R7_execution_adjacent_preparation A9_execution_adjacent_or_external_action: R8_external_or_physical_execution A10_publication_or_claim_escalation: R9_publication_or_institutional_claim +policy_file_version: responsibility_levels-v0.5 diff --git a/policy/tool_to_requested_scope.yaml b/policy/tool_to_requested_scope.yaml index 8dfe58a..7688ef3 100644 --- a/policy/tool_to_requested_scope.yaml +++ b/policy/tool_to_requested_scope.yaml @@ -1,16 +1,13 @@ -version: akta-tool-scope-v0.3 -# Maps review-relevant tools to SCOPE machine-enforced requested_scope values. -# Action-type fallbacks apply when a tool is not listed explicitly. - +version: akta-tool-scope-v0.5 action_type_defaults: A5_protocol_modification: draft_tools: - - protocol_editor.draft_change + - protocol_editor.draft_change draft_scope: protocol_draft active_scope: active_protocol_update A6_experimental_planning: validation_draft_tools: - - experiment_planner.create_validation_draft + - experiment_planner.create_validation_draft validation_draft_scope: single_validation_run_draft plan_scope: single_validation_plan A7_resource_or_queue_prioritization: @@ -19,12 +16,11 @@ action_type_defaults: default_scope: execution_payload_preparation A9_execution_adjacent_or_external_action: robot_tools: - - robot_queue.submit + - robot_queue.submit robot_scope: robot_queue_submission default_scope: execution_payload_preparation A10_publication_or_claim_escalation: default_scope: publication_claim - tools: protocol_editor.draft_change: requested_scope: protocol_draft @@ -53,7 +49,6 @@ tools: scientific_memory.import: requested_scope: scientific_memory_import review_route: memory_import_review - review_route_defaults: protocol_draft: protocol_owner_review active_protocol_update: active_protocol_review @@ -64,3 +59,4 @@ review_route_defaults: execution_payload_preparation: execution_preparation_review publication_claim: publication_claim_review scientific_memory_import: memory_import_review +policy_file_version: tool_to_requested_scope-v0.5 diff --git a/policy/validation_statuses.yaml b/policy/validation_statuses.yaml index 9d57bb1..79e7cc2 100644 --- a/policy/validation_statuses.yaml +++ b/policy/validation_statuses.yaml @@ -1,4 +1,4 @@ -version: akta-core-v0.1 +version: akta-core-v0.5 validation_statuses: V0_unvalidated: id: V0 @@ -21,3 +21,4 @@ validation_statuses: V6_operationally_validated: id: V6 name: operationally_validated +policy_file_version: validation_statuses-v0.5 diff --git a/policy/verification_statuses.yaml b/policy/verification_statuses.yaml index edcb9b4..a268c09 100644 --- a/policy/verification_statuses.yaml +++ b/policy/verification_statuses.yaml @@ -1,4 +1,4 @@ -version: akta-core-v0.1 +version: akta-core-v0.5 verification_statuses: Q0_unchecked: id: Q0 @@ -21,3 +21,4 @@ verification_statuses: Q6_deployment_verified: id: Q6 name: deployment_verified +policy_file_version: verification_statuses-v0.5 diff --git a/pyproject.toml b/pyproject.toml index 08cebdb..887d82e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "akta-protocol" -version = "0.4.0" +version = "0.5.0" description = "Open Scientific Action Protocol — reference kernel for scientific action admissibility" readme = "README.md" license = { text = "MIT" } diff --git a/schemas/domain_overlay.schema.json b/schemas/domain_overlay.schema.json index d0c38de..af57719 100644 --- a/schemas/domain_overlay.schema.json +++ b/schemas/domain_overlay.schema.json @@ -3,10 +3,31 @@ "$id": "https://akta.dev/schemas/domain_overlay.schema.json", "title": "AKTA Domain Overlay", "type": "object", - "required": ["domain", "version"], + "required": [ + "domain", + "version", + "tier", + "maintainer", + "review_status", + "review_date", + "non_certification_statement" + ], "properties": { "domain": { "type": "string" }, "version": { "type": "string" }, + "tier": { + "type": "string", + "enum": [ + "core_reference", + "experimental_domain_overlay", + "expert_reviewed_domain_overlay", + "institutional_deployment_overlay" + ] + }, + "maintainer": { "type": "string" }, + "review_status": { "type": "string" }, + "review_date": { "type": "string" }, + "non_certification_statement": { "type": "string" }, "operational": { "type": "boolean" }, "disclaimer": { "type": "string" }, "minimum_evidence_for": { "type": "object" }, diff --git a/schemas/pcs_akta_artifact.schema.json b/schemas/pcs_akta_artifact.schema.json index b24e036..81f2ad9 100644 --- a/schemas/pcs_akta_artifact.schema.json +++ b/schemas/pcs_akta_artifact.schema.json @@ -10,7 +10,8 @@ "policy_hash", "tool_registry_hash", "manifest_hash", - "files" + "files", + "file_hashes" ], "properties": { "artifact_type": { "type": "string", "const": "akta_scientific_action_record" }, @@ -25,6 +26,10 @@ "type": "array", "items": { "type": "string" }, "minItems": 4 + }, + "file_hashes": { + "type": "object", + "additionalProperties": { "type": "string", "pattern": "^sha256:" } } }, "additionalProperties": true diff --git a/scripts/demo_akta_scope_protocol_drift.py b/scripts/demo_akta_scope_protocol_drift.py index 198cf45..7de020b 100644 --- a/scripts/demo_akta_scope_protocol_drift.py +++ b/scripts/demo_akta_scope_protocol_drift.py @@ -1,9 +1,9 @@ -"""Integrated AKTA x SCOPE protocol-drift demo (v0.3). +"""Integrated AKTA x SCOPE protocol-drift demo (v0.5). Flow: active protocol update -> AKTA review_required (requested_scope=active_protocol_update) - -> SCOPE packet -> protocol owner grants protocol_draft only - -> draft_change allowed; active update + robot blocked -> PF + PCS export + -> SCOPE adapter (simulated | python-import | cli) -> packet, decision, grant + -> draft_change allowed; active update + robot blocked -> PF + PCS full-chain export """ from __future__ import annotations @@ -78,20 +78,26 @@ def run_demo() -> int: from akta.records import AKTARecord, AKTADecision from adapters.pcs.export_artifact import export_pcs_bundle from adapters.pf_core.export_obligation import export_pf_obligation - from adapters.scope.client import submit_review_trigger + from adapters.scope.client import detect_adapter_mode, submit_review_trigger demo_dir = ROOT / "examples" / "integrated_protocol_drift" demo_dir.mkdir(parents=True, exist_ok=True) pcs_dir = demo_dir / "pcs_bundle" - print("=== AKTA v0.4 Demo: Integrated Protocol Drift (AKTA x SCOPE) ===\n") + adapter_mode = detect_adapter_mode() + mode_label = { + "simulated": "simulated (contract simulation only)", + "python-import": "python-import (SCOPE_REPO_PATH sibling repo)", + "cli": "cli (SCOPE_CLI subprocess)", + }.get(adapter_mode, adapter_mode) + print(f"=== AKTA v0.5 Demo: Integrated Protocol Drift (AKTA x SCOPE) ===") + print(f"SCOPE adapter mode: {mode_label}\n") context_data = json.loads((demo_dir / "context.json").read_text(encoding="utf-8")) ai_output = json.loads((demo_dir / "ai_output.json").read_text(encoding="utf-8")) gate = AKTAGate.from_policy_dir(ROOT / "policy", overlays_dir=ROOT / "overlays") - # Step 1: active protocol update -> review_required + active_protocol_update scope active_decision = gate.evaluate( ai_output=ai_output, requested_tool="protocol_editor.update_active_protocol", @@ -119,25 +125,30 @@ def run_demo() -> int: scope_result = submit_review_trigger( active_d["review_trigger"], + record=active_record, grant_scope="protocol_draft", reviewer_id="protocol_owner", ) + if scope_result.error: + print(f"SCOPE adapter error (invalid grant blocks PCS export): {scope_result.error}") + return 1 + scope_packet = scope_result.review_packet or {} scope_grant = scope_result.grant or {} + scope_decision = scope_result.decision or {} (demo_dir / "scope_review_packet.json").write_text( json.dumps(scope_packet, indent=2), encoding="utf-8" ) (demo_dir / "scope_narrow_grant.json").write_text( json.dumps(scope_grant, indent=2), encoding="utf-8" ) - print(f"SCOPE adapter mode: {scope_result.adapter_mode}") + (demo_dir / "scope_decision.json").write_text( + json.dumps(scope_decision, indent=2), encoding="utf-8" + ) (demo_dir / "review_trigger.json").write_text( json.dumps(active_d["review_trigger"], indent=2), encoding="utf-8" ) - # Step 2: narrow grant artifacts written by SCOPE adapter (simulated or subprocess) - - # Step 3: draft-only follow-up remains admissible; active + robot still blocked draft_decision = gate.evaluate( ai_output={"summary": "Draft protocol threshold change for owner review."}, requested_tool="protocol_editor.draft_change", @@ -165,7 +176,6 @@ def run_demo() -> int: ) assert robot_decision.admissibility in ("blocked", "authorization_required") - # Step 4: PF + PCS export for active-update record pf_path = export_pf_obligation( AKTARecord(active_record), demo_dir, validate=True, decision_id=active_d["decision_id"] ) @@ -179,6 +189,9 @@ def run_demo() -> int: pcs_dir, decision=active_d, scope_review_packet=scope_packet, + scope_decision=scope_decision, + scope_grant=scope_grant, + pf_obligation=pf_obligation, validate=True, ) @@ -186,8 +199,10 @@ def run_demo() -> int: print(f"Draft follow-up: {draft_d['admissibility']} (draft_change allowed)") print(f"Robot submit: {robot_decision.admissibility} (still blocked/authorization)") print(f"SCOPE grant: {scope_grant.get('granted_scope', scope_grant)}") + print(f"SCOPE decision: {scope_decision.get('status', scope_decision)}") print(f"PF obligation: {pf_obligation['obligation_type']}") print(f"PCS bundle: {pcs_dir / 'manifest.json'}") + print(f"Adapter mode: {mode_label}") print(f"Artifacts in {demo_dir}") return 0 diff --git a/scripts/regenerate_policy_manifest.py b/scripts/regenerate_policy_manifest.py new file mode 100644 index 0000000..f5a3afe --- /dev/null +++ b/scripts/regenerate_policy_manifest.py @@ -0,0 +1,54 @@ +"""Regenerate policy_manifest.yaml after policy file changes (dev use).""" + +from __future__ import annotations + +import hashlib +import hmac +from pathlib import Path + +import yaml + +from akta.hash import hash_file_content + +ROOT = Path(__file__).resolve().parent.parent +POLICY_DIR = ROOT / "policy" + +MANIFEST_FILES = [ + "action_ontology.yaml", + "responsibility_levels.yaml", + "evidence_states.yaml", + "validation_statuses.yaml", + "verification_statuses.yaml", + "deployment_profiles.yaml", + "admissibility_matrix.yaml", + "evidence_to_action_matrix.yaml", + "evidence_to_action_rules.yaml", + "default_tool_registry.yaml", + "tool_to_requested_scope.yaml", +] + +DEFAULT_HMAC_KEY = b"akta-dev-policy-integrity-v0.4-test-key" + + +def main() -> None: + files: dict[str, str] = {} + for name in MANIFEST_FILES: + path = POLICY_DIR / name + files[name] = hash_file_content(path.read_text(encoding="utf-8")) + + payload = "|".join(f"{k}:{v}" for k, v in sorted(files.items())) + sig = hmac.new(DEFAULT_HMAC_KEY, payload.encode("utf-8"), hashlib.sha256).hexdigest() + + manifest = { + "version": "akta-policy-manifest-v0.5", + "bundle_version": "akta-core-v0.5", + "files": files, + "signature": {"algorithm": "HMAC-SHA256", "value": sig}, + } + out = POLICY_DIR / "policy_manifest.yaml" + out.write_text(yaml.dump(manifest, sort_keys=False, default_flow_style=False), encoding="utf-8") + print(f"Wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/skills/akta-scientific-action-admissibility/SKILL.md b/skills/akta-scientific-action-admissibility/SKILL.md index 7699fec..9aeab28 100644 --- a/skills/akta-scientific-action-admissibility/SKILL.md +++ b/skills/akta-scientific-action-admissibility/SKILL.md @@ -1,7 +1,7 @@ --- name: akta-scientific-action-admissibility description: >- - Evaluates scientific action admissibility using the AKTA protocol (v0.4). + Evaluates scientific action admissibility using the AKTA protocol (v0.5). Classifies AI outputs (A0-A10), assigns responsibility levels, applies evidence and deployment profile policy, gates tool calls, emits AKTA Records, and produces SCOPE-compatible review triggers with requested_scope. Use when integrating @@ -11,7 +11,7 @@ description: >- authority transfer. --- -# AKTA Scientific Action Admissibility (v0.4) +# AKTA Scientific Action Admissibility (v0.5) ## Quick start @@ -28,7 +28,8 @@ akta gate \ akta record --decision akta_decision.json --out akta_record.json akta review-trigger export --decision akta_decision.json --out review_trigger.json -# v0.4: oracle-independent eval, transition runner, MCP server +# v0.5: SCOPE adapter modes, PCS full chain, production policy integrity +python scripts/demo_akta_scope_protocol_drift.py python evals/run_oracle_independent.py python -m adapters.mcp.server ``` @@ -50,9 +51,9 @@ Do **not** use AKTA to decide scientific truth — use VSA for evidence groundin 3. **Gate tool call** — block if `admissibility` is `blocked`, `abstain_insufficient_context`, `review_required`, or `authorization_required` 4. **Emit record** — `decision.to_record()` for every non-trivial decision 5. **Export integrations** — PF-Core obligation (`akta export pf`), PCS bundle (`akta export pcs`) -6. **SCOPE handoff** — when `review_required` or `authorization_required`, export `review_trigger` with `requested_scope` for SCOPE routing (simulated or subprocess via `SCOPE_CLI`) +6. **SCOPE handoff** — when `review_required` or `authorization_required`, export `review_trigger` with `requested_scope` for SCOPE routing (simulated, python-import via `SCOPE_REPO_PATH`, or CLI via `SCOPE_CLI`) -## Review context (v0.4) +## Review context (v0.5) Prior review metadata in `context.metadata` is enforced: @@ -134,7 +135,7 @@ See [examples/weak_evidence_gate.md](examples/weak_evidence_gate.md) and [exampl ## Additional resources - Protocol overview: [docs/scientific_action_admissibility.md](../../docs/scientific_action_admissibility.md) -- v0.4 integration: [docs/integration_guide.md](../../docs/integration_guide.md) +- v0.5 integration: [docs/integration_guide.md](../../docs/integration_guide.md) - Threat model: [docs/threat_model.md](../../docs/threat_model.md) - SCOPE bridge: [docs/scope_bridge.md](../../docs/scope_bridge.md) - Integration: [docs/integration_guide.md](../../docs/integration_guide.md) diff --git a/tests/contracts/README.md b/tests/contracts/README.md index 64d8090..056d861 100644 --- a/tests/contracts/README.md +++ b/tests/contracts/README.md @@ -13,6 +13,7 @@ pytest tests/contracts/ tests/integration/ -v | `test_scope_trigger_contract.py` | SCOPE review trigger shape, `requested_scope`, ID aliases | | `test_pf_obligation_contract.py` | PF-Core obligation schema and enforcement fields | | `test_pcs_manifest_contract.py` | PCS manifest schema, review trigger inclusion | +| `test_pcs_full_chain_tamper.py` | PCS v0.5 full-chain tamper detection (all artifacts) | | `test_pcs_contract.py` (integration) | Round-trip PCS export from live gate | Fixtures live in `tests/contracts/fixtures/`. Regenerate pinned hashes after intentional schema bumps: @@ -34,17 +35,19 @@ $env:SCOPE_CLI = "scope" # SCOPE CLI on PATH, or full path pytest tests/contracts/ tests/integration/ -v ``` -### SCOPE subprocess mode +### SCOPE CLI mode (v0.5) -When `SCOPE_CLI` or `SCOPE_REPO_PATH` is set, `adapters/scope/client.py` invokes: +When `SCOPE_CLI` is set (without `SCOPE_REPO_PATH`), `adapters/scope/client.py` invokes: ```text -scope review --stdin [--repo ] +scope packet create --trigger [--record ] --out +scope decision submit --packet --grant-scope --reviewer --out +scope grant issue --decision --out ``` -stdin JSON: `{"trigger": {...}, "grant_scope": "...", "reviewer_id": "..."}` +### SCOPE python-import mode -stdout JSON: `{"review_packet": {...}, "grant": {...}, "decision": {...}}` +When `SCOPE_REPO_PATH` is set, the adapter imports `ScopeEngine` (or compat aliases) from the sibling repo. Expected methods are documented in `adapters/scope/engine_protocol.py`. See [docs/scope_bridge.md](../../docs/scope_bridge.md) for trigger fields and anti-patterns. diff --git a/tests/contracts/test_pcs_full_chain_tamper.py b/tests/contracts/test_pcs_full_chain_tamper.py new file mode 100644 index 0000000..ffca0d8 --- /dev/null +++ b/tests/contracts/test_pcs_full_chain_tamper.py @@ -0,0 +1,117 @@ +"""Comprehensive PCS v0.5 full-chain tamper validation (contract tests).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from adapters.pcs.export_artifact import export_pcs_bundle, validate_pcs_bundle +from akta import AKTAGate, AKTAContext +from akta.records import AKTARecord + +ROOT = Path(__file__).resolve().parent.parent.parent + + +def _full_chain_bundle(tmp_path: Path) -> Path: + gate = AKTAGate.from_policy_dir(ROOT / "policy", overlays_dir=ROOT / "overlays") + decision = gate.evaluate( + ai_output={"summary": "Update active protocol threshold."}, + requested_tool="protocol_editor.update_active_protocol", + requested_action="update_threshold", + context=AKTAContext.from_dict({ + "evidence_state": "E4_internally_consistent_evidence", + "validation_status": "V3_preliminary_experimental_support", + }), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + ) + d = decision.to_dict() + record = decision.to_record().to_dict() + record["review_trigger"] = d["review_trigger"] + + out = tmp_path / "pcs" + export_pcs_bundle( + AKTARecord(record), + out, + decision=d, + scope_review_packet={"packet_type": "scope_review_packet"}, + scope_decision={"status": "granted", "granted_scope": "protocol_draft"}, + scope_grant={ + "grant_id": "SCOPE-GRANT-TAMPER", + "granted_scope": "protocol_draft", + "requested_scope": "active_protocol_update", + }, + pf_obligation={"obligation_type": "tool_review", "obligation_id": "PF-OBL-TAMPER"}, + validate=True, + ) + return out + + +def test_pcs_full_chain_fixture_validates() -> None: + bundle = ROOT / "examples" / "integrated_protocol_drift" / "pcs_bundle" + validate_pcs_bundle(bundle) + manifest = json.loads((bundle / "manifest.json").read_text(encoding="utf-8")) + assert manifest["schema_version"] == "akta-record-v0.5" + assert len(manifest["file_hashes"]) == 10 + + +@pytest.mark.parametrize( + "artifact", + [ + "akta_record.json", + "akta_decision.json", + "policy_hash.txt", + "domain_overlay_hash.txt", + "tool_registry_hash.txt", + "review_trigger.json", + "scope_review_packet.json", + "scope_decision.json", + "scope_grant.json", + "pf_obligation.json", + ], +) +def test_pcs_tamper_each_artifact_detected(tmp_path: Path, artifact: str) -> None: + out = _full_chain_bundle(tmp_path) + path = out / artifact + path.write_text(path.read_text(encoding="utf-8") + " ", encoding="utf-8") + with pytest.raises(ValueError, match="tamper"): + validate_pcs_bundle(out) + + +def test_pcs_tamper_missing_listed_artifact(tmp_path: Path) -> None: + out = _full_chain_bundle(tmp_path) + (out / "scope_grant.json").unlink() + with pytest.raises(ValueError, match="missing listed artifact"): + validate_pcs_bundle(out) + + +def test_pcs_tamper_manifest_hash_mismatch(tmp_path: Path) -> None: + out = _full_chain_bundle(tmp_path) + manifest_path = out / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["manifest_hash"] = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + with pytest.raises(ValueError, match="manifest_hash"): + validate_pcs_bundle(out) + + +def test_pcs_tamper_files_list_mismatch(tmp_path: Path) -> None: + out = _full_chain_bundle(tmp_path) + manifest_path = out / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["files"] = sorted(manifest["files"] + ["extra.json"]) + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + with pytest.raises(ValueError, match="files list"): + validate_pcs_bundle(out) + + +def test_pcs_validate_requires_file_hashes(tmp_path: Path) -> None: + out = _full_chain_bundle(tmp_path) + manifest_path = out / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + del manifest["file_hashes"] + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + with pytest.raises(ValueError, match="file_hashes"): + validate_pcs_bundle(out) diff --git a/tests/contracts/test_pcs_manifest_contract.py b/tests/contracts/test_pcs_manifest_contract.py index b48b6db..9cd994b 100644 --- a/tests/contracts/test_pcs_manifest_contract.py +++ b/tests/contracts/test_pcs_manifest_contract.py @@ -42,10 +42,12 @@ def test_pcs_manifest_includes_v03_review_trigger(tmp_path: Path) -> None: def test_pcs_pinned_fixture_full_shape() -> None: manifest = json.loads((FIXTURES / "pcs_manifest_v0.4.json").read_text(encoding="utf-8")) + manifest["schema_version"] = "akta-record-v0.5" + manifest["file_hashes"] = {f: f"sha256:{'0' * 64}" for f in manifest["files"] if f != "manifest.json"} validate_against_schema(manifest, "pcs_akta_artifact.schema.json") assert manifest["artifact_type"] == "akta_scientific_action_record" assert "review_trigger.json" in manifest["files"] - assert manifest["schema_version"] == "akta-record-v0.4" + assert manifest["schema_version"] == "akta-record-v0.5" for field in ( "record_hash", "policy_hash", diff --git a/tests/contracts/test_real_scope_cli_contract.py b/tests/contracts/test_real_scope_cli_contract.py new file mode 100644 index 0000000..dad0f47 --- /dev/null +++ b/tests/contracts/test_real_scope_cli_contract.py @@ -0,0 +1,100 @@ +"""SCOPE real CLI mode contract tests (mock subprocess with real command shapes).""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from adapters.scope.client import ADAPTER_MODE_CLI, submit_review_trigger + +ROOT = Path(__file__).resolve().parent.parent.parent + + +def _make_cli_side_effect(tmp_path: Path): + """Simulate scope packet create / decision submit / grant issue CLI chain.""" + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + proc = type("Proc", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + if cmd[1:3] == ["packet", "create"]: + trigger = json.loads(Path(cmd[cmd.index("--trigger") + 1]).read_text(encoding="utf-8")) + out = Path(cmd[cmd.index("--out") + 1]) + out.write_text(json.dumps({"packet_type": "scope_review_packet", "trigger": trigger}), encoding="utf-8") + elif cmd[1:3] == ["decision", "submit"]: + granted = cmd[cmd.index("--grant-scope") + 1] + reviewer = cmd[cmd.index("--reviewer") + 1] + out = Path(cmd[cmd.index("--out") + 1]) + out.write_text(json.dumps({"status": "granted", "granted_scope": granted, "reviewer_id": reviewer}), encoding="utf-8") + elif cmd[1:3] == ["grant", "issue"]: + decision = json.loads(Path(cmd[cmd.index("--decision") + 1]).read_text(encoding="utf-8")) + out = Path(cmd[cmd.index("--out") + 1]) + out.write_text(json.dumps({ + "grant_id": "SCOPE-GRANT-CLI01", + "granted_scope": decision["granted_scope"], + "requested_scope": "active_protocol_update", + "reviewer_id": decision.get("reviewer_id"), + "review_trigger_id": "AKTA-REVTRIG-CLI01", + }), encoding="utf-8") + return proc + + return fake_run, calls + + +def test_scope_cli_real_command_shapes(monkeypatch: pytest.MonkeyPatch) -> None: + trigger = { + "review_trigger_id": "AKTA-REVTRIG-CLI01", + "requested_scope": "active_protocol_update", + "required_review_role": "protocol_owner", + } + monkeypatch.setenv("SCOPE_CLI", "scope") + monkeypatch.delenv("SCOPE_REPO_PATH", raising=False) + + side_effect, calls = _make_cli_side_effect(Path(".")) + with patch("adapters.scope.client.subprocess.run", side_effect=side_effect): + result = submit_review_trigger(trigger, grant_scope="protocol_draft", reviewer_id="protocol_owner") + + assert result.adapter_mode == ADAPTER_MODE_CLI + assert result.error is None + assert len(calls) == 3 + assert calls[0][:4] == ["scope", "packet", "create", "--trigger"] + assert calls[1][:4] == ["scope", "decision", "submit", "--packet"] + assert calls[2][:4] == ["scope", "grant", "issue", "--decision"] + assert "--grant-scope" in calls[1] + assert result.grant is not None + assert result.grant["granted_scope"] == "protocol_draft" + + +def test_scope_cli_invalid_grant_blocks(monkeypatch: pytest.MonkeyPatch) -> None: + trigger = { + "review_trigger_id": "AKTA-REVTRIG-CLI02", + "requested_scope": "active_protocol_update", + } + monkeypatch.setenv("SCOPE_CLI", "scope") + monkeypatch.delenv("SCOPE_REPO_PATH", raising=False) + + side_effect, _ = _make_cli_side_effect(Path(".")) + with patch("adapters.scope.client.subprocess.run", side_effect=side_effect): + result = submit_review_trigger(trigger, grant_scope="robot_queue_submission") + + assert result.adapter_mode == ADAPTER_MODE_CLI + assert result.error is not None + + +def test_scope_cli_missing_binary(monkeypatch: pytest.MonkeyPatch) -> None: + trigger = { + "review_trigger_id": "AKTA-REVTRIG-CLI03", + "requested_scope": "protocol_draft", + } + monkeypatch.setenv("SCOPE_CLI", "nonexistent-scope-cli-xyz") + monkeypatch.delenv("SCOPE_REPO_PATH", raising=False) + + with patch("adapters.scope.client.subprocess.run", side_effect=FileNotFoundError("scope not found")): + result = submit_review_trigger(trigger) + + assert result.adapter_mode == ADAPTER_MODE_CLI + assert result.error is not None diff --git a/tests/contracts/test_scope_import_mode_contract.py b/tests/contracts/test_scope_import_mode_contract.py new file mode 100644 index 0000000..7d563e0 --- /dev/null +++ b/tests/contracts/test_scope_import_mode_contract.py @@ -0,0 +1,110 @@ +"""SCOPE python-import mode contract tests (mock ScopeEngine when no sibling repo).""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import ModuleType +from typing import Any +from unittest.mock import patch + +import pytest + +from adapters.scope.client import ADAPTER_MODE_PYTHON_IMPORT, submit_review_trigger + +ROOT = Path(__file__).resolve().parent.parent.parent + + +class MockScopeEngine: + """Minimal ScopeEngine stand-in for contract tests.""" + + def create_packet(self, trigger: dict[str, Any], record: dict[str, Any] | None = None) -> dict[str, Any]: + from akta.scope_contract import assemble_review_packet + + return assemble_review_packet(trigger, record) + + def submit_decision( + self, + packet: dict[str, Any], + *, + granted_scope: str, + reviewer_id: str, + ) -> dict[str, Any]: + return { + "status": "granted", + "granted_scope": granted_scope, + "reviewer_id": reviewer_id, + "packet_id": packet.get("trigger", {}).get("review_trigger_id"), + } + + def issue_grant(self, decision: dict[str, Any], trigger: dict[str, Any] | None = None) -> dict[str, Any]: + trigger = trigger or {} + return { + "grant_id": f"SCOPE-GRANT-{trigger.get('review_trigger_id', 'TEST')}", + "granted_scope": decision["granted_scope"], + "requested_scope": trigger.get("requested_scope"), + "reviewer_id": decision.get("reviewer_id"), + "review_trigger_id": trigger.get("review_trigger_id"), + } + + +@pytest.fixture +def scope_repo_path(tmp_path: Path) -> Path: + repo = tmp_path / "scope_repo" + repo.mkdir() + (repo / "scope.py").write_text("class ScopeEngine: pass\n", encoding="utf-8") + return repo + + +def test_scope_import_mode_full_chain(monkeypatch: pytest.MonkeyPatch, scope_repo_path: Path) -> None: + trigger = { + "review_trigger_id": "AKTA-REVTRIG-IMPORT01", + "requested_scope": "active_protocol_update", + "required_review_role": "protocol_owner", + } + monkeypatch.setenv("SCOPE_REPO_PATH", str(scope_repo_path)) + monkeypatch.delenv("SCOPE_CLI", raising=False) + + fake_scope = ModuleType("scope") + fake_scope.ScopeEngine = MockScopeEngine + with patch.dict(sys.modules, {"scope": fake_scope}): + result = submit_review_trigger(trigger, grant_scope="protocol_draft", reviewer_id="protocol_owner") + + assert result.adapter_mode == ADAPTER_MODE_PYTHON_IMPORT + assert result.error is None + assert result.review_packet is not None + assert result.decision is not None + assert result.grant is not None + assert result.grant["granted_scope"] == "protocol_draft" + + +def test_scope_import_invalid_grant_fails(monkeypatch: pytest.MonkeyPatch, scope_repo_path: Path) -> None: + trigger = { + "review_trigger_id": "AKTA-REVTRIG-IMPORT02", + "requested_scope": "active_protocol_update", + } + monkeypatch.setenv("SCOPE_REPO_PATH", str(scope_repo_path)) + monkeypatch.delenv("SCOPE_CLI", raising=False) + + fake_scope = ModuleType("scope") + fake_scope.ScopeEngine = MockScopeEngine + with patch.dict(sys.modules, {"scope": fake_scope}): + result = submit_review_trigger(trigger, grant_scope="robot_queue_submission") + + assert result.adapter_mode == ADAPTER_MODE_PYTHON_IMPORT + assert result.error is not None + + +def test_scope_import_missing_module_returns_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + repo = tmp_path / "empty_repo" + repo.mkdir() + monkeypatch.setenv("SCOPE_REPO_PATH", str(repo)) + monkeypatch.delenv("SCOPE_CLI", raising=False) + sys.modules.pop("scope", None) + + result = submit_review_trigger({ + "review_trigger_id": "AKTA-REVTRIG-IMPORT03", + "requested_scope": "protocol_draft", + }) + assert result.adapter_mode == ADAPTER_MODE_PYTHON_IMPORT + assert result.error is not None diff --git a/tests/integration/test_pcs_contract.py b/tests/integration/test_pcs_contract.py index 27455d5..d061dd6 100644 --- a/tests/integration/test_pcs_contract.py +++ b/tests/integration/test_pcs_contract.py @@ -28,7 +28,8 @@ def test_pcs_export_validates_manifest(tmp_path: Path) -> None: out = export_pcs_bundle(record, tmp_path / "pcs", decision=decision.to_dict(), validate=True) manifest = json.loads((out / "manifest.json").read_text(encoding="utf-8")) validate_against_schema(manifest, "pcs_akta_artifact.schema.json") - assert manifest["schema_version"] == "akta-record-v0.4" + assert manifest["schema_version"] == "akta-record-v0.5" + assert "file_hashes" in manifest assert (out / "akta_record.json").exists() assert (out / "akta_decision.json").exists() diff --git a/tests/test_adapters_v04.py b/tests/test_adapters_v04.py index 5b82d61..2d75121 100644 --- a/tests/test_adapters_v04.py +++ b/tests/test_adapters_v04.py @@ -88,25 +88,18 @@ def test_pcs_bench_export(tmp_path: Path) -> None: assert len(lines) == 5 first = json.loads(lines[0]) assert "inputs" in first - assert first["suite_id"] == "akta-pcs-bench-v0.4" + assert first["suite_id"] == "akta-pcs-bench-v0.5" -def test_scope_adapter_subprocess_mode_mock(monkeypatch: pytest.MonkeyPatch) -> None: +def test_scope_adapter_cli_mode_mock(monkeypatch: pytest.MonkeyPatch) -> None: trigger = { "review_trigger_id": "AKTA-REVTRIG-SUBPROC01", "requested_scope": "active_protocol_update", "required_review_role": "protocol_owner", } scope_output = json.dumps({ - "review_packet": {"trigger": trigger}, - "grant": { - "grant_id": "SCOPE-GRANT-SUBPROC01", - "granted_scope": "protocol_draft", - "requested_scope": "active_protocol_update", - "reviewer_id": "scope_reviewer", - "review_trigger_id": "AKTA-REVTRIG-SUBPROC01", - }, - "decision": {"status": "granted", "granted_scope": "protocol_draft"}, + "packet_type": "scope_review_packet", + "trigger": trigger, }) class FakeProc: @@ -114,19 +107,39 @@ class FakeProc: stdout = scope_output stderr = "" + def fake_run(cmd, **kwargs): + proc = FakeProc() + if cmd[1:3] == ["packet", "create"]: + out = Path(cmd[cmd.index("--out") + 1]) + out.write_text(scope_output, encoding="utf-8") + elif cmd[1:3] == ["decision", "submit"]: + granted = cmd[cmd.index("--grant-scope") + 1] + out = Path(cmd[cmd.index("--out") + 1]) + out.write_text(json.dumps({"status": "granted", "granted_scope": granted}), encoding="utf-8") + elif cmd[1:3] == ["grant", "issue"]: + out = Path(cmd[cmd.index("--out") + 1]) + out.write_text(json.dumps({ + "grant_id": "SCOPE-GRANT-SUBPROC01", + "granted_scope": "protocol_draft", + "requested_scope": "active_protocol_update", + "reviewer_id": "scope_reviewer", + "review_trigger_id": "AKTA-REVTRIG-SUBPROC01", + }), encoding="utf-8") + return proc + monkeypatch.setenv("SCOPE_CLI", "scope-mock") monkeypatch.delenv("SCOPE_REPO_PATH", raising=False) - with patch("adapters.scope.client.subprocess.run", return_value=FakeProc()): + with patch("adapters.scope.client.subprocess.run", side_effect=fake_run): result = submit_review_trigger(trigger, grant_scope="protocol_draft") - assert result.adapter_mode == "subprocess" + assert result.adapter_mode == "cli" assert result.error is None assert result.grant is not None assert result.grant["granted_scope"] == "protocol_draft" -def test_scope_adapter_subprocess_cli_missing(monkeypatch: pytest.MonkeyPatch) -> None: +def test_scope_adapter_cli_missing(monkeypatch: pytest.MonkeyPatch) -> None: trigger = { "review_trigger_id": "AKTA-REVTRIG-MISSING01", "requested_scope": "protocol_draft", @@ -139,5 +152,5 @@ def test_scope_adapter_subprocess_cli_missing(monkeypatch: pytest.MonkeyPatch) - ): result = submit_review_trigger(trigger) - assert result.adapter_mode == "subprocess" + assert result.adapter_mode == "cli" assert result.error is not None diff --git a/tests/test_classifier_trust_boundary.py b/tests/test_classifier_trust_boundary.py new file mode 100644 index 0000000..c1ce818 --- /dev/null +++ b/tests/test_classifier_trust_boundary.py @@ -0,0 +1,141 @@ +"""LLM classifier trust boundary tests (v0.5).""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from akta import AKTAGate, AKTAContext +from akta.classifier_plugins import PluginClassification +from akta.classify import classify +from akta.policy import PolicyBundle +from akta.tool_registry import ToolRegistry + +ROOT = Path(__file__).resolve().parent.parent + + +def test_tool_registry_overrides_llm_output() -> None: + from unittest.mock import patch + + from akta.classify import run_plugin_classification + + policy = PolicyBundle.from_dir(ROOT / "policy") + registry = ToolRegistry(policy.tool_registry) + tool_spec = registry.resolve("lab_scheduler.prioritize") + llm_proposal = PluginClassification( + action_type="A4_recommendation", + confidence=0.95, + rationale="LLM would suggest A4", + source="llm_classifier", + uncertainty_flags=["llm_advisory"], + llm_metadata={ + "model": "test", + "prompt_hash": "abc", + "schema": "akta_classification_v0.5", + "confidence": 0.95, + }, + ) + with patch("akta.classify.run_plugin_classification", return_value=llm_proposal): + result = classify( + policy, + "lab_scheduler.prioritize", + "prioritize samples", + tool_spec, + AKTAContext(), + ai_output="recommend next experiment", + ) + assert result.action_type == "A7_resource_or_queue_prioritization" + assert result.classifier_mode == "deterministic" + assert "llm_overridden_by_tool_registry" in result.uncertainty_flags + + +def test_llm_advisory_metadata_in_decision(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AKTA_LLM_CLASSIFIER", "1") + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + policy = PolicyBundle.from_dir(ROOT / "policy") + registry = ToolRegistry(policy.tool_registry) + tool_spec = registry.resolve("unregistered.custom_tool") + + mock_body = { + "choices": [{ + "message": { + "content": json.dumps({ + "action_type": "A4_recommendation", + "confidence": 0.88, + "rationale": "Mocked LLM", + }), + }, + }], + } + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(mock_body).encode("utf-8") + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_resp): + result = classify( + policy, + "unregistered.custom_tool", + "ambiguous", + tool_spec, + AKTAContext(), + ai_output="unclear", + ) + + assert result.classifier_mode == "llm_advisory" + assert result.llm_metadata is not None + assert result.llm_metadata["model"] + assert result.llm_metadata["prompt_hash"] + assert result.llm_metadata["schema"] == "akta_classification_v0.5" + + +def test_low_confidence_llm_fail_closed_for_mutating(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AKTA_LLM_CLASSIFIER", "1") + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + + mock_body = { + "choices": [{ + "message": { + "content": json.dumps({ + "action_type": "A8_tool_or_workflow_mutation", + "confidence": 0.55, + "rationale": "Low confidence mutating", + }), + }, + }], + } + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(mock_body).encode("utf-8") + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + gate = AKTAGate.from_policy_dir(ROOT / "policy", overlays_dir=ROOT / "overlays") + with patch("urllib.request.urlopen", return_value=mock_resp): + decision = gate.evaluate( + ai_output="unclear mutating request", + requested_tool="unregistered.custom_mutator", + requested_action="mutate_workflow", + context=AKTAContext.from_dict({"evidence_state": "E4_internally_consistent_evidence"}), + deployment_profile="P5_review_gated_experimental_planner", + ) + assert decision.admissibility == "abstain_insufficient_context" + d = decision.to_dict() + assert d["classification"]["classifier_mode"] == "llm_advisory" + assert d.get("llm_advisory") is not None + + +def test_core_tests_pass_with_llm_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AKTA_LLM_CLASSIFIER", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + gate = AKTAGate.from_policy_dir(ROOT / "policy", overlays_dir=ROOT / "overlays") + decision = gate.evaluate( + ai_output={"summary": "Search literature."}, + requested_tool="literature_search.query", + requested_action="search", + context=AKTAContext(), + deployment_profile="P1_literature_hypothesis_assistant", + ) + assert decision.to_dict()["classification"]["classifier_mode"] == "deterministic" diff --git a/tests/test_overlay_governance_v05.py b/tests/test_overlay_governance_v05.py new file mode 100644 index 0000000..e0b3d13 --- /dev/null +++ b/tests/test_overlay_governance_v05.py @@ -0,0 +1,89 @@ +"""Overlay governance tests for production mode (v0.5).""" + +from __future__ import annotations + +import hashlib +import hmac +import shutil +from pathlib import Path + +import pytest +import yaml + +from akta import AKTAGate, AKTAContext +from akta.errors import PolicyError +from akta.overlays import DomainOverlay + +ROOT = Path(__file__).resolve().parent.parent + + +def _policy_with_deploy_key(tmp_path: Path, deploy_key: bytes) -> Path: + policy_dir = tmp_path / "policy" + shutil.copytree(ROOT / "policy", policy_dir) + manifest_path = policy_dir / "policy_manifest.yaml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + payload = "|".join(f"{k}:{v}" for k, v in sorted(manifest["files"].items())) + manifest["signature"]["value"] = hmac.new(deploy_key, payload.encode("utf-8"), hashlib.sha256).hexdigest() + manifest_path.write_text(yaml.dump(manifest, sort_keys=False), encoding="utf-8") + return policy_dir + + +def test_core_reference_overlay_tier() -> None: + overlay = DomainOverlay.load("generic_lab_v0", ROOT / "overlays") + assert overlay.tier == "core_reference" + + +def test_experimental_overlay_refused_in_production(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AKTA_PRODUCTION_MODE", "1") + with pytest.raises(PolicyError, match="Production mode refuses overlay"): + DomainOverlay.load("biology_v0", ROOT / "overlays") + + +def test_experimental_overlay_ok_in_dev(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AKTA_PRODUCTION_MODE", raising=False) + monkeypatch.delenv("AKTA_VERIFY_POLICY", raising=False) + overlay = DomainOverlay.load("biology_v0", ROOT / "overlays") + assert overlay.tier == "experimental_domain_overlay" + + +def test_gate_refuses_experimental_overlay_in_production( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + deploy_key = b"production-overlay-gate-test-v0.5" + policy_dir = _policy_with_deploy_key(tmp_path, deploy_key) + monkeypatch.setenv("AKTA_PRODUCTION_MODE", "1") + monkeypatch.setenv("AKTA_POLICY_HMAC_KEY", deploy_key.decode("utf-8")) + gate = AKTAGate.from_policy_dir(policy_dir, overlays_dir=ROOT / "overlays") + with pytest.raises(PolicyError, match="Production mode refuses overlay"): + gate.evaluate( + ai_output={"summary": "Schedule passage"}, + requested_tool="cell_culture.schedule", + requested_action="schedule_passage", + context=AKTAContext.from_dict({"evidence_state": "E4_internally_consistent_evidence"}), + deployment_profile="P5_review_gated_experimental_planner", + domain_overlay="biology_v0", + ) + + +def test_overlay_governance_fields_present() -> None: + overlays = ( + "generic_lab_v0", + "materials_v0", + "computational_science_v0", + "biology_v0", + "chemistry_v0", + "clinical_v0", + ) + required = ("tier", "maintainer", "review_status", "review_date", "non_certification_statement") + for name in overlays: + overlay = DomainOverlay.load(name, ROOT / "overlays") + for field in required: + assert overlay.data.get(field), f"{name} missing {field}" + + +def test_experimental_overlays_not_operational() -> None: + for name in ("biology_v0", "chemistry_v0", "clinical_v0"): + overlay = DomainOverlay.load(name, ROOT / "overlays") + assert overlay.tier == "experimental_domain_overlay" + assert not overlay.operational diff --git a/tests/test_pcs_bundle_v05.py b/tests/test_pcs_bundle_v05.py new file mode 100644 index 0000000..90378f7 --- /dev/null +++ b/tests/test_pcs_bundle_v05.py @@ -0,0 +1,114 @@ +"""PCS v0.5 full-chain export and tamper detection tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from akta import AKTAGate, AKTAContext +from adapters.pcs.export_artifact import export_pcs_bundle, validate_pcs_bundle +from akta.records import AKTARecord, validate_against_schema + +ROOT = Path(__file__).resolve().parent.parent + + +def _review_required_bundle(tmp_path: Path) -> Path: + gate = AKTAGate.from_policy_dir(ROOT / "policy", overlays_dir=ROOT / "overlays") + decision = gate.evaluate( + ai_output={"summary": "Update active protocol threshold."}, + requested_tool="protocol_editor.update_active_protocol", + requested_action="update_threshold", + context=AKTAContext.from_dict({ + "evidence_state": "E4_internally_consistent_evidence", + "validation_status": "V3_preliminary_experimental_support", + }), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + ) + d = decision.to_dict() + record = decision.to_record().to_dict() + record["review_trigger"] = d["review_trigger"] + + scope_packet = {"packet_type": "scope_review_packet", "trigger": {"requested_scope": "active_protocol_update"}} + scope_decision = {"status": "granted", "granted_scope": "protocol_draft"} + scope_grant = { + "grant_id": "SCOPE-GRANT-TEST", + "granted_scope": "protocol_draft", + "requested_scope": "active_protocol_update", + } + pf_obligation = {"obligation_type": "tool_review", "obligation_id": "PF-OBL-TEST"} + + out = tmp_path / "pcs" + export_pcs_bundle( + AKTARecord(record), + out, + decision=d, + scope_review_packet=scope_packet, + scope_decision=scope_decision, + scope_grant=scope_grant, + pf_obligation=pf_obligation, + validate=True, + ) + return out + + +def test_pcs_v05_manifest_lists_all_artifacts(tmp_path: Path) -> None: + out = _review_required_bundle(tmp_path) + manifest = json.loads((out / "manifest.json").read_text(encoding="utf-8")) + validate_against_schema(manifest, "pcs_akta_artifact.schema.json") + assert manifest["schema_version"] == "akta-record-v0.5" + expected = { + "akta_record.json", + "akta_decision.json", + "policy_hash.txt", + "domain_overlay_hash.txt", + "tool_registry_hash.txt", + "review_trigger.json", + "scope_review_packet.json", + "scope_decision.json", + "scope_grant.json", + "pf_obligation.json", + "manifest.json", + } + assert set(manifest["files"]) == expected + assert set(manifest["file_hashes"].keys()) == expected - {"manifest.json"} + + +def test_pcs_bundle_tamper_detection(tmp_path: Path) -> None: + out = _review_required_bundle(tmp_path) + validate_pcs_bundle(out) + + record_path = out / "akta_record.json" + record_path.write_text(record_path.read_text(encoding="utf-8") + " ", encoding="utf-8") + with pytest.raises(ValueError, match="tamper"): + validate_pcs_bundle(out) + + +def test_invalid_scope_grant_blocks_pcs_export(tmp_path: Path) -> None: + gate = AKTAGate.from_policy_dir(ROOT / "policy", overlays_dir=ROOT / "overlays") + decision = gate.evaluate( + ai_output={"summary": "Update active protocol."}, + requested_tool="protocol_editor.update_active_protocol", + requested_action="update_threshold", + context=AKTAContext.from_dict({"evidence_state": "E4_internally_consistent_evidence"}), + deployment_profile="P4_protocol_drafting_assistant", + domain_overlay="generic_lab_v0", + ) + d = decision.to_dict() + record = decision.to_record().to_dict() + record["review_trigger"] = d["review_trigger"] + bad_grant = { + "grant_id": "SCOPE-GRANT-BAD", + "granted_scope": "robot_queue_submission", + "requested_scope": "active_protocol_update", + } + with pytest.raises(ValueError, match="Invalid SCOPE grant"): + export_pcs_bundle( + AKTARecord(record), + tmp_path / "bad", + decision=d, + scope_grant=bad_grant, + validate=True, + ) diff --git a/tests/test_policy.py b/tests/test_policy.py index e24742d..c4fe5b2 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -9,11 +9,12 @@ def test_policy_bundle_loads() -> None: bundle = PolicyBundle.from_dir(ROOT / "policy") - assert bundle.version == "akta-core-v0.4" - assert bundle.tool_to_requested_scope.get("version") == "akta-tool-scope-v0.3" + assert bundle.version == "akta-core-v0.5" + assert bundle.tool_to_requested_scope.get("version") == "akta-tool-scope-v0.5" assert bundle.policy_hash.startswith("sha256:") assert bundle.tool_registry_hash.startswith("sha256:") assert "tools" in bundle.tool_registry or "lab_scheduler.prioritize" in bundle.tool_registry.get("tools", {}) + assert bundle.policy_file_versions["action_ontology.yaml"] == "action_ontology-v0.5" def test_admissibility_matrix_normalize() -> None: diff --git a/tests/test_policy_integrity_v05.py b/tests/test_policy_integrity_v05.py new file mode 100644 index 0000000..6b59b86 --- /dev/null +++ b/tests/test_policy_integrity_v05.py @@ -0,0 +1,82 @@ +"""Tests for production vs dev policy integrity (v0.5).""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +import yaml + +from akta.errors import PolicyError +from akta.policy_integrity import verify_policy_integrity + +ROOT = Path(__file__).resolve().parent.parent + + +def test_dev_mode_verifies_with_dev_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AKTA_PRODUCTION_MODE", raising=False) + monkeypatch.delenv("AKTA_VERIFY_POLICY", raising=False) + assert verify_policy_integrity(ROOT / "policy") is True + + +def test_production_requires_manifest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + shutil.copytree(ROOT / "policy", tmp_path / "policy") + (tmp_path / "policy" / "policy_manifest.yaml").unlink() + monkeypatch.setenv("AKTA_PRODUCTION_MODE", "1") + with pytest.raises(PolicyError, match="no policy_manifest"): + verify_policy_integrity(tmp_path / "policy") + + +def test_production_rejects_dev_hmac_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + shutil.copytree(ROOT / "policy", tmp_path / "policy") + monkeypatch.setenv("AKTA_PRODUCTION_MODE", "1") + monkeypatch.setenv("AKTA_POLICY_HMAC_KEY", "akta-dev-policy-integrity-v0.4-test-key") + with pytest.raises(PolicyError, match="rejects in-repo dev HMAC"): + verify_policy_integrity(tmp_path / "policy") + + +def test_production_requires_hmac_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + shutil.copytree(ROOT / "policy", tmp_path / "policy") + monkeypatch.setenv("AKTA_PRODUCTION_MODE", "1") + monkeypatch.delenv("AKTA_POLICY_HMAC_KEY", raising=False) + with pytest.raises(PolicyError, match="requires AKTA_POLICY_HMAC_KEY"): + verify_policy_integrity(tmp_path / "policy") + + +def test_production_verifies_with_deployment_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import hashlib + import hmac + + shutil.copytree(ROOT / "policy", tmp_path / "policy") + manifest_path = tmp_path / "policy" / "policy_manifest.yaml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + deploy_key = b"production-deploy-secret-key-v0.5" + payload = "|".join(f"{k}:{v}" for k, v in sorted(manifest["files"].items())) + manifest["signature"]["value"] = hmac.new(deploy_key, payload.encode("utf-8"), hashlib.sha256).hexdigest() + manifest_path.write_text(yaml.dump(manifest, sort_keys=False), encoding="utf-8") + + monkeypatch.setenv("AKTA_PRODUCTION_MODE", "1") + monkeypatch.setenv("AKTA_POLICY_HMAC_KEY", deploy_key.decode("utf-8")) + assert verify_policy_integrity(tmp_path / "policy") is True + + +def test_tampered_policy_fails_in_production(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import hashlib + import hmac + + shutil.copytree(ROOT / "policy", tmp_path / "policy") + deploy_key = b"production-deploy-secret-key-v0.5-tamper" + manifest_path = tmp_path / "policy" / "policy_manifest.yaml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + payload = "|".join(f"{k}:{v}" for k, v in sorted(manifest["files"].items())) + manifest["signature"]["value"] = hmac.new(deploy_key, payload.encode("utf-8"), hashlib.sha256).hexdigest() + manifest_path.write_text(yaml.dump(manifest, sort_keys=False), encoding="utf-8") + + ontology = tmp_path / "policy" / "action_ontology.yaml" + ontology.write_text(ontology.read_text(encoding="utf-8") + "\n# tamper\n", encoding="utf-8") + + monkeypatch.setenv("AKTA_PRODUCTION_MODE", "1") + monkeypatch.setenv("AKTA_POLICY_HMAC_KEY", deploy_key.decode("utf-8")) + with pytest.raises(PolicyError, match="hash mismatch"): + verify_policy_integrity(tmp_path / "policy") diff --git a/tests/test_policy_versioning_v05.py b/tests/test_policy_versioning_v05.py new file mode 100644 index 0000000..ea61620 --- /dev/null +++ b/tests/test_policy_versioning_v05.py @@ -0,0 +1,33 @@ +"""Policy bundle versioning tests (v0.5).""" + +from __future__ import annotations + +from pathlib import Path + +from akta import AKTAGate, AKTAContext +from akta.policy import PolicyBundle + +ROOT = Path(__file__).resolve().parent.parent + + +def test_policy_bundle_version_v05() -> None: + bundle = PolicyBundle.from_dir(ROOT / "policy") + assert bundle.version == "akta-core-v0.5" + assert "action_ontology.yaml" in bundle.policy_file_versions + assert bundle.policy_file_versions["action_ontology.yaml"] == "action_ontology-v0.5" + assert bundle.policy_file_versions["evidence_to_action_rules.yaml"] == "evidence_to_action_rules-v0.5" + + +def test_decision_includes_policy_file_versions() -> None: + gate = AKTAGate.from_policy_dir(ROOT / "policy", overlays_dir=ROOT / "overlays") + decision = gate.evaluate( + ai_output={"summary": "Explain concept."}, + requested_tool="literature_search.query", + requested_action="search", + context=AKTAContext(), + deployment_profile="P1_literature_hypothesis_assistant", + ) + d = decision.to_dict() + assert d["policy_version"] == "akta-core-v0.5" + assert isinstance(d.get("policy_file_versions"), dict) + assert d["policy_file_versions"]["evidence_to_action_rules.yaml"] == "evidence_to_action_rules-v0.5" diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index ebd215f..d943e61 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -48,6 +48,8 @@ def _get(url: str, path: str) -> dict: def test_health(server_url: str) -> None: data = _get(server_url, "/v0/health") assert data["status"] == "ok" + assert data["api_version"] == "v0.5" + assert data["version"] == "0.5.0" def test_policy(server_url: str) -> None: