diff --git a/CHANGELOG.md b/CHANGELOG.md index 8780d96..b5a4123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [1.5.3] - 2026-03-23 + +### Added + +- **`spec_version`** — Required top-level CPS field (default `"1.0"`) on `Capsule` / `CapsuleDict`, included in `to_dict()` / canonical hash. `from_dict()` defaults missing values to `"1.0"` for backward compatibility. Specification updated in `spec/README.md`; golden fixtures regenerated. +- **`validate_capsule_dict` / `validate_capsule` (Python)** and **`validateCapsuleDict` (TypeScript)** — FR-002 runtime validation for CPS content dicts: required keys, section shapes, chain rules, numeric ranges, optional `claimed_hash` integrity check, and optional `strict_unknown_keys` / `strictUnknownKeys`. Exported from `qp_capsule` and `@quantumpipes/capsule`. Invalid vectors updated to include `spec_version` where appropriate; added `missing_spec_version` (16 negative vectors total). +- **Structured verify (FR-003)** — Python: `SealVerifyCode`, `SealVerificationResult`, `Seal.verify_detailed()`, `Seal.verify_with_key_detailed()`; `verify()` / `verify_with_key()` unchanged (boolean, delegate to detailed). TypeScript: `verifyDetailed()`, `SealVerificationResult`, `SealVerifyCode`; `verify()` returns `(await verifyDetailed(...)).ok`. + +--- + ## [1.5.2] - 2026-03-18 ### Added diff --git a/conformance/README.md b/conformance/README.md index 1bcb839..e411814 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -101,7 +101,7 @@ The URI spec is at [`spec/uri-scheme.md`](../spec/uri-scheme.md). ## Invalid Capsule Fixtures -The `invalid-fixtures.json` file provides 15 test vectors for **malformed or structurally invalid** capsules. A conformant verifier SHOULD reject each of these. +The `invalid-fixtures.json` file provides 16 test vectors for **malformed or structurally invalid** capsules. A conformant verifier SHOULD reject each of these. Each entry contains: diff --git a/conformance/fixtures.json b/conformance/fixtures.json index 84c7796..408edac 100644 --- a/conformance/fixtures.json +++ b/conformance/fixtures.json @@ -2,7 +2,7 @@ "version": "1.0", "specification": "Capsule Protocol Specification v1.0", "generated_by": "Python reference implementation (qp-capsule)", - "generated_at": "2026-03-08T15:09:35.637607+00:00", + "generated_at": "2026-03-22T06:17:01.011997+00:00", "description": "Golden test vectors for cross-language Capsule verification. Each fixture contains a capsule_dict, the expected canonical_json, and the expected sha3_256_hash. A conformant implementation must produce byte-identical canonical_json and matching hash for each fixture.", "fixtures": [ { @@ -15,6 +15,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "user_request", "source": "", @@ -59,8 +60,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"pending\",\"summary\":\"\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"\",\"source\":\"\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":null},\"type\":\"agent\"}", - "sha3_256_hash": "e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"pending\",\"summary\":\"\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"\",\"source\":\"\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":null},\"type\":\"agent\"}", + "sha3_256_hash": "8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cd" }, { "name": "full", @@ -72,6 +73,7 @@ "parent_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "sequence": 5, "previous_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "spec_version": "1.0", "trigger": { "type": "user_request", "source": "user_alice", @@ -220,8 +222,8 @@ } } }, - "canonical_json": "{\"authority\":{\"approver\":\"user_alice\",\"chain\":[{\"approver\":\"auto_policy\",\"decision\":\"escalate\",\"level\":1},{\"approver\":\"user_alice\",\"decision\":\"approved\",\"level\":2}],\"escalation_reason\":\"Production deployment requires human approval\",\"policy_reference\":\"POLICY-PROD-DEPLOY-001\",\"type\":\"human_approved\"},\"context\":{\"agent_id\":\"executor_001\",\"environment\":{\"cwd\":\"/workspace\",\"os\":\"linux\",\"python\":\"3.12\"},\"session_id\":\"sess_def456\"},\"domain\":\"goals\",\"execution\":{\"duration_ms\":5700,\"resources_used\":{\"api_calls\":3,\"cpu_seconds\":2.5},\"tool_calls\":[{\"arguments\":{\"manifest\":\"deploy-v2.4.yaml\",\"namespace\":\"production\"},\"duration_ms\":4500,\"error\":null,\"result\":{\"pods_created\":3,\"service_updated\":true},\"success\":true,\"tool\":\"kubectl_apply\"},{\"arguments\":{\"timeout\":30,\"url\":\"https://api.example.com/health\"},\"duration_ms\":1200,\"error\":null,\"result\":{\"latency_ms\":45,\"status\":200},\"success\":true,\"tool\":\"health_check\"}]},\"id\":\"b2c3d4e5-f6a7-8901-bcde-f12345678901\",\"outcome\":{\"error\":null,\"metrics\":{\"cost_usd\":0.0042,\"latency_ms\":5700,\"tokens_in\":1500,\"tokens_out\":350},\"result\":{\"endpoint\":\"https://api.example.com\",\"replicas\":3,\"version\":\"2.4\"},\"side_effects\":[\"Updated production deployment\",\"DNS records updated\"],\"status\":\"success\",\"summary\":\"Deployed v2.4 to production with 3 replicas, all health checks passing\"},\"parent_id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"previous_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"reasoning\":{\"analysis\":\"Production deployment requires blue-green strategy.\",\"confidence\":0.95,\"model\":\"anthropic/claude-sonnet-4-20250514\",\"options\":[{\"cons\":[\"Requires double resources temporarily\"],\"description\":\"Blue-green deployment\",\"estimated_impact\":{\"scope\":\"production\",\"severity\":\"medium\"},\"feasibility\":0.9,\"id\":\"opt_0\",\"pros\":[\"Zero downtime\",\"Easy rollback\"],\"rejection_reason\":\"\",\"risks\":[\"DNS propagation delay\"],\"selected\":true},{\"cons\":[\"Brief downtime possible\"],\"description\":\"Rolling update\",\"estimated_impact\":{\"scope\":\"production\",\"severity\":\"low\"},\"feasibility\":0.7,\"id\":\"opt_1\",\"pros\":[\"Simple\"],\"rejection_reason\":\"Higher risk of downtime\",\"risks\":[\"Partial deployment state\"],\"selected\":false}],\"options_considered\":[\"Blue-green deployment\",\"Rolling update\"],\"prompt_hash\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"reasoning\":\"Blue-green provides zero-downtime with easy rollback capability\",\"selected_option\":\"Blue-green deployment\"},\"sequence\":5,\"trigger\":{\"correlation_id\":\"corr_abc123\",\"request\":\"Deploy service v2.4 to production\",\"source\":\"user_alice\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":\"user_alice\"},\"type\":\"agent\"}", - "sha3_256_hash": "f4e04d02aadacf1cc051d4ce10b72c486827d5f7036ee59e16309ebd923cd194" + "canonical_json": "{\"authority\":{\"approver\":\"user_alice\",\"chain\":[{\"approver\":\"auto_policy\",\"decision\":\"escalate\",\"level\":1},{\"approver\":\"user_alice\",\"decision\":\"approved\",\"level\":2}],\"escalation_reason\":\"Production deployment requires human approval\",\"policy_reference\":\"POLICY-PROD-DEPLOY-001\",\"type\":\"human_approved\"},\"context\":{\"agent_id\":\"executor_001\",\"environment\":{\"cwd\":\"/workspace\",\"os\":\"linux\",\"python\":\"3.12\"},\"session_id\":\"sess_def456\"},\"domain\":\"goals\",\"execution\":{\"duration_ms\":5700,\"resources_used\":{\"api_calls\":3,\"cpu_seconds\":2.5},\"tool_calls\":[{\"arguments\":{\"manifest\":\"deploy-v2.4.yaml\",\"namespace\":\"production\"},\"duration_ms\":4500,\"error\":null,\"result\":{\"pods_created\":3,\"service_updated\":true},\"success\":true,\"tool\":\"kubectl_apply\"},{\"arguments\":{\"timeout\":30,\"url\":\"https://api.example.com/health\"},\"duration_ms\":1200,\"error\":null,\"result\":{\"latency_ms\":45,\"status\":200},\"success\":true,\"tool\":\"health_check\"}]},\"id\":\"b2c3d4e5-f6a7-8901-bcde-f12345678901\",\"outcome\":{\"error\":null,\"metrics\":{\"cost_usd\":0.0042,\"latency_ms\":5700,\"tokens_in\":1500,\"tokens_out\":350},\"result\":{\"endpoint\":\"https://api.example.com\",\"replicas\":3,\"version\":\"2.4\"},\"side_effects\":[\"Updated production deployment\",\"DNS records updated\"],\"status\":\"success\",\"summary\":\"Deployed v2.4 to production with 3 replicas, all health checks passing\"},\"parent_id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"previous_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"reasoning\":{\"analysis\":\"Production deployment requires blue-green strategy.\",\"confidence\":0.95,\"model\":\"anthropic/claude-sonnet-4-20250514\",\"options\":[{\"cons\":[\"Requires double resources temporarily\"],\"description\":\"Blue-green deployment\",\"estimated_impact\":{\"scope\":\"production\",\"severity\":\"medium\"},\"feasibility\":0.9,\"id\":\"opt_0\",\"pros\":[\"Zero downtime\",\"Easy rollback\"],\"rejection_reason\":\"\",\"risks\":[\"DNS propagation delay\"],\"selected\":true},{\"cons\":[\"Brief downtime possible\"],\"description\":\"Rolling update\",\"estimated_impact\":{\"scope\":\"production\",\"severity\":\"low\"},\"feasibility\":0.7,\"id\":\"opt_1\",\"pros\":[\"Simple\"],\"rejection_reason\":\"Higher risk of downtime\",\"risks\":[\"Partial deployment state\"],\"selected\":false}],\"options_considered\":[\"Blue-green deployment\",\"Rolling update\"],\"prompt_hash\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"reasoning\":\"Blue-green provides zero-downtime with easy rollback capability\",\"selected_option\":\"Blue-green deployment\"},\"sequence\":5,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":\"corr_abc123\",\"request\":\"Deploy service v2.4 to production\",\"source\":\"user_alice\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":\"user_alice\"},\"type\":\"agent\"}", + "sha3_256_hash": "755b20bec1a5019ecca17dbf03270160050fa3579d2a0e6f5a0d04c133df64f4" }, { "name": "kill_switch", @@ -233,6 +235,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "system", "source": "kill_switch", @@ -280,8 +283,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"system\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"c3d4e5f6-a7b8-9012-cdef-123456789012\",\"outcome\":{\"error\":\"Kill switch activated: mode=hard, reason=unexpected behavior\",\"metrics\":{},\"result\":null,\"side_effects\":[\"All running agents terminated\",\"Pending tasks cancelled\"],\"status\":\"blocked\",\"summary\":\"All agents terminated via hard kill\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"Emergency stop: unexpected behavior detected\",\"source\":\"kill_switch\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"system\",\"user_id\":null},\"type\":\"kill\"}", - "sha3_256_hash": "636f3080c16d828a67a07972c3e47a33b423545658a2c09076ebb9aa3a531e26" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"system\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"c3d4e5f6-a7b8-9012-cdef-123456789012\",\"outcome\":{\"error\":\"Kill switch activated: mode=hard, reason=unexpected behavior\",\"metrics\":{},\"result\":null,\"side_effects\":[\"All running agents terminated\",\"Pending tasks cancelled\"],\"status\":\"blocked\",\"summary\":\"All agents terminated via hard kill\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"Emergency stop: unexpected behavior detected\",\"source\":\"kill_switch\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"system\",\"user_id\":null},\"type\":\"kill\"}", + "sha3_256_hash": "b12fc10e6c73e49940dc4b6a901742c57d7ffd37442a387d0166ee77a4c028d1" }, { "name": "tool_invocation", @@ -293,6 +296,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "agent", "source": "executor_001", @@ -348,8 +352,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":2,\"resources_used\":{},\"tool_calls\":[{\"arguments\":{\"path\":\"/etc/hostname\"},\"duration_ms\":2,\"error\":null,\"result\":\"prod-server-01\",\"success\":true,\"tool\":\"file_read\"}]},\"id\":\"d4e5f6a7-b8c9-0123-defa-234567890123\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":\"prod-server-01\",\"side_effects\":[],\"status\":\"success\",\"summary\":\"Read /etc/hostname\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"Read file contents\",\"source\":\"executor_001\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"agent\",\"user_id\":null},\"type\":\"tool\"}", - "sha3_256_hash": "1de4b4cd4a9512c55f28ad3dd3dce2ff31c28062d4e63f55125cfa4c631083ba" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":2,\"resources_used\":{},\"tool_calls\":[{\"arguments\":{\"path\":\"/etc/hostname\"},\"duration_ms\":2,\"error\":null,\"result\":\"prod-server-01\",\"success\":true,\"tool\":\"file_read\"}]},\"id\":\"d4e5f6a7-b8c9-0123-defa-234567890123\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":\"prod-server-01\",\"side_effects\":[],\"status\":\"success\",\"summary\":\"Read /etc/hostname\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"Read file contents\",\"source\":\"executor_001\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"agent\",\"user_id\":null},\"type\":\"tool\"}", + "sha3_256_hash": "7faf4d8f3139081993a1fc81dd157bb59101a26dbae6475db58f7158c57207c7" }, { "name": "chat_interaction", @@ -361,6 +365,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "user_request", "source": "hub_chat", @@ -422,8 +427,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"chat-agent\",\"environment\":{\"model\":\"gpt-4o\",\"turn\":3},\"session_id\":\"sess-chat-001\"},\"domain\":\"chat\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"e5f6a7b8-c9d0-1234-efab-345678901234\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":\"Paris\",\"side_effects\":[],\"status\":\"success\",\"summary\":\"Answered: Paris\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.99,\"model\":null,\"options\":[{\"cons\":[],\"description\":\"Answer from knowledge\",\"estimated_impact\":{},\"feasibility\":0.0,\"id\":\"opt_0\",\"pros\":[],\"rejection_reason\":\"\",\"risks\":[],\"selected\":true}],\"options_considered\":[\"Answer from knowledge\"],\"prompt_hash\":null,\"reasoning\":\"Factual question with known answer\",\"selected_option\":\"Answer from knowledge\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"What is the capital of France?\",\"source\":\"hub_chat\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":\"user@example.com\"},\"type\":\"chat\"}", - "sha3_256_hash": "96597ff0096dd62b5b71de237d91fc55bbeb95d373e1ce611f42c4ef0a9f718a" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"chat-agent\",\"environment\":{\"model\":\"gpt-4o\",\"turn\":3},\"session_id\":\"sess-chat-001\"},\"domain\":\"chat\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"e5f6a7b8-c9d0-1234-efab-345678901234\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":\"Paris\",\"side_effects\":[],\"status\":\"success\",\"summary\":\"Answered: Paris\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.99,\"model\":null,\"options\":[{\"cons\":[],\"description\":\"Answer from knowledge\",\"estimated_impact\":{},\"feasibility\":0.0,\"id\":\"opt_0\",\"pros\":[],\"rejection_reason\":\"\",\"risks\":[],\"selected\":true}],\"options_considered\":[\"Answer from knowledge\"],\"prompt_hash\":null,\"reasoning\":\"Factual question with known answer\",\"selected_option\":\"Answer from knowledge\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"What is the capital of France?\",\"source\":\"hub_chat\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":\"user@example.com\"},\"type\":\"chat\"}", + "sha3_256_hash": "091d4da0f51e0dd6f7217b5a817906f06b75569526e8cdba7c58349fefef08e4" }, { "name": "workflow_hierarchy", @@ -435,6 +440,7 @@ "parent_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "system", "source": "orchestrator", @@ -479,8 +485,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"f6a7b8c9-d0e1-2345-fabc-456789012345\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"Pipeline completed\"},\"parent_id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"Execute deployment pipeline\",\"source\":\"orchestrator\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"system\",\"user_id\":null},\"type\":\"workflow\"}", - "sha3_256_hash": "4cb02d655fc06bb27155a696086d755891c403f62d090c7f0aa111c6f23e180f" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"f6a7b8c9-d0e1-2345-fabc-456789012345\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"Pipeline completed\"},\"parent_id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"Execute deployment pipeline\",\"source\":\"orchestrator\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"system\",\"user_id\":null},\"type\":\"workflow\"}", + "sha3_256_hash": "e1788855486070ea220720e93dcb02a3c088508744f256ba81392ee68a7fbc67" }, { "name": "unicode_strings", @@ -492,6 +498,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "user_request", "source": "utilisateur_français", @@ -565,8 +572,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"agent-α\",\"environment\":{\"note\":\"café ☃ ❤\",\"region\":\"東京\"},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"a7b8c9d0-e1f2-3456-abcd-567890123456\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"Déploiement réussi ✓\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"Évaluation des risques terminée\",\"confidence\":0.88,\"model\":null,\"options\":[{\"cons\":[],\"description\":\"Étape 1: préparation\",\"estimated_impact\":{},\"feasibility\":0.0,\"id\":\"opt_0\",\"pros\":[],\"rejection_reason\":\"\",\"risks\":[],\"selected\":false},{\"cons\":[],\"description\":\"Étape 2: exécution\",\"estimated_impact\":{},\"feasibility\":0.0,\"id\":\"opt_1\",\"pros\":[],\"rejection_reason\":\"\",\"risks\":[],\"selected\":true}],\"options_considered\":[\"Étape 1: préparation\",\"Étape 2: exécution\"],\"prompt_hash\":null,\"reasoning\":\"L'option présente le meilleur rapport bénéfice/risque\",\"selected_option\":\"Étape 2: exécution\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"Déployer le service à la production — priorité haute\",\"source\":\"utilisateur_français\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":null},\"type\":\"agent\"}", - "sha3_256_hash": "94184d174089c6b5eef4809340681eb1251b8e74a6c080033d9f6fd2d3977c0f" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"agent-α\",\"environment\":{\"note\":\"café ☃ ❤\",\"region\":\"東京\"},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"a7b8c9d0-e1f2-3456-abcd-567890123456\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"Déploiement réussi ✓\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"Évaluation des risques terminée\",\"confidence\":0.88,\"model\":null,\"options\":[{\"cons\":[],\"description\":\"Étape 1: préparation\",\"estimated_impact\":{},\"feasibility\":0.0,\"id\":\"opt_0\",\"pros\":[],\"rejection_reason\":\"\",\"risks\":[],\"selected\":false},{\"cons\":[],\"description\":\"Étape 2: exécution\",\"estimated_impact\":{},\"feasibility\":0.0,\"id\":\"opt_1\",\"pros\":[],\"rejection_reason\":\"\",\"risks\":[],\"selected\":true}],\"options_considered\":[\"Étape 1: préparation\",\"Étape 2: exécution\"],\"prompt_hash\":null,\"reasoning\":\"L'option présente le meilleur rapport bénéfice/risque\",\"selected_option\":\"Étape 2: exécution\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"Déployer le service à la production — priorité haute\",\"source\":\"utilisateur_français\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":null},\"type\":\"agent\"}", + "sha3_256_hash": "877775878811c65929ae682c8d644158c26f8a2772142ca52e6850016a86abb5" }, { "name": "fractional_timestamp", @@ -578,6 +585,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "system", "source": "heartbeat", @@ -622,8 +630,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"system\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"b8c9d0e1-f2a3-4567-bcde-678901234567\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"All systems nominal\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"System health check\",\"source\":\"heartbeat\",\"timestamp\":\"2026-06-15T08:30:45.123456+00:00\",\"type\":\"system\",\"user_id\":null},\"type\":\"system\"}", - "sha3_256_hash": "d0b22cecc78b8dd5e46b8b3d7b3a827c5653f98be79c1f05cf0ddad13550d686" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"system\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"b8c9d0e1-f2a3-4567-bcde-678901234567\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"All systems nominal\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"System health check\",\"source\":\"heartbeat\",\"timestamp\":\"2026-06-15T08:30:45.123456+00:00\",\"type\":\"system\",\"user_id\":null},\"type\":\"system\"}", + "sha3_256_hash": "cfd1b19b9ecc1f54faebebe7dc133fae21b84c131238ed729b54175d333484c2" }, { "name": "empty_vs_null", @@ -635,6 +643,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "user_request", "source": "", @@ -679,8 +688,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"c9d0e1f2-a3b4-5678-cdef-789012345678\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"pending\",\"summary\":\"\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"\",\"source\":\"\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":null},\"type\":\"agent\"}", - "sha3_256_hash": "5ef51fd85ceb57f2f4c05fd5be68d60975ab53dfb9aa8358ef7793a51c6699dc" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"c9d0e1f2-a3b4-5678-cdef-789012345678\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"pending\",\"summary\":\"\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"\",\"source\":\"\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":null},\"type\":\"agent\"}", + "sha3_256_hash": "921c7dd9af3f9a9354618f3b95f30ef8b0d7092289ddd7e992379c4a15f1b5ec" }, { "name": "confidence_one", @@ -692,6 +701,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "agent", "source": "self", @@ -750,8 +760,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"d0e1f2a3-b4c5-6789-defa-890123456789\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"Completed with full confidence\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":1.0,\"model\":null,\"options\":[{\"cons\":[],\"description\":\"Only option\",\"estimated_impact\":{},\"feasibility\":1.0,\"id\":\"opt_0\",\"pros\":[],\"rejection_reason\":\"\",\"risks\":[],\"selected\":true}],\"options_considered\":[\"Only option\"],\"prompt_hash\":null,\"reasoning\":\"No alternatives\",\"selected_option\":\"Only option\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"Self-evaluation\",\"source\":\"self\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"agent\",\"user_id\":null},\"type\":\"agent\"}", - "sha3_256_hash": "7ade287b80ce652ad44183ebb159c5120f0843b5cd80458443082a4813d0e4a0" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"d0e1f2a3-b4c5-6789-defa-890123456789\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"Completed with full confidence\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":1.0,\"model\":null,\"options\":[{\"cons\":[],\"description\":\"Only option\",\"estimated_impact\":{},\"feasibility\":1.0,\"id\":\"opt_0\",\"pros\":[],\"rejection_reason\":\"\",\"risks\":[],\"selected\":true}],\"options_considered\":[\"Only option\"],\"prompt_hash\":null,\"reasoning\":\"No alternatives\",\"selected_option\":\"Only option\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"Self-evaluation\",\"source\":\"self\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"agent\",\"user_id\":null},\"type\":\"agent\"}", + "sha3_256_hash": "af8e95200edb98c8c9f1050f21e2f14303505fbd6f242f0e877db298ae5a3af6" }, { "name": "deep_nesting", @@ -763,6 +773,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "system", "source": "scheduler", @@ -832,8 +843,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"deep-agent\",\"environment\":{\"a_first\":\"sorted first\",\"config\":{\"array\":[3,1,2],\"nested\":{\"deep\":{\"flag\":true,\"value\":42}}},\"z_last\":\"sorted last\"},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":100,\"resources_used\":{\"cpu\":{\"cores\":4,\"utilization\":0.75},\"memory\":{\"avg_mb\":128,\"peak_mb\":256}},\"tool_calls\":[]},\"id\":\"e1f2a3b4-c5d6-7890-efab-901234567890\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"Nested config processed\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"Run nested config job\",\"source\":\"scheduler\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"system\",\"user_id\":null},\"type\":\"agent\"}", - "sha3_256_hash": "8f6fb106e0defb2697e9f05c06fffb8123234e7b2f229b5d04a9a51ff16845f5" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"deep-agent\",\"environment\":{\"a_first\":\"sorted first\",\"config\":{\"array\":[3,1,2],\"nested\":{\"deep\":{\"flag\":true,\"value\":42}}},\"z_last\":\"sorted last\"},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":100,\"resources_used\":{\"cpu\":{\"cores\":4,\"utilization\":0.75},\"memory\":{\"avg_mb\":128,\"peak_mb\":256}},\"tool_calls\":[]},\"id\":\"e1f2a3b4-c5d6-7890-efab-901234567890\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"Nested config processed\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"Run nested config job\",\"source\":\"scheduler\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"system\",\"user_id\":null},\"type\":\"agent\"}", + "sha3_256_hash": "caf01df1b30bbde7d25f52ae5b22dc68ef3e31880ec1b5615482d6d39016141e" }, { "name": "chain_genesis", @@ -845,6 +856,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "user_request", "source": "cli", @@ -889,8 +901,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"f2a3b4c5-d6e7-8901-fabc-012345678901\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"Genesis capsule\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"First action in chain\",\"source\":\"cli\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":null},\"type\":\"agent\"}", - "sha3_256_hash": "67c707931e5b4554f8203925ba32a245816867d53486a7b67f39f8b962775105" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"f2a3b4c5-d6e7-8901-fabc-012345678901\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"Genesis capsule\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"First action in chain\",\"source\":\"cli\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":null},\"type\":\"agent\"}", + "sha3_256_hash": "1bcbd6691266a2ee434f5f23608b407b07ee8d3ba77a9d874778d2e5cc50770e" }, { "name": "chain_linked", @@ -902,6 +914,7 @@ "parent_id": null, "sequence": 1, "previous_hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "spec_version": "1.0", "trigger": { "type": "user_request", "source": "cli", @@ -946,8 +959,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"a3b4c5d6-e7f8-9012-abcd-123456789abc\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"Linked capsule\"},\"parent_id\":null,\"previous_hash\":\"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9\",\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":1,\"trigger\":{\"correlation_id\":null,\"request\":\"Second action in chain\",\"source\":\"cli\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":null},\"type\":\"agent\"}", - "sha3_256_hash": "8f4972c8ab1e709146ab012316db7e47b1fb4b5718411127f2ea14509bf747a9" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":null,\"type\":\"autonomous\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"a3b4c5d6-e7f8-9012-abcd-123456789abc\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"Linked capsule\"},\"parent_id\":null,\"previous_hash\":\"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9\",\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":1,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"Second action in chain\",\"source\":\"cli\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"user_request\",\"user_id\":null},\"type\":\"agent\"}", + "sha3_256_hash": "c68c082eeccd3064703d620db16ea8e830bb9f7845556048f0fa14634a34383a" }, { "name": "failure_with_error", @@ -959,6 +972,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "agent", "source": "executor_001", @@ -1014,8 +1028,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":\"DENY-ALL-DESTRUCTIVE\",\"type\":\"policy\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[{\"arguments\":{\"database\":\"production\"},\"duration_ms\":0,\"error\":\"Blocked by policy DENY-ALL-DESTRUCTIVE\",\"result\":null,\"success\":false,\"tool\":\"db_drop\"}]},\"id\":\"b4c5d6e7-f8a9-0123-bcde-23456789abcd\",\"outcome\":{\"error\":\"Action blocked by safety policy\",\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"failure\",\"summary\":\"Destructive action denied by policy\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"Delete production database\",\"source\":\"executor_001\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"agent\",\"user_id\":null},\"type\":\"tool\"}", - "sha3_256_hash": "7503baff37a36699f25cbbd897e421cb53e17e6fa57be95e0c96794b8abe1840" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":\"DENY-ALL-DESTRUCTIVE\",\"type\":\"policy\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"agents\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[{\"arguments\":{\"database\":\"production\"},\"duration_ms\":0,\"error\":\"Blocked by policy DENY-ALL-DESTRUCTIVE\",\"result\":null,\"success\":false,\"tool\":\"db_drop\"}]},\"id\":\"b4c5d6e7-f8a9-0123-bcde-23456789abcd\",\"outcome\":{\"error\":\"Action blocked by safety policy\",\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"failure\",\"summary\":\"Destructive action denied by policy\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"Delete production database\",\"source\":\"executor_001\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"agent\",\"user_id\":null},\"type\":\"tool\"}", + "sha3_256_hash": "b407b8ea64fedf843a7829cdf9dee7993276680d18520d6e1f76698efee96ce4" }, { "name": "auth_escalated", @@ -1027,6 +1041,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "system", "source": "auth_service", @@ -1082,8 +1097,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":\"admin@example.com\",\"chain\":[{\"level\":1,\"method\":\"password\",\"result\":\"passed\"},{\"level\":2,\"method\":\"totp\",\"result\":\"passed\"}],\"escalation_reason\":\"Admin action requires MFA\",\"policy_reference\":null,\"type\":\"escalated\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"auth\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"c5d6e7f8-a9b0-1234-cdef-3456789abcde\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"MFA verified, admin access granted\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"MFA challenge for admin action\",\"source\":\"auth_service\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"system\",\"user_id\":\"admin@example.com\"},\"type\":\"auth\"}", - "sha3_256_hash": "336e52623385149110d295881629e517f0ed16740410ac0ec308fced730d49fa" + "canonical_json": "{\"authority\":{\"approver\":\"admin@example.com\",\"chain\":[{\"level\":1,\"method\":\"password\",\"result\":\"passed\"},{\"level\":2,\"method\":\"totp\",\"result\":\"passed\"}],\"escalation_reason\":\"Admin action requires MFA\",\"policy_reference\":null,\"type\":\"escalated\"},\"context\":{\"agent_id\":\"\",\"environment\":{},\"session_id\":null},\"domain\":\"auth\",\"execution\":{\"duration_ms\":0,\"resources_used\":{},\"tool_calls\":[]},\"id\":\"c5d6e7f8-a9b0-1234-cdef-3456789abcde\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":null,\"side_effects\":[],\"status\":\"success\",\"summary\":\"MFA verified, admin access granted\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"MFA challenge for admin action\",\"source\":\"auth_service\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"system\",\"user_id\":\"admin@example.com\"},\"type\":\"auth\"}", + "sha3_256_hash": "cbe2799722f0586f229fcce8cbaafa376bd74b908d3d4b5936d8147db41f550a" }, { "name": "vault_secret", @@ -1095,6 +1110,7 @@ "parent_id": null, "sequence": 0, "previous_hash": null, + "spec_version": "1.0", "trigger": { "type": "scheduled", "source": "secret_rotator", @@ -1164,8 +1180,8 @@ "metrics": {} } }, - "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":\"POLICY-SECRET-ROTATION-90D\",\"type\":\"policy\"},\"context\":{\"agent_id\":\"vault-agent\",\"environment\":{\"region\":\"us-east-1\",\"vault_backend\":\"hashicorp\"},\"session_id\":null},\"domain\":\"secrets\",\"execution\":{\"duration_ms\":320,\"resources_used\":{\"api_calls\":2},\"tool_calls\":[{\"arguments\":{\"secret\":\"db/prod/credentials\",\"ttl\":\"90d\"},\"duration_ms\":320,\"error\":null,\"result\":{\"rotated\":true,\"version\":7},\"success\":true,\"tool\":\"vault_rotate\"}]},\"id\":\"d6e7f8a9-b0c1-2345-defa-456789abcdef\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":{\"new_version\":7,\"secret_path\":\"db/prod/credentials\"},\"side_effects\":[\"Old credentials revoked after 5m grace period\"],\"status\":\"success\",\"summary\":\"Rotated database credentials, version 7\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"trigger\":{\"correlation_id\":null,\"request\":\"Rotate database credentials for production\",\"source\":\"secret_rotator\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"scheduled\",\"user_id\":null},\"type\":\"vault\"}", - "sha3_256_hash": "af88f095652eefeb8b6aeaca64860ac4ffc0476f3f274157387dd633ab6c9396" + "canonical_json": "{\"authority\":{\"approver\":null,\"chain\":[],\"escalation_reason\":null,\"policy_reference\":\"POLICY-SECRET-ROTATION-90D\",\"type\":\"policy\"},\"context\":{\"agent_id\":\"vault-agent\",\"environment\":{\"region\":\"us-east-1\",\"vault_backend\":\"hashicorp\"},\"session_id\":null},\"domain\":\"secrets\",\"execution\":{\"duration_ms\":320,\"resources_used\":{\"api_calls\":2},\"tool_calls\":[{\"arguments\":{\"secret\":\"db/prod/credentials\",\"ttl\":\"90d\"},\"duration_ms\":320,\"error\":null,\"result\":{\"rotated\":true,\"version\":7},\"success\":true,\"tool\":\"vault_rotate\"}]},\"id\":\"d6e7f8a9-b0c1-2345-defa-456789abcdef\",\"outcome\":{\"error\":null,\"metrics\":{},\"result\":{\"new_version\":7,\"secret_path\":\"db/prod/credentials\"},\"side_effects\":[\"Old credentials revoked after 5m grace period\"],\"status\":\"success\",\"summary\":\"Rotated database credentials, version 7\"},\"parent_id\":null,\"previous_hash\":null,\"reasoning\":{\"analysis\":\"\",\"confidence\":0.0,\"model\":null,\"options\":[],\"options_considered\":[],\"prompt_hash\":null,\"reasoning\":\"\",\"selected_option\":\"\"},\"sequence\":0,\"spec_version\":\"1.0\",\"trigger\":{\"correlation_id\":null,\"request\":\"Rotate database credentials for production\",\"source\":\"secret_rotator\",\"timestamp\":\"2026-01-15T12:00:00+00:00\",\"type\":\"scheduled\",\"user_id\":null},\"type\":\"vault\"}", + "sha3_256_hash": "4093195c56dd7a7ad3ae3513842a0a6f60537474cdfe90b92964c572e8f93a48" } ] } \ No newline at end of file diff --git a/conformance/invalid-fixtures.json b/conformance/invalid-fixtures.json index 4520d16..99b8473 100644 --- a/conformance/invalid-fixtures.json +++ b/conformance/invalid-fixtures.json @@ -12,7 +12,7 @@ "fixtures": [ { "name": "missing_id", - "description": "Capsule with no 'id' field. All 12 top-level fields are required per CPS Section 1.", + "description": "Capsule with no 'id' field. All 13 top-level fields are required per CPS Section 1.", "expected_error": "missing_field", "error_field": "id", "capsule_dict": { @@ -21,12 +21,50 @@ "parent_id": null, "sequence": 0, "previous_hash": null, - "trigger": { "type": "user_request", "source": "", "timestamp": "2026-01-15T12:00:00+00:00", "request": "", "correlation_id": null, "user_id": null }, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": 0.0, "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": { + "type": "user_request", + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 0.0, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -40,12 +78,50 @@ "parent_id": null, "sequence": 0, "previous_hash": null, - "trigger": { "type": "user_request", "source": "", "timestamp": "2026-01-15T12:00:00+00:00", "request": "", "correlation_id": null, "user_id": null }, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": 0.0, "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": { + "type": "user_request", + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 0.0, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -60,11 +136,42 @@ "parent_id": null, "sequence": 0, "previous_hash": null, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": 0.0, "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 0.0, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -79,11 +186,40 @@ "parent_id": null, "sequence": 0, "previous_hash": null, - "trigger": { "type": "user_request", "source": "", "timestamp": "2026-01-15T12:00:00+00:00", "request": "", "correlation_id": null, "user_id": null }, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": { + "type": "user_request", + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -98,12 +234,50 @@ "parent_id": null, "sequence": "zero", "previous_hash": null, - "trigger": { "type": "user_request", "source": "", "timestamp": "2026-01-15T12:00:00+00:00", "request": "", "correlation_id": null, "user_id": null }, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": 0.0, "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": { + "type": "user_request", + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 0.0, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -118,12 +292,50 @@ "parent_id": null, "sequence": 0, "previous_hash": null, - "trigger": { "type": "user_request", "source": "", "timestamp": "2026-01-15T12:00:00+00:00", "request": "", "correlation_id": null, "user_id": null }, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": "high", "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": { + "type": "user_request", + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": "high", + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -138,12 +350,50 @@ "parent_id": null, "sequence": 0, "previous_hash": null, - "trigger": { "type": null, "source": "", "timestamp": "2026-01-15T12:00:00+00:00", "request": "", "correlation_id": null, "user_id": null }, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": 0.0, "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": { + "type": null, + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 0.0, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -158,12 +408,50 @@ "parent_id": null, "sequence": -1, "previous_hash": null, - "trigger": { "type": "user_request", "source": "", "timestamp": "2026-01-15T12:00:00+00:00", "request": "", "correlation_id": null, "user_id": null }, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": 0.0, "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": { + "type": "user_request", + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 0.0, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -178,12 +466,50 @@ "parent_id": null, "sequence": 0, "previous_hash": null, - "trigger": { "type": "user_request", "source": "", "timestamp": "2026-01-15T12:00:00+00:00", "request": "", "correlation_id": null, "user_id": null }, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": 1.5, "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": { + "type": "user_request", + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 1.5, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -198,12 +524,50 @@ "parent_id": null, "sequence": 0, "previous_hash": null, - "trigger": { "type": "user_request", "source": "", "timestamp": "2026-01-15T12:00:00+00:00", "request": "", "correlation_id": null, "user_id": null }, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": 0.0, "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": { + "type": "user_request", + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 0.0, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -218,12 +582,50 @@ "parent_id": null, "sequence": 0, "previous_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "trigger": { "type": "user_request", "source": "", "timestamp": "2026-01-15T12:00:00+00:00", "request": "", "correlation_id": null, "user_id": null }, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": 0.0, "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": { + "type": "user_request", + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 0.0, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -238,12 +640,50 @@ "parent_id": null, "sequence": 1, "previous_hash": null, - "trigger": { "type": "user_request", "source": "", "timestamp": "2026-01-15T12:00:00+00:00", "request": "", "correlation_id": null, "user_id": null }, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": 0.0, "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": { + "type": "user_request", + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 0.0, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -265,12 +705,47 @@ "parent_id": null, "sequence": 0, "previous_hash": null, - "trigger": ["not", "an", "object"], - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": 0.0, "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": [ + "not", + "an", + "object" + ], + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 0.0, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } } }, { @@ -285,14 +760,109 @@ "parent_id": null, "sequence": 0, "previous_hash": null, - "trigger": { "type": "user_request", "source": "", "timestamp": "2026-01-15T12:00:00+00:00", "request": "", "correlation_id": null, "user_id": null }, - "context": { "agent_id": "", "session_id": null, "environment": {} }, - "reasoning": { "analysis": "", "options": [], "options_considered": [], "selected_option": "", "reasoning": "", "confidence": 0.0, "model": null, "prompt_hash": null }, - "authority": { "type": "autonomous", "approver": null, "policy_reference": null, "chain": [], "escalation_reason": null }, - "execution": { "tool_calls": [], "duration_ms": 0, "resources_used": {} }, - "outcome": { "status": "pending", "result": null, "summary": "", "error": null, "side_effects": [], "metrics": {} } + "spec_version": "1.0", + "trigger": { + "type": "user_request", + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 0.0, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } }, "claimed_hash": "e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee" + }, + { + "name": "missing_spec_version", + "description": "Capsule with no 'spec_version' field. Required per CPS Section 1 (implicit 1.0 only for legacy parsers; validators reject missing in strict validation).", + "expected_error": "missing_field", + "error_field": "spec_version", + "capsule_dict": { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "type": "agent", + "domain": "agents", + "parent_id": null, + "sequence": 0, + "previous_hash": null, + "trigger": { + "type": "user_request", + "source": "", + "timestamp": "2026-01-15T12:00:00+00:00", + "request": "", + "correlation_id": null, + "user_id": null + }, + "context": { + "agent_id": "", + "session_id": null, + "environment": {} + }, + "reasoning": { + "analysis": "", + "options": [], + "options_considered": [], + "selected_option": "", + "reasoning": "", + "confidence": 0.0, + "model": null, + "prompt_hash": null + }, + "authority": { + "type": "autonomous", + "approver": null, + "policy_reference": null, + "chain": [], + "escalation_reason": null + }, + "execution": { + "tool_calls": [], + "duration_ms": 0, + "resources_used": {} + }, + "outcome": { + "status": "pending", + "result": null, + "summary": "", + "error": null, + "side_effects": [], + "metrics": {} + } + } } ] } diff --git a/conformance/uri-fixtures.json b/conformance/uri-fixtures.json index 8ff2ae8..9fd6618 100644 --- a/conformance/uri-fixtures.json +++ b/conformance/uri-fixtures.json @@ -6,13 +6,13 @@ { "name": "hash_reference", "description": "Bare SHA3-256 hash reference (most portable form).", - "uri": "capsule://sha3_e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee", + "uri": "capsule://sha3_8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cd", "expected": { "scheme": "capsule", "chain": null, "reference_type": "hash", "hash_algorithm": "sha3", - "hash_value": "e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee", + "hash_value": "8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cd", "sequence": null, "id": null, "fragment": null @@ -21,13 +21,13 @@ { "name": "hash_with_fragment_section", "description": "Hash reference with fragment pointing to a section.", - "uri": "capsule://sha3_e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee#reasoning", + "uri": "capsule://sha3_8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cd#reasoning", "expected": { "scheme": "capsule", "chain": null, "reference_type": "hash", "hash_algorithm": "sha3", - "hash_value": "e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee", + "hash_value": "8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cd", "sequence": null, "id": null, "fragment": "reasoning" @@ -36,13 +36,13 @@ { "name": "hash_with_fragment_nested", "description": "Hash reference with fragment to a nested field.", - "uri": "capsule://sha3_e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee#reasoning/confidence", + "uri": "capsule://sha3_8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cd#reasoning/confidence", "expected": { "scheme": "capsule", "chain": null, "reference_type": "hash", "hash_algorithm": "sha3", - "hash_value": "e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee", + "hash_value": "8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cd", "sequence": null, "id": null, "fragment": "reasoning/confidence" @@ -51,13 +51,13 @@ { "name": "hash_with_fragment_array_index", "description": "Hash reference with fragment to an array element.", - "uri": "capsule://sha3_f4e04d02aadacf1cc051d4ce10b72c486827d5f7036ee59e16309ebd923cd194#execution/tool_calls/0", + "uri": "capsule://sha3_7faf4d8f3139081993a1fc81dd157bb59101a26dbae6475db58f7158c57207c7#execution/tool_calls/0", "expected": { "scheme": "capsule", "chain": null, "reference_type": "hash", "hash_algorithm": "sha3", - "hash_value": "f4e04d02aadacf1cc051d4ce10b72c486827d5f7036ee59e16309ebd923cd194", + "hash_value": "7faf4d8f3139081993a1fc81dd157bb59101a26dbae6475db58f7158c57207c7", "sequence": null, "id": null, "fragment": "execution/tool_calls/0" @@ -81,13 +81,13 @@ { "name": "chain_hash", "description": "Chain name with hash reference.", - "uri": "capsule://deploy-bot/sha3_e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee", + "uri": "capsule://deploy-bot/sha3_8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cd", "expected": { "scheme": "capsule", "chain": "deploy-bot", "reference_type": "hash", "hash_algorithm": "sha3", - "hash_value": "e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee", + "hash_value": "8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cd", "sequence": null, "id": null, "fragment": null @@ -158,37 +158,37 @@ { "name": "wrong_scheme", "description": "Must use capsule:// scheme.", - "uri": "http://sha3_e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee", + "uri": "http://sha3_8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cd", "reason": "Wrong scheme: expected 'capsule'" }, { "name": "hash_too_short", "description": "SHA3-256 hash must be exactly 64 hex characters.", - "uri": "capsule://sha3_e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8d", + "uri": "capsule://sha3_8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2c", "reason": "Hash too short: 63 hex characters instead of 64" }, { "name": "hash_too_long", "description": "SHA3-256 hash must be exactly 64 hex characters.", - "uri": "capsule://sha3_e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8deee", + "uri": "capsule://sha3_8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cdd", "reason": "Hash too long: 65 hex characters instead of 64" }, { "name": "hash_uppercase", "description": "Hash must be lowercase hex.", - "uri": "capsule://sha3_E6266F6D907A02E1F3531DC354765C0BCA506180B91C1F5892A544B81BCF8DEE", + "uri": "capsule://sha3_8C71E187DFBFFCA067265F576D9FB72EE8A223C3DFF801DD7C5DD8FCB915F2CD", "reason": "Hash contains uppercase characters" }, { "name": "hash_non_hex", "description": "Hash must contain only hex digits.", - "uri": "capsule://sha3_g6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee", + "uri": "capsule://sha3_g8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cd", "reason": "Hash contains non-hex character 'g'" }, { "name": "unknown_hash_algorithm", "description": "Only sha3_ prefix is defined in CPS v1.0.", - "uri": "capsule://md5_e6266f6d907a02e1f3531dc354765c0b", + "uri": "capsule://md5_8c71e187dfbffca067265f576d9fb72ee8a2", "reason": "Unknown hash algorithm prefix 'md5_'" }, { @@ -212,7 +212,7 @@ { "name": "fragment_traversal", "description": "Fragment must not attempt path traversal.", - "uri": "capsule://sha3_e6266f6d907a02e1f3531dc354765c0bca506180b91c1f5892a544b81bcf8dee#../../etc/passwd", + "uri": "capsule://sha3_8c71e187dfbffca067265f576d9fb72ee8a223c3dff801dd7c5dd8fcb915f2cd#../../etc/passwd", "reason": "Fragment contains path traversal" }, { diff --git a/reference/python/docs/api.md b/reference/python/docs/api.md index 4fe7783..eef58cd 100644 --- a/reference/python/docs/api.md +++ b/reference/python/docs/api.md @@ -1,11 +1,13 @@ --- title: "API Reference" description: "Complete API reference for Capsule: every class, method, parameter, and type." -date_modified: "2026-03-18" +date_modified: "2026-03-23" ai_context: | Complete Python API reference for the qp-capsule package v1.5.1+. Covers Capsule model - (6 sections, 8 CapsuleTypes, to_dict/to_sealed_dict/from_dict/from_sealed_dict), - Seal (seal, verify, verify_with_key, compute_hash, keyring integration), + (6 sections, 8 CapsuleTypes, spec_version, to_dict/to_sealed_dict/from_dict/from_sealed_dict), + runtime validation FR-002 (validate_capsule_dict, validate_capsule, CapsuleValidationResult), + Seal FR-003 (verify_detailed, verify_with_key_detailed, SealVerifyCode, SealVerificationResult; + seal, verify, verify_with_key, compute_hash, keyring integration), Keyring (epoch-based key rotation, NIST SP 800-57), CapsuleChain (add, verify, seal_and_store), CapsuleStorageProtocol (7 methods), CapsuleStorage (SQLite), PostgresCapsuleStorage (multi-tenant), storage schema with @@ -37,8 +39,13 @@ from qp_capsule import ( AuthoritySection, ExecutionSection, OutcomeSection, ToolCall, + # Runtime validation (FR-002) + CapsuleValidationResult, + validate_capsule, + validate_capsule_dict, + # Cryptographic Seal - Seal, compute_hash, + Seal, SealVerificationResult, SealVerifyCode, compute_hash, # Key Management (v1.3.0+) Keyring, Epoch, @@ -84,6 +91,9 @@ class Capsule: sequence: int # Position in chain (0-indexed) previous_hash: str | None # SHA3-256 hash of previous Capsule + # Protocol + spec_version: str # CPS wire version (default "1.0"; hashed with content) + # The 6 Sections trigger: TriggerSection context: ContextSection @@ -279,6 +289,38 @@ class CapsuleType(StrEnum): --- +## Runtime validation (FR-002) + +Validate a CPS **content** dictionary (the shape returned by `Capsule.to_dict()`) before sealing or when ingesting JSON. This is separate from cryptographic `Seal.verify()` — it checks required keys, types, chain rules, numeric ranges, and optional integrity against a claimed content hash. + + + +### CapsuleValidationResult + +```python +@dataclass(frozen=True) +class CapsuleValidationResult: + ok: bool + category: str | None # e.g. "missing_field", "wrong_type", "integrity_violation" + field: str | None # dotted path when applicable, e.g. "reasoning.confidence" + message: str +``` + +### Functions + +**`validate_capsule_dict(data, *, claimed_hash=None, strict_unknown_keys=False) -> CapsuleValidationResult`** + +- **`claimed_hash`**: If set (64-char hex), recomputes SHA3-256 via `compute_hash(data)` and fails with category `integrity_violation` on mismatch (tamper detection). +- **`strict_unknown_keys`**: If `True`, rejects any top-level key not defined by CPS for capsule content. + +**`validate_capsule(capsule: Capsule) -> CapsuleValidationResult`** + +Validates `capsule.to_dict()`. Returns `wrong_type` if the argument is not a `Capsule` instance. + +Conformance negative vectors live in [`conformance/invalid-fixtures.json`](../../../conformance/invalid-fixtures.json). + +--- + ## Seal Cryptographic sealing with two-tier architecture. @@ -306,18 +348,32 @@ class Seal: **`pq_enabled: bool`** — Whether post-quantum signatures are active. +### SealVerifyCode and SealVerificationResult (FR-003) + + + +`SealVerifyCode` is a `StrEnum` of machine-readable outcomes, including: `ok`, `missing_hash`, `missing_signature`, `malformed_hex`, `hash_mismatch`, `invalid_signature`, `pq_verification_failed`, `pq_library_unavailable`, `unsupported_algorithm` (reserved). + +`SealVerificationResult` holds `ok: bool`, `code: SealVerifyCode`, `message: str`, and a `success` property equal to `ok`. + ### Methods - + **`seal(capsule: Capsule) -> Capsule`** Seal a Capsule. Fills `hash`, `signature`, `signature_pq` (if PQ enabled), `signed_at`, `signed_by`. Raises `SealError` on failure. +**`verify_detailed(capsule: Capsule, verify_pq: bool = False) -> SealVerificationResult`** +Verify a sealed Capsule and return a structured result (same cryptographic steps as `verify()`). Use this when you need a reason code (e.g. hash mismatch vs invalid signature) instead of only `True`/`False`. + **`verify(capsule: Capsule, verify_pq: bool = False) -> bool`** -Verify a sealed Capsule. Returns `True` if hash and Ed25519 signature are valid. When a `keyring` was provided at construction, the capsule's `signed_by` fingerprint is used to look up the correct epoch's public key, enabling verification across key rotations. Set `verify_pq=True` to also verify the ML-DSA-65 signature. +Verify a sealed Capsule. Equivalent to `verify_detailed(capsule, verify_pq=verify_pq).ok`. Returns `True` if hash and Ed25519 signature are valid. When a `keyring` was provided at construction, the capsule's `signed_by` fingerprint is used to look up the correct epoch's public key, enabling verification across key rotations. Set `verify_pq=True` to also verify the ML-DSA-65 signature when `signature_pq` is present. + +**`verify_with_key_detailed(capsule: Capsule, public_key_hex: str) -> SealVerificationResult`** +Verify using an explicit Ed25519 public key (64-byte hex = 128 hex chars). Structured counterpart to `verify_with_key()`. **`verify_with_key(capsule: Capsule, public_key_hex: str) -> bool`** -Verify a Capsule using a specific Ed25519 public key (hex-encoded). Useful for verifying Capsules sealed by other instances. +Verify a Capsule using a specific Ed25519 public key (hex-encoded). Returns `verify_with_key_detailed(...).ok`. Useful for verifying Capsules sealed by other instances. **`get_public_key() -> str`** Returns the Ed25519 public key as a 64-character hex string. @@ -327,7 +383,7 @@ Returns the keyring's `qp_key_XXXX` format when a keyring is available with an a ### compute_hash - + ```python def compute_hash(data: dict) -> str diff --git a/reference/python/pyproject.toml b/reference/python/pyproject.toml index fe90634..3e15586 100644 --- a/reference/python/pyproject.toml +++ b/reference/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "qp-capsule" -version = "1.5.2" +version = "1.5.3" description = "Capsule Protocol Specification (CPS) — tamper-evident audit records for AI operations. Create, seal, verify, and chain Capsules in Python." readme = "README.md" license = "Apache-2.0" diff --git a/reference/python/src/qp_capsule/__init__.py b/reference/python/src/qp_capsule/__init__.py index 2f3e69b..c56b1be 100644 --- a/reference/python/src/qp_capsule/__init__.py +++ b/reference/python/src/qp_capsule/__init__.py @@ -36,7 +36,7 @@ Spec: https://github.com/quantumpipes/capsule """ -__version__ = "1.5.2" +__version__ = "1.5.3" __author__ = "Quantum Pipes Technologies, LLC" __license__ = "Apache-2.0" @@ -64,7 +64,7 @@ ) from qp_capsule.keyring import Epoch, Keyring from qp_capsule.protocol import CapsuleStorageProtocol -from qp_capsule.seal import Seal, compute_hash +from qp_capsule.seal import Seal, SealVerificationResult, SealVerifyCode, compute_hash with contextlib.suppress(ImportError): from qp_capsule.chain import CapsuleChain, ChainVerificationResult @@ -76,6 +76,7 @@ from qp_capsule.storage_pg import CapsuleStoragePG, PostgresCapsuleStorage from qp_capsule.audit import Capsules +from qp_capsule.validation import CapsuleValidationResult, validate_capsule, validate_capsule_dict __all__ = [ # Version @@ -94,6 +95,8 @@ # Seal "Seal", "compute_hash", + "SealVerifyCode", + "SealVerificationResult", # Keyring "Keyring", "Epoch", @@ -115,4 +118,8 @@ "KeyringError", # High-Level API "Capsules", + # Validation (FR-002) + "CapsuleValidationResult", + "validate_capsule_dict", + "validate_capsule", ] diff --git a/reference/python/src/qp_capsule/capsule.py b/reference/python/src/qp_capsule/capsule.py index b298e6b..1502d1e 100644 --- a/reference/python/src/qp_capsule/capsule.py +++ b/reference/python/src/qp_capsule/capsule.py @@ -352,6 +352,11 @@ class Capsule: sequence: int = 0 # Position in the hash chain previous_hash: str | None = None # Hash of previous Capsule in chain + # ------------------------------------------------------------------------- + # Protocol version (on-wire, included in canonical hash) + # ------------------------------------------------------------------------- + spec_version: str = "1.0" # CPS wire-format version for parse-time branching + # ------------------------------------------------------------------------- # The 6 Sections # ------------------------------------------------------------------------- @@ -410,6 +415,7 @@ def to_dict(self) -> dict[str, Any]: # Hash chain "sequence": self.sequence, "previous_hash": self.previous_hash, + "spec_version": self.spec_version, "trigger": { "type": self.trigger.type, "source": self.trigger.source, @@ -502,6 +508,7 @@ def from_dict(cls, data: dict[str, Any]) -> Capsule: parent_id=UUID(data["parent_id"]) if data.get("parent_id") else None, sequence=data.get("sequence", 0), previous_hash=data.get("previous_hash"), + spec_version=data.get("spec_version", "1.0"), ) # Trigger diff --git a/reference/python/src/qp_capsule/seal.py b/reference/python/src/qp_capsule/seal.py index d306a2b..9336d5a 100644 --- a/reference/python/src/qp_capsule/seal.py +++ b/reference/python/src/qp_capsule/seal.py @@ -35,7 +35,9 @@ import json import os import types +from dataclasses import dataclass from datetime import UTC, datetime +from enum import StrEnum from pathlib import Path from typing import TYPE_CHECKING, Any @@ -62,6 +64,66 @@ def _pq_available() -> bool: return _oqs_module is not None +class SealVerifyCode(StrEnum): + """Machine-readable result of :meth:`Seal.verify_detailed`.""" + + OK = "ok" + MISSING_HASH = "missing_hash" + MISSING_SIGNATURE = "missing_signature" + MALFORMED_HEX = "malformed_hex" + HASH_MISMATCH = "hash_mismatch" + INVALID_SIGNATURE = "invalid_signature" + PQ_VERIFICATION_FAILED = "pq_verification_failed" + PQ_LIBRARY_UNAVAILABLE = "pq_library_unavailable" + UNSUPPORTED_ALGORITHM = "unsupported_algorithm" + + +@dataclass(frozen=True) +class SealVerificationResult: + """Structured outcome of verify_detailed / verify_with_key_detailed.""" + + ok: bool + code: SealVerifyCode + message: str = "" + + @property + def success(self) -> bool: + return self.ok + + +def _try_hex_bytes( + value: str, + expected_len: int | None, +) -> tuple[bytes, SealVerificationResult | None]: + """ + Parse hex string to bytes. + + Returns: + (bytes, None) on success, or (_, error) on failure. + """ + try: + raw = bytes.fromhex(value) + except ValueError: + return ( + b"", + SealVerificationResult( + False, + SealVerifyCode.MALFORMED_HEX, + "value is not valid hexadecimal", + ), + ) + if expected_len is not None and len(raw) != expected_len: + return ( + b"", + SealVerificationResult( + False, + SealVerifyCode.MALFORMED_HEX, + f"expected {expected_len} bytes after decoding hex", + ), + ) + return raw, None + + class Seal: """ Cryptographic sealing for Capsules. @@ -349,43 +411,49 @@ def _sign_dilithium(self, hash_value: str) -> str | None: except Exception: return None - def verify(self, capsule: Capsule, verify_pq: bool = False) -> bool: + def verify_detailed(self, capsule: Capsule, verify_pq: bool = False) -> SealVerificationResult: """ - Verify a sealed Capsule. - - Process: - 1. Check Capsule is sealed (Ed25519 required, PQ depends on mode) - 2. Recompute hash from content - 3. Verify hash matches stored hash - 4. Verify Ed25519 signature (epoch-aware via keyring, or local key) - 5. Verify post-quantum signature (if requested and present) - - When a keyring is configured, the capsule's ``signed_by`` fingerprint - is used to look up the correct epoch's public key. This enables - verification of capsules signed across key rotations. - - Args: - capsule: The Capsule to verify - verify_pq: If True, also verify post-quantum signature (if present) + Verify a sealed Capsule and return a structured result (FR-003). - Returns: - True if seal is valid, False otherwise + Same cryptographic steps as :meth:`verify`, but returns :class:`SealVerificationResult` + with a :class:`SealVerifyCode` instead of only ``True``/``False``. """ - if not capsule.is_sealed(): - return False + if not capsule.hash: + return SealVerificationResult( + False, SealVerifyCode.MISSING_HASH, "capsule has no hash field" + ) + if not capsule.signature: + return SealVerificationResult( + False, SealVerifyCode.MISSING_SIGNATURE, "capsule has no signature field" + ) + + _, herr = _try_hex_bytes(capsule.hash, 32) + if herr is not None: + return herr + _, serr = _try_hex_bytes(capsule.signature, 64) + if serr is not None: + return serr try: - # 1. Recompute hash from content content = json.dumps( capsule.to_dict(), sort_keys=True, separators=(",", ":"), ensure_ascii=False ) computed_hash = hashlib.sha3_256(content.encode("utf-8")).hexdigest() + except Exception as e: + return SealVerificationResult( + False, + SealVerifyCode.HASH_MISMATCH, + f"could not compute content hash: {e!s}", + ) - # 2. Check hash matches (detect content tampering) - if computed_hash != capsule.hash: - return False + if computed_hash != capsule.hash: + return SealVerificationResult( + False, + SealVerifyCode.HASH_MISMATCH, + "recomputed hash does not match stored hash", + ) - # 3. Determine verification key (keyring lookup or local key) + try: resolve_key: VerifyKey | None = None if self._keyring is not None and capsule.signed_by: pub_hex = self._keyring.lookup_public_key(capsule.signed_by) @@ -395,22 +463,62 @@ def verify(self, capsule: Capsule, verify_pq: bool = False) -> bool: if resolve_key is None: _, resolve_key = self._ensure_keys() - # 4. Verify Ed25519 signature resolve_key.verify( capsule.hash.encode("utf-8"), bytes.fromhex(capsule.signature), ) + except BadSignatureError: + return SealVerificationResult( + False, + SealVerifyCode.INVALID_SIGNATURE, + "Ed25519 signature verification failed", + ) + except Exception as e: + return SealVerificationResult( + False, + SealVerifyCode.INVALID_SIGNATURE, + f"Ed25519 verification error: {e!s}", + ) - # 5. Verify post-quantum signature (if requested and present) - if verify_pq and capsule.signature_pq: - return self._verify_dilithium(capsule.hash, capsule.signature_pq) + if verify_pq and capsule.signature_pq: + if not self._verify_dilithium(capsule.hash, capsule.signature_pq): + if _oqs_module is None: + return SealVerificationResult( + False, + SealVerifyCode.PQ_LIBRARY_UNAVAILABLE, + "post-quantum verification requested but oqs is not installed", + ) + return SealVerificationResult( + False, + SealVerifyCode.PQ_VERIFICATION_FAILED, + "ML-DSA-65 signature verification failed", + ) - return True + return SealVerificationResult(True, SealVerifyCode.OK, "") - except BadSignatureError: - return False - except Exception: - return False + def verify(self, capsule: Capsule, verify_pq: bool = False) -> bool: + """ + Verify a sealed Capsule. + + Process: + 1. Check Capsule is sealed (Ed25519 required, PQ depends on mode) + 2. Recompute hash from content + 3. Verify hash matches stored hash + 4. Verify Ed25519 signature (epoch-aware via keyring, or local key) + 5. Verify post-quantum signature (if requested and present) + + When a keyring is configured, the capsule's ``signed_by`` fingerprint + is used to look up the correct epoch's public key. This enables + verification of capsules signed across key rotations. + + Args: + capsule: The Capsule to verify + verify_pq: If True, also verify post-quantum signature (if present) + + Returns: + True if seal is valid, False otherwise + """ + return self.verify_detailed(capsule, verify_pq=verify_pq).ok def _verify_dilithium(self, hash_value: str, signature_hex: str) -> bool: """ @@ -438,43 +546,84 @@ def _verify_dilithium(self, hash_value: str, signature_hex: str) -> bool: except Exception: return False - def verify_with_key(self, capsule: Capsule, public_key_hex: str) -> bool: - """ - Verify a Capsule with a specific Ed25519 public key. - - Useful for verifying Capsules sealed by other instances. + def verify_with_key_detailed( + self, capsule: Capsule, public_key_hex: str + ) -> SealVerificationResult: + """Verify using an explicit Ed25519 public key (structured result, FR-003).""" + if not capsule.hash: + return SealVerificationResult( + False, SealVerifyCode.MISSING_HASH, "capsule has no hash field" + ) + if not capsule.signature: + return SealVerificationResult( + False, SealVerifyCode.MISSING_SIGNATURE, "capsule has no signature field" + ) - Args: - capsule: The Capsule to verify - public_key_hex: Hex-encoded Ed25519 public key - - Returns: - True if seal is valid, False otherwise - """ - if not capsule.is_sealed(): - return False + _, herr = _try_hex_bytes(capsule.hash, 32) + if herr is not None: + return herr + _, serr = _try_hex_bytes(capsule.signature, 64) + if serr is not None: + return serr try: - # Recompute hash content = json.dumps( capsule.to_dict(), sort_keys=True, separators=(",", ":"), ensure_ascii=False ) computed_hash = hashlib.sha3_256(content.encode("utf-8")).hexdigest() + except Exception as e: + return SealVerificationResult( + False, + SealVerifyCode.HASH_MISMATCH, + f"could not compute content hash: {e!s}", + ) + + if computed_hash != capsule.hash: + return SealVerificationResult( + False, + SealVerifyCode.HASH_MISMATCH, + "recomputed hash does not match stored hash", + ) - if computed_hash != capsule.hash: - return False + pk_raw, pk_err = _try_hex_bytes(public_key_hex, 32) + if pk_err is not None: + return pk_err - # Verify Ed25519 with provided key - verify_key = VerifyKey(bytes.fromhex(public_key_hex)) + try: + verify_key = VerifyKey(pk_raw) verify_key.verify( capsule.hash.encode("utf-8"), bytes.fromhex(capsule.signature), ) + except BadSignatureError: + return SealVerificationResult( + False, + SealVerifyCode.INVALID_SIGNATURE, + "Ed25519 signature verification failed", + ) + except Exception as e: + return SealVerificationResult( + False, + SealVerifyCode.INVALID_SIGNATURE, + f"Ed25519 verification error: {e!s}", + ) - return True + return SealVerificationResult(True, SealVerifyCode.OK, "") - except (BadSignatureError, Exception): - return False + def verify_with_key(self, capsule: Capsule, public_key_hex: str) -> bool: + """ + Verify a Capsule with a specific Ed25519 public key. + + Useful for verifying Capsules sealed by other instances. + + Args: + capsule: The Capsule to verify + public_key_hex: Hex-encoded Ed25519 public key + + Returns: + True if seal is valid, False otherwise + """ + return self.verify_with_key_detailed(capsule, public_key_hex).ok def compute_hash(data: dict[str, Any]) -> str: diff --git a/reference/python/src/qp_capsule/validation.py b/reference/python/src/qp_capsule/validation.py new file mode 100644 index 0000000..cd9728d --- /dev/null +++ b/reference/python/src/qp_capsule/validation.py @@ -0,0 +1,392 @@ +# Copyright 2026 Quantum Pipes Technologies, LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Patent Pending — See PATENTS.md for details. + +""" +Runtime validation for CPS capsule content dictionaries (FR-002). + +Validates structure, types, value ranges, chain rules, and optional integrity +against a claimed SHA3-256 content hash. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any +from uuid import UUID + +from qp_capsule.capsule import CapsuleType +from qp_capsule.seal import compute_hash + +_VALID_CAPSULE_TYPES = frozenset(m.value for m in CapsuleType) + + +def _parse_uuid(s: str) -> UUID | None: + try: + return UUID(s) + except (ValueError, TypeError, AttributeError): + return None + + +_ORDERED_TOP_LEVEL = ( + "id", + "type", + "domain", + "parent_id", + "sequence", + "previous_hash", + "spec_version", + "trigger", + "context", + "reasoning", + "authority", + "execution", + "outcome", +) + +_REQUIRED_TOP_LEVEL = frozenset(_ORDERED_TOP_LEVEL) + +_TRIGGER_KEYS = frozenset( + ("type", "source", "timestamp", "request", "correlation_id", "user_id") +) +_CONTEXT_KEYS = frozenset(("agent_id", "session_id", "environment")) +_REASONING_KEYS = frozenset( + ( + "analysis", + "options", + "options_considered", + "selected_option", + "reasoning", + "confidence", + "model", + "prompt_hash", + ) +) +_AUTHORITY_KEYS = frozenset( + ("type", "approver", "policy_reference", "chain", "escalation_reason") +) +_EXECUTION_KEYS = frozenset(("tool_calls", "duration_ms", "resources_used")) +_OUTCOME_KEYS = frozenset( + ("status", "result", "summary", "error", "side_effects", "metrics") +) + + +@dataclass(frozen=True) +class CapsuleValidationResult: + """Result of :func:`validate_capsule_dict`.""" + + ok: bool + category: str | None + field: str | None + message: str + + @staticmethod + def success() -> CapsuleValidationResult: + return CapsuleValidationResult(True, None, None, "") + + @staticmethod + def fail( + category: str, + field: str, + message: str, + ) -> CapsuleValidationResult: + return CapsuleValidationResult(False, category, field, message) + + +def _is_hex64(s: str) -> bool: + return len(s) == 64 and all(c in "0123456789abcdef" for c in s.lower()) + + +def _parse_iso8601_utc(ts: str) -> bool: + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + except (TypeError, ValueError): + return False + return dt.tzinfo is not None + + +def validate_capsule_dict( + data: Any, + *, + claimed_hash: str | None = None, + strict_unknown_keys: bool = False, +) -> CapsuleValidationResult: + """ + Validate a CPS capsule **content** dictionary (pre-seal / ``to_dict`` shape). + + Does not require seal fields (``hash``, ``signature``, …). When + *claimed_hash* is set, recomputes SHA3-256 over *data* (canonical JSON + rules must match :func:`qp_capsule.seal.compute_hash`) and fails with + ``integrity_violation`` if it differs. + + Args: + data: Parsed JSON object (must be a ``dict`` at the root). + claimed_hash: Optional hex digest to check against recomputed hash + (e.g. tampered-content conformance vectors). + strict_unknown_keys: If True, reject unknown top-level keys. + + Returns: + :class:`CapsuleValidationResult` with ``ok`` and error details. + """ + if not isinstance(data, dict): + return CapsuleValidationResult.fail( + "wrong_type", + "", + "capsule root must be a JSON object", + ) + + if strict_unknown_keys: + extra = set(data.keys()) - _REQUIRED_TOP_LEVEL + if extra: + name = sorted(extra)[0] + return CapsuleValidationResult.fail( + "invalid_value", + name, + f"unknown top-level key: {name!r}", + ) + + for key in _ORDERED_TOP_LEVEL: + if key not in data: + return CapsuleValidationResult.fail( + "missing_field", + key, + f"missing required field {key!r}", + ) + + # Top-level types and values + if not isinstance(data["id"], str) or _parse_uuid(data["id"]) is None: + return CapsuleValidationResult.fail( + "invalid_value" if isinstance(data["id"], str) else "wrong_type", + "id", + "id must be a UUID string", + ) + + if not isinstance(data["type"], str): + return CapsuleValidationResult.fail("wrong_type", "type", "type must be a string") + if data["type"] not in _VALID_CAPSULE_TYPES: + return CapsuleValidationResult.fail( + "invalid_value", + "type", + f"unknown capsule type {data['type']!r}", + ) + + if not isinstance(data["domain"], str): + return CapsuleValidationResult.fail( + "wrong_type", + "domain", + "domain must be a string", + ) + + pid = data["parent_id"] + if pid is not None: + if not isinstance(pid, str) or _parse_uuid(pid) is None: + return CapsuleValidationResult.fail( + "invalid_value" if isinstance(pid, str) else "wrong_type", + "parent_id", + "parent_id must be null or a UUID string", + ) + + if not isinstance(data["sequence"], int): + return CapsuleValidationResult.fail( + "wrong_type", + "sequence", + "sequence must be an integer", + ) + if data["sequence"] < 0: + return CapsuleValidationResult.fail( + "invalid_value", + "sequence", + "sequence must be non-negative", + ) + + ph = data["previous_hash"] + if ph is not None: + if not isinstance(ph, str) or not _is_hex64(ph): + return CapsuleValidationResult.fail( + "invalid_value" if isinstance(ph, str) else "wrong_type", + "previous_hash", + "previous_hash must be null or a 64-character hex string", + ) + + if not isinstance(data["spec_version"], str) or not data["spec_version"]: + return CapsuleValidationResult.fail( + "wrong_type" if not isinstance(data["spec_version"], str) else "invalid_value", + "spec_version", + "spec_version must be a non-empty string", + ) + + seq = data["sequence"] + if seq == 0 and ph is not None: + return CapsuleValidationResult.fail( + "chain_violation", + "previous_hash", + "genesis capsule (sequence 0) must have previous_hash null", + ) + if seq != 0 and ph is None: + return CapsuleValidationResult.fail( + "chain_violation", + "previous_hash", + "non-genesis capsule must have previous_hash set", + ) + + # Sections + t = data["trigger"] + if not isinstance(t, dict): + return CapsuleValidationResult.fail("wrong_type", "trigger", "trigger must be an object") + for k in _TRIGGER_KEYS: + if k not in t: + return CapsuleValidationResult.fail( + "missing_field", + f"trigger.{k}", + f"missing required field trigger.{k!r}", + ) + if t["type"] is None or not isinstance(t["type"], str): + return CapsuleValidationResult.fail( + "wrong_type", + "trigger.type", + "trigger.type must be a non-null string", + ) + if not isinstance(t["timestamp"], str) or not _parse_iso8601_utc(t["timestamp"]): + return CapsuleValidationResult.fail( + "invalid_value" if isinstance(t["timestamp"], str) else "wrong_type", + "trigger.timestamp", + "trigger.timestamp must be an ISO 8601 string with timezone", + ) + + ctx = data["context"] + if not isinstance(ctx, dict): + return CapsuleValidationResult.fail("wrong_type", "context", "context must be an object") + for k in _CONTEXT_KEYS: + if k not in ctx: + return CapsuleValidationResult.fail( + "missing_field", + f"context.{k}", + f"missing required field context.{k!r}", + ) + if not isinstance(ctx["environment"], dict): + return CapsuleValidationResult.fail( + "wrong_type", + "context.environment", + "context.environment must be an object", + ) + + r = data["reasoning"] + if not isinstance(r, dict): + return CapsuleValidationResult.fail( + "wrong_type", + "reasoning", + "reasoning must be an object", + ) + for k in _REASONING_KEYS: + if k not in r: + return CapsuleValidationResult.fail( + "missing_field", + f"reasoning.{k}", + f"missing required field reasoning.{k!r}", + ) + conf_raw = r["confidence"] + if isinstance(conf_raw, bool) or not isinstance(conf_raw, (int, float)): + return CapsuleValidationResult.fail( + "wrong_type", + "reasoning.confidence", + "reasoning.confidence must be a number", + ) + c = float(conf_raw) + if c < 0.0 or c > 1.0: + return CapsuleValidationResult.fail( + "invalid_value", + "reasoning.confidence", + "reasoning.confidence must be between 0.0 and 1.0", + ) + + a = data["authority"] + if not isinstance(a, dict): + return CapsuleValidationResult.fail( + "wrong_type", + "authority", + "authority must be an object", + ) + for k in _AUTHORITY_KEYS: + if k not in a: + return CapsuleValidationResult.fail( + "missing_field", + f"authority.{k}", + f"missing required field authority.{k!r}", + ) + + e = data["execution"] + if not isinstance(e, dict): + return CapsuleValidationResult.fail( + "wrong_type", + "execution", + "execution must be an object", + ) + for k in _EXECUTION_KEYS: + if k not in e: + return CapsuleValidationResult.fail( + "missing_field", + f"execution.{k}", + f"missing required field execution.{k!r}", + ) + if not isinstance(e["tool_calls"], list): + return CapsuleValidationResult.fail( + "wrong_type", + "execution.tool_calls", + "execution.tool_calls must be an array", + ) + if not isinstance(e["duration_ms"], int): + return CapsuleValidationResult.fail( + "wrong_type", + "execution.duration_ms", + "execution.duration_ms must be an integer", + ) + if not isinstance(e["resources_used"], dict): + return CapsuleValidationResult.fail( + "wrong_type", + "execution.resources_used", + "execution.resources_used must be an object", + ) + + o = data["outcome"] + if not isinstance(o, dict): + return CapsuleValidationResult.fail("wrong_type", "outcome", "outcome must be an object") + for k in _OUTCOME_KEYS: + if k not in o: + return CapsuleValidationResult.fail( + "missing_field", + f"outcome.{k}", + f"missing required field outcome.{k!r}", + ) + + if claimed_hash is not None: + if not isinstance(claimed_hash, str) or not _is_hex64(claimed_hash): + return CapsuleValidationResult.fail( + "invalid_value", + "hash", + "claimed_hash must be a 64-character hex string", + ) + if compute_hash(data) != claimed_hash.lower(): + return CapsuleValidationResult.fail( + "integrity_violation", + "hash", + "content hash does not match claimed_hash", + ) + + return CapsuleValidationResult.success() + + +def validate_capsule(capsule: Any) -> CapsuleValidationResult: + """ + Validate a :class:`qp_capsule.capsule.Capsule` instance via its ``to_dict()`` output. + """ + from qp_capsule.capsule import Capsule as CapsuleCls + + if not isinstance(capsule, CapsuleCls): + return CapsuleValidationResult.fail( + "wrong_type", + "", + "expected qp_capsule.capsule.Capsule instance", + ) + return validate_capsule_dict(capsule.to_dict()) diff --git a/reference/python/tests/test_chain_concurrency.py b/reference/python/tests/test_chain_concurrency.py index 0212003..45bbd87 100644 --- a/reference/python/tests/test_chain_concurrency.py +++ b/reference/python/tests/test_chain_concurrency.py @@ -484,7 +484,7 @@ def test_returns_version_string(self): from qp_capsule.cli import _get_version version = _get_version() - assert version == "1.5.2" + assert version == "1.5.3" def test_matches_package_version(self): import qp_capsule diff --git a/reference/python/tests/test_cli.py b/reference/python/tests/test_cli.py index ab224a9..d686e5b 100644 --- a/reference/python/tests/test_cli.py +++ b/reference/python/tests/test_cli.py @@ -632,7 +632,7 @@ def test_version_flag(self, capsys, monkeypatch): main(["--version"]) out = capsys.readouterr().out assert "capsule" in out - assert "1.5.2" in out + assert "1.5.3" in out def test_verify_via_main(self, seal, temp_dir, monkeypatch): monkeypatch.setenv("NO_COLOR", "1") diff --git a/reference/python/tests/test_invalid_fixtures.py b/reference/python/tests/test_invalid_fixtures.py index 10cd12a..f65ed11 100644 --- a/reference/python/tests/test_invalid_fixtures.py +++ b/reference/python/tests/test_invalid_fixtures.py @@ -28,12 +28,14 @@ _REQUIRED_TOP_LEVEL = frozenset({ "id", "type", "domain", "parent_id", "sequence", "previous_hash", + "spec_version", "trigger", "context", "reasoning", "authority", "execution", "outcome", }) _ALL_FIXTURE_NAMES = [ "missing_id", "missing_type", "missing_trigger_section", - "missing_reasoning_section", "sequence_wrong_type", + "missing_reasoning_section", "missing_spec_version", + "sequence_wrong_type", "confidence_wrong_type", "trigger_type_null", "negative_sequence", "confidence_out_of_range", "unknown_capsule_type", "genesis_with_previous_hash", "non_genesis_null_previous_hash", @@ -64,8 +66,8 @@ class TestSuiteIntegrity: def test_file_exists(self): assert _FIXTURES_PATH.exists() - def test_has_15_fixtures(self, fixtures): - assert len(fixtures) == 15 + def test_has_16_fixtures(self, fixtures): + assert len(fixtures) == 16 def test_all_have_required_keys(self, fixtures): for f in fixtures: @@ -102,14 +104,14 @@ def test_empty_object_has_no_keys(self, by_name): @pytest.mark.parametrize("name", [ "missing_id", "missing_type", "missing_trigger_section", - "missing_reasoning_section", "empty_object", + "missing_reasoning_section", "missing_spec_version", "empty_object", ]) def test_all_categorized_as_missing_field(self, by_name, name): assert by_name[name]["expected_error"] == "missing_field" @pytest.mark.parametrize("name", [ "missing_id", "missing_type", "missing_trigger_section", - "missing_reasoning_section", + "missing_reasoning_section", "missing_spec_version", ]) def test_remaining_fields_are_valid(self, by_name, name): """Missing-field fixtures should only omit the declared field.""" diff --git a/reference/python/tests/test_package_exports.py b/reference/python/tests/test_package_exports.py new file mode 100644 index 0000000..4db04ec --- /dev/null +++ b/reference/python/tests/test_package_exports.py @@ -0,0 +1,20 @@ +""" +Smoke tests: public qp-capsule exports for CPS protocol features (FR-002, FR-003). +""" + +import qp_capsule + + +def test_fr002_validation_exports(): + assert hasattr(qp_capsule, "validate_capsule_dict") + assert hasattr(qp_capsule, "validate_capsule") + assert hasattr(qp_capsule, "CapsuleValidationResult") + assert callable(qp_capsule.validate_capsule_dict) + assert callable(qp_capsule.validate_capsule) + + +def test_fr003_seal_verify_exports(): + assert hasattr(qp_capsule, "SealVerifyCode") + assert hasattr(qp_capsule, "SealVerificationResult") + assert hasattr(qp_capsule.Seal, "verify_detailed") + assert hasattr(qp_capsule.Seal, "verify_with_key_detailed") diff --git a/reference/python/tests/test_seal.py b/reference/python/tests/test_seal.py index c97b01e..540ebe7 100644 --- a/reference/python/tests/test_seal.py +++ b/reference/python/tests/test_seal.py @@ -7,11 +7,12 @@ import tempfile from pathlib import Path +from unittest.mock import MagicMock, patch import pytest from qp_capsule.capsule import Capsule, ReasoningSection, TriggerSection -from qp_capsule.seal import Seal, compute_hash +from qp_capsule.seal import Seal, SealVerifyCode, compute_hash @pytest.fixture @@ -190,6 +191,157 @@ def test_verify_tampered_signature_fails(self, seal, sample_capsule): assert seal.verify(sample_capsule) is False +class TestVerifyBooleanMatchesDetailed: + """verify() must match verify_detailed().ok for all paths exercised here.""" + + def test_matches_when_valid(self, seal, sample_capsule): + seal.seal(sample_capsule) + assert seal.verify(sample_capsule) == seal.verify_detailed(sample_capsule).ok + + def test_matches_when_unsealed(self, seal, sample_capsule): + assert seal.verify(sample_capsule) == seal.verify_detailed(sample_capsule).ok + + def test_matches_when_tampered(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.domain = "x" + assert seal.verify(sample_capsule) == seal.verify_detailed(sample_capsule).ok + + def test_matches_with_key(self, seal, sample_capsule): + seal.seal(sample_capsule) + pub = seal.get_public_key() + assert seal.verify_with_key(sample_capsule, pub) == seal.verify_with_key_detailed( + sample_capsule, pub + ).ok + + +class TestVerifyDetailed: + """Structured verification (FR-003).""" + + def test_ok(self, seal, sample_capsule): + seal.seal(sample_capsule) + r = seal.verify_detailed(sample_capsule) + assert r.ok + assert r.code == SealVerifyCode.OK + assert r.success is True + + def test_missing_hash(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.hash = "" + r = seal.verify_detailed(sample_capsule) + assert not r.ok + assert r.code == SealVerifyCode.MISSING_HASH + + def test_missing_signature(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.signature = "" + r = seal.verify_detailed(sample_capsule) + assert r.code == SealVerifyCode.MISSING_SIGNATURE + + def test_malformed_hash_hex(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.hash = "zz" + "a" * 62 + r = seal.verify_detailed(sample_capsule) + assert r.code == SealVerifyCode.MALFORMED_HEX + + def test_malformed_hash_wrong_byte_length(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.hash = "a" * 62 + r = seal.verify_detailed(sample_capsule) + assert r.code == SealVerifyCode.MALFORMED_HEX + + def test_malformed_signature_hex(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.signature = "qq" + r = seal.verify_detailed(sample_capsule) + assert r.code == SealVerifyCode.MALFORMED_HEX + + def test_hash_mismatch(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.reasoning.reasoning = "tampered" + r = seal.verify_detailed(sample_capsule) + assert r.code == SealVerifyCode.HASH_MISMATCH + + def test_invalid_signature(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.signature = "ab" * 64 + r = seal.verify_detailed(sample_capsule) + assert r.code == SealVerifyCode.INVALID_SIGNATURE + + def test_verify_with_key_detailed_ok(self, seal, sample_capsule): + seal.seal(sample_capsule) + pub = seal.get_public_key() + r = seal.verify_with_key_detailed(sample_capsule, pub) + assert r.ok and r.code == SealVerifyCode.OK + + def test_verify_with_key_detailed_bad_public_key_hex(self, seal, sample_capsule): + seal.seal(sample_capsule) + r = seal.verify_with_key_detailed(sample_capsule, "not-a-key") + assert r.code == SealVerifyCode.MALFORMED_HEX + + def test_verify_detailed_pq_library_unavailable(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.signature_pq = "ab" * 128 + with patch("qp_capsule.seal._oqs_module", None): + with patch.object(seal, "_verify_dilithium", return_value=False): + r = seal.verify_detailed(sample_capsule, verify_pq=True) + assert r.code == SealVerifyCode.PQ_LIBRARY_UNAVAILABLE + + def test_verify_detailed_pq_verification_failed(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.signature_pq = "ab" * 128 + mock_oqs = MagicMock() + with patch("qp_capsule.seal._oqs_module", mock_oqs): + with patch.object(seal, "_verify_dilithium", return_value=False): + r = seal.verify_detailed(sample_capsule, verify_pq=True) + assert r.code == SealVerifyCode.PQ_VERIFICATION_FAILED + + def test_verify_detailed_ed25519_non_signature_exception(self, seal, sample_capsule): + seal.seal(sample_capsule) + with patch("qp_capsule.seal.VerifyKey.verify", side_effect=RuntimeError("boom")): + r = seal.verify_detailed(sample_capsule) + assert r.code == SealVerifyCode.INVALID_SIGNATURE + assert "Ed25519 verification error" in r.message + + def test_verify_with_key_detailed_missing_hash(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.hash = "" + r = seal.verify_with_key_detailed(sample_capsule, seal.get_public_key()) + assert r.code == SealVerifyCode.MISSING_HASH + + def test_verify_with_key_detailed_missing_signature(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.signature = "" + r = seal.verify_with_key_detailed(sample_capsule, seal.get_public_key()) + assert r.code == SealVerifyCode.MISSING_SIGNATURE + + def test_verify_with_key_detailed_malformed_hash_hex(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.hash = "zz" + "a" * 62 + r = seal.verify_with_key_detailed(sample_capsule, seal.get_public_key()) + assert r.code == SealVerifyCode.MALFORMED_HEX + + def test_verify_with_key_detailed_malformed_signature_hex(self, seal, sample_capsule): + seal.seal(sample_capsule) + sample_capsule.signature = "qq" + r = seal.verify_with_key_detailed(sample_capsule, seal.get_public_key()) + assert r.code == SealVerifyCode.MALFORMED_HEX + + def test_verify_with_key_detailed_hash_compute_error(self, seal, sample_capsule): + seal.seal(sample_capsule) + with patch("qp_capsule.seal.json.dumps", side_effect=TypeError("boom")): + r = seal.verify_with_key_detailed(sample_capsule, seal.get_public_key()) + assert r.code == SealVerifyCode.HASH_MISMATCH + assert "could not compute content hash" in r.message + + def test_verify_with_key_detailed_verify_non_signature_exception(self, seal, sample_capsule): + seal.seal(sample_capsule) + pub = seal.get_public_key() + with patch("qp_capsule.seal.VerifyKey.verify", side_effect=RuntimeError("boom")): + r = seal.verify_with_key_detailed(sample_capsule, pub) + assert r.code == SealVerifyCode.INVALID_SIGNATURE + assert "Ed25519 verification error" in r.message + + class TestPublicKey: """Test public key operations.""" diff --git a/reference/python/tests/test_validation.py b/reference/python/tests/test_validation.py new file mode 100644 index 0000000..63e9f6f --- /dev/null +++ b/reference/python/tests/test_validation.py @@ -0,0 +1,302 @@ +""" +Tests for validate_capsule_dict / validate_capsule (FR-002). + +Uses conformance/invalid-fixtures.json and golden vectors. +""" + +import copy +import json +from pathlib import Path +from uuid import uuid4 + +import pytest + +from qp_capsule.capsule import Capsule, CapsuleType +from qp_capsule.validation import ( + CapsuleValidationResult, + validate_capsule, + validate_capsule_dict, +) + +_HERE = Path(__file__).resolve().parent +_REPO_ROOT = _HERE.parent.parent.parent +_INVALID_PATH = _REPO_ROOT / "conformance" / "invalid-fixtures.json" +_GOLDEN_PATH = _REPO_ROOT / "conformance" / "fixtures.json" + + +def _invalid_fixtures(): + data = json.loads(_INVALID_PATH.read_text()) + return data["fixtures"] + + +@pytest.fixture(scope="module") +def invalid_fixtures(): + return _invalid_fixtures() + + +@pytest.mark.parametrize("fixture", _invalid_fixtures(), ids=lambda f: f["name"]) +def test_invalid_vectors_rejected(fixture): + d = fixture["capsule_dict"] + claimed = fixture.get("claimed_hash") + r = validate_capsule_dict(d, claimed_hash=claimed) + assert not r.ok, f"expected failure for {fixture['name']}: {r}" + assert r.category == fixture["expected_error"], fixture["name"] + assert r.field == fixture["error_field"], fixture["name"] + + +def test_golden_fixtures_accepted(): + data = json.loads(_GOLDEN_PATH.read_text()) + for f in data["fixtures"]: + r = validate_capsule_dict(f["capsule_dict"]) + assert r.ok, f"{f['name']}: {r.message}" + + +def test_validate_capsule_round_trip(): + c = Capsule.create(capsule_type=CapsuleType.AGENT) + c.id = uuid4() + r = validate_capsule(c) + assert r.ok + + +def test_validate_capsule_rejects_non_capsule(): + r = validate_capsule({"not": "a capsule"}) + assert not r.ok + assert r.category == "wrong_type" + + +def test_strict_unknown_keys(): + d = json.loads(_GOLDEN_PATH.read_text())["fixtures"][0]["capsule_dict"] + d2 = dict(d) + d2["extra_top"] = 1 + r = validate_capsule_dict(d2, strict_unknown_keys=True) + assert not r.ok + assert r.field == "extra_top" + + +def test_claimed_hash_invalid_hex(): + d = json.loads(_GOLDEN_PATH.read_text())["fixtures"][0]["capsule_dict"] + r = validate_capsule_dict(d, claimed_hash="not_hex") + assert not r.ok + assert r.field == "hash" + + +def test_root_not_object(): + r = validate_capsule_dict([]) + assert not r.ok + assert r.category == "wrong_type" + assert r.field == "" + + +def _minimal_golden() -> dict: + data = json.loads(_GOLDEN_PATH.read_text()) + for f in data["fixtures"]: + if f["name"] == "minimal": + return copy.deepcopy(f["capsule_dict"]) + raise RuntimeError("minimal fixture missing") + + +class TestValidationBranches: + """Cover branches not exercised by invalid-fixtures.json alone.""" + + def test_id_wrong_type(self): + d = _minimal_golden() + d["id"] = 1 + r = validate_capsule_dict(d) + assert r.category == "wrong_type" + assert r.field == "id" + + def test_id_invalid_uuid_string(self): + d = _minimal_golden() + d["id"] = "not-a-uuid" + r = validate_capsule_dict(d) + assert r.category == "invalid_value" + + def test_type_wrong_type(self): + d = _minimal_golden() + d["type"] = 1 + r = validate_capsule_dict(d) + assert r.field == "type" + assert r.category == "wrong_type" + + def test_domain_wrong_type(self): + d = _minimal_golden() + d["domain"] = 1 + r = validate_capsule_dict(d) + assert r.field == "domain" + + def test_parent_id_wrong_type(self): + d = _minimal_golden() + d["parent_id"] = 1 + r = validate_capsule_dict(d) + assert r.field == "parent_id" + assert r.category == "wrong_type" + + def test_parent_id_invalid_uuid(self): + d = _minimal_golden() + d["parent_id"] = "not-a-uuid" + r = validate_capsule_dict(d) + assert r.field == "parent_id" + assert r.category == "invalid_value" + + def test_sequence_float_not_int(self): + d = _minimal_golden() + d["sequence"] = 0.0 + r = validate_capsule_dict(d) + assert r.field == "sequence" + assert r.category == "wrong_type" + + def test_previous_hash_wrong_type(self): + d = _minimal_golden() + d["previous_hash"] = 1 + r = validate_capsule_dict(d) + assert r.field == "previous_hash" + assert r.category == "wrong_type" + + def test_previous_hash_bad_hex_length(self): + d = _minimal_golden() + d["previous_hash"] = "aa" + r = validate_capsule_dict(d) + assert r.field == "previous_hash" + assert r.category == "invalid_value" + + def test_spec_version_wrong_type(self): + d = _minimal_golden() + d["spec_version"] = 1 + r = validate_capsule_dict(d) + assert r.field == "spec_version" + assert r.category == "wrong_type" + + def test_spec_version_empty(self): + d = _minimal_golden() + d["spec_version"] = "" + r = validate_capsule_dict(d) + assert r.field == "spec_version" + assert r.category == "invalid_value" + + def test_trigger_missing_subkey(self): + d = _minimal_golden() + del d["trigger"]["timestamp"] + r = validate_capsule_dict(d) + assert "trigger.timestamp" in r.field + + def test_trigger_timestamp_naive(self): + d = _minimal_golden() + d["trigger"]["timestamp"] = "2026-01-15T12:00:00" + r = validate_capsule_dict(d) + assert r.field == "trigger.timestamp" + assert r.category == "invalid_value" + + def test_trigger_timestamp_wrong_type(self): + d = _minimal_golden() + d["trigger"]["timestamp"] = 1 + r = validate_capsule_dict(d) + assert r.field == "trigger.timestamp" + assert r.category == "wrong_type" + + def test_context_wrong_type(self): + d = _minimal_golden() + d["context"] = [] + r = validate_capsule_dict(d) + assert r.field == "context" + + def test_context_missing_subkey(self): + d = _minimal_golden() + del d["context"]["agent_id"] + r = validate_capsule_dict(d) + assert r.field == "context.agent_id" + + def test_context_environment_wrong_type(self): + d = _minimal_golden() + d["context"]["environment"] = [] + r = validate_capsule_dict(d) + assert r.field == "context.environment" + + def test_reasoning_wrong_type(self): + d = _minimal_golden() + d["reasoning"] = [] + r = validate_capsule_dict(d) + assert r.field == "reasoning" + + def test_reasoning_missing_subkey(self): + d = _minimal_golden() + del d["reasoning"]["analysis"] + r = validate_capsule_dict(d) + assert r.field == "reasoning.analysis" + + def test_confidence_bool_rejected(self): + d = _minimal_golden() + d["reasoning"]["confidence"] = True + r = validate_capsule_dict(d) + assert r.field == "reasoning.confidence" + + def test_authority_wrong_type(self): + d = _minimal_golden() + d["authority"] = [] + r = validate_capsule_dict(d) + assert r.field == "authority" + + def test_authority_missing_subkey(self): + d = _minimal_golden() + del d["authority"]["type"] + r = validate_capsule_dict(d) + assert r.field == "authority.type" + + def test_execution_wrong_type(self): + d = _minimal_golden() + d["execution"] = [] + r = validate_capsule_dict(d) + assert r.field == "execution" + + def test_execution_missing_subkey(self): + d = _minimal_golden() + del d["execution"]["tool_calls"] + r = validate_capsule_dict(d) + assert r.field == "execution.tool_calls" + + def test_tool_calls_wrong_type(self): + d = _minimal_golden() + d["execution"]["tool_calls"] = {} + r = validate_capsule_dict(d) + assert r.field == "execution.tool_calls" + + def test_duration_ms_wrong_type(self): + d = _minimal_golden() + d["execution"]["duration_ms"] = 0.0 + r = validate_capsule_dict(d) + assert r.field == "execution.duration_ms" + + def test_resources_used_wrong_type(self): + d = _minimal_golden() + d["execution"]["resources_used"] = [] + r = validate_capsule_dict(d) + assert r.field == "execution.resources_used" + + def test_outcome_wrong_type(self): + d = _minimal_golden() + d["outcome"] = [] + r = validate_capsule_dict(d) + assert r.field == "outcome" + + def test_outcome_missing_subkey(self): + d = _minimal_golden() + del d["outcome"]["status"] + r = validate_capsule_dict(d) + assert r.field == "outcome.status" + + def test_parse_iso8601_invalid_string(self): + d = _minimal_golden() + d["trigger"]["timestamp"] = "not-a-date" + r = validate_capsule_dict(d) + assert r.field == "trigger.timestamp" + assert r.category == "invalid_value" + + def test_capsule_validation_result_success_factory(self): + r = CapsuleValidationResult.success() + assert r.ok and r.category is None + + def test_capsule_validation_result_fail_factory(self): + r = CapsuleValidationResult.fail("invalid_value", "field", "msg") + assert not r.ok + assert r.category == "invalid_value" + assert r.field == "field" + assert r.message == "msg" diff --git a/reference/typescript/README.md b/reference/typescript/README.md index ca20115..3a84373 100644 --- a/reference/typescript/README.md +++ b/reference/typescript/README.md @@ -1,6 +1,6 @@ # Capsule Protocol — TypeScript Reference Implementation -> **Status**: Conformant — 16/16 golden fixtures passing, 101 tests, 100% coverage +> **Status**: Conformant — 16/16 golden fixtures passing; full `npm test` includes validation + seal + conformance suites TypeScript reference implementation of the [Capsule Protocol Specification (CPS)](../../spec/). Create, seal, verify, and chain Capsules in TypeScript/JavaScript. @@ -66,10 +66,18 @@ console.log(`Sealed: ${capsule.hash.slice(0, 16)}... Valid: ${valid}`); |---|---| | `computeHash(capsuleDict)` | SHA3-256 of canonical JSON (64-char hex) | | `seal(capsule, privateKey)` | Hash + Ed25519 sign | -| `verify(capsule, publicKey)` | Recompute hash + verify signature | +| `verify(capsule, publicKey)` | Recompute hash + verify signature (`true` / `false`) | +| `verifyDetailed(capsule, publicKey)` | Same checks as `verify`, returns `{ ok, code, message }` (FR-003) | | `generateKeyPair()` | Ed25519 key pair generation | | `getFingerprint(privateKey)` | Public key fingerprint (16 hex chars) | +### Runtime validation (FR-002) + +| Export | Description | +|---|---| +| `validateCapsuleDict(data, options?)` | Validate CPS content dict: required keys, types, chain rules; optional `claimedHash`, `strictUnknownKeys` | +| `CapsuleValidationResult` | `{ ok, category, field, message }` | + ### Chain Verification | Export | Description | @@ -94,7 +102,7 @@ Both are audited, zero-dependency, pure-JS implementations by Paul Miller. This implementation passes all 16 golden test vectors from [`conformance/fixtures.json`](../../conformance/fixtures.json). ```bash -npm test # 101 tests: 47 conformance + 22 canonical + 15 capsule + 11 seal + 6 chain +npm test # runs conformance, canonical, capsule, seal (incl. verifyDetailed), validation, invalid-fixtures, chain, exports ``` ### Known TypeScript Pitfalls (CPS Section 2) diff --git a/reference/typescript/__tests__/capsule.test.ts b/reference/typescript/__tests__/capsule.test.ts index 719089e..b062551 100644 --- a/reference/typescript/__tests__/capsule.test.ts +++ b/reference/typescript/__tests__/capsule.test.ts @@ -80,7 +80,7 @@ describe("toDict", () => { expect("signed_by" in dict).toBe(false); }); - it("includes all 12 content fields", () => { + it("includes all 13 content fields", () => { const dict = toDict(createCapsule()); const keys = Object.keys(dict); expect(keys).toContain("id"); @@ -89,13 +89,14 @@ describe("toDict", () => { expect(keys).toContain("parent_id"); expect(keys).toContain("sequence"); expect(keys).toContain("previous_hash"); + expect(keys).toContain("spec_version"); expect(keys).toContain("trigger"); expect(keys).toContain("context"); expect(keys).toContain("reasoning"); expect(keys).toContain("authority"); expect(keys).toContain("execution"); expect(keys).toContain("outcome"); - expect(keys).toHaveLength(12); + expect(keys).toHaveLength(13); }); }); diff --git a/reference/typescript/__tests__/exports.test.ts b/reference/typescript/__tests__/exports.test.ts new file mode 100644 index 0000000..8518ab9 --- /dev/null +++ b/reference/typescript/__tests__/exports.test.ts @@ -0,0 +1,17 @@ +/** + * Public package exports (FR-002, FR-003). + */ + +import { describe, expect, it } from "vitest"; +import * as pkg from "../src/index.js"; + +describe("package exports", () => { + it("exports FR-002 validation API", () => { + expect(typeof pkg.validateCapsuleDict).toBe("function"); + }); + + it("exports FR-003 verifyDetailed", () => { + expect(typeof pkg.verifyDetailed).toBe("function"); + expect(typeof pkg.verify).toBe("function"); + }); +}); diff --git a/reference/typescript/__tests__/invalid-fixtures.test.ts b/reference/typescript/__tests__/invalid-fixtures.test.ts new file mode 100644 index 0000000..5cede25 --- /dev/null +++ b/reference/typescript/__tests__/invalid-fixtures.test.ts @@ -0,0 +1,49 @@ +/** + * Invalid capsule conformance (conformance/invalid-fixtures.json). + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { validateCapsuleDict } from "../src/validation.js"; + +const INVALID_PATH = resolve(__dirname, "../../../conformance/invalid-fixtures.json"); +const invalidData = JSON.parse(readFileSync(INVALID_PATH, "utf-8")) as { + fixtures: Array<{ + name: string; + expected_error: string; + error_field: string; + capsule_dict: Record; + claimed_hash?: string; + }>; +}; + +const GOLDEN_PATH = resolve(__dirname, "../../../conformance/fixtures.json"); +const goldenData = JSON.parse(readFileSync(GOLDEN_PATH, "utf-8")) as { + fixtures: Array<{ name: string; capsule_dict: Record }>; +}; + +describe("invalid-fixtures.json (FR-002)", () => { + it("has 16 fixtures", () => { + expect(invalidData.fixtures).toHaveLength(16); + }); + + it.each(invalidData.fixtures)( + "rejects $name", + (fixture) => { + const r = validateCapsuleDict(fixture.capsule_dict, { + claimedHash: fixture.claimed_hash, + }); + expect(r.ok, fixture.name).toBe(false); + expect(r.category).toBe(fixture.expected_error); + expect(r.field).toBe(fixture.error_field); + }, + ); +}); + +describe("golden fixtures pass validation", () => { + it.each(goldenData.fixtures)("$name is valid", (f) => { + const r = validateCapsuleDict(f.capsule_dict); + expect(r.ok).toBe(true); + }); +}); diff --git a/reference/typescript/__tests__/seal.test.ts b/reference/typescript/__tests__/seal.test.ts index c698740..a1e6c82 100644 --- a/reference/typescript/__tests__/seal.test.ts +++ b/reference/typescript/__tests__/seal.test.ts @@ -12,7 +12,13 @@ import { toDict, } from "../src/capsule.js"; import { computeHash } from "../src/seal.js"; -import { seal, verify, generateKeyPair, getFingerprint } from "../src/seal.js"; +import { + seal, + verify, + verifyDetailed, + generateKeyPair, + getFingerprint, +} from "../src/seal.js"; describe("Seal", () => { it("seals a capsule with hash and signature", async () => { @@ -90,6 +96,88 @@ describe("Seal", () => { expect(await verify(capsule, pub)).toBe(false); }); + it("verifyDetailed returns ok for valid capsule", async () => { + const capsule = createCapsule({ type: "agent" }); + const { privateKey, publicKey } = generateKeyPair(); + const pub = await publicKey; + await seal(capsule, privateKey); + const r = await verifyDetailed(capsule, pub); + expect(r.ok).toBe(true); + expect(r.code).toBe("ok"); + }); + + it("verifyDetailed missing_hash when hash empty", async () => { + const capsule = createCapsule({ type: "agent" }); + const { publicKey } = generateKeyPair(); + const pub = await publicKey; + const r = await verifyDetailed(capsule, pub); + expect(r.ok).toBe(false); + expect(r.code).toBe("missing_hash"); + }); + + it("verifyDetailed missing_signature when signature empty", async () => { + const capsule = createCapsule({ type: "agent" }); + const { publicKey } = generateKeyPair(); + const pub = await publicKey; + capsule.hash = "a".repeat(64); + capsule.signature = ""; + const r = await verifyDetailed(capsule, pub); + expect(r.code).toBe("missing_signature"); + }); + + it("verifyDetailed hash_mismatch on tamper", async () => { + const capsule = createCapsule({ type: "agent" }); + const { privateKey, publicKey } = generateKeyPair(); + const pub = await publicKey; + await seal(capsule, privateKey); + capsule.domain = "tampered"; + const r = await verifyDetailed(capsule, pub); + expect(r.code).toBe("hash_mismatch"); + }); + + it("verifyDetailed malformed_hex on bad signature length", async () => { + const capsule = createCapsule({ type: "agent" }); + const { privateKey, publicKey } = generateKeyPair(); + const pub = await publicKey; + await seal(capsule, privateKey); + capsule.signature = "ab"; + const r = await verifyDetailed(capsule, pub); + expect(r.code).toBe("malformed_hex"); + }); + + it("verifyDetailed invalid_signature with wrong key", async () => { + const capsule = createCapsule({ type: "agent" }); + const keys1 = generateKeyPair(); + const keys2 = generateKeyPair(); + await seal(capsule, keys1.privateKey); + const wrongPub = await keys2.publicKey; + const r = await verifyDetailed(capsule, wrongPub); + expect(r.ok).toBe(false); + expect(r.code).toBe("invalid_signature"); + }); + + it("verifyDetailed malformed_hex when hash has wrong length", async () => { + const capsule = createCapsule({ type: "agent" }); + const { privateKey, publicKey } = generateKeyPair(); + const pub = await publicKey; + await seal(capsule, privateKey); + capsule.hash = "a".repeat(62); + const r = await verifyDetailed(capsule, pub); + expect(r.code).toBe("malformed_hex"); + }); + + it("verify boolean matches verifyDetailed.ok", async () => { + const capsule = createCapsule({ type: "agent" }); + const { privateKey, publicKey } = generateKeyPair(); + const pub = await publicKey; + await seal(capsule, privateKey); + const detailed = await verifyDetailed(capsule, pub); + expect(await verify(capsule, pub)).toBe(detailed.ok); + capsule.domain = "tampered"; + const d2 = await verifyDetailed(capsule, pub); + expect(await verify(capsule, pub)).toBe(d2.ok); + }); + it("hash is deterministic for same content", () => { const dict = toDict( createCapsule({ diff --git a/reference/typescript/__tests__/validation.test.ts b/reference/typescript/__tests__/validation.test.ts new file mode 100644 index 0000000..a284224 --- /dev/null +++ b/reference/typescript/__tests__/validation.test.ts @@ -0,0 +1,72 @@ +/** + * Unit tests for validateCapsuleDict (FR-002), beyond invalid-fixtures.json table tests. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { validateCapsuleDict } from "../src/validation.js"; + +const GOLDEN_PATH = resolve(__dirname, "../../../conformance/fixtures.json"); + +function minimalGoldenDict(): Record { + const data = JSON.parse(readFileSync(GOLDEN_PATH, "utf-8")) as { + fixtures: Array<{ name: string; capsule_dict: Record }>; + }; + const m = data.fixtures.find((f) => f.name === "minimal"); + if (!m) throw new Error("minimal fixture missing"); + return structuredClone(m.capsule_dict); +} + +describe("validateCapsuleDict (FR-002)", () => { + it("accepts minimal golden vector", () => { + const d = minimalGoldenDict(); + const r = validateCapsuleDict(d); + expect(r.ok).toBe(true); + expect(r.category).toBeNull(); + }); + + it("rejects empty object", () => { + const r = validateCapsuleDict({}); + expect(r.ok).toBe(false); + expect(r.category).toBe("missing_field"); + expect(r.field).toBe("id"); + }); + + it("strictUnknownKeys rejects extra top-level key", () => { + const d = minimalGoldenDict(); + const bad = { ...d, extra_field: 1 }; + const r = validateCapsuleDict(bad, { strictUnknownKeys: true }); + expect(r.ok).toBe(false); + expect(r.field).toBe("extra_field"); + expect(r.category).toBe("invalid_value"); + }); + + it("claimedHash mismatch yields integrity_violation", () => { + const d = minimalGoldenDict(); + const r = validateCapsuleDict(d, { + claimedHash: "a".repeat(64), + }); + expect(r.ok).toBe(false); + expect(r.category).toBe("integrity_violation"); + expect(r.field).toBe("hash"); + }); + + it("claimedHash matching computeHashFromDict passes", () => { + const data = JSON.parse(readFileSync(GOLDEN_PATH, "utf-8")) as { + fixtures: Array<{ name: string; capsule_dict: Record; sha3_256_hash: string }>; + }; + const minimal = data.fixtures.find((f) => f.name === "minimal"); + if (!minimal) throw new Error("minimal missing"); + const r = validateCapsuleDict(minimal.capsule_dict, { + claimedHash: minimal.sha3_256_hash, + }); + expect(r.ok).toBe(true); + }); + + it("rejects root that is not a plain object", () => { + const r = validateCapsuleDict(null); + expect(r.ok).toBe(false); + expect(r.category).toBe("wrong_type"); + }); +}); diff --git a/reference/typescript/src/capsule.ts b/reference/typescript/src/capsule.ts index 0004e5b..4789096 100644 --- a/reference/typescript/src/capsule.ts +++ b/reference/typescript/src/capsule.ts @@ -132,6 +132,8 @@ export interface CapsuleDict { parent_id: string | null; sequence: number; previous_hash: string | null; + /** CPS wire-format version (included in canonical hash). */ + spec_version: string; trigger: TriggerSection; context: ContextSection; reasoning: ReasoningSection; @@ -236,6 +238,7 @@ export function createCapsule( parent_id: null, sequence: 0, previous_hash: null, + spec_version: "1.0", ...scalars, trigger: createTrigger(trigger), context: createContext(context), @@ -263,6 +266,7 @@ export function toDict(capsule: Capsule): CapsuleDict { parent_id: capsule.parent_id, sequence: capsule.sequence, previous_hash: capsule.previous_hash, + spec_version: capsule.spec_version, trigger: capsule.trigger, context: capsule.context, reasoning: capsule.reasoning, diff --git a/reference/typescript/src/index.ts b/reference/typescript/src/index.ts index e9e5394..8382292 100644 --- a/reference/typescript/src/index.ts +++ b/reference/typescript/src/index.ts @@ -39,6 +39,9 @@ export { computeHashFromDict, seal, verify, + verifyDetailed, + type SealVerificationResult, + type SealVerifyCode, generateKeyPair, getFingerprint, } from "./seal.js"; @@ -47,3 +50,9 @@ export { type ChainVerificationResult, verifyChain, } from "./chain.js"; + +export { + type CapsuleValidationResult, + type ValidateCapsuleDictOptions, + validateCapsuleDict, +} from "./validation.js"; diff --git a/reference/typescript/src/seal.ts b/reference/typescript/src/seal.ts index ee0be4b..a8c44a8 100644 --- a/reference/typescript/src/seal.ts +++ b/reference/typescript/src/seal.ts @@ -72,35 +72,120 @@ export async function seal( // Verify // --------------------------------------------------------------------------- +/** FR-003: structured verify outcome (aligns with Python `SealVerifyCode`). */ +export type SealVerifyCode = + | "ok" + | "missing_hash" + | "missing_signature" + | "malformed_hex" + | "hash_mismatch" + | "invalid_signature" + | "pq_verification_failed" + | "pq_library_unavailable" + | "unsupported_algorithm"; + +export interface SealVerificationResult { + ok: boolean; + code: SealVerifyCode; + message: string; +} + /** - * Verify a sealed Capsule's integrity and authenticity. + * Verify a sealed Capsule and return a structured result (FR-003). * * 1. Recompute SHA3-256 from content and compare to stored hash * 2. Verify Ed25519 signature over the hash string + * + * TS reference is classical-only; PQ codes are reserved for parity with Python. */ -export async function verify( +export async function verifyDetailed( capsule: Capsule, publicKey: Uint8Array, -): Promise { - if (!capsule.hash || !capsule.signature) { - return false; +): Promise { + if (!capsule.hash) { + return { + ok: false, + code: "missing_hash", + message: "capsule has no hash field", + }; + } + if (!capsule.signature) { + return { + ok: false, + code: "missing_signature", + message: "capsule has no signature field", + }; } + const _hashBytes = tryParseHex(capsule.hash, 32); + if (_hashBytes === null) { + return { + ok: false, + code: "malformed_hex", + message: "hash must be 64 hex chars (32 bytes)", + }; + } + const sigBytes = tryParseHex(capsule.signature, 64); + if (sigBytes === null) { + return { + ok: false, + code: "malformed_hex", + message: "signature must be 128 hex chars (64 bytes)", + }; + } + + let computedHash: string; try { const dict = toDict(capsule); - const computedHash = computeHash(dict); + computedHash = computeHash(dict); + } catch (e) { + return { + ok: false, + code: "hash_mismatch", + message: `could not compute content hash: ${String(e)}`, + }; + } - if (computedHash !== capsule.hash) { - return false; - } + if (computedHash.toLowerCase() !== capsule.hash.toLowerCase()) { + return { + ok: false, + code: "hash_mismatch", + message: "recomputed hash does not match stored hash", + }; + } - const hashBytes = new TextEncoder().encode(capsule.hash); - const signatureBytes = hexToBytes(capsule.signature); + const hashBytes = new TextEncoder().encode(capsule.hash); - return await ed25519.verifyAsync(signatureBytes, hashBytes, publicKey); - } catch { - return false; + try { + const valid = await ed25519.verifyAsync(sigBytes, hashBytes, publicKey); + if (!valid) { + return { + ok: false, + code: "invalid_signature", + message: "Ed25519 signature verification failed", + }; + } + } catch (e) { + return { + ok: false, + code: "invalid_signature", + message: `Ed25519 verification error: ${String(e)}`, + }; } + + return { ok: true, code: "ok", message: "" }; +} + +/** + * Verify a sealed Capsule's integrity and authenticity. + * + * Same as {@link verifyDetailed} but returns only success/failure. + */ +export async function verify( + capsule: Capsule, + publicKey: Uint8Array, +): Promise { + return (await verifyDetailed(capsule, publicKey)).ok; } // --------------------------------------------------------------------------- @@ -129,10 +214,16 @@ export async function getFingerprint(privateKey: Uint8Array): Promise { // Internal // --------------------------------------------------------------------------- -function hexToBytes(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2); +/** Parse hex to exactly `byteLength` bytes; return null if invalid. */ +function tryParseHex(hex: string, byteLength: number): Uint8Array | null { + if (hex.length !== byteLength * 2 || !/^[0-9a-fA-F]+$/.test(hex)) { + return null; + } + const out = new Uint8Array(byteLength); for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + const v = parseInt(hex.slice(i, i + 2), 16); + if (Number.isNaN(v)) return null; + out[i / 2] = v; } - return bytes; + return out; } diff --git a/reference/typescript/src/validation.ts b/reference/typescript/src/validation.ts new file mode 100644 index 0000000..f0639a0 --- /dev/null +++ b/reference/typescript/src/validation.ts @@ -0,0 +1,329 @@ +/** + * Runtime validation for CPS capsule content dictionaries (FR-002). + * + * @license Apache-2.0 + */ + +import { computeHashFromDict } from "./seal.js"; + +/** Result of {@link validateCapsuleDict}. */ +export interface CapsuleValidationResult { + ok: boolean; + category: string | null; + field: string | null; + message: string; +} + +const VALID_CAPSULE_TYPES = new Set([ + "agent", + "tool", + "system", + "kill", + "workflow", + "chat", + "vault", + "auth", +]); + +const ORDERED_TOP_LEVEL = [ + "id", + "type", + "domain", + "parent_id", + "sequence", + "previous_hash", + "spec_version", + "trigger", + "context", + "reasoning", + "authority", + "execution", + "outcome", +] as const; + +const REQUIRED_TOP_LEVEL = new Set(ORDERED_TOP_LEVEL); + +const TRIGGER_KEYS = new Set([ + "type", + "source", + "timestamp", + "request", + "correlation_id", + "user_id", +]); +const CONTEXT_KEYS = new Set(["agent_id", "session_id", "environment"]); +const REASONING_KEYS = new Set([ + "analysis", + "options", + "options_considered", + "selected_option", + "reasoning", + "confidence", + "model", + "prompt_hash", +]); +const AUTHORITY_KEYS = new Set([ + "type", + "approver", + "policy_reference", + "chain", + "escalation_reason", +]); +const EXECUTION_KEYS = new Set(["tool_calls", "duration_ms", "resources_used"]); +const OUTCOME_KEYS = new Set([ + "status", + "result", + "summary", + "error", + "side_effects", + "metrics", +]); + +function fail( + category: string, + field: string, + message: string, +): CapsuleValidationResult { + return { ok: false, category, field, message }; +} + +function success(): CapsuleValidationResult { + return { ok: true, category: null, field: null, message: "" }; +} + +function isUuidString(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); +} + +function isHex64Loose(s: string): boolean { + return s.length === 64 && /^[0-9a-f]+$/i.test(s); +} + +function hasIso8601Timezone(ts: string): boolean { + if (ts.endsWith("Z")) return !Number.isNaN(Date.parse(ts)); + return /[+-]\d{2}:\d{2}$/.test(ts) && !Number.isNaN(Date.parse(ts)); +} + +export interface ValidateCapsuleDictOptions { + /** If set, must match {@link computeHashFromDict} of *data*. */ + claimedHash?: string; + /** Reject unknown top-level keys. */ + strictUnknownKeys?: boolean; +} + +/** + * Validate a CPS capsule **content** object (pre-seal / `toDict` shape). + */ +export function validateCapsuleDict( + data: unknown, + options: ValidateCapsuleDictOptions = {}, +): CapsuleValidationResult { + const { claimedHash, strictUnknownKeys = false } = options; + + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return fail("wrong_type", "", "capsule root must be a JSON object"); + } + + const d = data as Record; + + if (strictUnknownKeys) { + const extra = Object.keys(d).filter((k) => !REQUIRED_TOP_LEVEL.has(k)); + extra.sort(); + if (extra.length > 0) { + const name = extra[0]!; + return fail("invalid_value", name, `unknown top-level key: '${name}'`); + } + } + + for (const key of ORDERED_TOP_LEVEL) { + if (!(key in d)) { + return fail("missing_field", key, `missing required field '${key}'`); + } + } + + if (typeof d["id"] !== "string" || !isUuidString(d["id"])) { + return fail( + typeof d["id"] === "string" ? "invalid_value" : "wrong_type", + "id", + "id must be a UUID string", + ); + } + + if (typeof d["type"] !== "string") { + return fail("wrong_type", "type", "type must be a string"); + } + if (!VALID_CAPSULE_TYPES.has(d["type"])) { + return fail("invalid_value", "type", `unknown capsule type '${String(d["type"])}'`); + } + + if (typeof d["domain"] !== "string") { + return fail("wrong_type", "domain", "domain must be a string"); + } + + const pid = d["parent_id"]; + if (pid !== null) { + if (typeof pid !== "string" || !isUuidString(pid)) { + return fail( + typeof pid === "string" ? "invalid_value" : "wrong_type", + "parent_id", + "parent_id must be null or a UUID string", + ); + } + } + + if (typeof d["sequence"] !== "number" || !Number.isInteger(d["sequence"])) { + return fail("wrong_type", "sequence", "sequence must be an integer"); + } + if ((d["sequence"] as number) < 0) { + return fail("invalid_value", "sequence", "sequence must be non-negative"); + } + + const ph = d["previous_hash"]; + if (ph !== null) { + if (typeof ph !== "string" || !isHex64Loose(ph)) { + return fail( + typeof ph === "string" ? "invalid_value" : "wrong_type", + "previous_hash", + "previous_hash must be null or a 64-character hex string", + ); + } + } + + const sv = d["spec_version"]; + if (typeof sv !== "string" || sv.length === 0) { + return fail( + typeof sv === "string" ? "invalid_value" : "wrong_type", + "spec_version", + "spec_version must be a non-empty string", + ); + } + + const seq = d["sequence"] as number; + if (seq === 0 && ph !== null) { + return fail( + "chain_violation", + "previous_hash", + "genesis capsule (sequence 0) must have previous_hash null", + ); + } + if (seq !== 0 && ph === null) { + return fail( + "chain_violation", + "previous_hash", + "non-genesis capsule must have previous_hash set", + ); + } + + const t = d["trigger"]; + if (typeof t !== "object" || t === null || Array.isArray(t)) { + return fail("wrong_type", "trigger", "trigger must be an object"); + } + const tr = t as Record; + for (const k of TRIGGER_KEYS) { + if (!(k in tr)) { + return fail("missing_field", `trigger.${k}`, `missing required field 'trigger.${k}'`); + } + } + if (tr["type"] === null || typeof tr["type"] !== "string") { + return fail("wrong_type", "trigger.type", "trigger.type must be a non-null string"); + } + if ( + typeof tr["timestamp"] !== "string" || + !hasIso8601Timezone(tr["timestamp"] as string) + ) { + return fail( + typeof tr["timestamp"] === "string" ? "invalid_value" : "wrong_type", + "trigger.timestamp", + "trigger.timestamp must be an ISO 8601 string with timezone", + ); + } + + const ctx = d["context"]; + if (typeof ctx !== "object" || ctx === null || Array.isArray(ctx)) { + return fail("wrong_type", "context", "context must be an object"); + } + const cx = ctx as Record; + for (const k of CONTEXT_KEYS) { + if (!(k in cx)) { + return fail("missing_field", `context.${k}`, `missing required field 'context.${k}'`); + } + } + if (typeof cx["environment"] !== "object" || cx["environment"] === null || Array.isArray(cx["environment"])) { + return fail("wrong_type", "context.environment", "context.environment must be an object"); + } + + const r = d["reasoning"]; + if (typeof r !== "object" || r === null || Array.isArray(r)) { + return fail("wrong_type", "reasoning", "reasoning must be an object"); + } + const rs = r as Record; + for (const k of REASONING_KEYS) { + if (!(k in rs)) { + return fail("missing_field", `reasoning.${k}`, `missing required field 'reasoning.${k}'`); + } + } + const confRaw = rs["confidence"]; + if (typeof confRaw === "boolean" || typeof confRaw !== "number") { + return fail("wrong_type", "reasoning.confidence", "reasoning.confidence must be a number"); + } + const c = confRaw as number; + if (c < 0 || c > 1) { + return fail("invalid_value", "reasoning.confidence", "reasoning.confidence must be between 0.0 and 1.0"); + } + + const a = d["authority"]; + if (typeof a !== "object" || a === null || Array.isArray(a)) { + return fail("wrong_type", "authority", "authority must be an object"); + } + const au = a as Record; + for (const k of AUTHORITY_KEYS) { + if (!(k in au)) { + return fail("missing_field", `authority.${k}`, `missing required field 'authority.${k}'`); + } + } + + const e = d["execution"]; + if (typeof e !== "object" || e === null || Array.isArray(e)) { + return fail("wrong_type", "execution", "execution must be an object"); + } + const ex = e as Record; + for (const k of EXECUTION_KEYS) { + if (!(k in ex)) { + return fail("missing_field", `execution.${k}`, `missing required field 'execution.${k}'`); + } + } + if (!Array.isArray(ex["tool_calls"])) { + return fail("wrong_type", "execution.tool_calls", "execution.tool_calls must be an array"); + } + if (typeof ex["duration_ms"] !== "number" || !Number.isInteger(ex["duration_ms"])) { + return fail("wrong_type", "execution.duration_ms", "execution.duration_ms must be an integer"); + } + const ru = ex["resources_used"]; + if (typeof ru !== "object" || ru === null || Array.isArray(ru)) { + return fail("wrong_type", "execution.resources_used", "execution.resources_used must be an object"); + } + + const o = d["outcome"]; + if (typeof o !== "object" || o === null || Array.isArray(o)) { + return fail("wrong_type", "outcome", "outcome must be an object"); + } + const ou = o as Record; + for (const k of OUTCOME_KEYS) { + if (!(k in ou)) { + return fail("missing_field", `outcome.${k}`, `missing required field 'outcome.${k}'`); + } + } + + if (claimedHash !== undefined) { + if (typeof claimedHash !== "string" || !isHex64Loose(claimedHash)) { + return fail("invalid_value", "hash", "claimed_hash must be a 64-character hex string"); + } + const computed = computeHashFromDict(d); + const want = claimedHash.toLowerCase(); + if (computed !== want) { + return fail("integrity_violation", "hash", "content hash does not match claimed_hash"); + } + } + + return success(); +} diff --git a/spec/README.md b/spec/README.md index 686a809..a763ca4 100644 --- a/spec/README.md +++ b/spec/README.md @@ -2,7 +2,7 @@ **Version**: 1.0 **Status**: Active -**Last Updated**: 2026-03-07 +**Last Updated**: 2026-03-23 --- @@ -26,6 +26,7 @@ A Capsule is a JSON object with the following top-level keys (all required): | `parent_id` | string \| null | Parent Capsule UUID for hierarchical linking (e.g., WORKFLOW → AGENT → TOOL), or null for top-level Capsules | | `sequence` | integer | Position in the hash chain, 0-indexed. Genesis Capsule is 0, each subsequent Capsule increments by 1. | | `previous_hash` | string \| null | SHA3-256 hex digest of the immediately preceding Capsule in the chain, or `null` for the genesis Capsule (sequence 0). This field creates the tamper-evident chain. | +| `spec_version` | string | CPS wire-format version for parse-time branching (e.g. `"1.0"`). **Included in the canonical content** hashed for SHA3-256. Parsers SHOULD treat records omitting this field as `"1.0"` for backward compatibility unless operating in strict mode. | | `trigger` | object | [Trigger Section](#11-trigger-section) | | `context` | object | [Context Section](#12-context-section) | | `reasoning` | object | [Reasoning Section](#13-reasoning-section) |