diff --git a/.github/pr-test-selection.json b/.github/pr-test-selection.json index 20e5b8daa..f6dc75a8a 100644 --- a/.github/pr-test-selection.json +++ b/.github/pr-test-selection.json @@ -10,6 +10,7 @@ "tests/Unit/V2/ActivitySessionHistoryTest.php", "tests/Unit/V2/ActivityTaskClaimTest.php", "tests/Unit/V2/CancellationContextTest.php", + "tests/Unit/V2/CommandIntentVectorTest.php", "tests/Unit/V2/CompiledWorkflowDefinitionTest.php", "tests/Unit/V2/EmbeddedHttpSnapshotTest.php", "tests/Unit/V2/ExecutionTracingTest.php", diff --git a/resources/protocol/command-intent-v1-vectors.json b/resources/protocol/command-intent-v1-vectors.json new file mode 100644 index 000000000..5c8fcb73f --- /dev/null +++ b/resources/protocol/command-intent-v1-vectors.json @@ -0,0 +1,1032 @@ +{ + "schema": "durable-workflow.command-intent/v1", + "value_cases": [ + { + "id": "value-null", + "value": { + "type": "null" + }, + "canonical_base64": "bg==", + "native_input": "finite_avro" + }, + { + "id": "value-false", + "value": { + "type": "bool", + "value": false + }, + "canonical_base64": "YjA=", + "native_input": "finite_avro" + }, + { + "id": "value-true", + "value": { + "type": "bool", + "value": true + }, + "canonical_base64": "YjE=", + "native_input": "finite_avro" + }, + { + "id": "value-zero", + "value": { + "type": "int", + "value": "0" + }, + "canonical_base64": "aTA=", + "native_input": "finite_avro" + }, + { + "id": "value-one", + "value": { + "type": "int", + "value": "1" + }, + "canonical_base64": "aTE=", + "native_input": "finite_avro" + }, + { + "id": "value-minus-one", + "value": { + "type": "int", + "value": "-1" + }, + "canonical_base64": "aS0x", + "native_input": "finite_avro" + }, + { + "id": "value-long-min", + "value": { + "type": "int", + "value": "-9223372036854775808" + }, + "canonical_base64": "aS05MjIzMzcyMDM2ODU0Nzc1ODA4", + "native_input": "finite_avro" + }, + { + "id": "value-long-max", + "value": { + "type": "int", + "value": "9223372036854775807" + }, + "canonical_base64": "aTkyMjMzNzIwMzY4NTQ3NzU4MDc=", + "native_input": "finite_avro" + }, + { + "id": "value-double-zero", + "value": { + "type": "double", + "bits": "0000000000000000" + }, + "canonical_base64": "ZDB4MDAwMDAwMDAwMDAwMDAwMA==", + "native_input": "finite_avro" + }, + { + "id": "value-double-negative-zero", + "value": { + "type": "double", + "bits": "8000000000000000" + }, + "canonical_base64": "ZDB4ODAwMDAwMDAwMDAwMDAwMA==", + "native_input": "finite_avro" + }, + { + "id": "value-double-one", + "value": { + "type": "double", + "bits": "3ff0000000000000" + }, + "canonical_base64": "ZDB4M2ZmMDAwMDAwMDAwMDAwMA==", + "native_input": "finite_avro" + }, + { + "id": "value-double-tenth", + "value": { + "type": "double", + "bits": "3fb999999999999a" + }, + "canonical_base64": "ZDB4M2ZiOTk5OTk5OTk5OTk5YQ==", + "native_input": "finite_avro" + }, + { + "id": "value-double-next-tenth", + "value": { + "type": "double", + "bits": "3fb999999999999b" + }, + "canonical_base64": "ZDB4M2ZiOTk5OTk5OTk5OTk5Yg==", + "native_input": "finite_avro" + }, + { + "id": "value-double-smallest-subnormal", + "value": { + "type": "double", + "bits": "0000000000000001" + }, + "canonical_base64": "ZDB4MDAwMDAwMDAwMDAwMDAwMQ==", + "native_input": "finite_avro" + }, + { + "id": "value-double-largest-finite", + "value": { + "type": "double", + "bits": "7fefffffffffffff" + }, + "canonical_base64": "ZDB4N2ZlZmZmZmZmZmZmZmZmZg==", + "native_input": "finite_avro" + }, + { + "id": "value-double-infinity", + "value": { + "type": "double", + "bits": "7ff0000000000000" + }, + "canonical_base64": "ZGluZg==", + "native_input": "nonfinite_refused" + }, + { + "id": "value-double-negative-infinity", + "value": { + "type": "double", + "bits": "fff0000000000000" + }, + "canonical_base64": "ZC1pbmY=", + "native_input": "nonfinite_refused" + }, + { + "id": "value-double-nan", + "value": { + "type": "double", + "bits": "7ff8000000000000" + }, + "canonical_base64": "ZG5hbg==", + "native_input": "nonfinite_refused" + }, + { + "id": "value-text-empty", + "value": { + "type": "string", + "value": "" + }, + "canonical_base64": "czA6", + "native_input": "finite_avro" + }, + { + "id": "value-text-one", + "value": { + "type": "string", + "value": "1" + }, + "canonical_base64": "czE6MQ==", + "native_input": "finite_avro" + }, + { + "id": "value-text-unicode", + "value": { + "type": "string", + "value": "雪🧭" + }, + "canonical_base64": "czc66Zuq8J+nrQ==", + "native_input": "finite_avro" + }, + { + "id": "value-text-composed", + "value": { + "type": "string", + "value": "é" + }, + "canonical_base64": "czI6w6k=", + "native_input": "finite_avro" + }, + { + "id": "value-text-decomposed", + "value": { + "type": "string", + "value": "é" + }, + "canonical_base64": "czM6ZcyB", + "native_input": "finite_avro" + }, + { + "id": "value-text-controls", + "value": { + "type": "string", + "value": "a\u0000b\nc" + }, + "canonical_base64": "czU6YQBiCmM=", + "native_input": "finite_avro" + }, + { + "id": "value-bytes-text", + "value": { + "type": "bytes", + "base64": "YWI=" + }, + "canonical_base64": "eDI6YWI=", + "native_input": "finite_avro" + }, + { + "id": "value-text-bytes", + "value": { + "type": "string", + "value": "ab" + }, + "canonical_base64": "czI6YWI=", + "native_input": "finite_avro" + }, + { + "id": "value-bytes-arbitrary", + "value": { + "type": "bytes", + "base64": "AP+A" + }, + "canonical_base64": "eDM6AP+A", + "native_input": "finite_avro" + }, + { + "id": "value-array-empty", + "value": { + "type": "array", + "items": [] + }, + "canonical_base64": "bDBbXQ==", + "native_input": "finite_avro" + }, + { + "id": "value-map-empty", + "value": { + "type": "map", + "entries": [] + }, + "canonical_base64": "bTB7fQ==", + "native_input": "finite_avro" + }, + { + "id": "value-array-order", + "value": { + "type": "array", + "items": [ + { + "type": "int", + "value": "1" + }, + { + "type": "int", + "value": "2" + } + ] + }, + "canonical_base64": "bDJbaTFpMl0=", + "native_input": "finite_avro" + }, + { + "id": "value-array-reversed", + "value": { + "type": "array", + "items": [ + { + "type": "int", + "value": "2" + }, + { + "type": "int", + "value": "1" + } + ] + }, + "canonical_base64": "bDJbaTJpMV0=", + "native_input": "finite_avro" + }, + { + "id": "value-map-order", + "value": { + "type": "map", + "entries": [ + [ + "z", + { + "type": "int", + "value": "1" + } + ], + [ + "a", + { + "type": "array", + "items": [ + { + "type": "int", + "value": "2" + }, + { + "type": "int", + "value": "3" + } + ] + } + ] + ] + }, + "canonical_base64": "bTJ7czE6YWwyW2kyaTNdczE6emkxfQ==", + "native_input": "finite_avro" + }, + { + "id": "value-map-reversed", + "value": { + "type": "map", + "entries": [ + [ + "a", + { + "type": "array", + "items": [ + { + "type": "int", + "value": "2" + }, + { + "type": "int", + "value": "3" + } + ] + } + ], + [ + "z", + { + "type": "int", + "value": "1" + } + ] + ] + }, + "canonical_base64": "bTJ7czE6YWwyW2kyaTNdczE6emkxfQ==", + "native_input": "finite_avro" + }, + { + "id": "value-map-numeric-keys", + "value": { + "type": "map", + "entries": [ + [ + "1", + { + "type": "string", + "value": "one" + } + ], + [ + "0", + { + "type": "string", + "value": "zero" + } + ] + ] + }, + "canonical_base64": "bTJ7czE6MHM0Onplcm9zMToxczM6b25lfQ==", + "native_input": "finite_avro" + }, + { + "id": "value-map-key-byte-order", + "value": { + "type": "map", + "entries": [ + [ + "é", + { + "type": "int", + "value": "1" + } + ], + [ + "z", + { + "type": "int", + "value": "2" + } + ], + [ + "é", + { + "type": "int", + "value": "3" + } + ] + ] + }, + "canonical_base64": "bTN7czM6ZcyBaTNzMTp6aTJzMjrDqWkxfQ==", + "native_input": "finite_avro" + }, + { + "id": "value-protected-marker", + "value": { + "type": "map", + "entries": [ + [ + "__dw_intent", + { + "type": "string", + "value": "plain-digest" + } + ] + ] + }, + "canonical_base64": "cDEyOnBsYWluLWRpZ2VzdA==", + "native_input": "declared_marker_not_plaintext_proof" + }, + { + "id": "value-protected-marker-plus-field", + "value": { + "type": "map", + "entries": [ + [ + "__dw_intent", + { + "type": "string", + "value": "plain-digest" + } + ], + [ + "extra", + { + "type": "int", + "value": "1" + } + ] + ] + }, + "canonical_base64": "bTJ7czExOl9fZHdfaW50ZW50czEyOnBsYWluLWRpZ2VzdHM1OmV4dHJhaTF9", + "native_input": "finite_avro" + }, + { + "id": "value-protected-marker-wrong-type", + "value": { + "type": "map", + "entries": [ + [ + "__dw_intent", + { + "type": "int", + "value": "1" + } + ] + ] + }, + "canonical_base64": "bTF7czExOl9fZHdfaW50ZW50aTF9", + "native_input": "finite_avro" + } + ], + "command_cases": [ + { + "id": "command-base", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "int", + "value": "1" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:92bc5b6c450ea8a520c84e381945dc93c18df3318bae0f49f982fe122a77b197" + }, + { + "id": "command-update", + "command_type": "update", + "name": "changed", + "arguments": [ + { + "type": "int", + "value": "1" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:121d528a80d8b21568fefef54029db23981dac8d2f3d484137db59ae0c60dd88" + }, + { + "id": "command-other-name", + "command_type": "signal", + "name": "other", + "arguments": [ + { + "type": "int", + "value": "1" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:dd6381210fb0f99cfa20a4050155e3bf0dc28b6486832d370170cf12f1b7d0ae" + }, + { + "id": "command-int-as-double", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "double", + "bits": "3ff0000000000000" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:464360c61f154d5bcdba168068612e27b34085ff9007b9fe3cd812c3801ad8c9" + }, + { + "id": "command-int-as-string", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "string", + "value": "1" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:1ddbb485bf0d510d57c7624170ae08423ad2b99ef1b6c908b517e6e9e2ed9396" + }, + { + "id": "command-selected-run", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "int", + "value": "1" + } + ], + "target_scope": "run", + "requested_run_id": "run-a", + "digest": "v1:321ad166f560c209cf1b52c688a9887e558ba742328b39a24006a884cc2ba3de" + }, + { + "id": "command-other-run", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "int", + "value": "1" + } + ], + "target_scope": "run", + "requested_run_id": "run-b", + "digest": "v1:46f1878c19c0cd0c1da5ff2f79c05bdc4609133cb578bbd2ad12fda3578dd764" + }, + { + "id": "command-instance-ignores-run", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "int", + "value": "1" + } + ], + "target_scope": "instance", + "requested_run_id": "ignored", + "digest": "v1:92bc5b6c450ea8a520c84e381945dc93c18df3318bae0f49f982fe122a77b197" + }, + { + "id": "command-map-order", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "map", + "entries": [ + [ + "z", + { + "type": "int", + "value": "1" + } + ], + [ + "a", + { + "type": "array", + "items": [ + { + "type": "int", + "value": "2" + }, + { + "type": "int", + "value": "3" + } + ] + } + ] + ] + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:21d26ae018c5162f24c4437ac141983455855c70bd70f0fa48d6679c26b8444b" + }, + { + "id": "command-map-reversed", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "map", + "entries": [ + [ + "a", + { + "type": "array", + "items": [ + { + "type": "int", + "value": "2" + }, + { + "type": "int", + "value": "3" + } + ] + } + ], + [ + "z", + { + "type": "int", + "value": "1" + } + ] + ] + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:21d26ae018c5162f24c4437ac141983455855c70bd70f0fa48d6679c26b8444b" + }, + { + "id": "command-negative-zero", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "double", + "bits": "8000000000000000" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:9fae1f042fa0ea6793a33204059ac9f6b69973df18a96f19a5c847d7bc7896e2" + }, + { + "id": "command-positive-zero", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "double", + "bits": "0000000000000000" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:468c123777a6f7f23ef48b369b7355c99434a33ebcf27789f85976859c48033f" + }, + { + "id": "command-empty", + "command_type": "signal", + "name": "changed", + "arguments": [], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:381253a64e036eadf822bcca55fe473501a7dde69f8b4ec66dc64c907c3cfe4a" + }, + { + "id": "command-outer-map-projected", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "int", + "value": "1" + }, + { + "type": "string", + "value": "two" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:f4fc076740dbac1cc7c286004e04f6cfe53bcda2ac12c74b019017468aad4475" + }, + { + "id": "command-list-reversed", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "string", + "value": "two" + }, + { + "type": "int", + "value": "1" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:b9ed79f686e60452fe6bbb9fc945caddc087777f6aed6a5fa6ff63cdc656bbab" + }, + { + "id": "command-bytes", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "bytes", + "base64": "YWI=" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:91d49b626129179bb7268afad3ed793610b141329383bfd3536079d5ee05c154" + }, + { + "id": "command-text", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "string", + "value": "ab" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:0ab6effe1f12a02aea59fbdd07eaa2241d48fa0bbcfdaeb4d68f46e027a42dee" + }, + { + "id": "command-composed", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "string", + "value": "é" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:71ac7078726203f2b475a0634000b04c9d1293c5479cfd407eed388fd820c5a5" + }, + { + "id": "command-decomposed", + "command_type": "signal", + "name": "changed", + "arguments": [ + { + "type": "string", + "value": "é" + } + ], + "target_scope": "instance", + "requested_run_id": null, + "digest": "v1:fbc1d1fc12804d4eb6f75f394efbaad44b8f1edbb0f077e07fe3fdcb34abf865" + } + ], + "native_argument_cases": [ + { + "id": "native-root-empty-list", + "request_root": { + "type": "array", + "items": [] + }, + "arguments": [], + "outcome": "argument_list" + }, + { + "id": "native-root-argument-list", + "request_root": { + "type": "array", + "items": [ + { + "type": "int", + "value": "1" + }, + { + "type": "string", + "value": "two" + } + ] + }, + "arguments": [ + { + "type": "int", + "value": "1" + }, + { + "type": "string", + "value": "two" + } + ], + "outcome": "argument_list" + }, + { + "id": "native-root-outer-map", + "request_root": { + "type": "map", + "entries": [ + [ + "z", + { + "type": "string", + "value": "two" + } + ], + [ + "a", + { + "type": "int", + "value": "1" + } + ] + ] + }, + "arguments": [ + { + "type": "int", + "value": "1" + }, + { + "type": "string", + "value": "two" + } + ], + "outcome": "argument_list" + }, + { + "id": "native-root-outer-map-reversed", + "request_root": { + "type": "map", + "entries": [ + [ + "a", + { + "type": "int", + "value": "1" + } + ], + [ + "z", + { + "type": "string", + "value": "two" + } + ] + ] + }, + "arguments": [ + { + "type": "int", + "value": "1" + }, + { + "type": "string", + "value": "two" + } + ], + "outcome": "argument_list" + }, + { + "id": "native-root-nested-map", + "request_root": { + "type": "array", + "items": [ + { + "type": "map", + "entries": [ + [ + "b", + { + "type": "int", + "value": "2" + } + ], + [ + "a", + { + "type": "int", + "value": "1" + } + ] + ] + } + ] + }, + "arguments": [ + { + "type": "map", + "entries": [ + [ + "b", + { + "type": "int", + "value": "2" + } + ], + [ + "a", + { + "type": "int", + "value": "1" + } + ] + ] + } + ], + "outcome": "argument_list" + }, + { + "id": "native-root-empty-map", + "request_root": { + "type": "map", + "entries": [] + }, + "arguments": null, + "outcome": "refuse" + }, + { + "id": "native-root-scalar", + "request_root": { + "type": "string", + "value": "x" + }, + "arguments": null, + "outcome": "refuse" + } + ], + "digest_relations": [ + { + "left": "command-base", + "right": "command-update", + "equal": false + }, + { + "left": "command-base", + "right": "command-other-name", + "equal": false + }, + { + "left": "command-base", + "right": "command-int-as-double", + "equal": false + }, + { + "left": "command-base", + "right": "command-int-as-string", + "equal": false + }, + { + "left": "command-base", + "right": "command-selected-run", + "equal": false + }, + { + "left": "command-selected-run", + "right": "command-other-run", + "equal": false + }, + { + "left": "command-base", + "right": "command-instance-ignores-run", + "equal": true + }, + { + "left": "command-map-order", + "right": "command-map-reversed", + "equal": true + }, + { + "left": "command-negative-zero", + "right": "command-positive-zero", + "equal": false + }, + { + "left": "command-outer-map-projected", + "right": "command-list-reversed", + "equal": false + }, + { + "left": "command-bytes", + "right": "command-text", + "equal": false + }, + { + "left": "command-composed", + "right": "command-decomposed", + "equal": false + } + ] +} diff --git a/resources/protocol/command-intent-v1.md b/resources/protocol/command-intent-v1.md new file mode 100644 index 000000000..e7d3378bf --- /dev/null +++ b/resources/protocol/command-intent-v1.md @@ -0,0 +1,77 @@ +# Command intent source vectors + +`command-intent-v1-vectors.json` records Core's existing `CommandIntent` +encoding and digest behavior from #877. `CommandIntentVectorTest` loads every +case and compares it with the actual implementation. This is a source contract +for the shared command receipt work in durable-workflow/.github#216 and +durable-workflow/server#934. It does not advertise a native receipt capability. + +## Typed representation + +The fixture uses tagged values so JSON cannot erase Avro distinctions: + +| Tag | Representation | +| --- | --- | +| `null` | No value member. | +| `bool` | JSON Boolean `value`. | +| `int` | Canonical signed 64-bit decimal string `value`. | +| `double` | Sixteen lowercase hex `bits`, big-endian IEEE 754 binary64. | +| `string` | UTF-8 `value`, with no Unicode normalization. | +| `bytes` | Canonical padded `base64`. | +| `array` | Ordered `items`. | +| `map` | Unique string-keyed `[key, value]` pairs in `entries`. | + +`canonical_base64` contains the exact canonical bytes, including arbitrary +binary values and string byte lengths. Map keys sort by UTF-8 bytes. Array +order remains significant. Integer/double, bytes/text, empty array/map, +composed/decomposed Unicode and positive/negative zero remain distinct. +Command digests cover operation kind, command name, argument list and explicit +target scope/run. Instance-scoped intent does not acquire an explicit run fence +from an unused run argument. Namespace, instance identity and the caller key +belong to receipt lookup scope; they are not extra fields silently added to +Core's existing digest. + +## Native input boundaries + +`native_input` separates ordinary finite Avro values from two Core conventions: + +- Core has canonical tokens for non-finite doubles. Native ingress currently + refuses those values. The tokens do not authorize a new wire value. +- Core's sole `__dw_intent` map member substitutes its declared string as a + protected-value token. It is not authenticated plaintext proof. An untrusted + map or ciphertext cannot acquire verified native intent by presenting that + marker. No encryption transform or protected-input admission is implemented + by these vectors. + +Core's object serialization, DateTime, backed-enum and Stringable fallbacks are +embedded conventions outside this typed Avro fixture. A portable caller must +map an application value into an already supported typed value; this file does +not invent an object wire encoding. + +`native_argument_cases` binds the existing native request-root mapping at +Server `d8eb3bc1a5368752be823784521a9a09ec44de71`: an Avro array is the argument +list, while a nonempty outer map projects values in sorted key order. Empty +maps and scalar roots refuse. Nested maps remain map values. The PHP test +checks this source mapping against the expected canonical arguments; it does +not execute or qualify the native adapter. + +## Receipt and external-payload authority + +Server owns the companion admission, concurrency, closure, continuation, +pruning and read-later cases linked to these canonical IDs. The native source +does not yet implement the complete receipt authority. Canonical value equality +alone does not prove that a command was admitted or retained. + +A registered external payload needs its verified codec, reference, size/hash +and typed datum bound to retained intent. A URI alone is insufficient. The +reviewed binding must allow authorized recovery of an existing receipt without +fetching an original object that has become unavailable. Changed, corrupt, +foreign or unsupported input cannot prove equality or authorize new admission. +Provider `external_storage` remains unsupported by the native path. This +artifact supplies no external fetch, transform, new receipt TTL or schema. + +A genuine historical receipt with unverifiable intent is distinct from a +missing or pruned receipt. Neither unavailable intent nor missing admission +permits automatic resend. Read-later retains its separate authorization and +pending-versus-completed-null semantics. Those outcomes must be qualified by +the owning backend and installed consumer cohort before capability publication. diff --git a/tests/Unit/V2/CommandIntentVectorTest.php b/tests/Unit/V2/CommandIntentVectorTest.php new file mode 100644 index 000000000..d6feef494 --- /dev/null +++ b/tests/Unit/V2/CommandIntentVectorTest.php @@ -0,0 +1,205 @@ +assertKeys( + ['schema', 'value_cases', 'command_cases', 'native_argument_cases', 'digest_relations'], + $vectors + ); + $this->assertSame('durable-workflow.command-intent/v1', $vectors['schema']); + $ids = []; + foreach ($vectors['value_cases'] as $case) { + $this->assertKeys(['id', 'value', 'canonical_base64', 'native_input'], $case); + $this->assertNewId($case['id'], $ids); + $value = $this->decodeValue($case['value']); + $this->assertSame( + $case['canonical_base64'], + base64_encode(CommandIntent::canonical($value)), + $case['id'] + ); + $native = match (true) { + is_float($value) && ! is_finite($value) => 'nonfinite_refused', + $value instanceof AvroMapValue && $value->pairs === [['__dw_intent', 'plain-digest']] + => 'declared_marker_not_plaintext_proof', + default => 'finite_avro', + }; + $this->assertSame($native, $case['native_input'], $case['id']); + } + + $digests = []; + foreach ($vectors['command_cases'] as $case) { + $this->assertKeys( + ['id', 'command_type', 'name', 'arguments', 'target_scope', 'requested_run_id', 'digest'], + $case + ); + $this->assertNewId($case['id'], $ids); + $this->assertContains($case['command_type'], ['signal', 'update']); + $this->assertContains($case['target_scope'], ['instance', 'run']); + $this->assertTrue(is_string($case['name'])); + $this->assertTrue($case['requested_run_id'] === null || is_string($case['requested_run_id'])); + $this->assertTrue(array_is_list($case['arguments'])); + $actual = CommandIntent::digest( + CommandType::from($case['command_type']), + $case['name'], + array_map($this->decodeValue(...), $case['arguments']), + $case['target_scope'], + $case['requested_run_id'] + ); + $this->assertSame($case['digest'], $actual, $case['id']); + $digests[$case['id']] = $actual; + } + + foreach ($vectors['digest_relations'] as $relation) { + $this->assertKeys(['left', 'right', 'equal'], $relation); + $this->assertArrayHasKey($relation['left'], $digests); + $this->assertArrayHasKey($relation['right'], $digests); + $this->assertIsBool($relation['equal']); + $this->assertSame($relation['equal'], $digests[$relation['left']] === $digests[$relation['right']]); + } + + // These assert the source mapping, not execution of a native adapter. + foreach ($vectors['native_argument_cases'] as $case) { + $this->assertKeys(['id', 'request_root', 'arguments', 'outcome'], $case); + $this->assertNewId($case['id'], $ids); + $root = $this->decodeValue($case['request_root']); + $arguments = null; + if (is_array($root) && array_is_list($root)) { + $arguments = $root; + } elseif ($root instanceof AvroMapValue && $root->pairs !== []) { + $pairs = $root->pairs; + usort($pairs, static fn (array $left, array $right): int => strcmp($left[0], $right[0])); + $arguments = array_column($pairs, 1); + } + $this->assertSame($arguments === null ? 'refuse' : 'argument_list', $case['outcome']); + $expected = $case['arguments'] === null + ? null : array_map($this->decodeValue(...), $case['arguments']); + $this->assertSame(CommandIntent::canonical($expected), CommandIntent::canonical($arguments), $case['id']); + } + } + + /** + * @param array $node + */ + private function decodeValue(array $node): mixed + { + $keys = match ($node['type'] ?? null) { + 'null' => ['type'], + 'bool', 'int', 'string' => ['type', 'value'], + 'double' => ['type', 'bits'], + 'bytes' => ['type', 'base64'], + 'array' => ['type', 'items'], + 'map' => ['type', 'entries'], + default => throw new InvalidArgumentException('Unknown typed fixture value.'), + }; + $this->assertKeys($keys, $node); + + return match ($node['type']) { + 'null' => null, + 'bool' => $this->booleanValue($node['value']), + 'int' => $this->integerValue($node['value']), + 'string' => $this->stringValue($node['value']), + 'double' => $this->doubleValue($node['bits']), + 'bytes' => AvroBinaryValue::fromBytes($this->bytesValue($node['base64'])), + 'array' => array_map($this->decodeValue(...), $this->listValue($node['items'])), + 'map' => AvroMapValue::fromPairs(array_map(function (array $pair): array { + $this->assertTrue(array_is_list($pair)); + $this->assertCount(2, $pair); + + return [$this->stringValue($pair[0]), $this->decodeValue($pair[1])]; + }, $this->listValue($node['entries']))), + }; + } + + private function booleanValue(mixed $value): bool + { + $this->assertIsBool($value); + + return $value; + } + + private function stringValue(mixed $value): string + { + $this->assertIsString($value); + + return $value; + } + + private function integerValue(mixed $value): int + { + $this->assertIsString($value); + $this->assertMatchesRegularExpression('/\A(?:0|-?[1-9][0-9]*)\z/', $value); + $integer = filter_var($value, FILTER_VALIDATE_INT); + $this->assertNotFalse($integer); + + return $integer; + } + + private function doubleValue(mixed $bits): float + { + $this->assertIsString($bits); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{16}\z/', $bits); + + return unpack('Evalue', hex2bin($bits))['value']; + } + + private function bytesValue(mixed $encoded): string + { + $this->assertIsString($encoded); + $bytes = base64_decode($encoded, true); + $this->assertNotFalse($bytes); + $this->assertSame($encoded, base64_encode($bytes)); + + return $bytes; + } + + /** + * @return list + */ + private function listValue(mixed $value): array + { + $this->assertIsArray($value); + $this->assertTrue(array_is_list($value)); + + return $value; + } + + /** + * @param list $expected + * @param array $value + */ + private function assertKeys(array $expected, array $value): void + { + $actual = array_keys($value); + sort($expected); + sort($actual); + $this->assertSame($expected, $actual); + } + + /** + * @param array $seen + */ + private function assertNewId(string $id, array &$seen): void + { + $this->assertMatchesRegularExpression('/\A[a-z][a-z0-9-]+\z/', $id); + $this->assertArrayNotHasKey($id, $seen); + $seen[$id] = true; + } +}