Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

- Experimental domain entity references for top-level arguments of embedded
queued activities (#996). Register an entity class, deterministic
adapter and authorized resolver. History records a portable reference and
argument positions. Each real attempt resolves current state outside the
engine transaction; replay never calls the resolver. Ordinary maps and
unregistered objects retain their existing serialization behavior. See
[the registration and retry contract](docs/entity-references.md).

## 2.28.0 - 2026-11-13

**Behavior change:** an un-awaited `select()` loser is cancelled when the run
Expand Down
7 changes: 7 additions & 0 deletions docs/api-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
This document describes the v2 public API stability contract of the
`durable-workflow/workflow` package.

The [domain entity reference API](entity-references.md) is experimental. Its
adapter/resolver registrations apply only to top-level arguments of in-process
queued PHP activities. The optional `entity_reference_positions` in
an activity snapshot identifies arguments adapted to portable `{type,id,version}`
maps; absence preserves ordinary argument behavior. History recovery and export
retain this discriminator. It does not advertise a service-mode capability.

## Relationship to the platform compatibility authority

This document is the per-package stability contract for the PHP workflow
Expand Down
109 changes: 109 additions & 0 deletions docs/entity-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Domain entity references in activities

This experimental embedded API lets queued PHP activities receive a
domain entity loaded by the application. Workflow history records its logical
type, identifier and reference version. The entity class implements no Workflow
or Laravel interface.

Choose the argument that expresses the activity's contract:

- Pass an identifier when the activity should own the lookup explicitly.
- Pass an Avro-compatible value when the activity needs the exact recorded state.
- Pass a registered entity when each attempt should load its current state.

Only top-level activity arguments are adapted. Nested objects, collections,
activity results and workflow arguments keep their existing serialization rules.
This API does not add object serialization or a Server/SDK capability.

## Register the application boundary

Configure an entity class, adapter and resolver under a stable logical type:

```php
// config/workflows.php
'v2' => [
'entity_references' => [
'orders.order' => [
'entity_class' => App\Domain\Order::class,
'adapter' => App\Infrastructure\OrderReferenceAdapter::class,
'resolver' => App\Infrastructure\OrderReferenceResolver::class,
],
],
],
```

The class binding lets the worker check the declared activity parameter before
repository access and preserve Laravel method injection. Class names stay in
application configuration. The recorded reference contains only
`{type: "orders.order", id: "o-123", version: 1}` and explicit argument positions;
an ordinary map with the same keys remains an ordinary value.

The adapter implements `Workflow\V2\EntityReferences\EntityReferenceAdapter`:

```php
final class OrderReferenceAdapter implements EntityReferenceAdapter
{
public function supports(object $entity): bool
{
return $entity instanceof Order;
}

public function identify(object $entity): string
{
return $entity->id;
}

public function version(): int
{
return 1;
}
}
```

Adapter construction and methods must be deterministic and perform no I/O.
Adaptation runs while the workflow turn is committed and may run again on a
transaction retry. Workflow code must follow its usual replay rules; registering
an adapter does not permit database reads inside workflow code.

The resolver implements `EntityReferenceResolver`. It receives the portable
reference and an engine-supplied `EntityReferenceContext` containing namespace,
workflow instance/run, activity execution, attempt number, argument position and
the named object parameter type when one is declared:

```php
public function resolve(EntityReference $reference, EntityReferenceContext $context): object
{
$order = $this->orders->find($reference->id)
?? throw EntityNotFound::for($reference);
if (! $this->authorization->canProcess($context->namespace, $order)) {
throw EntityAccessDenied::for($reference);
}
return $order;
}
```

The resolver owns application authorization. A reference identifier is not proof
of ownership or tenancy. Resolution happens outside the engine's claim/commit
transaction before the actual activity method runs. Each retry resolves again;
replay consumes the recorded activity outcome without calling the resolver.
Other Laravel dependencies are still injected normally.

```php
public function handle(LoggerInterface $logger, Order $order): void
{
// $order is the authorized current entity for this attempt.
}
```

Unknown logical types, unsupported reference versions, malformed metadata and
parameter mismatches fail before the method runs with non-retryable
`EntityReferenceRefused`. Missing entities, denied access and resolver exceptions
follow the activity's ordinary retry policy. Deploy matching registrations to
the in-process workers that can receive the activity, and keep them available
for retained history and retries.

Local entity arguments are refused before scheduling or lookup in this increment.
Local callbacks still execute inside the hosting workflow transaction. Supporting
a resolver there requires splitting preparation, callback and outcome recording
so application I/O runs outside that transaction. That accepted follow-up remains
open in [issue 996](https://github.com/durable-workflow/workflow/issues/996).
6 changes: 6 additions & 0 deletions scripts/ci/validate-regression-corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@
)
]: (
"tests/Feature/V2/V2EmbeddedReplayRegressionCorpusTest.php",
"tests/Fixtures/V2/EntityReferences/Order.php",
"tests/Fixtures/V2/EntityReferences/OrderReferenceAdapter.php",
"tests/Fixtures/V2/EntityReferences/OrderReferenceResolver.php",
"tests/Fixtures/V2/EntityReferences/OrderRepository.php",
"tests/Fixtures/V2/EntityReferences/OrderStatusActivity.php",
"tests/Fixtures/V2/EntityReferences/OrderStatusWorkflow.php",
"tests/Fixtures/V2/TestConstructorInjectionActivity.php",
"tests/Fixtures/V2/TestConstructorInjectionWorkflow.php",
"tests/Fixtures/V2/TestTracingReplayActivity.php",
Expand Down
22 changes: 22 additions & 0 deletions src/V2/EntityReferences/EntityAccessDenied.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\EntityReferences;

use RuntimeException;

/**
* Thrown by a resolver when the trusted context may not load the referenced entity (#996).
* Whether it is retried follows the activity's retry policy; mark it
* non-retryable there if a retry can't succeed.
*
* @experimental Draft API for workflow issue 996.
*/
final class EntityAccessDenied extends RuntimeException
{
public static function for(EntityReference $reference): self
{
return new self(sprintf('Access to entity [%s:%s] was denied.', $reference->type, $reference->id));
}
}
22 changes: 22 additions & 0 deletions src/V2/EntityReferences/EntityNotFound.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\EntityReferences;

use RuntimeException;

/**
* Thrown by a resolver when the referenced entity no longer exists (#996).
* Whether it is retried follows the activity's retry policy; mark it
* non-retryable there if a retry can't succeed.
*
* @experimental Draft API for workflow issue 996.
*/
final class EntityNotFound extends RuntimeException
{
public static function for(EntityReference $reference): self
{
return new self(sprintf('Entity [%s:%s] was not found.', $reference->type, $reference->id));
}
}
61 changes: 61 additions & 0 deletions src/V2/EntityReferences/EntityReference.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\EntityReferences;

use InvalidArgumentException;

/**
* A portable reference to a persisted domain entity (#996).
*
* On the wire it is an ordinary Avro map, `{type, id, version}`, so the value
* schema is unchanged and arguments without references stay byte-identical.
* The engine also records which argument positions hold references; the map
* alone never triggers resolution.
*
* @experimental Draft API for workflow issue 996.
*/
final class EntityReference
{
/**
* @param non-empty-string $type Logical type registered in config.
* @param non-empty-string $id
* @param positive-int $version
*/
public function __construct(
public readonly string $type,
public readonly string $id,
public readonly int $version,
) {
if ($type === '' || $id === '' || $version < 1) {
throw new InvalidArgumentException('An entity reference needs a type, an identifier and a version >= 1.');
}
}

/**
* @return array{type: string, id: string, version: int}
*/
public function toArray(): array
{
return [
'type' => $this->type,
'id' => $this->id,
'version' => $this->version,
];
}

/**
* @param array<mixed> $value
*/
public static function fromArray(array $value): self
{
if (count($value) !== 3
|| ! is_string($value['type'] ?? null) || ! is_string($value['id'] ?? null)
|| ! is_int($value['version'] ?? null)) {
throw new InvalidArgumentException('A recorded entity reference must be exactly {type, id, version}.');
}

return new self($value['type'], $value['id'], $value['version']);
}
}
43 changes: 43 additions & 0 deletions src/V2/EntityReferences/EntityReferenceAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\EntityReferences;

/**
* Turns an application domain entity into a portable reference when workflow
* code schedules an activity with it (#996).
*
* Registered by logical type in `workflows.v2.entity_references`. The domain
* class itself implements nothing from Durable Workflow or Laravel; the
* adapter belongs to the application's infrastructure layer.
* Its constructor and methods must be deterministic and perform no I/O:
* adaptation happens while the workflow turn is committed, including retries.
*
* @experimental Draft API for workflow issue 996; not covered by the stability
* guarantees until it ships.
*/
interface EntityReferenceAdapter
{
/**
* Whether this adapter turns the given object into a reference.
*/
public function supports(object $entity): bool;

/**
* The entity's stable identifier. It is recorded in history and must not
* contain the entity's state, credentials or a tenant the worker would
* trust on its own.
*
* @return non-empty-string
*/
public function identify(object $entity): string;

/**
* The reference format version this adapter writes. A resolver refuses
* versions it does not support, as a non-retryable failure.
*
* @return positive-int
*/
public function version(): int;
}
30 changes: 30 additions & 0 deletions src/V2/EntityReferences/EntityReferenceContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\EntityReferences;

/**
* Trusted execution context for resolving a reference (#996): where the
* activity attempt runs, and what the resolved value is bound to.
*
* @experimental Draft API for workflow issue 996.
*/
final class EntityReferenceContext
{
/**
* @param class-string|null $parameterType The declared parameter type the
* resolved object must satisfy, or null for an untyped, built-in or
* compound type. The worker also checks compound types locally.
*/
public function __construct(
public readonly ?string $namespace,
public readonly string $workflowInstanceId,
public readonly string $workflowRunId,
public readonly string $activityExecutionId,
public readonly int $attemptNumber,
public readonly int $argumentPosition,
public readonly ?string $parameterType,
) {
}
}
39 changes: 39 additions & 0 deletions src/V2/EntityReferences/EntityReferenceRefused.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\EntityReferences;

use RuntimeException;
use Workflow\Exceptions\NonRetryableExceptionContract;

/**
* The worker refused a recorded reference before the activity ran (#996):
* unknown type, unsupported version, or a resolved object that doesn't match
* the declared parameter type. Always non-retryable.
*
* @experimental Draft API for workflow issue 996.
*/
final class EntityReferenceRefused extends RuntimeException implements NonRetryableExceptionContract
{
public const INVALID_REFERENCE = 'invalid_entity_reference';

public const INVALID_REGISTRATION = 'invalid_entity_reference_registration';

public const AMBIGUOUS_TYPE = 'ambiguous_entity_reference_type';

public const UNSUPPORTED_EXECUTION_MODE = 'entity_reference_execution_mode_not_supported';

public const UNKNOWN_TYPE = 'unknown_entity_reference_type';

public const UNSUPPORTED_VERSION = 'unsupported_entity_reference_version';

public const PARAMETER_MISMATCH = 'entity_reference_parameter_mismatch';

public function __construct(
public readonly string $reason,
string $message,
) {
parent::__construct($message);
}
}
Loading
Loading