Skip to content

Commit c816c7a

Browse files
Publish invocable carrier contract
Publish invocable carrier contract
1 parent dce7e97 commit c816c7a

12 files changed

Lines changed: 305 additions & 0 deletions

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,15 @@ queue and activity type, the activity poll response includes a redacted
643643
`task.external_executor` mapping block with the handler, carrier target, auth
644644
reference, rollout metadata, and config schema version.
645645

646+
The first concrete invocable carrier contract is published at
647+
`worker_protocol.invocable_carrier_contract` with carrier type
648+
`invocable_http`. It is activity-task only: the target endpoint receives the
649+
external task input envelope over `POST` and must return the external task
650+
result envelope. The server validates `invocable_http` carrier config
651+
fail-closed, including non-empty `url`, `POST` method, bounded
652+
`timeout_seconds`, and activity-only capabilities, before mapping it onto
653+
pollable activity tasks.
654+
646655
The carrier-neutral external task input envelope is published from
647656
`GET /api/cluster/info` at `worker_protocol.external_task_input_contract`.
648657
That manifest explicitly splits its scope: activity tasks are the

app/Http/Controllers/Api/HealthController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ public function clusterInfo(Request $request): JsonResponse
8686
'payload_codec_envelope_responses' => true,
8787
'bridge_adapter_outcome_contract' => true,
8888
'external_executor_config_contract' => true,
89+
'invocable_carrier_contract' => true,
8990
'payload_codecs' => CodecRegistry::universal(),
9091
'response_compression' => (bool) config('server.compression.enabled', true)
9192
? ['gzip', 'deflate']

app/Support/ClientCompatibility.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ public static function info(): array
5757
'schema' => ExternalExecutorConfigContract::CONTRACT_SCHEMA,
5858
'version' => ExternalExecutorConfigContract::CONTRACT_VERSION,
5959
],
60+
'invocable_carrier_contract' => [
61+
'schema' => InvocableCarrierContract::SCHEMA,
62+
'version' => InvocableCarrierContract::VERSION,
63+
],
6064
'external_task_input_contract' => [
6165
'schema' => ExternalTaskInputContract::SCHEMA,
6266
'version' => ExternalTaskInputContract::VERSION,
@@ -85,6 +89,7 @@ public static function info(): array
8589
'worker_protocol.version',
8690
'worker_protocol.external_execution_surface_contract',
8791
'worker_protocol.external_executor_config_contract',
92+
'worker_protocol.invocable_carrier_contract',
8893
'worker_protocol.external_task_input_contract',
8994
'worker_protocol.external_task_result_contract',
9095
],

app/Support/ExternalExecutionSurfaceContract.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ public static function manifest(): array
7171
'cluster_info_path' => 'worker_protocol.external_executor_config_contract',
7272
'required_outcome' => 'configuration-first mapping from task kind, queue, and handler name to an external carrier invocation',
7373
],
74+
'invocable_http_carrier' => [
75+
'schema' => InvocableCarrierContract::SCHEMA,
76+
'version' => InvocableCarrierContract::VERSION,
77+
'status' => 'published',
78+
'cluster_info_path' => 'worker_protocol.invocable_carrier_contract',
79+
'required_outcome' => 'activity-only HTTP invocation contract with stable request, response, failure, auth, and rollout boundaries',
80+
],
7481
'bridge_adapters' => [
7582
'status' => 'planned',
7683
'required_outcome' => 'bounded ingress and handoff adapters with explicit duplicate, auth, malformed payload, and routing outcomes',

app/Support/ExternalExecutorConfigContract.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ public static function namedErrors(): array
138138
'invalid_queue_binding',
139139
'missing_handler_target',
140140
'unsupported_carrier_capability',
141+
'invalid_carrier_target',
142+
'invalid_invocable_carrier_scope',
141143
];
142144
}
143145

@@ -326,6 +328,18 @@ private static function validate(array $document): array
326328
$defaults = is_array($document['defaults'] ?? null) ? $document['defaults'] : [];
327329
$seenNames = [];
328330

