From f1c7552c85293af033ca97dbf788f6b85181630c Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sun, 11 Oct 2026 20:10:39 +0000 Subject: [PATCH 1/5] Decode authorized catalog terminal output through bounded runtime transport --- src/Client.php | 10 ++- src/Transport/RuntimePayloads.php | 7 ++ tests/ServiceCatalogReadClientTest.php | 89 ++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 1b2ef0ad..27f55b0e 100644 --- a/src/Client.php +++ b/src/Client.php @@ -230,7 +230,15 @@ public function listServiceCatalogCalls(array $filters = []): array /** @return array */ public function describeServiceCatalogCall(string $callId): array { - return $this->serviceCatalogRead('/service-catalog/calls', id: $callId); + $response = $this->serviceCatalogRead('/service-catalog/calls', id: $callId); + if (($response['payloads_in_observer_namespace'] ?? null) === true) { + $response['output_available'] = is_array($response['output_envelope'] ?? null); + $response['output'] = $response['output_available'] + ? $this->decodedResponsePayload($response, 'output') + : null; + } + + return $response; } /** diff --git a/src/Transport/RuntimePayloads.php b/src/Transport/RuntimePayloads.php index d89917c1..04077681 100644 --- a/src/Transport/RuntimePayloads.php +++ b/src/Transport/RuntimePayloads.php @@ -40,6 +40,13 @@ public function response(array $response, string $requestPath, bool $worker): ar $paths = [...$paths, ...$this->historyPaths(['task', 'history_events']), ...$this->historyPaths(['history_events']), ...$this->exportPaths(['task', 'history_export'])]; } else { + if (preg_match('~\A/service-catalog/calls/[^/]+\z~', $path) + && ($response['payloads_in_observer_namespace'] ?? null) === true + && is_array($response['output_payload_reference'] ?? null)) { + // Keep the opaque reference while hydrating only the authorized + // terminal output of this exact call, never arbitrary metadata. + $response['output_envelope'] = $response['output_payload_reference']; + } foreach (['input_envelope', 'output_envelope', 'result_envelope'] as $field) { $paths[] = [$field]; } diff --git a/tests/ServiceCatalogReadClientTest.php b/tests/ServiceCatalogReadClientTest.php index 0ccf6f9c..d512680a 100644 --- a/tests/ServiceCatalogReadClientTest.php +++ b/tests/ServiceCatalogReadClientTest.php @@ -5,11 +5,19 @@ namespace DurableWorkflow\Tests; use DurableWorkflow\Client; +use DurableWorkflow\Codec\AvroPayloadCodec; +use DurableWorkflow\Exception\ExternalPayloadException; use DurableWorkflow\Exception\ServerException; use DurableWorkflow\Exception\TransportException; use DurableWorkflow\Tests\Support\FakeTransport; +use DurableWorkflow\Transport\Psr18Transport; +use DurableWorkflow\Transport\RuntimePayloads; +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Promise\Create; +use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; final class ServiceCatalogReadClientTest extends TestCase { @@ -118,4 +126,85 @@ private function discovery(): array { return ['service_catalog_read_contract' => ['schema' => 'durable-workflow.v2.service-catalog-read.contract', 'version' => 1]]; } + + #[DataProvider('outputs')] + public function testOwnedTerminalOutputIsDecodedWithoutReplacingTheOpaqueReference(mixed $value): void + { + $codec = new AvroPayloadCodec(); + $envelope = $codec->envelope($value); + $transport = new FakeTransport([$this->discovery(), [ + 'id' => 'call-1', 'payloads_in_observer_namespace' => true, + 'output_payload_reference' => $envelope, + ]]); + $result = (new Client('https://runtime.test', transport: $transport))->describeServiceCatalogCall('call-1'); + self::assertTrue($result['output_available']); + self::assertSame($envelope, $result['output_payload_reference']); + self::assertSame($codec->encode($value), $codec->encode($result['output'])); + } + + public static function outputs(): array + { + return [[null], [false], [0], [''], [['result', ['nested' => 'value']]]]; + } + + public function testExternalOutputUsesOnlyTheConfiguredRuntimeNamespaceAndOperatorCredential(): void + { + $codec = new AvroPayloadCodec(); + $value = ['result', str_repeat('x', 10000)]; + $blob = $codec->envelope($value)['blob']; + $reference = ['schema' => RuntimePayloads::SCHEMA, 'reference_id' => 'ep_01ARZ3NDEKTSV4RRFFQ69G5FAV', + 'codec' => 'avro', 'size_bytes' => strlen($blob), 'sha256' => hash('sha256', $blob)]; + $envelope = ['codec' => 'avro', 'external_payload' => $reference]; + $requests = []; + $http = new GuzzleClient(['handler' => function (RequestInterface $request, array $options) use (&$requests, $blob, $envelope) { + $requests[] = $request; + self::assertFalse($options['allow_redirects']); + if (count($requests) === 1) { + return Create::promiseFor(new Response(200, [], json_encode($this->discovery(), JSON_THROW_ON_ERROR))); + } + if (count($requests) === 2) { + return Create::promiseFor(new Response(200, [], json_encode([ + 'id' => 'call-1', 'payloads_in_observer_namespace' => true, 'output_payload_reference' => $envelope, + ], JSON_THROW_ON_ERROR))); + } + + return Create::promiseFor(new Response(200, [ + 'Content-Type' => 'application/octet-stream', 'X-Durable-Workflow-Payload-Codec' => 'avro', + 'X-Durable-Workflow-Payload-Size' => (string) strlen($blob), + 'X-Durable-Workflow-Payload-SHA256' => hash('sha256', $blob), + ], $blob)); + }]); + $client = new Client('https://runtime.test/api/runtime/v1/namespaces/owner-id', namespace: 'owner', + transport: new Psr18Transport($http), controlToken: 'operator', workerToken: 'worker'); + $result = $client->describeServiceCatalogCall('call-1'); + self::assertSame($codec->encode($value), $codec->encode($result['output'])); + self::assertSame($envelope, $result['output_payload_reference']); + self::assertCount(3, $requests); + self::assertSame('https://runtime.test/api/runtime/v1/namespaces/owner-id/api/external-payloads/v1/'.$reference['reference_id'], (string) $requests[2]->getUri()); + self::assertSame('owner', $requests[2]->getHeaderLine('X-Namespace')); + self::assertSame('Bearer operator', $requests[2]->getHeaderLine('Authorization')); + } + + public function testForeignOrUnverifiedOutputsAreNotFetchedAndOwnedDownloadsHonorTheByteLimit(): void + { + $blob = (new AvroPayloadCodec())->envelope(['private'])['blob']; + $reference = ['schema' => RuntimePayloads::SCHEMA, 'reference_id' => 'ep_01ARZ3NDEKTSV4RRFFQ69G5FAV', + 'codec' => 'avro', 'size_bytes' => strlen($blob), 'sha256' => hash('sha256', $blob)]; + foreach ([false, null, true] as $authorized) { + $transport = new FakeTransport([$this->discovery(), [ + 'payloads_in_observer_namespace' => $authorized, + 'output_payload_reference' => ['codec' => 'avro', 'external_payload' => $reference], + ]]); + $client = new Client('https://runtime.test', transport: $transport, maxExternalPayloadBytes: 1); + try { + $result = $client->describeServiceCatalogCall('call-1'); + self::assertNotTrue($authorized); + self::assertArrayNotHasKey('output', $result); + } catch (ExternalPayloadException $exception) { + self::assertTrue($authorized); + self::assertSame('external_payload_oversized', $exception->reason); + } + self::assertCount(2, $transport->requests); + } + } } From 99c37d923bb8f4b94d53df7af9e61b8049a5223a Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sun, 11 Oct 2026 20:14:20 +0000 Subject: [PATCH 2/5] Hydrate the declared opaque catalog reference shape --- src/Transport/RuntimePayloads.php | 2 +- tests/ServiceCatalogReadClientTest.php | 28 ++++++++++++++++++-------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/Transport/RuntimePayloads.php b/src/Transport/RuntimePayloads.php index 04077681..c93be238 100644 --- a/src/Transport/RuntimePayloads.php +++ b/src/Transport/RuntimePayloads.php @@ -45,7 +45,7 @@ public function response(array $response, string $requestPath, bool $worker): ar && is_array($response['output_payload_reference'] ?? null)) { // Keep the opaque reference while hydrating only the authorized // terminal output of this exact call, never arbitrary metadata. - $response['output_envelope'] = $response['output_payload_reference']; + $response['output_envelope'] = ['codec' => 'avro', 'external_payload' => $response['output_payload_reference']]; } foreach (['input_envelope', 'output_envelope', 'result_envelope'] as $field) { $paths[] = [$field]; diff --git a/tests/ServiceCatalogReadClientTest.php b/tests/ServiceCatalogReadClientTest.php index d512680a..f2242e6c 100644 --- a/tests/ServiceCatalogReadClientTest.php +++ b/tests/ServiceCatalogReadClientTest.php @@ -13,6 +13,8 @@ use DurableWorkflow\Transport\Psr18Transport; use DurableWorkflow\Transport\RuntimePayloads; use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; use GuzzleHttp\Promise\Create; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; @@ -131,14 +133,24 @@ private function discovery(): array public function testOwnedTerminalOutputIsDecodedWithoutReplacingTheOpaqueReference(mixed $value): void { $codec = new AvroPayloadCodec(); - $envelope = $codec->envelope($value); - $transport = new FakeTransport([$this->discovery(), [ - 'id' => 'call-1', 'payloads_in_observer_namespace' => true, - 'output_payload_reference' => $envelope, - ]]); + $blob = $codec->envelope($value)['blob']; + $reference = ['schema' => RuntimePayloads::SCHEMA, 'reference_id' => 'ep_01ARZ3NDEKTSV4RRFFQ69G5FAV', + 'codec' => 'avro', 'size_bytes' => strlen($blob), 'sha256' => hash('sha256', $blob)]; + $handler = new MockHandler([ + new Response(200, [], json_encode($this->discovery(), JSON_THROW_ON_ERROR)), + new Response(200, [], json_encode([ + 'id' => 'call-1', 'payloads_in_observer_namespace' => true, 'output_payload_reference' => $reference, + ], JSON_THROW_ON_ERROR)), + new Response(200, [ + 'Content-Type' => 'application/octet-stream', 'X-Durable-Workflow-Payload-Codec' => 'avro', + 'X-Durable-Workflow-Payload-Size' => (string) strlen($blob), + 'X-Durable-Workflow-Payload-SHA256' => hash('sha256', $blob), + ], $blob), + ]); + $transport = new Psr18Transport(new GuzzleClient(['handler' => HandlerStack::create($handler)])); $result = (new Client('https://runtime.test', transport: $transport))->describeServiceCatalogCall('call-1'); self::assertTrue($result['output_available']); - self::assertSame($envelope, $result['output_payload_reference']); + self::assertSame($reference, $result['output_payload_reference']); self::assertSame($codec->encode($value), $codec->encode($result['output'])); } @@ -154,7 +166,7 @@ public function testExternalOutputUsesOnlyTheConfiguredRuntimeNamespaceAndOperat $blob = $codec->envelope($value)['blob']; $reference = ['schema' => RuntimePayloads::SCHEMA, 'reference_id' => 'ep_01ARZ3NDEKTSV4RRFFQ69G5FAV', 'codec' => 'avro', 'size_bytes' => strlen($blob), 'sha256' => hash('sha256', $blob)]; - $envelope = ['codec' => 'avro', 'external_payload' => $reference]; + $envelope = $reference; $requests = []; $http = new GuzzleClient(['handler' => function (RequestInterface $request, array $options) use (&$requests, $blob, $envelope) { $requests[] = $request; @@ -193,7 +205,7 @@ public function testForeignOrUnverifiedOutputsAreNotFetchedAndOwnedDownloadsHono foreach ([false, null, true] as $authorized) { $transport = new FakeTransport([$this->discovery(), [ 'payloads_in_observer_namespace' => $authorized, - 'output_payload_reference' => ['codec' => 'avro', 'external_payload' => $reference], + 'output_payload_reference' => $reference, ]]); $client = new Client('https://runtime.test', transport: $transport, maxExternalPayloadBytes: 1); try { From b3b61feae3e64a4c25f8248616cd1a47fbc3b0d3 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sun, 11 Oct 2026 20:15:04 +0000 Subject: [PATCH 3/5] Document catalog results and reserve the next feature candidate train --- composer.json | 2 +- docs/portal/operate/troubleshooting.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c06830ec..4b974520 100644 --- a/composer.json +++ b/composer.json @@ -82,7 +82,7 @@ } }, "durable-workflow": { - "product-train": "2.3.0-rc.2", + "product-train": "2.4.0-rc.1", "supported-server-versions": "2.5.0", "worker-protocol-version": "1.19", "control-plane-version": "2", diff --git a/docs/portal/operate/troubleshooting.md b/docs/portal/operate/troubleshooting.md index f8dae70e..31344a71 100644 --- a/docs/portal/operate/troubleshooting.md +++ b/docs/portal/operate/troubleshooting.md @@ -79,6 +79,14 @@ does not authorize browsing another namespace. See the [Server read contract](https://github.com/durable-workflow/server/blob/main/docs/contracts/service-catalog-reads.md) for scopes, filters, pagination and detail bounds. +For an authorized owner, call detail also includes `output_available` and the +decoded `output`. This keeps `false`, `0`, an empty string and `null` distinct +from a missing result. The original opaque reference remains available. +Downloads use the configured runtime and operator credential, verify size and +digest, and honor the client's `maxExternalPayloadBytes` limit. Foreign or +unverified output references are not fetched. Decoded values retain the SDK's +typed Avro binary and map adapters. + ## A result wait timed out `WorkflowHandle::result()` polls for a bounded caller duration. When it times out, the durable execution may still be running. Call `describe()` and inspect its status before starting anything again. Reuse the original workflow ID or an application idempotency key; do not create duplicates just because the client lost patience. From 89f8e860b248797a610c0b6ea909fc70eec0f6da Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sun, 11 Oct 2026 20:16:16 +0000 Subject: [PATCH 4/5] Align the quickstart contract with the next candidate train --- docs/quickstart-contract.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/quickstart-contract.json b/docs/quickstart-contract.json index f6eb7297..3085ff0e 100644 --- a/docs/quickstart-contract.json +++ b/docs/quickstart-contract.json @@ -3,9 +3,9 @@ "schema_version": 2, "package": { "name": "durable-workflow/sdk", - "published_version": "2.3.0-rc.2", - "composer_requirement": "2.3.0-rc.2", - "onboarding_requirement": "^2.3@RC" + "published_version": "2.4.0-rc.1", + "composer_requirement": "2.4.0-rc.1", + "onboarding_requirement": "^2.4@RC" }, "runtime_targets": { "server": { From f35e85041f15dcf29a0cb824babb981b59e170ef Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sun, 11 Oct 2026 20:29:46 +0000 Subject: [PATCH 5/5] Decode committed catalog results within the observer namespace --- src/Client.php | 4 ++-- src/Transport/RuntimePayloads.php | 11 ++++++++--- tests/ServiceCatalogReadClientTest.php | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/Client.php b/src/Client.php index 27f55b0e..6d62259b 100644 --- a/src/Client.php +++ b/src/Client.php @@ -232,9 +232,9 @@ public function describeServiceCatalogCall(string $callId): array { $response = $this->serviceCatalogRead('/service-catalog/calls', id: $callId); if (($response['payloads_in_observer_namespace'] ?? null) === true) { - $response['output_available'] = is_array($response['output_envelope'] ?? null); + $response['output_available'] = is_array($response['result_envelope'] ?? null); $response['output'] = $response['output_available'] - ? $this->decodedResponsePayload($response, 'output') + ? $this->decodedResponsePayload($response, 'result') : null; } diff --git a/src/Transport/RuntimePayloads.php b/src/Transport/RuntimePayloads.php index c93be238..823c5bb2 100644 --- a/src/Transport/RuntimePayloads.php +++ b/src/Transport/RuntimePayloads.php @@ -40,14 +40,19 @@ public function response(array $response, string $requestPath, bool $worker): ar $paths = [...$paths, ...$this->historyPaths(['task', 'history_events']), ...$this->historyPaths(['history_events']), ...$this->exportPaths(['task', 'history_export'])]; } else { - if (preg_match('~\A/service-catalog/calls/[^/]+\z~', $path) - && ($response['payloads_in_observer_namespace'] ?? null) === true + $catalogCall = preg_match('~\A/service-catalog/calls/[^/]+\z~', $path) === 1; + $catalogOutputAllowed = ($response['payloads_in_observer_namespace'] ?? null) === true; + if ($catalogCall && $catalogOutputAllowed + && !is_array($response['result_envelope'] ?? null) && is_array($response['output_payload_reference'] ?? null)) { // Keep the opaque reference while hydrating only the authorized // terminal output of this exact call, never arbitrary metadata. - $response['output_envelope'] = ['codec' => 'avro', 'external_payload' => $response['output_payload_reference']]; + $response['result_envelope'] = ['codec' => 'avro', 'external_payload' => $response['output_payload_reference']]; } foreach (['input_envelope', 'output_envelope', 'result_envelope'] as $field) { + if ($catalogCall && !$catalogOutputAllowed) { + continue; + } $paths[] = [$field]; } if (preg_match('~\A/activities/[^/]+\z~', $path)) { diff --git a/tests/ServiceCatalogReadClientTest.php b/tests/ServiceCatalogReadClientTest.php index f2242e6c..0c7e4c66 100644 --- a/tests/ServiceCatalogReadClientTest.php +++ b/tests/ServiceCatalogReadClientTest.php @@ -159,6 +159,21 @@ public static function outputs(): array return [[null], [false], [0], [''], [['result', ['nested' => 'value']]]]; } + #[DataProvider('outputs')] + public function testCommittedInlineResultEnvelopeIsDecoded(mixed $value): void + { + $codec = new AvroPayloadCodec(); + $envelope = $codec->envelope($value); + $transport = new FakeTransport([$this->discovery(), [ + 'payloads_in_observer_namespace' => true, 'output_payload_reference' => null, + 'result_envelope' => $envelope, + ]]); + $result = (new Client('https://runtime.test', transport: $transport))->describeServiceCatalogCall('call-1'); + self::assertTrue($result['output_available']); + self::assertSame($codec->encode($value), $codec->encode($result['output'])); + self::assertCount(2, $transport->requests); + } + public function testExternalOutputUsesOnlyTheConfiguredRuntimeNamespaceAndOperatorCredential(): void { $codec = new AvroPayloadCodec(); @@ -177,6 +192,7 @@ public function testExternalOutputUsesOnlyTheConfiguredRuntimeNamespaceAndOperat if (count($requests) === 2) { return Create::promiseFor(new Response(200, [], json_encode([ 'id' => 'call-1', 'payloads_in_observer_namespace' => true, 'output_payload_reference' => $envelope, + 'result_envelope' => ['codec' => 'avro', 'external_payload' => $envelope], ], JSON_THROW_ON_ERROR))); } @@ -206,6 +222,7 @@ public function testForeignOrUnverifiedOutputsAreNotFetchedAndOwnedDownloadsHono $transport = new FakeTransport([$this->discovery(), [ 'payloads_in_observer_namespace' => $authorized, 'output_payload_reference' => $reference, + 'result_envelope' => ['codec' => 'avro', 'external_payload' => $reference], ]]); $client = new Client('https://runtime.test', transport: $transport, maxExternalPayloadBytes: 1); try {