diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 73711cf55..2bd300130 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -49,6 +49,8 @@ on: - tests/Unit/ServerParityActivityCancellationAckContractTest.mjs - tests/Unit/ServerParityCancellationScopesContractTest.mjs - tests/Unit/ServerParityServiceCatalogContractTest.mjs + - tests/Unit/ServerParityServiceEndpointsContractTest.mjs + - tests/Unit/ServerParityServiceCallsContractTest.mjs - tests/Unit/ServerParityWorkflowImportContractTest.mjs - tests/Unit/ServerParityWaterlineImportContractTest.mjs - tests/Unit/ServerParitySystemMetricsContractTest.mjs @@ -1807,7 +1809,7 @@ jobs: bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -w /source \ - "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParitySdkDirectionLanesTest.mjs tests/Unit/ServerParityCodecDeadlineCellsTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityFailureLocationTest.mjs tests/Unit/ServerParityFailureExcerptTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityRecordLocalActivityContractTest.mjs tests/Unit/ServerParityActivityCancellationAckContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParityWorkflowImportContractTest.mjs tests/Unit/ServerParityWaterlineImportContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs tests/Unit/ServerParityMessageStreamsContractTest.mjs tests/Unit/ServerParityCancellationScopesContractTest.mjs tests/Unit/ServerParityWorkflowPollRequestContractTest.mjs tests/Unit/ServerParityUpdateValidationContractTest.mjs tests/Unit/ServerParityFixtureSelectionTest.mjs tests/Unit/ServerParityUpdateValidationRetryTest.mjs tests/Unit/ServerParityKilledAuthorPollTest.mjs tests/Unit/ServerParityImagePullTest.mjs tests/Unit/ServerParityWorkerSessionsContractTest.mjs tests/Unit/ServerParityWorkerSessionActivitiesContractTest.mjs tests/Unit/ServerParityNexusOperationsContractTest.mjs tests/Unit/ServerParityBridgeAdaptersContractTest.mjs tests/Unit/ServerParityWorkflowStreamWritesContractTest.mjs > parity-evidence/comparator-tests.txt + "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParitySdkDirectionLanesTest.mjs tests/Unit/ServerParityCodecDeadlineCellsTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityFailureLocationTest.mjs tests/Unit/ServerParityFailureExcerptTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityRecordLocalActivityContractTest.mjs tests/Unit/ServerParityActivityCancellationAckContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParityServiceEndpointsContractTest.mjs tests/Unit/ServerParityServiceCallsContractTest.mjs tests/Unit/ServerParityWorkflowImportContractTest.mjs tests/Unit/ServerParityWaterlineImportContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs tests/Unit/ServerParityMessageStreamsContractTest.mjs tests/Unit/ServerParityCancellationScopesContractTest.mjs tests/Unit/ServerParityWorkflowPollRequestContractTest.mjs tests/Unit/ServerParityUpdateValidationContractTest.mjs tests/Unit/ServerParityFixtureSelectionTest.mjs tests/Unit/ServerParityUpdateValidationRetryTest.mjs tests/Unit/ServerParityKilledAuthorPollTest.mjs tests/Unit/ServerParityImagePullTest.mjs tests/Unit/ServerParityWorkerSessionsContractTest.mjs tests/Unit/ServerParityWorkerSessionActivitiesContractTest.mjs tests/Unit/ServerParityNexusOperationsContractTest.mjs tests/Unit/ServerParityBridgeAdaptersContractTest.mjs tests/Unit/ServerParityWorkflowStreamWritesContractTest.mjs > parity-evidence/comparator-tests.txt docker run --rm --user=1000:1000 --cpus=1 --memory=256m --memory-swap=256m \ --network=none --entrypoint node -v "$PWD:/source:ro" -w /source \ "$image" --test tests/Unit/ServerParityCodecDeadlineLoadTest.mjs > parity-evidence/codec-load-tests.txt diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index c4ffc514a..62942046b 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -82,15 +82,17 @@ Updated October 24, 2026. ([#737](https://github.com/durable-workflow/server/issues/737)). No expectation is edited to make native pass. -- **Parity surface.** Native answers 156 of the 175 API route methods in +- **Parity surface.** Native answers 174 of the 175 API route methods in published 2.11.1's route table, with message streams, durable workflow stream writes, update validation, activity cancellation acknowledgement, - worker sessions, workflow-task cancellation scopes, caller-indexed Nexus - operation reads, bridge adapter webhooks, and embedded v2 and Waterline v1 - workflow import. Eight of those are the prepared local-activity routes, - which it answers with PHP's refusal for a bridge without the prepared role. - An execution test sends each one through the native router, and the ones - still answering `501 rust_capability_not_implemented` must match + worker sessions, workflow-task cancellation scopes, service catalog + administration, Nexus service-call execution, show and cancel for the + handler kinds that stay accepted, caller-indexed Nexus operation reads, + bridge adapter webhooks, and embedded v2 and Waterline v1 workflow import. + Eight of those are the prepared local-activity routes, which it answers + with PHP's refusal for a bridge without the prepared role. An execution + test sends each one through the native router, and the ones still + answering `501 rust_capability_not_implemented` must match [`unserved-php-routes.v2.11.1.txt`](../rust/tests/fixtures/unserved-php-routes.v2.11.1.txt). Unlike published PHP, native's embedded v2 import refuses a bundle whose rows reference rows outside it, links only runs of one namespace, and reads @@ -174,14 +176,60 @@ any other unknown path or method answers PHP's own 404 or 405 ([checkpoint](rust-server-port-checkpoints/2026-10-23-message-streams.md)), except external payload references as input, stream retention cleanup and takeover of PHP data with streams. - - Service endpoints. (The service catalog reads from Server 2.11 are served: - native never holds catalog rows, so every list is PHP's empty page and every - identity its not-found answer; the `service-catalog` fixture checks 37 cases. - Native neither executes service operations nor accepts the workflow-task - `start_service_operation` command, which it refuses as - `unsupported_workflow_command`. When it does, an operation bound to a - workflow update must refuse a `wait_timeout_seconds` above the update - ceiling of 60 before it runs, as PHP does (Server #775).) + - Workflow-bound service handlers (start, signal, update and query + workflow), the workflow-task `start_service_operation` command, and + takeover of a PHP database that holds service calls. Each of these still + answers 501 or refuses. When the + workflow-bound handlers come, an operation bound to a workflow update must + refuse a `wait_timeout_seconds` above the update ceiling of 60 before it + runs, as PHP does (Server #775). + - Service catalog administration is served, and takeover keeps a PHP + database's endpoints, services and operations. The 15 admin routes under + `/api/service-endpoints` create, read, update and delete a namespace's + endpoints, services and operations as `ServiceCatalogController` does, + with the `service-endpoints` fixture's 72 cases. The `/api/service-catalog` + reads render those rows by page and identity; the `service-catalog` + fixture checks the empty namespace's 37 cases. + - Service-call execution, show and cancel are served for the handler kinds + that stay accepted (`activity_execution`, `invocable_http`), with the + `service-calls` fixture's 91 cases. That covers admission and its audit + row, the boundary's authorization and circuit refusals, the 503 for an + invalid limit, the shared rate and concurrency limits, handler failures + retried in the request, deadlines, idempotent replays, and cancellation. + A workflow-bound kind answers 501 before anything is written. + The `/api/service-catalog/calls` list and a call read by identity show + the recorded calls as `ServiceCatalogReadController` does: the scopes + (by default calls the namespace made or that target it), status and + outcome filters and buckets, newest accepted first, and the detail with + caller and link references; payload references and the Nexus result + show only to the namespace that owns the call. The fixture's last nine + cases read them. + - Differences from published 2.11.1: + - Native JSON documents don't keep object key order. An update that + only reorders a document's keys writes nothing, where PHP's strict + comparison writes it and moves `updated_at`. + - PHP checks a name and then inserts without a lock, so concurrent + creates of one name can end in a unique-index 500, and a delete can + race a child's create. Native writes under its serialized transaction, + so the second create gets 409. + - Laravel also reads a write's fields from the query string. Native reads + a field from it only when the body lacks that field, and only under its + exact name, not PHP's `metadata[a]=1` array syntax. + - A call's deadline lives in the native-owned `dw_service_call_deadlines` + (schema version 7), not in 2.11.1's + `workflow_service_calls.call_deadline_at`. Native keeps the frozen PHP + tables plus its own relations, so takeover layouts stay recognisable; + accepting a source with service calls will copy their deadlines. + - Native commits a call's admission and dispatch in one serialized + transaction, where PHP commits each step on its own. The rows end the + same. + - The shared admission counters live in the native-owned + `dw_service_admission_counters` (schema version 7), not in PHP's + cache. Native counts under its transition lock instead of a cache + lock, so an admission never answers 503 because the lock was busy, and + a call whose audit row fails to write takes back its rate count too. + The namespace-wide limits and the hard ceilings run only at their + default of none, and the concurrency lease lives the default day. - (Bridge adapter webhooks are served: `POST /api/bridge-adapters/webhook/{adapter}`, with the `bridge-adapters` fixture. Native admits bridge updates as the regular update route does; published 2.11.1 skips that admission, Server #747. diff --git a/rust/migrations/mariadb/0006_catalog.json b/rust/migrations/mariadb/0007_catalog.json similarity index 99% rename from rust/migrations/mariadb/0006_catalog.json rename to rust/migrations/mariadb/0007_catalog.json index 737a3c180..29555b5a9 100644 --- a/rust/migrations/mariadb/0006_catalog.json +++ b/rust/migrations/mariadb/0007_catalog.json @@ -19,6 +19,14 @@ "dw_server_schema", "CREATE TABLE `dw_server_schema` (\n `engine` varchar(64) NOT NULL,\n `version` bigint(20) NOT NULL,\n `bootstrap_checksum` varbinary(48) NOT NULL,\n PRIMARY KEY (`engine`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin" ], + [ + "dw_service_admission_counters", + "CREATE TABLE `dw_service_admission_counters` (\n `cache_key` varchar(191) NOT NULL,\n `amount` bigint(20) NOT NULL,\n `expires_at` datetime(6) NOT NULL,\n PRIMARY KEY (`cache_key`),\n KEY `dw_service_admission_counters_expiry` (`expires_at`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ], + [ + "dw_service_call_deadlines", + "CREATE TABLE `dw_service_call_deadlines` (\n `service_call_id` varchar(26) NOT NULL,\n `namespace` varchar(128) NOT NULL,\n `call_deadline_at` datetime(6) NOT NULL,\n PRIMARY KEY (`service_call_id`),\n KEY `dw_service_call_deadlines_due` (`call_deadline_at`,`service_call_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ], [ "dw_stream_reclaims", "CREATE TABLE `dw_stream_reclaims` (\n `stream_id` bigint(20) unsigned NOT NULL,\n `namespace` varchar(128) NOT NULL,\n `workflow_run_id` varchar(26) NOT NULL,\n `reclaim_at` datetime(6) NOT NULL,\n PRIMARY KEY (`stream_id`),\n KEY `dw_stream_reclaims_due` (`namespace`,`reclaim_at`,`stream_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" diff --git a/rust/migrations/mariadb/0007_service_calls.sql b/rust/migrations/mariadb/0007_service_calls.sql new file mode 100644 index 000000000..6cd5af928 --- /dev/null +++ b/rust/migrations/mariadb/0007_service_calls.sql @@ -0,0 +1,24 @@ +-- Native development bootstrap version 7: Nexus service calls. +-- +-- dw_service_call_deadlines holds the deadline of each admitted call whose +-- operation set a deadline policy, resolved at admission from the policy and +-- accepted_at (published 2.11.1 keeps it in +-- workflow_service_calls.call_deadline_at; native keeps the frozen PHP tables +-- and its own relations). An open call past its deadline is timed out when +-- read; the deadline sweep reads (call_deadline_at, service_call_id). +CREATE TABLE dw_service_call_deadlines ( + service_call_id VARCHAR(26) NOT NULL PRIMARY KEY, + namespace VARCHAR(128) NOT NULL, + call_deadline_at DATETIME(6) NOT NULL, + KEY dw_service_call_deadlines_due (call_deadline_at, service_call_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- dw_service_admission_counters holds the shared boundary admission +-- counters PHP keeps in its cache: a per-minute rate count and a concurrency +-- lease count per boundary, each read as zero once expires_at has passed. +CREATE TABLE dw_service_admission_counters ( + cache_key VARCHAR(191) NOT NULL PRIMARY KEY, + amount BIGINT NOT NULL, + expires_at DATETIME(6) NOT NULL, + KEY dw_service_admission_counters_expiry (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +UPDATE dw_server_schema SET version=7 WHERE engine='rust-development' AND version=6; diff --git a/rust/migrations/mysql/0006_catalog.json b/rust/migrations/mysql/0007_catalog.json similarity index 99% rename from rust/migrations/mysql/0006_catalog.json rename to rust/migrations/mysql/0007_catalog.json index b57c5f919..343490500 100644 --- a/rust/migrations/mysql/0006_catalog.json +++ b/rust/migrations/mysql/0007_catalog.json @@ -23,6 +23,14 @@ "dw_server_schema", "CREATE TABLE `dw_server_schema` (\n `engine` varchar(64) COLLATE utf8mb4_bin NOT NULL,\n `version` bigint NOT NULL,\n `bootstrap_checksum` varbinary(48) NOT NULL,\n PRIMARY KEY (`engine`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin" ], + [ + "dw_service_admission_counters", + "CREATE TABLE `dw_service_admission_counters` (\n `cache_key` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL,\n `amount` bigint NOT NULL,\n `expires_at` datetime(6) NOT NULL,\n PRIMARY KEY (`cache_key`),\n KEY `dw_service_admission_counters_expiry` (`expires_at`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ], + [ + "dw_service_call_deadlines", + "CREATE TABLE `dw_service_call_deadlines` (\n `service_call_id` varchar(26) COLLATE utf8mb4_unicode_ci NOT NULL,\n `namespace` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,\n `call_deadline_at` datetime(6) NOT NULL,\n PRIMARY KEY (`service_call_id`),\n KEY `dw_service_call_deadlines_due` (`call_deadline_at`,`service_call_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ], [ "dw_stream_reclaims", "CREATE TABLE `dw_stream_reclaims` (\n `stream_id` bigint unsigned NOT NULL,\n `namespace` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,\n `workflow_run_id` varchar(26) COLLATE utf8mb4_unicode_ci NOT NULL,\n `reclaim_at` datetime(6) NOT NULL,\n PRIMARY KEY (`stream_id`),\n KEY `dw_stream_reclaims_due` (`namespace`,`reclaim_at`,`stream_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" diff --git a/rust/migrations/mysql/0007_service_calls.sql b/rust/migrations/mysql/0007_service_calls.sql new file mode 100644 index 000000000..6cd5af928 --- /dev/null +++ b/rust/migrations/mysql/0007_service_calls.sql @@ -0,0 +1,24 @@ +-- Native development bootstrap version 7: Nexus service calls. +-- +-- dw_service_call_deadlines holds the deadline of each admitted call whose +-- operation set a deadline policy, resolved at admission from the policy and +-- accepted_at (published 2.11.1 keeps it in +-- workflow_service_calls.call_deadline_at; native keeps the frozen PHP tables +-- and its own relations). An open call past its deadline is timed out when +-- read; the deadline sweep reads (call_deadline_at, service_call_id). +CREATE TABLE dw_service_call_deadlines ( + service_call_id VARCHAR(26) NOT NULL PRIMARY KEY, + namespace VARCHAR(128) NOT NULL, + call_deadline_at DATETIME(6) NOT NULL, + KEY dw_service_call_deadlines_due (call_deadline_at, service_call_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- dw_service_admission_counters holds the shared boundary admission +-- counters PHP keeps in its cache: a per-minute rate count and a concurrency +-- lease count per boundary, each read as zero once expires_at has passed. +CREATE TABLE dw_service_admission_counters ( + cache_key VARCHAR(191) NOT NULL PRIMARY KEY, + amount BIGINT NOT NULL, + expires_at DATETIME(6) NOT NULL, + KEY dw_service_admission_counters_expiry (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +UPDATE dw_server_schema SET version=7 WHERE engine='rust-development' AND version=6; diff --git a/rust/migrations/postgres/0006_catalog.json b/rust/migrations/postgres/0007_catalog.json similarity index 99% rename from rust/migrations/postgres/0006_catalog.json rename to rust/migrations/postgres/0007_catalog.json index 38675b901..063d1cd6f 100644 --- a/rust/migrations/postgres/0006_catalog.json +++ b/rust/migrations/postgres/0007_catalog.json @@ -766,6 +766,84 @@ "generated": "" } ], + [ + "column", + "dw_service_admission_counters.1", + { + "collation": "default", + "default": null, + "generated": "", + "identity": "", + "name": "cache_key", + "not_null": true, + "type": "text" + } + ], + [ + "column", + "dw_service_admission_counters.2", + { + "collation": null, + "default": null, + "generated": "", + "identity": "", + "name": "amount", + "not_null": true, + "type": "bigint" + } + ], + [ + "column", + "dw_service_admission_counters.3", + { + "collation": null, + "default": null, + "generated": "", + "identity": "", + "name": "expires_at", + "not_null": true, + "type": "timestamp(6) without time zone" + } + ], + [ + "column", + "dw_service_call_deadlines.1", + { + "collation": "default", + "default": null, + "generated": "", + "identity": "", + "name": "service_call_id", + "not_null": true, + "type": "text" + } + ], + [ + "column", + "dw_service_call_deadlines.2", + { + "collation": "default", + "default": null, + "generated": "", + "identity": "", + "name": "namespace", + "not_null": true, + "type": "text" + } + ], + [ + "column", + "dw_service_call_deadlines.3", + { + "collation": null, + "default": null, + "generated": "", + "identity": "", + "name": "call_deadline_at", + "not_null": true, + "type": "timestamp(6) without time zone" + } + ], [ "column", "dw_stream_reclaims.1", @@ -12039,6 +12117,26 @@ "definition": "PRIMARY KEY (key)" } ], + [ + "constraint", + "dw_service_admission_counters.dw_service_admission_counters_pkey", + { + "deferrable": false, + "deferred": false, + "definition": "PRIMARY KEY (cache_key)", + "validated": true + } + ], + [ + "constraint", + "dw_service_call_deadlines.dw_service_call_deadlines_pkey", + { + "deferrable": false, + "deferred": false, + "definition": "PRIMARY KEY (service_call_id)", + "validated": true + } + ], [ "constraint", "dw_stream_reclaims.dw_stream_reclaims_pkey", @@ -13144,6 +13242,50 @@ "definition": "CREATE UNIQUE INDEX dw_query_cache_pkey ON public.dw_query_cache USING btree (key)" } ], + [ + "index", + "dw_service_admission_counters_expiry", + { + "definition": "CREATE INDEX dw_service_admission_counters_expiry ON public.dw_service_admission_counters USING btree (expires_at)", + "primary": false, + "ready": true, + "unique": false, + "valid": true + } + ], + [ + "index", + "dw_service_admission_counters_pkey", + { + "definition": "CREATE UNIQUE INDEX dw_service_admission_counters_pkey ON public.dw_service_admission_counters USING btree (cache_key)", + "primary": true, + "ready": true, + "unique": true, + "valid": true + } + ], + [ + "index", + "dw_service_call_deadlines_due", + { + "definition": "CREATE INDEX dw_service_call_deadlines_due ON public.dw_service_call_deadlines USING btree (call_deadline_at, service_call_id)", + "primary": false, + "ready": true, + "unique": false, + "valid": true + } + ], + [ + "index", + "dw_service_call_deadlines_pkey", + { + "definition": "CREATE UNIQUE INDEX dw_service_call_deadlines_pkey ON public.dw_service_call_deadlines USING btree (service_call_id)", + "primary": true, + "ready": true, + "unique": true, + "valid": true + } + ], [ "index", "dw_stream_reclaims_due", @@ -17392,6 +17534,36 @@ "force_row_security": false } ], + [ + "relation", + "dw_service_admission_counters", + { + "access_method": "heap", + "force_row_security": false, + "kind": "r", + "options": null, + "partition": false, + "partition_key": null, + "persistence": "p", + "row_security": false, + "view": null + } + ], + [ + "relation", + "dw_service_call_deadlines", + { + "access_method": "heap", + "force_row_security": false, + "kind": "r", + "options": null, + "partition": false, + "partition_key": null, + "persistence": "p", + "row_security": false, + "view": null + } + ], [ "relation", "dw_stream_reclaims", diff --git a/rust/migrations/postgres/0007_service_calls.sql b/rust/migrations/postgres/0007_service_calls.sql new file mode 100644 index 000000000..338ec0b90 --- /dev/null +++ b/rust/migrations/postgres/0007_service_calls.sql @@ -0,0 +1,24 @@ +-- Native development bootstrap version 7: Nexus service calls. +-- +-- dw_service_call_deadlines holds the deadline of each admitted call whose +-- operation set a deadline policy, resolved at admission from the policy and +-- accepted_at (published 2.11.1 keeps it in +-- workflow_service_calls.call_deadline_at; native keeps the frozen PHP tables +-- and its own relations). An open call past its deadline is timed out when +-- read; the deadline sweep reads (call_deadline_at, service_call_id). +CREATE TABLE dw_service_call_deadlines ( + service_call_id text NOT NULL PRIMARY KEY, + namespace text NOT NULL, + call_deadline_at timestamp(6) without time zone NOT NULL +); +CREATE INDEX dw_service_call_deadlines_due ON dw_service_call_deadlines (call_deadline_at, service_call_id); +-- dw_service_admission_counters holds the shared boundary admission +-- counters PHP keeps in its cache: a per-minute rate count and a concurrency +-- lease count per boundary, each read as zero once expires_at has passed. +CREATE TABLE dw_service_admission_counters ( + cache_key text NOT NULL PRIMARY KEY, + amount bigint NOT NULL, + expires_at timestamp(6) without time zone NOT NULL +); +CREATE INDEX dw_service_admission_counters_expiry ON dw_service_admission_counters (expires_at); +UPDATE dw_server_schema SET version=7 WHERE engine='rust-development' AND version=6; diff --git a/rust/migrations/sqlite/0007_service_calls.sql b/rust/migrations/sqlite/0007_service_calls.sql new file mode 100644 index 000000000..fa2de782c --- /dev/null +++ b/rust/migrations/sqlite/0007_service_calls.sql @@ -0,0 +1,24 @@ +-- Native development bootstrap version 7: Nexus service calls. +-- +-- dw_service_call_deadlines holds the deadline of each admitted call whose +-- operation set a deadline policy, resolved at admission from the policy and +-- accepted_at (published 2.11.1 keeps it in +-- workflow_service_calls.call_deadline_at; native keeps the frozen PHP tables +-- and its own relations). An open call past its deadline is timed out when +-- read; the deadline sweep reads (call_deadline_at, service_call_id). +CREATE TABLE dw_service_call_deadlines ( + service_call_id TEXT NOT NULL PRIMARY KEY, + namespace TEXT NOT NULL, + call_deadline_at TEXT NOT NULL +); +CREATE INDEX dw_service_call_deadlines_due ON dw_service_call_deadlines (call_deadline_at, service_call_id); +-- dw_service_admission_counters holds the shared boundary admission +-- counters PHP keeps in its cache: a per-minute rate count and a concurrency +-- lease count per boundary, each read as zero once expires_at has passed. +CREATE TABLE dw_service_admission_counters ( + cache_key TEXT NOT NULL PRIMARY KEY, + amount INTEGER NOT NULL, + expires_at TEXT NOT NULL +); +CREATE INDEX dw_service_admission_counters_expiry ON dw_service_admission_counters (expires_at); +UPDATE dw_server_schema SET version=7 WHERE engine='rust-development' AND version=6; diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs index 1aa083fe4..a5678ad41 100644 --- a/rust/src/runtime/execution.rs +++ b/rust/src/runtime/execution.rs @@ -490,6 +490,21 @@ impl Runtime { .map_err(|_| refuse(StatusCode::SERVICE_UNAVAILABLE, "signal_codec_unavailable"))? } + /// `AvroPayloadEnvelopeResolver::assertSerializedPayload`'s test: whether + /// `blob` is one complete Avro value, decoded under the transition codec. + pub(super) async fn avro_value_decodes(&self, blob: String) -> Result { + let permit = self.transition_codec.clone().acquire_owned().await + .map_err(|_| refuse(StatusCode::SERVICE_UNAVAILABLE, "signal_codec_unavailable"))?; + tokio::task::spawn_blocking(move || { + let _permit = permit; + let codec = crate::codec::ValueCodec::new() + .map_err(|_| refuse(StatusCode::SERVICE_UNAVAILABLE, "signal_codec_unavailable"))?; + Ok(codec.decode(&blob).is_ok()) + }) + .await + .map_err(|_| refuse(StatusCode::SERVICE_UNAVAILABLE, "signal_codec_unavailable"))? + } + /// Refuses an envelope blob the codec cannot decode, with PHP's message. async fn decodable(&self, blob: &Value) -> Result<()> { let Some(blob) = blob.as_str().map(str::to_owned) else { @@ -1678,6 +1693,29 @@ impl Runtime { delegate!(workflow_in_namespace(workflow_id: &str) -> Result); delegate!(bridge_identity_receipt(workflow_id: &str, kind: &str, identity: &str) -> Result>); delegate!(nexus_operations(workflow_id: &str, run_id: Option<&str>, limit: i64) -> Result>); + delegate!(service_catalog_list(query: &[(String, String)], catalog: super::service_catalog::Catalog) -> Result); + delegate!(service_catalog_show(query: &[(String, String)], catalog: super::service_catalog::Catalog, id: &str) -> Result); + delegate!(list_service_endpoints() -> Result); + delegate!(create_service_endpoint(input: Value) -> Result); + delegate!(show_service_endpoint(name: &str) -> Result); + delegate!(update_service_endpoint(name: &str, input: Value) -> Result); + delegate!(delete_service_endpoint(name: &str) -> Result); + delegate!(list_services(endpoint: &str) -> Result); + delegate!(create_service(endpoint: &str, input: Value) -> Result); + delegate!(show_service(endpoint: &str, service: &str) -> Result); + delegate!(update_service(endpoint: &str, service: &str, input: Value) -> Result); + delegate!(delete_service(endpoint: &str, service: &str) -> Result); + delegate!(list_operations(endpoint: &str, service: &str) -> Result); + delegate!(create_operation(endpoint: &str, service: &str, input: Value) -> Result); + delegate!(show_operation(names: [&str; 3]) -> Result); + delegate!(update_operation(names: [&str; 3], input: Value) -> Result); + delegate!(delete_operation(names: [&str; 3]) -> Result); + delegate!(service_operation_exists(names: [&str; 3]) -> Result<()>); + delegate!(service_call_exists(names: [&str; 3], id: &str) -> Result<()>); + delegate!(validate_service_call_search_attributes(attributes: Value) -> Result<()>); + delegate!(execute_service_call(names: [&str; 3], execution: super::service_calls::Execution, caller: super::service_calls::Caller) -> Result<(StatusCode, Value)>); + delegate!(show_service_call(names: [&str; 3], id: &str) -> Result); + delegate!(cancel_service_call(names: [&str; 3], id: &str, reason: Option) -> Result<(StatusCode, Value)>); delegate!(import_existing_run(run_id: &str) -> Result>); delegate!(import_write(plan: &super::workflow_import::Plan) -> std::result::Result); delegate!(waterline_existing(workflow_id: &str, run_id: &str) -> Result); diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index 82bf465cb..736b94dff 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -246,6 +246,36 @@ pub fn router(runtime: Runtime) -> Router { .route("/api/system/activity-timeouts/pass", post(activity_timeout_pass)) .route("/api/workers", get(list_workers)) .route("/api/task-queues", get(list_task_queues)) + .route("/api/service-endpoints", get(service_endpoints_index).post(service_endpoint_store)) + .route( + "/api/service-endpoints/{endpoint}", + get(service_endpoint_show).put(service_endpoint_update).delete(service_endpoint_destroy), + ) + .route("/api/service-endpoints/{endpoint}/services", get(services_index).post(service_store)) + .route( + "/api/service-endpoints/{endpoint}/services/{service}", + get(service_show).put(service_update).delete(service_destroy), + ) + .route( + "/api/service-endpoints/{endpoint}/services/{service}/operations", + get(operations_index).post(operation_store), + ) + .route( + "/api/service-endpoints/{endpoint}/services/{service}/operations/{operation}", + get(operation_show).put(operation_update).delete(operation_destroy), + ) + .route( + "/api/service-endpoints/{endpoint}/services/{service}/operations/{operation}/execute", + post(operation_execute), + ) + .route( + "/api/service-endpoints/{endpoint}/services/{service}/operations/{operation}/service-calls/{call}", + get(service_call_show), + ) + .route( + "/api/service-endpoints/{endpoint}/services/{service}/operations/{operation}/service-calls/{call}/cancel", + post(service_call_cancel), + ) .route("/api/service-catalog/{catalog}", get(service_catalog_list)) .route("/api/service-catalog/{catalog}/{id}", get(service_catalog_show)) .route("/api/task-queues/{task_queue}", get(describe_task_queue)) @@ -495,6 +525,8 @@ async fn guard(State(runtime): State, mut request: Request, next: Next) || path.starts_with("/api/runtime-credentials/") // History import writes runs into the store; PHP serves it to admins. || path.starts_with("/api/workflows/import/") + // Service catalog administration. + || catalog_admin_route(&path) { &["admin"] } else { @@ -691,7 +723,7 @@ const NAMESPACE_FAMILIES: [(&str, Rest, Allowed); 10] = [ // Runtime credentials are server-wide; the namespace only scopes a tenant. ("/api/runtime-credentials/", Rest::Anything, Allowed::Any), ("/api/external-payloads/v1/", Rest::Anything, READ), - // The service catalog is namespace-scoped and, in native, always empty. + // The service catalog reads are namespace-scoped. ("/api/service-catalog/", Rest::Anything, READ), // Schedules are namespace-scoped rows, as in PHP. ("/api/schedules/", Rest::Anything, Allowed::Any), @@ -722,6 +754,8 @@ fn named_namespace_operation(method: &str, path: &str) -> bool { /// further family needs its own shared namespace fixtures. fn namespace_route(path: &str) -> Option { exact_namespace_route(path) + // The catalog routes act on the namespace's own rows and calls. + .or_else(|| (catalog_admin_route(path) || catalog_call_route(path)).then_some(Allowed::Any)) .or_else(|| { NAMESPACE_FAMILIES.iter().find_map(|(prefix, shape, allowed)| { let rest = path.strip_prefix(prefix)?; @@ -2490,22 +2524,201 @@ async fn service_catalog_list( Query(query): Query>, ) -> Response { match super::service_catalog::Catalog::from_path(&catalog) { - Some(catalog) => super::service_catalog::list(&query, catalog, runtime.namespace()).map(Json).into_response(), + Some(catalog) => runtime.service_catalog_list(&query, catalog).await.map(Json).into_response(), None => unavailable().await, } } /// `GET /api/service-catalog/{catalog}/{id}`. async fn service_catalog_show( - Path((catalog, _id)): Path<(String, String)>, + Extension(runtime): Extension, + Path((catalog, id)): Path<(String, String)>, Query(query): Query>, ) -> Response { match super::service_catalog::Catalog::from_path(&catalog) { - Some(catalog) => super::service_catalog::show(&query, catalog).map(Json).into_response(), + Some(catalog) => runtime.service_catalog_show(&query, catalog, &id).await.map(Json).into_response(), None => unavailable().await, } } +/// PHP's admin-only catalog routes: an endpoint, its services, a service's +/// operations, and each of those by name. Service-call reads, execution and +/// cancellation lie deeper and are operator routes. +fn catalog_admin_route(path: &str) -> bool { + let Some(rest) = path.strip_prefix("/api/service-endpoints") else { + return false; + }; + let Some(rest) = rest.strip_prefix('/') else { + return rest.is_empty(); + }; + let segments: Vec<&str> = rest.split('/').collect(); + segments.iter().all(|segment| !segment.is_empty()) + && matches!( + segments.as_slice(), + [_] | [_, "services"] | [_, "services", _] | [_, "services", _, "operations"] | [_, "services", _, "operations", _] + ) +} + +/// PHP's operator routes for an operation's calls: execute, and a call's +/// show and cancel. +fn catalog_call_route(path: &str) -> bool { + let Some(rest) = path.strip_prefix("/api/service-endpoints/") else { + return false; + }; + let segments: Vec<&str> = rest.split('/').collect(); + segments.iter().all(|segment| !segment.is_empty()) + && matches!( + segments.as_slice(), + [_, "services", _, "operations", _, "execute"] + | [_, "services", _, "operations", _, "service-calls", _] + | [_, "services", _, "operations", _, "service-calls", _, "cancel"] + ) +} + +type CatalogQuery = Query>; +type Created = Result<(StatusCode, Json)>; + +/// The input a catalog write validates: `$request->all()`. +fn catalog_input(body: &[u8], query: &[(String, String)], level: super::service_endpoints::Level, create: bool) -> Result { + let rules = super::service_endpoints::rules_for(level, create); + Ok(super::service_endpoints::input(request_json(body)?, query, &rules)) +} + +/// `GET /api/service-endpoints`. +async fn service_endpoints_index(Extension(runtime): Extension) -> Result> { + runtime.list_service_endpoints().await.map(Json) +} + +/// `POST /api/service-endpoints`. +async fn service_endpoint_store(Extension(runtime): Extension, Query(query): CatalogQuery, body: axum::body::Bytes) -> Created { + let input = catalog_input(&body, &query, super::service_endpoints::Level::Endpoint, true)?; + Ok((StatusCode::CREATED, Json(runtime.create_service_endpoint(input).await?))) +} + +async fn service_endpoint_show(Extension(runtime): Extension, Path(endpoint): Path) -> Result> { + runtime.show_service_endpoint(&endpoint).await.map(Json) +} + +async fn service_endpoint_update( + Extension(runtime): Extension, + Path(endpoint): Path, + Query(query): CatalogQuery, + body: axum::body::Bytes, +) -> Result> { + let input = catalog_input(&body, &query, super::service_endpoints::Level::Endpoint, false)?; + runtime.update_service_endpoint(&endpoint, input).await.map(Json) +} + +async fn service_endpoint_destroy(Extension(runtime): Extension, Path(endpoint): Path) -> Result> { + runtime.delete_service_endpoint(&endpoint).await.map(Json) +} + +async fn services_index(Extension(runtime): Extension, Path(endpoint): Path) -> Result> { + runtime.list_services(&endpoint).await.map(Json) +} + +async fn service_store( + Extension(runtime): Extension, + Path(endpoint): Path, + Query(query): CatalogQuery, + body: axum::body::Bytes, +) -> Created { + let input = catalog_input(&body, &query, super::service_endpoints::Level::Service, true)?; + Ok((StatusCode::CREATED, Json(runtime.create_service(&endpoint, input).await?))) +} + +async fn service_show(Extension(runtime): Extension, Path((endpoint, service)): Path<(String, String)>) -> Result> { + runtime.show_service(&endpoint, &service).await.map(Json) +} + +async fn service_update( + Extension(runtime): Extension, + Path((endpoint, service)): Path<(String, String)>, + Query(query): CatalogQuery, + body: axum::body::Bytes, +) -> Result> { + let input = catalog_input(&body, &query, super::service_endpoints::Level::Service, false)?; + runtime.update_service(&endpoint, &service, input).await.map(Json) +} + +async fn service_destroy(Extension(runtime): Extension, Path((endpoint, service)): Path<(String, String)>) -> Result> { + runtime.delete_service(&endpoint, &service).await.map(Json) +} + +async fn operations_index(Extension(runtime): Extension, Path((endpoint, service)): Path<(String, String)>) -> Result> { + runtime.list_operations(&endpoint, &service).await.map(Json) +} + +async fn operation_store( + Extension(runtime): Extension, + Path((endpoint, service)): Path<(String, String)>, + Query(query): CatalogQuery, + body: axum::body::Bytes, +) -> Created { + let input = catalog_input(&body, &query, super::service_endpoints::Level::Operation, true)?; + Ok((StatusCode::CREATED, Json(runtime.create_operation(&endpoint, &service, input).await?))) +} + +async fn operation_show( + Extension(runtime): Extension, + Path((endpoint, service, operation)): Path<(String, String, String)>, +) -> Result> { + runtime.show_operation([&endpoint, &service, &operation]).await.map(Json) +} + +async fn operation_update( + Extension(runtime): Extension, + Path((endpoint, service, operation)): Path<(String, String, String)>, + Query(query): CatalogQuery, + body: axum::body::Bytes, +) -> Result> { + let input = catalog_input(&body, &query, super::service_endpoints::Level::Operation, false)?; + runtime.update_operation([&endpoint, &service, &operation], input).await.map(Json) +} + +async fn operation_destroy( + Extension(runtime): Extension, + Path((endpoint, service, operation)): Path<(String, String, String)>, +) -> Result> { + runtime.delete_operation([&endpoint, &service, &operation]).await.map(Json) +} + +/// `POST .../operations/{operation}/execute`. +async fn operation_execute( + Extension(runtime): Extension, + Extension(authenticated): Extension, + Path((endpoint, service, operation)): Path<(String, String, String)>, + Query(query): CatalogQuery, + body: axum::body::Bytes, +) -> Created { + let caller = super::service_calls::Caller::from_principal(&authenticated.principal); + let (status, body) = runtime + .execute_service_operation([&endpoint, &service, &operation], request_json(&body)?, &query, caller) + .await?; + Ok((status, Json(body))) +} + +/// `GET .../operations/{operation}/service-calls/{call}`. +async fn service_call_show( + Extension(runtime): Extension, + Path((endpoint, service, operation, call)): Path<(String, String, String, String)>, +) -> Result> { + runtime.show_service_call([&endpoint, &service, &operation], &call).await.map(Json) +} + +/// `POST .../service-calls/{call}/cancel`. +async fn service_call_cancel( + Extension(runtime): Extension, + Path((endpoint, service, operation, call)): Path<(String, String, String, String)>, + Query(query): CatalogQuery, + body: axum::body::Bytes, +) -> Created { + let (status, body) = runtime + .cancel_service_operation_call([&endpoint, &service, &operation], &call, request_json(&body)?, &query) + .await?; + Ok((status, Json(body))) +} + /// A request no native route serves gets PHP's answer for it: 501 when PHP /// serves it and native does not yet, else PHP's 405 or 404. async fn route_fallback(method: axum::http::Method, uri: axum::http::Uri) -> Response { diff --git a/rust/src/runtime/laravel_rules.rs b/rust/src/runtime/laravel_rules.rs index 1f470b118..a7c0669a7 100644 --- a/rust/src/runtime/laravel_rules.rs +++ b/rust/src/runtime/laravel_rules.rs @@ -42,6 +42,11 @@ pub(crate) enum Rule { Date, /// `regex:...`, as a predicate on the string. Pattern(fn(&str) -> bool), + /// `regex:...` as Laravel's `validateRegex` applies it: to a string, or + /// to a number's PHP string. + Regex(fn(&str) -> bool), + /// `prohibited`: the attribute is missing, null or empty. Implicit. + Prohibited, } pub(crate) type Rules = [(&'static str, &'static [Rule])]; @@ -219,7 +224,8 @@ fn message(rule: Rule, attribute: &str, rules: &[Rule], value: Option<&Value>) - "array" => format!("The {attribute} field must have between {min} and {max} items."), _ => format!("The {attribute} field must be between {min} and {max} characters."), }, - Rule::NotBlank | Rule::Pattern(_) => format!("The {attribute} field format is invalid."), + Rule::NotBlank | Rule::Pattern(_) | Rule::Regex(_) => format!("The {attribute} field format is invalid."), + Rule::Prohibited => format!("The {attribute} field is prohibited."), Rule::Date => format!("The {attribute} field must be a valid date."), Rule::Nullable | Rule::Sometimes => String::new(), } @@ -242,11 +248,21 @@ fn size(value: &Value, rules: &[Rule]) -> Option { Value::Array(items) => Some(items.len() as f64), Value::Object(fields) => Some(fields.len() as f64), Value::String(text) => Some(text.chars().count() as f64), - Value::Number(number) => number.as_f64(), + // Without a numeric rule a number is measured as its PHP string. + Value::Number(number) => Some(php_number(number).chars().count() as f64), _ => None, } } +/// PHP's `(string)` of a decoded JSON number: an integer's digits, else the +/// float's shortest form, with integers beyond int64 read as floats. +fn php_number(number: &serde_json::Number) -> String { + match number.as_i64() { + Some(whole) => whole.to_string(), + None => super::php_value::float(number.as_f64().unwrap_or_default(), false).replace('e', "E"), + } +} + fn passes(rule: Rule, data: &Value, value: Option<&Value>, rules: &[Rule]) -> bool { match rule { Rule::Required => filled(value), @@ -256,6 +272,12 @@ fn passes(rule: Rule, data: &Value, value: Option<&Value>, rules: &[Rule]) -> bo Rule::NotBlank => value.and_then(Value::as_str).is_some_and(|text| text.chars().any(|character| !character.is_whitespace())), Rule::String => value.is_some_and(Value::is_string), Rule::Pattern(matches) => value.and_then(Value::as_str).is_some_and(matches), + Rule::Regex(matches) => match value { + Some(Value::String(text)) => matches(text), + Some(Value::Number(number)) => matches(&php_number(number)), + _ => false, + }, + Rule::Prohibited => !filled(value), Rule::Date => value .and_then(Value::as_str) .is_some_and(|text| super::schedule_backfill::php_date(text).is_some()), @@ -339,7 +361,7 @@ fn attribute_errors(body: &Value, attribute: &str, expanded: bool, list: &[Rule] if matches!(rule, Rule::Nullable | Rule::Sometimes) { continue; } - let implicit = matches!(rule, Rule::Required | Rule::RequiredWith(_) | Rule::Present); + let implicit = matches!(rule, Rule::Required | Rule::RequiredWith(_) | Rule::Present | Rule::Prohibited); if !implicit && skipped(value, nullable) { continue; } diff --git a/rust/src/runtime/mod.rs b/rust/src/runtime/mod.rs index 7d8dbdfe3..0b5bbe60f 100644 --- a/rust/src/runtime/mod.rs +++ b/rust/src/runtime/mod.rs @@ -129,6 +129,8 @@ mod search_attributes; mod selection_summary; mod selections; mod service_catalog; +mod service_calls; +mod service_endpoints; mod sqlite; mod start_definition; mod start_request; diff --git a/rust/src/runtime/mysql.rs b/rust/src/runtime/mysql.rs index 81bc63261..fecdfe6a0 100644 --- a/rust/src/runtime/mysql.rs +++ b/rust/src/runtime/mysql.rs @@ -9,7 +9,7 @@ use sqlx::{ }; use std::{collections::BTreeMap, sync::OnceLock, time::Duration}; -pub const VERSION: i64 = 6; +pub const VERSION: i64 = 7; type Catalog = Vec<(String, String)>; pub const MARKER_SQL: &str = "CREATE TABLE dw_server_schema (engine VARCHAR(64) NOT NULL PRIMARY KEY,version BIGINT NOT NULL,bootstrap_checksum VARBINARY(48) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin"; @@ -48,7 +48,9 @@ impl Flavor { "\n", include_str!("../../migrations/mysql/0005_worker_session_holders.sql"), "\n", - include_str!("../../migrations/mysql/0006_stream_reclaims.sql") + include_str!("../../migrations/mysql/0006_stream_reclaims.sql"), + "\n", + include_str!("../../migrations/mysql/0007_service_calls.sql") ), ), Self::MariaDb => ( @@ -60,14 +62,16 @@ impl Flavor { "\n", include_str!("../../migrations/mariadb/0005_worker_session_holders.sql"), "\n", - include_str!("../../migrations/mariadb/0006_stream_reclaims.sql") + include_str!("../../migrations/mariadb/0006_stream_reclaims.sql"), + "\n", + include_str!("../../migrations/mariadb/0007_service_calls.sql") ), ), }; slot.get_or_init(|| { let mut migrations = Migrator::with_migrations(vec![Migration::new( VERSION, - "full frozen PHP schema, native worker incarnations, session holders and stream reclaim deadlines".into(), + "full frozen PHP schema, native worker incarnations, session holders, stream reclaim deadlines, service call deadlines and service admission counters".into(), MigrationType::Simple, sql.into_sql_str(), false, @@ -80,8 +84,8 @@ impl Flavor { } fn expected(self) -> Result { Ok(serde_json::from_str(&canonical(match self { - Self::MySql => include_str!("../../migrations/mysql/0006_catalog.json"), - Self::MariaDb => include_str!("../../migrations/mariadb/0006_catalog.json"), + Self::MySql => include_str!("../../migrations/mysql/0007_catalog.json"), + Self::MariaDb => include_str!("../../migrations/mariadb/0007_catalog.json"), }))?) } /// The single native migration, which a takeover records as applied. @@ -100,7 +104,9 @@ impl Flavor { "\n", include_str!("../../migrations/mysql/0005_worker_session_holders.sql"), "\n", - include_str!("../../migrations/mysql/0006_stream_reclaims.sql") + include_str!("../../migrations/mysql/0006_stream_reclaims.sql"), + "\n", + include_str!("../../migrations/mysql/0007_service_calls.sql") ), ), Self::MariaDb => ( @@ -110,7 +116,9 @@ impl Flavor { "\n", include_str!("../../migrations/mariadb/0005_worker_session_holders.sql"), "\n", - include_str!("../../migrations/mariadb/0006_stream_reclaims.sql") + include_str!("../../migrations/mariadb/0006_stream_reclaims.sql"), + "\n", + include_str!("../../migrations/mariadb/0007_service_calls.sql") ), ), }; @@ -130,6 +138,14 @@ impl Flavor { .replace( "CREATE TABLE dw_stream_reclaims", "CREATE TABLE IF NOT EXISTS dw_stream_reclaims", + ) + .replace( + "CREATE TABLE dw_service_call_deadlines", + "CREATE TABLE IF NOT EXISTS dw_service_call_deadlines", + ) + .replace( + "CREATE TABLE dw_service_admission_counters", + "CREATE TABLE IF NOT EXISTS dw_service_admission_counters", )) } pub(super) fn checksum(self) -> &'static [u8] { @@ -1367,6 +1383,25 @@ mod tests { db.remove().await; } + #[tokio::test] + #[ignore = "requires an isolated MySQL/MariaDB create-database role"] + async fn qualification_takeover_keeps_php_catalog_rows_that_native_then_serves() { + let db = Database::new().await; + php_layout(&db, Some("php-2.9.7")).await; + let mut connection = db.connection().await; + for statement in crate::runtime::service_endpoints::PHP_CATALOG_ROWS { + connection.execute(statement).await.unwrap(); + } + connection.close().await.unwrap(); + let fingerprint = backup_fingerprint(&db).await; + let report = takeover::take_over_mysql(db.options.clone(), &fingerprint, &TAKEOVER_QUEUES).await.unwrap(); + assert_eq!(report["status"], "taken_over", "{report}"); + let runtime = crate::runtime::Runtime::open_mysql(db.options.clone(), "test-token".into()).await.unwrap(); + crate::runtime::service_endpoints::assert_php_catalog_served(&runtime).await; + runtime.close().await; + db.remove().await; + } + #[tokio::test] #[ignore = "requires an isolated MySQL/MariaDB create-database role"] async fn qualification_takeover_converts_the_mariadb_driver_layout() { diff --git a/rust/src/runtime/namespace_cleanup.rs b/rust/src/runtime/namespace_cleanup.rs index 40aeb114b..e98980008 100644 --- a/rust/src/runtime/namespace_cleanup.rs +++ b/rust/src/runtime/namespace_cleanup.rs @@ -150,6 +150,7 @@ const NATIVE_CLEANUP: &[&str] = &[ "DELETE FROM dw_worker_registration_incarnations WHERE namespace=$1", "DELETE FROM dw_worker_session_holders WHERE namespace=$1", "DELETE FROM dw_stream_reclaims WHERE namespace=$1", + "DELETE FROM dw_service_call_deadlines WHERE namespace=$1", ]; /// Registered external payloads and payload columns holding a reference. diff --git a/rust/src/runtime/postgres.rs b/rust/src/runtime/postgres.rs index 66553bb59..0e50d8363 100644 --- a/rust/src/runtime/postgres.rs +++ b/rust/src/runtime/postgres.rs @@ -13,7 +13,7 @@ use sqlx::{ use super::{Result, refuse}; -pub const VERSION: i64 = 6; +pub const VERSION: i64 = 7; pub const CATALOG_QUERY: &str = include_str!("postgres-catalog.sql"); type Catalog = Vec<(String, String, Json)>; @@ -22,7 +22,7 @@ fn migrator() -> &'static Migrator { MIGRATOR.get_or_init(|| { let mut migrations = Migrator::with_migrations(vec![Migration::new( VERSION, - "full frozen PHP PostgreSQL schema, native worker incarnations, session holders and stream reclaim deadlines".into(), + "full frozen PHP PostgreSQL schema, native worker incarnations, session holders, stream reclaim deadlines, service call deadlines and service admission counters".into(), MigrationType::Simple, concat!( include_str!("../../migrations/postgres/0003_full_schema.sql"), @@ -31,7 +31,9 @@ fn migrator() -> &'static Migrator { "\n", include_str!("../../migrations/postgres/0005_worker_session_holders.sql"), "\n", - include_str!("../../migrations/postgres/0006_stream_reclaims.sql") + include_str!("../../migrations/postgres/0006_stream_reclaims.sql"), + "\n", + include_str!("../../migrations/postgres/0007_service_calls.sql") ) .into_sql_str(), false, @@ -61,7 +63,9 @@ pub(super) fn native_relations_sql() -> Result { "\n", include_str!("../../migrations/postgres/0005_worker_session_holders.sql"), "\n", - include_str!("../../migrations/postgres/0006_stream_reclaims.sql") + include_str!("../../migrations/postgres/0006_stream_reclaims.sql"), + "\n", + include_str!("../../migrations/postgres/0007_service_calls.sql") ), ) } @@ -95,7 +99,7 @@ pub(super) async fn php_source_catalog(connection: &mut PgConnection) -> Result< )); } let native: Catalog = - serde_json::from_str(include_str!("../../migrations/postgres/0006_catalog.json"))?; + serde_json::from_str(include_str!("../../migrations/postgres/0007_catalog.json"))?; let expected: Catalog = native .into_iter() .filter(|(kind, name, _)| { @@ -190,7 +194,7 @@ pub(super) async fn verify(connection: &mut PgConnection) -> Result<()> { // from SQL text. It retains types, precision, defaults, indexes, foreign // keys and sequence ownership while excluding live rows/sequence values. let expected: Catalog = - serde_json::from_str(include_str!("../../migrations/postgres/0006_catalog.json"))?; + serde_json::from_str(include_str!("../../migrations/postgres/0007_catalog.json"))?; let actual = catalog(connection).await?; if actual != expected { return Err(refuse( @@ -402,7 +406,7 @@ mod tests { #[test] fn compiled_catalog_preserves_long_object_identities_and_int64_limits() { let rows: Catalog = - serde_json::from_str(include_str!("../../migrations/postgres/0006_catalog.json")) + serde_json::from_str(include_str!("../../migrations/postgres/0007_catalog.json")) .unwrap(); let identities: std::collections::BTreeSet<_> = rows.iter().map(|(kind, name, _)| (kind, name)).collect(); @@ -933,6 +937,24 @@ mod tests { db.remove().await; } + #[tokio::test] + #[ignore = "requires a disposable PostgreSQL; run the qualification_ tests explicitly"] + async fn qualification_takeover_keeps_php_catalog_rows_that_native_then_serves() { + let db = Database::new().await; + let mut connection = php_297(&db).await; + for statement in crate::runtime::service_endpoints::PHP_CATALOG_ROWS { + sqlx::raw_sql(statement).execute(&mut connection).await.unwrap(); + } + connection.close().await.unwrap(); + let fingerprint = backup_fingerprint(&db).await; + let report = takeover::take_over_postgres(db.options.clone(), &fingerprint, &TAKEOVER_QUEUES).await.unwrap(); + assert_eq!(report["status"], "taken_over", "{report}"); + let runtime = crate::runtime::Runtime::open_postgres(db.options.clone(), "test-token".into()).await.unwrap(); + crate::runtime::service_endpoints::assert_php_catalog_served(&runtime).await; + runtime.close().await; + db.remove().await; + } + #[tokio::test] #[ignore = "requires a disposable PostgreSQL; run the qualification_ tests explicitly"] async fn qualification_takeover_of_the_frozen_layout_adds_only_native_relations() { diff --git a/rust/src/runtime/schema.rs b/rust/src/runtime/schema.rs index 62fd112b4..994c97a8d 100644 --- a/rust/src/runtime/schema.rs +++ b/rust/src/runtime/schema.rs @@ -11,14 +11,14 @@ use tokio::sync::OnceCell; use super::{Result, refuse}; -pub(super) const VERSION: i64 = 6; +pub(super) const VERSION: i64 = 7; fn migrator() -> &'static Migrator { static MIGRATOR: OnceLock = OnceLock::new(); MIGRATOR.get_or_init(|| { Migrator::with_migrations(vec![Migration::new( VERSION, - "full frozen PHP schema, native worker incarnations, session holders and stream reclaim deadlines".into(), + "full frozen PHP schema, native worker incarnations, session holders, stream reclaim deadlines, service call deadlines and service admission counters".into(), MigrationType::Simple, concat!( include_str!("../../migrations/sqlite/0003_full_schema.sql"), @@ -27,7 +27,9 @@ fn migrator() -> &'static Migrator { "\n", include_str!("../../migrations/sqlite/0005_worker_session_holders.sql"), "\n", - include_str!("../../migrations/sqlite/0006_stream_reclaims.sql") + include_str!("../../migrations/sqlite/0006_stream_reclaims.sql"), + "\n", + include_str!("../../migrations/sqlite/0007_service_calls.sql") ) .into_sql_str(), false, @@ -47,7 +49,9 @@ pub(super) fn native_relations_sql() -> Result { "\n", include_str!("../../migrations/sqlite/0005_worker_session_holders.sql"), "\n", - include_str!("../../migrations/sqlite/0006_stream_reclaims.sql") + include_str!("../../migrations/sqlite/0006_stream_reclaims.sql"), + "\n", + include_str!("../../migrations/sqlite/0007_service_calls.sql") ), ) } diff --git a/rust/src/runtime/service_calls.rs b/rust/src/runtime/service_calls.rs new file mode 100644 index 000000000..25a4237f7 --- /dev/null +++ b/rust/src/runtime/service_calls.rs @@ -0,0 +1,1974 @@ +//! Nexus service-call execution, as published Server 2.11.1 serves it +//! (`ServiceCatalogController::executeOperation`, `serviceCallShow` and +//! `serviceCallCancel` over Workflow 2.10.2's `DefaultServiceControlPlane`). +//! +//! An operator executes a registered operation. The request is validated as +//! Laravel does, then admitted at the service boundary, which writes an audit +//! row to `workflow_service_calls` for every decision: +//! - authorization (required roles, caller-namespace deny and allow lists, on +//! the endpoint, service and operation policies) and the circuit breaker; +//! - the shared admission limits: a per-minute rate and a concurrency limit +//! per caller boundary refuse 429 once spent; an invalid limit answers 503 +//! `service_boundary_admission_unavailable`, as PHP does. +//! +//! An admitted `activity_execution` or `invocable_http` operation is then +//! dispatched: the call stays `accepted`, stamped with an activity execution +//! or carrier request id (an uppercase ULID unless the binding names one). +//! A binding with no target is a handler failure, retried in the request as +//! the operation's `retry_policy` allows. The execute `arguments` are checked +//! and then dropped, as PHP drops them for these kinds. +//! +//! A deadline policy sets `call_deadline_at` at admission. An open call past +//! its deadline fails as `timed_out` on the next execute, replay, show or +//! cancel that reads it. A repeated idempotency key replays the first call, +//! including a rejected one, after re-checking authorization and the circuit. +//! +//! Responses merge the controller's call view with the control plane's, as +//! `array_replace` does. Show builds the controller half from the row as +//! read before a lazy expiry, as PHP does. +//! +//! Not in this slice, refused 501 `rust_capability_not_implemented` before +//! anything is written: +//! - a workflow-bound handler kind (start, signal, update, query workflow); +//! - an `external_payload` reference in `arguments`; +//! - cancelling a call whose policy propagates to a linked workflow. +//! +//! Differences: +//! - Native writes the audit row and the dispatch stamps in one serialized +//! transaction, where PHP commits each step on its own. The final rows are +//! the same; a retry backoff is waited out with the transaction committed. +//! - A call's deadline lives in the native-owned `dw_service_call_deadlines`, +//! not in PHP 2.11.1's `workflow_service_calls.call_deadline_at` column: +//! native keeps the frozen PHP tables and its own relations, so takeover +//! layouts stay recognisable. Takeover still refuses a source with service +//! calls; accepting one will copy their deadlines. +//! - The shared admission counters PHP keeps in its cache live in the +//! native-owned `dw_service_admission_counters`, counted under the +//! transition lock and written in the call's transaction: a per-minute rate +//! count that lives 120 s, and a concurrency lease that lives a day. Only a +//! sync call returns its lease when the request ends; an async call's stays +//! until it expires, as PHP's does. The namespace-wide limits and hard +//! ceilings run only at their default of none. +use super::laravel_rules::{self, Rule}; +use super::service_endpoints::{Endpoint, Operation, Service, deadline_instant, deadline_policy_error, php_document}; +use super::{Result, RuntimeError}; +use axum::http::StatusCode; +use chrono::{DateTime, Utc}; +use serde_json::{Map, Value, json}; + +const MODES: &[&str] = &["sync", "async"]; + +/// `executeOperation`'s rules, in PHP's order. +const EXECUTE_RULES: &laravel_rules::Rules = &[ + ("arguments", &[Rule::Nullable]), + ("payload_codec", &[Rule::Nullable, Rule::String, Rule::Max(191)]), + ("mode_override", &[Rule::Nullable, Rule::String, Rule::In(MODES)]), + ("wait_for", &[Rule::Nullable, Rule::String, Rule::In(&["accepted", "completed"])]), + ("wait_timeout_seconds", &[Rule::Nullable, Rule::Integer, Rule::Min(0), Rule::Max(86400)]), + ("idempotency_key", &[Rule::Nullable, Rule::String, Rule::Max(191)]), + ("caller_namespace", &[Rule::Nullable, Rule::String, Rule::Max(255)]), + ("caller_workflow_instance_id", &[Rule::Nullable, Rule::String, Rule::Max(191)]), + ("caller_workflow_run_id", &[Rule::Nullable, Rule::String, Rule::Max(26)]), + ("target_workflow_instance_id", &[Rule::Nullable, Rule::String, Rule::Max(191)]), + ("target_workflow_run_id", &[Rule::Nullable, Rule::String, Rule::Max(26)]), + ("connection", &[Rule::Nullable, Rule::String, Rule::Max(191)]), + ("queue", &[Rule::Nullable, Rule::String, Rule::Max(191)]), + ("business_key", &[Rule::Nullable, Rule::String, Rule::Max(191)]), + ("labels", &[Rule::Nullable, Rule::Array]), + ("memo", &[Rule::Nullable, Rule::Array]), + ("search_attributes", &[Rule::Nullable, Rule::Array]), + ("duplicate_start_policy", &[Rule::Nullable, Rule::String, Rule::In(&["reject_duplicate", "return_existing_active"])]), +]; + +/// `serviceCallCancel`'s rule. +const CANCEL_RULES: &laravel_rules::Rules = &[("reason", &[Rule::Nullable, Rule::String, Rule::Max(1000)])]; + +/// The rules the service boundary reads: the package defaults with the +/// server's, which native runs only at their defaults (`DW_SERVICE_BOUNDARY_*` +/// are refused at startup otherwise). +fn global_rules() -> Value { + json!({ + "default_action": "allow", + "authorization": {"required_roles": []}, + "namespaces": {"allow_callers": null, "deny_callers": [], "cross_namespace_default": "allow"}, + "rate_limit": {"requests_per_minute": null, "retry_after_seconds": 1, "sync_only": false}, + "concurrency": {"max_in_flight": null, "retry_after_seconds": 1, "sync_only": true}, + "circuit_break": {"open_targets": [], "retry_after_seconds": 30}, + }) +} + +const SHARED_POLICY: &str = "server.shared-service-boundary"; +const DEFAULT_POLICY: &str = "workflow.default-service-boundary"; +const TIMED_OUT: &str = "Service call deadline expired."; + +// --------------------------------------------------------------------------- +// PHP value helpers. + +/// `stringFrom`: a trimmed, non-empty string. +fn string_from(value: &Value) -> Option { + value.as_str().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned) +} + +/// `is_array`, as PHP reads a decoded JSON value. +fn is_array(value: &Value) -> bool { + value.is_array() || value.is_object() +} + +/// `arrayValue`: the value if it is an array, else `[]`. +fn array_value(value: &Value) -> Value { + if is_array(value) { value.clone() } else { json!([]) } +} + +/// `arrayValueOrNull`. +fn array_or_null(value: &Value) -> Value { + if is_array(value) { value.clone() } else { Value::Null } +} + +/// A PHP array's entries, keyed as PHP keys them. +fn entries(value: &Value) -> Vec<(String, Value)> { + match value { + Value::Object(fields) => fields.iter().map(|(key, value)| (key.clone(), value.clone())).collect(), + Value::Array(items) => items.iter().enumerate().map(|(index, value)| (index.to_string(), value.clone())).collect(), + _ => Vec::new(), + } +} + +/// A PHP array's values, in order (`array_values`). +fn values(value: &Value) -> Vec { + entries(value).into_iter().map(|(_, value)| value).collect() +} + +/// `$array[$key] ?? null`. +fn at<'a>(value: &'a Value, key: &str) -> &'a Value { + match value { + Value::Object(fields) => fields.get(key).unwrap_or(&Value::Null), + Value::Array(items) => key.parse::().ok().and_then(|index| items.get(index)).unwrap_or(&Value::Null), + _ => &Value::Null, + } +} + +/// `array_is_list` for a non-empty associative array: PHP reads an object +/// keyed 0..n as a list, which `php_document` has already made one. +fn is_assoc(value: &Value) -> bool { + value.as_object().is_some_and(|fields| !fields.is_empty()) +} + +/// `ServiceBoundaryRequest::mergePolicy`: associative arrays merge key by +/// key, recursively; lists, scalars and nulls replace. +fn merge_policy(policies: &[&Value]) -> Value { + let mut merged = Map::new(); + for policy in policies { + for (key, value) in entries(policy) { + let nested = is_assoc(&value) && merged.get(&key).is_some_and(is_assoc); + let value = if nested { merge_policy(&[&merged[&key], &value]) } else { value }; + merged.insert(key, value); + } + } + php_document(Value::Object(merged)) +} + +/// `mergeMetadata`: null when both sides are empty. +fn merge_metadata(metadata: &Value, extra: &Value) -> Value { + let base = array_value(metadata); + if values(&base).is_empty() && values(extra).is_empty() { + return Value::Null; + } + merge_policy(&[&base, extra]) +} + +/// PHP's array union `$left + $right`: the left side's keys win. +fn union(left: &Value, right: Value) -> Value { + let mut map = match right { + Value::Object(map) => map, + _ => Map::new(), + }; + for (key, value) in entries(left) { + map.insert(key, value); + } + Value::Object(map) +} + +/// `withoutAdmissionControls`: the rate and concurrency rules removed at +/// every associative level. +fn without_admission_controls(policy: &Value) -> Value { + let mut stripped = Map::new(); + for (key, value) in entries(policy) { + if matches!(key.as_str(), "rate_limit" | "concurrency" | "concurrency_limit") { + continue; + } + let value = if is_assoc(&value) { without_admission_controls(&value) } else { value }; + stripped.insert(key, value); + } + php_document(Value::Object(stripped)) +} + +/// `arrayAt($source, [$key])`. +fn array_at(source: &Value, key: &str) -> Value { + array_value(at(source, key)) +} + +/// `listAt`: the non-empty strings of the array at `path`, or none when the +/// path holds no array. +fn list_at(source: &Value, path: &[&str]) -> Option> { + let mut value = source; + for part in path { + if !is_array(value) { + return None; + } + match value { + Value::Object(fields) => value = fields.get(*part)?, + Value::Array(items) => value = items.get(part.parse::().ok()?)?, + _ => return None, + } + } + is_array(value).then(|| { + values(value) + .into_iter() + .filter_map(|entry| entry.as_str().filter(|text| !text.trim().is_empty()).map(str::to_owned)) + .collect() + }) +} + +fn first_list(source: &Value, paths: &[&[&str]]) -> Option> { + paths.iter().find_map(|path| list_at(source, path)) +} + +/// PHP's `(int)` of a decoded JSON value. +fn php_int(value: &Value) -> i64 { + match value { + Value::Bool(flag) => i64::from(*flag), + Value::Number(number) => number.as_i64().unwrap_or_else(|| { + let float = number.as_f64().unwrap_or_default(); + if float.is_finite() { float.trunc().clamp(i64::MIN as f64, i64::MAX as f64) as i64 } else { 0 } + }), + Value::String(text) => php_string_int(text), + Value::Array(items) => i64::from(!items.is_empty()), + Value::Object(fields) => i64::from(!fields.is_empty()), + Value::Null => 0, + } +} + +/// PHP's `(int)` of a string: its leading numeric prefix, after leading +/// whitespace, or 0. +fn php_string_int(text: &str) -> i64 { + let text = text.trim_start_matches([' ', '\t', '\n', '\r', '\x0b', '\x0c']); + let bytes = text.as_bytes(); + let mut end = usize::from(matches!(bytes.first(), Some(b'+' | b'-'))); + let digits = |from: usize| from + bytes[from..].iter().take_while(|byte| byte.is_ascii_digit()).count(); + let whole = digits(end); + let mut fraction = whole; + if bytes.get(whole) == Some(&b'.') { + fraction = digits(whole + 1); + } + if fraction == end || (fraction == end + 1 && whole == end) { + return 0; + } + end = fraction; + if matches!(bytes.get(end), Some(b'e' | b'E')) { + let sign = end + 1 + usize::from(matches!(bytes.get(end + 1), Some(b'+' | b'-'))); + let exponent = digits(sign); + if exponent > sign { + end = exponent; + } + } + let prefix = &text[..end]; + prefix.parse::().unwrap_or_else(|_| { + let float: f64 = prefix.parse().unwrap_or(0.0); + if float.is_finite() { float.trunc().clamp(i64::MIN as f64, i64::MAX as f64) as i64 } else { 0 } + }) +} + +/// `positiveInt`: an int, or a string of digits, above zero. +fn positive_int(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_i64().filter(|whole| *whole > 0), + Value::String(text) if !text.trim().is_empty() && text.trim().bytes().all(|byte| byte.is_ascii_digit()) => { + super::php_value::int_cast(text.trim()).filter(|whole| *whole > 0) + } + _ => None, + } +} + +/// `numericValue`: an int, a float, or a numeric string. +fn numeric_value(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_f64(), + Value::String(text) => super::php_value::numeric(text.trim()), + _ => None, + } +} + +/// `filter_var($text, FILTER_VALIDATE_INT)` for a string of digits. +fn validated_digits(text: &str) -> Option { + (text == "0" || !text.starts_with('0')).then(|| text.parse().ok()).flatten() +} + +// --------------------------------------------------------------------------- +// The service boundary. + +/// The caller as the audit row records it (`ServiceCallPrincipal`). +#[derive(Clone, Debug)] +pub(crate) struct Caller { + pub(crate) subject: String, + pub(crate) method: String, + pub(crate) roles: Vec, + pub(crate) tenant: Option, + pub(crate) claims: Value, +} + +impl Caller { + pub(crate) fn from_principal(principal: &super::runtime_credentials::Principal) -> Self { + Self { + subject: principal.subject.clone(), + method: principal.method.to_owned(), + roles: principal.roles.clone(), + tenant: principal.tenant.clone(), + claims: php_document(principal.claims.clone()), + } + } +} + +/// `ServiceBoundaryDecision`. +#[derive(Clone, Debug)] +struct Decision { + outcome: &'static str, + reason: String, + message: Option, + policy: String, + retry_after: Option, + metadata: Value, +} + +impl Decision { + fn allow(policy: &str, metadata: Value) -> Self { + Self { outcome: "accepted", reason: "accepted".into(), message: None, policy: policy.into(), retry_after: None, metadata } + } + + /// `denyAuthorization` and `denyNamespacePolicy`. + fn forbidden(reason: &str, message: String, axis: &str) -> Self { + Self { + outcome: "rejected_forbidden", + reason: reason.into(), + message: Some(message), + policy: DEFAULT_POLICY.into(), + retry_after: None, + metadata: json!({"failure_reason": "policy_rejection", "forbidden_axis": axis}), + } + } + + /// `SharedServiceBoundaryPolicy::unavailable`. + fn unavailable() -> Self { + Self { + outcome: "rejected_throttled", + reason: "service_boundary_admission_unavailable".into(), + message: Some("Shared service-call admission could not be evaluated.".into()), + policy: SHARED_POLICY.into(), + retry_after: Some(1), + metadata: json!({"failure_reason": "policy_rejection", "admission_scope": "shared_cache"}), + } + } + + fn allowed(&self) -> bool { + self.outcome == "accepted" + } + + /// `ServiceCallAdmission::httpStatus` for a denial. + fn http_status(&self) -> StatusCode { + if self.reason == "service_boundary_admission_unavailable" { + return StatusCode::SERVICE_UNAVAILABLE; + } + match self.outcome { + "rejected_forbidden" => StatusCode::FORBIDDEN, + "rejected_not_found" => StatusCode::NOT_FOUND, + "rejected_throttled" | "rejected_concurrency_limited" => StatusCode::TOO_MANY_REQUESTS, + "rejected_circuit_open" => StatusCode::SERVICE_UNAVAILABLE, + _ => StatusCode::CONFLICT, + } + } +} + +/// `ServiceCallOutcome::category`. +fn category(outcome: &str) -> &'static str { + match outcome { + "accepted" => "accepted", + "completed" | "cancelled" | "timed_out" | "degraded" | "handler_failed" => "handler", + _ => "rejected", + } +} + +/// `ServiceCallBindingKind::tryFromHandlerBindingKind`. +fn binding_kind(handler_binding_kind: &str) -> Option<&'static str> { + Some(match handler_binding_kind.trim().to_ascii_lowercase().as_str() { + "workflow_run" | "start_workflow" | "workflow_class" => "workflow_run", + "workflow_update" | "update_workflow" => "workflow_update", + "workflow_signal" | "signal_workflow" => "workflow_signal", + "workflow_query" | "query_workflow" => "workflow_query", + "activity_execution" => "activity_execution", + "invocable_carrier_request" | "invocable_http" => "invocable_carrier_request", + _ => return None, + }) +} + +/// `ServiceBoundaryRequest`, as `ServiceCallBoundary::requestFor` builds it. +struct BoundaryRequest<'a> { + caller: &'a Caller, + caller_namespace: String, + target_namespace: String, + endpoint: &'a Endpoint, + service: &'a Service, + operation: &'a Operation, + mode: &'static str, +} + +impl BoundaryRequest<'_> { + fn boundary_key(&self) -> String { + format!("{}|{}|{}|{}|{}", self.caller_namespace, self.target_namespace, self.endpoint.row.name, + self.service.row.name, self.operation.row.name) + } + + fn target_key(&self) -> String { + format!("{}|{}|{}|{}", self.target_namespace, self.endpoint.row.name, self.service.row.name, self.operation.row.name) + } + + fn policies(&self) -> [Value; 3] { + [ + array_value(&self.endpoint.boundary_policy), + array_value(&self.service.boundary_policy), + array_value(self.operation.document("boundary_policy")), + ] + } + + fn effective_policy(&self) -> Value { + let [endpoint, service, operation] = self.policies(); + merge_policy(&[&endpoint, &service, &operation]) + } + + /// `DefaultServiceBoundaryPolicy::evaluate` over the rules and request + /// without admission controls: authorization, then the circuit. + fn base_decision(&self) -> Decision { + let rules = without_admission_controls(&global_rules()); + if let Some(denial) = self.authorization(&rules) { + return denial; + } + let [endpoint, service, operation] = self.policies().map(|policy| without_admission_controls(&policy)); + let effective = merge_policy(&[&endpoint, &service, &operation]); + let circuit = merge_policy(&[&array_at(&rules, "circuit_break"), &array_at(&effective, "circuit_break")]); + let open_targets = list_at(&circuit, &["open_targets"]).unwrap_or_default(); + if at(&circuit, "open") == &Value::Bool(true) || open_targets.contains(&self.target_key()) { + let retry = at(&circuit, "retry_after_seconds"); + return Decision { + outcome: "rejected_circuit_open", + reason: "circuit_open".into(), + message: Some(format!("Service-boundary circuit is open for target [{}].", self.target_key())), + policy: DEFAULT_POLICY.into(), + retry_after: (!retry.is_null()).then(|| php_int(retry).max(0)), + metadata: json!({"failure_reason": "policy_rejection", "target_key": self.target_key()}), + }; + } + Decision::allow(DEFAULT_POLICY, json!({"boundary_key": self.boundary_key()})) + } + + /// `checkAuthorization`. + fn authorization(&self, rules: &Value) -> Option { + if let Some(denial) = self.required_roles(&array_at(rules, "authorization"), "operation") { + return Some(denial); + } + let [endpoint, service, operation] = self.policies(); + for (axis, policy) in [("endpoint", endpoint), ("service", service), ("operation", operation)] { + if let Some(denial) = self.required_roles(&policy, axis).or_else(|| self.caller_namespace_policy(&policy, axis)) { + return Some(denial); + } + } + let namespaces = array_at(rules, "namespaces"); + if let Some(denial) = self.caller_namespace_policy(&namespaces, "operation") { + return Some(denial); + } + if at(&namespaces, "cross_namespace_default").as_str().unwrap_or("allow") == "deny" + && self.caller_namespace != self.target_namespace + { + return Some(Decision::forbidden("cross_namespace_blocked", + format!("Cross-namespace calls from [{}] to [{}] are blocked by service-boundary policy.", + self.caller_namespace, self.target_namespace), "operation")); + } + if at(rules, "default_action").as_str().unwrap_or("allow") == "deny" { + return Some(Decision::forbidden("default_action_denied", "Service-boundary policy default action is deny.".into(), "operation")); + } + None + } + + /// `checkRequiredRoles`. + fn required_roles(&self, policy: &Value, axis: &str) -> Option { + let required = first_list(policy, &[&["required_roles"], &["authorization", "required_roles"]]).unwrap_or_default(); + if required.is_empty() || required.iter().any(|role| self.caller.roles.contains(role)) { + return None; + } + Some(Decision::forbidden("missing_required_role", + format!("Caller [{}] is missing one of the required roles for service-boundary admission.", self.caller.subject), axis)) + } + + /// `checkCallerNamespacePolicy`. The caller namespace is never null here. + fn caller_namespace_policy(&self, policy: &Value, axis: &str) -> Option { + let caller = &self.caller_namespace; + let deny = first_list(policy, &[&["deny_callers"], &["caller_namespaces", "deny"], &["caller_namespaces", "deny_callers"], + &["authorization", "deny_callers"], &["authorization", "caller_namespaces", "deny"], + &["authorization", "caller_namespaces", "deny_callers"]]).unwrap_or_default(); + if deny.contains(caller) { + return Some(Decision::forbidden("caller_namespace_denied", + format!("Caller namespace [{caller}] is explicitly denied by service-boundary policy."), axis)); + } + let allow = first_list(policy, &[&["allow_callers"], &["caller_namespaces", "allow"], &["caller_namespaces", "allow_callers"], + &["authorization", "allow_callers"], &["authorization", "caller_namespaces", "allow"], + &["authorization", "caller_namespaces", "allow_callers"]])?; + (!allow.contains(caller)).then(|| Decision::forbidden("caller_namespace_not_allowed", + format!("Caller namespace [{caller}] is not on the service-boundary allow list for target namespace [{}].", + self.target_namespace), axis)) + } + + /// `SharedServiceBoundaryPolicy::limits`, or an error for an invalid one + /// (PHP's `unavailable`). Native runs the namespace and hard limits only + /// at their default of none. + fn limits(&self) -> std::result::Result { + let rules = global_rules(); + let effective = self.effective_policy(); + let rate = merge_policy(&[&array_at(&rules, "rate_limit"), &array_at(&effective, "rate_limit")]); + let concurrency = merge_policy(&[&array_at(&rules, "concurrency"), &array_at(&effective, "concurrency_limit")]); + let skips = |rules: &Value| at(rules, "sync_only") == &Value::Bool(true) && self.mode != "sync"; + let rate_limit = if skips(&rate) { None } else { limit(at(&rate, "requests_per_minute"))? }; + let concurrency_limit = if skips(&concurrency) { None } else { limit(at(&concurrency, "max_in_flight"))? }; + Ok(Limits { + rate: rate_limit, + concurrency: concurrency_limit, + rate_retry: retry_after_seconds(&rate)?, + concurrency_retry: retry_after_seconds(&concurrency)?, + }) + } + + /// `boundaryRateKey` for this minute and `boundaryConcurrencyKey`. + fn counter_keys(&self, now: DateTime) -> (String, String) { + use sha2::{Digest, Sha256}; + let hash: String = Sha256::digest(self.boundary_key().as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect(); + (format!("server:service-boundary:rate:{hash}:{}", now.timestamp().div_euclid(60)), + format!("server:service-boundary:concurrency:{hash}")) + } +} + +/// The boundary limits in force for one request. +#[derive(Clone, Copy, Debug)] +struct Limits { + rate: Option, + concurrency: Option, + rate_retry: i64, + concurrency_retry: i64, +} + +/// The rate counter's life and the concurrency lease's +/// (`concurrency_lease_ttl_seconds`, which native runs at its default). +const RATE_TTL_SECONDS: i64 = 120; +const CONCURRENCY_TTL_SECONDS: i64 = 86_400; + +/// `SharedServiceBoundaryPolicy::limit`: null, empty or zero is no limit. +fn limit(value: &Value) -> std::result::Result, ()> { + match value { + Value::Null => Ok(None), + Value::String(text) if text.is_empty() || text == "0" => Ok(None), + Value::Number(number) if number.as_i64() == Some(0) && !number.is_f64() => Ok(None), + Value::Number(number) => number.as_i64().filter(|whole| *whole > 0).map(Some).ok_or(()), + Value::String(text) if text.bytes().all(|byte| byte.is_ascii_digit()) && !text.starts_with('0') => { + validated_digits(text).filter(|whole| *whole >= 1).map(Some).ok_or(()) + } + _ => Err(()), + } +} + +/// `configuredRetryAfterSeconds`: whether the rule's value is valid. +fn retry_after_seconds(rules: &Value) -> std::result::Result { + match at(rules, "retry_after_seconds") { + Value::Null => Ok(1), + Value::String(text) if text.is_empty() => Ok(1), + Value::Number(number) => number.as_i64().filter(|whole| *whole >= 0).map(|whole| whole.min(60)).ok_or(()), + Value::String(text) if text.bytes().all(|byte| byte.is_ascii_digit()) => { + validated_digits(text).map(|whole| whole.min(60)).ok_or(()) + } + _ => Err(()), + } +} + +// --------------------------------------------------------------------------- +// The call row. + +/// A `workflow_service_calls` row. JSON columns hold PHP's reading of them. +#[derive(Clone, Debug, Default)] +pub(crate) struct Call { + id: String, + endpoint_id: Option, + service_id: Option, + operation_id: Option, + namespace: Option, + endpoint_name: String, + service_name: String, + operation_name: String, + caller_namespace: Option, + caller_instance: Option, + caller_run: Option, + target_namespace: Option, + linked_instance: Option, + linked_run: Option, + linked_update: Option, + status: String, + outcome: Option, + operation_mode: String, + binding_kind: Option, + target_reference: Option, + payload_codec: Option, + input_reference: Option, + output_reference: Option, + failure_reference: Option, + failure_message: Option, + idempotency_key: Option, + deadline_policy: Value, + idempotency_policy: Value, + cancellation_policy: Value, + retry_policy: Value, + boundary_policy: Value, + metadata: Value, + outcome_category: Option, + outcome_reason: Option, + outcome_message: Option, + outcome_metadata: Value, + policy_name: Option, + retry_after: Option, + principal_subject: Option, + principal_method: Option, + principal_roles: Value, + principal_tenant: Option, + principal_claims: Value, + accepted_at: Option>, + call_deadline_at: Option>, + started_at: Option>, + completed_at: Option>, + failed_at: Option>, + cancelled_at: Option>, + created_at: Option>, + updated_at: Option>, +} + +impl Call { + fn is_open(&self) -> bool { + matches!(self.status.as_str(), "pending" | "accepted" | "started") + } + + fn is_terminal(&self) -> bool { + matches!(self.status.as_str(), "completed" | "failed" | "cancelled") + } + + fn is_failed(&self) -> bool { + self.status == "failed" + } + + /// `acceptedShape`. + fn accepted_shape(&self) -> bool { + self.status != "failed" && self.status != "cancelled" + } + + /// `ServiceCallDeadline::expireLocked`. + fn expire(&mut self, instant: DateTime) -> bool { + let Some(deadline) = self.call_deadline_at else { return false }; + if !self.is_open() || self.accepted_at.is_none() || deadline > instant { + return false; + } + let mut metadata = array_value(&self.metadata); + if let Value::Object(fields) = &mut metadata { + fields.remove("nexus_result"); + } + self.status = "failed".into(); + self.outcome = Some("timed_out".into()); + self.outcome_category = Some("handler".into()); + self.outcome_reason = Some("timed_out".into()); + self.outcome_message = Some(TIMED_OUT.into()); + self.failure_message = Some(TIMED_OUT.into()); + self.outcome_metadata = json!({"service_error_type": "ServiceCallTimedOut", "caller_observed_error_type": "ServiceCallTimedOut", + "typed_error_message": TIMED_OUT, "call_deadline_at": micros(Some(deadline))}); + self.failed_at = Some(instant); + self.completed_at = None; + self.output_reference = None; + self.failure_reference = None; + self.metadata = php_document(metadata); + true + } + + /// `serviceCallAttempts`. + fn attempts(&self) -> Vec { + for container in [&self.metadata, &self.outcome_metadata] { + let attempts = at(container, "service_call_attempts"); + if is_array(attempts) { + return values(attempts).into_iter().filter(is_array).collect(); + } + } + Vec::new() + } +} + +/// `Y-m-d\TH:i:s.u\Z`. +fn micros(at: Option>) -> Value { + at.map_or(Value::Null, |at| json!(at.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string())) +} + +/// Carbon's `toIso8601String`. +fn iso(at: Option>) -> Value { + at.map_or(Value::Null, |at| json!(at.to_rfc3339_opts(chrono::SecondsFormat::Secs, false))) +} + +/// `failureTypeFrom`. +fn failure_type(source: &Value) -> Option { + for key in ["service_error_type", "serviceErrorType", "caller_observed_error_type", "callerObservedErrorType", "failure_type", + "failureType", "error_type", "errorType", "typed_error", "typedError", "exception_type", "exceptionType"] + { + if let Some(value) = string_from(at(source, key)) { + return Some(value); + } + } + ["control_plane", "handler", "error", "failure"].iter().find_map(|key| { + let nested = at(source, key); + if is_array(nested) { failure_type(nested) } else { None } + }) +} + +// --------------------------------------------------------------------------- +// Catalog reads (`ServiceCatalogReadController::callsIndex` and `callShow`). + +/// `ServiceCallStatus::bucket`, for a known status. +fn status_bucket(status: &str) -> Option<&'static str> { + match status { + "pending" | "accepted" | "started" => Some("open"), + "completed" => Some("completed"), + "failed" => Some("failed"), + "cancelled" => Some("cancelled"), + _ => None, + } +} + +/// `ServiceCallOutcome::bucket`, for a known outcome. +fn outcome_bucket(outcome: &str) -> Option<&'static str> { + match outcome { + "accepted" => Some("open"), + "completed" | "degraded" => Some("completed"), + "cancelled" => Some("cancelled"), + "timed_out" | "handler_failed" => Some("failed"), + "rejected_not_found" | "rejected_forbidden" | "rejected_throttled" | "rejected_concurrency_limited" + | "rejected_circuit_open" => Some("policy"), + _ => None, + } +} + +/// `ServiceCallView::listItem`. +pub(crate) fn catalog_list_item(call: &Call) -> Value { + let outcome = call.outcome.as_deref().filter(|outcome| outcome_bucket(outcome).is_some()); + let attempts = call.attempts(); + let pairs: Vec<(&str, Value)> = vec![ + ("id", json!(call.id)), + ("workflow_service_endpoint_id", json!(call.endpoint_id)), + ("workflow_service_id", json!(call.service_id)), + ("workflow_service_operation_id", json!(call.operation_id)), + ("namespace", json!(call.namespace)), + ("endpoint_name", json!(call.endpoint_name)), + ("service_name", json!(call.service_name)), + ("operation_name", json!(call.operation_name)), + ("caller_namespace", json!(call.caller_namespace)), + ("caller_workflow_instance_id", json!(call.caller_instance)), + ("caller_workflow_run_id", json!(call.caller_run)), + ("caller_principal_subject", json!(call.principal_subject)), + ("caller_principal_method", json!(call.principal_method)), + ("caller_principal_roles", array_value(&call.principal_roles)), + ("caller_principal_tenant", json!(call.principal_tenant)), + ("target_namespace", json!(call.target_namespace)), + ("linked_workflow_instance_id", json!(call.linked_instance)), + ("linked_workflow_run_id", json!(call.linked_run)), + ("linked_workflow_update_id", json!(call.linked_update)), + ("status", json!(call.status)), + ("status_bucket", json!(status_bucket(&call.status))), + ("outcome", json!(outcome)), + ("outcome_bucket", json!(outcome.and_then(outcome_bucket))), + ("is_terminal", json!(call.is_terminal())), + ("is_policy_outcome", json!(outcome.and_then(outcome_bucket) == Some("policy"))), + ("operation_mode", json!(call.operation_mode)), + ("resolved_binding_kind", json!(call.binding_kind)), + ("resolved_target_reference", json!(call.target_reference)), + ("idempotency_key", json!(call.idempotency_key)), + ("retry_policy", array_value(&call.retry_policy)), + ("retry_attempt_count", json!(attempts.len())), + ("service_call_attempts", Value::Array(attempts)), + ("failure_message", json!(call.failure_message)), + ("accepted_at", micros(call.accepted_at)), + ("call_deadline_at", micros(call.call_deadline_at)), + ("started_at", micros(call.started_at)), + ("completed_at", micros(call.completed_at)), + ("failed_at", micros(call.failed_at)), + ("cancelled_at", micros(call.cancelled_at)), + ("created_at", micros(call.created_at)), + ("updated_at", micros(call.updated_at)), + ]; + Value::Object(pairs.into_iter().map(|(key, value)| (key.to_owned(), value)).collect()) +} + +/// `ServiceCallResultEnvelope::fromCall`. +fn result_envelope(call: &Call) -> Value { + let result = at(&call.metadata, "nexus_result"); + if !is_array(result) { + return Value::Null; + } + if at(result, "blob").is_string() { + return result.clone(); + } + let external = at(result, "external_reference"); + match (&call.output_reference, external) { + (Some(uri), Value::Object(reference)) => { + let mut storage = reference.clone(); + storage.entry("uri").or_insert_with(|| json!(uri)); + json!({"codec": at(result, "codec"), "external_storage": storage}) + } + _ => Value::Null, + } +} + +/// `callShow` over `ServiceCallView::detail`, read from `observer`'s +/// namespace: a call owned elsewhere hides its payload references. +pub(crate) fn catalog_detail(call: &Call, observer: &str) -> Value { + let mut view = catalog_list_item(call); + let in_observer = |namespace: &Option| namespace.as_deref() == Some(observer); + let reference = |namespace: &Option, fields: Vec<(&str, Value)>| { + let mut fields: Map = fields.into_iter().map(|(key, value)| (key.to_owned(), value)).collect(); + fields.insert("namespace".into(), json!(namespace)); + fields.insert("in_observer_namespace".into(), json!(in_observer(namespace))); + Value::Object(fields) + }; + let owned = in_observer(&call.namespace); + let mut metadata = array_value(&call.metadata); + if !owned && let Value::Object(fields) = &mut metadata { + fields.remove("nexus_result"); + } + let hidden = |value: &Option| if owned { json!(value) } else { Value::Null }; + let detail: Vec<(&str, Value)> = vec![ + ("payload_codec", json!(call.payload_codec)), + ("input_payload_reference", hidden(&call.input_reference)), + ("output_payload_reference", hidden(&call.output_reference)), + ("failure_payload_reference", hidden(&call.failure_reference)), + ("deadline_policy", array_value(&call.deadline_policy)), + ("idempotency_policy", array_value(&call.idempotency_policy)), + ("cancellation_policy", array_value(&call.cancellation_policy)), + ("boundary_policy", array_value(&call.boundary_policy)), + ("metadata", metadata), + ("caller_principal_claims", array_value(&call.principal_claims)), + ("caller_link", if call.caller_instance.is_none() && call.caller_run.is_none() { + Value::Null + } else { + reference(&call.caller_namespace, vec![("workflow_instance_id", json!(call.caller_instance)), + ("workflow_run_id", json!(call.caller_run))]) + }), + ("linked_run_ref", if call.linked_instance.is_none() && call.linked_run.is_none() { + Value::Null + } else { + reference(&call.target_namespace, vec![("workflow_instance_id", json!(call.linked_instance)), + ("workflow_run_id", json!(call.linked_run))]) + }), + ("linked_update_ref", if call.linked_update.is_none() { + Value::Null + } else { + reference(&call.target_namespace, vec![("workflow_instance_id", json!(call.linked_instance)), + ("workflow_update_id", json!(call.linked_update)), ("workflow_run_id", json!(call.linked_run))]) + }), + ("payloads_in_observer_namespace", json!(owned)), + ("result_envelope", if owned { result_envelope(call) } else { Value::Null }), + ("failure_type", json!(failure_type(&array_value(&call.outcome_metadata)))), + ]; + let fields = view.as_object_mut().expect("a list item is an object"); + for (key, value) in detail { + fields.insert(key.to_owned(), value); + } + view +} + +/// The controller's `serializeServiceCall`. +fn controller_view(call: &Call, endpoint: &Endpoint, service: &Service, operation: &Operation) -> Map { + let metadata_string = |key: &str| string_from(at(&call.outcome_metadata, key)).map_or(Value::Null, Value::from); + let attempts = call.attempts(); + let pairs: Vec<(&str, Value)> = vec![ + ("id", json!(call.id)), + ("namespace", json!(call.namespace)), + ("endpoint_id", json!(endpoint.row.id)), + ("endpoint_name", json!(endpoint.row.name)), + ("service_id", json!(service.row.id)), + ("service_name", json!(service.row.name)), + ("operation_id", json!(operation.row.id)), + ("operation_name", json!(operation.row.name)), + ("caller_namespace", json!(call.caller_namespace)), + ("caller_workflow_instance_id", json!(call.caller_instance)), + ("caller_workflow_run_id", json!(call.caller_run)), + ("target_namespace", json!(call.target_namespace)), + ("linked_workflow_instance_id", json!(call.linked_instance)), + ("linked_workflow_run_id", json!(call.linked_run)), + ("linked_workflow_update_id", json!(call.linked_update)), + ("status", json!(call.status)), + ("outcome", json!(call.outcome)), + ("outcome_category", json!(call.outcome_category)), + ("outcome_reason", json!(call.outcome_reason)), + ("outcome_message", json!(call.outcome_message)), + ("outcome_metadata", call.outcome_metadata.clone()), + ("service_error_type", metadata_string("service_error_type")), + ("caller_observed_error_type", metadata_string("caller_observed_error_type")), + ("typed_error_message", metadata_string("typed_error_message")), + ("policy_name", json!(call.policy_name)), + ("retry_after_seconds", json!(call.retry_after)), + ("operation_mode", json!(call.operation_mode)), + ("resolved_binding_kind", json!(call.binding_kind)), + ("resolved_target_reference", json!(call.target_reference)), + ("caller_principal_subject", json!(call.principal_subject)), + ("caller_principal_method", json!(call.principal_method)), + ("caller_principal_roles", call.principal_roles.clone()), + ("caller_principal_tenant", json!(call.principal_tenant)), + ("caller_principal_claims", call.principal_claims.clone()), + ("payload_codec", json!(call.payload_codec)), + ("input_payload_reference", json!(call.input_reference)), + ("output_payload_reference", json!(call.output_reference)), + ("failure_payload_reference", json!(call.failure_reference)), + ("failure_message", json!(call.failure_message)), + ("idempotency_key", json!(call.idempotency_key)), + ("deadline_policy", call.deadline_policy.clone()), + ("idempotency_policy", call.idempotency_policy.clone()), + ("cancellation_policy", call.cancellation_policy.clone()), + ("retry_policy", call.retry_policy.clone()), + ("boundary_policy", call.boundary_policy.clone()), + ("metadata", call.metadata.clone()), + ("retry_attempt_count", json!(attempts.len())), + ("service_call_attempts", Value::Array(attempts)), + ("accepted_at", micros(call.accepted_at)), + ("call_deadline_at", micros(call.call_deadline_at)), + ("started_at", micros(call.started_at)), + ("completed_at", micros(call.completed_at)), + ("failed_at", micros(call.failed_at)), + ("cancelled_at", micros(call.cancelled_at)), + ("created_at", iso(call.created_at)), + ("updated_at", iso(call.updated_at)), + ]; + pairs.into_iter().map(|(key, value)| (key.to_owned(), value)).collect() +} + +/// The control plane's `serialize`, on a call already reconciled. +fn plane_view(call: &Call) -> Map { + let attempts = call.attempts(); + let pairs: Vec<(&str, Value)> = vec![ + ("service_call_id", json!(call.id)), + ("namespace", json!(call.namespace)), + ("caller_namespace", json!(call.caller_namespace)), + ("target_namespace", json!(call.target_namespace)), + ("endpoint_name", json!(call.endpoint_name)), + ("service_name", json!(call.service_name)), + ("operation_name", json!(call.operation_name)), + ("operation_mode", json!(call.operation_mode)), + ("status", json!(call.status)), + ("outcome", json!(call.outcome)), + ("outcome_category", json!(call.outcome_category)), + ("outcome_reason", json!(call.outcome_reason)), + ("resolved_binding_kind", json!(call.binding_kind)), + ("resolved_target_reference", json!(call.target_reference)), + ("linked_workflow_instance_id", json!(call.linked_instance)), + ("linked_workflow_run_id", json!(call.linked_run)), + ("linked_workflow_update_id", json!(call.linked_update)), + ("payload_codec", json!(call.payload_codec)), + ("input_payload_reference", json!(call.input_reference)), + ("output_payload_reference", json!(call.output_reference)), + ("failure_payload_reference", json!(call.failure_reference)), + ("retry_attempt_count", json!(attempts.len())), + ("service_call_attempts", Value::Array(attempts)), + ("accepted_at", micros(call.accepted_at)), + ("call_deadline_at", micros(call.call_deadline_at)), + ("started_at", micros(call.started_at)), + ("completed_at", micros(call.completed_at)), + ("failed_at", micros(call.failed_at)), + ("cancelled_at", micros(call.cancelled_at)), + ("failure_message", json!(call.failure_message)), + ]; + let mut view: Map = pairs.into_iter().map(|(key, value)| (key.to_owned(), value)).collect(); + if let Some(kind) = failure_type(&array_value(&call.outcome_metadata)) { + for key in ["error_type", "failure_type", "service_error_type"] { + view.insert(key.into(), json!(kind)); + } + } + let message = string_from(at(&call.outcome_metadata, "typed_error_message")) + .or_else(|| call.outcome_message.as_deref().and_then(|text| string_from(&json!(text)))) + .or_else(|| call.failure_message.as_deref().and_then(|text| string_from(&json!(text)))); + if call.is_failed() + && let Some(message) = message + { + view.insert("message".into(), json!(message)); + } + view +} + +/// `array_replace($first, $second)`. +fn replaced(mut first: Map, second: Map) -> Value { + first.extend(second); + Value::Object(first) +} + +fn with(mut view: Map, extra: Value) -> Map { + if let Value::Object(fields) = extra { + view.extend(fields); + } + view +} + +/// `serializeAdmissionRejection`, with the target redacted for a forbidden +/// call. +fn rejection_view(call: &Call, decision: &Decision, endpoint: &Endpoint, service: &Service, operation: &Operation) -> Value { + let redacted = decision.outcome == "rejected_forbidden"; + let mut view = json!({ + "accepted": false, "service_call_id": call.id, "namespace": call.namespace, "status": call.status, + "outcome": decision.outcome, "error_type": decision.outcome, "outcome_category": call.outcome_category, + "reason": decision.reason, "retry_after_seconds": decision.retry_after, "policy_name": decision.policy, + "caller_namespace": call.caller_namespace, "caller_principal_subject": call.principal_subject, + "linked_workflow_instance_id": null, "linked_workflow_run_id": null, "linked_workflow_update_id": null, + }); + if redacted { + let failure_reason = match at(&decision.metadata, "failure_reason") { + Value::String(text) => text.clone(), + Value::Number(number) => number.to_string(), + Value::Bool(flag) => if *flag { "1".into() } else { String::new() }, + _ => "policy_rejection".into(), + }; + view["message"] = json!("Nexus operation is not permitted for this caller."); + view["outcome_metadata"] = json!({"failure_reason": failure_reason}); + } else { + view["message"] = json!(decision.message); + view["outcome_metadata"] = decision.metadata.clone(); + for (key, value) in [("endpoint_id", &endpoint.row.id), ("endpoint_name", &endpoint.row.name), + ("service_id", &service.row.id), ("service_name", &service.row.name), ("operation_id", &operation.row.id), + ("operation_name", &operation.row.name)] + { + view[key] = json!(value); + } + view["operation_mode"] = json!(call.operation_mode); + } + view +} + +/// `serviceCallNotFound`. +fn call_not_found(endpoint: &Endpoint, service: &Service, operation: &Operation, id: &str) -> RuntimeError { + let namespace = service.row.namespace.as_deref().unwrap_or_default(); + RuntimeError::Protocol { + status: StatusCode::NOT_FOUND, + response: json!({ + "message": format!("Service call [{id}] not found under operation [{}] for service [{}] at endpoint [{}] in namespace [{namespace}].", + operation.row.name, service.row.name, endpoint.row.name), + "reason": "service_call_not_found", "namespace": service.row.namespace, "endpoint_name": endpoint.row.name, + "service_name": service.row.name, "operation_name": operation.row.name, "service_call_id": id}), + } +} + +fn not_implemented() -> RuntimeError { + super::refuse(StatusCode::NOT_IMPLEMENTED, "rust_capability_not_implemented") +} + +/// `ServiceCallDeadlinePolicy` failures at execute (`assertDeadlinePolicy`). +fn invalid(field: &str, message: &str) -> RuntimeError { + laravel_rules::failure(vec![(field.to_owned(), vec![message.to_owned()])]).expect("a message") +} + +// --------------------------------------------------------------------------- +// Dispatch helpers. + +/// `maxServiceCallAttempts`: the first positive attempt count, at most 10. +fn max_attempts(policy: &Value) -> i64 { + ["max_attempts", "maximum_attempts", "maximumAttempts"] + .iter() + .find_map(|key| positive_int(at(policy, key))) + .map_or(1, |attempts| attempts.min(10)) +} + +/// `serviceCallBackoffSeconds`. +fn backoff_seconds(policy: &Value, completed_attempt: i64) -> f64 { + for key in ["backoff_seconds", "backoffSeconds"] { + if let Some(backoff) = policy.as_object().and_then(|fields| fields.get(key)) { + let value = if is_array(backoff) { + values(backoff).get(usize::try_from(completed_attempt - 1).unwrap_or(0)).and_then(numeric_value) + } else { + numeric_value(backoff) + }; + return value.unwrap_or(0.0).max(0.0); + } + } + let first = |keys: &[&str]| keys.iter().find_map(|key| numeric_value(at(policy, key))); + let initial = first(&["initial_interval_seconds", "initialIntervalSeconds", "initial_interval", "initialInterval"]).unwrap_or(0.0); + let coefficient = first(&["backoff_coefficient", "backoffCoefficient"]).unwrap_or(2.0); + let maximum = first(&["maximum_interval_seconds", "max_interval_seconds", "maximumIntervalSeconds", "maxIntervalSeconds"]); + let mut backoff = initial * coefficient.powi(i32::try_from((completed_attempt - 1).max(0)).unwrap_or(0)); + if let Some(maximum) = maximum.filter(|maximum| *maximum >= 0.0) { + backoff = backoff.min(maximum); + } + backoff.max(0.0) +} + +/// `nonRetryableFailureTypes`. +fn non_retryable(policy: &Value) -> Vec { + for key in ["non_retryable_error_types", "nonRetryableErrorTypes", "non_retryable_failure_types", "nonRetryableFailureTypes"] { + let types = list_at(policy, &[key]).unwrap_or_default(); + if !types.is_empty() { + return types; + } + } + Vec::new() +} + +/// `cancellationAllowed`. +fn cancellation_allowed(policy: &Value) -> bool { + let policy = array_value(policy); + if ["allow_cancel", "cancellable", "allow_external_cancel"] + .iter() + .any(|key| policy.as_object().is_some_and(|fields| fields.get(*key) == Some(&Value::Bool(false)))) + { + return false; + } + at(&policy, "mode").as_str() != Some("none") +} + +/// A new uppercase ULID, as `(string) Str::ulid()` makes one. +fn upper_ulid() -> String { + super::store::id() +} + +/// What a binding names, as `bindingString` reads it. +fn binding_string(binding: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| string_from(at(binding, key))) +} + +/// The execute request after its checks: what the boundary and the +/// handlers read. +#[derive(Clone, Debug, Default)] +pub(crate) struct Execution { + caller_namespace: String, + mode_override: Option, + idempotency_key: Option, + caller_instance: Option, + caller_run: Option, + connection: Option, + queue: Option, +} + +// --------------------------------------------------------------------------- +// The request checks before admission. + +/// `RuntimeExternalPayloadTransport::resolveIncoming` on `arguments`, before +/// the route runs. +fn transport(body: &Value) -> Result<()> { + let Some(arguments) = body.get("arguments").filter(|arguments| is_array(arguments)) else { + return Ok(()); + }; + if !at(arguments, "external_storage").is_null() || arguments.as_object().is_some_and(|fields| fields.contains_key("external_storage")) { + return Err(super::external_transport::provider_reference()); + } + let Some(fields) = arguments.as_object().filter(|fields| fields.contains_key("external_payload")) else { + return Ok(()); + }; + let mut keys: Vec<&str> = fields.keys().map(String::as_str).collect(); + keys.sort_unstable(); + if keys != ["codec", "external_payload"] || !is_array(&fields["external_payload"]) { + return Err(super::external_transport::typed_error(StatusCode::UNPROCESSABLE_ENTITY, "external_payload_unsupported", + "External payload envelopes must contain exactly codec and external_payload.")); + } + // Resolving and claiming a runtime reference here is a later slice. + Err(not_implemented()) +} + +/// The `arguments` checks of `executeOperation`: the field and blob that +/// must decode as one Avro value, if any. +fn arguments_blob(arguments: &Value) -> Result> { + use super::input_envelope::codec_refusal; + match arguments { + Value::String(blob) => Ok(Some(("arguments", blob.clone()))), + Value::Object(fields) if fields.contains_key("codec") => { + if let Some(message) = codec_refusal(&fields["codec"]) { + return Err(invalid("arguments.codec", &message)); + } + let mut keys: Vec<&str> = fields.keys().map(String::as_str).collect(); + keys.sort_unstable(); + if keys != ["blob", "codec"] { + // The transport refuses `external_storage` before the route. + return Err(invalid("arguments", "The arguments envelope must contain only codec and blob, or codec and external storage.")); + } + if !fields["codec"].as_str().is_some_and(|codec| !codec.is_empty()) { + return Err(invalid("arguments.codec", "The payload envelope codec must be a non-empty string.")); + } + match &fields["blob"] { + Value::String(blob) => Ok(Some(("arguments.blob", blob.clone()))), + _ => Err(invalid("arguments.blob", "The payload envelope blob must be a string.")), + } + } + _ => Ok(None), + } +} + +impl super::Runtime { + /// `ServiceCatalogController::executeOperation`. + pub(crate) async fn execute_service_operation( + &self, + names: [&str; 3], + body: Value, + query: &[(String, String)], + caller: Caller, + ) -> Result<(StatusCode, Value)> { + let body = match body { + Value::Object(fields) => Value::Object(fields), + _ => json!({}), + }; + transport(&laravel_rules::normalize(&body))?; + self.service_operation_exists(names).await?; + let input = super::service_endpoints::input(body, query, EXECUTE_RULES); + laravel_rules::validate(&input, EXECUTE_RULES)?; + let validated = laravel_rules::validated(&input, EXECUTE_RULES); + if let Some(message) = super::input_envelope::codec_refusal(at(&validated, "payload_codec")) { + return Err(invalid("payload_codec", &message)); + } + if let Some((field, blob)) = arguments_blob(at(&validated, "arguments"))? + && !self.avro_value_decodes(blob).await? + { + return Err(invalid(field, super::command_payloads::UNDECODABLE)); + } + let attributes = at(&validated, "search_attributes"); + if !attributes.is_null() { + self.validate_service_call_search_attributes(attributes.clone()).await?; + } + let text = |key: &str| at(&validated, key).as_str().map(str::to_owned); + let execution = Execution { + caller_namespace: text("caller_namespace").unwrap_or_else(|| self.namespace().to_owned()), + mode_override: text("mode_override"), + idempotency_key: text("idempotency_key"), + caller_instance: text("caller_workflow_instance_id"), + caller_run: text("caller_workflow_run_id"), + connection: text("connection"), + queue: text("queue"), + }; + self.execute_service_call(names, execution, caller).await + } + + /// `ServiceCatalogController::serviceCallCancel`: the call first, then the + /// input. + pub(crate) async fn cancel_service_operation_call( + &self, + names: [&str; 3], + id: &str, + body: Value, + query: &[(String, String)], + ) -> Result<(StatusCode, Value)> { + self.service_call_exists(names, id).await?; + let input = super::service_endpoints::input(body, query, CANCEL_RULES); + laravel_rules::validate(&input, CANCEL_RULES)?; + let reason = string_from(at(&laravel_rules::validated(&input, CANCEL_RULES), "reason")); + self.cancel_service_call(names, id, reason).await + } +} + +store_methods! { + pub(crate) fn service_call_row(row: &DB::Row) -> Result { + let text = |field: &str| -> Result> { DB::optional_string(row, field) }; + let document = |field: &str| -> Result { Ok(DB::optional_document_row(row, field)?.map_or(Value::Null, php_document)) }; + let time = |field: &str| -> Result>> { DB::optional_instant(row, field) }; + Ok(Call { + id: DB::string(row, "id")?, + endpoint_id: text("workflow_service_endpoint_id")?, + service_id: text("workflow_service_id")?, + operation_id: text("workflow_service_operation_id")?, + namespace: text("namespace")?, + endpoint_name: DB::string(row, "endpoint_name")?, + service_name: DB::string(row, "service_name")?, + operation_name: DB::string(row, "operation_name")?, + caller_namespace: text("caller_namespace")?, + caller_instance: text("caller_workflow_instance_id")?, + caller_run: text("caller_workflow_run_id")?, + target_namespace: text("target_namespace")?, + linked_instance: text("linked_workflow_instance_id")?, + linked_run: text("linked_workflow_run_id")?, + linked_update: text("linked_workflow_update_id")?, + status: DB::string(row, "status")?, + outcome: text("outcome")?, + operation_mode: DB::string(row, "operation_mode")?, + binding_kind: text("resolved_binding_kind")?, + target_reference: text("resolved_target_reference")?, + payload_codec: text("payload_codec")?, + input_reference: text("input_payload_reference")?, + output_reference: text("output_payload_reference")?, + failure_reference: text("failure_payload_reference")?, + failure_message: text("failure_message")?, + idempotency_key: text("idempotency_key")?, + deadline_policy: document("deadline_policy")?, + idempotency_policy: document("idempotency_policy")?, + cancellation_policy: document("cancellation_policy")?, + retry_policy: document("retry_policy")?, + boundary_policy: document("boundary_policy")?, + metadata: document("metadata")?, + outcome_category: text("outcome_category")?, + outcome_reason: text("outcome_reason")?, + outcome_message: text("outcome_message")?, + outcome_metadata: document("outcome_metadata")?, + policy_name: text("policy_name")?, + retry_after: DB::optional_number(row, "retry_after_seconds")?, + principal_subject: text("caller_principal_subject")?, + principal_method: text("caller_principal_method")?, + principal_roles: document("caller_principal_roles")?, + principal_tenant: text("caller_principal_tenant")?, + principal_claims: document("caller_principal_claims")?, + accepted_at: time("accepted_at")?, + call_deadline_at: time("call_deadline_at")?, + started_at: time("started_at")?, + completed_at: time("completed_at")?, + failed_at: time("failed_at")?, + cancelled_at: time("cancelled_at")?, + created_at: time("created_at")?, + updated_at: time("updated_at")?, + }) + } + + async fn insert_service_call(connection: &mut DB::Connection, call: &Call) -> Result<()> { + Self::query("INSERT INTO workflow_service_calls(id,workflow_service_endpoint_id,workflow_service_id,workflow_service_operation_id,namespace,endpoint_name,service_name,operation_name,caller_namespace,caller_workflow_instance_id,caller_workflow_run_id,target_namespace,linked_workflow_instance_id,linked_workflow_run_id,linked_workflow_update_id,status,outcome,operation_mode,resolved_binding_kind,resolved_target_reference,payload_codec,input_payload_reference,output_payload_reference,failure_payload_reference,failure_message,idempotency_key,deadline_policy,idempotency_policy,cancellation_policy,retry_policy,boundary_policy,metadata,outcome_category,outcome_reason,outcome_message,outcome_metadata,policy_name,retry_after_seconds,caller_principal_subject,caller_principal_method,caller_principal_roles,caller_principal_tenant,caller_principal_claims,accepted_at,started_at,completed_at,failed_at,cancelled_at,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41,$42,$43,$44,$45,$46,$47,$48,$49,$50)") + .bind(call.id.as_str()).bind(call.endpoint_id.as_deref()).bind(call.service_id.as_deref()) + .bind(call.operation_id.as_deref()).bind(call.namespace.as_deref()).bind(call.endpoint_name.as_str()) + .bind(call.service_name.as_str()).bind(call.operation_name.as_str()).bind(call.caller_namespace.as_deref()) + .bind(call.caller_instance.as_deref()).bind(call.caller_run.as_deref()).bind(call.target_namespace.as_deref()) + .bind(call.linked_instance.as_deref()).bind(call.linked_run.as_deref()).bind(call.linked_update.as_deref()) + .bind(call.status.as_str()).bind(call.outcome.as_deref()).bind(call.operation_mode.as_str()) + .bind(call.binding_kind.as_deref()).bind(call.target_reference.as_deref()).bind(call.payload_codec.as_deref()) + .bind(call.input_reference.as_deref()).bind(call.output_reference.as_deref()).bind(call.failure_reference.as_deref()) + .bind(call.failure_message.as_deref()).bind(call.idempotency_key.as_deref()) + .bind(DB::optional_document(&call.deadline_policy)).bind(DB::optional_document(&call.idempotency_policy)) + .bind(DB::optional_document(&call.cancellation_policy)).bind(DB::optional_document(&call.retry_policy)) + .bind(DB::optional_document(&call.boundary_policy)).bind(DB::optional_document(&call.metadata)) + .bind(call.outcome_category.as_deref()).bind(call.outcome_reason.as_deref()).bind(call.outcome_message.as_deref()) + .bind(DB::optional_document(&call.outcome_metadata)).bind(call.policy_name.as_deref()) + .bind(DB::optional_integer(call.retry_after)).bind(call.principal_subject.as_deref()) + .bind(call.principal_method.as_deref()).bind(DB::optional_document(&call.principal_roles)) + .bind(call.principal_tenant.as_deref()).bind(DB::optional_document(&call.principal_claims)) + .bind(DB::optional_time(call.accepted_at)) + .bind(DB::optional_time(call.started_at)).bind(DB::optional_time(call.completed_at)) + .bind(DB::optional_time(call.failed_at)).bind(DB::optional_time(call.cancelled_at)) + .bind(DB::optional_time(call.created_at)).bind(DB::optional_time(call.updated_at)) + .execute(&mut *connection).await?; + if let Some(deadline) = call.call_deadline_at { + Self::query("INSERT INTO dw_service_call_deadlines(service_call_id,namespace,call_deadline_at) VALUES ($1,$2,$3)") + .bind(call.id.as_str()).bind(call.namespace.as_deref().unwrap_or_default()).bind(DB::bind_time(deadline)) + .execute(&mut *connection).await?; + } + Ok(()) + } + + /// Writes the row's changeable columns and moves `updated_at`, as an + /// Eloquent save of a dirty model does. + async fn save_service_call(connection: &mut DB::Connection, call: &mut Call) -> Result<()> { + call.updated_at = Some(super::store::now()); + Self::query("UPDATE workflow_service_calls SET linked_workflow_instance_id=$1,linked_workflow_run_id=$2,linked_workflow_update_id=$3,status=$4,outcome=$5,resolved_binding_kind=$6,resolved_target_reference=$7,output_payload_reference=$8,failure_payload_reference=$9,failure_message=$10,metadata=$11,outcome_category=$12,outcome_reason=$13,outcome_message=$14,outcome_metadata=$15,started_at=$16,completed_at=$17,failed_at=$18,cancelled_at=$19,updated_at=$20 WHERE id=$21") + .bind(call.linked_instance.as_deref()).bind(call.linked_run.as_deref()).bind(call.linked_update.as_deref()) + .bind(call.status.as_str()).bind(call.outcome.as_deref()).bind(call.binding_kind.as_deref()) + .bind(call.target_reference.as_deref()).bind(call.output_reference.as_deref()) + .bind(call.failure_reference.as_deref()).bind(call.failure_message.as_deref()) + .bind(DB::optional_document(&call.metadata)).bind(call.outcome_category.as_deref()) + .bind(call.outcome_reason.as_deref()).bind(call.outcome_message.as_deref()) + .bind(DB::optional_document(&call.outcome_metadata)).bind(DB::optional_time(call.started_at)) + .bind(DB::optional_time(call.completed_at)).bind(DB::optional_time(call.failed_at)) + .bind(DB::optional_time(call.cancelled_at)).bind(DB::optional_time(call.updated_at)).bind(call.id.as_str()) + .execute(&mut *connection).await?; + Ok(()) + } + + /// `LinkedServiceCallCloser::reconcile` for these handler kinds: the lazy + /// deadline expiry of an open call. + async fn reconcile_service_call(connection: &mut DB::Connection, call: &mut Call) -> Result<()> { + if call.expire(super::store::now()) { + Self::save_service_call(connection, call).await?; + } + Ok(()) + } + + /// `findServiceCall`: by namespace, operation and the trimmed id. + async fn find_service_call(&self, connection: &mut DB::Connection, operation: &Operation, id: &str) -> Result> { + Self::query("SELECT c.*,d.call_deadline_at FROM workflow_service_calls c LEFT JOIN dw_service_call_deadlines d ON d.service_call_id=c.id WHERE c.namespace=$1 AND c.workflow_service_operation_id=$2 AND c.id=$3 LIMIT 1") + .bind(self.namespace.as_ref()).bind(operation.row.id.as_str()).bind(id.trim()) + .fetch_optional(&mut *connection).await? + .map(|row| Self::service_call_row(&row)).transpose() + } + + /// The catalog lookups `executeOperation` answers before any input check. + pub(crate) async fn service_operation_exists(&self, names: [&str; 3]) -> Result<()> { + let mut tx = self.begin_read().await?; + self.required_operation(&mut tx, names).await?; + tx.commit().await?; + Ok(()) + } + + /// The catalog and call lookups `serviceCallCancel` answers before its input check. + pub(crate) async fn service_call_exists(&self, names: [&str; 3], id: &str) -> Result<()> { + let mut tx = self.begin_read().await?; + let (endpoint, service, operation) = self.required_operation(&mut tx, names).await?; + if self.find_service_call(&mut tx, &operation, id).await?.is_none() { + return Err(call_not_found(&endpoint, &service, &operation, id.trim())); + } + tx.commit().await?; + Ok(()) + } + + /// `SearchAttributeValueValidator::validateForNamespace` on the execute + /// request's attributes; PHP keeps only their types, which these + /// handlers never read. + pub(crate) async fn validate_service_call_search_attributes(&self, attributes: Value) -> Result<()> { + let definitions = Self::query("SELECT name,type FROM search_attribute_definitions WHERE namespace=$1") + .bind(self.namespace.as_ref()).fetch_all(&self.pool).await? + .iter() + .map(|row| Ok((DB::string(row, "name")?, DB::string(row, "type")?))) + .collect::>>()?; + let attributes: Map = entries(&attributes).into_iter().collect(); + let limits = super::search_attribute_upserts::Limits::from_env()?; + super::search_attribute_upserts::validate(&definitions, &attributes, &Map::new(), &limits) + .map(|_| ()) + .map_err(|messages| laravel_rules::failure(vec![("search_attributes".to_owned(), messages)]).expect("messages")) + } + + /// The rest of `executeOperation`: idempotent replay, admission, dispatch. + pub(crate) async fn execute_service_call(&self, names: [&str; 3], execution: Execution, caller: Caller) -> Result<(StatusCode, Value)> { + let mut tx = self.begin().await?; + let (endpoint, service, operation) = self.required_operation(&mut tx, names).await?; + let mode = match execution.mode_override.as_deref().or(Some(operation.mode())).map(|mode| mode.trim().to_ascii_lowercase()) { + Some(mode) if mode == "sync" => "sync", + _ => "async", + }; + let request = BoundaryRequest { + caller: &caller, + caller_namespace: execution.caller_namespace.clone(), + target_namespace: operation.row.namespace.clone().unwrap_or_default(), + endpoint: &endpoint, + service: &service, + operation: &operation, + mode, + }; + let kind = binding_kind(operation.kind()); + if let Some(key) = execution.idempotency_key.as_deref().map(str::trim).filter(|key| !key.is_empty()) + && let Some(mut prior) = Self::query("SELECT c.*,d.call_deadline_at FROM workflow_service_calls c LEFT JOIN dw_service_call_deadlines d ON d.service_call_id=c.id WHERE c.namespace=$1 AND c.workflow_service_operation_id=$2 AND c.caller_namespace=$3 AND c.idempotency_key=$4 ORDER BY c.created_at,c.id LIMIT 1") + .bind(self.namespace.as_ref()).bind(operation.row.id.as_str()).bind(execution.caller_namespace.as_str()).bind(key) + .fetch_optional(&mut *tx).await? + .map(|row| Self::service_call_row(&row)).transpose()? + { + // `authorizeReplay`: authorization and the circuit, no limits. + let decision = request.base_decision(); + if !decision.allowed() { + let call = self.admission_row(&request, &execution, kind, &decision); + Self::insert_service_call(&mut tx, &call).await?; + tx.commit().await?; + return Ok((decision.http_status(), rejection_view(&call, &decision, &endpoint, &service, &operation))); + } + // Native commits a call's dispatch with its admission, so an + // accepted call here has issued its handler. + let accepted = prior.accepted_shape(); + Self::reconcile_service_call(&mut tx, &mut prior).await?; + tx.commit().await?; + let body = with(json!({"accepted": accepted, "idempotent_replay": true, "reason": null}).as_object().cloned().unwrap_or_default(), + Value::Object(with(Map::from_iter([("found".to_owned(), json!(true))]), Value::Object(plane_view(&prior))))); + return Ok((if accepted { StatusCode::OK } else { StatusCode::CONFLICT }, Value::Object(body))); + } + if let Some(message) = deadline_policy_error(&array_or_null(operation.document("deadline_policy"))) { + return Err(invalid("deadline_policy", message)); + } + if !matches!(kind, Some("activity_execution" | "invocable_carrier_request")) { + return Err(not_implemented()); + } + let mut decision = request.base_decision(); + // A sync call's concurrency lease, released when the request ends. + let mut lease = None; + if decision.allowed() { + decision = match request.limits() { + Err(()) => Decision::unavailable(), + Ok(limits) if limits.rate.is_none() && limits.concurrency.is_none() => { + Decision::allow(SHARED_POLICY, decision.metadata) + } + Ok(limits) => { + let (admitted, reserved) = Self::admit_with_counters(&mut tx, &request, limits, decision.metadata).await?; + if mode == "sync" { + lease = reserved; + } + admitted + } + }; + } + let mut call = self.admission_row(&request, &execution, kind, &decision); + Self::insert_service_call(&mut tx, &call).await?; + if !decision.allowed() { + tx.commit().await?; + return Ok((decision.http_status(), rejection_view(&call, &decision, &endpoint, &service, &operation))); + } + // `markAccepted` writes nothing new; then an already-past deadline. + if call.expire(super::store::now()) { + Self::save_service_call(&mut tx, &mut call).await?; + tx.commit().await?; + self.release_lease(lease).await?; + let plane = with(plane_view(&call), json!({"accepted": true, "idempotent_replay": false, "reason": call.outcome_reason})); + return Ok((StatusCode::OK, replaced(controller_view(&call, &endpoint, &service, &operation), plane))); + } + // PHP releases in a `finally`, so a failed dispatch returns it too. + let dispatched = self.dispatch_service_call(tx, &mut call, &operation, &execution).await; + self.release_lease(lease).await?; + let handler = dispatched?; + let reason = call.is_failed().then(|| call.outcome_reason.clone().unwrap_or_else(|| "handler_failed".into())); + let accepted = call.accepted_shape(); + let plane = with(plane_view(&call), json!({"accepted": accepted, "idempotent_replay": false, "handler": handler, "reason": reason})); + Ok((if accepted { StatusCode::OK } else { StatusCode::CONFLICT }, + replaced(controller_view(&call, &endpoint, &service, &operation), plane))) + } + + /// A shared admission counter: its value until it expires, else 0. + async fn admission_counter(connection: &mut DB::Connection, key: &str, now: DateTime) -> Result { + let row = Self::query("SELECT amount,expires_at FROM dw_service_admission_counters WHERE cache_key=$1") + .bind(key).fetch_optional(&mut *connection).await?; + Ok(match row { + Some(row) if DB::instant(&row, "expires_at")? > now => DB::number(&row, "amount")?.max(0), + _ => 0, + }) + } + + /// Stores a counter's new value with a fresh life, as the cache's `put` does. + async fn put_admission_counter(connection: &mut DB::Connection, key: &str, amount: i64, ttl: i64, now: DateTime) -> Result<()> { + Self::query("DELETE FROM dw_service_admission_counters WHERE cache_key=$1").bind(key).execute(&mut *connection).await?; + if amount > 0 { + Self::query("INSERT INTO dw_service_admission_counters(cache_key,amount,expires_at) VALUES ($1,$2,$3)") + .bind(key).bind(amount).bind(DB::bind_time(now + chrono::Duration::seconds(ttl))) + .execute(&mut *connection).await?; + } + Ok(()) + } + + /// `admitWithLock` under the transition lock: the rate, then the + /// concurrency limit; an admitted call counts against both. Returns the + /// decision and the concurrency lease it took, if any. + async fn admit_with_counters( + connection: &mut DB::Connection, + request: &BoundaryRequest<'_>, + limits: Limits, + base_metadata: Value, + ) -> Result<(Decision, Option)> { + let now = super::store::now(); + Self::query("DELETE FROM dw_service_admission_counters WHERE expires_at<=$1") + .bind(DB::bind_time(now)).execute(&mut *connection).await?; + let (rate_key, concurrency_key) = request.counter_keys(now); + let rate = Self::admission_counter(connection, &rate_key, now).await?; + let concurrency = Self::admission_counter(connection, &concurrency_key, now).await?; + let denied = |outcome: &'static str, reason: &str, message: &str, retry: i64, metadata: Value| Decision { + outcome, + reason: reason.into(), + message: Some(message.into()), + policy: SHARED_POLICY.into(), + retry_after: Some(retry), + metadata: union(&json!({"failure_reason": "policy_rejection"}), metadata), + }; + if let Some(limit) = limits.rate.filter(|limit| rate >= *limit) { + return Ok((denied("rejected_throttled", "rate_limit_exceeded", "Shared service-call rate capacity is exhausted.", + limits.rate_retry, json!({"admission_scope": "boundary", "observed_window_count": rate, "requests_per_minute": limit})), None)); + } + if let Some(limit) = limits.concurrency.filter(|limit| concurrency >= *limit) { + return Ok((denied("rejected_concurrency_limited", "concurrency_limit_exceeded", + "Shared service-call concurrency capacity is exhausted.", limits.concurrency_retry, + json!({"admission_scope": "boundary", "observed_in_flight": concurrency, "max_in_flight": limit})), None)); + } + if limits.rate.is_some() { + Self::put_admission_counter(connection, &rate_key, rate + 1, RATE_TTL_SECONDS, now).await?; + } + let lease = if limits.concurrency.is_some() { + Self::put_admission_counter(connection, &concurrency_key, concurrency + 1, CONCURRENCY_TTL_SECONDS, now).await?; + Some(concurrency_key) + } else { + None + }; + let metadata = union(&base_metadata, json!({"admission_scope": "shared_cache", "boundary_rate_limit": limits.rate, + "namespace_rate_limit": null, "boundary_concurrency_limit": limits.concurrency, "namespace_concurrency_limit": null})); + Ok((Decision::allow(SHARED_POLICY, metadata), lease)) + } + + /// `SharedServiceBoundaryPolicy::release` of a sync call's lease. + async fn release_lease(&self, lease: Option) -> Result<()> { + let Some(key) = lease else { return Ok(()) }; + let mut tx = self.begin().await?; + let now = super::store::now(); + let current = Self::admission_counter(&mut tx, &key, now).await?; + Self::put_admission_counter(&mut tx, &key, current - 1, CONCURRENCY_TTL_SECONDS, now).await?; + tx.commit().await?; + Ok(()) + } + + /// `ServiceBoundaryAuditRecorder::record`. + fn admission_row(&self, request: &BoundaryRequest<'_>, execution: &Execution, kind: Option<&'static str>, decision: &Decision) -> Call { + let now = super::store::now(); + let operation = request.operation; + let effective = request.effective_policy(); + let metadata = if values(&decision.metadata).is_empty() { Value::Null } else { decision.metadata.clone() }; + let deadline_policy = array_or_null(operation.document("deadline_policy")); + let allowed = decision.allowed(); + Call { + id: super::store::id().to_ascii_lowercase(), + endpoint_id: Some(request.endpoint.row.id.clone()), + service_id: Some(request.service.row.id.clone()), + operation_id: Some(operation.row.id.clone()), + namespace: Some(request.target_namespace.clone()), + endpoint_name: request.endpoint.row.name.clone(), + service_name: request.service.row.name.clone(), + operation_name: operation.row.name.clone(), + caller_namespace: Some(request.caller_namespace.clone()), + caller_instance: execution.caller_instance.clone(), + caller_run: execution.caller_run.clone(), + target_namespace: Some(request.target_namespace.clone()), + status: if allowed { "accepted" } else { "failed" }.into(), + outcome: Some(decision.outcome.into()), + operation_mode: request.mode.into(), + binding_kind: Some(kind.unwrap_or("unresolved").into()), + target_reference: kind.and(operation.target().map(str::to_owned)), + idempotency_key: execution.idempotency_key.clone(), + call_deadline_at: if allowed { deadline_instant(&deadline_policy, now) } else { None }, + deadline_policy, + idempotency_policy: array_or_null(operation.document("idempotency_policy")), + cancellation_policy: array_or_null(operation.document("cancellation_policy")), + retry_policy: array_or_null(operation.document("retry_policy")), + boundary_policy: if values(&effective).is_empty() { Value::Null } else { effective }, + outcome_metadata: metadata.clone(), + metadata, + outcome_category: Some(category(decision.outcome).into()), + outcome_reason: Some(decision.reason.clone()), + outcome_message: decision.message.clone(), + policy_name: Some(decision.policy.clone()), + retry_after: decision.retry_after, + principal_subject: Some(request.caller.subject.clone()), + principal_method: Some(request.caller.method.clone()), + principal_roles: if request.caller.roles.is_empty() { Value::Null } else { json!(request.caller.roles) }, + principal_tenant: request.caller.tenant.clone(), + principal_claims: if values(&request.caller.claims).is_empty() { Value::Null } else { request.caller.claims.clone() }, + accepted_at: allowed.then_some(now), + failed_at: (!allowed).then_some(now), + failure_message: if allowed { None } else { decision.message.clone() }, + created_at: Some(now), + updated_at: Some(now), + ..Call::default() + } + } + + /// `dispatchHandler`: each attempt, its bookkeeping, and the retry + /// backoff waited out with the transaction committed. + async fn dispatch_service_call( + &self, + mut tx: super::transaction::Transaction<'static, DB>, + call: &mut Call, + operation: &Operation, + execution: &Execution, + ) -> Result { + let max = max_attempts(&call.retry_policy); + let mut result = Value::Null; + for attempt in 1..=max { + if call.expire(super::store::now()) { + Self::save_service_call(&mut tx, call).await?; + } + if !call.is_open() { + tx.commit().await?; + return Ok(json!({"accepted": call.accepted_shape(), "reason": call.outcome_reason, "retry_scheduled": false})); + } + let started = super::store::now(); + result = Self::dispatch_attempt(&mut tx, call, operation, execution, attempt, max).await?; + Self::reconcile_service_call(&mut tx, call).await?; + let retry = attempt < max && result["retry_scheduled"] == json!(true); + let backoff = retry.then(|| backoff_seconds(&call.retry_policy, attempt)); + let entry = Self::attempt_entry(call, &result, attempt, started, retry, backoff); + Self::record_attempt(&mut tx, call, entry).await?; + if !retry { + break; + } + if let Some(backoff) = backoff.filter(|backoff| *backoff > 0.0) { + tx.commit().await?; + tokio::time::sleep(std::time::Duration::from_secs_f64(backoff.min(86400.0))).await; + tx = self.begin().await?; + *call = Self::query("SELECT c.*,d.call_deadline_at FROM workflow_service_calls c LEFT JOIN dw_service_call_deadlines d ON d.service_call_id=c.id WHERE c.id=$1") + .bind(call.id.as_str()).fetch_one(&mut *tx).await + .map_err(RuntimeError::from) + .and_then(|row| Self::service_call_row(&row))?; + } + } + // The response reads the call again, which may expire it. + Self::reconcile_service_call(&mut tx, call).await?; + tx.commit().await?; + Ok(result) + } + + /// One attempt of an `activity_execution` or `invocable_carrier_request` + /// handler. + async fn dispatch_attempt( + connection: &mut DB::Connection, + call: &mut Call, + operation: &Operation, + execution: &Execution, + attempt: i64, + max: i64, + ) -> Result { + let binding = array_value(operation.document("handler_binding")); + let target = operation.target().and_then(|target| string_from(&json!(target))); + if binding_kind(operation.kind()) == Some("activity_execution") { + let class = binding_string(&binding, &["activity_class", "class"]).or(target); + let Some(kind) = binding_string(&binding, &["activity_type", "type"]).or_else(|| class.clone()) else { + return Self::handler_failure(connection, call, "handler_target_missing", "Activity binding has no activity class or type.", attempt, max).await; + }; + let id = binding_string(&binding, &["activity_execution_id"]).unwrap_or_else(upper_ulid); + let mut metadata = json!({"activity_execution_id": id, "activity_class": class, "activity_type": kind}); + for (key, value) in [("connection", &execution.connection), ("queue", &execution.queue)] { + if let Some(value) = binding_string(&binding, &[key]).or_else(|| value.clone()) { + metadata[key] = json!(value); + } + } + call.binding_kind = Some("activity_execution".into()); + call.target_reference = Some(id.clone()); + call.linked_instance = binding_string(&binding, &["workflow_instance_id", "owning_workflow_instance_id"]); + call.linked_run = binding_string(&binding, &["workflow_run_id", "owning_workflow_run_id"]); + call.linked_update = None; + call.metadata = merge_metadata(&call.metadata, &metadata); + Self::save_service_call(connection, call).await?; + return Ok(json!({"accepted": true, "kind": "activity_execution", "activity_execution_id": id, + "activity_class": class, "activity_type": kind})); + } + let Some(endpoint) = binding_string(&binding, &["carrier_endpoint", "endpoint", "url"]).or(target) else { + return Self::handler_failure(connection, call, "handler_target_missing", + "Invocable carrier binding has no endpoint or carrier reference.", attempt, max).await; + }; + let handler = binding_string(&binding, &["carrier_handler", "handler", "handler_name"]); + let carrier = binding_string(&binding, &["carrier", "carrier_name"]); + let id = binding_string(&binding, &["carrier_request_id"]).unwrap_or_else(upper_ulid); + let mut metadata = json!({"carrier_request_id": id, "carrier_endpoint": endpoint}); + if let Some(handler) = &handler { + metadata["carrier_handler"] = json!(handler); + } + if let Some(carrier) = &carrier { + metadata["carrier"] = json!(carrier); + } + call.binding_kind = Some("invocable_carrier_request".into()); + call.target_reference = Some(id.clone()); + call.linked_instance = binding_string(&binding, &["workflow_instance_id", "bound_workflow_instance_id"]); + call.linked_run = None; + call.linked_update = None; + call.metadata = merge_metadata(&call.metadata, &metadata); + Self::save_service_call(connection, call).await?; + Ok(json!({"accepted": true, "kind": "invocable_carrier_request", "carrier_request_id": id, "carrier_endpoint": endpoint, + "carrier_handler": handler, "carrier": carrier})) + } + + /// `markHandlerFailure`: a retry while attempts remain and the failure + /// type is retryable, else the call fails. + async fn handler_failure( + connection: &mut DB::Connection, + call: &mut Call, + reason: &str, + message: &str, + attempt: i64, + max: i64, + ) -> Result { + if call.expire(super::store::now()) { + Self::save_service_call(connection, call).await?; + } + if call.is_terminal() { + return Ok(json!({"accepted": false, "kind": call.binding_kind, "reason": call.outcome_reason, + "message": call.failure_message, "retry_scheduled": false})); + } + let typed = json!({"failure_type": reason, "service_error_type": reason, "caller_observed_error_type": reason, + "typed_error_message": message}); + let outcome_metadata = merge_policy(&[&json!({"failure_reason": "handler_failure"}), &typed]); + let retry = attempt < max && !non_retryable(&call.retry_policy).iter().any(|kind| kind == reason); + if retry { + call.status = "started".into(); + call.outcome = Some("accepted".into()); + call.outcome_category = Some("accepted".into()); + call.outcome_reason = Some("retry_scheduled".into()); + call.outcome_message = Some(message.into()); + call.outcome_metadata = Value::Null; + call.metadata = merge_metadata(&call.metadata, &json!({"last_retry_failure": outcome_metadata})); + call.started_at = call.started_at.or_else(|| Some(super::store::now())); + call.failure_message = None; + call.failed_at = None; + } else { + call.status = "failed".into(); + call.outcome = Some("handler_failed".into()); + call.outcome_category = Some("handler".into()); + call.outcome_reason = Some(reason.into()); + call.outcome_message = Some(message.into()); + call.metadata = merge_metadata(&call.metadata, &outcome_metadata); + call.outcome_metadata = outcome_metadata; + call.failure_message = Some(message.into()); + call.failed_at = Some(super::store::now()); + } + Self::save_service_call(connection, call).await?; + Ok(json!({"accepted": false, "kind": call.binding_kind, "reason": reason, "message": message, "error_type": reason, + "failure_type": reason, "service_error_type": reason, "retry_scheduled": retry})) + } + + /// `serviceCallAttemptEntry`: null fields left out. + fn attempt_entry(call: &Call, result: &Value, attempt: i64, started: DateTime, retry: bool, backoff: Option) -> Value { + let mut entry = Map::new(); + let failure = failure_type(&array_value(&call.outcome_metadata)).or_else(|| failure_type(&array_value(result))); + for (key, value) in [ + ("attempt", json!(attempt)), + ("status", json!(call.status)), + ("outcome", json!(call.outcome)), + ("outcome_reason", json!(string_from(at(result, "reason")).or_else(|| call.outcome_reason.clone()))), + ("failure_type", json!(failure)), + ("failure_message", json!(call.failure_message.clone().or_else(|| string_from(at(result, "message"))))), + ("started_at", micros(Some(started))), + ("completed_at", micros(call.completed_at)), + ("failed_at", micros(call.failed_at)), + ("cancelled_at", micros(call.cancelled_at)), + ("retry_scheduled", json!(retry)), + ("scheduled_backoff_seconds", json!(backoff)), + ] { + if !value.is_null() { + entry.insert(key.into(), value); + } + } + php_document(Value::Object(entry)) + } + + /// `recordServiceCallAttempt`. + async fn record_attempt(connection: &mut DB::Connection, call: &mut Call, entry: Value) -> Result<()> { + let mut metadata = array_value(&call.metadata); + let existing = at(&metadata, "service_call_attempts"); + let mut attempts: Vec = if is_array(existing) { values(existing).into_iter().filter(is_array).collect() } else { Vec::new() }; + attempts.push(entry); + let count = attempts.len(); + metadata = merge_policy(&[&metadata, &json!({"service_call_attempts": attempts.clone(), "retry_attempt_count": count})]); + call.metadata = metadata; + if call.is_failed() { + call.outcome_metadata = merge_policy(&[&array_value(&call.outcome_metadata), + &json!({"service_call_attempts": attempts, "retry_attempt_count": count})]); + } + Self::save_service_call(connection, call).await + } + + /// `serviceCallShow`: the controller view of the row as read, merged with + /// the control plane's after a lazy expiry. + pub(crate) async fn show_service_call(&self, names: [&str; 3], id: &str) -> Result { + let mut tx = self.begin().await?; + let (endpoint, service, operation) = self.required_operation(&mut tx, names).await?; + let Some(read) = self.find_service_call(&mut tx, &operation, id).await? else { + return Err(call_not_found(&endpoint, &service, &operation, id.trim())); + }; + let mut call = read.clone(); + Self::reconcile_service_call(&mut tx, &mut call).await?; + tx.commit().await?; + let plane = with(Map::from_iter([("found".to_owned(), json!(true))]), Value::Object(plane_view(&call))); + Ok(replaced(controller_view(&read, &endpoint, &service, &operation), plane)) + } + + /// `cancelCall`, then the controller's view of the call as it is now. + pub(crate) async fn cancel_service_call(&self, names: [&str; 3], id: &str, reason: Option) -> Result<(StatusCode, Value)> { + let mut tx = self.begin().await?; + let (endpoint, service, operation) = self.required_operation(&mut tx, names).await?; + let Some(mut call) = self.find_service_call(&mut tx, &operation, id).await? else { + return Err(call_not_found(&endpoint, &service, &operation, id.trim())); + }; + let refusal = if call.expire(super::store::now()) { + Self::save_service_call(&mut tx, &mut call).await?; + Some("service_call_terminal") + } else if call.is_terminal() { + Some("service_call_terminal") + } else if !cancellation_allowed(&call.cancellation_policy) { + Some("cancellation_not_allowed") + } else { + None + }; + if refusal.is_none() + && at(&array_value(&call.cancellation_policy), "propagate_to_linked_workflow") == &Value::Bool(true) + && call.linked_instance.as_deref().is_some_and(|instance| !instance.is_empty()) + { + // Cancelling the linked workflow comes with the workflow-bound handlers. + return Err(not_implemented()); + } + if refusal.is_none() { + call.status = "cancelled".into(); + call.outcome = Some("cancelled".into()); + call.outcome_category = Some("handler".into()); + call.outcome_reason = Some("cancelled_by_request".into()); + call.outcome_message = reason.clone(); + call.outcome_metadata = json!({"failure_reason": "cancellation"}); + call.metadata = if values(&call.metadata).is_empty() { Value::Null } else { call.metadata.clone() }; + call.cancelled_at = call.cancelled_at.or_else(|| Some(super::store::now())); + call.failure_message = reason.or_else(|| call.failure_message.clone()); + Self::save_service_call(&mut tx, &mut call).await?; + } + Self::reconcile_service_call(&mut tx, &mut call).await?; + tx.commit().await?; + let accepted = refusal.is_none(); + let plane = with(plane_view(&call), json!({"accepted": accepted, "reason": refusal})); + Ok((if accepted { StatusCode::OK } else { StatusCode::CONFLICT }, + replaced(controller_view(&call, &endpoint, &service, &operation), plane))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policies_merge_as_php_arrays() { + let merged = merge_policy(&[&json!({"a": {"x": 1, "y": [1]}, "b": 1}), &json!({"a": {"y": [2], "z": null}, "c": {}})]); + assert_eq!(merged, json!({"a": {"x": 1, "y": [2], "z": null}, "b": 1, "c": []})); + assert_eq!(merge_metadata(&Value::Null, &json!([])), Value::Null); + assert_eq!(without_admission_controls(&json!({"rate_limit": 1, "auth": {"concurrency": 2, "k": 3}})), json!({"auth": {"k": 3}})); + } + + #[test] + fn limits_follow_the_shared_policy() { + for (value, expected) in [(json!(null), Ok(None)), (json!(0), Ok(None)), (json!("0"), Ok(None)), (json!(""), Ok(None)), + (json!(3), Ok(Some(3))), (json!("12"), Ok(Some(12))), (json!(-1), Err(())), (json!(1.5), Err(())), + (json!("lots"), Err(())), (json!("012"), Err(())), (json!(true), Err(()))] + { + assert_eq!(limit(&value), expected, "{value}"); + } + for (value, expected) in [(json!({}), Ok(1)), (json!({"retry_after_seconds": 90}), Ok(60)), + (json!({"retry_after_seconds": "0"}), Ok(0)), (json!({"retry_after_seconds": "007"}), Err(())), + (json!({"retry_after_seconds": -1}), Err(()))] + { + assert_eq!(retry_after_seconds(&value), expected, "{value}"); + } + } + + #[test] + fn retries_read_the_policy_as_php() { + assert_eq!(max_attempts(&json!({"max_attempts": 3})), 3); + assert_eq!(max_attempts(&json!({"maximumAttempts": "40"})), 10); + assert_eq!(max_attempts(&json!({"max_attempts": 0})), 1); + assert_eq!(backoff_seconds(&json!({}), 1), 0.0); + assert_eq!(backoff_seconds(&json!({"backoff_seconds": [1, 2.5]}), 2), 2.5); + assert_eq!(backoff_seconds(&json!({"initial_interval_seconds": 1, "maximum_interval_seconds": 3}), 3), 3.0); + assert_eq!(non_retryable(&json!({"non_retryable_error_types": ["a", "", 3]})), vec!["a".to_owned()]); + assert!(!cancellation_allowed(&json!({"mode": "none"}))); + assert!(!cancellation_allowed(&json!({"cancellable": false}))); + assert!(cancellation_allowed(&Value::Null)); + } + + #[test] + fn arguments_follow_the_envelope_rules() { + assert_eq!(arguments_blob(&json!("x")).unwrap(), Some(("arguments", "x".to_owned()))); + assert_eq!(arguments_blob(&json!({"codec": "avro", "blob": "b"})).unwrap(), Some(("arguments.blob", "b".to_owned()))); + assert_eq!(arguments_blob(&json!([1, 2])).unwrap(), None); + assert_eq!(arguments_blob(&json!({"a": 1})).unwrap(), None); + let message = |value: Value| match arguments_blob(&value) { + Err(RuntimeError::Protocol { response, .. }) => response["errors"].clone(), + other => panic!("{other:?}"), + }; + assert_eq!(message(json!({"codec": "avro"})), + json!({"arguments": ["The arguments envelope must contain only codec and blob, or codec and external storage."]})); + assert_eq!(message(json!({"codec": "avro", "blob": 5})), json!({"arguments.blob": ["The payload envelope blob must be a string."]})); + assert_eq!(message(json!({"codec": null, "blob": "b"})), + json!({"arguments.codec": ["The payload envelope codec must be a non-empty string."]})); + assert!(message(json!({"codec": "json", "blob": "b"}))["arguments.codec"][0].as_str().unwrap().starts_with("unsupported_payload_codec")); + } + + #[test] + fn php_casts_match() { + assert_eq!([php_int(&json!("12abc")), php_int(&json!(3.9)), php_int(&json!(true)), php_int(&json!([1]))], [12, 3, 1, 1]); + assert_eq!([php_string_int(" 7"), php_string_int("1e3"), php_string_int("-2.9x"), php_string_int("abc"), php_string_int(".5")], + [7, 1000, -2, 0, 0]); + assert_eq!(positive_int(&json!(" 7 ")), Some(7)); + assert_eq!(positive_int(&json!(-1)), None); + } +} diff --git a/rust/src/runtime/service_catalog.rs b/rust/src/runtime/service_catalog.rs index 8596c3ffa..0a121e280 100644 --- a/rust/src/runtime/service_catalog.rs +++ b/rust/src/runtime/service_catalog.rs @@ -2,17 +2,30 @@ //! (`ServiceCatalogReadController`): endpoints, services, operations and //! service calls, each listed by page or read by identity. //! -//! Native never holds catalog rows. Takeover refuses a PHP database with -//! services, endpoints, operations or service calls, and native has no path -//! that writes them. Every list is therefore PHP's empty page and every -//! identity PHP's not-found answer, after PHP's validation of the filters. +//! Endpoints, services and operations are the namespace's rows +//! (`service_endpoints`), in PHP's orders: by name, then id. A page counts +//! the matches and reads only its window. An endpoint names up to 100 of its +//! services and operations, a service up to 100 operations, each with a flag +//! for more; an operation names its endpoint and service when they are in the +//! namespace. +//! +//! Service calls are the ones the namespace can see (`ServiceCatalog`'s +//! scopes: by default those it called or that target it), newest accepted +//! first as PHP's `orderByDesc` reads them on each database (a rejected call +//! has no `accepted_at`, so where it sorts follows the database's NULL +//! order, as it does for PHP). A call read by identity is visible when the +//! namespace called it or is its target; its payload references and Nexus +//! result show only to the namespace that owns it. use super::laravel_rules::{self, Rule}; use super::laravel_validation; +use super::service_endpoints::{endpoint_item, operation_item, service_item}; use super::{Result, RuntimeError}; use axum::http::StatusCode; use serde_json::{Map, Value, json}; const DEFAULT_PAGE_SIZE: i64 = 50; +/// `ServiceCatalogReadContract::RELATED_LIMIT`. +const RELATED_LIMIT: i64 = 100; const SCOPES: &[&str] = &["relevant", "owned", "caller", "target"]; const STATUSES: &[&str] = &["pending", "accepted", "started", "completed", "failed", "cancelled"]; const OUTCOMES: &[&str] = &[ @@ -105,41 +118,284 @@ fn whole(value: &Value, default: i64) -> i64 { value.as_str().and_then(|text| text.parse().ok()).unwrap_or(default) } -/// A page of the catalog: always empty in native. -pub(crate) fn list(query: &[(String, String)], catalog: Catalog, namespace: &str) -> Result { - let filters = filters(query, catalog, false)?; - let mut page = json!({"data": [], "current_page": whole(&filters["page"], 1), - "per_page": whole(&filters["per_page"], DEFAULT_PAGE_SIZE), "total": 0, "last_page": 1, "namespace": namespace}); - if catalog == Catalog::Calls { - let status_bucket = match &filters["status_bucket"] { - Value::Null => filters["bucket"].clone(), - bucket => bucket.clone(), - }; - let buckets = |names: &[&str], members: &[&[&str]]| -> Value { - Value::Object(names.iter().zip(members).map(|(name, values)| ((*name).to_owned(), json!(values))).collect()) - }; - let extra = json!({ - "scope": if filters["scope"].is_null() { json!("relevant") } else { filters["scope"].clone() }, - "bucket": status_bucket, "status_bucket": status_bucket, - "outcome": filters["outcome"], "outcome_bucket": filters["outcome_bucket"], - "statuses": STATUSES, "outcomes": OUTCOMES, - "status_buckets": buckets(STATUS_BUCKETS, &[&["pending", "accepted", "started"], &["completed"], &["failed"], &["cancelled"]]), - "outcome_buckets": buckets(OUTCOME_BUCKETS, &[&["accepted"], &["completed", "degraded"], &["timed_out", "handler_failed"], - &["cancelled"], &["rejected_not_found", "rejected_forbidden", "rejected_throttled", - "rejected_concurrency_limited", "rejected_circuit_open"]]), - }); - page.as_object_mut().expect("page").extend(extra.as_object().expect("extra").clone()); - } - Ok(page) +/// The validated paging: the page, its size and the rows before it. +fn paging(filters: &Value) -> (i64, i64, i64) { + let page = whole(&filters["page"], 1); + let per_page = whole(&filters["per_page"], DEFAULT_PAGE_SIZE); + (page, per_page, page.saturating_sub(1).saturating_mul(per_page)) +} + +/// Laravel's `LengthAwarePaginator` fields around a page of rows. +fn page_body(data: Vec, filters: &Value, total: i64, namespace: &str) -> Value { + let (page, per_page, _) = paging(filters); + json!({"data": data, "current_page": page, "per_page": per_page, "total": total, + "last_page": ((total + per_page - 1) / per_page).max(1), "namespace": namespace}) +} + +/// `ServiceCallStatus::buckets` and `ServiceCallOutcome::buckets`, by name. +const STATUS_MEMBERS: &[&[&str]] = &[&["pending", "accepted", "started"], &["completed"], &["failed"], &["cancelled"]]; +const OUTCOME_MEMBERS: &[&[&str]] = &[&["accepted"], &["completed", "degraded"], &["timed_out", "handler_failed"], &["cancelled"], + &["rejected_not_found", "rejected_forbidden", "rejected_throttled", "rejected_concurrency_limited", "rejected_circuit_open"]]; + +/// The values a calls list keeps for one column: the exact filter, else the +/// bucket's members, else none (every value). +fn allowed<'a>(exact: Option<&'a str>, bucket: Option<&str>, names: &[&str], members: &[&'a [&'a str]]) -> Option> { + match (exact, bucket) { + (Some(value), _) => Some(vec![value]), + (None, Some(bucket)) => names.iter().position(|name| *name == bucket).map(|index| members[index].to_vec()), + (None, None) => None, + } +} + +/// `$n IS NULL OR column IN (...)` binds: a flag and `slots` values, the +/// list padded with its last value. +fn in_binds<'a>(values: &Option>, slots: usize) -> (Option<&'static str>, Vec<&'a str>) { + match values { + Some(values) => (Some("1"), (0..slots).map(|index| values[index.min(values.len() - 1)]).collect()), + None => (None, vec![""; slots]), + } +} + +/// `serviceCallsQuery`'s scope with each filter after it, as a count and a +/// page. Every scope takes the namespace twice, so the numbering is shared. +macro_rules! calls_sql { + ($scope:literal) => { + ( + concat!("SELECT COUNT(*) FROM workflow_service_calls c WHERE ", $scope, calls_sql!(@filters)), + concat!( + "SELECT c.*,d.call_deadline_at FROM workflow_service_calls c ", + "LEFT JOIN dw_service_call_deadlines d ON d.service_call_id=c.id WHERE ", $scope, calls_sql!(@filters), + " ORDER BY c.accepted_at DESC,c.created_at DESC,c.id LIMIT $19 OFFSET $20" + ), + ) + }; + (@filters) => { + concat!( + " AND ($3 IS NULL OR c.workflow_service_endpoint_id=$4) AND ($5 IS NULL OR c.workflow_service_id=$6)", + " AND ($7 IS NULL OR c.workflow_service_operation_id=$8) AND ($9 IS NULL OR c.status IN ($10,$11,$12))", + " AND ($13 IS NULL OR c.outcome IN ($14,$15,$16,$17,$18))" + ) + }; } -/// One catalog object by identity: never present in native. -pub(crate) fn show(query: &[(String, String)], catalog: Catalog) -> Result { - filters(query, catalog, true)?; - Err(RuntimeError::Protocol { +/// The calls page's own fields after the paging. +fn calls_extra(filters: &Value) -> Value { + let status_bucket = match &filters["status_bucket"] { + Value::Null => filters["bucket"].clone(), + bucket => bucket.clone(), + }; + let buckets = |names: &[&str], members: &[&[&str]]| -> Value { + Value::Object(names.iter().zip(members).map(|(name, values)| ((*name).to_owned(), json!(values))).collect()) + }; + let extra = json!({ + "scope": if filters["scope"].is_null() { json!("relevant") } else { filters["scope"].clone() }, + "bucket": status_bucket, "status_bucket": status_bucket, + "outcome": filters["outcome"], "outcome_bucket": filters["outcome_bucket"], + "statuses": STATUSES, "outcomes": OUTCOMES, + "status_buckets": buckets(STATUS_BUCKETS, STATUS_MEMBERS), + "outcome_buckets": buckets(OUTCOME_BUCKETS, OUTCOME_MEMBERS), + }); + extra +} + +/// A calls page: the paging fields, then the calls list's own. +fn calls_page(data: Vec, filters: &Value, total: i64, namespace: &str) -> Value { + let mut page = page_body(data, filters, total, namespace); + page.as_object_mut().expect("page").extend(calls_extra(filters).as_object().expect("extra").clone()); + page +} + +/// PHP's 404 for an identity the namespace does not hold. +fn object_not_found() -> RuntimeError { + RuntimeError::Protocol { status: StatusCode::NOT_FOUND, response: json!({"message": "Catalog object not found.", "reason": "catalog_object_not_found"}), - }) + } +} + +/// A filter's value, or null when the query leaves it out. +fn identity<'a>(filters: &'a Value, key: &str) -> Option<&'a str> { + filters[key].as_str() +} + +store_methods! { + /// `endpointsIndex`, `servicesIndex`, `operationsIndex` and `callsIndex`. + pub(crate) async fn service_catalog_list(&self, query: &[(String, String)], catalog: Catalog) -> Result { + let filters = filters(query, catalog, false)?; + let namespace = self.namespace.as_ref(); + let (_, limit, offset) = paging(&filters); + let endpoint = identity(&filters, "endpoint_id"); + let service = identity(&filters, "service_id"); + let mut tx = self.begin_read().await?; + // Each optional filter binds twice: a null keeps every row. + let (count, rows) = match catalog { + Catalog::Calls => { + tx.commit().await?; + return self.catalog_calls(&filters).await; + } + Catalog::Endpoints => ( + Self::scalar("SELECT COUNT(*) FROM workflow_service_endpoints WHERE namespace=$1") + .bind(namespace).fetch_one(&mut *tx).await?, + Self::query("SELECT * FROM workflow_service_endpoints WHERE namespace=$1 ORDER BY endpoint_name,id LIMIT $2 OFFSET $3") + .bind(namespace).bind(limit).bind(offset).fetch_all(&mut *tx).await?, + ), + Catalog::Services => ( + Self::scalar("SELECT COUNT(*) FROM workflow_services WHERE namespace=$1 AND ($2 IS NULL OR workflow_service_endpoint_id=$3)") + .bind(namespace).bind(endpoint).bind(endpoint).fetch_one(&mut *tx).await?, + Self::query("SELECT * FROM workflow_services WHERE namespace=$1 AND ($2 IS NULL OR workflow_service_endpoint_id=$3) ORDER BY service_name,id LIMIT $4 OFFSET $5") + .bind(namespace).bind(endpoint).bind(endpoint).bind(limit).bind(offset).fetch_all(&mut *tx).await?, + ), + Catalog::Operations => ( + Self::scalar("SELECT COUNT(*) FROM workflow_service_operations WHERE namespace=$1 AND ($2 IS NULL OR workflow_service_endpoint_id=$3) AND ($4 IS NULL OR workflow_service_id=$5)") + .bind(namespace).bind(endpoint).bind(endpoint).bind(service).bind(service).fetch_one(&mut *tx).await?, + Self::query("SELECT * FROM workflow_service_operations WHERE namespace=$1 AND ($2 IS NULL OR workflow_service_endpoint_id=$3) AND ($4 IS NULL OR workflow_service_id=$5) ORDER BY operation_name,id LIMIT $6 OFFSET $7") + .bind(namespace).bind(endpoint).bind(endpoint).bind(service).bind(service).bind(limit).bind(offset) + .fetch_all(&mut *tx).await?, + ), + }; + tx.commit().await?; + // Laravel reads no rows when the count is zero. + let rows = if count == 0 { Vec::new() } else { rows }; + let data = rows.iter().map(|row| match catalog { + Catalog::Endpoints => Self::endpoint_row(row).map(|endpoint| endpoint_item(&endpoint)), + Catalog::Services => Self::service_row(row).map(|service| service_item(&service)), + _ => Self::operation_row(row).map(|operation| operation_item(&operation)), + }).collect::>>()?; + Ok(page_body(data, &filters, count, namespace)) + } + + /// `endpointShow`, `serviceShow`, `operationShow` and `callShow`. + pub(crate) async fn service_catalog_show(&self, query: &[(String, String)], catalog: Catalog, id: &str) -> Result { + filters(query, catalog, true)?; + let namespace = self.namespace.as_ref(); + let mut tx = self.begin_read().await?; + let body = match catalog { + Catalog::Calls => Self::query(concat!( + "SELECT c.*,d.call_deadline_at FROM workflow_service_calls c LEFT JOIN dw_service_call_deadlines d ", + "ON d.service_call_id=c.id WHERE c.id=$1 AND (c.caller_namespace=$2 OR c.target_namespace=$3) LIMIT 1")) + .bind(id).bind(namespace).bind(namespace).fetch_optional(&mut *tx).await? + .map(|row| Self::service_call_row(&row).map(|call| super::service_calls::catalog_detail(&call, namespace))) + .transpose()?, + Catalog::Endpoints => match self.catalog_endpoint_by_id(&mut tx, id).await? { + None => None, + Some(endpoint) => { + let (services, more_services) = self.related(&mut tx, + "SELECT * FROM workflow_services WHERE namespace=$1 AND workflow_service_endpoint_id=$2 ORDER BY service_name,id LIMIT $3", id).await?; + let (operations, more_operations) = self.related(&mut tx, + "SELECT * FROM workflow_service_operations WHERE namespace=$1 AND workflow_service_endpoint_id=$2 ORDER BY operation_name,id LIMIT $3", id).await?; + let mut body = endpoint_item(&endpoint); + body["services"] = services.iter().map(Self::service_row).map(|service| service.map(|service| service_item(&service))) + .collect::>>()?.into(); + body["operations"] = operations.iter().map(Self::operation_row) + .map(|operation| operation.map(|operation| operation_item(&operation))).collect::>>()?.into(); + body["related_has_more"] = json!({"services": more_services, "operations": more_operations}); + Some(body) + } + }, + Catalog::Services => match Self::query("SELECT * FROM workflow_services WHERE id=$1 AND namespace=$2 LIMIT 1") + .bind(id).bind(namespace).fetch_optional(&mut *tx).await? + { + None => None, + Some(row) => { + let service = Self::service_row(&row)?; + let endpoint = self.catalog_endpoint_by_id(&mut tx, &service.endpoint_id).await?; + let (operations, more) = self.related(&mut tx, + "SELECT * FROM workflow_service_operations WHERE namespace=$1 AND workflow_service_id=$2 ORDER BY operation_name,id LIMIT $3", id).await?; + let mut body = service_item(&service); + body["endpoint"] = endpoint.as_ref().map_or(Value::Null, endpoint_item); + body["operations"] = operations.iter().map(Self::operation_row) + .map(|operation| operation.map(|operation| operation_item(&operation))).collect::>>()?.into(); + body["related_has_more"] = json!({"operations": more}); + Some(body) + } + }, + Catalog::Operations => match Self::query("SELECT * FROM workflow_service_operations WHERE id=$1 AND namespace=$2 LIMIT 1") + .bind(id).bind(namespace).fetch_optional(&mut *tx).await? + { + None => None, + Some(row) => { + // `ServiceOperationView::detail`: the endpoint and service + // when the namespace holds them. + let operation = Self::operation_row(&row)?; + let endpoint = self.catalog_endpoint_by_id(&mut tx, &operation.endpoint_id).await?; + let service = Self::query("SELECT * FROM workflow_services WHERE id=$1 AND namespace=$2 LIMIT 1") + .bind(operation.service_id.as_str()).bind(namespace).fetch_optional(&mut *tx).await? + .map(|row| Self::service_row(&row)).transpose()?; + let mut body = operation_item(&operation); + body["endpoint"] = endpoint.as_ref().map_or(Value::Null, endpoint_item); + body["service"] = service.as_ref().map_or(Value::Null, service_item); + Some(body) + } + }, + }; + tx.commit().await?; + body.ok_or_else(object_not_found) + } + + /// `callsIndex`: the calls the namespace sees in the scope, filtered and + /// paged. + async fn catalog_calls(&self, filters: &Value) -> Result { + let namespace = self.namespace.as_ref(); + let (_, limit, offset) = paging(filters); + let (count_sql, page_sql) = match filters["scope"].as_str().unwrap_or("relevant") { + "owned" => calls_sql!("(c.namespace=$1 OR c.namespace=$2)"), + "caller" => calls_sql!("(c.caller_namespace=$1 OR c.caller_namespace=$2)"), + "target" => calls_sql!("(c.target_namespace=$1 OR c.target_namespace=$2)"), + _ => calls_sql!("(c.caller_namespace=$1 OR c.target_namespace=$2)"), + }; + let status_bucket = identity(filters, "status_bucket").or_else(|| identity(filters, "bucket")); + let statuses = allowed(identity(filters, "status"), status_bucket, STATUS_BUCKETS, STATUS_MEMBERS); + let outcomes = allowed(identity(filters, "outcome"), identity(filters, "outcome_bucket"), OUTCOME_BUCKETS, OUTCOME_MEMBERS); + let (status_flag, status_values) = in_binds(&statuses, 3); + let (outcome_flag, outcome_values) = in_binds(&outcomes, 5); + let ids = ["endpoint_id", "service_id", "operation_id"].map(|key| identity(filters, key)); + // The same binds for the count and the page, in placeholder order. + macro_rules! bound { + ($query:expr) => {{ + let mut query = $query.bind(namespace).bind(namespace); + for id in ids { + query = query.bind(id).bind(id); + } + query = query.bind(status_flag); + for value in &status_values { + query = query.bind(*value); + } + query = query.bind(outcome_flag); + for value in &outcome_values { + query = query.bind(*value); + } + query + }}; + } + let mut tx = self.begin_read().await?; + let total = bound!(Self::scalar(count_sql)).fetch_one(&mut *tx).await?; + // Laravel reads no rows when the count is zero. + let rows = if total == 0 { + Vec::new() + } else { + bound!(Self::query(page_sql)).bind(limit).bind(offset).fetch_all(&mut *tx).await? + }; + tx.commit().await?; + let data = rows.iter().map(|row| Self::service_call_row(row).map(|call| super::service_calls::catalog_list_item(&call))) + .collect::>>()?; + Ok(calls_page(data, filters, total, namespace)) + } + + /// `ServiceCatalog::findEndpoint`: an endpoint by id in the namespace. + async fn catalog_endpoint_by_id(&self, connection: &mut DB::Connection, id: &str) -> Result> { + Self::query("SELECT * FROM workflow_service_endpoints WHERE id=$1 AND namespace=$2 LIMIT 1") + .bind(id).bind(self.namespace.as_ref()).fetch_optional(&mut *connection).await? + .map(|row| Self::endpoint_row(&row)).transpose() + } + + /// `related`: up to `RELATED_LIMIT` rows and whether more exist. + async fn related(&self, connection: &mut DB::Connection, sql: &'static str, id: &str) -> Result<(Vec, bool)> { + let mut rows = Self::query(sql).bind(self.namespace.as_ref()).bind(id).bind(RELATED_LIMIT + 1) + .fetch_all(&mut *connection).await?; + let more = rows.len() > RELATED_LIMIT as usize; + rows.truncate(RELATED_LIMIT as usize); + Ok((rows, more)) + } } #[cfg(test)] @@ -150,18 +406,19 @@ mod tests { pairs.iter().map(|(key, value)| ((*key).to_owned(), (*value).to_owned())).collect() } - fn refusal(result: Result) -> Value { - match result { - Err(RuntimeError::Protocol { status, response }) => json!([status.as_u16(), response]), - other => panic!("expected a refusal, got {other:?}"), - } + fn page(pairs: &[(&str, &str)], catalog: Catalog, total: i64) -> Result { + let filters = filters(&query(pairs), catalog, false)?; + Ok(if catalog == Catalog::Calls { calls_page(Vec::new(), &filters, total, "second") } else { page_body(Vec::new(), &filters, total, "default") }) } #[test] - fn lists_are_empty_pages_with_the_requested_paging() { - assert_eq!(list(&query(&[("page", "2"), ("per_page", "2")]), Catalog::Endpoints, "default").unwrap(), + fn pages_report_the_requested_paging_and_the_count() { + assert_eq!(page(&[("page", "2"), ("per_page", "2")], Catalog::Endpoints, 0).unwrap(), json!({"data":[],"current_page":2,"per_page":2,"total":0,"last_page":1,"namespace":"default"})); - let calls = list(&query(&[("bucket", "open")]), Catalog::Calls, "second").unwrap(); + assert_eq!(page(&[("per_page", "2")], Catalog::Services, 5).unwrap()["last_page"], 3); + assert_eq!(page(&[], Catalog::Operations, 50).unwrap()["last_page"], 1); + assert_eq!(paging(&json!({"page": "3", "per_page": "20"})), (3, 20, 40)); + let calls = page(&[("bucket", "open")], Catalog::Calls, 0).unwrap(); assert_eq!([&calls["scope"], &calls["bucket"], &calls["status_bucket"], &calls["outcome"], &calls["namespace"]], [&json!("relevant"), &json!("open"), &json!("open"), &Value::Null, &json!("second")]); assert_eq!(calls["outcome_buckets"]["policy"].as_array().unwrap().len(), 5); @@ -169,27 +426,31 @@ mod tests { #[test] fn filters_fail_like_laravel() { - assert_eq!(refusal(list(&query(&[("per_page", "0")]), Catalog::Endpoints, "default"))[1]["message"], + let fail = |pairs: &[(&str, &str)], catalog: Catalog, detail: bool| match filters(&query(pairs), catalog, detail) { + Err(RuntimeError::Protocol { status, response }) => json!([status.as_u16(), response]), + other => panic!("expected a refusal, got {other:?}"), + }; + assert_eq!(fail(&[("per_page", "0")], Catalog::Endpoints, false)[1]["message"], "The per page field must be between 1 and 100."); - assert_eq!(refusal(list(&query(&[("page", "x")]), Catalog::Endpoints, "default"))[1]["message"], - "The page field must be an integer."); - let unknown = refusal(list(&query(&[("bogus", "1"), ("other", "2")]), Catalog::Endpoints, "default")); + assert_eq!(fail(&[("page", "x")], Catalog::Endpoints, false)[1]["message"], "The page field must be an integer."); + let unknown = fail(&[("bogus", "1"), ("other", "2")], Catalog::Endpoints, false); assert_eq!(unknown[1]["message"], "Unsupported catalog filter. (and 1 more error)"); - assert_eq!(refusal(list(&query(&[("endpoint_id", "x")]), Catalog::Endpoints, "default"))[1]["errors"], + assert_eq!(fail(&[("endpoint_id", "x")], Catalog::Endpoints, false)[1]["errors"], json!({"endpoint_id":["Unsupported catalog filter."]})); - assert_eq!(refusal(list(&query(&[("endpoint_id", "")]), Catalog::Services, "default"))[1]["message"], - "The endpoint id field is required."); - assert_eq!(refusal(list(&query(&[("bucket", "open"), ("status_bucket", "failed")]), Catalog::Calls, "default"))[1], + assert_eq!(fail(&[("endpoint_id", "")], Catalog::Services, false)[1]["message"], "The endpoint id field is required."); + assert_eq!(fail(&[("bucket", "open"), ("status_bucket", "failed")], Catalog::Calls, false)[1], json!({"message":"Must match status_bucket when both are supplied.","reason":"validation_failed", "errors":{"bucket":["Must match status_bucket when both are supplied."]}, "validation_errors":{"bucket":["Must match status_bucket when both are supplied."]}})); - assert_eq!(refusal(show(&query(&[("page", "2")]), Catalog::Endpoints))[1]["errors"], - json!({"page":["Unsupported catalog filter."]})); + assert_eq!(fail(&[("page", "2")], Catalog::Endpoints, true)[1]["errors"], json!({"page":["Unsupported catalog filter."]})); } #[test] - fn identities_are_not_found() { - assert_eq!(refusal(show(&query(&[("namespace", "default")]), Catalog::Calls)), - json!([404, {"message":"Catalog object not found.","reason":"catalog_object_not_found"}])); + fn identities_are_not_found_as_php_answers() { + match object_not_found() { + RuntimeError::Protocol { status, response } => assert_eq!(json!([status.as_u16(), response]), + json!([404, {"message":"Catalog object not found.","reason":"catalog_object_not_found"}])), + other => panic!("{other:?}"), + } } } diff --git a/rust/src/runtime/service_endpoints.rs b/rust/src/runtime/service_endpoints.rs new file mode 100644 index 000000000..89377e6f3 --- /dev/null +++ b/rust/src/runtime/service_endpoints.rs @@ -0,0 +1,1036 @@ +//! Nexus service catalog administration, as published Server 2.11.1 serves it +//! (`ServiceCatalogController`'s endpoint, service and operation routes), and +//! the catalog rows the read surface (`service_catalog`) renders. +//! +//! An admin registers a namespace's service endpoints, the services under an +//! endpoint and the operations under a service. Each is addressed by name, +//! lowercased as PHP's `strtolower` does (ASCII only), in the path and in the +//! body. The database's own comparison then finds it, as PHP's query does. +//! +//! - A create looks for the name first and answers 409 with the existing id. +//! Otherwise it stores a lowercase ULID (`HasUlids`) and answers 201. +//! - An update writes only when a validated field changes, as Eloquent's +//! dirty check does, so an unchanged request keeps `updated_at`. +//! - A delete refuses an endpoint that still has services, operations or +//! service calls, a service with operations or calls, and an operation with +//! calls, checked in that order. +//! - Service and operation lists come in creation order: PHP's relations +//! order by `created_at` and `id` before the controller's name order. +//! Endpoint lists are by name. +//! - Answers render JSON columns as PHP's array cast reads them: an empty +//! object is `[]` and an object keyed 0..n is a list. +//! +//! Every write takes native's serialized write transaction. PHP checks a name +//! and then inserts without a lock, so two concurrent creates of one name can +//! end in its unique-index error (500), and a delete can race a child's +//! create. Native answers the second create 409 and orders the delete. +//! +//! Differences: +//! - Native JSON documents don't keep object key order. An update that only +//! reorders a document's keys changes nothing in native, where PHP's strict +//! comparison writes it and moves `updated_at`. A stored object whose +//! integer keys are out of order reads back as a list. +//! - Laravel also reads input from the query string. Native reads a ruled +//! field from it when the body lacks that field, by exact name only +//! (`metadata[a]=1` style keys are not read). +use super::laravel_rules::{self, Rule}; +use super::{Result, RuntimeError}; +use axum::http::StatusCode; +use chrono::{DateTime, Utc}; +use serde_json::{Map, Value, json}; + +/// `ServiceCatalogController::NAME_PATTERN`: `/^[a-zA-Z0-9._-]+$/`. Laravel's +/// TrimStrings has removed the trailing newline PHP's `$` would allow. +fn catalog_name(text: &str) -> bool { + !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +const OPERATION_MODES: &[&str] = &["sync", "async"]; +const HANDLER_BINDING_KINDS: &[&str] = + &["start_workflow", "signal_workflow", "update_workflow", "query_workflow", "activity_execution", "invocable_http"]; + +/// `catalogNameRules(191)`. Operation names use the same 191: PHP takes the +/// smaller of it and DW_MAX_OPERATION_NAME_LENGTH, which native runs only at +/// its default of 256. +const NAME: &[Rule] = &[Rule::Required, Rule::String, Rule::Max(191), Rule::Regex(catalog_name)]; +const DESCRIPTION: &[Rule] = &[Rule::Nullable, Rule::String, Rule::Max(1000)]; +const DOCUMENT: &[Rule] = &[Rule::Nullable, Rule::Array]; + +const ENDPOINT_STORE: &laravel_rules::Rules = &[("endpoint_name", NAME), ("description", DESCRIPTION), ("metadata", DOCUMENT)]; +const SERVICE_STORE: &laravel_rules::Rules = &[("service_name", NAME), ("description", DESCRIPTION), ("metadata", DOCUMENT)]; +const DESCRIBE: &laravel_rules::Rules = &[("description", DESCRIPTION), ("metadata", DOCUMENT)]; + +/// The operation's JSON documents, in PHP's rule order. +const DOCUMENTS: [&str; 7] = + ["handler_binding", "deadline_policy", "idempotency_policy", "cancellation_policy", "retry_policy", "boundary_policy", "metadata"]; + +const MODE: &[Rule] = &[Rule::Required, Rule::String, Rule::In(OPERATION_MODES)]; +const MODE_CHANGE: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::In(OPERATION_MODES)]; +const KIND: &[Rule] = &[Rule::Required, Rule::String, Rule::In(HANDLER_BINDING_KINDS)]; +const KIND_CHANGE: &[Rule] = &[Rule::Sometimes, Rule::String, Rule::In(HANDLER_BINDING_KINDS)]; +const TARGET: &[Rule] = &[Rule::Nullable, Rule::String, Rule::Max(191)]; +const RENAME: &[Rule] = &[Rule::Sometimes, Rule::Prohibited]; + +/// `validateOperationPayload`'s rules: a create requires the mode, kind and +/// name; an update may change the mode and kind and prohibits a name. +fn operation_rules(partial: bool) -> Vec<(&'static str, &'static [Rule])> { + let mut rules = vec![ + ("description", DESCRIPTION), + ("operation_mode", if partial { MODE_CHANGE } else { MODE }), + ("handler_binding_kind", if partial { KIND_CHANGE } else { KIND }), + ("handler_target_reference", TARGET), + ]; + rules.extend(DOCUMENTS.iter().map(|field| (*field, DOCUMENT))); + rules.push(("operation_name", if partial { RENAME } else { NAME })); + rules +} + +/// `normalizeCatalogName`: PHP 8's `strtolower`. +fn lower(name: &str) -> String { + name.to_ascii_lowercase() +} + +/// The request input `$request->validate` reads: the JSON body (a body that +/// is not an object holds no named field), then each ruled field the body +/// lacks from the query string, normalized by TrimStrings and +/// ConvertEmptyStringsToNull. +pub(crate) fn input(body: Value, query: &[(String, String)], rules: &laravel_rules::Rules) -> Value { + let mut fields = match body { + Value::Object(fields) => fields, + _ => Map::new(), + }; + for (field, _) in rules { + if !fields.contains_key(*field) + // A repeated parameter's last value wins. + && let Some((_, value)) = query.iter().rev().find(|(key, _)| key == field) + { + fields.insert((*field).to_owned(), json!(value)); + } + } + laravel_rules::normalize(&Value::Object(fields)) +} + +fn validated(input: &Value, rules: &laravel_rules::Rules) -> Result> { + laravel_rules::validate(input, rules)?; + match laravel_rules::validated(input, rules) { + Value::Object(fields) => Ok(fields), + _ => Ok(Map::new()), + } +} + +/// One `ValidationException::withMessages` failure. +fn invalid(field: &str, message: &str) -> RuntimeError { + laravel_rules::failure(vec![(field.to_owned(), vec![message.to_owned()])]).expect("a message") +} + +/// `ServiceCallDeadlinePolicy::resolve`'s refusal of a stored policy, if any. +/// A null policy is no deadline. +pub(crate) fn deadline_policy_error(policy: &Value) -> Option<&'static str> { + let fields = match policy { + Value::Null => return None, + Value::Object(fields) => fields, + _ => return Some("Service deadline_policy must contain exactly timeout_seconds or deadline_at."), + }; + let deadline = match fields.iter().next() { + Some((key, seconds)) if fields.len() == 1 && key == "timeout_seconds" => { + // An integer from 1 to 2^31-1 seconds after acceptance stays + // within the year limits below. + return match seconds.as_i64() { + Some(1..=2_147_483_647) => None, + _ => Some("Service timeout_seconds must be an integer from 1 to 2147483647."), + }; + } + Some((key, value)) if fields.len() == 1 && key == "deadline_at" => { + match value.as_str().map(rfc3339_instant) { + Some(Ok(deadline)) => deadline, + Some(Err(message)) => return Some(message), + None => return Some("Service deadline_at must be offset-bearing RFC3339 with at most six fractional digits."), + } + } + _ => return Some("Service deadline_policy must contain exactly timeout_seconds or deadline_at."), + }; + let year = chrono::Datelike::year(&deadline); + (!(1970..=9999).contains(&year)).then_some("Service call deadlines must resolve within UTC years 1970 through 9999.") +} + +/// `ServiceCallDeadlinePolicy::resolve`: the deadline a valid policy sets +/// for a call accepted at `accepted_at`, or none. An invalid policy has none; +/// operation writes refuse one before any call can carry it. +pub(crate) fn deadline_instant(policy: &Value, accepted_at: DateTime) -> Option> { + if deadline_policy_error(policy).is_some() { + return None; + } + let fields = policy.as_object()?; + if let Some(seconds) = fields.get("timeout_seconds").and_then(Value::as_i64) { + return Some(accepted_at + chrono::Duration::seconds(seconds)); + } + let text = fields.get("deadline_at")?.as_str()?; + rfc3339_instant(text).ok().map(|instant| instant.and_utc()) +} + +/// The `deadline_at` shape and calendar checks: the pattern +/// `\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)\z`, +/// then `createFromFormat`, which warns on a date or time out of range. +fn rfc3339_instant(text: &str) -> std::result::Result { + const SHAPE: &str = "Service deadline_at must be offset-bearing RFC3339 with at most six fractional digits."; + const CALENDAR: &str = "Service deadline_at must be a valid calendar instant."; + let bytes = text.as_bytes(); + let digits = |range: std::ops::Range| -> Option { + let part = bytes.get(range)?; + part.iter().all(u8::is_ascii_digit).then(|| part.iter().fold(0, |sum, digit| sum * 10 + u32::from(digit - b'0'))) + }; + let separators = [(4, b'-'), (7, b'-'), (10, b'T'), (13, b':'), (16, b':')]; + if bytes.len() < 20 || separators.iter().any(|(at, byte)| bytes[*at] != *byte) { + return Err(SHAPE); + } + let (Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) = + (digits(0..4), digits(5..7), digits(8..10), digits(11..13), digits(14..16), digits(17..19)) + else { + return Err(SHAPE); + }; + let mut at = 19; + let mut micros = 0; + if bytes[at] == b'.' { + let start = at + 1; + let end = start + bytes[start..].iter().take_while(|byte| byte.is_ascii_digit()).count(); + if end == start || end - start > 6 { + return Err(SHAPE); + } + // `str_pad($fraction, 6, '0')`: the digits are the leading places. + micros = digits(start..end).ok_or(SHAPE)? * 10u32.pow(u32::try_from(6 - (end - start)).unwrap_or(0)); + at = end; + } + let offset_minutes: i64 = match &bytes[at..] { + [b'Z'] => 0, + [sign @ (b'+' | b'-'), h1, h2, b':', m1, m2] + if matches!((h1, h2), (b'0' | b'1', b'0'..=b'9') | (b'2', b'0'..=b'3')) + && matches!(m1, b'0'..=b'5') + && m2.is_ascii_digit() => + { + let minutes = i64::from((h1 - b'0') * 10 + (h2 - b'0')) * 60 + i64::from((m1 - b'0') * 10 + (m2 - b'0')); + if *sign == b'-' { -minutes } else { minutes } + } + _ => return Err(SHAPE), + }; + let date = chrono::NaiveDate::from_ymd_opt(i32::try_from(year).unwrap_or(0), month, day).ok_or(CALENDAR)?; + let time = chrono::NaiveTime::from_hms_micro_opt(hour, minute, second, micros).ok_or(CALENDAR)?; + Ok(date.and_time(time) - chrono::Duration::minutes(offset_minutes)) +} + +/// PHP's `trim`, as `assertOperationBindingTargetOrPayload` applies it. +fn php_trim(text: &str) -> &str { + text.trim_matches([' ', '\t', '\n', '\r', '\0', '\x0b']) +} + +/// `assertOperationBindingTargetOrPayload`. +fn binding_error(target: &Value, binding: &Value) -> Option { + let has_target = target.as_str().is_some_and(|text| !php_trim(text).is_empty()); + let has_binding = match binding { + Value::Object(fields) => !fields.is_empty(), + Value::Array(items) => !items.is_empty(), + _ => false, + }; + (!has_target && !has_binding) + .then(|| invalid("handler_target_reference", "Provide handler_target_reference or a non-empty handler_binding.")) +} + +/// `validateOperationPayload`: the rules, then the deadline policy when one +/// is given, then the binding check for a create or an update that names +/// either binding field. +fn operation_payload(input: &Value, partial: bool) -> Result> { + let rules = operation_rules(partial); + let validated = validated(input, &rules)?; + if let Some(policy) = validated.get("deadline_policy") + && let Some(message) = deadline_policy_error(policy) + { + return Err(invalid("deadline_policy", message)); + } + if (!partial || validated.contains_key("handler_target_reference") || validated.contains_key("handler_binding")) + && let Some(error) = binding_error( + validated.get("handler_target_reference").unwrap_or(&Value::Null), + validated.get("handler_binding").unwrap_or(&Value::Null), + ) + { + return Err(error); + } + Ok(validated) +} + +/// A JSON column as PHP's array cast reads it: `json_decode($text, true)`, +/// encoded again as the response does. +pub(crate) fn php_document(value: Value) -> Value { + match value { + Value::Null => Value::Null, + value => super::php_value::decode(value.to_string().as_bytes()).map_or(Value::Null, |decoded| decoded.to_json()), + } +} + +/// Carbon's `toIso8601String` and `format('c')`: whole seconds with an offset. +fn iso(at: Option>) -> Value { + at.map_or(Value::Null, |at| json!(at.to_rfc3339_opts(chrono::SecondsFormat::Secs, false))) +} + +fn or_empty(value: &Value) -> Value { + if value.is_array() || value.is_object() { value.clone() } else { json!([]) } +} + +fn not_found(response: Value) -> RuntimeError { + RuntimeError::Protocol { status: StatusCode::NOT_FOUND, response } +} + +fn conflict(response: Value) -> RuntimeError { + RuntimeError::Protocol { status: StatusCode::CONFLICT, response } +} + +/// The catalog's own row fields. +#[derive(Clone, Debug)] +pub(crate) struct Row { + pub(crate) id: String, + pub(crate) namespace: Option, + pub(crate) name: String, + description: Option, + created_at: Option>, + updated_at: Option>, +} + +#[derive(Clone, Debug)] +pub(crate) struct Endpoint { + pub(crate) row: Row, + metadata: Value, + /// Not settable over the API; a taken-over PHP database may hold one. + pub(crate) boundary_policy: Value, +} + +#[derive(Clone, Debug)] +pub(crate) struct Service { + pub(crate) row: Row, + pub(crate) endpoint_id: String, + metadata: Value, + /// Not settable over the API; a taken-over PHP database may hold one. + pub(crate) boundary_policy: Value, +} + +#[derive(Clone, Debug)] +pub(crate) struct Operation { + pub(crate) row: Row, + pub(crate) endpoint_id: String, + pub(crate) service_id: String, + mode: String, + kind: String, + target: Option, + /// `DOCUMENTS`, in order, as PHP reads them. + documents: [Value; 7], +} + +impl Operation { + /// A JSON document column as PHP's array cast reads it, or null. + pub(crate) fn document(&self, field: &str) -> &Value { + DOCUMENTS.iter().position(|name| *name == field).map_or(&Value::Null, |index| &self.documents[index]) + } + + pub(crate) fn mode(&self) -> &str { + &self.mode + } + + pub(crate) fn kind(&self) -> &str { + &self.kind + } + + pub(crate) fn target(&self) -> Option<&str> { + self.target.as_deref() + } +} + +/// `ServiceEndpointView::listItem`. +pub(crate) fn endpoint_item(endpoint: &Endpoint) -> Value { + json!({"id": endpoint.row.id, "namespace": endpoint.row.namespace, "endpoint_name": endpoint.row.name, + "description": endpoint.row.description, "metadata": or_empty(&endpoint.metadata), + "created_at": iso(endpoint.row.created_at), "updated_at": iso(endpoint.row.updated_at)}) +} + +/// `ServiceView::listItem`. +pub(crate) fn service_item(service: &Service) -> Value { + json!({"id": service.row.id, "workflow_service_endpoint_id": service.endpoint_id, "namespace": service.row.namespace, + "service_name": service.row.name, "description": service.row.description, "metadata": or_empty(&service.metadata), + "created_at": iso(service.row.created_at), "updated_at": iso(service.row.updated_at)}) +} + +/// `ServiceOperationView::listItem`. +pub(crate) fn operation_item(operation: &Operation) -> Value { + let mut item = json!({"id": operation.row.id, "workflow_service_endpoint_id": operation.endpoint_id, + "workflow_service_id": operation.service_id, "namespace": operation.row.namespace, + "operation_name": operation.row.name, "description": operation.row.description, + "operation_mode": operation.mode, "handler_binding_kind": operation.kind, + "handler_target_reference": operation.target, + "created_at": iso(operation.row.created_at), "updated_at": iso(operation.row.updated_at)}); + for (field, value) in DOCUMENTS.iter().zip(&operation.documents) { + item[*field] = or_empty(value); + } + item +} + +/// `serializeEndpoint`. +fn endpoint_body(endpoint: &Endpoint) -> Value { + json!({"id": endpoint.row.id, "namespace": endpoint.row.namespace, "endpoint_name": endpoint.row.name, + "description": endpoint.row.description, "metadata": endpoint.metadata, + "created_at": iso(endpoint.row.created_at), "updated_at": iso(endpoint.row.updated_at)}) +} + +/// `serializeService`. +fn service_body(service: &Service, endpoint: &Endpoint) -> Value { + json!({"id": service.row.id, "namespace": service.row.namespace, "endpoint_id": endpoint.row.id, + "endpoint_name": endpoint.row.name, "service_name": service.row.name, "description": service.row.description, + "metadata": service.metadata, "created_at": iso(service.row.created_at), "updated_at": iso(service.row.updated_at)}) +} + +/// `serializeOperation`. +fn operation_body(operation: &Operation, endpoint: &Endpoint, service: &Service) -> Value { + let mut body = json!({"id": operation.row.id, "namespace": operation.row.namespace, "endpoint_id": endpoint.row.id, + "endpoint_name": endpoint.row.name, "service_id": service.row.id, "service_name": service.row.name, + "operation_name": operation.row.name, "description": operation.row.description, + "operation_mode": operation.mode, "handler_binding_kind": operation.kind, + "handler_target_reference": operation.target, + "created_at": iso(operation.row.created_at), "updated_at": iso(operation.row.updated_at)}); + for (field, value) in DOCUMENTS.iter().zip(&operation.documents) { + body[*field] = value.clone(); + } + body +} + +/// `endpointNotFound`. +fn endpoint_not_found(namespace: &str, name: &str) -> RuntimeError { + not_found(json!({"accepted": false, + "message": format!("Service endpoint [{name}] not found in namespace [{namespace}]."), + "reason": "endpoint_not_found", "namespace": namespace, "endpoint_name": name})) +} + +/// `serviceNotFound`. +fn service_not_found(endpoint: &Endpoint, name: &str) -> RuntimeError { + let namespace = endpoint.row.namespace.as_deref().unwrap_or_default(); + not_found(json!({ + "message": format!("Service [{name}] not found under endpoint [{}] in namespace [{namespace}].", endpoint.row.name), + "reason": "service_not_found", "namespace": endpoint.row.namespace, "endpoint_name": endpoint.row.name, + "service_name": name})) +} + +/// `operationNotFound`. +fn operation_not_found(endpoint: &Endpoint, service: &Service, name: &str) -> RuntimeError { + let namespace = service.row.namespace.as_deref().unwrap_or_default(); + not_found(json!({ + "message": format!("Operation [{name}] not found under service [{}] at endpoint [{}] in namespace [{namespace}].", + service.row.name, endpoint.row.name), + "reason": "operation_not_found", "namespace": service.row.namespace, "endpoint_name": endpoint.row.name, + "service_name": service.row.name, "operation_name": name})) +} + +/// A lowercase ULID, as `HasUlids::newUniqueId` makes one. +fn new_id() -> String { + super::store::id().to_ascii_lowercase() +} + +/// The validated fields an update changes, as Eloquent's dirty check sees +/// them: a document compares as PHP reads it back. +fn changed(current: &Value, next: &Value) -> bool { + current != next +} + +store_methods! { + fn catalog_row(row: &DB::Row, name: &str) -> Result { + Ok(Row { + id: DB::string(row, "id")?, + namespace: DB::optional_string(row, "namespace")?, + name: DB::string(row, name)?, + description: DB::optional_string(row, "description")?, + created_at: DB::optional_instant(row, "created_at")?, + updated_at: DB::optional_instant(row, "updated_at")?, + }) + } + + fn catalog_document(row: &DB::Row, field: &str) -> Result { + Ok(DB::optional_document_row(row, field)?.map_or(Value::Null, php_document)) + } + + pub(crate) fn endpoint_row(row: &DB::Row) -> Result { + Ok(Endpoint { + row: Self::catalog_row(row, "endpoint_name")?, + metadata: Self::catalog_document(row, "metadata")?, + boundary_policy: Self::catalog_document(row, "boundary_policy")?, + }) + } + + pub(crate) fn service_row(row: &DB::Row) -> Result { + Ok(Service { + row: Self::catalog_row(row, "service_name")?, + endpoint_id: DB::string(row, "workflow_service_endpoint_id")?, + metadata: Self::catalog_document(row, "metadata")?, + boundary_policy: Self::catalog_document(row, "boundary_policy")?, + }) + } + + pub(crate) fn operation_row(row: &DB::Row) -> Result { + let mut documents: [Value; 7] = Default::default(); + for (slot, field) in documents.iter_mut().zip(DOCUMENTS) { + *slot = Self::catalog_document(row, field)?; + } + Ok(Operation { + row: Self::catalog_row(row, "operation_name")?, + endpoint_id: DB::string(row, "workflow_service_endpoint_id")?, + service_id: DB::string(row, "workflow_service_id")?, + mode: DB::string(row, "operation_mode")?, + kind: DB::string(row, "handler_binding_kind")?, + target: DB::optional_string(row, "handler_target_reference")?, + documents, + }) + } + + /// `findEndpoint`: the namespace's endpoint of this (lowercased) name. + async fn catalog_endpoint(&self, connection: &mut DB::Connection, name: &str) -> Result> { + Self::query("SELECT * FROM workflow_service_endpoints WHERE namespace=$1 AND endpoint_name=$2 LIMIT 1") + .bind(self.namespace.as_ref()).bind(name).fetch_optional(&mut *connection).await? + .map(|row| Self::endpoint_row(&row)).transpose() + } + + /// `findService`. + async fn catalog_service(&self, connection: &mut DB::Connection, endpoint: &Endpoint, name: &str) -> Result> { + Self::query("SELECT * FROM workflow_services WHERE namespace=$1 AND workflow_service_endpoint_id=$2 AND service_name=$3 LIMIT 1") + .bind(self.namespace.as_ref()).bind(endpoint.row.id.as_str()).bind(name).fetch_optional(&mut *connection).await? + .map(|row| Self::service_row(&row)).transpose() + } + + /// `findOperation`. + async fn catalog_operation(&self, connection: &mut DB::Connection, service: &Service, name: &str) -> Result> { + Self::query("SELECT * FROM workflow_service_operations WHERE namespace=$1 AND workflow_service_id=$2 AND operation_name=$3 LIMIT 1") + .bind(self.namespace.as_ref()).bind(service.row.id.as_str()).bind(name).fetch_optional(&mut *connection).await? + .map(|row| Self::operation_row(&row)).transpose() + } + + /// The endpoint, or PHP's 404 for it. + async fn required_endpoint(&self, connection: &mut DB::Connection, name: &str) -> Result { + let name = lower(name); + match self.catalog_endpoint(connection, &name).await? { + Some(endpoint) => Ok(endpoint), + None => Err(endpoint_not_found(&self.namespace, &name)), + } + } + + /// The endpoint and service, or PHP's 404 for the first one missing. + async fn required_service(&self, connection: &mut DB::Connection, endpoint: &str, service: &str) -> Result<(Endpoint, Service)> { + let endpoint = self.required_endpoint(connection, endpoint).await?; + let name = lower(service); + match self.catalog_service(connection, &endpoint, &name).await? { + Some(service) => Ok((endpoint, service)), + None => Err(service_not_found(&endpoint, &name)), + } + } + + /// The endpoint, service and operation, or PHP's 404 for the first one missing. + pub(crate) async fn required_operation(&self, connection: &mut DB::Connection, names: [&str; 3]) -> Result<(Endpoint, Service, Operation)> { + let (endpoint, service) = self.required_service(connection, names[0], names[1]).await?; + let name = lower(names[2]); + match self.catalog_operation(connection, &service, &name).await? { + Some(operation) => Ok((endpoint, service, operation)), + None => Err(operation_not_found(&endpoint, &service, &name)), + } + } + + async fn catalog_has(connection: &mut DB::Connection, sql: &'static str, id: &str) -> Result { + Ok(Self::scalar(sql).bind(id).fetch_one(&mut *connection).await? > 0) + } + + /// `endpointIndex`. + pub(crate) async fn list_service_endpoints(&self) -> Result { + let mut tx = self.begin_read().await?; + let rows = Self::query("SELECT * FROM workflow_service_endpoints WHERE namespace=$1 ORDER BY endpoint_name") + .bind(self.namespace.as_ref()).fetch_all(&mut *tx).await?; + let endpoints = rows.iter().map(|row| Self::endpoint_row(row).map(|endpoint| endpoint_body(&endpoint))).collect::>>()?; + tx.commit().await?; + Ok(json!({"service_endpoints": endpoints})) + } + + /// `endpointStore`. + pub(crate) async fn create_service_endpoint(&self, input: Value) -> Result { + let fields = validated(&input, ENDPOINT_STORE)?; + let name = lower(fields.get("endpoint_name").and_then(Value::as_str).unwrap_or_default()); + let mut tx = self.begin().await?; + if let Some(existing) = self.catalog_endpoint(&mut tx, &name).await? { + return Err(conflict(json!({ + "message": format!("A service endpoint [{name}] already exists in namespace [{}].", self.namespace), + "reason": "endpoint_already_exists", "namespace": self.namespace.as_ref(), "endpoint_name": name, + "endpoint_id": existing.row.id}))); + } + let at = super::store::now(); + let endpoint = Endpoint { + row: Row { id: new_id(), namespace: Some(self.namespace.to_string()), name, + description: fields.get("description").and_then(Value::as_str).map(str::to_owned), + created_at: Some(at), updated_at: Some(at) }, + metadata: php_document(fields.get("metadata").cloned().unwrap_or(Value::Null)), + boundary_policy: Value::Null, + }; + Self::query("INSERT INTO workflow_service_endpoints(id,namespace,endpoint_name,description,metadata,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7)") + .bind(endpoint.row.id.as_str()).bind(self.namespace.as_ref()).bind(endpoint.row.name.as_str()) + .bind(endpoint.row.description.as_deref()).bind(DB::optional_document(&endpoint.metadata)) + .bind(DB::bind_time(at)).bind(DB::bind_time(at)).execute(&mut *tx).await?; + tx.commit().await?; + Ok(endpoint_body(&endpoint)) + } + + /// `endpointShow`. + pub(crate) async fn show_service_endpoint(&self, name: &str) -> Result { + let mut tx = self.begin_read().await?; + let endpoint = self.required_endpoint(&mut tx, name).await?; + tx.commit().await?; + Ok(endpoint_body(&endpoint)) + } + + /// `endpointUpdate`: the endpoint first, then the input. + pub(crate) async fn update_service_endpoint(&self, name: &str, input: Value) -> Result { + let mut tx = self.begin().await?; + let mut endpoint = self.required_endpoint(&mut tx, name).await?; + let fields = validated(&input, DESCRIBE)?; + let mut dirty = false; + if let Some(description) = fields.get("description") { + let description = description.as_str().map(str::to_owned); + dirty |= description != endpoint.row.description; + endpoint.row.description = description; + } + if let Some(metadata) = fields.get("metadata") { + let metadata = php_document(metadata.clone()); + dirty |= changed(&endpoint.metadata, &metadata); + endpoint.metadata = metadata; + } + if dirty { + let at = super::store::now(); + Self::query("UPDATE workflow_service_endpoints SET description=$1,metadata=$2,updated_at=$3 WHERE id=$4") + .bind(endpoint.row.description.as_deref()).bind(DB::optional_document(&endpoint.metadata)) + .bind(DB::bind_time(at)).bind(endpoint.row.id.as_str()).execute(&mut *tx).await?; + endpoint.row.updated_at = Some(at); + } + tx.commit().await?; + Ok(endpoint_body(&endpoint)) + } + + /// `endpointDestroy`. + pub(crate) async fn delete_service_endpoint(&self, name: &str) -> Result { + let mut tx = self.begin().await?; + let endpoint = self.required_endpoint(&mut tx, name).await?; + let namespace = endpoint.row.namespace.as_deref().unwrap_or_default(); + for (sql, children, reason) in [ + ("SELECT COUNT(*) FROM workflow_services WHERE workflow_service_endpoint_id=$1", "registered services", "endpoint_has_services"), + ("SELECT COUNT(*) FROM workflow_service_operations WHERE workflow_service_endpoint_id=$1", "registered operations", "endpoint_has_operations"), + ("SELECT COUNT(*) FROM workflow_service_calls WHERE workflow_service_endpoint_id=$1", "recorded service calls", "endpoint_has_service_calls"), + ] { + if Self::catalog_has(&mut tx, sql, &endpoint.row.id).await? { + return Err(conflict(json!({ + "message": format!("Service endpoint [{}] in namespace [{namespace}] still has {children}.", endpoint.row.name), + "reason": reason, "namespace": endpoint.row.namespace, "endpoint_name": endpoint.row.name}))); + } + } + Self::query("DELETE FROM workflow_service_endpoints WHERE id=$1").bind(endpoint.row.id.as_str()).execute(&mut *tx).await?; + tx.commit().await?; + Ok(json!({"namespace": self.namespace.as_ref(), "endpoint_name": endpoint.row.name, "outcome": "deleted"})) + } + + /// `serviceIndex`: the endpoint's services in creation order. + pub(crate) async fn list_services(&self, endpoint: &str) -> Result { + let mut tx = self.begin_read().await?; + let endpoint = self.required_endpoint(&mut tx, endpoint).await?; + let rows = Self::query("SELECT * FROM workflow_services WHERE workflow_service_endpoint_id=$1 ORDER BY created_at,id,service_name") + .bind(endpoint.row.id.as_str()).fetch_all(&mut *tx).await?; + let services = rows.iter().map(|row| Self::service_row(row).map(|service| service_body(&service, &endpoint))) + .collect::>>()?; + tx.commit().await?; + Ok(json!({"services": services})) + } + + /// `serviceStore`: the endpoint first, then the input. + pub(crate) async fn create_service(&self, endpoint: &str, input: Value) -> Result { + let mut tx = self.begin().await?; + let endpoint = self.required_endpoint(&mut tx, endpoint).await?; + let fields = validated(&input, SERVICE_STORE)?; + let name = lower(fields.get("service_name").and_then(Value::as_str).unwrap_or_default()); + let namespace = endpoint.row.namespace.clone(); + if let Some(existing) = self.catalog_service(&mut tx, &endpoint, &name).await? { + return Err(conflict(json!({ + "message": format!("A service [{name}] already exists under endpoint [{}] in namespace [{}].", + endpoint.row.name, namespace.as_deref().unwrap_or_default()), + "reason": "service_already_exists", "namespace": namespace, "endpoint_name": endpoint.row.name, + "service_name": name, "service_id": existing.row.id}))); + } + let at = super::store::now(); + let service = Service { + row: Row { id: new_id(), namespace, name, + description: fields.get("description").and_then(Value::as_str).map(str::to_owned), + created_at: Some(at), updated_at: Some(at) }, + endpoint_id: endpoint.row.id.clone(), + metadata: php_document(fields.get("metadata").cloned().unwrap_or(Value::Null)), + boundary_policy: Value::Null, + }; + Self::query("INSERT INTO workflow_services(id,workflow_service_endpoint_id,namespace,service_name,description,metadata,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)") + .bind(service.row.id.as_str()).bind(service.endpoint_id.as_str()).bind(service.row.namespace.as_deref()) + .bind(service.row.name.as_str()).bind(service.row.description.as_deref()).bind(DB::optional_document(&service.metadata)) + .bind(DB::bind_time(at)).bind(DB::bind_time(at)).execute(&mut *tx).await?; + tx.commit().await?; + Ok(service_body(&service, &endpoint)) + } + + /// `serviceShow`. + pub(crate) async fn show_service(&self, endpoint: &str, service: &str) -> Result { + let mut tx = self.begin_read().await?; + let (endpoint, service) = self.required_service(&mut tx, endpoint, service).await?; + tx.commit().await?; + Ok(service_body(&service, &endpoint)) + } + + /// `serviceUpdate`. + pub(crate) async fn update_service(&self, endpoint: &str, service: &str, input: Value) -> Result { + let mut tx = self.begin().await?; + let (endpoint, mut service) = self.required_service(&mut tx, endpoint, service).await?; + let fields = validated(&input, DESCRIBE)?; + let mut dirty = false; + if let Some(description) = fields.get("description") { + let description = description.as_str().map(str::to_owned); + dirty |= description != service.row.description; + service.row.description = description; + } + if let Some(metadata) = fields.get("metadata") { + let metadata = php_document(metadata.clone()); + dirty |= changed(&service.metadata, &metadata); + service.metadata = metadata; + } + if dirty { + let at = super::store::now(); + Self::query("UPDATE workflow_services SET description=$1,metadata=$2,updated_at=$3 WHERE id=$4") + .bind(service.row.description.as_deref()).bind(DB::optional_document(&service.metadata)) + .bind(DB::bind_time(at)).bind(service.row.id.as_str()).execute(&mut *tx).await?; + service.row.updated_at = Some(at); + } + tx.commit().await?; + Ok(service_body(&service, &endpoint)) + } + + /// `serviceDestroy`. + pub(crate) async fn delete_service(&self, endpoint: &str, service: &str) -> Result { + let mut tx = self.begin().await?; + let (endpoint, service) = self.required_service(&mut tx, endpoint, service).await?; + let namespace = service.row.namespace.as_deref().unwrap_or_default(); + for (sql, children, reason) in [ + ("SELECT COUNT(*) FROM workflow_service_operations WHERE workflow_service_id=$1", "registered operations", "service_has_operations"), + ("SELECT COUNT(*) FROM workflow_service_calls WHERE workflow_service_id=$1", "recorded service calls", "service_has_service_calls"), + ] { + if Self::catalog_has(&mut tx, sql, &service.row.id).await? { + return Err(conflict(json!({ + "message": format!("Service [{}] under endpoint [{}] in namespace [{namespace}] still has {children}.", + service.row.name, endpoint.row.name), + "reason": reason, "namespace": service.row.namespace, "endpoint_name": endpoint.row.name, + "service_name": service.row.name}))); + } + } + Self::query("DELETE FROM workflow_services WHERE id=$1").bind(service.row.id.as_str()).execute(&mut *tx).await?; + tx.commit().await?; + Ok(json!({"namespace": endpoint.row.namespace, "endpoint_name": endpoint.row.name, + "service_name": service.row.name, "outcome": "deleted"})) + } + + /// `operationIndex`: the service's operations in creation order. + pub(crate) async fn list_operations(&self, endpoint: &str, service: &str) -> Result { + let mut tx = self.begin_read().await?; + let (endpoint, service) = self.required_service(&mut tx, endpoint, service).await?; + let rows = Self::query("SELECT * FROM workflow_service_operations WHERE workflow_service_id=$1 ORDER BY created_at,id,operation_name") + .bind(service.row.id.as_str()).fetch_all(&mut *tx).await?; + let operations = rows.iter() + .map(|row| Self::operation_row(row).map(|operation| operation_body(&operation, &endpoint, &service))) + .collect::>>()?; + tx.commit().await?; + Ok(json!({"operations": operations})) + } + + /// `operationStore`: the endpoint and service first, then the input. + pub(crate) async fn create_operation(&self, endpoint: &str, service: &str, input: Value) -> Result { + let mut tx = self.begin().await?; + let (endpoint, service) = self.required_service(&mut tx, endpoint, service).await?; + let fields = operation_payload(&input, false)?; + let name = lower(fields.get("operation_name").and_then(Value::as_str).unwrap_or_default()); + let namespace = service.row.namespace.clone(); + if let Some(existing) = self.catalog_operation(&mut tx, &service, &name).await? { + return Err(conflict(json!({ + "message": format!("An operation [{name}] already exists under service [{}] at endpoint [{}] in namespace [{}].", + service.row.name, endpoint.row.name, namespace.as_deref().unwrap_or_default()), + "reason": "operation_already_exists", "namespace": namespace, "endpoint_name": endpoint.row.name, + "service_name": service.row.name, "operation_name": name, "operation_id": existing.row.id}))); + } + let at = super::store::now(); + let text = |field: &str| fields.get(field).and_then(Value::as_str).map(str::to_owned); + let operation = Operation { + row: Row { id: new_id(), namespace, name, description: text("description"), created_at: Some(at), updated_at: Some(at) }, + endpoint_id: endpoint.row.id.clone(), + service_id: service.row.id.clone(), + mode: text("operation_mode").unwrap_or_default(), + kind: text("handler_binding_kind").unwrap_or_default(), + target: text("handler_target_reference"), + documents: DOCUMENTS.map(|field| php_document(fields.get(field).cloned().unwrap_or(Value::Null))), + }; + Self::query("INSERT INTO workflow_service_operations(id,workflow_service_endpoint_id,workflow_service_id,namespace,operation_name,description,operation_mode,handler_binding_kind,handler_target_reference,handler_binding,deadline_policy,idempotency_policy,cancellation_policy,retry_policy,boundary_policy,metadata,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)") + .bind(operation.row.id.as_str()).bind(operation.endpoint_id.as_str()).bind(operation.service_id.as_str()) + .bind(operation.row.namespace.as_deref()).bind(operation.row.name.as_str()).bind(operation.row.description.as_deref()) + .bind(operation.mode.as_str()).bind(operation.kind.as_str()).bind(operation.target.as_deref()) + .bind(DB::optional_document(&operation.documents[0])).bind(DB::optional_document(&operation.documents[1])) + .bind(DB::optional_document(&operation.documents[2])).bind(DB::optional_document(&operation.documents[3])) + .bind(DB::optional_document(&operation.documents[4])).bind(DB::optional_document(&operation.documents[5])) + .bind(DB::optional_document(&operation.documents[6])) + .bind(DB::bind_time(at)).bind(DB::bind_time(at)).execute(&mut *tx).await?; + tx.commit().await?; + Ok(operation_body(&operation, &endpoint, &service)) + } + + /// `operationShow`. + pub(crate) async fn show_operation(&self, names: [&str; 3]) -> Result { + let mut tx = self.begin_read().await?; + let (endpoint, service, operation) = self.required_operation(&mut tx, names).await?; + tx.commit().await?; + Ok(operation_body(&operation, &endpoint, &service)) + } + + /// `operationUpdate`: the given fields, with the binding check repeated + /// against the stored target and binding when either changes. + pub(crate) async fn update_operation(&self, names: [&str; 3], input: Value) -> Result { + let mut tx = self.begin().await?; + let (endpoint, service, mut operation) = self.required_operation(&mut tx, names).await?; + let fields = operation_payload(&input, true)?; + if fields.contains_key("handler_target_reference") || fields.contains_key("handler_binding") { + let target = fields.get("handler_target_reference").cloned().unwrap_or_else(|| json!(operation.target)); + let binding = fields.get("handler_binding").cloned().unwrap_or_else(|| operation.document("handler_binding").clone()); + if let Some(error) = binding_error(&target, &binding) { + return Err(error); + } + } + let mut dirty = false; + let text = |field: &str| fields.get(field).map(|value| value.as_str().map(str::to_owned)); + if let Some(description) = text("description") { + dirty |= description != operation.row.description; + operation.row.description = description; + } + // A required string column: PHP would fail to write null, which the + // rules never let through (`sometimes|string`). + if let Some(Some(mode)) = text("operation_mode") { + dirty |= mode != operation.mode; + operation.mode = mode; + } + if let Some(Some(kind)) = text("handler_binding_kind") { + dirty |= kind != operation.kind; + operation.kind = kind; + } + if let Some(target) = text("handler_target_reference") { + dirty |= target != operation.target; + operation.target = target; + } + for (index, field) in DOCUMENTS.iter().enumerate() { + if let Some(value) = fields.get(*field) { + let value = php_document(value.clone()); + dirty |= changed(&operation.documents[index], &value); + operation.documents[index] = value; + } + } + if dirty { + let at = super::store::now(); + Self::query("UPDATE workflow_service_operations SET description=$1,operation_mode=$2,handler_binding_kind=$3,handler_target_reference=$4,handler_binding=$5,deadline_policy=$6,idempotency_policy=$7,cancellation_policy=$8,retry_policy=$9,boundary_policy=$10,metadata=$11,updated_at=$12 WHERE id=$13") + .bind(operation.row.description.as_deref()).bind(operation.mode.as_str()).bind(operation.kind.as_str()) + .bind(operation.target.as_deref()) + .bind(DB::optional_document(&operation.documents[0])).bind(DB::optional_document(&operation.documents[1])) + .bind(DB::optional_document(&operation.documents[2])).bind(DB::optional_document(&operation.documents[3])) + .bind(DB::optional_document(&operation.documents[4])).bind(DB::optional_document(&operation.documents[5])) + .bind(DB::optional_document(&operation.documents[6])) + .bind(DB::bind_time(at)).bind(operation.row.id.as_str()).execute(&mut *tx).await?; + operation.row.updated_at = Some(at); + } + tx.commit().await?; + Ok(operation_body(&operation, &endpoint, &service)) + } + + /// `operationDestroy`. + pub(crate) async fn delete_operation(&self, names: [&str; 3]) -> Result { + let mut tx = self.begin().await?; + let (endpoint, service, operation) = self.required_operation(&mut tx, names).await?; + if Self::catalog_has(&mut tx, "SELECT COUNT(*) FROM workflow_service_calls WHERE workflow_service_operation_id=$1", &operation.row.id).await? { + return Err(conflict(json!({ + "message": format!("Operation [{}] under service [{}] at endpoint [{}] in namespace [{}] still has recorded service calls.", + operation.row.name, service.row.name, endpoint.row.name, operation.row.namespace.as_deref().unwrap_or_default()), + "reason": "operation_has_service_calls", "namespace": operation.row.namespace, "endpoint_name": endpoint.row.name, + "service_name": service.row.name, "operation_name": operation.row.name}))); + } + Self::query("DELETE FROM workflow_service_operations WHERE id=$1").bind(operation.row.id.as_str()).execute(&mut *tx).await?; + tx.commit().await?; + Ok(json!({"namespace": endpoint.row.namespace, "endpoint_name": endpoint.row.name, + "service_name": service.row.name, "operation_name": operation.row.name, "outcome": "deleted"})) + } +} + +/// The ruled fields each write reads, for `input`. +pub(crate) fn rules_for(level: Level, create: bool) -> Vec<(&'static str, &'static [Rule])> { + match (level, create) { + (Level::Endpoint, true) => ENDPOINT_STORE.to_vec(), + (Level::Service, true) => SERVICE_STORE.to_vec(), + (Level::Endpoint | Level::Service, false) => DESCRIBE.to_vec(), + (Level::Operation, create) => operation_rules(!create), + } +} + +/// The catalog level a route writes. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Level { + Endpoint, + Service, + Operation, +} + +/// Catalog rows as published PHP writes them, for the takeover tests: lowercase +/// ULIDs, microsecond UTC times and `json_encode` documents, with escaped +/// slashes and `[]` for an empty map. +#[cfg(test)] +pub(crate) const PHP_CATALOG_ROWS: [&str; 3] = [ + r#"INSERT INTO workflow_service_endpoints(id,namespace,endpoint_name,description,metadata,created_at,updated_at) VALUES ('01m5wvy8d1j3dtr13es05k7vey','default','billing','Payments λ','{"team":"core","docs":"https:\/\/example.test\/billing","empty":[]}','2026-10-26 19:45:31.123456','2026-10-26 19:45:32.654321')"#, + r#"INSERT INTO workflow_services(id,workflow_service_endpoint_id,namespace,service_name,description,metadata,created_at,updated_at) VALUES ('01m5wvy8takr150q5sh66c1xck','01m5wvy8d1j3dtr13es05k7vey','default','invoices',NULL,NULL,'2026-10-26 19:45:33.000001','2026-10-26 19:45:33.000001')"#, + r#"INSERT INTO workflow_service_operations(id,workflow_service_endpoint_id,workflow_service_id,namespace,operation_name,description,operation_mode,handler_binding_kind,handler_target_reference,handler_binding,deadline_policy,idempotency_policy,cancellation_policy,retry_policy,boundary_policy,metadata,created_at,updated_at) VALUES ('01m5wvy930w32awr226fys6rsj','01m5wvy8d1j3dtr13es05k7vey','01m5wvy8takr150q5sh66c1xck','default','charge',NULL,'sync','start_workflow','billing.charge',NULL,'{"timeout_seconds":30}',NULL,NULL,'{"max_attempts":3}',NULL,'[]','2026-10-26 19:45:34.5','2026-10-26 19:45:34.5')"#, +]; + +/// What native serves over `PHP_CATALOG_ROWS` after a takeover, and that it +/// administers them as its own rows. +#[cfg(test)] +pub(crate) async fn assert_php_catalog_served(runtime: &super::Runtime) { + let endpoint = runtime.show_service_endpoint("Billing").await.unwrap(); + assert_eq!(endpoint, json!({"id": "01m5wvy8d1j3dtr13es05k7vey", "namespace": "default", "endpoint_name": "billing", + "description": "Payments λ", "metadata": {"team": "core", "docs": "https://example.test/billing", "empty": []}, + "created_at": "2026-10-26T19:45:31+00:00", "updated_at": "2026-10-26T19:45:32+00:00"})); + let operations = runtime.list_operations("billing", "invoices").await.unwrap(); + let charge = &operations["operations"][0]; + assert_eq!((charge["deadline_policy"].clone(), charge["retry_policy"].clone(), charge["metadata"].clone(), charge["created_at"].clone()), + (json!({"timeout_seconds": 30}), json!({"max_attempts": 3}), json!([]), json!("2026-10-26T19:45:34+00:00"))); + // PHP's documents compare as PHP reads them: an unchanged update keeps updated_at. + let same = runtime.update_service_endpoint("billing", + json!({"metadata": {"team": "core", "docs": "https://example.test/billing", "empty": {}}})).await.unwrap(); + assert_eq!(same["updated_at"], "2026-10-26T19:45:32+00:00"); + // A native service follows PHP's in creation order. + runtime.create_service("billing", json!({"service_name": "accounts"})).await.unwrap(); + let services = runtime.list_services("billing").await.unwrap(); + let names: Vec<_> = services["services"].as_array().unwrap().iter().map(|service| service["service_name"].clone()).collect(); + assert_eq!(names, [json!("invoices"), json!("accounts")]); + let page = runtime.service_catalog_list(&[], super::service_catalog::Catalog::Operations).await.unwrap(); + assert_eq!((page["total"].clone(), page["data"][0]["id"].clone()), (json!(1), json!("01m5wvy930w32awr226fys6rsj"))); + match runtime.delete_service_endpoint("billing").await { + Err(RuntimeError::Protocol { status, response }) => { + assert_eq!((status, response["reason"].clone()), (StatusCode::CONFLICT, json!("endpoint_has_services"))); + } + other => panic!("expected a refusal, got {other:?}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn refusal(result: Result>) -> Value { + match result { + Err(RuntimeError::Protocol { status, response }) => json!([status.as_u16(), response["errors"]]), + other => panic!("expected a refusal, got {other:?}"), + } + } + + #[test] + fn names_follow_laravel_rules() { + let check = |body: Value| validated(&laravel_rules::normalize(&body), ENDPOINT_STORE); + assert!(check(json!({"endpoint_name": "Billing.v2_x-1"})).is_ok()); + assert_eq!(refusal(check(json!({}))), json!([422, {"endpoint_name": ["The endpoint name field is required."]}])); + assert_eq!(refusal(check(json!({"endpoint_name": "bad name!"}))), + json!([422, {"endpoint_name": ["The endpoint name field format is invalid."]}])); + // A number is not a string, but its digits fit the pattern and the length. + assert_eq!(refusal(check(json!({"endpoint_name": 500}))), + json!([422, {"endpoint_name": ["The endpoint name field must be a string."]}])); + assert_eq!(refusal(check(json!({"endpoint_name": ["a"]}))), + json!([422, {"endpoint_name": ["The endpoint name field must be a string.", "The endpoint name field format is invalid."]}])); + assert_eq!(refusal(check(json!({"endpoint_name": "a".repeat(192)}))), + json!([422, {"endpoint_name": ["The endpoint name field must not be greater than 191 characters."]}])); + assert_eq!(refusal(check(json!({"endpoint_name": "a", "metadata": "x", "description": 5}))), + json!([422, {"description": ["The description field must be a string."], "metadata": ["The metadata field must be an array."]}])); + } + + #[test] + fn operation_payloads_follow_validate_operation_payload() { + let check = |body: Value, partial: bool| operation_payload(&laravel_rules::normalize(&body), partial); + let create = json!({"operation_name": "charge", "operation_mode": "sync", "handler_binding_kind": "start_workflow", + "handler_target_reference": "billing.charge"}); + assert!(check(create.clone(), false).is_ok()); + assert_eq!(refusal(check(json!({}), false)), json!([422, { + "operation_mode": ["The operation mode field is required."], + "handler_binding_kind": ["The handler binding kind field is required."], + "operation_name": ["The operation name field is required."]}])); + let mut unbound = create.clone(); + unbound["handler_target_reference"] = json!(" "); + unbound["handler_binding"] = json!({}); + assert_eq!(refusal(check(unbound, false)), + json!([422, {"handler_target_reference": ["Provide handler_target_reference or a non-empty handler_binding."]}])); + let mut deadline = create.clone(); + deadline["deadline_policy"] = json!({"timeout_seconds": 0}); + assert_eq!(refusal(check(deadline, false)), + json!([422, {"deadline_policy": ["Service timeout_seconds must be an integer from 1 to 2147483647."]}])); + // An update may not rename, and checks the binding only when it names one. + assert_eq!(refusal(check(json!({"operation_name": "x"}), true)), + json!([422, {"operation_name": ["The operation name field is prohibited."]}])); + assert!(check(json!({"operation_name": "", "description": "d"}), true).is_ok()); + assert_eq!(refusal(check(json!({"handler_binding": null}), true)), + json!([422, {"handler_target_reference": ["Provide handler_target_reference or a non-empty handler_binding."]}])); + assert_eq!(refusal(check(json!({"operation_mode": "later"}), true)), + json!([422, {"operation_mode": ["The selected operation mode is invalid."]}])); + } + + #[test] + fn deadline_policies_follow_the_package_resolver() { + let exactly = Some("Service deadline_policy must contain exactly timeout_seconds or deadline_at."); + let shape = Some("Service deadline_at must be offset-bearing RFC3339 with at most six fractional digits."); + let calendar = Some("Service deadline_at must be a valid calendar instant."); + let years = Some("Service call deadlines must resolve within UTC years 1970 through 9999."); + let seconds = Some("Service timeout_seconds must be an integer from 1 to 2147483647."); + for (policy, expected) in [ + (json!(null), None), + (json!({"timeout_seconds": 1}), None), + (json!({"timeout_seconds": 2_147_483_647}), None), + (json!({"timeout_seconds": 2_147_483_648_i64}), seconds), + (json!({"timeout_seconds": 5.0}), seconds), + (json!({"timeout_seconds": "5"}), seconds), + (json!({}), exactly), + (json!([5]), exactly), + (json!({"timeout_seconds": 5, "deadline_at": "2030-01-01T00:00:00Z"}), exactly), + (json!({"deadline_at": "2030-01-01T00:00:00Z"}), None), + (json!({"deadline_at": "2030-01-01T00:00:00.123456+05:30"}), None), + (json!({"deadline_at": "2030-01-01T00:00:00.1234567Z"}), shape), + (json!({"deadline_at": "2030-01-01T00:00:00"}), shape), + (json!({"deadline_at": "2030-01-01 00:00:00Z"}), shape), + (json!({"deadline_at": "2030-01-01T00:00:00+24:00"}), shape), + (json!({"deadline_at": "2030-01-01T00:00:00.Z"}), shape), + (json!({"deadline_at": 5}), shape), + (json!({"deadline_at": "2030-02-30T00:00:00Z"}), calendar), + (json!({"deadline_at": "2030-01-01T24:00:00Z"}), calendar), + (json!({"deadline_at": "2030-01-01T23:59:60Z"}), calendar), + (json!({"deadline_at": "1969-12-31T23:59:59Z"}), years), + (json!({"deadline_at": "1970-01-01T00:30:00+01:00"}), years), + (json!({"deadline_at": "9999-12-31T23:30:00-01:00"}), years), + ] { + assert_eq!(deadline_policy_error(&policy), expected, "{policy}"); + } + } + + #[test] + fn documents_read_back_as_php_arrays() { + assert_eq!(php_document(json!({})), json!([])); + assert_eq!(php_document(json!({"0": "a", "1": {"x": {}}})), json!(["a", {"x": []}])); + assert_eq!(php_document(json!({"a": 1.0, "b": [1, 2]})), json!({"a": 1, "b": [1, 2]})); + assert_eq!(php_document(Value::Null), Value::Null); + } + + #[test] + fn input_reads_missing_ruled_fields_from_the_query() { + let query = vec![("description".to_owned(), " from query ".to_owned()), ("endpoint_name".to_owned(), "q".to_owned()), + ("other".to_owned(), "x".to_owned())]; + assert_eq!(input(json!({"endpoint_name": " body "}), &query, ENDPOINT_STORE), + json!({"endpoint_name": "body", "description": "from query"})); + assert_eq!(input(json!(["x"]), &[], ENDPOINT_STORE), json!({})); + } +} diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs index eabf0db5a..7034abe6c 100644 --- a/rust/src/runtime/upgrade.rs +++ b/rust/src/runtime/upgrade.rs @@ -107,6 +107,8 @@ pub(super) fn native_relation(name: &str) -> bool { | "dw_worker_registration_incarnations" | "dw_worker_session_holders" | "dw_stream_reclaims" + | "dw_service_call_deadlines" + | "dw_service_admission_counters" ) } @@ -125,6 +127,10 @@ pub(super) fn native_object(name: &str) -> bool { | "dw_worker_session_holders_pkey" | "dw_stream_reclaims_pkey" | "dw_stream_reclaims_due" + | "dw_service_call_deadlines_pkey" + | "dw_service_call_deadlines_due" + | "dw_service_admission_counters_pkey" + | "dw_service_admission_counters_expiry" ) } diff --git a/rust/src/runtime/upgrade/takeover.rs b/rust/src/runtime/upgrade/takeover.rs index 4412cfb71..ac27fadb7 100644 --- a/rust/src/runtime/upgrade/takeover.rs +++ b/rust/src/runtime/upgrade/takeover.rs @@ -221,7 +221,7 @@ fn report(backend: &str, layout: &str, digest: &Value, inventory: &Value, conver /// Published PHP features native does not implement yet. A source holding any /// of their rows is refused, rather than taken over without them. Derived /// projections and PHP's own queue and poll bookkeeping are left in place. -const UNSUPPORTED_FEATURES: [(&str, &[&str]); 6] = [ +const UNSUPPORTED_FEATURES: [(&str, &[&str]); 5] = [ ("upgrade_source_runtime_credentials_unsupported", &["runtime_credentials"]), ( "upgrade_source_message_streams_unsupported", @@ -233,10 +233,6 @@ const UNSUPPORTED_FEATURES: [(&str, &[&str]); 6] = [ "workflow_inbound_stream_items", ], ), - ( - "upgrade_source_services_unsupported", - &["workflow_services", "workflow_service_endpoints", "workflow_service_operations"], - ), ("upgrade_source_update_validation_unsupported", &["workflow_update_validation_tasks"]), ("upgrade_source_worker_sessions_unsupported", &["workflow_worker_sessions"]), ( diff --git a/rust/src/runtime/upgrade_tests.rs b/rust/src/runtime/upgrade_tests.rs index c2953c883..afbb2ba59 100644 --- a/rust/src/runtime/upgrade_tests.rs +++ b/rust/src/runtime/upgrade_tests.rs @@ -29,12 +29,16 @@ async fn php_layout() -> (tempfile::TempDir, String) { "\n", include_str!("../../migrations/sqlite/0005_worker_session_holders.sql"), "\n", - include_str!("../../migrations/sqlite/0006_stream_reclaims.sql") + include_str!("../../migrations/sqlite/0006_stream_reclaims.sql"), + "\n", + include_str!("../../migrations/sqlite/0007_service_calls.sql") )) .execute(&mut connection) .await .unwrap(); for table in [ + "dw_service_admission_counters", + "dw_service_call_deadlines", "dw_stream_reclaims", "dw_worker_session_holders", "dw_worker_registration_incarnations", @@ -1106,6 +1110,20 @@ async fn takeover_refusals_leave_the_php_database_unchanged() { } } +#[tokio::test] +async fn takeover_keeps_php_catalog_rows_that_native_then_serves() { + let (_directory, path) = php297_layout().await; + for statement in crate::runtime::service_endpoints::PHP_CATALOG_ROWS { + mutate(&path, statement).await; + } + let fingerprint = backup_fingerprint(&path).await; + let report = takeover::take_over_sqlite(&path, &fingerprint, &queue_config()).await.unwrap(); + assert_eq!(report["status"], "taken_over", "{report}"); + let runtime = crate::runtime::Runtime::open(&path, "test-token".into()).await.unwrap(); + crate::runtime::service_endpoints::assert_php_catalog_served(&runtime).await; + runtime.close().await; +} + /// The frozen layout in PHP Server's default WAL mode, copied while its /// writer was still open: one committed row is only in the WAL, as a crashed /// PHP would leave it. diff --git a/rust/src/runtime/workflow_streams.rs b/rust/src/runtime/workflow_streams.rs index a9c69e59f..9db71fc9c 100644 --- a/rust/src/runtime/workflow_streams.rs +++ b/rust/src/runtime/workflow_streams.rs @@ -794,7 +794,7 @@ pub(super) async fn items( ) -> Result> { located(&runtime, &workflow_id, &run_id).await?; items_rules(&query)?; - let value = |field: &str, default: i64| query_integer(&query, field).and_then(|value| value.ok()).unwrap_or(default); + let value = |field: &str, default: i64| query_integer(&query, field).and_then(std::result::Result::ok).unwrap_or(default); let from = value("from", 0); let max_items = value("max_items", DEFAULT_MAX_ITEMS_PER_SUBSCRIBE); let wait_seconds = value("wait_seconds", 0); diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index 26241677f..c1dfa961b 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -220,6 +220,10 @@ mod operator_dashboard; mod waterline_reads; #[path = "execution/nexus_operations.rs"] mod nexus_operations; +#[path = "execution/service_endpoints.rs"] +mod service_endpoints; +#[path = "execution/service_calls.rs"] +mod service_calls; #[path = "execution/task_queue_build_ids.rs"] mod task_queue_build_ids; #[path = "execution/start_timeouts.rs"] diff --git a/rust/tests/execution/command_requests.rs b/rust/tests/execution/command_requests.rs index 3ac168504..e156b55e9 100644 --- a/rust/tests/execution/command_requests.rs +++ b/rust/tests/execution/command_requests.rs @@ -80,8 +80,9 @@ async fn update_wait_timeout_is_bounded_before_admission() { } // Server #775: a service operation bound to a workflow update hands its wait -// to the update admission. Native runs no service operation, so neither the -// execute route nor the workflow-task command can carry a longer wait here. +// to the update admission. Native dispatches no workflow-bound handler yet, so +// neither the execute route nor the workflow-task command can carry a longer +// wait here. #[tokio::test] async fn service_operations_cannot_carry_an_update_wait_past_the_ceiling() { let database = TestDatabase::new().await; @@ -93,7 +94,16 @@ async fn service_operations_cannot_carry_an_update_wait_past_the_ceiling() { "service_name":"approvals","operation_name":"approve","wait_for":"completed","wait_timeout_seconds":86400}])).await; assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{refused}"); assert_eq!(refused["reason"], "unsupported_workflow_command", "{refused}"); + for (path, body) in [ + ("/api/service-endpoints", json!({"endpoint_name": "billing"})), + ("/api/service-endpoints/billing/services", json!({"service_name": "approvals"})), + ("/api/service-endpoints/billing/services/approvals/operations", json!({"operation_name": "approve", + "operation_mode": "sync", "handler_binding_kind": "update_workflow", "handler_target_reference": "approve"})), + ] { + let (status, created) = request(&app, "POST", path, body).await; + assert_eq!(status, StatusCode::CREATED, "{created}"); + } let (status, unserved) = request(&app, "POST", "/api/service-endpoints/billing/services/approvals/operations/approve/execute", json!({"wait_timeout_seconds":61})).await; - assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "{unserved}"); + assert_eq!((status, unserved["reason"].clone()), (StatusCode::NOT_IMPLEMENTED, json!("rust_capability_not_implemented")), "{unserved}"); } diff --git a/rust/tests/execution/service_calls.rs b/rust/tests/execution/service_calls.rs new file mode 100644 index 000000000..dbe4b9cf8 --- /dev/null +++ b/rust/tests/execution/service_calls.rs @@ -0,0 +1,377 @@ +use super::*; + +// Nexus service-call execution, show and cancel for the handler kinds that +// stay accepted (`activity_execution`, `invocable_http`), with the boundary's +// refusals, retries, deadlines and idempotent replays. The shared +// `service-calls` fixture records the same requests against published 2.11.1. + +const OPERATIONS: &str = "/api/service-endpoints/pay/services/billing/operations"; + +async fn catalog(app: &Router, operations: &[(&str, Value)]) { + let (status, body) = request(app, "POST", "/api/service-endpoints", json!({"endpoint_name": "pay"})).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + let (status, body) = request(app, "POST", "/api/service-endpoints/pay/services", json!({"service_name": "billing"})).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + for (name, definition) in operations { + let mut definition = definition.clone(); + definition["operation_name"] = json!(name); + if definition.get("operation_mode").is_none() { + definition["operation_mode"] = json!("async"); + } + let (status, body) = request(app, "POST", OPERATIONS, definition).await; + assert_eq!(status, StatusCode::CREATED, "{name}: {body}"); + } +} + +async fn execute(app: &Router, operation: &str, body: Value) -> (StatusCode, Value) { + request(app, "POST", &format!("{OPERATIONS}/{operation}/execute"), body).await +} + +fn activity(target: &str) -> Value { + json!({"handler_binding_kind": "activity_execution", "handler_target_reference": target}) +} + +fn is_upper_ulid(value: &Value) -> bool { + value.as_str().is_some_and(|id| id.len() == 26 && id.bytes().all(|byte| byte.is_ascii_digit() || byte.is_ascii_uppercase())) +} + +#[tokio::test] +async fn executed_calls_stay_accepted_with_their_handler_stamped() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + catalog(&app, &[ + ("charge", activity("charge-activity")), + ("charge-bound", json!({"handler_binding_kind": "activity_execution", "handler_binding": {"activity_class": "C", + "activity_type": "t", "activity_execution_id": "act-fixed-1", "owning_workflow_instance_id": "wf-own-1", "queue": "bq"}})), + ("notify", json!({"handler_binding_kind": "invocable_http", "handler_target_reference": "https://carrier.example/notify"})), + ("timed", json!({"handler_binding_kind": "activity_execution", "handler_target_reference": "t", + "deadline_policy": {"timeout_seconds": 3600}})), + ]).await; + + let (status, call) = execute(&app, "charge", json!({"connection": "cx", "queue": "qx", "caller_namespace": "Partner-NS", + "arguments": {"codec": "avro", "blob": "wwHioz3/VYAiNwwA"}})).await; + assert_eq!(status, StatusCode::OK, "{call}"); + assert_eq!((call["status"].clone(), call["outcome"].clone(), call["accepted"].clone(), call["idempotent_replay"].clone(), + call["reason"].clone(), call["payload_codec"].clone()), + (json!("accepted"), json!("accepted"), json!(true), json!(false), Value::Null, Value::Null)); + assert_eq!(call["id"], call["service_call_id"]); + assert!(is_upper_ulid(&call["resolved_target_reference"]), "{call}"); + assert_eq!(call["handler"], json!({"accepted": true, "kind": "activity_execution", + "activity_execution_id": call["resolved_target_reference"], "activity_class": "charge-activity", "activity_type": "charge-activity"})); + assert_eq!((call["metadata"]["boundary_key"].clone(), call["metadata"]["connection"].clone(), call["metadata"]["queue"].clone()), + (json!("Partner-NS|default|pay|billing|charge"), json!("cx"), json!("qx"))); + assert_eq!((call["caller_principal_subject"].clone(), call["caller_principal_roles"].clone(), call["caller_principal_claims"].clone()), + (json!("legacy-token"), json!(["admin"]), json!({"legacy_credential": true}))); + assert_eq!(call["policy_name"], "server.shared-service-boundary"); + let attempts = call["service_call_attempts"].as_array().unwrap(); + assert_eq!((attempts.len(), call["retry_attempt_count"].clone(), attempts[0]["retry_scheduled"].clone()), (1, json!(1), json!(false))); + assert!(call["accepted_at"].as_str().unwrap().ends_with('Z') && call["created_at"].as_str().unwrap().ends_with("+00:00"), "{call}"); + + let (status, bound) = execute(&app, "charge-bound", json!({})).await; + assert_eq!((status, bound["resolved_target_reference"].clone(), bound["linked_workflow_instance_id"].clone(), + bound["metadata"]["queue"].clone()), (StatusCode::OK, json!("act-fixed-1"), json!("wf-own-1"), json!("bq"))); + + let (status, notify) = execute(&app, "notify", json!({"mode_override": "sync"})).await; + assert_eq!((status, notify["resolved_binding_kind"].clone(), notify["operation_mode"].clone()), + (StatusCode::OK, json!("invocable_carrier_request"), json!("sync"))); + assert_eq!((notify["handler"]["carrier_endpoint"].clone(), notify["handler"]["carrier_handler"].clone()), + (json!("https://carrier.example/notify"), Value::Null)); + + let (status, timed) = execute(&app, "timed", json!({})).await; + assert_eq!(status, StatusCode::OK, "{timed}"); + let accepted = chrono::DateTime::parse_from_rfc3339(timed["accepted_at"].as_str().unwrap()).unwrap(); + let deadline = chrono::DateTime::parse_from_rfc3339(timed["call_deadline_at"].as_str().unwrap()).unwrap(); + assert_eq!(deadline - accepted, chrono::Duration::seconds(3600), "{timed}"); + + // Show reads the call by id, trimmed, under its own operation only. + let id = call["id"].as_str().unwrap(); + let (status, shown) = request(&app, "GET", &format!("{OPERATIONS}/charge/service-calls/%20{id}%20"), Value::Null).await; + assert_eq!((status, shown["found"].clone(), shown["service_call_id"].clone(), shown["status"].clone()), + (StatusCode::OK, json!(true), json!(id), json!("accepted"))); + let (status, missing) = request(&app, "GET", &format!("{OPERATIONS}/notify/service-calls/{id}"), Value::Null).await; + assert_eq!((status, missing), (StatusCode::NOT_FOUND, json!({ + "message": format!("Service call [{id}] not found under operation [notify] for service [billing] at endpoint [pay] in namespace [default]."), + "reason": "service_call_not_found", "namespace": "default", "endpoint_name": "pay", "service_name": "billing", + "operation_name": "notify", "service_call_id": id}))); +} + +#[tokio::test] +async fn handler_failures_retry_and_deadlines_expire() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + catalog(&app, &[ + ("broken", json!({"handler_binding_kind": "activity_execution", "handler_binding": {"queue": "q"}})), + ("broken-retry", json!({"handler_binding_kind": "activity_execution", "handler_binding": {"queue": "q"}, + "retry_policy": {"max_attempts": 3}})), + ("broken-noretry", json!({"handler_binding_kind": "activity_execution", "handler_binding": {"queue": "q"}, + "retry_policy": {"max_attempts": 3, "non_retryable_error_types": ["handler_target_missing"]}})), + ("notify-broken", json!({"handler_binding_kind": "invocable_http", "handler_binding": {"carrier": "c"}})), + ("expired", json!({"handler_binding_kind": "activity_execution", "handler_target_reference": "t", + "deadline_policy": {"deadline_at": "2001-02-03T04:05:06.5+01:00"}})), + ]).await; + + let (status, broken) = execute(&app, "broken", json!({})).await; + assert_eq!(status, StatusCode::CONFLICT, "{broken}"); + assert_eq!((broken["status"].clone(), broken["outcome"].clone(), broken["outcome_category"].clone(), broken["reason"].clone(), + broken["message"].clone(), broken["error_type"].clone()), + (json!("failed"), json!("handler_failed"), json!("handler"), json!("handler_target_missing"), + json!("Activity binding has no activity class or type."), json!("handler_target_missing"))); + assert_eq!(broken["outcome_metadata"]["failure_reason"], "handler_failure"); + assert_eq!(broken["handler"]["retry_scheduled"], false); + assert_eq!(broken["service_call_attempts"].as_array().unwrap().len(), 1); + + let (status, retried) = execute(&app, "broken-retry", json!({})).await; + assert_eq!(status, StatusCode::CONFLICT, "{retried}"); + let attempts = retried["service_call_attempts"].as_array().unwrap(); + assert_eq!(attempts.iter().map(|attempt| attempt["retry_scheduled"].clone()).collect::>(), + [json!(true), json!(true), json!(false)], "{retried}"); + assert_eq!((attempts[0]["status"].clone(), attempts[0]["scheduled_backoff_seconds"].clone(), attempts[2]["status"].clone()), + (json!("started"), json!(0), json!("failed"))); + assert!(retried["started_at"].is_string() && retried["metadata"]["last_retry_failure"].is_object(), "{retried}"); + + let (_, noretry) = execute(&app, "broken-noretry", json!({})).await; + assert_eq!(noretry["service_call_attempts"].as_array().unwrap().len(), 1, "{noretry}"); + let (status, carrier) = execute(&app, "notify-broken", json!({})).await; + assert_eq!((status, carrier["handler"]["kind"].clone(), carrier["message"].clone()), (StatusCode::CONFLICT, + json!("invocable_carrier_request"), json!("Invocable carrier binding has no endpoint or carrier reference."))); + + // A deadline already past is admitted and timed out in the same request. + let (status, expired) = execute(&app, "expired", json!({})).await; + assert_eq!(status, StatusCode::OK, "{expired}"); + assert_eq!((expired["accepted"].clone(), expired["status"].clone(), expired["outcome"].clone(), expired["reason"].clone(), + expired["call_deadline_at"].clone(), expired["message"].clone(), expired.get("handler").is_some()), + (json!(true), json!("failed"), json!("timed_out"), json!("timed_out"), json!("2001-02-03T03:05:06.500000Z"), + json!("Service call deadline expired."), false)); + let id = expired["id"].as_str().unwrap(); + let (status, cancelled) = request(&app, "POST", &format!("{OPERATIONS}/expired/service-calls/{id}/cancel"), json!({})).await; + assert_eq!((status, cancelled["reason"].clone()), (StatusCode::CONFLICT, json!("service_call_terminal"))); +} + +#[tokio::test] +async fn the_boundary_refuses_and_records_every_decision() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + let with_policy = |policy: Value| { + let mut definition = activity("a"); + definition["boundary_policy"] = policy; + definition + }; + catalog(&app, &[ + ("need-role", with_policy(json!({"required_roles": ["nexus-caller"]}))), + ("deny-caller", with_policy(json!({"deny_callers": ["blocked"]}))), + ("allow-list", with_policy(json!({"allow_callers": ["partner"]}))), + ("circuit", with_policy(json!({"circuit_break": {"open": true}}))), + ("misconfigured", with_policy(json!({"rate_limit": {"requests_per_minute": "lots"}}))), + ("limited", with_policy(json!({"concurrency_limit": {"max_in_flight": 1, "sync_only": false}}))), + ("start", json!({"handler_binding_kind": "start_workflow", "handler_target_reference": "wf"})), + ]).await; + + let (status, forbidden) = execute(&app, "need-role", json!({})).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{forbidden}"); + assert_eq!((forbidden["reason"].clone(), forbidden["message"].clone(), forbidden["outcome_metadata"].clone(), + forbidden.get("endpoint_id").is_some()), (json!("missing_required_role"), + json!("Nexus operation is not permitted for this caller."), json!({"failure_reason": "policy_rejection"}), false)); + // The audit row keeps what the response redacts. + let id = forbidden["service_call_id"].as_str().unwrap(); + let (status, shown) = request(&app, "GET", &format!("{OPERATIONS}/need-role/service-calls/{id}"), Value::Null).await; + assert_eq!((status, shown["outcome_message"].clone(), shown["outcome_metadata"]["forbidden_axis"].clone(), shown["status"].clone()), + (StatusCode::OK, json!("Caller [legacy-token] is missing one of the required roles for service-boundary admission."), + json!("operation"), json!("failed"))); + + let (status, denied) = execute(&app, "deny-caller", json!({"caller_namespace": "blocked"})).await; + assert_eq!((status, denied["reason"].clone()), (StatusCode::FORBIDDEN, json!("caller_namespace_denied"))); + let (status, listed) = execute(&app, "allow-list", json!({})).await; + assert_eq!((status, listed["reason"].clone()), (StatusCode::FORBIDDEN, json!("caller_namespace_not_allowed"))); + let (status, open) = execute(&app, "circuit", json!({})).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{open}"); + assert_eq!((open["reason"].clone(), open["retry_after_seconds"].clone(), open["outcome_metadata"]["target_key"].clone(), + open["endpoint_name"].clone()), (json!("circuit_open"), json!(30), json!("default|pay|billing|circuit"), json!("pay"))); + let (status, unavailable) = execute(&app, "misconfigured", json!({})).await; + assert_eq!((status, unavailable["outcome"].clone(), unavailable["reason"].clone(), unavailable["retry_after_seconds"].clone()), + (StatusCode::SERVICE_UNAVAILABLE, json!("rejected_throttled"), json!("service_boundary_admission_unavailable"), json!(1))); + + // An async call keeps its concurrency lease, so the next one is refused. + let (status, admitted) = execute(&app, "limited", json!({})).await; + assert_eq!(status, StatusCode::OK, "{admitted}"); + let (status, limited) = execute(&app, "limited", json!({})).await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS, "{limited}"); + assert_eq!((limited["outcome"].clone(), limited["reason"].clone(), limited["retry_after_seconds"].clone(), + limited["outcome_metadata"].clone()), (json!("rejected_concurrency_limited"), json!("concurrency_limit_exceeded"), json!(1), + json!({"failure_reason": "policy_rejection", "admission_scope": "boundary", "observed_in_flight": 1, "max_in_flight": 1}))); + + // A later slice: workflow-bound handlers. + let (status, start) = execute(&app, "start", json!({})).await; + assert_eq!((status, start["reason"].clone()), (StatusCode::NOT_IMPLEMENTED, json!("rust_capability_not_implemented"))); + let rows = text_rows(&database, "SELECT id, status FROM workflow_service_calls ORDER BY id", + "SELECT id, status FROM workflow_service_calls ORDER BY id", "SELECT id, status FROM workflow_service_calls ORDER BY id").await; + assert_eq!(rows.len(), 7, "one audit row per decision, none for a 501: {rows:?}"); +} + +#[tokio::test] +async fn shared_admission_limits_count_per_boundary() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + let with_policy = |mode: &str, policy: Value| { + let mut definition = activity("a"); + definition["operation_mode"] = json!(mode); + definition["boundary_policy"] = policy; + definition + }; + catalog(&app, &[ + ("rated", with_policy("async", json!({"rate_limit": {"requests_per_minute": 2, "retry_after_seconds": "7"}}))), + ("held-sync", with_policy("sync", json!({"concurrency_limit": {"max_in_flight": 1}}))), + ]).await; + + // The rate window is one minute; start early enough in one to stay in it. + let second = chrono::Timelike::second(&chrono::Utc::now()); + if second >= 50 { + tokio::time::sleep(std::time::Duration::from_secs(u64::from(61 - second))).await; + } + let (status, first) = execute(&app, "rated", json!({})).await; + assert_eq!(status, StatusCode::OK, "{first}"); + assert_eq!(first["outcome_metadata"], json!({"boundary_key": "default|default|pay|billing|rated", "admission_scope": "shared_cache", + "boundary_rate_limit": 2, "namespace_rate_limit": null, "boundary_concurrency_limit": null, "namespace_concurrency_limit": null})); + let (status, second_call) = execute(&app, "rated", json!({})).await; + assert_eq!(status, StatusCode::OK, "{second_call}"); + let (status, throttled) = execute(&app, "rated", json!({})).await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS, "{throttled}"); + assert_eq!((throttled["outcome"].clone(), throttled["reason"].clone(), throttled["message"].clone(), + throttled["retry_after_seconds"].clone(), throttled["policy_name"].clone(), throttled["outcome_metadata"].clone()), + (json!("rejected_throttled"), json!("rate_limit_exceeded"), json!("Shared service-call rate capacity is exhausted."), json!(7), + json!("server.shared-service-boundary"), + json!({"failure_reason": "policy_rejection", "admission_scope": "boundary", "observed_window_count": 2, "requests_per_minute": 2}))); + // Another caller namespace is another boundary with its own window. + let (status, other) = execute(&app, "rated", json!({"caller_namespace": "other"})).await; + assert_eq!(status, StatusCode::OK, "{other}"); + + // A sync call returns its lease when the request ends. + for _ in 0..3 { + let (status, call) = execute(&app, "held-sync", json!({})).await; + assert_eq!(status, StatusCode::OK, "{call}"); + assert_eq!(call["outcome_metadata"]["boundary_concurrency_limit"], json!(1), "{call}"); + } + let leases = text_rows(&database, "SELECT cache_key, cache_key FROM dw_service_admission_counters WHERE cache_key LIKE '%concurrency%'", + "SELECT cache_key, cache_key FROM dw_service_admission_counters WHERE cache_key LIKE '%concurrency%'", + "SELECT cache_key, cache_key FROM dw_service_admission_counters WHERE cache_key LIKE '%concurrency%'").await; + assert!(leases.is_empty(), "a returned lease leaves no counter: {leases:?}"); +} + +#[tokio::test] +async fn cancellation_and_idempotent_replays_follow_the_control_plane() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + let mut nocancel = activity("a"); + nocancel["cancellation_policy"] = json!({"mode": "none"}); + catalog(&app, &[("charge", activity("a")), ("nocancel", nocancel)]).await; + + let (_, call) = execute(&app, "charge", json!({"idempotency_key": "k1"})).await; + let id = call["id"].as_str().unwrap().to_owned(); + let (status, replay) = execute(&app, "charge", json!({"idempotency_key": " k1 ", "arguments": "wwHioz3/VYAiNwoIZG9uZQ=="})).await; + assert_eq!(status, StatusCode::OK, "{replay}"); + assert_eq!((replay["accepted"].clone(), replay["idempotent_replay"].clone(), replay["reason"].clone(), replay["found"].clone(), + replay["service_call_id"].clone(), replay.get("id").is_some()), + (json!(true), json!(true), Value::Null, json!(true), json!(id), false)); + let (_, other) = execute(&app, "charge", json!({"idempotency_key": "k1", "caller_namespace": "other"})).await; + assert_ne!(other["id"], json!(id), "another caller namespace is another call"); + + let cancel = format!("{OPERATIONS}/charge/service-calls/{id}/cancel"); + let (status, cancelled) = request(&app, "POST", &cancel, json!({"reason": "operator stop"})).await; + assert_eq!(status, StatusCode::OK, "{cancelled}"); + assert_eq!((cancelled["status"].clone(), cancelled["outcome_category"].clone(), cancelled["outcome_reason"].clone(), + cancelled["outcome_message"].clone(), cancelled["failure_message"].clone(), cancelled["outcome_metadata"].clone(), + cancelled["accepted"].clone(), cancelled["reason"].clone()), + (json!("cancelled"), json!("handler"), json!("cancelled_by_request"), json!("operator stop"), json!("operator stop"), + json!({"failure_reason": "cancellation"}), json!(true), Value::Null)); + let (status, again) = request(&app, "POST", &cancel, json!({})).await; + assert_eq!((status, again["accepted"].clone(), again["reason"].clone()), (StatusCode::CONFLICT, json!(false), json!("service_call_terminal"))); + let (status, refused) = request(&app, "POST", &cancel, json!({"reason": "x".repeat(1001)})).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{refused}"); + let (status, unknown) = request(&app, "POST", &format!("{OPERATIONS}/charge/service-calls/nope/cancel"), + json!({"reason": "x".repeat(1001)})).await; + assert_eq!((status, unknown["reason"].clone()), (StatusCode::NOT_FOUND, json!("service_call_not_found")), "the call before the input"); + let (status, replayed) = execute(&app, "charge", json!({"idempotency_key": "k1"})).await; + assert_eq!((status, replayed["accepted"].clone(), replayed["status"].clone()), (StatusCode::CONFLICT, json!(false), json!("cancelled"))); + + let (_, kept) = execute(&app, "nocancel", json!({})).await; + let (status, not_allowed) = request(&app, "POST", &format!("{OPERATIONS}/nocancel/service-calls/{}/cancel", kept["id"].as_str().unwrap()), + json!({})).await; + assert_eq!((status, not_allowed["reason"].clone(), not_allowed["status"].clone()), + (StatusCode::CONFLICT, json!("cancellation_not_allowed"), json!("accepted"))); +} + +#[tokio::test] +async fn execute_requests_are_checked_like_the_controller() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + catalog(&app, &[("charge", activity("a"))]).await; + for (body, field, message) in [ + (json!({"payload_codec": "json"}), "payload_codec", "unsupported_payload_codec"), + (json!({"arguments": "not-avro"}), "arguments", "The payload must contain one complete Avro Value"), + (json!({"arguments": {"codec": "avro", "blob": "%%%"}}), "arguments.blob", "The payload must contain one complete Avro Value"), + (json!({"arguments": {"codec": "avro"}}), "arguments", "The arguments envelope must contain only codec and blob"), + (json!({"arguments": {"codec": "avro", "blob": 5}}), "arguments.blob", "The payload envelope blob must be a string."), + (json!({"search_attributes": {"1bad": "x"}}), "search_attributes", "Search attribute key [1bad] must start with a letter"), + (json!({"mode_override": "fast"}), "mode_override", "The selected mode override is invalid."), + (json!({"wait_timeout_seconds": 86401}), "wait_timeout_seconds", "The wait timeout seconds field must not be greater than 86400."), + ] { + let (status, refused) = execute(&app, "charge", body.clone()).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}: {refused}"); + assert!(refused["errors"][field][0].as_str().unwrap().starts_with(message), "{body}: {refused}"); + } + // The runtime transport refuses a provider reference before routing. + let (status, transport) = request(&app, "POST", &format!("{OPERATIONS}/nope/execute"), + json!({"arguments": {"codec": "avro", "external_storage": {}}})).await; + assert_eq!((status, transport["reason"].clone()), (StatusCode::UNPROCESSABLE_ENTITY, json!("external_payload_unsupported"))); + let (status, missing) = execute(&app, "NOPE", json!({"mode_override": "fast"})).await; + assert_eq!((status, missing["reason"].clone(), missing["operation_name"].clone()), + (StatusCode::NOT_FOUND, json!("operation_not_found"), json!("nope")), "the catalog before the input"); +} + +#[tokio::test] +async fn catalog_reads_list_the_recorded_calls() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + let mut guarded = activity("a"); + guarded["boundary_policy"] = json!({"required_roles": ["nexus-caller"]}); + catalog(&app, &[("charge", activity("a")), ("guarded", guarded)]).await; + let (_, first) = execute(&app, "charge", json!({})).await; + let (_, second) = execute(&app, "charge", json!({"caller_namespace": "partner", "caller_workflow_instance_id": "wf-caller"})).await; + let (status, refused) = execute(&app, "guarded", json!({})).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{refused}"); + let operation = first["operation_id"].as_str().unwrap(); + + // Newest accepted first; the list item is `ServiceCallView::listItem`. + let (status, page) = request(&app, "GET", &format!("/api/service-catalog/calls?operation_id={operation}"), Value::Null).await; + assert_eq!(status, StatusCode::OK, "{page}"); + let ids = |page: &Value| page["data"].as_array().unwrap().iter().map(|item| item["id"].clone()).collect::>(); + assert_eq!((page["total"].clone(), ids(&page)), (json!(2), vec![second["id"].clone(), first["id"].clone()])); + let item = &page["data"][1]; + assert_eq!((item["status_bucket"].clone(), item["outcome_bucket"].clone(), item["is_terminal"].clone(), + item["is_policy_outcome"].clone(), item["caller_principal_roles"].clone(), item["retry_attempt_count"].clone()), + (json!("open"), json!("open"), json!(false), json!(false), json!(["admin"]), json!(1))); + assert!(item["created_at"].as_str().unwrap().ends_with('Z') && item["created_at"].as_str().unwrap().contains('.'), "{item}"); + assert!(item.get("metadata").is_none(), "the detail fields stay out of a list item: {item}"); + + // Buckets and scopes. + let (_, policy) = request(&app, "GET", "/api/service-catalog/calls?outcome_bucket=policy", Value::Null).await; + assert_eq!((policy["total"].clone(), ids(&policy)), (json!(1), vec![refused["service_call_id"].clone()])); + assert_eq!((policy["data"][0]["is_policy_outcome"].clone(), policy["data"][0]["accepted_at"].clone(), policy["outcome_bucket"].clone()), + (json!(true), Value::Null, json!("policy"))); + let (_, caller) = request(&app, "GET", &format!("/api/service-catalog/calls?scope=caller&operation_id={operation}"), Value::Null).await; + assert_eq!(ids(&caller), vec![first["id"].clone()], "another caller's call is not this namespace's as caller"); + let (_, paged) = request(&app, "GET", "/api/service-catalog/calls?per_page=1&page=2&status=accepted", Value::Null).await; + assert_eq!((paged["total"].clone(), paged["last_page"].clone(), ids(&paged)), (json!(2), json!(2), vec![first["id"].clone()])); + + // A call by identity: the detail, with the caller link and the payload view. + let (status, shown) = request(&app, "GET", &format!("/api/service-catalog/calls/{}", second["id"].as_str().unwrap()), Value::Null).await; + assert_eq!(status, StatusCode::OK, "{shown}"); + assert_eq!((shown["caller_link"].clone(), shown["payloads_in_observer_namespace"].clone(), shown["result_envelope"].clone(), + shown["failure_type"].clone(), shown["linked_run_ref"].clone(), shown["deadline_policy"].clone()), + (json!({"namespace": "partner", "workflow_instance_id": "wf-caller", "workflow_run_id": null, "in_observer_namespace": false}), + json!(true), Value::Null, Value::Null, Value::Null, json!([]))); + let (status, refused_shown) = request(&app, "GET", &format!("/api/service-catalog/calls/{}", refused["service_call_id"].as_str().unwrap()), + Value::Null).await; + assert_eq!((status, refused_shown["outcome"].clone()), (StatusCode::OK, json!("rejected_forbidden"))); + let (status, missing) = request(&app, "GET", "/api/service-catalog/calls/01nosuchcall00000000000000", Value::Null).await; + assert_eq!((status, missing["reason"].clone()), (StatusCode::NOT_FOUND, json!("catalog_object_not_found"))); +} diff --git a/rust/tests/execution/service_endpoints.rs b/rust/tests/execution/service_endpoints.rs new file mode 100644 index 000000000..82aa57037 --- /dev/null +++ b/rust/tests/execution/service_endpoints.rs @@ -0,0 +1,208 @@ +use super::*; + +// Service catalog administration (`ServiceCatalogController`'s endpoint, +// service and operation routes) and the catalog reads over the rows it +// writes. The shared `service-endpoints` fixture records the same routes +// against published 2.11.1. + +const OPERATION: &str = "/api/service-endpoints/billing/services/invoices/operations"; + +async fn stored_updated_at(database: &TestDatabase, table: &str) -> Vec<(String, String)> { + // Test-owned table names only. + text_rows( + database, + &format!("SELECT id, updated_at FROM {table} ORDER BY id"), + &format!("SELECT id, updated_at::text FROM {table} ORDER BY id"), + &format!("SELECT id, CAST(updated_at AS CHAR) FROM {table} ORDER BY id"), + ) + .await +} + +fn is_ulid(value: &Value) -> bool { + value.as_str().is_some_and(|id| id.len() == 26 && id.bytes().all(|byte| byte.is_ascii_digit() || byte.is_ascii_lowercase())) +} + +#[tokio::test] +async fn catalog_administration_follows_the_published_controller() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + + // Endpoints: lowercased names, PHP's array documents, 409 with the id. + let (status, billing) = request(&app, "POST", "/api/service-endpoints", + json!({"endpoint_name": " Billing ", "description": "Payments", "metadata": {"team": "core", "empty": {}}})).await; + assert_eq!(status, StatusCode::CREATED, "{billing}"); + assert!(is_ulid(&billing["id"]), "{billing}"); + assert_eq!((billing["namespace"].clone(), billing["endpoint_name"].clone(), billing["metadata"].clone()), + (json!("default"), json!("billing"), json!({"team": "core", "empty": []}))); + assert_eq!(billing["created_at"], billing["updated_at"]); + let (status, duplicate) = request(&app, "POST", "/api/service-endpoints", json!({"endpoint_name": "BILLING"})).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(duplicate, json!({"message": "A service endpoint [billing] already exists in namespace [default].", + "reason": "endpoint_already_exists", "namespace": "default", "endpoint_name": "billing", "endpoint_id": billing["id"]})); + let (status, invalid) = request(&app, "POST", "/api/service-endpoints", json!({"endpoint_name": "bad name!", "metadata": "x"})).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(invalid["errors"], json!({"endpoint_name": ["The endpoint name field format is invalid."], + "metadata": ["The metadata field must be an array."]})); + let (status, alpha) = request(&app, "POST", "/api/service-endpoints", json!({"endpoint_name": "alpha"})).await; + assert_eq!((status, alpha["metadata"].clone(), alpha["description"].clone()), (StatusCode::CREATED, Value::Null, Value::Null)); + let (_, listed) = request(&app, "GET", "/api/service-endpoints", Value::Null).await; + let names: Vec<_> = listed["service_endpoints"].as_array().unwrap().iter().map(|endpoint| endpoint["endpoint_name"].clone()).collect(); + assert_eq!(names, [json!("alpha"), json!("billing")], "endpoints list by name"); + let (status, shown) = request(&app, "GET", "/api/service-endpoints/BILLING", Value::Null).await; + assert_eq!((status, shown), (StatusCode::OK, billing.clone())); + let (status, missing) = request(&app, "GET", "/api/service-endpoints/Missing", Value::Null).await; + assert_eq!((status, missing), (StatusCode::NOT_FOUND, json!({"accepted": false, + "message": "Service endpoint [missing] not found in namespace [default].", "reason": "endpoint_not_found", + "namespace": "default", "endpoint_name": "missing"}))); + + // An unchanged update writes nothing; a change keeps the other fields. + let before = stored_updated_at(&database, "workflow_service_endpoints").await; + let (status, same) = request(&app, "PUT", "/api/service-endpoints/billing", + json!({"description": "Payments", "metadata": {"team": "core", "empty": {}}})).await; + assert_eq!((status, same), (StatusCode::OK, billing.clone())); + assert_eq!(stored_updated_at(&database, "workflow_service_endpoints").await, before, "an unchanged update keeps updated_at"); + let (status, cleared) = request(&app, "PUT", "/api/service-endpoints/billing", json!({"description": " "})).await; + assert_eq!((status, cleared["description"].clone(), cleared["metadata"].clone()), + (StatusCode::OK, Value::Null, json!({"team": "core", "empty": []}))); + assert_ne!(stored_updated_at(&database, "workflow_service_endpoints").await, before, "a change moves updated_at"); + + // Services list in creation order, not by name. + let (status, invoices) = request(&app, "POST", "/api/service-endpoints/billing/services", + json!({"service_name": "Invoices", "metadata": {"tier": 1}})).await; + assert_eq!(status, StatusCode::CREATED, "{invoices}"); + assert_eq!((invoices["endpoint_id"].clone(), invoices["endpoint_name"].clone(), invoices["service_name"].clone()), + (billing["id"].clone(), json!("billing"), json!("invoices"))); + let (status, _) = request(&app, "POST", "/api/service-endpoints/billing/services", json!({"service_name": "accounts"})).await; + assert_eq!(status, StatusCode::CREATED); + let (_, services) = request(&app, "GET", "/api/service-endpoints/billing/services", Value::Null).await; + let names: Vec<_> = services["services"].as_array().unwrap().iter().map(|service| service["service_name"].clone()).collect(); + assert_eq!(names, [json!("invoices"), json!("accounts")], "services list in creation order"); + let (status, duplicate) = request(&app, "POST", "/api/service-endpoints/billing/services", json!({"service_name": "INVOICES"})).await; + assert_eq!((status, duplicate["reason"].clone(), duplicate["service_id"].clone()), + (StatusCode::CONFLICT, json!("service_already_exists"), invoices["id"].clone())); + let (status, missing) = request(&app, "GET", "/api/service-endpoints/billing/services/nope", Value::Null).await; + assert_eq!((status, missing["message"].clone()), + (StatusCode::NOT_FOUND, json!("Service [nope] not found under endpoint [billing] in namespace [default]."))); + let (status, missing) = request(&app, "POST", "/api/service-endpoints/nope/services", json!({})).await; + assert_eq!((status, missing["reason"].clone()), (StatusCode::NOT_FOUND, json!("endpoint_not_found")), "the endpoint before the input"); + + // Operations: required mode, kind and binding; a deadline policy; no rename. + let (status, charge) = request(&app, "POST", OPERATION, json!({"operation_name": "Charge", "operation_mode": "sync", + "handler_binding_kind": "start_workflow", "handler_target_reference": "billing.charge", + "deadline_policy": {"timeout_seconds": 30}})).await; + assert_eq!(status, StatusCode::CREATED, "{charge}"); + assert_eq!((charge["operation_name"].clone(), charge["service_id"].clone(), charge["deadline_policy"].clone(), + charge["handler_binding"].clone()), (json!("charge"), invoices["id"].clone(), json!({"timeout_seconds": 30}), Value::Null)); + let (status, unbound) = request(&app, "POST", OPERATION, json!({"operation_name": "refund", "operation_mode": "async", + "handler_binding_kind": "signal_workflow"})).await; + assert_eq!((status, unbound["errors"].clone()), (StatusCode::UNPROCESSABLE_ENTITY, + json!({"handler_target_reference": ["Provide handler_target_reference or a non-empty handler_binding."]}))); + let (status, deadline) = request(&app, "POST", OPERATION, json!({"operation_name": "refund", "operation_mode": "async", + "handler_binding_kind": "signal_workflow", "handler_target_reference": "x", "deadline_policy": {"deadline_at": "2030-02-30T00:00:00Z"}})).await; + assert_eq!((status, deadline["message"].clone()), + (StatusCode::UNPROCESSABLE_ENTITY, json!("Service deadline_at must be a valid calendar instant."))); + let charge_path = format!("{OPERATION}/CHARGE"); + let (status, renamed) = request(&app, "PUT", &charge_path, json!({"operation_name": "other"})).await; + assert_eq!((status, renamed["message"].clone()), (StatusCode::UNPROCESSABLE_ENTITY, json!("The operation name field is prohibited."))); + let (status, rebound) = request(&app, "PUT", &charge_path, + json!({"handler_target_reference": null, "handler_binding": {"workflow_type": "billing.charge"}, "operation_mode": "async"})).await; + assert_eq!(status, StatusCode::OK, "{rebound}"); + assert_eq!((rebound["handler_target_reference"].clone(), rebound["handler_binding"].clone(), rebound["operation_mode"].clone()), + (Value::Null, json!({"workflow_type": "billing.charge"}), json!("async"))); + let (status, emptied) = request(&app, "PUT", &charge_path, json!({"handler_binding": {}})).await; + assert_eq!((status, emptied["errors"]["handler_target_reference"].clone()), (StatusCode::UNPROCESSABLE_ENTITY, + json!(["Provide handler_target_reference or a non-empty handler_binding."]))); + let (_, operations) = request(&app, "GET", OPERATION, Value::Null).await; + assert_eq!(operations["operations"].as_array().unwrap().len(), 1, "{operations}"); + + // Deletes refuse what still depends on the row. + let (status, refused) = request(&app, "DELETE", "/api/service-endpoints/billing", Value::Null).await; + assert_eq!((status, refused), (StatusCode::CONFLICT, json!({ + "message": "Service endpoint [billing] in namespace [default] still has registered services.", + "reason": "endpoint_has_services", "namespace": "default", "endpoint_name": "billing"}))); + let (status, refused) = request(&app, "DELETE", "/api/service-endpoints/billing/services/invoices", Value::Null).await; + assert_eq!((status, refused["reason"].clone()), (StatusCode::CONFLICT, json!("service_has_operations"))); + // Test-owned literals only. + database.execute(sqlx::AssertSqlSafe(format!( + "INSERT INTO workflow_service_calls(id,workflow_service_endpoint_id,workflow_service_id,workflow_service_operation_id,endpoint_name,service_name,operation_name,namespace,status,operation_mode,created_at,updated_at) \ + VALUES ('call-1','{}','{}','{}','billing','invoices','charge','default','completed','sync','2026-10-26 00:00:00','2026-10-26 00:00:00')", + billing["id"].as_str().unwrap(), invoices["id"].as_str().unwrap(), charge["id"].as_str().unwrap() + ))).await; + let (status, refused) = request(&app, "DELETE", &charge_path, Value::Null).await; + assert_eq!((status, refused["message"].clone()), (StatusCode::CONFLICT, + json!("Operation [charge] under service [invoices] at endpoint [billing] in namespace [default] still has recorded service calls."))); + database.execute(sqlx::AssertSqlSafe("DELETE FROM workflow_service_calls WHERE id='call-1'".to_owned())).await; + let (status, deleted) = request(&app, "DELETE", &charge_path, Value::Null).await; + assert_eq!((status, deleted), (StatusCode::OK, json!({"namespace": "default", "endpoint_name": "billing", + "service_name": "invoices", "operation_name": "charge", "outcome": "deleted"}))); + for path in ["/api/service-endpoints/billing/services/invoices", "/api/service-endpoints/billing/services/accounts"] { + let (status, _) = request(&app, "DELETE", path, Value::Null).await; + assert_eq!(status, StatusCode::OK, "{path}"); + } + let (status, deleted) = request(&app, "DELETE", "/api/service-endpoints/billing", Value::Null).await; + assert_eq!((status, deleted), (StatusCode::OK, json!({"namespace": "default", "endpoint_name": "billing", "outcome": "deleted"}))); +} + +#[tokio::test] +async fn catalog_reads_render_the_namespace_rows() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + namespaces::create(&app, "other").await; + let (_, billing) = request(&app, "POST", "/api/service-endpoints", json!({"endpoint_name": "billing"})).await; + let (_, alpha) = request(&app, "POST", "/api/service-endpoints", json!({"endpoint_name": "alpha", "metadata": {"a": 1}})).await; + let (_, invoices) = request(&app, "POST", "/api/service-endpoints/billing/services", json!({"service_name": "invoices"})).await; + let (_, charge) = request(&app, "POST", "/api/service-endpoints/billing/services/invoices/operations", + json!({"operation_name": "charge", "operation_mode": "sync", "handler_binding_kind": "start_workflow", + "handler_target_reference": "billing.charge"})).await; + let (status, _) = namespaces::scoped(&app, "other", "POST", "/api/service-endpoints", json!({"endpoint_name": "elsewhere"})).await; + assert_eq!(status, StatusCode::CREATED, "a named namespace administers its own catalog"); + + let (status, page) = request(&app, "GET", "/api/service-catalog/endpoints?per_page=1&page=2", Value::Null).await; + assert_eq!(status, StatusCode::OK, "{page}"); + assert_eq!((page["total"].clone(), page["last_page"].clone(), page["current_page"].clone()), (json!(2), json!(2), json!(2))); + assert_eq!(page["data"], json!([{"id": billing["id"], "namespace": "default", "endpoint_name": "billing", "description": null, + "metadata": [], "created_at": billing["created_at"], "updated_at": billing["updated_at"]}]), "the read view defaults metadata to []"); + let (_, first) = request(&app, "GET", "/api/service-catalog/endpoints?per_page=1", Value::Null).await; + assert_eq!(first["data"][0]["metadata"], alpha["metadata"]); + let (_, services) = request(&app, "GET", &format!("/api/service-catalog/services?endpoint_id={}", billing["id"].as_str().unwrap()), Value::Null).await; + assert_eq!((services["total"].clone(), services["data"][0]["workflow_service_endpoint_id"].clone()), (json!(1), billing["id"].clone())); + let (_, none) = request(&app, "GET", &format!("/api/service-catalog/services?endpoint_id={}", alpha["id"].as_str().unwrap()), Value::Null).await; + assert_eq!(none["total"], 0); + let (_, operations) = request(&app, "GET", &format!("/api/service-catalog/operations?service_id={}", invoices["id"].as_str().unwrap()), Value::Null).await; + assert_eq!((operations["total"].clone(), operations["data"][0]["handler_binding"].clone()), (json!(1), json!([]))); + + let (status, endpoint) = request(&app, "GET", &format!("/api/service-catalog/endpoints/{}", billing["id"].as_str().unwrap()), Value::Null).await; + assert_eq!(status, StatusCode::OK, "{endpoint}"); + assert_eq!((endpoint["services"][0]["id"].clone(), endpoint["operations"][0]["id"].clone(), endpoint["related_has_more"].clone()), + (invoices["id"].clone(), charge["id"].clone(), json!({"services": false, "operations": false}))); + let (_, service) = request(&app, "GET", &format!("/api/service-catalog/services/{}", invoices["id"].as_str().unwrap()), Value::Null).await; + assert_eq!((service["endpoint"]["id"].clone(), service["operations"][0]["operation_name"].clone(), service["related_has_more"].clone()), + (billing["id"].clone(), json!("charge"), json!({"operations": false}))); + let (_, operation) = request(&app, "GET", &format!("/api/service-catalog/operations/{}", charge["id"].as_str().unwrap()), Value::Null).await; + assert_eq!((operation["endpoint"]["endpoint_name"].clone(), operation["service"]["service_name"].clone(), + operation["handler_target_reference"].clone()), (json!("billing"), json!("invoices"), json!("billing.charge"))); + let (status, foreign) = namespaces::scoped(&app, "other", "GET", + &format!("/api/service-catalog/endpoints/{}", billing["id"].as_str().unwrap()), Value::Null).await; + assert_eq!((status, foreign["reason"].clone()), (StatusCode::NOT_FOUND, json!("catalog_object_not_found"))); + let (_, other) = namespaces::scoped(&app, "other", "GET", "/api/service-catalog/endpoints", Value::Null).await; + assert_eq!((other["total"].clone(), other["data"][0]["endpoint_name"].clone()), (json!(1), json!("elsewhere"))); +} + +#[tokio::test] +async fn catalog_administration_needs_an_admin() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap().with_role_tokens( + Some("worker-only".into()), + Some("operator-only".into()), + Some("admin-only".into()), + )); + for (token, expected) in [("operator-only", StatusCode::FORBIDDEN), ("admin-only", StatusCode::OK)] { + let (status, _, body) = + admission::admission_http(&app, "GET", "/api/service-endpoints", false, Some(token), Some("2"), Some("default"), Value::Null).await; + assert_eq!(status, expected, "{token}: {body}"); + } + // The catalog reads stay operator routes. + let (status, _, body) = admission::admission_http(&app, "GET", "/api/service-catalog/endpoints", false, Some("operator-only"), + Some("2"), Some("default"), Value::Null).await; + assert_eq!(status, StatusCode::OK, "{body}"); +} diff --git a/rust/tests/fixtures/unserved-php-routes.v2.11.1.txt b/rust/tests/fixtures/unserved-php-routes.v2.11.1.txt index d22e43f90..7f61e043a 100644 --- a/rust/tests/fixtures/unserved-php-routes.v2.11.1.txt +++ b/rust/tests/fixtures/unserved-php-routes.v2.11.1.txt @@ -1,22 +1,4 @@ # Published Server 2.11.1 API routes that native answers with 501 # rust_capability_not_implemented, in route-table order. Checked by # rust/tests/execution/route_coverage.rs. -GET /api/service-endpoints -POST /api/service-endpoints -GET /api/service-endpoints/{endpointName} -PUT /api/service-endpoints/{endpointName} -DELETE /api/service-endpoints/{endpointName} -GET /api/service-endpoints/{endpointName}/services -POST /api/service-endpoints/{endpointName}/services -GET /api/service-endpoints/{endpointName}/services/{serviceName} -PUT /api/service-endpoints/{endpointName}/services/{serviceName} -DELETE /api/service-endpoints/{endpointName}/services/{serviceName} -GET /api/service-endpoints/{endpointName}/services/{serviceName}/operations -POST /api/service-endpoints/{endpointName}/services/{serviceName}/operations -GET /api/service-endpoints/{endpointName}/services/{serviceName}/operations/{operationName} -PUT /api/service-endpoints/{endpointName}/services/{serviceName}/operations/{operationName} -DELETE /api/service-endpoints/{endpointName}/services/{serviceName}/operations/{operationName} -POST /api/service-endpoints/{endpointName}/services/{serviceName}/operations/{operationName}/execute -GET /api/service-endpoints/{endpointName}/services/{serviceName}/operations/{operationName}/service-calls/{serviceCallId} -POST /api/service-endpoints/{endpointName}/services/{serviceName}/operations/{operationName}/service-calls/{serviceCallId}/cancel POST /api/worker/workflow-tasks/{taskId}/cancellation-scopes/checkpoint diff --git a/scripts/conformance/server-parity/catalog-placeholders.mjs b/scripts/conformance/server-parity/catalog-placeholders.mjs new file mode 100644 index 000000000..0af84a562 --- /dev/null +++ b/scripts/conformance/server-parity/catalog-placeholders.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; + +// Exact comparison of recorded catalog and service-call answers, except for +// generated values: +// - "{id}" is a lowercase ULID and "{ULID}" an uppercase one; +// - "{time}" is a whole-second UTC time with an offset, "{µs}" one with six +// fractional digits and "Z". +// "{kind:NAME}" binds NAME on first sight and must recur exactly; ids bound to +// different names must differ. "@own" is the fixture's namespace, also inside +// other strings. +const FORMATS = { + id: /^[0-9a-hjkmnp-tv-z]{26}$/, + ULID: /^[0-9A-HJKMNP-TV-Z]{26}$/, + time: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/, + 'µs': /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/, +}; +const PLACEHOLDER = /^\{(id|ULID|time|µs)(?::(.+))?\}$/u; +const INLINE = /\{(id|ULID|time|µs)(?::([^{}]+))?\}/gu; + +export function catalogContext(own) { + return {own, bindings: new Map()}; +} + +export function matchCatalog(expected, actual, context, path) { + if (typeof expected === 'string') { + const placeholder = PLACEHOLDER.exec(expected); + if (placeholder) { + const [, kind, name] = placeholder; + assert.ok(typeof actual === 'string' && FORMATS[kind].test(actual), `${path}: ${kind} expected, got ${JSON.stringify(actual)}`); + bind(context, kind, name, actual, path); + return; + } + const literal = expected.split('@own').join(context.own); + const inline = [...literal.matchAll(INLINE)]; + if (inline.length === 0) { + assert.equal(actual, literal, path); + return; + } + // Placeholders inside a message: the literal text must match exactly. + const escape = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + let pattern = '^'; + let at = 0; + for (const match of inline) { + pattern += escape(literal.slice(at, match.index)) + `(${FORMATS[match[1]].source.slice(1, -1)})`; + at = match.index + match[0].length; + } + pattern += escape(literal.slice(at)) + '$'; + const found = typeof actual === 'string' ? new RegExp(pattern, 'u').exec(actual) : null; + assert.ok(found, `${path}: ${JSON.stringify(actual)} does not match ${JSON.stringify(expected)}`); + inline.forEach((match, index) => bind(context, match[1], match[2], found[index + 1], path)); + return; + } + if (Array.isArray(expected)) { + assert.ok(Array.isArray(actual), `${path}: a list expected, got ${JSON.stringify(actual)}`); + assert.equal(actual.length, expected.length, `${path}: length`); + expected.forEach((item, index) => matchCatalog(item, actual[index], context, `${path}[${index}]`)); + return; + } + if (expected !== null && typeof expected === 'object') { + assert.ok(actual !== null && typeof actual === 'object' && !Array.isArray(actual), + `${path}: an object expected, got ${JSON.stringify(actual)}`); + assert.deepStrictEqual(Object.keys(actual).sort(), Object.keys(expected).sort(), `${path}: fields`); + for (const key of Object.keys(expected)) { + matchCatalog(expected[key], actual[key], context, `${path}.${key}`); + } + return; + } + assert.deepStrictEqual(actual, expected, path); +} + +function bind(context, kind, name, actual, path) { + if (name === undefined) { + return; + } + const key = `${kind}:${name}`; + if (context.bindings.has(key)) { + assert.equal(actual, context.bindings.get(key), `${path}: ${key}`); + } else { + context.bindings.set(key, actual); + } +} + +/** Ids bound to different names must be different objects. */ +export function assertDistinctIds(context, message) { + for (const kind of ['id', 'ULID']) { + const ids = [...context.bindings].filter(([name]) => name.startsWith(`${kind}:`)).map(([, value]) => value); + assert.equal(new Set(ids).size, ids.length, message); + } +} diff --git a/scripts/conformance/server-parity/contract.mjs b/scripts/conformance/server-parity/contract.mjs index df9c973f5..b304c9b82 100644 --- a/scripts/conformance/server-parity/contract.mjs +++ b/scripts/conformance/server-parity/contract.mjs @@ -23,6 +23,8 @@ import {checkWorkerSessionActivities} from './worker-session-activities-contract import {checkActivityCancellationAcknowledgement} from './activity-cancellation-ack-contract.mjs'; import {checkCancellationScopes} from './cancellation-scopes-contract.mjs'; import {checkServiceCatalog} from './service-catalog-contract.mjs'; +import {checkServiceEndpoints} from './service-endpoints-contract.mjs'; +import {checkServiceCalls} from './service-calls-contract.mjs'; import {checkWorkflowImport} from './workflow-import-contract.mjs'; import {checkWaterlineImport} from './waterline-import-contract.mjs'; import {checkSystemMetrics} from './system-metrics-contract.mjs'; @@ -176,6 +178,8 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { ? checkActivityCancellationAcknowledgement(fixture, observation, workflowId) : null; const cancellationScopes = fixture.cancellation_scopes ? checkCancellationScopes(fixture, observation, workflowId) : null; const serviceCatalog = fixture.service_catalog ? checkServiceCatalog(fixture, observation, workflowId) : null; + const serviceEndpoints = fixture.service_endpoints ? checkServiceEndpoints(fixture, observation, workflowId) : null; + const serviceCalls = fixture.service_calls ? checkServiceCalls(fixture, observation, workflowId) : null; const workflowImport = fixture.workflow_import ? checkWorkflowImport(fixture, observation, workflowId) : null; const waterlineImport = fixture.waterline_import ? checkWaterlineImport(fixture, observation, workflowId) : null; const systemMetrics = fixture.system_metrics ? checkSystemMetrics(fixture, observation, workflowId) : null; @@ -745,6 +749,8 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { ...(activityCancellationAcknowledgement ? {activity_cancellation_acknowledgement: activityCancellationAcknowledgement} : {}), ...(cancellationScopes ? {cancellation_scopes: cancellationScopes} : {}), ...(serviceCatalog ? {service_catalog: serviceCatalog} : {}), + ...(serviceEndpoints ? {service_endpoints: serviceEndpoints} : {}), + ...(serviceCalls ? {service_calls: serviceCalls} : {}), ...(workflowImport ? {workflow_import: workflowImport} : {}), ...(waterlineImport ? {waterline_import: waterlineImport} : {}), ...(systemMetrics ? {system_metrics: systemMetrics} : {}), diff --git a/scripts/conformance/server-parity/mysql-schema.php b/scripts/conformance/server-parity/mysql-schema.php index 0d828a2cf..da70be8f6 100644 --- a/scripts/conformance/server-parity/mysql-schema.php +++ b/scripts/conformance/server-parity/mysql-schema.php @@ -106,7 +106,7 @@ function catalog(PDO $pdo): array } $pdo->exec('ROLLBACK'); $pdo = connection($argv[3]); -$native = array_values(array_filter(catalog($pdo), static fn (array $row): bool => ! in_array($row[0], ['dw_server_schema', '_sqlx_migrations', 'dw_task_completions', 'dw_poll_receipts', 'dw_query_cache', 'dw_worker_registration_incarnations', 'dw_worker_session_holders', 'dw_stream_reclaims'], true))); +$native = array_values(array_filter(catalog($pdo), static fn (array $row): bool => ! in_array($row[0], ['dw_server_schema', '_sqlx_migrations', 'dw_task_completions', 'dw_poll_receipts', 'dw_query_cache', 'dw_worker_registration_incarnations', 'dw_worker_session_holders', 'dw_stream_reclaims', 'dw_service_call_deadlines', 'dw_service_admission_counters'], true))); $pdo->exec('ROLLBACK'); // Native polling uses an additional index on an existing table. Retain all // original column, index and constraint definitions in the comparison. diff --git a/scripts/conformance/server-parity/postgres-schema.php b/scripts/conformance/server-parity/postgres-schema.php index 236486e51..978c850bc 100644 --- a/scripts/conformance/server-parity/postgres-schema.php +++ b/scripts/conformance/server-parity/postgres-schema.php @@ -70,8 +70,8 @@ function catalog(string $database): array throw new InvalidArgumentException('Compare requires two independent databases.'); } $native = catalog($argv[3]); -$tables = ['_sqlx_migrations', 'dw_server_schema', 'dw_task_completions', 'dw_poll_receipts', 'dw_query_cache', 'dw_worker_registration_incarnations', 'dw_worker_session_holders', 'dw_stream_reclaims']; -$indexes = ['_sqlx_migrations_pkey', 'dw_task_completions_pkey', 'dw_poll_receipts_pkey', 'dw_workflow_tasks_poll', 'dw_query_cache_pkey', 'dw_worker_registration_incarnations_pkey', 'dw_worker_incarnation_authority', 'dw_worker_incarnation_retention', 'dw_worker_session_holders_pkey', 'dw_stream_reclaims_pkey', 'dw_stream_reclaims_due']; +$tables = ['_sqlx_migrations', 'dw_server_schema', 'dw_task_completions', 'dw_poll_receipts', 'dw_query_cache', 'dw_worker_registration_incarnations', 'dw_worker_session_holders', 'dw_stream_reclaims', 'dw_service_call_deadlines', 'dw_service_admission_counters']; +$indexes = ['_sqlx_migrations_pkey', 'dw_task_completions_pkey', 'dw_poll_receipts_pkey', 'dw_workflow_tasks_poll', 'dw_query_cache_pkey', 'dw_worker_registration_incarnations_pkey', 'dw_worker_incarnation_authority', 'dw_worker_incarnation_retention', 'dw_worker_session_holders_pkey', 'dw_stream_reclaims_pkey', 'dw_stream_reclaims_due', 'dw_service_call_deadlines_pkey', 'dw_service_call_deadlines_due', 'dw_service_admission_counters_pkey', 'dw_service_admission_counters_expiry']; $native = array_values(array_filter($native, static function (array $row) use ($tables, $indexes): bool { if (in_array($row[0], ['column', 'constraint'], true)) { return ! in_array(explode('.', $row[1], 2)[0], $tables, true); diff --git a/scripts/conformance/server-parity/probe.php b/scripts/conformance/server-parity/probe.php index 46a0e3bae..0f73532d5 100644 --- a/scripts/conformance/server-parity/probe.php +++ b/scripts/conformance/server-parity/probe.php @@ -442,6 +442,8 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, ? finishHttpActivityCancellationAcknowledgement($client, $fixture, $workflowId, $url) : null; $cancellationScopes = isset($fixture['cancellation_scopes']) ? finishHttpCancellationScopes($client, $fixture, $workflowId, $url) : null; $serviceCatalog = isset($fixture['service_catalog']) ? finishHttpServiceCatalog($client, $fixture, $workflowId, $url) : null; + $serviceEndpoints = isset($fixture['service_endpoints']) ? finishHttpServiceEndpoints($client, $fixture, $workflowId, $url) : null; + $serviceCalls = isset($fixture['service_calls']) ? finishHttpServiceCalls($client, $fixture, $workflowId, $url) : null; $workflowImport = isset($fixture['workflow_import']) ? finishHttpWorkflowImport($client, $fixture, $workflowId, $url) : null; $waterlineImport = isset($fixture['waterline_import']) ? finishHttpWaterlineImport($client, $fixture, $workflowId, $url) : null; $systemMetrics = isset($fixture['system_metrics']) ? finishHttpSystemMetrics($client, $fixture, $workflowId, $url) : null; @@ -564,6 +566,8 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, 'activity_cancellation_acknowledgement' => $activityCancellationAcknowledgement, 'cancellation_scopes' => $cancellationScopes, 'service_catalog' => $serviceCatalog, + 'service_endpoints' => $serviceEndpoints, + 'service_calls' => $serviceCalls, 'workflow_import' => $workflowImport, 'waterline_import' => $waterlineImport, 'system_metrics' => $systemMetrics, @@ -890,6 +894,8 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa require __DIR__.'/activity-cancellation-ack-probe.php'; require __DIR__.'/cancellation-scopes-probe.php'; require __DIR__.'/service-catalog-probe.php'; + require __DIR__.'/service-endpoints-probe.php'; + require __DIR__.'/service-calls-probe.php'; require __DIR__.'/workflow-import-probe.php'; require __DIR__.'/waterline-import-probe.php'; require __DIR__.'/system-metrics-probe.php'; @@ -1093,6 +1099,12 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa if ($mode === 'embedded' && isset($fixture['service_catalog'])) { $observation['service_catalog'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_service_catalog']; } + if ($mode === 'embedded' && isset($fixture['service_endpoints'])) { + $observation['service_endpoints'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_service_endpoints']; + } + if ($mode === 'embedded' && isset($fixture['service_calls'])) { + $observation['service_calls'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_service_calls']; + } if ($mode === 'embedded' && isset($fixture['workflow_import'])) { $observation['workflow_import'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_workflow_import']; } diff --git a/scripts/conformance/server-parity/schema.php b/scripts/conformance/server-parity/schema.php index f52b18e84..541a593ff 100644 --- a/scripts/conformance/server-parity/schema.php +++ b/scripts/conformance/server-parity/schema.php @@ -38,7 +38,7 @@ function catalog(string $filename): array $php = catalog($argv[1]); $native = catalog($argv[2]); -$nativeOnly = ['_sqlx_migrations', 'dw_server_schema', 'dw_task_completions', 'dw_poll_receipts', 'dw_workflow_tasks_poll', 'dw_query_cache', 'dw_worker_registration_incarnations', 'dw_worker_incarnation_authority', 'dw_worker_incarnation_retention', 'dw_worker_session_holders', 'dw_stream_reclaims', 'dw_stream_reclaims_due']; +$nativeOnly = ['_sqlx_migrations', 'dw_server_schema', 'dw_task_completions', 'dw_poll_receipts', 'dw_workflow_tasks_poll', 'dw_query_cache', 'dw_worker_registration_incarnations', 'dw_worker_incarnation_authority', 'dw_worker_incarnation_retention', 'dw_worker_session_holders', 'dw_stream_reclaims', 'dw_service_call_deadlines', 'dw_stream_reclaims_due', 'dw_service_call_deadlines_due', 'dw_service_admission_counters', 'dw_service_admission_counters_expiry']; $native = array_values(array_filter($native, static fn (array $row): bool => ! in_array($row['name'], $nativeOnly, true))); if ($php !== $native) { $byName = static fn (array $rows): array => array_column($rows, null, 'name'); diff --git a/scripts/conformance/server-parity/service-calls-contract.mjs b/scripts/conformance/server-parity/service-calls-contract.mjs new file mode 100644 index 000000000..246318c2d --- /dev/null +++ b/scripts/conformance/server-parity/service-calls-contract.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import {assertDistinctIds, catalogContext, matchCatalog} from './catalog-placeholders.mjs'; + +// Reviewed Nexus service-call execution, show and cancel (Server 2.11.1's +// ServiceCatalogController over Workflow 2.10.2's DefaultServiceControlPlane) +// for the handler kinds that stay accepted, with the boundary's refusals, +// handler failures and retries, deadlines and idempotent replays. Every +// answer is exact except generated ids and times (see catalog-placeholders). +// Embedded applications have no HTTP service calls. +export function checkServiceCalls(fixture, observation, workflowId) { + const definition = fixture.service_calls; + const state = observation.service_calls; + const projection = {scope: 'service_calls', cases: definition.requests.length}; + assert.ok(state && typeof state === 'object', 'actual service calls observation'); + if (observation.mode === 'embedded') { + assert.deepStrictEqual(state, {applicable: false, reason: 'embedded_has_no_http_service_calls'}, + 'embedded service calls explicitly inapplicable'); + return projection; + } + const own = `${workflowId}-calls`.toLowerCase(); + assert.deepStrictEqual([state.applicable, state.namespace], [true, own], 'service calls namespace'); + const labels = definition.requests.map(request => request.label); + assert.deepStrictEqual(Object.keys(state.responses ?? {}).sort(), [...labels].sort(), 'every service-call request answered'); + const context = catalogContext(own); + for (const label of labels) { + matchCatalog(definition.expected[label], state.responses[label], context, label); + } + assertDistinctIds(context, 'each id names one call, handler or catalog object'); + return projection; +} diff --git a/scripts/conformance/server-parity/service-calls-probe.php b/scripts/conformance/server-parity/service-calls-probe.php new file mode 100644 index 000000000..57f170d4b --- /dev/null +++ b/scripts/conformance/server-parity/service-calls-probe.php @@ -0,0 +1,18 @@ +createNamespace($own, 'service calls λ', 30); + + return ['applicable' => true, 'namespace' => $own, + 'responses' => serviceCatalogRequests($fixture['service_calls']['requests'], $own, $url)]; +} diff --git a/scripts/conformance/server-parity/service-endpoints-contract.mjs b/scripts/conformance/server-parity/service-endpoints-contract.mjs new file mode 100644 index 000000000..7478572cf --- /dev/null +++ b/scripts/conformance/server-parity/service-endpoints-contract.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import {assertDistinctIds, catalogContext, matchCatalog} from './catalog-placeholders.mjs'; + +// Reviewed service catalog administration (Server 2.11.1's +// ServiceCatalogController endpoint, service and operation routes) and the +// catalog reads over its rows. Every response is exact except generated +// values: "{id:NAME}" is a lowercase ULID, the same wherever NAME recurs and +// distinct across names; "{time}" is a whole-second UTC ISO-8601 time and +// "{time:NAME}" one that recurs exactly. "@own" is the fixture's namespace, +// also inside messages. Embedded applications have no HTTP catalog. +export function checkServiceEndpoints(fixture, observation, workflowId) { + const definition = fixture.service_endpoints; + const state = observation.service_endpoints; + const projection = {scope: 'service_endpoints', cases: definition.requests.length}; + assert.ok(state && typeof state === 'object', 'actual service endpoints observation'); + if (observation.mode === 'embedded') { + assert.deepStrictEqual(state, {applicable: false, reason: 'embedded_has_no_http_service_endpoints'}, + 'embedded service endpoints explicitly inapplicable'); + return projection; + } + const own = `${workflowId}-endpoints`.toLowerCase(); + assert.deepStrictEqual([state.applicable, state.namespace], [true, own], 'service endpoints namespace'); + const labels = definition.requests.map(request => request.label); + assert.deepStrictEqual(Object.keys(state.responses ?? {}).sort(), [...labels].sort(), 'every catalog request answered'); + const context = catalogContext(own); + for (const label of labels) { + matchCatalog(definition.expected[label], state.responses[label], context, label); + } + assertDistinctIds(context, 'each id names one catalog object'); + return projection; +} + diff --git a/scripts/conformance/server-parity/service-endpoints-probe.php b/scripts/conformance/server-parity/service-endpoints-probe.php new file mode 100644 index 000000000..ad6394fe4 --- /dev/null +++ b/scripts/conformance/server-parity/service-endpoints-probe.php @@ -0,0 +1,74 @@ +createNamespace($own, 'service endpoints λ', 30); + + return ['applicable' => true, 'namespace' => $own, + 'responses' => serviceCatalogRequests($fixture['service_endpoints']['requests'], $own, $url)]; +} + +/** + * Sends each request in order and keeps every answer by label. A path may + * name a field of an earlier answer as "{label.field}"; "@own" is the + * fixture's namespace. + * + * @param list> $requests + * @return array + */ +function serviceCatalogRequests(array $requests, string $own, string $url): array +{ + $token = getenv('DW_PARITY_TOKEN') ?: null; + // The SQLite reference shares its database with a one-second maintenance + // loop; retry its retryable lock-pressure refusals, as the other probes do. + $stack = HandlerStack::create(); + $stack->push(LockPressurePollRetries::middleware(), 'lock_pressure_poll_retry'); + $http = new ServiceEndpointsHttp(['base_uri' => rtrim($url, '/').'/api/', 'http_errors' => false, + 'connect_timeout' => 1, 'timeout' => 30, 'handler' => $stack]); + $responses = []; + foreach ($requests as $request) { + $path = preg_replace_callback('/\{([^{}.]+)\.([a-z_]+)\}/', static function (array $match) use (&$responses): string { + $value = $responses[$match[1]][1][$match[2]] ?? null; + if (! is_string($value)) { + throw new RuntimeException("No {$match[1]}.{$match[2]} for a later path."); + } + + return rawurlencode($value); + }, $request['path']); + // A request opening a per-minute rate window starts at least 15 + // seconds before the minute ends, so the requests after it share it. + if (($request['minute_start'] ?? false) === true && (int) gmdate('s') >= 45) { + sleep(61 - (int) gmdate('s')); + } + $namespace = ($request['namespace'] ?? '@own') === '@own' ? $own : $request['namespace']; + $options = ['headers' => [ + ...($token === null ? [] : ['Authorization' => 'Bearer '.$token]), + 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'X-Namespace' => $namespace, + 'X-Durable-Workflow-Control-Plane-Version' => '2', + ]]; + if (array_key_exists('raw_body', $request)) { + $options['body'] = $request['raw_body']; + } elseif (array_key_exists('body', $request)) { + $options['body'] = json_encode($request['body'], JSON_THROW_ON_ERROR); + } + $response = $http->request($request['method'] ?? 'GET', $path, $options); + $responses[$request['label']] = [$response->getStatusCode(), + json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)]; + } + + return $responses; +} diff --git a/tests/Fixtures/ServerParity/service-calls.json b/tests/Fixtures/ServerParity/service-calls.json new file mode 100644 index 000000000..493ee5e9e --- /dev/null +++ b/tests/Fixtures/ServerParity/service-calls.json @@ -0,0 +1,6976 @@ +{ + "schema": "durable-workflow.server-parity-fixture/v1", + "id": "service-calls", + "review_state": "reviewed", + "contract": "Published Server 2.11.1 Nexus service-call execution (ServiceCatalogController over Workflow 2.10.2's DefaultServiceControlPlane, operator): POST .../operations/{operation}/execute validates like Laravel (mode, wait, ids, labels, memo, search attributes, payload codec and an Avro arguments payload or {codec, blob} envelope; a provider external_storage reference is refused by the runtime transport before routing), then admits at the service boundary and writes an audit row for every decision. Required roles, caller-namespace deny and allow lists answer 403 with the target redacted (show on the audit row is not redacted), an open circuit 503 circuit_open with retry 30, an invalid shared limit 503 service_boundary_admission_unavailable. Shared admission limits count per caller boundary: a per-minute rate refuses 429 rate_limit_exceeded once the window is spent, a concurrency limit 429 concurrency_limit_exceeded while a lease is taken; an async call keeps its lease, a sync call returns it when the request ends. An activity_execution or invocable_http call stays accepted with its handler stamped (an uppercase ULID unless the binding names one); a binding without a target is a handler failure (409), retried in the request up to retry_policy.max_attempts unless its type is non-retryable. A deadline policy sets call_deadline_at; one already past is admitted and timed out (200, accepted). GET .../service-calls/{id} reads a call of the operation by trimmed id; POST .../cancel cancels an open call once (repeat or terminal 409 service_call_terminal, a policy with mode none 409 cancellation_not_allowed), validating the reason after the lookup. A repeated idempotency key replays the first call in the describe shape, re-checking authorization (a refusal writes a new audit row). The catalog reads list the recorded calls by scope, status and outcome filter or bucket, newest accepted first (each list holds only accepted or only rejected calls, since a rejected call's null accepted_at sorts by the database's NULL order), and read one call's detail with its caller link; an unknown call is 404 catalog_object_not_found. Generated ids and times are placeholders; @own is the fixture's namespace. Embedded applications have no HTTP service calls.", + "workflow_type": "parity.v1.echo", + "input": { + "message": "service calls λ", + "count": 9007199254740993, + "optional": null + }, + "typed_value": { + "type": "map", + "value": { + "message": { + "type": "string", + "value": "service calls λ" + }, + "count": { + "type": "int64", + "value": "9007199254740993" + }, + "optional": { + "type": "null", + "value": null + } + } + }, + "activity": false, + "expected_events": [ + "StartAccepted", + "WorkflowStarted", + "WorkflowCompleted" + ], + "service_calls": { + "requests": [ + { + "label": "register endpoint", + "method": "POST", + "path": "service-endpoints", + "body": { + "endpoint_name": "pay" + } + }, + { + "label": "register service", + "method": "POST", + "path": "service-endpoints/pay/services", + "body": { + "service_name": "billing" + } + }, + { + "label": "register charge", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "charge", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "charge-activity" + } + }, + { + "label": "register charge-sync", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "charge-sync", + "operation_mode": "sync", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "charge-activity" + } + }, + { + "label": "register charge-bound", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "charge-bound", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_binding": { + "activity_class": "C", + "activity_type": "t", + "activity_execution_id": "act-fixed-1", + "owning_workflow_instance_id": "wf-own-1", + "owning_workflow_run_id": "01howNrun0000000000000000a", + "queue": "bq" + } + } + }, + { + "label": "register notify", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "notify", + "operation_mode": "async", + "handler_binding_kind": "invocable_http", + "handler_target_reference": "https://carrier.example/notify" + } + }, + { + "label": "register notify-bound", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "notify-bound", + "operation_mode": "async", + "handler_binding_kind": "invocable_http", + "handler_binding": { + "url": "https://c.example/x", + "handler": "h", + "carrier": "c", + "carrier_request_id": "car-fixed-1", + "bound_workflow_instance_id": "wf-bound-1" + } + } + }, + { + "label": "register broken", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "broken", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_binding": { + "queue": "q" + } + } + }, + { + "label": "register broken-retry", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "broken-retry", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_binding": { + "queue": "q" + }, + "retry_policy": { + "max_attempts": 3 + } + } + }, + { + "label": "register broken-noretry", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "broken-noretry", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_binding": { + "queue": "q" + }, + "retry_policy": { + "max_attempts": 3, + "non_retryable_error_types": [ + "handler_target_missing" + ] + } + } + }, + { + "label": "register notify-broken", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "notify-broken", + "operation_mode": "async", + "handler_binding_kind": "invocable_http", + "handler_binding": { + "carrier": "c" + } + } + }, + { + "label": "register timed", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "timed", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "deadline_policy": { + "timeout_seconds": 3600 + } + } + }, + { + "label": "register expired", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "expired", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "deadline_policy": { + "deadline_at": "2001-02-03T04:05:06.5+01:00" + } + } + }, + { + "label": "register nocancel", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "nocancel", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "cancellation_policy": { + "mode": "none" + } + } + }, + { + "label": "register need-role", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "need-role", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "boundary_policy": { + "required_roles": [ + "nexus-caller" + ] + } + } + }, + { + "label": "register deny-caller", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "deny-caller", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "boundary_policy": { + "deny_callers": [ + "blocked" + ] + } + } + }, + { + "label": "register allow-list", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "allow-list", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "boundary_policy": { + "allow_callers": [ + "partner" + ] + } + } + }, + { + "label": "register circuit", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "circuit", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "boundary_policy": { + "circuit_break": { + "open": true + } + } + } + }, + { + "label": "register misconfigured", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "misconfigured", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "boundary_policy": { + "rate_limit": { + "requests_per_minute": "lots" + } + } + } + }, + { + "label": "register rated", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "rated", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "boundary_policy": { + "rate_limit": { + "requests_per_minute": 1, + "retry_after_seconds": 7 + } + } + } + }, + { + "label": "register held", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "held", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "boundary_policy": { + "concurrency_limit": { + "max_in_flight": 1, + "sync_only": false + } + } + } + }, + { + "label": "register held-sync", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "held-sync", + "operation_mode": "sync", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "boundary_policy": { + "concurrency_limit": { + "max_in_flight": 1 + } + } + } + }, + { + "label": "register replay-target", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations", + "body": { + "operation_name": "replay-target", + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "charge-activity" + } + }, + { + "label": "execute unknown endpoint", + "method": "POST", + "path": "service-endpoints/nope/services/billing/operations/charge/execute", + "body": {} + }, + { + "label": "execute unknown service", + "method": "POST", + "path": "service-endpoints/pay/services/nope/operations/charge/execute", + "body": {} + }, + { + "label": "execute unknown operation", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/NOPE/execute", + "body": {} + }, + { + "label": "execute invalid options", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/execute", + "body": { + "mode_override": "fast", + "wait_timeout_seconds": 86401 + } + }, + { + "label": "execute json codec", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/execute", + "body": { + "payload_codec": "json" + } + }, + { + "label": "execute plain arguments", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/execute", + "body": { + "arguments": "not-avro" + } + }, + { + "label": "execute bad blob", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/execute", + "body": { + "arguments": { + "codec": "avro", + "blob": "%%%" + } + } + }, + { + "label": "execute envelope without blob", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/execute", + "body": { + "arguments": { + "codec": "avro" + } + } + }, + { + "label": "execute numeric blob", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/execute", + "body": { + "arguments": { + "codec": "avro", + "blob": 5 + } + } + }, + { + "label": "execute provider reference", + "method": "POST", + "path": "service-endpoints/nope/services/billing/operations/charge/execute", + "body": { + "arguments": { + "codec": "avro", + "external_storage": { + "uri": "s3://bucket/key" + } + } + } + }, + { + "label": "execute bad search attribute", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/execute", + "body": { + "search_attributes": { + "1bad": "x" + } + } + }, + { + "label": "charge", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/execute", + "body": {} + }, + { + "label": "charge with options", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/execute", + "body": { + "arguments": { + "codec": "avro", + "blob": "wwHioz3/VYAiNwwA" + }, + "connection": "cx", + "queue": "qx", + "caller_namespace": "Partner-NS", + "caller_workflow_instance_id": "caller-wf", + "caller_workflow_run_id": "01hcallerrun00000000000000" + } + }, + { + "label": "charge sync", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge-sync/execute", + "body": {} + }, + { + "label": "charge override", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/execute", + "body": { + "mode_override": "sync" + } + }, + { + "label": "charge bound", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge-bound/execute", + "body": {} + }, + { + "label": "notify", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/notify/execute", + "body": {} + }, + { + "label": "notify bound", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/notify-bound/execute", + "body": {} + }, + { + "label": "show charge", + "method": "GET", + "path": "service-endpoints/pay/services/billing/operations/charge/service-calls/{charge.id}" + }, + { + "label": "show charge padded", + "method": "GET", + "path": "service-endpoints/pay/services/billing/operations/charge/service-calls/%20{charge.id}%20" + }, + { + "label": "show under another operation", + "method": "GET", + "path": "service-endpoints/pay/services/billing/operations/notify/service-calls/{charge.id}" + }, + { + "label": "show unknown call", + "method": "GET", + "path": "service-endpoints/pay/services/billing/operations/charge/service-calls/01nosuchcall00000000000000" + }, + { + "label": "broken", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/broken/execute", + "body": {} + }, + { + "label": "broken retry", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/broken-retry/execute", + "body": {} + }, + { + "label": "broken no retry", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/broken-noretry/execute", + "body": {} + }, + { + "label": "notify broken", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/notify-broken/execute", + "body": {} + }, + { + "label": "timed", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/timed/execute", + "body": {} + }, + { + "label": "expired", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/expired/execute", + "body": {} + }, + { + "label": "cancel expired", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/expired/service-calls/{expired.id}/cancel", + "body": {} + }, + { + "label": "show expired", + "method": "GET", + "path": "service-endpoints/pay/services/billing/operations/expired/service-calls/{expired.id}" + }, + { + "label": "cancel charge", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/service-calls/{charge.id}/cancel", + "body": { + "reason": "operator stop" + } + }, + { + "label": "cancel charge again", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/service-calls/{charge.id}/cancel", + "body": {} + }, + { + "label": "cancel long reason", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge-sync/service-calls/{charge sync.id}/cancel", + "body": { + "reason": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + }, + { + "label": "cancel unknown call", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/charge/service-calls/01nosuchcall00000000000000/cancel", + "body": { + "reason": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + }, + { + "label": "nocancel", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/nocancel/execute", + "body": {} + }, + { + "label": "cancel nocancel", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/nocancel/service-calls/{nocancel.id}/cancel", + "body": {} + }, + { + "label": "need role", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/need-role/execute", + "body": {} + }, + { + "label": "show need role", + "method": "GET", + "path": "service-endpoints/pay/services/billing/operations/need-role/service-calls/{need role.service_call_id}" + }, + { + "label": "deny caller", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/deny-caller/execute", + "body": { + "caller_namespace": "blocked" + } + }, + { + "label": "allow list", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/allow-list/execute", + "body": {} + }, + { + "label": "circuit", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/circuit/execute", + "body": {} + }, + { + "label": "misconfigured", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/misconfigured/execute", + "body": {} + }, + { + "label": "rated", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/rated/execute", + "body": {}, + "minute_start": true + }, + { + "label": "rated again", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/rated/execute", + "body": {} + }, + { + "label": "rated other caller", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/rated/execute", + "body": { + "caller_namespace": "other" + } + }, + { + "label": "held", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/held/execute", + "body": {} + }, + { + "label": "held again", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/held/execute", + "body": {} + }, + { + "label": "held sync", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/held-sync/execute", + "body": {} + }, + { + "label": "held sync again", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/held-sync/execute", + "body": {} + }, + { + "label": "show rated again", + "method": "GET", + "path": "service-endpoints/pay/services/billing/operations/rated/service-calls/{rated again.service_call_id}" + }, + { + "label": "replay first", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/replay-target/execute", + "body": { + "idempotency_key": "k1" + } + }, + { + "label": "replay again", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/replay-target/execute", + "body": { + "idempotency_key": " k1 ", + "arguments": "wwHioz3/VYAiNwoIZG9uZQ==" + } + }, + { + "label": "replay other caller", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/replay-target/execute", + "body": { + "idempotency_key": "k1", + "caller_namespace": "other" + } + }, + { + "label": "cancel replayed", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/replay-target/service-calls/{replay first.id}/cancel", + "body": {} + }, + { + "label": "replay cancelled", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/replay-target/execute", + "body": { + "idempotency_key": "k1" + } + }, + { + "label": "replay second key", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/replay-target/execute", + "body": { + "idempotency_key": "k2" + } + }, + { + "label": "require a role", + "method": "PUT", + "path": "service-endpoints/pay/services/billing/operations/replay-target", + "body": { + "boundary_policy": { + "required_roles": [ + "nexus-caller" + ] + } + } + }, + { + "label": "replay forbidden", + "method": "POST", + "path": "service-endpoints/pay/services/billing/operations/replay-target/execute", + "body": { + "idempotency_key": "k2" + } + }, + { + "label": "show replayed second", + "method": "GET", + "path": "service-endpoints/pay/services/billing/operations/replay-target/service-calls/{replay second key.id}" + }, + { + "label": "catalog calls of charge", + "method": "GET", + "path": "service-catalog/calls?operation_id={register charge.id}" + }, + { + "label": "catalog calls of charge as caller", + "method": "GET", + "path": "service-catalog/calls?scope=caller&operation_id={register charge.id}" + }, + { + "label": "catalog cancelled calls", + "method": "GET", + "path": "service-catalog/calls?bucket=cancelled" + }, + { + "label": "catalog handler failures", + "method": "GET", + "path": "service-catalog/calls?outcome=handler_failed" + }, + { + "label": "catalog policy calls page", + "method": "GET", + "path": "service-catalog/calls?outcome_bucket=policy&per_page=3&page=2" + }, + { + "label": "catalog show call with caller", + "method": "GET", + "path": "service-catalog/calls/{charge with options.id}" + }, + { + "label": "catalog show failed call", + "method": "GET", + "path": "service-catalog/calls/{broken.id}" + }, + { + "label": "catalog show rejected call", + "method": "GET", + "path": "service-catalog/calls/{need role.service_call_id}" + }, + { + "label": "catalog show unknown call", + "method": "GET", + "path": "service-catalog/calls/01nosuchcall00000000000000" + } + ], + "expected": { + "register endpoint": [ + 201, + { + "id": "{id:register endpoint}", + "namespace": "@own", + "endpoint_name": "pay", + "description": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register service": [ + 201, + { + "id": "{id:register service}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_name": "billing", + "description": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register charge": [ + 201, + { + "id": "{id:register charge}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "charge", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "charge-activity", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register charge-sync": [ + 201, + { + "id": "{id:register charge-sync}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "charge-sync", + "description": null, + "operation_mode": "sync", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "charge-activity", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register charge-bound": [ + 201, + { + "id": "{id:register charge-bound}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "charge-bound", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": null, + "handler_binding": { + "activity_class": "C", + "activity_type": "t", + "activity_execution_id": "act-fixed-1", + "owning_workflow_instance_id": "wf-own-1", + "owning_workflow_run_id": "01howNrun0000000000000000a", + "queue": "bq" + }, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register notify": [ + 201, + { + "id": "{id:register notify}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "notify", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "invocable_http", + "handler_target_reference": "https://carrier.example/notify", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register notify-bound": [ + 201, + { + "id": "{id:register notify-bound}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "notify-bound", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "invocable_http", + "handler_target_reference": null, + "handler_binding": { + "url": "https://c.example/x", + "handler": "h", + "carrier": "c", + "carrier_request_id": "car-fixed-1", + "bound_workflow_instance_id": "wf-bound-1" + }, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register broken": [ + 201, + { + "id": "{id:register broken}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "broken", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": null, + "handler_binding": { + "queue": "q" + }, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register broken-retry": [ + 201, + { + "id": "{id:register broken-retry}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "broken-retry", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": null, + "handler_binding": { + "queue": "q" + }, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": { + "max_attempts": 3 + }, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register broken-noretry": [ + 201, + { + "id": "{id:register broken-noretry}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "broken-noretry", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": null, + "handler_binding": { + "queue": "q" + }, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": { + "max_attempts": 3, + "non_retryable_error_types": [ + "handler_target_missing" + ] + }, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register notify-broken": [ + 201, + { + "id": "{id:register notify-broken}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "notify-broken", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "invocable_http", + "handler_target_reference": null, + "handler_binding": { + "carrier": "c" + }, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register timed": [ + 201, + { + "id": "{id:register timed}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "timed", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "handler_binding": null, + "deadline_policy": { + "timeout_seconds": 3600 + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register expired": [ + 201, + { + "id": "{id:register expired}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "expired", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "handler_binding": null, + "deadline_policy": { + "deadline_at": "2001-02-03T04:05:06.5+01:00" + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register nocancel": [ + 201, + { + "id": "{id:register nocancel}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "nocancel", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": { + "mode": "none" + }, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register need-role": [ + 201, + { + "id": "{id:register need-role}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "need-role", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "required_roles": [ + "nexus-caller" + ] + }, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register deny-caller": [ + 201, + { + "id": "{id:register deny-caller}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "deny-caller", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "deny_callers": [ + "blocked" + ] + }, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register allow-list": [ + 201, + { + "id": "{id:register allow-list}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "allow-list", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "allow_callers": [ + "partner" + ] + }, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register circuit": [ + 201, + { + "id": "{id:register circuit}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "circuit", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "circuit_break": { + "open": true + } + }, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register misconfigured": [ + 201, + { + "id": "{id:register misconfigured}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "misconfigured", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "rate_limit": { + "requests_per_minute": "lots" + } + }, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register rated": [ + 201, + { + "id": "{id:register rated}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "rated", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "rate_limit": { + "requests_per_minute": 1, + "retry_after_seconds": 7 + } + }, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register held": [ + 201, + { + "id": "{id:register held}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "held", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "concurrency_limit": { + "max_in_flight": 1, + "sync_only": false + } + }, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register held-sync": [ + 201, + { + "id": "{id:register held-sync}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "held-sync", + "description": null, + "operation_mode": "sync", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "t", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "concurrency_limit": { + "max_in_flight": 1 + } + }, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "register replay-target": [ + 201, + { + "id": "{id:register replay-target}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "replay-target", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "charge-activity", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "execute unknown endpoint": [ + 404, + { + "accepted": false, + "message": "Service endpoint [nope] not found in namespace [@own].", + "reason": "endpoint_not_found", + "namespace": "@own", + "endpoint_name": "nope" + } + ], + "execute unknown service": [ + 404, + { + "message": "Service [nope] not found under endpoint [pay] in namespace [@own].", + "reason": "service_not_found", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "nope" + } + ], + "execute unknown operation": [ + 404, + { + "message": "Operation [nope] not found under service [billing] at endpoint [pay] in namespace [@own].", + "reason": "operation_not_found", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "nope" + } + ], + "execute invalid options": [ + 422, + { + "message": "The selected mode override is invalid. (and 1 more error)", + "reason": "validation_failed", + "errors": { + "mode_override": [ + "The selected mode override is invalid." + ], + "wait_timeout_seconds": [ + "The wait timeout seconds field must not be greater than 86400." + ] + }, + "validation_errors": { + "mode_override": [ + "The selected mode override is invalid." + ], + "wait_timeout_seconds": [ + "The wait timeout seconds field must not be greater than 86400." + ] + } + } + ], + "execute json codec": [ + 422, + { + "message": "unsupported_payload_codec: workflow payload codec \"json\" is not supported by Durable Workflow 2.0; use codec=\"avro\" with the fixed Avro Value schema and single-object framing. JSON remains the HTTP document transport, not a workflow payload codec.", + "reason": "validation_failed", + "errors": { + "payload_codec": [ + "unsupported_payload_codec: workflow payload codec \"json\" is not supported by Durable Workflow 2.0; use codec=\"avro\" with the fixed Avro Value schema and single-object framing. JSON remains the HTTP document transport, not a workflow payload codec." + ] + }, + "validation_errors": { + "payload_codec": [ + "unsupported_payload_codec: workflow payload codec \"json\" is not supported by Durable Workflow 2.0; use codec=\"avro\" with the fixed Avro Value schema and single-object framing. JSON remains the HTTP document transport, not a workflow payload codec." + ] + } + } + ], + "execute plain arguments": [ + 422, + { + "message": "The payload must contain one complete Avro Value encoded with the official codec. JSON is only the HTTP document transport.", + "reason": "validation_failed", + "errors": { + "arguments": [ + "The payload must contain one complete Avro Value encoded with the official codec. JSON is only the HTTP document transport." + ] + }, + "validation_errors": { + "arguments": [ + "The payload must contain one complete Avro Value encoded with the official codec. JSON is only the HTTP document transport." + ] + } + } + ], + "execute bad blob": [ + 422, + { + "message": "The payload must contain one complete Avro Value encoded with the official codec. JSON is only the HTTP document transport.", + "reason": "validation_failed", + "errors": { + "arguments.blob": [ + "The payload must contain one complete Avro Value encoded with the official codec. JSON is only the HTTP document transport." + ] + }, + "validation_errors": { + "arguments.blob": [ + "The payload must contain one complete Avro Value encoded with the official codec. JSON is only the HTTP document transport." + ] + } + } + ], + "execute envelope without blob": [ + 422, + { + "message": "The arguments envelope must contain only codec and blob, or codec and external storage.", + "reason": "validation_failed", + "errors": { + "arguments": [ + "The arguments envelope must contain only codec and blob, or codec and external storage." + ] + }, + "validation_errors": { + "arguments": [ + "The arguments envelope must contain only codec and blob, or codec and external storage." + ] + } + } + ], + "execute numeric blob": [ + 422, + { + "message": "The payload envelope blob must be a string.", + "reason": "validation_failed", + "errors": { + "arguments.blob": [ + "The payload envelope blob must be a string." + ] + }, + "validation_errors": { + "arguments.blob": [ + "The payload envelope blob must be a string." + ] + } + } + ], + "execute provider reference": [ + 422, + { + "schema": "durable-workflow.v2.runtime-external-payload-error.v1", + "reason": "external_payload_unsupported", + "message": "Provider-specific external_storage references are not accepted by the runtime transport.", + "retryable": false, + "status": 422 + } + ], + "execute bad search attribute": [ + 422, + { + "message": "Search attribute key [1bad] must start with a letter and contain only letters, numbers, and underscores.", + "reason": "validation_failed", + "errors": { + "search_attributes": [ + "Search attribute key [1bad] must start with a letter and contain only letters, numbers, and underscores." + ] + }, + "validation_errors": { + "search_attributes": [ + "Search attribute key [1bad] must start with a letter and contain only letters, numbers, and underscores." + ] + } + } + ], + "charge": [ + 200, + { + "id": "{id:charge}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register charge}", + "operation_name": "charge", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|charge" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|charge", + "activity_execution_id": "{ULID:charge}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:charge}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:charge}", + "activity_class": "charge-activity", + "activity_type": "charge-activity" + }, + "reason": null + } + ], + "charge with options": [ + 200, + { + "id": "{id:charge with options}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register charge}", + "operation_name": "charge", + "caller_namespace": "Partner-NS", + "caller_workflow_instance_id": "caller-wf", + "caller_workflow_run_id": "01hcallerrun00000000000000", + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "Partner-NS|@own|pay|billing|charge" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge with options}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "Partner-NS|@own|pay|billing|charge", + "activity_execution_id": "{ULID:charge with options}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "connection": "cx", + "queue": "qx", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:charge with options}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:charge with options}", + "activity_class": "charge-activity", + "activity_type": "charge-activity" + }, + "reason": null + } + ], + "charge sync": [ + 200, + { + "id": "{id:charge sync}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register charge-sync}", + "operation_name": "charge-sync", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|charge-sync" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "sync", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge sync}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|charge-sync", + "activity_execution_id": "{ULID:charge sync}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:charge sync}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:charge sync}", + "activity_class": "charge-activity", + "activity_type": "charge-activity" + }, + "reason": null + } + ], + "charge override": [ + 200, + { + "id": "{id:charge override}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register charge}", + "operation_name": "charge", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|charge" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "sync", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge override}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|charge", + "activity_execution_id": "{ULID:charge override}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:charge override}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:charge override}", + "activity_class": "charge-activity", + "activity_type": "charge-activity" + }, + "reason": null + } + ], + "charge bound": [ + 200, + { + "id": "{id:charge bound}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register charge-bound}", + "operation_name": "charge-bound", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": "wf-own-1", + "linked_workflow_run_id": "01howNrun0000000000000000a", + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|charge-bound" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "act-fixed-1", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|charge-bound", + "activity_execution_id": "act-fixed-1", + "activity_class": "C", + "activity_type": "t", + "queue": "bq", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:charge bound}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "act-fixed-1", + "activity_class": "C", + "activity_type": "t" + }, + "reason": null + } + ], + "notify": [ + 200, + { + "id": "{id:notify}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register notify}", + "operation_name": "notify", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|notify" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "invocable_carrier_request", + "resolved_target_reference": "{ULID:notify}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|notify", + "carrier_request_id": "{ULID:notify}", + "carrier_endpoint": "https://carrier.example/notify", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:notify}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "invocable_carrier_request", + "carrier_request_id": "{ULID:notify}", + "carrier_endpoint": "https://carrier.example/notify", + "carrier_handler": null, + "carrier": null + }, + "reason": null + } + ], + "notify bound": [ + 200, + { + "id": "{id:notify bound}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register notify-bound}", + "operation_name": "notify-bound", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": "wf-bound-1", + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|notify-bound" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "invocable_carrier_request", + "resolved_target_reference": "car-fixed-1", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|notify-bound", + "carrier_request_id": "car-fixed-1", + "carrier_endpoint": "https://c.example/x", + "carrier_handler": "h", + "carrier": "c", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:notify bound}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "invocable_carrier_request", + "carrier_request_id": "car-fixed-1", + "carrier_endpoint": "https://c.example/x", + "carrier_handler": "h", + "carrier": "c" + }, + "reason": null + } + ], + "show charge": [ + 200, + { + "id": "{id:charge}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register charge}", + "operation_name": "charge", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|charge" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|charge", + "activity_execution_id": "{ULID:charge}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "found": true, + "service_call_id": "{id:charge}" + } + ], + "show charge padded": [ + 200, + { + "id": "{id:charge}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register charge}", + "operation_name": "charge", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|charge" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|charge", + "activity_execution_id": "{ULID:charge}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "found": true, + "service_call_id": "{id:charge}" + } + ], + "show under another operation": [ + 404, + { + "message": "Service call [{id:charge}] not found under operation [notify] for service [billing] at endpoint [pay] in namespace [@own].", + "reason": "service_call_not_found", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "notify", + "service_call_id": "{id:charge}" + } + ], + "show unknown call": [ + 404, + { + "message": "Service call [01nosuchcall00000000000000] not found under operation [charge] for service [billing] at endpoint [pay] in namespace [@own].", + "reason": "service_call_not_found", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "charge", + "service_call_id": "01nosuchcall00000000000000" + } + ], + "broken": [ + 409, + { + "id": "{id:broken}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register broken}", + "operation_name": "broken", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "outcome": "handler_failed", + "outcome_category": "handler", + "outcome_reason": "handler_target_missing", + "outcome_message": "Activity binding has no activity class or type.", + "outcome_metadata": { + "failure_reason": "handler_failure", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Activity binding has no activity class or type.", + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Activity binding has no activity class or type.", + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": "Activity binding has no activity class or type.", + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|broken", + "failure_reason": "handler_failure", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Activity binding has no activity class or type.", + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:broken}", + "error_type": "handler_target_missing", + "failure_type": "handler_target_missing", + "message": "Activity binding has no activity class or type.", + "accepted": false, + "idempotent_replay": false, + "handler": { + "accepted": false, + "kind": "activity_execution", + "reason": "handler_target_missing", + "message": "Activity binding has no activity class or type.", + "error_type": "handler_target_missing", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "retry_scheduled": false + }, + "reason": "handler_target_missing" + } + ], + "broken retry": [ + 409, + { + "id": "{id:broken retry}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register broken-retry}", + "operation_name": "broken-retry", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "outcome": "handler_failed", + "outcome_category": "handler", + "outcome_reason": "handler_target_missing", + "outcome_message": "Activity binding has no activity class or type.", + "outcome_metadata": { + "failure_reason": "handler_failure", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Activity binding has no activity class or type.", + "service_call_attempts": [ + { + "attempt": 1, + "status": "started", + "outcome": "accepted", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "retry_scheduled": true, + "scheduled_backoff_seconds": 0 + }, + { + "attempt": 2, + "status": "started", + "outcome": "accepted", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "retry_scheduled": true, + "scheduled_backoff_seconds": 0 + }, + { + "attempt": 3, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 3 + }, + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Activity binding has no activity class or type.", + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": "Activity binding has no activity class or type.", + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": { + "max_attempts": 3 + }, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|broken-retry", + "last_retry_failure": { + "failure_reason": "handler_failure", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Activity binding has no activity class or type." + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "started", + "outcome": "accepted", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "retry_scheduled": true, + "scheduled_backoff_seconds": 0 + }, + { + "attempt": 2, + "status": "started", + "outcome": "accepted", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "retry_scheduled": true, + "scheduled_backoff_seconds": 0 + }, + { + "attempt": 3, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 3, + "failure_reason": "handler_failure", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Activity binding has no activity class or type." + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "started", + "outcome": "accepted", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "retry_scheduled": true, + "scheduled_backoff_seconds": 0 + }, + { + "attempt": 2, + "status": "started", + "outcome": "accepted", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "retry_scheduled": true, + "scheduled_backoff_seconds": 0 + }, + { + "attempt": 3, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 3, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": "{µs}", + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:broken retry}", + "error_type": "handler_target_missing", + "failure_type": "handler_target_missing", + "message": "Activity binding has no activity class or type.", + "accepted": false, + "idempotent_replay": false, + "handler": { + "accepted": false, + "kind": "activity_execution", + "reason": "handler_target_missing", + "message": "Activity binding has no activity class or type.", + "error_type": "handler_target_missing", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "retry_scheduled": false + }, + "reason": "handler_target_missing" + } + ], + "broken no retry": [ + 409, + { + "id": "{id:broken no retry}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register broken-noretry}", + "operation_name": "broken-noretry", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "outcome": "handler_failed", + "outcome_category": "handler", + "outcome_reason": "handler_target_missing", + "outcome_message": "Activity binding has no activity class or type.", + "outcome_metadata": { + "failure_reason": "handler_failure", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Activity binding has no activity class or type.", + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Activity binding has no activity class or type.", + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": "Activity binding has no activity class or type.", + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": { + "max_attempts": 3, + "non_retryable_error_types": [ + "handler_target_missing" + ] + }, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|broken-noretry", + "failure_reason": "handler_failure", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Activity binding has no activity class or type.", + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:broken no retry}", + "error_type": "handler_target_missing", + "failure_type": "handler_target_missing", + "message": "Activity binding has no activity class or type.", + "accepted": false, + "idempotent_replay": false, + "handler": { + "accepted": false, + "kind": "activity_execution", + "reason": "handler_target_missing", + "message": "Activity binding has no activity class or type.", + "error_type": "handler_target_missing", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "retry_scheduled": false + }, + "reason": "handler_target_missing" + } + ], + "notify broken": [ + 409, + { + "id": "{id:notify broken}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register notify-broken}", + "operation_name": "notify-broken", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "outcome": "handler_failed", + "outcome_category": "handler", + "outcome_reason": "handler_target_missing", + "outcome_message": "Invocable carrier binding has no endpoint or carrier reference.", + "outcome_metadata": { + "failure_reason": "handler_failure", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Invocable carrier binding has no endpoint or carrier reference.", + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Invocable carrier binding has no endpoint or carrier reference.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Invocable carrier binding has no endpoint or carrier reference.", + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "invocable_carrier_request", + "resolved_target_reference": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": "Invocable carrier binding has no endpoint or carrier reference.", + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|notify-broken", + "failure_reason": "handler_failure", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Invocable carrier binding has no endpoint or carrier reference.", + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Invocable carrier binding has no endpoint or carrier reference.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Invocable carrier binding has no endpoint or carrier reference.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:notify broken}", + "error_type": "handler_target_missing", + "failure_type": "handler_target_missing", + "message": "Invocable carrier binding has no endpoint or carrier reference.", + "accepted": false, + "idempotent_replay": false, + "handler": { + "accepted": false, + "kind": "invocable_carrier_request", + "reason": "handler_target_missing", + "message": "Invocable carrier binding has no endpoint or carrier reference.", + "error_type": "handler_target_missing", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "retry_scheduled": false + }, + "reason": "handler_target_missing" + } + ], + "timed": [ + 200, + { + "id": "{id:timed}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register timed}", + "operation_name": "timed", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|timed" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:timed}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": { + "timeout_seconds": 3600 + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|timed", + "activity_execution_id": "{ULID:timed}", + "activity_class": "t", + "activity_type": "t", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": "{µs}", + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:timed}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:timed}", + "activity_class": "t", + "activity_type": "t" + }, + "reason": null + } + ], + "expired": [ + 200, + { + "id": "{id:expired}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register expired}", + "operation_name": "expired", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "outcome": "timed_out", + "outcome_category": "handler", + "outcome_reason": "timed_out", + "outcome_message": "Service call deadline expired.", + "outcome_metadata": { + "service_error_type": "ServiceCallTimedOut", + "caller_observed_error_type": "ServiceCallTimedOut", + "typed_error_message": "Service call deadline expired.", + "call_deadline_at": "2001-02-03T03:05:06.500000Z" + }, + "service_error_type": "ServiceCallTimedOut", + "caller_observed_error_type": "ServiceCallTimedOut", + "typed_error_message": "Service call deadline expired.", + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "t", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": "Service call deadline expired.", + "idempotency_key": null, + "deadline_policy": { + "deadline_at": "2001-02-03T04:05:06.5+01:00" + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|expired" + }, + "service_call_attempts": [], + "retry_attempt_count": 0, + "accepted_at": "{µs}", + "call_deadline_at": "2001-02-03T03:05:06.500000Z", + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:expired}", + "error_type": "ServiceCallTimedOut", + "failure_type": "ServiceCallTimedOut", + "message": "Service call deadline expired.", + "accepted": true, + "idempotent_replay": false, + "reason": "timed_out" + } + ], + "cancel expired": [ + 409, + { + "id": "{id:expired}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register expired}", + "operation_name": "expired", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "outcome": "timed_out", + "outcome_category": "handler", + "outcome_reason": "timed_out", + "outcome_message": "Service call deadline expired.", + "outcome_metadata": { + "service_error_type": "ServiceCallTimedOut", + "caller_observed_error_type": "ServiceCallTimedOut", + "typed_error_message": "Service call deadline expired.", + "call_deadline_at": "2001-02-03T03:05:06.500000Z" + }, + "service_error_type": "ServiceCallTimedOut", + "caller_observed_error_type": "ServiceCallTimedOut", + "typed_error_message": "Service call deadline expired.", + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "t", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": "Service call deadline expired.", + "idempotency_key": null, + "deadline_policy": { + "deadline_at": "2001-02-03T04:05:06.5+01:00" + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|expired" + }, + "service_call_attempts": [], + "retry_attempt_count": 0, + "accepted_at": "{µs}", + "call_deadline_at": "2001-02-03T03:05:06.500000Z", + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:expired}", + "error_type": "ServiceCallTimedOut", + "failure_type": "ServiceCallTimedOut", + "message": "Service call deadline expired.", + "accepted": false, + "reason": "service_call_terminal" + } + ], + "show expired": [ + 200, + { + "id": "{id:expired}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register expired}", + "operation_name": "expired", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "outcome": "timed_out", + "outcome_category": "handler", + "outcome_reason": "timed_out", + "outcome_message": "Service call deadline expired.", + "outcome_metadata": { + "service_error_type": "ServiceCallTimedOut", + "caller_observed_error_type": "ServiceCallTimedOut", + "typed_error_message": "Service call deadline expired.", + "call_deadline_at": "2001-02-03T03:05:06.500000Z" + }, + "service_error_type": "ServiceCallTimedOut", + "caller_observed_error_type": "ServiceCallTimedOut", + "typed_error_message": "Service call deadline expired.", + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "t", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": "Service call deadline expired.", + "idempotency_key": null, + "deadline_policy": { + "deadline_at": "2001-02-03T04:05:06.5+01:00" + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|expired" + }, + "service_call_attempts": [], + "retry_attempt_count": 0, + "accepted_at": "{µs}", + "call_deadline_at": "2001-02-03T03:05:06.500000Z", + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "found": true, + "service_call_id": "{id:expired}", + "error_type": "ServiceCallTimedOut", + "failure_type": "ServiceCallTimedOut", + "message": "Service call deadline expired." + } + ], + "cancel charge": [ + 200, + { + "id": "{id:charge}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register charge}", + "operation_name": "charge", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "cancelled", + "outcome": "cancelled", + "outcome_category": "handler", + "outcome_reason": "cancelled_by_request", + "outcome_message": "operator stop", + "outcome_metadata": { + "failure_reason": "cancellation" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": "operator stop", + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|charge", + "activity_execution_id": "{ULID:charge}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": "{µs}", + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:charge}", + "accepted": true, + "reason": null + } + ], + "cancel charge again": [ + 409, + { + "id": "{id:charge}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register charge}", + "operation_name": "charge", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "cancelled", + "outcome": "cancelled", + "outcome_category": "handler", + "outcome_reason": "cancelled_by_request", + "outcome_message": "operator stop", + "outcome_metadata": { + "failure_reason": "cancellation" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": "operator stop", + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|charge", + "activity_execution_id": "{ULID:charge}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": "{µs}", + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:charge}", + "accepted": false, + "reason": "service_call_terminal" + } + ], + "cancel long reason": [ + 422, + { + "message": "The reason field must not be greater than 1000 characters.", + "reason": "validation_failed", + "errors": { + "reason": [ + "The reason field must not be greater than 1000 characters." + ] + }, + "validation_errors": { + "reason": [ + "The reason field must not be greater than 1000 characters." + ] + } + } + ], + "cancel unknown call": [ + 404, + { + "message": "Service call [01nosuchcall00000000000000] not found under operation [charge] for service [billing] at endpoint [pay] in namespace [@own].", + "reason": "service_call_not_found", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "charge", + "service_call_id": "01nosuchcall00000000000000" + } + ], + "nocancel": [ + 200, + { + "id": "{id:nocancel}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register nocancel}", + "operation_name": "nocancel", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|nocancel" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:nocancel}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": { + "mode": "none" + }, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|nocancel", + "activity_execution_id": "{ULID:nocancel}", + "activity_class": "t", + "activity_type": "t", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:nocancel}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:nocancel}", + "activity_class": "t", + "activity_type": "t" + }, + "reason": null + } + ], + "cancel nocancel": [ + 409, + { + "id": "{id:nocancel}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register nocancel}", + "operation_name": "nocancel", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|nocancel" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:nocancel}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": { + "mode": "none" + }, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|nocancel", + "activity_execution_id": "{ULID:nocancel}", + "activity_class": "t", + "activity_type": "t", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:nocancel}", + "accepted": false, + "reason": "cancellation_not_allowed" + } + ], + "need role": [ + 403, + { + "accepted": false, + "service_call_id": "{id:need role}", + "namespace": "@own", + "status": "failed", + "outcome": "rejected_forbidden", + "error_type": "rejected_forbidden", + "outcome_category": "rejected", + "reason": "missing_required_role", + "message": "Nexus operation is not permitted for this caller.", + "retry_after_seconds": null, + "policy_name": "workflow.default-service-boundary", + "outcome_metadata": { + "failure_reason": "policy_rejection" + }, + "caller_namespace": "@own", + "caller_principal_subject": "legacy-token", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null + } + ], + "show need role": [ + 200, + { + "id": "{id:need role}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register need-role}", + "operation_name": "need-role", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "outcome": "rejected_forbidden", + "outcome_category": "rejected", + "outcome_reason": "missing_required_role", + "outcome_message": "Caller [legacy-token] is missing one of the required roles for service-boundary admission.", + "outcome_metadata": { + "failure_reason": "policy_rejection", + "forbidden_axis": "operation" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "workflow.default-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "t", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": "Caller [legacy-token] is missing one of the required roles for service-boundary admission.", + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "required_roles": [ + "nexus-caller" + ] + }, + "metadata": { + "failure_reason": "policy_rejection", + "forbidden_axis": "operation" + }, + "service_call_attempts": [], + "retry_attempt_count": 0, + "accepted_at": null, + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "found": true, + "service_call_id": "{id:need role}", + "message": "Caller [legacy-token] is missing one of the required roles for service-boundary admission." + } + ], + "deny caller": [ + 403, + { + "accepted": false, + "service_call_id": "{id:deny caller}", + "namespace": "@own", + "status": "failed", + "outcome": "rejected_forbidden", + "error_type": "rejected_forbidden", + "outcome_category": "rejected", + "reason": "caller_namespace_denied", + "message": "Nexus operation is not permitted for this caller.", + "retry_after_seconds": null, + "policy_name": "workflow.default-service-boundary", + "outcome_metadata": { + "failure_reason": "policy_rejection" + }, + "caller_namespace": "blocked", + "caller_principal_subject": "legacy-token", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null + } + ], + "allow list": [ + 403, + { + "accepted": false, + "service_call_id": "{id:allow list}", + "namespace": "@own", + "status": "failed", + "outcome": "rejected_forbidden", + "error_type": "rejected_forbidden", + "outcome_category": "rejected", + "reason": "caller_namespace_not_allowed", + "message": "Nexus operation is not permitted for this caller.", + "retry_after_seconds": null, + "policy_name": "workflow.default-service-boundary", + "outcome_metadata": { + "failure_reason": "policy_rejection" + }, + "caller_namespace": "@own", + "caller_principal_subject": "legacy-token", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null + } + ], + "circuit": [ + 503, + { + "accepted": false, + "service_call_id": "{id:circuit}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register circuit}", + "operation_name": "circuit", + "operation_mode": "async", + "status": "failed", + "outcome": "rejected_circuit_open", + "error_type": "rejected_circuit_open", + "outcome_category": "rejected", + "reason": "circuit_open", + "message": "Service-boundary circuit is open for target [@own|pay|billing|circuit].", + "retry_after_seconds": 30, + "policy_name": "workflow.default-service-boundary", + "outcome_metadata": { + "failure_reason": "policy_rejection", + "target_key": "@own|pay|billing|circuit" + }, + "caller_namespace": "@own", + "caller_principal_subject": "legacy-token", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null + } + ], + "misconfigured": [ + 503, + { + "accepted": false, + "service_call_id": "{id:misconfigured}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register misconfigured}", + "operation_name": "misconfigured", + "operation_mode": "async", + "status": "failed", + "outcome": "rejected_throttled", + "error_type": "rejected_throttled", + "outcome_category": "rejected", + "reason": "service_boundary_admission_unavailable", + "message": "Shared service-call admission could not be evaluated.", + "retry_after_seconds": 1, + "policy_name": "server.shared-service-boundary", + "outcome_metadata": { + "failure_reason": "policy_rejection", + "admission_scope": "shared_cache" + }, + "caller_namespace": "@own", + "caller_principal_subject": "legacy-token", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null + } + ], + "rated": [ + 200, + { + "id": "{id:rated}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register rated}", + "operation_name": "rated", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|rated", + "admission_scope": "shared_cache", + "boundary_rate_limit": 1, + "namespace_rate_limit": null, + "boundary_concurrency_limit": null, + "namespace_concurrency_limit": null + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:rated}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "rate_limit": { + "requests_per_minute": 1, + "retry_after_seconds": 7 + } + }, + "metadata": { + "boundary_key": "@own|@own|pay|billing|rated", + "admission_scope": "shared_cache", + "boundary_rate_limit": 1, + "namespace_rate_limit": null, + "boundary_concurrency_limit": null, + "namespace_concurrency_limit": null, + "activity_execution_id": "{ULID:rated}", + "activity_class": "t", + "activity_type": "t", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:rated}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:rated}", + "activity_class": "t", + "activity_type": "t" + }, + "reason": null + } + ], + "rated again": [ + 429, + { + "accepted": false, + "service_call_id": "{id:rated again}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register rated}", + "operation_name": "rated", + "operation_mode": "async", + "status": "failed", + "outcome": "rejected_throttled", + "error_type": "rejected_throttled", + "outcome_category": "rejected", + "reason": "rate_limit_exceeded", + "message": "Shared service-call rate capacity is exhausted.", + "retry_after_seconds": 7, + "policy_name": "server.shared-service-boundary", + "outcome_metadata": { + "failure_reason": "policy_rejection", + "admission_scope": "boundary", + "observed_window_count": 1, + "requests_per_minute": 1 + }, + "caller_namespace": "@own", + "caller_principal_subject": "legacy-token", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null + } + ], + "rated other caller": [ + 200, + { + "id": "{id:rated other caller}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register rated}", + "operation_name": "rated", + "caller_namespace": "other", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "other|@own|pay|billing|rated", + "admission_scope": "shared_cache", + "boundary_rate_limit": 1, + "namespace_rate_limit": null, + "boundary_concurrency_limit": null, + "namespace_concurrency_limit": null + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:rated other caller}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "rate_limit": { + "requests_per_minute": 1, + "retry_after_seconds": 7 + } + }, + "metadata": { + "boundary_key": "other|@own|pay|billing|rated", + "admission_scope": "shared_cache", + "boundary_rate_limit": 1, + "namespace_rate_limit": null, + "boundary_concurrency_limit": null, + "namespace_concurrency_limit": null, + "activity_execution_id": "{ULID:rated other caller}", + "activity_class": "t", + "activity_type": "t", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:rated other caller}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:rated other caller}", + "activity_class": "t", + "activity_type": "t" + }, + "reason": null + } + ], + "held": [ + 200, + { + "id": "{id:held}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register held}", + "operation_name": "held", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|held", + "admission_scope": "shared_cache", + "boundary_rate_limit": null, + "namespace_rate_limit": null, + "boundary_concurrency_limit": 1, + "namespace_concurrency_limit": null + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:held}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "concurrency_limit": { + "max_in_flight": 1, + "sync_only": false + } + }, + "metadata": { + "boundary_key": "@own|@own|pay|billing|held", + "admission_scope": "shared_cache", + "boundary_rate_limit": null, + "namespace_rate_limit": null, + "boundary_concurrency_limit": 1, + "namespace_concurrency_limit": null, + "activity_execution_id": "{ULID:held}", + "activity_class": "t", + "activity_type": "t", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:held}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:held}", + "activity_class": "t", + "activity_type": "t" + }, + "reason": null + } + ], + "held again": [ + 429, + { + "accepted": false, + "service_call_id": "{id:held again}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register held}", + "operation_name": "held", + "operation_mode": "async", + "status": "failed", + "outcome": "rejected_concurrency_limited", + "error_type": "rejected_concurrency_limited", + "outcome_category": "rejected", + "reason": "concurrency_limit_exceeded", + "message": "Shared service-call concurrency capacity is exhausted.", + "retry_after_seconds": 1, + "policy_name": "server.shared-service-boundary", + "outcome_metadata": { + "failure_reason": "policy_rejection", + "admission_scope": "boundary", + "observed_in_flight": 1, + "max_in_flight": 1 + }, + "caller_namespace": "@own", + "caller_principal_subject": "legacy-token", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null + } + ], + "held sync": [ + 200, + { + "id": "{id:held sync}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register held-sync}", + "operation_name": "held-sync", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|held-sync", + "admission_scope": "shared_cache", + "boundary_rate_limit": null, + "namespace_rate_limit": null, + "boundary_concurrency_limit": 1, + "namespace_concurrency_limit": null + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "sync", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:held sync}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "concurrency_limit": { + "max_in_flight": 1 + } + }, + "metadata": { + "boundary_key": "@own|@own|pay|billing|held-sync", + "admission_scope": "shared_cache", + "boundary_rate_limit": null, + "namespace_rate_limit": null, + "boundary_concurrency_limit": 1, + "namespace_concurrency_limit": null, + "activity_execution_id": "{ULID:held sync}", + "activity_class": "t", + "activity_type": "t", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:held sync}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:held sync}", + "activity_class": "t", + "activity_type": "t" + }, + "reason": null + } + ], + "held sync again": [ + 200, + { + "id": "{id:held sync again}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register held-sync}", + "operation_name": "held-sync", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|held-sync", + "admission_scope": "shared_cache", + "boundary_rate_limit": null, + "namespace_rate_limit": null, + "boundary_concurrency_limit": 1, + "namespace_concurrency_limit": null + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "sync", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:held sync again}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "concurrency_limit": { + "max_in_flight": 1 + } + }, + "metadata": { + "boundary_key": "@own|@own|pay|billing|held-sync", + "admission_scope": "shared_cache", + "boundary_rate_limit": null, + "namespace_rate_limit": null, + "boundary_concurrency_limit": 1, + "namespace_concurrency_limit": null, + "activity_execution_id": "{ULID:held sync again}", + "activity_class": "t", + "activity_type": "t", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:held sync again}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:held sync again}", + "activity_class": "t", + "activity_type": "t" + }, + "reason": null + } + ], + "show rated again": [ + 200, + { + "id": "{id:rated again}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register rated}", + "operation_name": "rated", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "outcome": "rejected_throttled", + "outcome_category": "rejected", + "outcome_reason": "rate_limit_exceeded", + "outcome_message": "Shared service-call rate capacity is exhausted.", + "outcome_metadata": { + "failure_reason": "policy_rejection", + "admission_scope": "boundary", + "observed_window_count": 1, + "requests_per_minute": 1 + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": 7, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "t", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": "Shared service-call rate capacity is exhausted.", + "idempotency_key": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "rate_limit": { + "requests_per_minute": 1, + "retry_after_seconds": 7 + } + }, + "metadata": { + "failure_reason": "policy_rejection", + "admission_scope": "boundary", + "observed_window_count": 1, + "requests_per_minute": 1 + }, + "service_call_attempts": [], + "retry_attempt_count": 0, + "accepted_at": null, + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "found": true, + "service_call_id": "{id:rated again}", + "message": "Shared service-call rate capacity is exhausted." + } + ], + "replay first": [ + 200, + { + "id": "{id:replay first}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register replay-target}", + "operation_name": "replay-target", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|replay-target" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:replay first}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": "k1", + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|replay-target", + "activity_execution_id": "{ULID:replay first}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:replay first}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:replay first}", + "activity_class": "charge-activity", + "activity_type": "charge-activity" + }, + "reason": null + } + ], + "replay again": [ + 200, + { + "accepted": true, + "idempotent_replay": true, + "reason": null, + "found": true, + "service_call_id": "{id:replay first}", + "namespace": "@own", + "caller_namespace": "@own", + "target_namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "replay-target", + "operation_mode": "async", + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:replay first}", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "failure_message": null + } + ], + "replay other caller": [ + 200, + { + "id": "{id:replay other caller}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register replay-target}", + "operation_name": "replay-target", + "caller_namespace": "other", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "other|@own|pay|billing|replay-target" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:replay other caller}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": "k1", + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "other|@own|pay|billing|replay-target", + "activity_execution_id": "{ULID:replay other caller}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:replay other caller}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:replay other caller}", + "activity_class": "charge-activity", + "activity_type": "charge-activity" + }, + "reason": null + } + ], + "cancel replayed": [ + 200, + { + "id": "{id:replay first}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register replay-target}", + "operation_name": "replay-target", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "cancelled", + "outcome": "cancelled", + "outcome_category": "handler", + "outcome_reason": "cancelled_by_request", + "outcome_message": null, + "outcome_metadata": { + "failure_reason": "cancellation" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:replay first}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": "k1", + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|replay-target", + "activity_execution_id": "{ULID:replay first}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": "{µs}", + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:replay first}", + "accepted": true, + "reason": null + } + ], + "replay cancelled": [ + 409, + { + "accepted": false, + "idempotent_replay": true, + "reason": null, + "found": true, + "service_call_id": "{id:replay first}", + "namespace": "@own", + "caller_namespace": "@own", + "target_namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "replay-target", + "operation_mode": "async", + "status": "cancelled", + "outcome": "cancelled", + "outcome_category": "handler", + "outcome_reason": "cancelled_by_request", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:replay first}", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": "{µs}", + "failure_message": null + } + ], + "replay second key": [ + 200, + { + "id": "{id:replay second key}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register replay-target}", + "operation_name": "replay-target", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|replay-target" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:replay second key}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": "k2", + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|replay-target", + "activity_execution_id": "{ULID:replay second key}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "service_call_id": "{id:replay second key}", + "accepted": true, + "idempotent_replay": false, + "handler": { + "accepted": true, + "kind": "activity_execution", + "activity_execution_id": "{ULID:replay second key}", + "activity_class": "charge-activity", + "activity_type": "charge-activity" + }, + "reason": null + } + ], + "require a role": [ + 200, + { + "id": "{id:register replay-target}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_name": "replay-target", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "activity_execution", + "handler_target_reference": "charge-activity", + "handler_binding": null, + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": { + "required_roles": [ + "nexus-caller" + ] + }, + "metadata": null, + "created_at": "{time}", + "updated_at": "{time}" + } + ], + "replay forbidden": [ + 403, + { + "accepted": false, + "service_call_id": "{id:replay forbidden}", + "namespace": "@own", + "status": "failed", + "outcome": "rejected_forbidden", + "error_type": "rejected_forbidden", + "outcome_category": "rejected", + "reason": "missing_required_role", + "message": "Nexus operation is not permitted for this caller.", + "retry_after_seconds": null, + "policy_name": "workflow.default-service-boundary", + "outcome_metadata": { + "failure_reason": "policy_rejection" + }, + "caller_namespace": "@own", + "caller_principal_subject": "legacy-token", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null + } + ], + "show replayed second": [ + 200, + { + "id": "{id:replay second key}", + "namespace": "@own", + "endpoint_id": "{id:register endpoint}", + "endpoint_name": "pay", + "service_id": "{id:register service}", + "service_name": "billing", + "operation_id": "{id:register replay-target}", + "operation_name": "replay-target", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "outcome": "accepted", + "outcome_category": "accepted", + "outcome_reason": "accepted", + "outcome_message": null, + "outcome_metadata": { + "boundary_key": "@own|@own|pay|billing|replay-target" + }, + "service_error_type": null, + "caller_observed_error_type": null, + "typed_error_message": null, + "policy_name": "server.shared-service-boundary", + "retry_after_seconds": null, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:replay second key}", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "caller_principal_claims": { + "legacy_credential": true + }, + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "failure_message": null, + "idempotency_key": "k2", + "deadline_policy": null, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": { + "boundary_key": "@own|@own|pay|billing|replay-target", + "activity_execution_id": "{ULID:replay second key}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{time}", + "updated_at": "{time}", + "found": true, + "service_call_id": "{id:replay second key}" + } + ], + "catalog calls of charge": [ + 200, + { + "data": [ + { + "id": "{id:charge override}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register charge}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "charge", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "status_bucket": "open", + "outcome": "accepted", + "outcome_bucket": "open", + "is_terminal": false, + "is_policy_outcome": false, + "operation_mode": "sync", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge override}", + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "failure_message": null, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}" + }, + { + "id": "{id:charge with options}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register charge}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "charge", + "caller_namespace": "Partner-NS", + "caller_workflow_instance_id": "caller-wf", + "caller_workflow_run_id": "01hcallerrun00000000000000", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "status_bucket": "open", + "outcome": "accepted", + "outcome_bucket": "open", + "is_terminal": false, + "is_policy_outcome": false, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge with options}", + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "failure_message": null, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}" + }, + { + "id": "{id:charge}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register charge}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "charge", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "cancelled", + "status_bucket": "cancelled", + "outcome": "cancelled", + "outcome_bucket": "cancelled", + "is_terminal": true, + "is_policy_outcome": false, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge}", + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "failure_message": "operator stop", + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": "{µs}", + "created_at": "{µs}", + "updated_at": "{µs}" + } + ], + "current_page": 1, + "per_page": 50, + "total": 3, + "last_page": 1, + "namespace": "@own", + "scope": "relevant", + "bucket": null, + "status_bucket": null, + "outcome": null, + "outcome_bucket": null, + "statuses": [ + "pending", + "accepted", + "started", + "completed", + "failed", + "cancelled" + ], + "outcomes": [ + "accepted", + "completed", + "cancelled", + "timed_out", + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open", + "degraded", + "handler_failed" + ], + "status_buckets": { + "open": [ + "pending", + "accepted", + "started" + ], + "completed": [ + "completed" + ], + "failed": [ + "failed" + ], + "cancelled": [ + "cancelled" + ] + }, + "outcome_buckets": { + "open": [ + "accepted" + ], + "completed": [ + "completed", + "degraded" + ], + "failed": [ + "timed_out", + "handler_failed" + ], + "cancelled": [ + "cancelled" + ], + "policy": [ + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open" + ] + } + } + ], + "catalog calls of charge as caller": [ + 200, + { + "data": [ + { + "id": "{id:charge override}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register charge}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "charge", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "status_bucket": "open", + "outcome": "accepted", + "outcome_bucket": "open", + "is_terminal": false, + "is_policy_outcome": false, + "operation_mode": "sync", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge override}", + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "failure_message": null, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}" + }, + { + "id": "{id:charge}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register charge}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "charge", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "cancelled", + "status_bucket": "cancelled", + "outcome": "cancelled", + "outcome_bucket": "cancelled", + "is_terminal": true, + "is_policy_outcome": false, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge}", + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "failure_message": "operator stop", + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": "{µs}", + "created_at": "{µs}", + "updated_at": "{µs}" + } + ], + "current_page": 1, + "per_page": 50, + "total": 2, + "last_page": 1, + "namespace": "@own", + "scope": "caller", + "bucket": null, + "status_bucket": null, + "outcome": null, + "outcome_bucket": null, + "statuses": [ + "pending", + "accepted", + "started", + "completed", + "failed", + "cancelled" + ], + "outcomes": [ + "accepted", + "completed", + "cancelled", + "timed_out", + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open", + "degraded", + "handler_failed" + ], + "status_buckets": { + "open": [ + "pending", + "accepted", + "started" + ], + "completed": [ + "completed" + ], + "failed": [ + "failed" + ], + "cancelled": [ + "cancelled" + ] + }, + "outcome_buckets": { + "open": [ + "accepted" + ], + "completed": [ + "completed", + "degraded" + ], + "failed": [ + "timed_out", + "handler_failed" + ], + "cancelled": [ + "cancelled" + ], + "policy": [ + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open" + ] + } + } + ], + "catalog cancelled calls": [ + 200, + { + "data": [ + { + "id": "{id:replay first}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register replay-target}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "replay-target", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "cancelled", + "status_bucket": "cancelled", + "outcome": "cancelled", + "outcome_bucket": "cancelled", + "is_terminal": true, + "is_policy_outcome": false, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:replay first}", + "idempotency_key": "k1", + "retry_policy": [], + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "failure_message": null, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": "{µs}", + "created_at": "{µs}", + "updated_at": "{µs}" + }, + { + "id": "{id:charge}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register charge}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "charge", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "cancelled", + "status_bucket": "cancelled", + "outcome": "cancelled", + "outcome_bucket": "cancelled", + "is_terminal": true, + "is_policy_outcome": false, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge}", + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "failure_message": "operator stop", + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": "{µs}", + "created_at": "{µs}", + "updated_at": "{µs}" + } + ], + "current_page": 1, + "per_page": 50, + "total": 2, + "last_page": 1, + "namespace": "@own", + "scope": "relevant", + "bucket": "cancelled", + "status_bucket": "cancelled", + "outcome": null, + "outcome_bucket": null, + "statuses": [ + "pending", + "accepted", + "started", + "completed", + "failed", + "cancelled" + ], + "outcomes": [ + "accepted", + "completed", + "cancelled", + "timed_out", + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open", + "degraded", + "handler_failed" + ], + "status_buckets": { + "open": [ + "pending", + "accepted", + "started" + ], + "completed": [ + "completed" + ], + "failed": [ + "failed" + ], + "cancelled": [ + "cancelled" + ] + }, + "outcome_buckets": { + "open": [ + "accepted" + ], + "completed": [ + "completed", + "degraded" + ], + "failed": [ + "timed_out", + "handler_failed" + ], + "cancelled": [ + "cancelled" + ], + "policy": [ + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open" + ] + } + } + ], + "catalog handler failures": [ + 200, + { + "data": [ + { + "id": "{id:notify broken}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register notify-broken}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "notify-broken", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "status_bucket": "failed", + "outcome": "handler_failed", + "outcome_bucket": "failed", + "is_terminal": true, + "is_policy_outcome": false, + "operation_mode": "async", + "resolved_binding_kind": "invocable_carrier_request", + "resolved_target_reference": null, + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Invocable carrier binding has no endpoint or carrier reference.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "failure_message": "Invocable carrier binding has no endpoint or carrier reference.", + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}" + }, + { + "id": "{id:broken no retry}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register broken-noretry}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "broken-noretry", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "status_bucket": "failed", + "outcome": "handler_failed", + "outcome_bucket": "failed", + "is_terminal": true, + "is_policy_outcome": false, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": null, + "idempotency_key": null, + "retry_policy": { + "max_attempts": 3, + "non_retryable_error_types": [ + "handler_target_missing" + ] + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "failure_message": "Activity binding has no activity class or type.", + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}" + }, + { + "id": "{id:broken retry}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register broken-retry}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "broken-retry", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "status_bucket": "failed", + "outcome": "handler_failed", + "outcome_bucket": "failed", + "is_terminal": true, + "is_policy_outcome": false, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": null, + "idempotency_key": null, + "retry_policy": { + "max_attempts": 3 + }, + "service_call_attempts": [ + { + "attempt": 1, + "status": "started", + "outcome": "accepted", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "retry_scheduled": true, + "scheduled_backoff_seconds": 0 + }, + { + "attempt": 2, + "status": "started", + "outcome": "accepted", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "retry_scheduled": true, + "scheduled_backoff_seconds": 0 + }, + { + "attempt": 3, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 3, + "failure_message": "Activity binding has no activity class or type.", + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": "{µs}", + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}" + }, + { + "id": "{id:broken}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register broken}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "broken", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "status_bucket": "failed", + "outcome": "handler_failed", + "outcome_bucket": "failed", + "is_terminal": true, + "is_policy_outcome": false, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": null, + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "failure_message": "Activity binding has no activity class or type.", + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}" + } + ], + "current_page": 1, + "per_page": 50, + "total": 4, + "last_page": 1, + "namespace": "@own", + "scope": "relevant", + "bucket": null, + "status_bucket": null, + "outcome": "handler_failed", + "outcome_bucket": null, + "statuses": [ + "pending", + "accepted", + "started", + "completed", + "failed", + "cancelled" + ], + "outcomes": [ + "accepted", + "completed", + "cancelled", + "timed_out", + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open", + "degraded", + "handler_failed" + ], + "status_buckets": { + "open": [ + "pending", + "accepted", + "started" + ], + "completed": [ + "completed" + ], + "failed": [ + "failed" + ], + "cancelled": [ + "cancelled" + ] + }, + "outcome_buckets": { + "open": [ + "accepted" + ], + "completed": [ + "completed", + "degraded" + ], + "failed": [ + "timed_out", + "handler_failed" + ], + "cancelled": [ + "cancelled" + ], + "policy": [ + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open" + ] + } + } + ], + "catalog policy calls page": [ + 200, + { + "data": [ + { + "id": "{id:misconfigured}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register misconfigured}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "misconfigured", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "status_bucket": "failed", + "outcome": "rejected_throttled", + "outcome_bucket": "policy", + "is_terminal": true, + "is_policy_outcome": true, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "t", + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [], + "retry_attempt_count": 0, + "failure_message": "Shared service-call admission could not be evaluated.", + "accepted_at": null, + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}" + }, + { + "id": "{id:circuit}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register circuit}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "circuit", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "status_bucket": "failed", + "outcome": "rejected_circuit_open", + "outcome_bucket": "policy", + "is_terminal": true, + "is_policy_outcome": true, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "t", + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [], + "retry_attempt_count": 0, + "failure_message": "Service-boundary circuit is open for target [@own|pay|billing|circuit].", + "accepted_at": null, + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}" + }, + { + "id": "{id:allow list}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register allow-list}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "allow-list", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "status_bucket": "failed", + "outcome": "rejected_forbidden", + "outcome_bucket": "policy", + "is_terminal": true, + "is_policy_outcome": true, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "t", + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [], + "retry_attempt_count": 0, + "failure_message": "Caller namespace [@own] is not on the service-boundary allow list for target namespace [@own].", + "accepted_at": null, + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}" + } + ], + "current_page": 2, + "per_page": 3, + "total": 8, + "last_page": 3, + "namespace": "@own", + "scope": "relevant", + "bucket": null, + "status_bucket": null, + "outcome": null, + "outcome_bucket": "policy", + "statuses": [ + "pending", + "accepted", + "started", + "completed", + "failed", + "cancelled" + ], + "outcomes": [ + "accepted", + "completed", + "cancelled", + "timed_out", + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open", + "degraded", + "handler_failed" + ], + "status_buckets": { + "open": [ + "pending", + "accepted", + "started" + ], + "completed": [ + "completed" + ], + "failed": [ + "failed" + ], + "cancelled": [ + "cancelled" + ] + }, + "outcome_buckets": { + "open": [ + "accepted" + ], + "completed": [ + "completed", + "degraded" + ], + "failed": [ + "timed_out", + "handler_failed" + ], + "cancelled": [ + "cancelled" + ], + "policy": [ + "rejected_not_found", + "rejected_forbidden", + "rejected_throttled", + "rejected_concurrency_limited", + "rejected_circuit_open" + ] + } + } + ], + "catalog show call with caller": [ + 200, + { + "id": "{id:charge with options}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register charge}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "charge", + "caller_namespace": "Partner-NS", + "caller_workflow_instance_id": "caller-wf", + "caller_workflow_run_id": "01hcallerrun00000000000000", + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "accepted", + "status_bucket": "open", + "outcome": "accepted", + "outcome_bucket": "open", + "is_terminal": false, + "is_policy_outcome": false, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "{ULID:charge with options}", + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "failure_message": null, + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": null, + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}", + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "deadline_policy": [], + "idempotency_policy": [], + "cancellation_policy": [], + "boundary_policy": [], + "metadata": { + "boundary_key": "Partner-NS|@own|pay|billing|charge", + "activity_execution_id": "{ULID:charge with options}", + "activity_class": "charge-activity", + "activity_type": "charge-activity", + "connection": "cx", + "queue": "qx", + "service_call_attempts": [ + { + "attempt": 1, + "status": "accepted", + "outcome": "accepted", + "outcome_reason": "accepted", + "started_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "caller_principal_claims": { + "legacy_credential": true + }, + "caller_link": { + "namespace": "Partner-NS", + "workflow_instance_id": "caller-wf", + "workflow_run_id": "01hcallerrun00000000000000", + "in_observer_namespace": false + }, + "linked_run_ref": null, + "linked_update_ref": null, + "payloads_in_observer_namespace": true, + "result_envelope": null, + "failure_type": null + } + ], + "catalog show failed call": [ + 200, + { + "id": "{id:broken}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register broken}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "broken", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "status_bucket": "failed", + "outcome": "handler_failed", + "outcome_bucket": "failed", + "is_terminal": true, + "is_policy_outcome": false, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": null, + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1, + "failure_message": "Activity binding has no activity class or type.", + "accepted_at": "{µs}", + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}", + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "deadline_policy": [], + "idempotency_policy": [], + "cancellation_policy": [], + "boundary_policy": [], + "metadata": { + "boundary_key": "@own|@own|pay|billing|broken", + "failure_reason": "handler_failure", + "failure_type": "handler_target_missing", + "service_error_type": "handler_target_missing", + "caller_observed_error_type": "handler_target_missing", + "typed_error_message": "Activity binding has no activity class or type.", + "service_call_attempts": [ + { + "attempt": 1, + "status": "failed", + "outcome": "handler_failed", + "outcome_reason": "handler_target_missing", + "failure_type": "handler_target_missing", + "failure_message": "Activity binding has no activity class or type.", + "started_at": "{µs}", + "failed_at": "{µs}", + "retry_scheduled": false + } + ], + "retry_attempt_count": 1 + }, + "caller_principal_claims": { + "legacy_credential": true + }, + "caller_link": null, + "linked_run_ref": null, + "linked_update_ref": null, + "payloads_in_observer_namespace": true, + "result_envelope": null, + "failure_type": "handler_target_missing" + } + ], + "catalog show rejected call": [ + 200, + { + "id": "{id:need role}", + "workflow_service_endpoint_id": "{id:register endpoint}", + "workflow_service_id": "{id:register service}", + "workflow_service_operation_id": "{id:register need-role}", + "namespace": "@own", + "endpoint_name": "pay", + "service_name": "billing", + "operation_name": "need-role", + "caller_namespace": "@own", + "caller_workflow_instance_id": null, + "caller_workflow_run_id": null, + "caller_principal_subject": "legacy-token", + "caller_principal_method": "token", + "caller_principal_roles": [ + "admin" + ], + "caller_principal_tenant": null, + "target_namespace": "@own", + "linked_workflow_instance_id": null, + "linked_workflow_run_id": null, + "linked_workflow_update_id": null, + "status": "failed", + "status_bucket": "failed", + "outcome": "rejected_forbidden", + "outcome_bucket": "policy", + "is_terminal": true, + "is_policy_outcome": true, + "operation_mode": "async", + "resolved_binding_kind": "activity_execution", + "resolved_target_reference": "t", + "idempotency_key": null, + "retry_policy": [], + "service_call_attempts": [], + "retry_attempt_count": 0, + "failure_message": "Caller [legacy-token] is missing one of the required roles for service-boundary admission.", + "accepted_at": null, + "call_deadline_at": null, + "started_at": null, + "completed_at": null, + "failed_at": "{µs}", + "cancelled_at": null, + "created_at": "{µs}", + "updated_at": "{µs}", + "payload_codec": null, + "input_payload_reference": null, + "output_payload_reference": null, + "failure_payload_reference": null, + "deadline_policy": [], + "idempotency_policy": [], + "cancellation_policy": [], + "boundary_policy": { + "required_roles": [ + "nexus-caller" + ] + }, + "metadata": { + "failure_reason": "policy_rejection", + "forbidden_axis": "operation" + }, + "caller_principal_claims": { + "legacy_credential": true + }, + "caller_link": null, + "linked_run_ref": null, + "linked_update_ref": null, + "payloads_in_observer_namespace": true, + "result_envelope": null, + "failure_type": null + } + ], + "catalog show unknown call": [ + 404, + { + "message": "Catalog object not found.", + "reason": "catalog_object_not_found" + } + ] + } + } +} diff --git a/tests/Fixtures/ServerParity/service-endpoints.json b/tests/Fixtures/ServerParity/service-endpoints.json new file mode 100644 index 000000000..7094dc215 --- /dev/null +++ b/tests/Fixtures/ServerParity/service-endpoints.json @@ -0,0 +1,2106 @@ +{ + "schema": "durable-workflow.server-parity-fixture/v1", + "id": "service-endpoints", + "review_state": "reviewed", + "contract": "Published Server 2.11.1 service catalog administration (ServiceCatalogController, admin): POST/GET/PUT/DELETE /api/service-endpoints[/{endpoint}[/services[/{service}[/operations[/{operation}]]]]] registers a namespace's Nexus endpoints, their services and the services' operations by lowercased name. Creates validate like Laravel (names required, string, at most 191 characters, /^[a-zA-Z0-9._-]+$/; descriptions at most 1000; metadata and policies arrays), answer 201 with a lowercase ULID and 409 with the existing id for a taken name. Operations require a sync or async mode, a handler binding kind and a non-empty handler_target_reference or handler_binding; a deadline_policy holds exactly timeout_seconds (1 to 2147483647) or an offset RFC3339 deadline_at within 1970-9999. Updates change only given fields, prohibit renaming an operation and repeat the binding check. Lookups answer 404 endpoint_not_found (with accepted false), service_not_found or operation_not_found, the parent first. Deletes refuse an endpoint with services, a service with operations, and succeed with outcome deleted. Endpoint lists are by name; service and operation lists in creation order. JSON documents read back as PHP arrays. The catalog reads under /api/service-catalog render the same rows by page and identity, and another namespace's identity is 404. Generated ids and whole-second times are placeholders; @own is the fixture's own namespace. Embedded applications have no HTTP catalog.", + "workflow_type": "parity.v1.echo", + "input": { + "message": "service endpoints λ", + "count": 9007199254740993, + "optional": null + }, + "typed_value": { + "type": "map", + "value": { + "message": { + "type": "string", + "value": "service endpoints λ" + }, + "count": { + "type": "int64", + "value": "9007199254740993" + }, + "optional": { + "type": "null", + "value": null + } + } + }, + "activity": false, + "expected_events": [ + "StartAccepted", + "WorkflowStarted", + "WorkflowCompleted" + ], + "service_endpoints": { + "requests": [ + { + "label": "endpoints empty", + "method": "GET", + "path": "service-endpoints" + }, + { + "label": "endpoint create", + "method": "POST", + "path": "service-endpoints", + "raw_body": "{\"endpoint_name\":\" Billing \",\"description\":\"Payments λ\",\"metadata\":{\"team\":\"core\",\"empty\":{},\"list\":[1,2],\"indexed\":{\"0\":\"a\",\"1\":\"b\"},\"ratio\":1.0,\"note\":\" padded \"}}" + }, + { + "label": "endpoint duplicate", + "method": "POST", + "path": "service-endpoints", + "body": { + "endpoint_name": "BILLING" + } + }, + { + "label": "endpoint invalid", + "method": "POST", + "path": "service-endpoints", + "body": { + "endpoint_name": "bad name!", + "description": 5, + "metadata": "x" + } + }, + { + "label": "endpoint missing name", + "method": "POST", + "path": "service-endpoints", + "raw_body": "{}" + }, + { + "label": "endpoint number name", + "method": "POST", + "path": "service-endpoints", + "body": { + "endpoint_name": 500 + } + }, + { + "label": "endpoint list name", + "method": "POST", + "path": "service-endpoints", + "body": { + "endpoint_name": [ + "a" + ] + } + }, + { + "label": "endpoint long name", + "method": "POST", + "path": "service-endpoints", + "body": { + "endpoint_name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "label": "endpoint long description", + "method": "POST", + "path": "service-endpoints", + "body": { + "endpoint_name": "long", + "description": "ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + }, + { + "label": "endpoint second", + "method": "POST", + "path": "service-endpoints", + "body": { + "endpoint_name": "alpha" + } + }, + { + "label": "endpoints list", + "method": "GET", + "path": "service-endpoints" + }, + { + "label": "endpoint show", + "method": "GET", + "path": "service-endpoints/BILLING" + }, + { + "label": "endpoint missing", + "method": "GET", + "path": "service-endpoints/Missing" + }, + { + "label": "endpoint update unchanged", + "method": "PUT", + "path": "service-endpoints/billing", + "raw_body": "{\"description\":\"Payments λ\"}" + }, + { + "label": "endpoint update", + "method": "PUT", + "path": "service-endpoints/billing", + "body": { + "description": " ", + "metadata": { + "team": "edge" + } + } + }, + { + "label": "endpoint update invalid", + "method": "PUT", + "path": "service-endpoints/billing", + "body": { + "metadata": "x" + } + }, + { + "label": "endpoint update missing", + "method": "PUT", + "path": "service-endpoints/nope", + "body": { + "metadata": "x" + } + }, + { + "label": "service create", + "method": "POST", + "path": "service-endpoints/billing/services", + "body": { + "service_name": "Invoices", + "description": "Invoice service", + "metadata": { + "tier": 1 + } + } + }, + { + "label": "service second", + "method": "POST", + "path": "service-endpoints/billing/services", + "body": { + "service_name": "accounts" + } + }, + { + "label": "service duplicate", + "method": "POST", + "path": "service-endpoints/billing/services", + "body": { + "service_name": "INVOICES" + } + }, + { + "label": "service invalid", + "method": "POST", + "path": "service-endpoints/billing/services", + "body": { + "service_name": "", + "metadata": [ + 1 + ] + } + }, + { + "label": "service missing endpoint", + "method": "POST", + "path": "service-endpoints/nope/services", + "raw_body": "{}" + }, + { + "label": "services list", + "method": "GET", + "path": "service-endpoints/billing/services" + }, + { + "label": "services missing endpoint", + "method": "GET", + "path": "service-endpoints/nope/services" + }, + { + "label": "service show", + "method": "GET", + "path": "service-endpoints/billing/services/Invoices" + }, + { + "label": "service missing", + "method": "GET", + "path": "service-endpoints/billing/services/nope" + }, + { + "label": "service update", + "method": "PUT", + "path": "service-endpoints/billing/services/invoices", + "body": { + "description": "Invoices", + "metadata": null + } + }, + { + "label": "operation create", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "Charge", + "operation_mode": "sync", + "handler_binding_kind": "start_workflow", + "handler_target_reference": "billing.charge", + "deadline_policy": { + "timeout_seconds": 30 + }, + "retry_policy": { + "max_attempts": 3 + } + } + }, + { + "label": "operation duplicate", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "CHARGE", + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": "billing.refund" + } + }, + { + "label": "operation missing fields", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "raw_body": "{}" + }, + { + "label": "operation unbound", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "refund", + "operation_mode": "async", + "handler_binding_kind": "signal_workflow" + } + }, + { + "label": "operation empty binding", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "raw_body": "{\"operation_name\":\"refund\",\"operation_mode\":\"async\",\"handler_binding_kind\":\"signal_workflow\",\"handler_target_reference\":\" \",\"handler_binding\":{}}" + }, + { + "label": "operation bad vocabulary", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "refund", + "operation_mode": "later", + "handler_binding_kind": 5, + "handler_target_reference": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + }, + { + "label": "operation deadline keys", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "raw_body": "{\"operation_name\":\"refund\",\"operation_mode\":\"async\",\"handler_binding_kind\":\"signal_workflow\",\"handler_target_reference\":\"billing.refund\",\"deadline_policy\":{}}" + }, + { + "label": "operation deadline both", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "refund", + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": "billing.refund", + "deadline_policy": { + "timeout_seconds": 5, + "deadline_at": "2030-01-01T00:00:00Z" + } + } + }, + { + "label": "operation deadline zero", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "refund", + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": "billing.refund", + "deadline_policy": { + "timeout_seconds": 0 + } + } + }, + { + "label": "operation deadline float", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "refund", + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": "billing.refund", + "deadline_policy": { + "timeout_seconds": 5.5 + } + } + }, + { + "label": "operation deadline shape", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "refund", + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": "billing.refund", + "deadline_policy": { + "deadline_at": "2030-01-01T00:00:00" + } + } + }, + { + "label": "operation deadline digits", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "refund", + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": "billing.refund", + "deadline_policy": { + "deadline_at": "2030-01-01T00:00:00.1234567Z" + } + } + }, + { + "label": "operation deadline calendar", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "refund", + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": "billing.refund", + "deadline_policy": { + "deadline_at": "2030-02-30T00:00:00Z" + } + } + }, + { + "label": "operation deadline hour", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "refund", + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": "billing.refund", + "deadline_policy": { + "deadline_at": "2030-01-01T24:00:00Z" + } + } + }, + { + "label": "operation deadline years", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "refund", + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": "billing.refund", + "deadline_policy": { + "deadline_at": "1970-01-01T00:30:00+01:00" + } + } + }, + { + "label": "operation second", + "method": "POST", + "path": "service-endpoints/billing/services/invoices/operations", + "body": { + "operation_name": "refund", + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_binding": { + "workflow_type": "billing.refund" + }, + "deadline_policy": { + "deadline_at": "2030-01-01T00:00:00.5+05:30" + } + } + }, + { + "label": "operations list", + "method": "GET", + "path": "service-endpoints/billing/services/invoices/operations" + }, + { + "label": "operation show", + "method": "GET", + "path": "service-endpoints/billing/services/invoices/operations/CHARGE" + }, + { + "label": "operation missing", + "method": "GET", + "path": "service-endpoints/billing/services/invoices/operations/nope" + }, + { + "label": "operation missing service", + "method": "GET", + "path": "service-endpoints/billing/services/nope/operations/charge" + }, + { + "label": "operation rename", + "method": "PUT", + "path": "service-endpoints/billing/services/invoices/operations/charge", + "body": { + "operation_name": "other" + } + }, + { + "label": "operation rename empty", + "method": "PUT", + "path": "service-endpoints/billing/services/invoices/operations/charge", + "body": { + "operation_name": "", + "description": "Charges" + } + }, + { + "label": "operation clear target", + "method": "PUT", + "path": "service-endpoints/billing/services/invoices/operations/charge", + "body": { + "handler_target_reference": null + } + }, + { + "label": "operation rebind", + "method": "PUT", + "path": "service-endpoints/billing/services/invoices/operations/charge", + "body": { + "handler_target_reference": null, + "handler_binding": { + "workflow_type": "billing.charge" + }, + "operation_mode": "async" + } + }, + { + "label": "operation unbind", + "method": "PUT", + "path": "service-endpoints/billing/services/invoices/operations/charge", + "raw_body": "{\"handler_binding\":{}}" + }, + { + "label": "operation update deadline", + "method": "PUT", + "path": "service-endpoints/billing/services/invoices/operations/charge", + "body": { + "deadline_policy": { + "timeout_seconds": "5" + } + } + }, + { + "label": "operation update mode", + "method": "PUT", + "path": "service-endpoints/billing/services/invoices/operations/charge", + "body": { + "operation_mode": null + } + }, + { + "label": "read endpoints", + "method": "GET", + "path": "service-catalog/endpoints" + }, + { + "label": "read endpoints page", + "method": "GET", + "path": "service-catalog/endpoints?per_page=1&page=2" + }, + { + "label": "read services of endpoint", + "method": "GET", + "path": "service-catalog/services?endpoint_id={endpoint create.id}" + }, + { + "label": "read operations of service", + "method": "GET", + "path": "service-catalog/operations?service_id={service create.id}" + }, + { + "label": "read endpoint", + "method": "GET", + "path": "service-catalog/endpoints/{endpoint create.id}" + }, + { + "label": "read service", + "method": "GET", + "path": "service-catalog/services/{service create.id}" + }, + { + "label": "read operation", + "method": "GET", + "path": "service-catalog/operations/{operation create.id}" + }, + { + "label": "read endpoint elsewhere", + "method": "GET", + "path": "service-catalog/endpoints/{endpoint create.id}", + "namespace": "default" + }, + { + "label": "endpoint delete with services", + "method": "DELETE", + "path": "service-endpoints/billing" + }, + { + "label": "service delete with operations", + "method": "DELETE", + "path": "service-endpoints/billing/services/invoices" + }, + { + "label": "operation delete", + "method": "DELETE", + "path": "service-endpoints/billing/services/invoices/operations/charge" + }, + { + "label": "operation delete again", + "method": "DELETE", + "path": "service-endpoints/billing/services/invoices/operations/charge" + }, + { + "label": "operation delete second", + "method": "DELETE", + "path": "service-endpoints/billing/services/invoices/operations/refund" + }, + { + "label": "service delete", + "method": "DELETE", + "path": "service-endpoints/billing/services/invoices" + }, + { + "label": "service delete second", + "method": "DELETE", + "path": "service-endpoints/billing/services/accounts" + }, + { + "label": "endpoint delete", + "method": "DELETE", + "path": "service-endpoints/billing" + }, + { + "label": "endpoint delete second", + "method": "DELETE", + "path": "service-endpoints/alpha" + }, + { + "label": "endpoints after", + "method": "GET", + "path": "service-endpoints" + } + ], + "expected": { + "endpoints empty": [ + 200, + { + "service_endpoints": [] + } + ], + "endpoint create": [ + 201, + { + "id": "{id:endpoint create}", + "namespace": "@own", + "endpoint_name": "billing", + "description": "Payments λ", + "metadata": { + "team": "core", + "empty": [], + "list": [ + 1, + 2 + ], + "indexed": [ + "a", + "b" + ], + "ratio": 1, + "note": "padded" + }, + "created_at": "{time:endpoint create created}", + "updated_at": "{time:endpoint create updated}" + } + ], + "endpoint duplicate": [ + 409, + { + "message": "A service endpoint [billing] already exists in namespace [@own].", + "reason": "endpoint_already_exists", + "namespace": "@own", + "endpoint_name": "billing", + "endpoint_id": "{id:endpoint create}" + } + ], + "endpoint invalid": [ + 422, + { + "message": "The endpoint name field format is invalid. (and 2 more errors)", + "reason": "validation_failed", + "errors": { + "endpoint_name": [ + "The endpoint name field format is invalid." + ], + "description": [ + "The description field must be a string." + ], + "metadata": [ + "The metadata field must be an array." + ] + }, + "validation_errors": { + "endpoint_name": [ + "The endpoint name field format is invalid." + ], + "description": [ + "The description field must be a string." + ], + "metadata": [ + "The metadata field must be an array." + ] + } + } + ], + "endpoint missing name": [ + 422, + { + "message": "The endpoint name field is required.", + "reason": "validation_failed", + "errors": { + "endpoint_name": [ + "The endpoint name field is required." + ] + }, + "validation_errors": { + "endpoint_name": [ + "The endpoint name field is required." + ] + } + } + ], + "endpoint number name": [ + 422, + { + "message": "The endpoint name field must be a string.", + "reason": "validation_failed", + "errors": { + "endpoint_name": [ + "The endpoint name field must be a string." + ] + }, + "validation_errors": { + "endpoint_name": [ + "The endpoint name field must be a string." + ] + } + } + ], + "endpoint list name": [ + 422, + { + "message": "The endpoint name field must be a string. (and 1 more error)", + "reason": "validation_failed", + "errors": { + "endpoint_name": [ + "The endpoint name field must be a string.", + "The endpoint name field format is invalid." + ] + }, + "validation_errors": { + "endpoint_name": [ + "The endpoint name field must be a string.", + "The endpoint name field format is invalid." + ] + } + } + ], + "endpoint long name": [ + 422, + { + "message": "The endpoint name field must not be greater than 191 characters.", + "reason": "validation_failed", + "errors": { + "endpoint_name": [ + "The endpoint name field must not be greater than 191 characters." + ] + }, + "validation_errors": { + "endpoint_name": [ + "The endpoint name field must not be greater than 191 characters." + ] + } + } + ], + "endpoint long description": [ + 422, + { + "message": "The description field must not be greater than 1000 characters.", + "reason": "validation_failed", + "errors": { + "description": [ + "The description field must not be greater than 1000 characters." + ] + }, + "validation_errors": { + "description": [ + "The description field must not be greater than 1000 characters." + ] + } + } + ], + "endpoint second": [ + 201, + { + "id": "{id:endpoint second}", + "namespace": "@own", + "endpoint_name": "alpha", + "description": null, + "metadata": null, + "created_at": "{time:endpoint second created}", + "updated_at": "{time}" + } + ], + "endpoints list": [ + 200, + { + "service_endpoints": [ + { + "id": "{id:endpoint second}", + "namespace": "@own", + "endpoint_name": "alpha", + "description": null, + "metadata": null, + "created_at": "{time:endpoint second created}", + "updated_at": "{time}" + }, + { + "id": "{id:endpoint create}", + "namespace": "@own", + "endpoint_name": "billing", + "description": "Payments λ", + "metadata": { + "team": "core", + "empty": [], + "list": [ + 1, + 2 + ], + "indexed": [ + "a", + "b" + ], + "ratio": 1, + "note": "padded" + }, + "created_at": "{time:endpoint create created}", + "updated_at": "{time}" + } + ] + } + ], + "endpoint show": [ + 200, + { + "id": "{id:endpoint create}", + "namespace": "@own", + "endpoint_name": "billing", + "description": "Payments λ", + "metadata": { + "team": "core", + "empty": [], + "list": [ + 1, + 2 + ], + "indexed": [ + "a", + "b" + ], + "ratio": 1, + "note": "padded" + }, + "created_at": "{time:endpoint create created}", + "updated_at": "{time:endpoint create updated}" + } + ], + "endpoint missing": [ + 404, + { + "accepted": false, + "message": "Service endpoint [missing] not found in namespace [@own].", + "reason": "endpoint_not_found", + "namespace": "@own", + "endpoint_name": "missing" + } + ], + "endpoint update unchanged": [ + 200, + { + "id": "{id:endpoint create}", + "namespace": "@own", + "endpoint_name": "billing", + "description": "Payments λ", + "metadata": { + "team": "core", + "empty": [], + "list": [ + 1, + 2 + ], + "indexed": [ + "a", + "b" + ], + "ratio": 1, + "note": "padded" + }, + "created_at": "{time:endpoint create created}", + "updated_at": "{time:endpoint create updated}" + } + ], + "endpoint update": [ + 200, + { + "id": "{id:endpoint create}", + "namespace": "@own", + "endpoint_name": "billing", + "description": null, + "metadata": { + "team": "edge" + }, + "created_at": "{time:endpoint create created}", + "updated_at": "{time}" + } + ], + "endpoint update invalid": [ + 422, + { + "message": "The metadata field must be an array.", + "reason": "validation_failed", + "errors": { + "metadata": [ + "The metadata field must be an array." + ] + }, + "validation_errors": { + "metadata": [ + "The metadata field must be an array." + ] + } + } + ], + "endpoint update missing": [ + 404, + { + "accepted": false, + "message": "Service endpoint [nope] not found in namespace [@own].", + "reason": "endpoint_not_found", + "namespace": "@own", + "endpoint_name": "nope" + } + ], + "service create": [ + 201, + { + "id": "{id:service create}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_name": "invoices", + "description": "Invoice service", + "metadata": { + "tier": 1 + }, + "created_at": "{time:service create created}", + "updated_at": "{time}" + } + ], + "service second": [ + 201, + { + "id": "{id:service second}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_name": "accounts", + "description": null, + "metadata": null, + "created_at": "{time:service second created}", + "updated_at": "{time}" + } + ], + "service duplicate": [ + 409, + { + "message": "A service [invoices] already exists under endpoint [billing] in namespace [@own].", + "reason": "service_already_exists", + "namespace": "@own", + "endpoint_name": "billing", + "service_name": "invoices", + "service_id": "{id:service create}" + } + ], + "service invalid": [ + 422, + { + "message": "The service name field is required.", + "reason": "validation_failed", + "errors": { + "service_name": [ + "The service name field is required." + ] + }, + "validation_errors": { + "service_name": [ + "The service name field is required." + ] + } + } + ], + "service missing endpoint": [ + 404, + { + "accepted": false, + "message": "Service endpoint [nope] not found in namespace [@own].", + "reason": "endpoint_not_found", + "namespace": "@own", + "endpoint_name": "nope" + } + ], + "services list": [ + 200, + { + "services": [ + { + "id": "{id:service create}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_name": "invoices", + "description": "Invoice service", + "metadata": { + "tier": 1 + }, + "created_at": "{time:service create created}", + "updated_at": "{time}" + }, + { + "id": "{id:service second}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_name": "accounts", + "description": null, + "metadata": null, + "created_at": "{time:service second created}", + "updated_at": "{time}" + } + ] + } + ], + "services missing endpoint": [ + 404, + { + "accepted": false, + "message": "Service endpoint [nope] not found in namespace [@own].", + "reason": "endpoint_not_found", + "namespace": "@own", + "endpoint_name": "nope" + } + ], + "service show": [ + 200, + { + "id": "{id:service create}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_name": "invoices", + "description": "Invoice service", + "metadata": { + "tier": 1 + }, + "created_at": "{time:service create created}", + "updated_at": "{time}" + } + ], + "service missing": [ + 404, + { + "message": "Service [nope] not found under endpoint [billing] in namespace [@own].", + "reason": "service_not_found", + "namespace": "@own", + "endpoint_name": "billing", + "service_name": "nope" + } + ], + "service update": [ + 200, + { + "id": "{id:service create}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_name": "invoices", + "description": "Invoices", + "metadata": null, + "created_at": "{time:service create created}", + "updated_at": "{time}" + } + ], + "operation create": [ + 201, + { + "id": "{id:operation create}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_id": "{id:service create}", + "service_name": "invoices", + "operation_name": "charge", + "description": null, + "operation_mode": "sync", + "handler_binding_kind": "start_workflow", + "handler_target_reference": "billing.charge", + "handler_binding": null, + "deadline_policy": { + "timeout_seconds": 30 + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": { + "max_attempts": 3 + }, + "boundary_policy": null, + "metadata": null, + "created_at": "{time:operation create created}", + "updated_at": "{time}" + } + ], + "operation duplicate": [ + 409, + { + "message": "An operation [charge] already exists under service [invoices] at endpoint [billing] in namespace [@own].", + "reason": "operation_already_exists", + "namespace": "@own", + "endpoint_name": "billing", + "service_name": "invoices", + "operation_name": "charge", + "operation_id": "{id:operation create}" + } + ], + "operation missing fields": [ + 422, + { + "message": "The operation mode field is required. (and 2 more errors)", + "reason": "validation_failed", + "errors": { + "operation_mode": [ + "The operation mode field is required." + ], + "handler_binding_kind": [ + "The handler binding kind field is required." + ], + "operation_name": [ + "The operation name field is required." + ] + }, + "validation_errors": { + "operation_mode": [ + "The operation mode field is required." + ], + "handler_binding_kind": [ + "The handler binding kind field is required." + ], + "operation_name": [ + "The operation name field is required." + ] + } + } + ], + "operation unbound": [ + 422, + { + "message": "Provide handler_target_reference or a non-empty handler_binding.", + "reason": "validation_failed", + "errors": { + "handler_target_reference": [ + "Provide handler_target_reference or a non-empty handler_binding." + ] + }, + "validation_errors": { + "handler_target_reference": [ + "Provide handler_target_reference or a non-empty handler_binding." + ] + } + } + ], + "operation empty binding": [ + 422, + { + "message": "Provide handler_target_reference or a non-empty handler_binding.", + "reason": "validation_failed", + "errors": { + "handler_target_reference": [ + "Provide handler_target_reference or a non-empty handler_binding." + ] + }, + "validation_errors": { + "handler_target_reference": [ + "Provide handler_target_reference or a non-empty handler_binding." + ] + } + } + ], + "operation bad vocabulary": [ + 422, + { + "message": "The selected operation mode is invalid. (and 3 more errors)", + "reason": "validation_failed", + "errors": { + "operation_mode": [ + "The selected operation mode is invalid." + ], + "handler_binding_kind": [ + "The handler binding kind field must be a string.", + "The selected handler binding kind is invalid." + ], + "handler_target_reference": [ + "The handler target reference field must not be greater than 191 characters." + ] + }, + "validation_errors": { + "operation_mode": [ + "The selected operation mode is invalid." + ], + "handler_binding_kind": [ + "The handler binding kind field must be a string.", + "The selected handler binding kind is invalid." + ], + "handler_target_reference": [ + "The handler target reference field must not be greater than 191 characters." + ] + } + } + ], + "operation deadline keys": [ + 422, + { + "message": "Service deadline_policy must contain exactly timeout_seconds or deadline_at.", + "reason": "validation_failed", + "errors": { + "deadline_policy": [ + "Service deadline_policy must contain exactly timeout_seconds or deadline_at." + ] + }, + "validation_errors": { + "deadline_policy": [ + "Service deadline_policy must contain exactly timeout_seconds or deadline_at." + ] + } + } + ], + "operation deadline both": [ + 422, + { + "message": "Service deadline_policy must contain exactly timeout_seconds or deadline_at.", + "reason": "validation_failed", + "errors": { + "deadline_policy": [ + "Service deadline_policy must contain exactly timeout_seconds or deadline_at." + ] + }, + "validation_errors": { + "deadline_policy": [ + "Service deadline_policy must contain exactly timeout_seconds or deadline_at." + ] + } + } + ], + "operation deadline zero": [ + 422, + { + "message": "Service timeout_seconds must be an integer from 1 to 2147483647.", + "reason": "validation_failed", + "errors": { + "deadline_policy": [ + "Service timeout_seconds must be an integer from 1 to 2147483647." + ] + }, + "validation_errors": { + "deadline_policy": [ + "Service timeout_seconds must be an integer from 1 to 2147483647." + ] + } + } + ], + "operation deadline float": [ + 422, + { + "message": "Service timeout_seconds must be an integer from 1 to 2147483647.", + "reason": "validation_failed", + "errors": { + "deadline_policy": [ + "Service timeout_seconds must be an integer from 1 to 2147483647." + ] + }, + "validation_errors": { + "deadline_policy": [ + "Service timeout_seconds must be an integer from 1 to 2147483647." + ] + } + } + ], + "operation deadline shape": [ + 422, + { + "message": "Service deadline_at must be offset-bearing RFC3339 with at most six fractional digits.", + "reason": "validation_failed", + "errors": { + "deadline_policy": [ + "Service deadline_at must be offset-bearing RFC3339 with at most six fractional digits." + ] + }, + "validation_errors": { + "deadline_policy": [ + "Service deadline_at must be offset-bearing RFC3339 with at most six fractional digits." + ] + } + } + ], + "operation deadline digits": [ + 422, + { + "message": "Service deadline_at must be offset-bearing RFC3339 with at most six fractional digits.", + "reason": "validation_failed", + "errors": { + "deadline_policy": [ + "Service deadline_at must be offset-bearing RFC3339 with at most six fractional digits." + ] + }, + "validation_errors": { + "deadline_policy": [ + "Service deadline_at must be offset-bearing RFC3339 with at most six fractional digits." + ] + } + } + ], + "operation deadline calendar": [ + 422, + { + "message": "Service deadline_at must be a valid calendar instant.", + "reason": "validation_failed", + "errors": { + "deadline_policy": [ + "Service deadline_at must be a valid calendar instant." + ] + }, + "validation_errors": { + "deadline_policy": [ + "Service deadline_at must be a valid calendar instant." + ] + } + } + ], + "operation deadline hour": [ + 422, + { + "message": "Service deadline_at must be a valid calendar instant.", + "reason": "validation_failed", + "errors": { + "deadline_policy": [ + "Service deadline_at must be a valid calendar instant." + ] + }, + "validation_errors": { + "deadline_policy": [ + "Service deadline_at must be a valid calendar instant." + ] + } + } + ], + "operation deadline years": [ + 422, + { + "message": "Service call deadlines must resolve within UTC years 1970 through 9999.", + "reason": "validation_failed", + "errors": { + "deadline_policy": [ + "Service call deadlines must resolve within UTC years 1970 through 9999." + ] + }, + "validation_errors": { + "deadline_policy": [ + "Service call deadlines must resolve within UTC years 1970 through 9999." + ] + } + } + ], + "operation second": [ + 201, + { + "id": "{id:operation second}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_id": "{id:service create}", + "service_name": "invoices", + "operation_name": "refund", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": null, + "handler_binding": { + "workflow_type": "billing.refund" + }, + "deadline_policy": { + "deadline_at": "2030-01-01T00:00:00.5+05:30" + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time:operation second created}", + "updated_at": "{time}" + } + ], + "operations list": [ + 200, + { + "operations": [ + { + "id": "{id:operation create}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_id": "{id:service create}", + "service_name": "invoices", + "operation_name": "charge", + "description": null, + "operation_mode": "sync", + "handler_binding_kind": "start_workflow", + "handler_target_reference": "billing.charge", + "handler_binding": null, + "deadline_policy": { + "timeout_seconds": 30 + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": { + "max_attempts": 3 + }, + "boundary_policy": null, + "metadata": null, + "created_at": "{time:operation create created}", + "updated_at": "{time}" + }, + { + "id": "{id:operation second}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_id": "{id:service create}", + "service_name": "invoices", + "operation_name": "refund", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": null, + "handler_binding": { + "workflow_type": "billing.refund" + }, + "deadline_policy": { + "deadline_at": "2030-01-01T00:00:00.5+05:30" + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": null, + "boundary_policy": null, + "metadata": null, + "created_at": "{time:operation second created}", + "updated_at": "{time}" + } + ] + } + ], + "operation show": [ + 200, + { + "id": "{id:operation create}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_id": "{id:service create}", + "service_name": "invoices", + "operation_name": "charge", + "description": null, + "operation_mode": "sync", + "handler_binding_kind": "start_workflow", + "handler_target_reference": "billing.charge", + "handler_binding": null, + "deadline_policy": { + "timeout_seconds": 30 + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": { + "max_attempts": 3 + }, + "boundary_policy": null, + "metadata": null, + "created_at": "{time:operation create created}", + "updated_at": "{time}" + } + ], + "operation missing": [ + 404, + { + "message": "Operation [nope] not found under service [invoices] at endpoint [billing] in namespace [@own].", + "reason": "operation_not_found", + "namespace": "@own", + "endpoint_name": "billing", + "service_name": "invoices", + "operation_name": "nope" + } + ], + "operation missing service": [ + 404, + { + "message": "Service [nope] not found under endpoint [billing] in namespace [@own].", + "reason": "service_not_found", + "namespace": "@own", + "endpoint_name": "billing", + "service_name": "nope" + } + ], + "operation rename": [ + 422, + { + "message": "The operation name field is prohibited.", + "reason": "validation_failed", + "errors": { + "operation_name": [ + "The operation name field is prohibited." + ] + }, + "validation_errors": { + "operation_name": [ + "The operation name field is prohibited." + ] + } + } + ], + "operation rename empty": [ + 200, + { + "id": "{id:operation create}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_id": "{id:service create}", + "service_name": "invoices", + "operation_name": "charge", + "description": "Charges", + "operation_mode": "sync", + "handler_binding_kind": "start_workflow", + "handler_target_reference": "billing.charge", + "handler_binding": null, + "deadline_policy": { + "timeout_seconds": 30 + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": { + "max_attempts": 3 + }, + "boundary_policy": null, + "metadata": null, + "created_at": "{time:operation create created}", + "updated_at": "{time}" + } + ], + "operation clear target": [ + 422, + { + "message": "Provide handler_target_reference or a non-empty handler_binding.", + "reason": "validation_failed", + "errors": { + "handler_target_reference": [ + "Provide handler_target_reference or a non-empty handler_binding." + ] + }, + "validation_errors": { + "handler_target_reference": [ + "Provide handler_target_reference or a non-empty handler_binding." + ] + } + } + ], + "operation rebind": [ + 200, + { + "id": "{id:operation create}", + "namespace": "@own", + "endpoint_id": "{id:endpoint create}", + "endpoint_name": "billing", + "service_id": "{id:service create}", + "service_name": "invoices", + "operation_name": "charge", + "description": "Charges", + "operation_mode": "async", + "handler_binding_kind": "start_workflow", + "handler_target_reference": null, + "handler_binding": { + "workflow_type": "billing.charge" + }, + "deadline_policy": { + "timeout_seconds": 30 + }, + "idempotency_policy": null, + "cancellation_policy": null, + "retry_policy": { + "max_attempts": 3 + }, + "boundary_policy": null, + "metadata": null, + "created_at": "{time:operation create created}", + "updated_at": "{time}" + } + ], + "operation unbind": [ + 422, + { + "message": "Provide handler_target_reference or a non-empty handler_binding.", + "reason": "validation_failed", + "errors": { + "handler_target_reference": [ + "Provide handler_target_reference or a non-empty handler_binding." + ] + }, + "validation_errors": { + "handler_target_reference": [ + "Provide handler_target_reference or a non-empty handler_binding." + ] + } + } + ], + "operation update deadline": [ + 422, + { + "message": "Service timeout_seconds must be an integer from 1 to 2147483647.", + "reason": "validation_failed", + "errors": { + "deadline_policy": [ + "Service timeout_seconds must be an integer from 1 to 2147483647." + ] + }, + "validation_errors": { + "deadline_policy": [ + "Service timeout_seconds must be an integer from 1 to 2147483647." + ] + } + } + ], + "operation update mode": [ + 422, + { + "message": "The operation mode field must be a string. (and 1 more error)", + "reason": "validation_failed", + "errors": { + "operation_mode": [ + "The operation mode field must be a string.", + "The selected operation mode is invalid." + ] + }, + "validation_errors": { + "operation_mode": [ + "The operation mode field must be a string.", + "The selected operation mode is invalid." + ] + } + } + ], + "read endpoints": [ + 200, + { + "data": [ + { + "id": "{id:endpoint second}", + "namespace": "@own", + "endpoint_name": "alpha", + "description": null, + "metadata": [], + "created_at": "{time:endpoint second created}", + "updated_at": "{time}" + }, + { + "id": "{id:endpoint create}", + "namespace": "@own", + "endpoint_name": "billing", + "description": null, + "metadata": { + "team": "edge" + }, + "created_at": "{time:endpoint create created}", + "updated_at": "{time}" + } + ], + "current_page": 1, + "per_page": 50, + "total": 2, + "last_page": 1, + "namespace": "@own" + } + ], + "read endpoints page": [ + 200, + { + "data": [ + { + "id": "{id:endpoint create}", + "namespace": "@own", + "endpoint_name": "billing", + "description": null, + "metadata": { + "team": "edge" + }, + "created_at": "{time:endpoint create created}", + "updated_at": "{time}" + } + ], + "current_page": 2, + "per_page": 1, + "total": 2, + "last_page": 2, + "namespace": "@own" + } + ], + "read services of endpoint": [ + 200, + { + "data": [ + { + "id": "{id:service second}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "namespace": "@own", + "service_name": "accounts", + "description": null, + "metadata": [], + "created_at": "{time:service second created}", + "updated_at": "{time}" + }, + { + "id": "{id:service create}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "namespace": "@own", + "service_name": "invoices", + "description": "Invoices", + "metadata": [], + "created_at": "{time:service create created}", + "updated_at": "{time}" + } + ], + "current_page": 1, + "per_page": 50, + "total": 2, + "last_page": 1, + "namespace": "@own" + } + ], + "read operations of service": [ + 200, + { + "data": [ + { + "id": "{id:operation create}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "workflow_service_id": "{id:service create}", + "namespace": "@own", + "operation_name": "charge", + "description": "Charges", + "operation_mode": "async", + "handler_binding_kind": "start_workflow", + "handler_target_reference": null, + "handler_binding": { + "workflow_type": "billing.charge" + }, + "deadline_policy": { + "timeout_seconds": 30 + }, + "idempotency_policy": [], + "cancellation_policy": [], + "retry_policy": { + "max_attempts": 3 + }, + "boundary_policy": [], + "metadata": [], + "created_at": "{time:operation create created}", + "updated_at": "{time}" + }, + { + "id": "{id:operation second}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "workflow_service_id": "{id:service create}", + "namespace": "@own", + "operation_name": "refund", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": null, + "handler_binding": { + "workflow_type": "billing.refund" + }, + "deadline_policy": { + "deadline_at": "2030-01-01T00:00:00.5+05:30" + }, + "idempotency_policy": [], + "cancellation_policy": [], + "retry_policy": [], + "boundary_policy": [], + "metadata": [], + "created_at": "{time:operation second created}", + "updated_at": "{time}" + } + ], + "current_page": 1, + "per_page": 50, + "total": 2, + "last_page": 1, + "namespace": "@own" + } + ], + "read endpoint": [ + 200, + { + "id": "{id:endpoint create}", + "namespace": "@own", + "endpoint_name": "billing", + "description": null, + "metadata": { + "team": "edge" + }, + "created_at": "{time:endpoint create created}", + "updated_at": "{time}", + "services": [ + { + "id": "{id:service second}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "namespace": "@own", + "service_name": "accounts", + "description": null, + "metadata": [], + "created_at": "{time:service second created}", + "updated_at": "{time}" + }, + { + "id": "{id:service create}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "namespace": "@own", + "service_name": "invoices", + "description": "Invoices", + "metadata": [], + "created_at": "{time:service create created}", + "updated_at": "{time}" + } + ], + "operations": [ + { + "id": "{id:operation create}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "workflow_service_id": "{id:service create}", + "namespace": "@own", + "operation_name": "charge", + "description": "Charges", + "operation_mode": "async", + "handler_binding_kind": "start_workflow", + "handler_target_reference": null, + "handler_binding": { + "workflow_type": "billing.charge" + }, + "deadline_policy": { + "timeout_seconds": 30 + }, + "idempotency_policy": [], + "cancellation_policy": [], + "retry_policy": { + "max_attempts": 3 + }, + "boundary_policy": [], + "metadata": [], + "created_at": "{time:operation create created}", + "updated_at": "{time}" + }, + { + "id": "{id:operation second}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "workflow_service_id": "{id:service create}", + "namespace": "@own", + "operation_name": "refund", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": null, + "handler_binding": { + "workflow_type": "billing.refund" + }, + "deadline_policy": { + "deadline_at": "2030-01-01T00:00:00.5+05:30" + }, + "idempotency_policy": [], + "cancellation_policy": [], + "retry_policy": [], + "boundary_policy": [], + "metadata": [], + "created_at": "{time:operation second created}", + "updated_at": "{time}" + } + ], + "related_has_more": { + "services": false, + "operations": false + } + } + ], + "read service": [ + 200, + { + "id": "{id:service create}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "namespace": "@own", + "service_name": "invoices", + "description": "Invoices", + "metadata": [], + "created_at": "{time:service create created}", + "updated_at": "{time}", + "endpoint": { + "id": "{id:endpoint create}", + "namespace": "@own", + "endpoint_name": "billing", + "description": null, + "metadata": { + "team": "edge" + }, + "created_at": "{time:endpoint create created}", + "updated_at": "{time}" + }, + "operations": [ + { + "id": "{id:operation create}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "workflow_service_id": "{id:service create}", + "namespace": "@own", + "operation_name": "charge", + "description": "Charges", + "operation_mode": "async", + "handler_binding_kind": "start_workflow", + "handler_target_reference": null, + "handler_binding": { + "workflow_type": "billing.charge" + }, + "deadline_policy": { + "timeout_seconds": 30 + }, + "idempotency_policy": [], + "cancellation_policy": [], + "retry_policy": { + "max_attempts": 3 + }, + "boundary_policy": [], + "metadata": [], + "created_at": "{time:operation create created}", + "updated_at": "{time}" + }, + { + "id": "{id:operation second}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "workflow_service_id": "{id:service create}", + "namespace": "@own", + "operation_name": "refund", + "description": null, + "operation_mode": "async", + "handler_binding_kind": "signal_workflow", + "handler_target_reference": null, + "handler_binding": { + "workflow_type": "billing.refund" + }, + "deadline_policy": { + "deadline_at": "2030-01-01T00:00:00.5+05:30" + }, + "idempotency_policy": [], + "cancellation_policy": [], + "retry_policy": [], + "boundary_policy": [], + "metadata": [], + "created_at": "{time:operation second created}", + "updated_at": "{time}" + } + ], + "related_has_more": { + "operations": false + } + } + ], + "read operation": [ + 200, + { + "id": "{id:operation create}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "workflow_service_id": "{id:service create}", + "namespace": "@own", + "operation_name": "charge", + "description": "Charges", + "operation_mode": "async", + "handler_binding_kind": "start_workflow", + "handler_target_reference": null, + "handler_binding": { + "workflow_type": "billing.charge" + }, + "deadline_policy": { + "timeout_seconds": 30 + }, + "idempotency_policy": [], + "cancellation_policy": [], + "retry_policy": { + "max_attempts": 3 + }, + "boundary_policy": [], + "metadata": [], + "created_at": "{time:operation create created}", + "updated_at": "{time}", + "endpoint": { + "id": "{id:endpoint create}", + "namespace": "@own", + "endpoint_name": "billing", + "description": null, + "metadata": { + "team": "edge" + }, + "created_at": "{time:endpoint create created}", + "updated_at": "{time}" + }, + "service": { + "id": "{id:service create}", + "workflow_service_endpoint_id": "{id:endpoint create}", + "namespace": "@own", + "service_name": "invoices", + "description": "Invoices", + "metadata": [], + "created_at": "{time:service create created}", + "updated_at": "{time}" + } + } + ], + "read endpoint elsewhere": [ + 404, + { + "message": "Catalog object not found.", + "reason": "catalog_object_not_found" + } + ], + "endpoint delete with services": [ + 409, + { + "message": "Service endpoint [billing] in namespace [@own] still has registered services.", + "reason": "endpoint_has_services", + "namespace": "@own", + "endpoint_name": "billing" + } + ], + "service delete with operations": [ + 409, + { + "message": "Service [invoices] under endpoint [billing] in namespace [@own] still has registered operations.", + "reason": "service_has_operations", + "namespace": "@own", + "endpoint_name": "billing", + "service_name": "invoices" + } + ], + "operation delete": [ + 200, + { + "namespace": "@own", + "endpoint_name": "billing", + "service_name": "invoices", + "operation_name": "charge", + "outcome": "deleted" + } + ], + "operation delete again": [ + 404, + { + "message": "Operation [charge] not found under service [invoices] at endpoint [billing] in namespace [@own].", + "reason": "operation_not_found", + "namespace": "@own", + "endpoint_name": "billing", + "service_name": "invoices", + "operation_name": "charge" + } + ], + "operation delete second": [ + 200, + { + "namespace": "@own", + "endpoint_name": "billing", + "service_name": "invoices", + "operation_name": "refund", + "outcome": "deleted" + } + ], + "service delete": [ + 200, + { + "namespace": "@own", + "endpoint_name": "billing", + "service_name": "invoices", + "outcome": "deleted" + } + ], + "service delete second": [ + 200, + { + "namespace": "@own", + "endpoint_name": "billing", + "service_name": "accounts", + "outcome": "deleted" + } + ], + "endpoint delete": [ + 200, + { + "namespace": "@own", + "endpoint_name": "billing", + "outcome": "deleted" + } + ], + "endpoint delete second": [ + 200, + { + "namespace": "@own", + "endpoint_name": "alpha", + "outcome": "deleted" + } + ], + "endpoints after": [ + 200, + { + "service_endpoints": [] + } + ] + } + } +} diff --git a/tests/Unit/ServerParityServiceCallsContractTest.mjs b/tests/Unit/ServerParityServiceCallsContractTest.mjs new file mode 100644 index 000000000..562f2980f --- /dev/null +++ b/tests/Unit/ServerParityServiceCallsContractTest.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import test from 'node:test'; +import {checkServiceCalls} from '../../scripts/conformance/server-parity/service-calls-contract.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/service-calls.json', import.meta.url))); +const id = 'parity-v1-service-calls'; +const own = `${id}-calls`; + +// The recorded answers with each placeholder given a concrete value: one per +// name, a fresh one for each unnamed placeholder. +const observation = () => { + const values = new Map(); + let fresh = 0; + const generate = kind => { + const index = String(fresh++).padStart(4, '0'); + return { + id: `01m5wvy8d1j3dtr13es05k${index}`, + ULID: `01M5WVY8D1J3DTR13ES05K${index}`, + time: `2026-10-26T19:${index.slice(2)}:00+00:00`, + 'µs': `2026-10-26T19:${index.slice(2)}:00.123456Z`, + }[kind]; + }; + const concrete = value => { + if (typeof value === 'string') { + return value.split('@own').join(own).replace(/\{(id|ULID|time|µs)(?::([^{}]+))?\}/gu, (_, kind, name) => { + if (name === undefined) { + return generate(kind); + } + const key = `${kind}:${name}`; + if (!values.has(key)) { + values.set(key, generate(kind)); + } + return values.get(key); + }); + } + if (Array.isArray(value)) { + return value.map(concrete); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, concrete(item)])); + } + return value; + }; + const responses = {}; + for (const {label} of fixture.service_calls.requests) { + responses[label] = concrete(fixture.service_calls.expected[label]); + } + return {mode: 'http', service_calls: {applicable: true, namespace: own, responses}}; +}; + +test('the service calls accept the published answers', () => { + assert.deepStrictEqual(checkServiceCalls(fixture, observation(), id), {scope: 'service_calls', cases: 91}); +}); + +test('the fixture covers execute, show, cancel and every reachable refusal', () => { + const {expected} = fixture.service_calls; + const statuses = new Set(Object.values(expected).map(([status]) => status)); + assert.deepStrictEqual([...statuses].sort(), [200, 201, 403, 404, 409, 422, 429, 503]); + assert.equal(expected['expired'][1].call_deadline_at, '2001-02-03T03:05:06.500000Z'); + assert.equal(expected['broken retry'][1].service_call_attempts.length, 3); + assert.equal(expected['replay again'][1].idempotent_replay, true); + assert.deepStrictEqual([expected['rated again'][1].reason, expected['held again'][1].reason, expected['held sync again'][0]], + ['rate_limit_exceeded', 'concurrency_limit_exceeded', 200]); + assert.equal(fixture.service_calls.requests.find(request => request.label === 'rated').minute_start, true); + assert.deepStrictEqual([expected['catalog calls of charge'][1].total, expected['catalog policy calls page'][1].total, + expected['catalog show failed call'][1].failure_type], [3, 8, 'handler_target_missing']); +}); + +test('the embedded service calls are explicitly inapplicable', () => { + checkServiceCalls(fixture, {mode: 'embedded', service_calls: {applicable: false, reason: 'embedded_has_no_http_service_calls'}}, id); + assert.throws(() => checkServiceCalls(fixture, {mode: 'embedded', service_calls: {applicable: true}}, id)); +}); + +for (const [label, mutate] of [ + ['a route that is not implemented', responses => { responses.charge = [501, {reason: 'rust_capability_not_implemented'}]; }], + ['a lowercase handler id', responses => { responses.charge[1].resolved_target_reference = responses.charge[1].resolved_target_reference.toLowerCase(); }], + ['a whole-second lifecycle time', responses => { responses.charge[1].accepted_at = '2026-10-26T19:00:00+00:00'; }], + ['an unredacted forbidden answer', responses => { responses['need role'][1].message = 'Caller [legacy-token] is missing one of the required roles for service-boundary admission.'; }], + ['a redacted audit row', responses => { responses['show need role'][1].outcome_metadata = {failure_reason: 'policy_rejection'}; }], + ['a 202 for an accepted call', responses => { responses.charge[0] = 202; }], + ['a rejected past deadline', responses => { responses.expired[1].accepted = false; }], + ['an idempotent cancel', responses => { responses['cancel charge again'] = [200, responses['cancel charge'][1]]; }], + ['a replay in the controller shape', responses => { responses['replay again'][1].id = responses['replay again'][1].service_call_id; }], + ['a replayed forbidden call that reuses the id', responses => { + responses['replay forbidden'][1].service_call_id = responses['replay second key'][1].id; + }], + ['a rate limit that never refuses', responses => { responses['rated again'] = [200, responses.rated[1]]; }], + ['a sync lease that is never returned', responses => { responses['held sync again'] = [429, responses['held again'][1]]; }], + ['a refusal that counts the refused call', responses => { responses['held again'][1].outcome_metadata.observed_in_flight = 2; }], + ['a calls list that is always empty', responses => { + Object.assign(responses['catalog calls of charge'][1], {data: [], total: 0, last_page: 1}); + }], + ['calls listed oldest first', responses => { responses['catalog calls of charge'][1].data.reverse(); }], + ['a caller scope that keeps another caller', responses => { + responses['catalog calls of charge as caller'][1].data.push(responses['catalog calls of charge'][1].data[1]); + }], + ['a whole-second list time', responses => { responses['catalog cancelled calls'][1].data[0].created_at = '2026-10-26T19:00:00+00:00'; }], + ['a caller link read as the observer', responses => { + responses['catalog show call with caller'][1].caller_link.in_observer_namespace = true; + }], + ['a message naming another call', responses => { + responses['show under another operation'][1].message = responses['show under another operation'][1].message.replace(/\[01[^\]]+\]/u, '[01m5wvy8d1j3dtr13es05k9999]'); + }], +]) { + test(`the service calls reject ${label}`, () => { + const value = observation(); + mutate(value.service_calls.responses); + assert.throws(() => checkServiceCalls(fixture, value, id)); + }); +} diff --git a/tests/Unit/ServerParityServiceEndpointsContractTest.mjs b/tests/Unit/ServerParityServiceEndpointsContractTest.mjs new file mode 100644 index 000000000..04d9a20e8 --- /dev/null +++ b/tests/Unit/ServerParityServiceEndpointsContractTest.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import test from 'node:test'; +import {checkServiceEndpoints} from '../../scripts/conformance/server-parity/service-endpoints-contract.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/service-endpoints.json', import.meta.url))); +const id = 'parity-v1-service-endpoints'; +const own = `${id}-endpoints`; + +// The recorded answers with each placeholder given a concrete value: one +// ULID per id name and one time per time name. +const observation = () => { + const values = new Map(); + const concrete = value => { + if (typeof value === 'string') { + const placeholder = /^\{(id|time)(?::(.+))?\}$/.exec(value); + if (!placeholder) { + return value.split('@own').join(own); + } + const key = `${placeholder[1]}:${placeholder[2] ?? ''}`; + if (!values.has(key) || placeholder[2] === undefined) { + const index = String(values.size).padStart(4, '0'); + values.set(key, placeholder[1] === 'id' ? `01m5wvy8d1j3dtr13es05k${index}` : `2026-10-26T19:${index.slice(2)}:00+00:00`); + } + return values.get(key); + } + if (Array.isArray(value)) { + return value.map(concrete); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, concrete(item)])); + } + return value; + }; + const responses = {}; + for (const {label} of fixture.service_endpoints.requests) { + responses[label] = concrete(fixture.service_endpoints.expected[label]); + } + return {mode: 'http', service_endpoints: {applicable: true, namespace: own, responses}}; +}; + +test('the service endpoints accept the published answers', () => { + assert.deepStrictEqual(checkServiceEndpoints(fixture, observation(), id), {scope: 'service_endpoints', cases: 72}); +}); + +test('the fixture covers every administration route and refusal', () => { + const {requests, expected} = fixture.service_endpoints; + const routes = new Set(requests.filter(request => request.path.startsWith('service-endpoints')) + .map(request => `${request.method} ${request.path.split('/').map((segment, index) => index % 2 === 1 ? '{}' : segment).join('/')}`)); + assert.equal(routes.size, 15, [...routes].join('\n')); + const statuses = new Set(Object.values(expected).map(([status]) => status)); + assert.deepStrictEqual([...statuses].sort(), [200, 201, 404, 409, 422]); + const services = expected['services list'][1].services.map(service => service.service_name); + assert.deepStrictEqual(services, ['invoices', 'accounts'], 'services list in creation order'); + assert.deepStrictEqual(expected['endpoint create'][1].metadata.indexed, ['a', 'b'], 'documents read back as PHP arrays'); +}); + +test('the embedded service endpoints are explicitly inapplicable', () => { + checkServiceEndpoints(fixture, {mode: 'embedded', service_endpoints: {applicable: false, reason: 'embedded_has_no_http_service_endpoints'}}, id); + assert.throws(() => checkServiceEndpoints(fixture, {mode: 'embedded', service_endpoints: {applicable: true}}, id)); +}); + +for (const [label, mutate] of [ + ['a missing response', responses => { delete responses['endpoint create']; }], + ['an extra response', responses => { responses.extra = [200, {}]; }], + ['a route that is not implemented', responses => { responses['endpoints empty'] = [501, {reason: 'rust_capability_not_implemented'}]; }], + ['an uppercase id', responses => { responses['endpoint create'][1].id = responses['endpoint create'][1].id.toUpperCase(); }], + ['an id that changes', responses => { responses['endpoint show'][1].id = responses['endpoint second'][1].id; }], + ['one id for two objects', responses => { responses['endpoint second'][1].id = responses['endpoint create'][1].id; }], + ['a fractional time', responses => { responses['endpoint create'][1].created_at = '2026-10-26T19:00:00.5+00:00'; }], + ['an unchanged update that moves updated_at', responses => { + responses['endpoint update unchanged'][1].updated_at = '2030-01-01T00:00:00+00:00'; + }], + ['an object metadata where PHP has a list', responses => { responses['endpoint create'][1].metadata.indexed = {0: 'a', 1: 'b'}; }], + ['services listed by name', responses => { responses['services list'][1].services.reverse(); }], + ['a missing accepted flag', responses => { delete responses['endpoint missing'][1].accepted; }], + ['an allowed rename', responses => { responses['operation rename'] = [200, {}]; }], + ['a lost read default', responses => { responses['read endpoints'][1].data[0].metadata = null; }], + ['a foreign identity found', responses => { responses['read endpoint elsewhere'] = [200, {}]; }], + ['a namespace that is not the fixture\'s', responses => { responses['endpoint missing'][1].namespace = 'default'; }], +]) { + test(`the service endpoints reject ${label}`, () => { + const value = observation(); + mutate(value.service_endpoints.responses); + assert.throws(() => checkServiceEndpoints(fixture, value, id)); + }); +}