331+
foreach ($carriers as $name => $carrier) {
332+
if (! is_array($carrier)) {
333+
$errors[] = self::error('invalid_schema', 'External executor carrier must be an object.', [
334+
'carrier' => is_string($name) ? $name : null,
335+
]);
336+
337+
continue;
338+
}
339+
340+
array_push($errors, ...self::validateCarrier(is_string($name) ? $name : null, $carrier));
341+
}
342+
329343
foreach ($mappings as $index => $mapping) {
330344
if (! is_array($mapping)) {
331345
$errors[] = self::error('invalid_schema', 'External executor mapping must be an object.', [
@@ -436,6 +450,62 @@ private static function carrierSupports(array $carrier, ?string $kind): bool
436450
return in_array($capability, $capabilities, true);
437451
}
438452

453+
/**
454+
* @return list<array<string, mixed>>
455+
*/
456+
private static function validateCarrier(?string $name, array $carrier): array
457+
{
458+
if (self::stringValue($carrier['type'] ?? null) !== InvocableCarrierContract::CARRIER_TYPE) {
459+
return [];
460+
}
461+
462+
$errors = [];
463+
$capabilities = $carrier['capabilities'] ?? null;
464+
465+
if (is_array($capabilities)) {
466+
$invalid = array_values(array_filter(
467+
$capabilities,
468+
static fn (mixed $capability): bool => $capability !== 'activity_task',
469+
));
470+
471+
if ($invalid !== []) {
472+
$errors[] = self::error(
473+
'invalid_invocable_carrier_scope',
474+
'Invocable HTTP carriers are activity-task carriers only.',
475+
['carrier' => $name, 'capabilities' => $invalid],
476+
);
477+
}
478+
}
479+
480+
if (self::stringValue($carrier['url'] ?? null) === null) {
481+
$errors[] = self::error('invalid_carrier_target', 'Invocable HTTP carriers must declare a non-empty url.', [
482+
'carrier' => $name,
483+
'field' => 'url',
484+
]);
485+
}
486+
487+
$method = strtoupper(self::stringValue($carrier['method'] ?? 'POST') ?? 'POST');
488+
if ($method !== 'POST') {
489+
$errors[] = self::error('invalid_carrier_target', 'Invocable HTTP carriers only support POST.', [
490+
'carrier' => $name,
491+
'field' => 'method',
492+
]);
493+
}
494+
495+
if (array_key_exists('timeout_seconds', $carrier)) {
496+
$timeout = $carrier['timeout_seconds'];
497+
if (! is_int($timeout) || $timeout < 1 || $timeout > 900) {
498+
$errors[] = self::error(
499+
'invalid_carrier_target',
500+
'Invocable HTTP carrier timeout_seconds must be an integer between 1 and 900.',
501+
['carrier' => $name, 'field' => 'timeout_seconds'],
502+
);
503+
}
504+
}
505+
506+
return $errors;
507+
}
508+
439509
/**
440510
* @return array<string, mixed>
441511
*/
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
namespace App\Support;
4+
5+
final class InvocableCarrierContract
6+
{
7+
public const SCHEMA = 'durable-workflow.v2.invocable-carrier.contract';
8+
9+
public const VERSION = 1;
10+
11+
public const CARRIER_TYPE = 'invocable_http';
12+
13+
/**
14+
* @return array<string, mixed>
15+
*/
16+
public static function manifest(): array
17+
{
18+
return [
19+
'schema' => self::SCHEMA,
20+
'version' => self::VERSION,
21+
'carrier_type' => self::CARRIER_TYPE,
22+
'scope' => [
23+
'task_kinds' => ['activity_task'],
24+
'explicit_non_goals' => [
25+
'workflow_task_execution',
26+
'workflow_replay',
27+
'history_mutation',
28+
'generic_webhook_ingress',
29+
],
30+
],
31+
'config_binding' => [
32+
'config_schema' => ExternalExecutorConfigContract::CONFIG_SCHEMA,
33+
'config_schema_version' => ExternalExecutorConfigContract::CONFIG_VERSION,
34+
'carrier_type_field' => 'carriers.<name>.type',
35+
'required_type_value' => self::CARRIER_TYPE,
36+
'mapping_kind' => 'activity',
37+
'capability' => 'activity_task',
38+
],
39+
'target_fields' => [
40+
'url' => [
41+
'type' => 'string',
42+
'required' => true,
43+
'meaning' => 'Absolute HTTPS URL for the activity handler endpoint; loopback HTTP is only for local development.',
44+
],
45+
'method' => [
46+
'type' => 'string',
47+
'required' => false,
48+
'default' => 'POST',
49+
'allowed' => ['POST'],
50+
],
51+
'timeout_seconds' => [
52+
'type' => 'integer',
53+
'required' => false,
54+
'minimum' => 1,
55+
'maximum' => 900,
56+
'meaning' => 'Transport deadline for one handler attempt; carriers must also respect task.deadlines.',
57+
],
58+
],
59+
'request' => [
60+
'method' => 'POST',
61+
'content_type' => 'application/vnd.durable-workflow.external-task-input+json',
62+
'body_schema' => ExternalTaskInputContract::SCHEMA,
63+
'body_version' => ExternalTaskInputContract::VERSION,
64+
'idempotency_key_source' => 'task.idempotency_key',
65+
],
66+
'response' => [
67+
'content_type' => 'application/vnd.durable-workflow.external-task-result+json',
68+
'body_schema' => ExternalTaskResultContract::SCHEMA,
69+
'body_version' => ExternalTaskResultContract::VERSION,
70+
'success_result_path' => 'result.payload',
71+
'failure_path' => 'failure',
72+
],
73+
'failure_mapping' => [
74+
'transport_timeout' => 'failure.kind=timeout classification=deadline_exceeded',
75+
'non_2xx_without_valid_envelope' => 'malformed_output',
76+
'invalid_json' => 'malformed_output',
77+
'schema_mismatch' => 'malformed_output',
78+
'unsupported_payload_reference' => 'unsupported_payload',
79+
],
80+
'auth' => [
81+
'source' => 'external_executor_config.auth_refs',
82+
'redaction' => 'tokens_secrets_signatures_never_echoed',
83+
'determinism' => 'effective auth must be discoverable by redacted config diagnostics before dispatch',
84+
],
85+
'rollout_safety' => [
86+
'coexistence' => 'poll_and_invocable_carriers_may_share_a_queue_only_when_mappings_are_activity_type_specific',
87+
'drain_signal' => 'operators must remove or overlay-disable mappings before deleting endpoint credentials',
88+
'retry_authority' => 'carrier policy may retry transport failures, but durable activity retry policy remains the server/runtime authority',
89+
],
90+
];
91+
}
92+
}

app/Support/WorkerProtocol.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ public static function supportedWorkflowTaskCommands(): array
9797
* history_compression: array{supported_encodings: list<string>, compression_threshold: int},
9898
* external_execution_surface: array<string, mixed>,
9999
* external_executor_config: array<string, mixed>,
100+
* invocable_carrier: array<string, mixed>,
100101
* external_task_input: array<string, mixed>,
101102
* external_task_result: array<string, mixed>,
102103
* }
@@ -143,6 +144,11 @@ public static function serverCapabilities(): array
143144
'config_schema' => ExternalExecutorConfigContract::CONFIG_SCHEMA,
144145
'config_schema_version' => ExternalExecutorConfigContract::CONFIG_VERSION,
145146
],
147+
'invocable_carrier' => [
148+
'schema' => InvocableCarrierContract::SCHEMA,
149+
'version' => InvocableCarrierContract::VERSION,
150+
'carrier_type' => InvocableCarrierContract::CARRIER_TYPE,
151+
],
146152
'external_task_input' => [
147153
'schema' => ExternalTaskInputContract::SCHEMA,
148154
'version' => ExternalTaskInputContract::VERSION,
@@ -174,6 +180,7 @@ public static function info(): array
174180
...ExternalExecutorConfigContract::manifest(),
175181
'runtime' => ExternalExecutorConfigContract::runtime(),
176182
],
183+
'invocable_carrier_contract' => InvocableCarrierContract::manifest(),
177184
'external_task_input_contract' => ExternalTaskInputContract::manifest(),
178185
'external_task_result_contract' => ExternalTaskResultContract::manifest(),
179186
];

