diff --git a/CHANGELOG.md b/CHANGELOG.md index ec0664e..b427f3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased corrective candidate +- Added opt-in ActionProposal, SettlementReceipt, and SettlementBundle v0.2 schemas that bound + ordered `gte` and `lte` thresholds to finite JavaScript binary64 numbers. + The v0.2 receipt signs its proposal schema version, and verification rejects + bundle, receipt, or audit-proposal version substitution. + Published v0.1 schema bytes and default v0.1 artifact hashes remain + unchanged. Existing v0.1 `eq` and finite numeric ordered clauses remain + compatible; non-numeric ordered thresholds or evidence now fail closed + instead of using JavaScript coercion. - The loopback sidecar now uses the host clock by default so current-time proposals are admissible, and accepts `--clock demo|system` plus `CONSEQUENCE_RAIL_PORT` / `CONSEQUENCE_RAIL_CLOCK` when flags are omitted. - ActionProposal and RecourseReservation parsers now reject unsafe, non-integral, and overflow-scale duration values so evidence freshness and remedy-window arithmetic stay exact. - HTTP sidecar failures now include a `request_id` (and the action id when the route named one). Unexpected exceptions are logged to stderr and returned as `INTERNAL_ERROR` instead of being swallowed as `REQUEST_INVALID`. diff --git a/README.md b/README.md index b51d618..8c2a58f 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,11 @@ OpenAPI clients can send current timestamps. Pass `--clock demo` to freeze time at the conformance epoch `2035-01-01T00:00:00.000Z`. When flags are omitted, `CONSEQUENCE_RAIL_PORT` and `CONSEQUENCE_RAIL_CLOCK` supply the same defaults. Read [`api/openapi.json`](api/openapi.json) for the request surface. +The sidecar accepts the immutable ActionProposal v0.1 format and the opt-in +v0.2 format for schema-bounded numeric `gte` and `lte` clauses. A v0.2 +proposal exports a v0.2 settlement bundle and a signed v0.2 receipt that binds +the proposal schema version even in the proposal-free receipt profile. Default +v0.1 demo artifacts remain unchanged. The v0.1 sidecar stores bounded state in memory and exposes only the synthetic connector. It accepts only loopback clients and same-origin loopback Host and Origin values, caps JSON bodies at 65,536 bytes, restricts content types and diff --git a/api/openapi.json b/api/openapi.json index fd9c8cf..1f2387d 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -55,7 +55,10 @@ "content": { "application/json": { "schema": { - "$ref": "../spec/schemas/action-proposal.schema.json" + "oneOf": [ + { "$ref": "../spec/schemas/action-proposal.schema.json" }, + { "$ref": "../spec/schemas/action-proposal-v0.2.schema.json" } + ] } } } @@ -376,7 +379,10 @@ "content": { "application/json": { "schema": { - "$ref": "../spec/schemas/settlement-bundle.schema.json" + "oneOf": [ + { "$ref": "../spec/schemas/settlement-bundle.schema.json" }, + { "$ref": "../spec/schemas/settlement-bundle-v0.2.schema.json" } + ] } } } @@ -396,7 +402,10 @@ "content": { "application/json": { "schema": { - "$ref": "../spec/schemas/settlement-bundle.schema.json" + "oneOf": [ + { "$ref": "../spec/schemas/settlement-bundle.schema.json" }, + { "$ref": "../spec/schemas/settlement-bundle-v0.2.schema.json" } + ] } } } diff --git a/conformance/README.md b/conformance/README.md index 964d505..cfd067c 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -8,6 +8,10 @@ conforming implementation must accept it, produce the same action digest for any semantically identical object-key ordering, and reject any unknown top-level field. +`refund-action-v0.2.json` is the ordered-comparison example. It uses both +numeric `gte` and `lte` clauses under the bounded v0.2 proposal schema while +the original v0.1 fixture remains unchanged. + `refund-recovery-contract.json` is the canonical Recovery Preflight contract. It pins a synthetic duplicate-refund fault, the actual mock connector remedy, an exact declared-state oracle, the signed reservation and connector diff --git a/conformance/refund-action-v0.2.json b/conformance/refund-action-v0.2.json new file mode 100644 index 0000000..3b3093e --- /dev/null +++ b/conformance/refund-action-v0.2.json @@ -0,0 +1,40 @@ +{ + "schema_version": "consequence-rail/action-proposal/v0.2", + "action_type": "demo.refund.issue/v1", + "subject": { + "type": "service", + "id": "support-agent-demo" + }, + "target": { + "connector": "mock-refund-processor", + "resource_type": "order", + "resource_id": "ord_demo_42" + }, + "parameters": { + "amount_minor": 12000, + "currency": "USD" + }, + "idempotency_key": "refund:ord_demo_42:ordered:1", + "requested_at": "2035-01-01T00:00:00.000Z", + "expires_at": "2035-01-01T00:02:00.000Z", + "assurance_mode": "enforced", + "postcondition": { + "op": "all", + "clauses": [ + { + "path": "active_refund_count", + "op": "gte", + "value": 1 + }, + { + "path": "net_refunded_minor", + "op": "lte", + "value": 12000 + } + ] + }, + "evidence_plan": { + "source": "mock-refund-processor", + "max_age_seconds": 60 + } +} diff --git a/scripts/check.js b/scripts/check.js index b48f7e8..dfebadb 100644 --- a/scripts/check.js +++ b/scripts/check.js @@ -14,12 +14,16 @@ const required = [ "api/openapi.json", "spec/model.md", "spec/state-machine.md", + "spec/schemas/action-proposal-v0.2.schema.json", "spec/schemas/connector-recourse-commitment.schema.json", "spec/schemas/recovery-contract.schema.json", "spec/schemas/recovery-drill-attestation.schema.json", "spec/schemas/recovery-drill-bundle.schema.json", + "spec/schemas/settlement-bundle-v0.2.schema.json", + "spec/schemas/settlement-receipt-v0.2.schema.json", "docs/threat-model.md", "conformance/refund-action.json", + "conformance/refund-action-v0.2.json", "conformance/refund-recovery-contract.json", ]; const textExtensions = new Set([".md", ".json", ".js", ".yaml", ".yml", ".txt"]); diff --git a/spec/model.md b/spec/model.md index 982420f..2cef239 100644 --- a/spec/model.md +++ b/spec/model.md @@ -19,6 +19,19 @@ external action. It includes: - data-only postcondition - declared evidence source and freshness window +Postconditions support strict `eq` and numeric `gte` and `lte` clauses. The +published ActionProposal v0.1 schema remains immutable. ActionProposal v0.2 +formally requires each ordered threshold to be a finite JavaScript binary64 +number from `-1.7976931348623157e+308` to `1.7976931348623157e+308`, inclusive. +The reference runtime applies that no-coercion rule to both proposal versions: +non-numeric evidence does not satisfy an ordered clause, and a non-numeric +ordered threshold is rejected. Existing v0.1 `eq` proposals retain their +strict-equality behavior. A v0.2 proposal is carried by a v0.2 settlement +bundle so the immutable v0.1 bundle schema continues to reference only the +v0.1 proposal schema. Its v0.2 settlement receipt signs the exact v0.2 +proposal schema identifier, so receipt-profile bundles remain version-bound +even though they omit the proposal. + The `action_digest` is the SHA-256 digest, encoded as unpadded base64url (43 characters), of the proposal's canonical JSON bytes. Artifact fields named as digests, event hashes, and evidence manifests use that same @@ -178,6 +191,7 @@ produce a settlement receipt. The receipt binds: +- proposal schema version in SettlementReceipt v0.2 - action digest - reservation digest - connector commitment digest and final reservation status @@ -187,6 +201,11 @@ The receipt binds: - event-chain head - close time +Published SettlementReceipt v0.1 bytes remain immutable. A v0.1 bundle MUST +carry a v0.1 receipt. A v0.2 bundle MUST carry a v0.2 receipt whose signed +`proposal_schema_version` is `consequence-rail/action-proposal/v0.2`. Verifiers +reject bundle, receipt, and included audit-proposal version substitutions. + `settlement` is a technical protocol term. It does not mean legal settlement, financial finality, insurance coverage, or guaranteed recovery. diff --git a/spec/schemas/action-proposal-v0.2.schema.json b/spec/schemas/action-proposal-v0.2.schema.json new file mode 100644 index 0000000..7ff4067 --- /dev/null +++ b/spec/schemas/action-proposal-v0.2.schema.json @@ -0,0 +1,221 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:consequence-rail:schema:action-proposal:v0.2", + "title": "ActionProposal", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "action_type", + "subject", + "target", + "parameters", + "idempotency_key", + "requested_at", + "expires_at", + "assurance_mode", + "postcondition", + "evidence_plan" + ], + "properties": { + "schema_version": { + "const": "consequence-rail/action-proposal/v0.2" + }, + "action_type": { + "enum": [ + "demo.refund.issue/v1", + "demo.email.send/v1" + ] + }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "minLength": 1 + }, + "id": { + "type": "string", + "minLength": 1 + } + } + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": [ + "connector", + "resource_type", + "resource_id" + ], + "properties": { + "connector": { + "type": "string", + "minLength": 1 + }, + "resource_type": { + "type": "string", + "minLength": 1 + }, + "resource_id": { + "type": "string", + "minLength": 1 + } + } + }, + "parameters": { + "type": "object", + "additionalProperties": true + }, + "idempotency_key": { + "type": "string", + "minLength": 1 + }, + "requested_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "assurance_mode": { + "enum": [ + "enforced", + "cooperative", + "observed" + ] + }, + "postcondition": { + "$ref": "#/$defs/postcondition" + }, + "evidence_plan": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "max_age_seconds" + ], + "properties": { + "source": { + "type": "string", + "minLength": 1 + }, + "max_age_seconds": { + "type": "integer", + "minimum": 1 + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "action_type": { "const": "demo.refund.issue/v1" } + } + }, + "then": { + "properties": { + "parameters": { + "type": "object", + "additionalProperties": false, + "required": ["amount_minor", "currency"], + "properties": { + "amount_minor": { "type": "integer", "minimum": 1 }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" } + } + } + } + } + }, + { + "if": { + "properties": { + "action_type": { "const": "demo.email.send/v1" } + } + }, + "then": { + "properties": { + "parameters": { + "type": "object", + "additionalProperties": false, + "required": ["recipient_id", "subject"], + "properties": { + "recipient_id": { "type": "string", "minLength": 1 }, + "subject": { "type": "string", "minLength": 1 } + } + } + } + } + } + ], + "$defs": { + "postcondition": { + "type": "object", + "additionalProperties": false, + "required": [ + "op", + "clauses" + ], + "properties": { + "op": { + "const": "all" + }, + "clauses": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "op", + "value" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "op": { + "enum": [ + "eq", + "gte", + "lte" + ] + }, + "value": {} + }, + "allOf": [ + { + "if": { + "properties": { + "op": { + "enum": ["gte", "lte"] + } + }, + "required": ["op"] + }, + "then": { + "properties": { + "value": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + } + } + } + ] + } + } + } + } + } +} diff --git a/spec/schemas/settlement-bundle-v0.2.schema.json b/spec/schemas/settlement-bundle-v0.2.schema.json new file mode 100644 index 0000000..c4ab895 --- /dev/null +++ b/spec/schemas/settlement-bundle-v0.2.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:consequence-rail:schema:settlement-bundle:v0.2", + "title": "SettlementBundle", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "profile", + "action", + "recourse_reservation", + "action_permit", + "evidence_manifest", + "outcome_evidence", + "settlement_receipt", + "events", + "trust_hint" + ], + "properties": { + "schema_version": { + "const": "consequence-rail/settlement-bundle/v0.2" + }, + "profile": { + "enum": [ + "receipt", + "audit" + ] + }, + "action": { + "type": "object", + "additionalProperties": false, + "required": [ + "action_id", + "action_digest", + "action_type", + "resource_id_digest" + ], + "properties": { + "action_id": { "type": "string", "minLength": 1 }, + "action_digest": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" }, + "action_type": { "type": "string", "minLength": 1 }, + "resource_id_digest": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" }, + "proposal": { "$ref": "action-proposal-v0.2.schema.json" } + } + }, + "recourse_reservation": { + "$ref": "recourse-reservation.schema.json" + }, + "action_permit": { + "$ref": "action-permit.schema.json" + }, + "evidence_manifest": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + } + }, + "outcome_evidence": { + "type": "array", + "items": { + "$ref": "outcome-evidence.schema.json" + } + }, + "settlement_receipt": { + "$ref": "settlement-receipt-v0.2.schema.json" + }, + "events": { + "type": "array", + "items": { + "$ref": "event.schema.json" + } + }, + "trust_hint": { + "type": "object", + "additionalProperties": false, + "required": ["rail", "connector", "warning"], + "properties": { + "rail": { + "type": "object", + "additionalProperties": false, + "required": ["key_id", "public_key_pem"], + "properties": { + "key_id": { "type": "string", "minLength": 1 }, + "public_key_pem": { "type": "string", "minLength": 1 } + } + }, + "connector": { + "type": "object", + "additionalProperties": false, + "required": ["key_id", "public_key_pem"], + "properties": { + "key_id": { "type": "string", "minLength": 1 }, + "public_key_pem": { "type": ["string", "null"] } + } + }, + "warning": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/spec/schemas/settlement-receipt-v0.2.schema.json b/spec/schemas/settlement-receipt-v0.2.schema.json new file mode 100644 index 0000000..496e5e1 --- /dev/null +++ b/spec/schemas/settlement-receipt-v0.2.schema.json @@ -0,0 +1,137 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:consequence-rail:schema:settlement-receipt:v0.2", + "title": "SettlementReceipt", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "proposal_schema_version", + "receipt_id", + "action_id", + "action_digest", + "recourse_reservation_digest", + "connector_recourse_commitment_digest", + "recourse_final_status", + "action_permit_digest", + "assurance_mode", + "bypass_possible", + "gated", + "outcome", + "configured_postcondition_result", + "evidence_digests", + "event_chain_head", + "closed_at", + "technical_claim", + "limitations", + "signature" + ], + "properties": { + "schema_version": { + "const": "consequence-rail/settlement-receipt/v0.2" + }, + "proposal_schema_version": { + "const": "consequence-rail/action-proposal/v0.2" + }, + "receipt_id": { + "type": "string" + }, + "action_id": { + "type": "string" + }, + "action_digest": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "recourse_reservation_digest": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "connector_recourse_commitment_digest": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "recourse_final_status": { + "enum": [ + "active", + "expired", + "released", + "consumed" + ] + }, + "action_permit_digest": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "assurance_mode": { + "enum": [ + "enforced", + "cooperative" + ] + }, + "bypass_possible": { + "type": "boolean" + }, + "gated": { + "const": true + }, + "outcome": { + "enum": [ + "settled", + "compensated", + "disputed" + ] + }, + "configured_postcondition_result": { + "enum": [ + "satisfied", + "unresolved" + ] + }, + "evidence_digests": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + } + }, + "event_chain_head": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "closed_at": { + "type": "string", + "format": "date-time" + }, + "technical_claim": { + "const": "The configured postcondition was evaluated against declared evidence sources." + }, + "limitations": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "prefixItems": [ + { + "const": "Signatures establish integrity and provenance, not truth or causality." + }, + { + "const": "Settlement is a technical protocol status, not a legal or financial determination." + }, + { + "const": "Recovery is not guaranteed." + } + ], + "items": false + }, + "signature": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "key_id", "value"], + "properties": { + "algorithm": { "const": "Ed25519" }, + "key_id": { "type": "string", "minLength": 1 }, + "value": { "type": "string", "pattern": "^[A-Za-z0-9_-]{86}$" } + } + } + } +} diff --git a/src/bundle-validation.js b/src/bundle-validation.js index b950dbe..4dad252 100644 --- a/src/bundle-validation.js +++ b/src/bundle-validation.js @@ -4,6 +4,20 @@ import { RailError } from "./errors.js"; const SHA256_BASE64URL = /^[A-Za-z0-9_-]{43}$/; const ED25519_BASE64URL = /^[A-Za-z0-9_-]{86}$/; const MAX_DURATION_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1_000); +const PROPOSAL_VERSIONS = new Set([ + "consequence-rail/action-proposal/v0.1", + "consequence-rail/action-proposal/v0.2", +]); +const BUNDLE_VERSION_BINDINGS = new Map([ + ["consequence-rail/settlement-bundle/v0.1", { + proposal: "consequence-rail/action-proposal/v0.1", + receipt: "consequence-rail/settlement-receipt/v0.1", + }], + ["consequence-rail/settlement-bundle/v0.2", { + proposal: "consequence-rail/action-proposal/v0.2", + receipt: "consequence-rail/settlement-receipt/v0.2", + }], +]); const BUNDLE_FIELDS = new Set([ "schema_version", @@ -138,6 +152,10 @@ const RECEIPT_FIELDS = new Set([ "limitations", "signature", ]); +const RECEIPT_V2_FIELDS = new Set([ + ...RECEIPT_FIELDS, + "proposal_schema_version", +]); const EVENT_FIELDS = new Set([ "schema_version", "action_id", @@ -235,6 +253,13 @@ function signature(value, label) { } } +function finiteBinary64(value) { + return typeof value === "number" && + Number.isFinite(value) && + value >= -Number.MAX_VALUE && + value <= Number.MAX_VALUE; +} + function postcondition(value) { exactObject(value, POSTCONDITION_FIELDS, POSTCONDITION_FIELDS, "ActionProposal.postcondition"); if (value.op !== "all" || !Array.isArray(value.clauses) || value.clauses.length === 0) { @@ -247,14 +272,23 @@ function postcondition(value) { if (!new Set(["eq", "gte", "lte"]).has(clause.op)) { invalid(`${label}.op is unsupported.`); } + if ( + (clause.op === "gte" || clause.op === "lte") && + !finiteBinary64(clause.value) + ) { + invalid(`${label}.value must be a finite number for ordered comparison.`); + } }); } -function proposal(value) { +function proposal(value, expectedVersion) { exactObject(value, PROPOSAL_FIELDS, PROPOSAL_FIELDS, "ActionProposal"); - if (value.schema_version !== "consequence-rail/action-proposal/v0.1") { + if (!PROPOSAL_VERSIONS.has(value.schema_version)) { invalid("ActionProposal schema version is unsupported."); } + if (expectedVersion && value.schema_version !== expectedVersion) { + invalid("ActionProposal schema version does not match the settlement bundle."); + } if (!new Set(["demo.refund.issue/v1", "demo.email.send/v1"]).has(value.action_type)) { invalid("ActionProposal action type is unsupported."); } @@ -308,13 +342,13 @@ function proposal(value) { ); } -function action(value) { +function action(value, expectedProposalVersion) { exactObject(value, ACTION_FIELDS, ACTION_REQUIRED_FIELDS, "SettlementBundle.action"); string(value.action_id, "SettlementBundle.action.action_id", { nonempty: true }); digestString(value.action_digest, "SettlementBundle.action.action_digest"); string(value.action_type, "SettlementBundle.action.action_type", { nonempty: true }); digestString(value.resource_id_digest, "SettlementBundle.action.resource_id_digest"); - if (Object.hasOwn(value, "proposal")) proposal(value.proposal); + if (Object.hasOwn(value, "proposal")) proposal(value.proposal, expectedProposalVersion); } function commitment(value) { @@ -422,10 +456,19 @@ function evidence(value, index) { signature(value.signature, `${label}.signature`); } -function receipt(value) { - exactObject(value, RECEIPT_FIELDS, RECEIPT_FIELDS, "SettlementReceipt"); - if (value.schema_version !== "consequence-rail/settlement-receipt/v0.1") { - invalid("SettlementReceipt schema version is unsupported."); +function receipt(value, versionBinding) { + const fields = versionBinding.receipt === "consequence-rail/settlement-receipt/v0.2" + ? RECEIPT_V2_FIELDS + : RECEIPT_FIELDS; + exactObject(value, fields, fields, "SettlementReceipt"); + if (value.schema_version !== versionBinding.receipt) { + invalid("SettlementReceipt schema version does not match the settlement bundle."); + } + if ( + versionBinding.receipt === "consequence-rail/settlement-receipt/v0.2" && + value.proposal_schema_version !== versionBinding.proposal + ) { + invalid("SettlementReceipt proposal schema version does not match the settlement bundle."); } for (const field of ["receipt_id", "action_id", "technical_claim"]) { string(value[field], `SettlementReceipt.${field}`, { nonempty: true }); @@ -517,13 +560,14 @@ export function validateSettlementBundle(bundle) { }); } exactObject(bundle, BUNDLE_FIELDS, BUNDLE_FIELDS, "SettlementBundle"); - if (bundle.schema_version !== "consequence-rail/settlement-bundle/v0.1") { + const versionBinding = BUNDLE_VERSION_BINDINGS.get(bundle.schema_version); + if (!versionBinding) { invalid("SettlementBundle schema version is unsupported."); } if (!new Set(["receipt", "audit"]).has(bundle.profile)) { invalid("SettlementBundle profile is unsupported."); } - action(bundle.action); + action(bundle.action, versionBinding.proposal); reservation(bundle.recourse_reservation); permit(bundle.action_permit); stringArray(bundle.evidence_manifest, "SettlementBundle.evidence_manifest", { digests: true }); @@ -531,7 +575,7 @@ export function validateSettlementBundle(bundle) { invalid("SettlementBundle.outcome_evidence must be an array."); } bundle.outcome_evidence.forEach(evidence); - receipt(bundle.settlement_receipt); + receipt(bundle.settlement_receipt, versionBinding); if (!Array.isArray(bundle.events)) invalid("SettlementBundle.events must be an array."); bundle.events.forEach(event); trustHint(bundle.trust_hint); diff --git a/src/postconditions.js b/src/postconditions.js index cd88465..536705b 100644 --- a/src/postconditions.js +++ b/src/postconditions.js @@ -1,11 +1,21 @@ import { RailError } from "./errors.js"; +function isFiniteBinary64(value) { + return typeof value === "number" && + Number.isFinite(value) && + value >= -Number.MAX_VALUE && + value <= Number.MAX_VALUE; +} + const OPERATORS = { eq: (actual, expected) => actual === expected, - gte: (actual, expected) => actual >= expected, - lte: (actual, expected) => actual <= expected, + gte: (actual, expected) => + isFiniteBinary64(actual) && actual >= expected, + lte: (actual, expected) => + isFiniteBinary64(actual) && actual <= expected, }; +const ORDERED_OPERATORS = new Set(["gte", "lte"]); const RESERVED_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]); const POSTCONDITION_FIELDS = new Set(["op", "clauses"]); const CLAUSE_FIELDS = new Set(["path", "op", "value"]); @@ -58,6 +68,15 @@ export function evaluatePostcondition(postcondition, evidence) { if (typeof operatorName !== "string" || !Object.hasOwn(OPERATORS, operatorName)) { throw new RailError("POSTCONDITION_INVALID", "Only eq, gte, and lte operators are supported."); } + if ( + ORDERED_OPERATORS.has(operatorName) && + !isFiniteBinary64(clause.value) + ) { + throw new RailError( + "POSTCONDITION_INVALID", + "Ordered postcondition values must be finite numbers.", + ); + } const operator = OPERATORS[operatorName]; const actual = readPath(evidence?.facts, clause.path); return { diff --git a/src/rail.js b/src/rail.js index fb56608..14c2b9c 100644 --- a/src/rail.js +++ b/src/rail.js @@ -7,6 +7,18 @@ import { verifyRecoveryPreflight } from "./recovery-preflight.js"; import { signArtifact, verifyArtifact } from "./signing.js"; export const ASSURANCE_MODES = ["enforced", "cooperative", "observed"]; +const ACTION_PROPOSAL_VERSIONS = new Set([ + "consequence-rail/action-proposal/v0.1", + "consequence-rail/action-proposal/v0.2", +]); +const BUNDLE_VERSION_BY_PROPOSAL = new Map([ + ["consequence-rail/action-proposal/v0.1", "consequence-rail/settlement-bundle/v0.1"], + ["consequence-rail/action-proposal/v0.2", "consequence-rail/settlement-bundle/v0.2"], +]); +const RECEIPT_VERSION_BY_PROPOSAL = new Map([ + ["consequence-rail/action-proposal/v0.1", "consequence-rail/settlement-receipt/v0.1"], + ["consequence-rail/action-proposal/v0.2", "consequence-rail/settlement-receipt/v0.2"], +]); export const ALLOWED_TRANSITIONS = { PROPOSED: ["AUTHORIZED", "DENIED"], @@ -960,7 +972,7 @@ export class ConsequenceRail { "Bundle profile must be receipt or audit.", ); return deepClone({ - schema_version: "consequence-rail/settlement-bundle/v0.1", + schema_version: BUNDLE_VERSION_BY_PROPOSAL.get(record.proposal.schema_version), profile: auditProfile ? "audit" : "receipt", action: { action_id: actionId, @@ -1182,8 +1194,14 @@ export class ConsequenceRail { outcome, }); const events = this.eventStore.list(record.action_id); + const receiptSchemaVersion = RECEIPT_VERSION_BY_PROPOSAL.get( + record.proposal.schema_version, + ); const receiptBody = { - schema_version: "consequence-rail/settlement-receipt/v0.1", + schema_version: receiptSchemaVersion, + ...(receiptSchemaVersion === "consequence-rail/settlement-receipt/v0.2" + ? { proposal_schema_version: record.proposal.schema_version } + : {}), receipt_id: `receipt_${digest({ action_id: record.action_id, event_chain_head: events.at(-1).event_hash, @@ -1217,7 +1235,7 @@ export class ConsequenceRail { validateProposal(input) { assertExactFields(input, PROPOSAL_FIELDS, "ActionProposal"); assert( - input.schema_version === "consequence-rail/action-proposal/v0.1", + ACTION_PROPOSAL_VERSIONS.has(input.schema_version), "SCHEMA_INVALID", "Unsupported ActionProposal schema version.", ); diff --git a/src/verify.js b/src/verify.js index 90dc825..20a45b0 100644 --- a/src/verify.js +++ b/src/verify.js @@ -6,6 +6,17 @@ import { evaluatePostcondition } from "./postconditions.js"; import { ALLOWED_TRANSITIONS } from "./rail.js"; import { verifyArtifact } from "./signing.js"; +const SETTLEMENT_VERSION_BINDINGS = new Map([ + ["consequence-rail/settlement-bundle/v0.1", { + proposal: "consequence-rail/action-proposal/v0.1", + receipt: "consequence-rail/settlement-receipt/v0.1", + }], + ["consequence-rail/settlement-bundle/v0.2", { + proposal: "consequence-rail/action-proposal/v0.2", + receipt: "consequence-rail/settlement-receipt/v0.2", + }], +]); + function integrityAssert(condition, message, details = {}) { if (!condition) { throw new RailError("BUNDLE_TAMPERED", message, details); @@ -53,8 +64,9 @@ export function verifyBundle( } = {}, ) { validateSettlementBundle(bundle); + const versionBinding = SETTLEMENT_VERSION_BINDINGS.get(bundle?.schema_version); integrityAssert( - bundle?.schema_version === "consequence-rail/settlement-bundle/v0.1", + versionBinding, "Unsupported settlement bundle version.", ); integrityAssert( @@ -77,6 +89,17 @@ export function verifyBundle( const receipt = bundle.settlement_receipt; const evidenceManifest = bundle.evidence_manifest ?? []; + integrityAssert( + receipt.schema_version === versionBinding.receipt, + "Receipt schema version does not match the bundle.", + ); + if (versionBinding.receipt === "consequence-rail/settlement-receipt/v0.2") { + integrityAssert( + receipt.proposal_schema_version === versionBinding.proposal, + "Signed receipt proposal version does not match the bundle.", + ); + } + integrityAssert(permit.action_id === action.action_id, "Permit action id does not match the bundle."); integrityAssert( permit.action_digest === action.action_digest, @@ -135,6 +158,10 @@ export function verifyBundle( ); if (action.proposal) { + integrityAssert( + action.proposal.schema_version === versionBinding.proposal, + "Included proposal schema version does not match the bundle.", + ); integrityAssert( digest(action.proposal) === action.action_digest, "Included proposal does not match the action digest.", diff --git a/test/postconditions-v0.2.test.js b/test/postconditions-v0.2.test.js new file mode 100644 index 0000000..21056d9 --- /dev/null +++ b/test/postconditions-v0.2.test.js @@ -0,0 +1,479 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { once } from "node:events"; +import { join } from "node:path"; +import test from "node:test"; +import { deepClone, digest } from "../src/canonical.js"; +import { + buildRefundProposal, + buildRefundReservation, + createDemoRuntime, + runRefundDemo, +} from "../src/demo.js"; +import { createReferenceServer } from "../src/http-server.js"; +import { evaluatePostcondition } from "../src/postconditions.js"; +import { demoConnectorTrustedKeys, demoTrustedKeys } from "../src/signing.js"; +import { verifyBundle } from "../src/verify.js"; + +const ORDERED_OPERATORS = ["gte", "lte"]; +const PROPOSAL_VERSIONS = [ + "consequence-rail/action-proposal/v0.1", + "consequence-rail/action-proposal/v0.2", +]; + +let coercionCalls = 0; + +const INVALID_VALUES = [ + ["numeric string", () => "1"], + ["non-numeric string", () => "one"], + ["true", () => true], + ["false", () => false], + ["null", () => null], + ["undefined", () => undefined], + ["array", () => [1]], + ["plain object", () => ({ value: 1 })], + ["boxed number", () => new Number(1)], + ["date", () => new Date(0)], + ["bigint", () => 1n], + ["symbol", () => Symbol("one")], + ["function", () => () => 1], + ["NaN", () => Number.NaN], + ["positive infinity", () => Number.POSITIVE_INFINITY], + ["negative infinity", () => Number.NEGATIVE_INFINITY], + ["coercible object", () => ({ + [Symbol.toPrimitive]() { + coercionCalls += 1; + return 1; + }, + })], +]; + +function orderedPostcondition(operator, value) { + return { + op: "all", + clauses: [{ path: "active_refund_count", op: operator, value }], + }; +} + +function orderedProposal(runtime, version, operator, value) { + const proposal = buildRefundProposal(runtime.clock); + proposal.schema_version = version; + proposal.postcondition = orderedPostcondition(operator, value); + return proposal; +} + +function snapshotProposal(proposal) { + return { + ...proposal, + subject: { ...proposal.subject }, + target: { ...proposal.target }, + parameters: { ...proposal.parameters }, + postcondition: { + ...proposal.postcondition, + clauses: proposal.postcondition.clauses.map((clause) => ({ ...clause })), + }, + evidence_plan: { ...proposal.evidence_plan }, + }; +} + +async function createV2AuditBundle() { + const runtime = createDemoRuntime(); + const proposal = JSON.parse(readFileSync( + join(process.cwd(), "conformance", "refund-action-v0.2.json"), + "utf8", + )); + const proposed = runtime.rail.propose(proposal); + runtime.rail.authorize(proposed.action_id, { + allow: true, + policy_id: "demo-refund-policy/v1", + policy_digest: digest({ allow: true, ordered_postconditions: true }), + }); + runtime.rail.reserveRecourse( + proposed.action_id, + buildRefundReservation(proposed.action_digest, proposal, runtime.clock), + ); + runtime.rail.issuePermit(proposed.action_id); + await runtime.rail.execute(proposed.action_id); + await runtime.rail.verifyOutcome(proposed.action_id); + return { + runtime, + bundle: runtime.rail.exportBundle(proposed.action_id, { profile: "audit" }), + receiptBundle: runtime.rail.exportBundle(proposed.action_id), + }; +} + +test("ordered threshold validation rejects every non-finite or non-number type", () => { + coercionCalls = 0; + for (const operator of ORDERED_OPERATORS) { + for (const [name, makeValue] of INVALID_VALUES) { + const value = makeValue(); + assert.throws( + () => evaluatePostcondition( + orderedPostcondition(operator, value), + { facts: { active_refund_count: 1 } }, + ), + (error) => error.code === "POSTCONDITION_INVALID", + `${operator} must reject threshold type ${name}.`, + ); + } + } + assert.equal(coercionCalls, 0); +}); + +test("ordered evidence validation never coerces invalid evidence types", () => { + coercionCalls = 0; + for (const operator of ORDERED_OPERATORS) { + for (const [name, makeValue] of INVALID_VALUES) { + const actual = makeValue(); + const result = evaluatePostcondition( + orderedPostcondition(operator, 1), + { facts: { active_refund_count: actual } }, + ); + assert.equal( + result.satisfied, + false, + `${operator} must not satisfy evidence type ${name}.`, + ); + assert.strictEqual(result.evaluations[0].actual, actual); + } + } + assert.equal(coercionCalls, 0); +}); + +test("Rail.propose rejects the full threshold matrix without mutation", () => { + coercionCalls = 0; + for (const version of PROPOSAL_VERSIONS) { + for (const operator of ORDERED_OPERATORS) { + for (const [name, makeValue] of INVALID_VALUES) { + const runtime = createDemoRuntime(); + const value = makeValue(); + const proposal = orderedProposal(runtime, version, operator, value); + const before = snapshotProposal(proposal); + assert.throws( + () => runtime.rail.propose(proposal), + (error) => error.code === "POSTCONDITION_INVALID", + `${version} ${operator} must reject threshold type ${name}.`, + ); + assert.deepEqual(proposal, before); + assert.equal(runtime.rail.actions.size, 0); + assert.equal(runtime.connector.reserveRecourseCalls, 0); + assert.equal(runtime.connector.executeCalls, 0); + assert.equal(runtime.connector.refunds.length, 0); + } + } + } + assert.equal(coercionCalls, 0); +}); + +test("finite binary64 boundaries are accepted by both ordered operators", () => { + for (const [operator, threshold, actual] of [ + ["gte", Number.MAX_VALUE, Number.MAX_VALUE], + ["gte", -Number.MAX_VALUE, -Number.MAX_VALUE], + ["lte", Number.MAX_VALUE, Number.MAX_VALUE], + ["lte", -Number.MAX_VALUE, -Number.MAX_VALUE], + ]) { + const evaluation = evaluatePostcondition( + orderedPostcondition(operator, threshold), + { facts: { active_refund_count: actual } }, + ); + assert.equal(evaluation.satisfied, true); + + for (const version of PROPOSAL_VERSIONS) { + const runtime = createDemoRuntime(); + const proposal = orderedProposal(runtime, version, operator, threshold); + const before = snapshotProposal(proposal); + const admitted = runtime.rail.propose(proposal); + assert.equal(admitted.state, "PROPOSED"); + assert.deepEqual(proposal, before); + } + } +}); + +test("strict eq behavior remains compatible across proposal versions", () => { + assert.equal(evaluatePostcondition({ + op: "all", + clauses: [{ path: "status", op: "eq", value: "complete" }], + }, { facts: { status: "complete" } }).satisfied, true); + assert.equal(evaluatePostcondition({ + op: "all", + clauses: [{ path: "status", op: "eq", value: "1" }], + }, { facts: { status: 1 } }).satisfied, false); + + for (const version of PROPOSAL_VERSIONS) { + const runtime = createDemoRuntime(); + const proposal = buildRefundProposal(runtime.clock); + proposal.schema_version = version; + proposal.postcondition = { + op: "all", + clauses: [{ path: "status", op: "eq", value: "complete" }], + }; + assert.equal(runtime.rail.propose(proposal).state, "PROPOSED"); + } +}); + +test("v0.1 remains immutable while v0.2 schemas bind bounds and receipt versions", () => { + const schemas = join(process.cwd(), "spec", "schemas"); + const v1 = JSON.parse(readFileSync(join(schemas, "action-proposal.schema.json"), "utf8")); + const v2 = JSON.parse(readFileSync( + join(schemas, "action-proposal-v0.2.schema.json"), + "utf8", + )); + const bundleV2 = JSON.parse(readFileSync( + join(schemas, "settlement-bundle-v0.2.schema.json"), + "utf8", + )); + const receiptV1 = JSON.parse(readFileSync( + join(schemas, "settlement-receipt.schema.json"), + "utf8", + )); + const receiptV2 = JSON.parse(readFileSync( + join(schemas, "settlement-receipt-v0.2.schema.json"), + "utf8", + )); + const openapi = JSON.parse(readFileSync(join(process.cwd(), "api", "openapi.json"), "utf8")); + const v1Clause = v1.$defs.postcondition.properties.clauses.items; + const v2Clause = v2.$defs.postcondition.properties.clauses.items; + + assert.equal(v1.$id, "urn:consequence-rail:schema:action-proposal:v0.1"); + assert.equal( + v1.properties.schema_version.const, + "consequence-rail/action-proposal/v0.1", + ); + assert.deepEqual(v1Clause.properties.value, {}); + assert.equal(Object.hasOwn(v1Clause, "allOf"), false); + + assert.equal(v2.$id, "urn:consequence-rail:schema:action-proposal:v0.2"); + assert.equal( + v2.properties.schema_version.const, + "consequence-rail/action-proposal/v0.2", + ); + assert.deepEqual(v2Clause.allOf, [{ + if: { + properties: { op: { enum: ["gte", "lte"] } }, + required: ["op"], + }, + then: { + properties: { + value: { + type: "number", + minimum: -Number.MAX_VALUE, + maximum: Number.MAX_VALUE, + }, + }, + }, + }]); + + assert.equal(bundleV2.$id, "urn:consequence-rail:schema:settlement-bundle:v0.2"); + assert.equal( + bundleV2.properties.schema_version.const, + "consequence-rail/settlement-bundle/v0.2", + ); + assert.equal( + bundleV2.properties.action.properties.proposal.$ref, + "action-proposal-v0.2.schema.json", + ); + assert.equal( + bundleV2.properties.settlement_receipt.$ref, + "settlement-receipt-v0.2.schema.json", + ); + assert.equal(receiptV1.$id, "urn:consequence-rail:schema:settlement-receipt:v0.1"); + assert.equal( + receiptV1.properties.schema_version.const, + "consequence-rail/settlement-receipt/v0.1", + ); + assert.equal(Object.hasOwn(receiptV1.properties, "proposal_schema_version"), false); + assert.equal(receiptV2.$id, "urn:consequence-rail:schema:settlement-receipt:v0.2"); + assert.equal( + receiptV2.properties.schema_version.const, + "consequence-rail/settlement-receipt/v0.2", + ); + assert.equal( + receiptV2.properties.proposal_schema_version.const, + "consequence-rail/action-proposal/v0.2", + ); + const expectedReceiptV2 = deepClone(receiptV1); + expectedReceiptV2.$id = "urn:consequence-rail:schema:settlement-receipt:v0.2"; + expectedReceiptV2.required.splice(1, 0, "proposal_schema_version"); + expectedReceiptV2.properties = { + schema_version: { const: "consequence-rail/settlement-receipt/v0.2" }, + proposal_schema_version: { const: "consequence-rail/action-proposal/v0.2" }, + ...Object.fromEntries(Object.entries(receiptV1.properties).slice(1)), + }; + assert.deepEqual(receiptV2, expectedReceiptV2); + assert.deepEqual( + openapi.paths["/v0/actions"].post.requestBody.content["application/json"].schema.oneOf, + [ + { $ref: "../spec/schemas/action-proposal.schema.json" }, + { $ref: "../spec/schemas/action-proposal-v0.2.schema.json" }, + ], + ); + const bundleSchemaReferences = [ + { $ref: "../spec/schemas/settlement-bundle.schema.json" }, + { $ref: "../spec/schemas/settlement-bundle-v0.2.schema.json" }, + ]; + assert.deepEqual( + openapi.paths["/v0/actions/{action_id}/bundle"].get + .responses["200"].content["application/json"].schema.oneOf, + bundleSchemaReferences, + ); + assert.deepEqual( + openapi.paths["/v0/bundles/verify"].post + .requestBody.content["application/json"].schema.oneOf, + bundleSchemaReferences, + ); +}); + +test("raw JSON overflow thresholds are rejected before action admission", async (context) => { + const runtime = createDemoRuntime(); + const server = createReferenceServer({ runtime }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + context.after(() => server.close()); + const address = server.address(); + + for (const [operator, rawValue, parsedValue] of [ + ["gte", "1e400", Number.POSITIVE_INFINITY], + ["lte", "-1e400", Number.NEGATIVE_INFINITY], + ]) { + assert.strictEqual(JSON.parse(`{"value":${rawValue}}`).value, parsedValue); + const proposal = orderedProposal( + runtime, + "consequence-rail/action-proposal/v0.2", + operator, + "RAW_BINARY64_BOUND", + ); + const body = JSON.stringify(proposal).replace('"RAW_BINARY64_BOUND"', rawValue); + const response = await fetch(`http://127.0.0.1:${address.port}/v0/actions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + assert.equal(response.status, 400); + assert.equal((await response.json()).code, "POSTCONDITION_INVALID"); + assert.equal(runtime.rail.actions.size, 0); + } +}); + +test("version-aligned bundle validation accepts v0.2 and rejects the full matrix", async () => { + coercionCalls = 0; + const { bundle, receiptBundle } = await createV2AuditBundle(); + const v1 = await runRefundDemo(); + const v1Bundle = v1.bundle; + const v1ReceiptBundle = v1.runtime.rail.exportBundle(v1.summary.action_id); + assert.equal(bundle.schema_version, "consequence-rail/settlement-bundle/v0.2"); + assert.equal( + bundle.action.proposal.schema_version, + "consequence-rail/action-proposal/v0.2", + ); + assert.equal( + bundle.settlement_receipt.schema_version, + "consequence-rail/settlement-receipt/v0.2", + ); + assert.equal( + bundle.settlement_receipt.proposal_schema_version, + "consequence-rail/action-proposal/v0.2", + ); + assert.equal( + receiptBundle.settlement_receipt.proposal_schema_version, + "consequence-rail/action-proposal/v0.2", + ); + assert.equal( + v1ReceiptBundle.settlement_receipt.schema_version, + "consequence-rail/settlement-receipt/v0.1", + ); + assert.equal( + Object.hasOwn(v1ReceiptBundle.settlement_receipt, "proposal_schema_version"), + false, + ); + assert.equal(verifyBundle(bundle, { + trustedKeys: demoTrustedKeys(), + trustedConnectorKeys: demoConnectorTrustedKeys(), + requireSemantics: true, + }).valid, true); + assert.equal(verifyBundle(receiptBundle, { + trustedKeys: demoTrustedKeys(), + trustedConnectorKeys: demoConnectorTrustedKeys(), + requireSemantics: false, + }).valid, true); + + for (const [version, sourceBundle] of [ + ["v0.1", v1Bundle], + ["v0.2", bundle], + ]) { + for (const operator of ORDERED_OPERATORS) { + for (const [name, makeValue] of INVALID_VALUES) { + const tampered = deepClone(sourceBundle); + tampered.action.proposal.postcondition.clauses[0].op = operator; + tampered.action.proposal.postcondition.clauses[0].value = makeValue(); + assert.throws( + () => verifyBundle(tampered, { + trustedKeys: demoTrustedKeys(), + trustedConnectorKeys: demoConnectorTrustedKeys(), + requireSemantics: true, + }), + (error) => error.code === "BUNDLE_TAMPERED", + `${version} ${operator} bundle threshold type ${name} must be rejected.`, + ); + } + } + } + assert.equal(coercionCalls, 0); + + for (const [label, source, targetVersion] of [ + ["receipt upgrade", v1ReceiptBundle, "consequence-rail/settlement-bundle/v0.2"], + ["audit upgrade", v1Bundle, "consequence-rail/settlement-bundle/v0.2"], + ["receipt downgrade", receiptBundle, "consequence-rail/settlement-bundle/v0.1"], + ["audit downgrade", bundle, "consequence-rail/settlement-bundle/v0.1"], + ]) { + const relabeled = deepClone(source); + relabeled.schema_version = targetVersion; + assert.throws( + () => verifyBundle(relabeled, { + trustedKeys: demoTrustedKeys(), + trustedConnectorKeys: demoConnectorTrustedKeys(), + requireSemantics: false, + }), + (error) => error.code === "BUNDLE_TAMPERED", + `${label} must fail the bundle, receipt, and proposal version map.`, + ); + } +}); + +test("signed receipts reject complete receipt and audit version relabeling", async () => { + const { bundle: v2Audit, receiptBundle: v2Receipt } = await createV2AuditBundle(); + const v1 = await runRefundDemo(); + const v1Audit = v1.bundle; + const v1Receipt = v1.runtime.rail.exportBundle(v1.summary.action_id); + + for (const [label, source, target] of [ + ["receipt upgrade", v1Receipt, "v0.2"], + ["audit upgrade", v1Audit, "v0.2"], + ["receipt downgrade", v2Receipt, "v0.1"], + ["audit downgrade", v2Audit, "v0.1"], + ]) { + const relabeled = deepClone(source); + relabeled.schema_version = `consequence-rail/settlement-bundle/${target}`; + relabeled.settlement_receipt.schema_version = + `consequence-rail/settlement-receipt/${target}`; + if (target === "v0.2") { + relabeled.settlement_receipt.proposal_schema_version = + "consequence-rail/action-proposal/v0.2"; + } else { + delete relabeled.settlement_receipt.proposal_schema_version; + } + if (relabeled.action.proposal) { + relabeled.action.proposal.schema_version = + `consequence-rail/action-proposal/${target}`; + } + + assert.throws( + () => verifyBundle(relabeled, { + trustedKeys: demoTrustedKeys(), + trustedConnectorKeys: demoConnectorTrustedKeys(), + requireSemantics: false, + }), + (error) => error.code === "SIGNATURE_INVALID", + `${label} must invalidate the signed receipt.`, + ); + } +});