docs/contracts/external-execution-surface.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Version 1 names these contract seams:
3434
- `result_envelope`: `worker_protocol.external_task_result_contract`
3535
- `auth_profile_tls_composition`
3636
- `handler_mappings`: `worker_protocol.external_executor_config_contract`
37+
- `invocable_http_carrier`: `worker_protocol.invocable_carrier_contract`
3738
- `bridge_adapters`
3839
- `payload_external_storage`
3940
- `admission_and_rollout_safety`
@@ -55,6 +56,15 @@ declared input schema; accepts the declared result schema; maps transport
5556
failures to structured failure or malformed-output outcomes; and resolves auth,
5657
TLS, profile, and environment inputs deterministically.
5758

59+
The first concrete invocable carrier is `invocable_http`, published at
60+
`worker_protocol.invocable_carrier_contract`. It is activity-task only. Its
61+
config target must declare a URL, may declare `method: POST`, and may declare a
62+
bounded `timeout_seconds` value. The server validates malformed invocable
63+
carrier config fail-closed through `invalid_carrier_target` and
64+
`invalid_invocable_carrier_scope` before exposing mappings on activity poll
65+
responses. Actual dispatch still belongs to a carrier implementation; this
66+
contract freezes the request, response, auth, failure, and rollout boundary.
67+
5868
Stable adjacent contract docs live in:
5969

6070
- `docs/contracts/bridge-adapters.md`

tests/Feature/ClusterInfoTest.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ public function test_it_publishes_external_execution_surface_contract_manifest()
164164
'worker_protocol.external_execution_surface_contract.contract_seams.handler_mappings.schema',
165165
'durable-workflow.v2.external-executor-config.contract',
166166
)
167+
->assertJsonPath(
168+
'worker_protocol.external_execution_surface_contract.contract_seams.invocable_http_carrier.status',
169+
'published',
170+
)
167171
->assertJsonPath(
168172
'worker_protocol.external_execution_surface_contract.contract_seams.bridge_adapters.status',
169173
'planned',
@@ -178,6 +182,35 @@ public function test_it_publishes_external_execution_surface_contract_manifest()
178182
);
179183
}
180184

185+
public function test_it_publishes_invocable_carrier_contract_manifest(): void
186+
{
187+
$this->getJson('/api/cluster/info')
188+
->assertOk()
189+
->assertJsonPath(
190+
'worker_protocol.invocable_carrier_contract.schema',
191+
'durable-workflow.v2.invocable-carrier.contract',
192+
)
193+
->assertJsonPath('worker_protocol.invocable_carrier_contract.carrier_type', 'invocable_http')
194+
->assertJsonPath('worker_protocol.invocable_carrier_contract.scope.task_kinds.0', 'activity_task')
195+
->assertJsonPath(
196+
'worker_protocol.invocable_carrier_contract.request.body_schema',
197+
'durable-workflow.v2.external-task-input.contract',
198+
)
199+
->assertJsonPath(
200+
'worker_protocol.invocable_carrier_contract.response.body_schema',
201+
'durable-workflow.v2.external-task-result.contract',
202+
)
203+
->assertJsonPath(
204+
'worker_protocol.server_capabilities.invocable_carrier.schema',
205+
'durable-workflow.v2.invocable-carrier.contract',
206+
)
207+
->assertJsonPath('capabilities.invocable_carrier_contract', true)
208+
->assertJsonPath(
209+
'client_compatibility.required_protocols.worker_protocol.invocable_carrier_contract.version',
210+
1,
211+
);
212+
}
213+
181214
public function test_it_publishes_external_executor_config_contract_when_no_config_is_set(): void
182215
{
183216
$this->getJson('/api/cluster/info')
@@ -306,6 +339,44 @@ public function test_it_reports_named_external_executor_config_validation_errors
306339
$response->assertJsonPath('worker_protocol.external_executor_config_contract.runtime.status', 'invalid');
307340
}
308341

342+
public function test_it_fails_closed_on_malformed_invocable_http_carrier_config(): void
343+
{
344+
$this->useExternalExecutorConfigFixture([
345+
'schema' => 'durable-workflow.external-executor.config',
346+
'version' => 1,
347+
'defaults' => [
348+
'task_queue' => 'operator-tasks',
349+
],
350+
'carriers' => [
351+
'bad-invocable' => [
352+
'type' => 'invocable_http',
353+
'method' => 'GET',
354+
'timeout_seconds' => true,
355+
'capabilities' => ['activity_task', 'workflow_task'],
356+
],
357+
],
358+
'mappings' => [
359+
[
360+
'name' => 'billing.backfill',
361+
'kind' => 'activity',
362+
'activity_type' => 'billing.backfill',
363+
'carrier' => 'bad-invocable',
364+
'handler' => 'billing.backfill',
365+
],
366+
],
367+
]);
368+
369+
$response = $this->getJson('/api/cluster/info')->assertOk();
370+
$codes = array_column(
371+
$response->json('worker_protocol.external_executor_config_contract.runtime.errors'),
372+
'code',
373+
);
374+
375+
$this->assertContains('invalid_carrier_target', $codes);
376+
$this->assertContains('invalid_invocable_carrier_scope', $codes);
377+
$response->assertJsonPath('worker_protocol.external_executor_config_contract.runtime.status', 'invalid');
378+
}
379+
309380
public function test_it_applies_named_external_executor_config_overlay_before_validation(): void
310381
{
311382
config(['server.external_executor.overlay' => 'prod']);

0 commit comments

Comments
 (0)