Skip to content

Commit 625b3cb

Browse files
titus-toiatitustangibleclaude
authored
0.2.0: integration-event taxonomy partition + await mechanisms (fan-in sagas) (#9)
* refactor(events): extract Event root (name/prefix) above DomainEvent * feat(events): RecordBehaviour trait — strict scalarise codec, journey slots, identity announcement Implement Task 2: RecordBehaviour trait with: - integration_payload(): strict codec for reversible scalars (ctor IS schema) - from_payload(): total hydration with type coercion - journey_correlation_id/event_id: journey slots (stamped, never in payload) - integration_action(): '{prefix}_integration_{name}' - to_integration(): identity announcement - delay()/is_unique(): default values (0, false) - NonReversibleValue exception for non-scalar values Add test fixtures FakeOutcome (backed enum) and FakeResolvedEvent (self-publisher). 8 tests pass in RecordBehaviourTest; full suite 255/255 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * refactor(events): rename RecordBehaviour -> IntegrationBehaviour (owner ruling) * chore: gitignore .superpowers scratch * feat(events)!: sever the partition — IntegrationEvent is a derived-only record, not a DomainEvent IIntegrationEvent no longer extends IDomainEvent: a class implementing only IIntegrationEvent (a "twin") cannot be raised through EventsUnitOfWork/ EventRouter, which type against IDomainEvent. Self-publishers now implement both interfaces explicitly (FakeResolvedEvent is the reference fixture). IntegrationEvent collapses to `extends Event implements IIntegrationEvent { use IntegrationBehaviour; }` — the v0.1 scalarise()/integration_payload()/ delay()/is_unique() body is gone in favour of the trait's strict codec. Collateral: EventRouterTest, EventsUnitOfWorkTest, and DomainEventsPublishMiddlewareTest swapped FakeIntegrationEvent (now a pure twin) for FakeResolvedEvent (a self-publisher) wherever a test raised the event as a domain event. DomainEventTest's "Scalarise" tests, which called the now-deleted public scalarise(), were removed — equivalent coverage already exists in IntegrationBehaviourTest. EventRouter/EventsUnitOfWork production code is untouched (Task 4 scope). * test(integration): update outbox suite event stubs to severed IIntegrationEvent contract OutboxPauseTest and OutboxPrimitiveTest built hand-rolled IIntegrationEvent stubs against the pre-fa6d56f shape (action()/payload(), no from_payload()/ correlation_id()/event_id()/stamp_journey()) and now fatal. Both now extend the IntegrationEvent base (ctor + prefix()), relying on IntegrationBehaviour for the codec. OutboxPrimitiveTest's payload assertion is updated from the old hand-rolled shape to the new named-array shape (integration_payload() keys by ctor param name). Also fixes a latent cross-test leak surfaced now that these tests can run to completion: OutboxPauseTest's wildcard-pause test writes via set_pause(), but fetch_pending()'s wildcard branch returns before its own internal commit, so IntegrationTestCase's outer ROLLBACK undoes the DB row while the WP options cache still holds it. clear_pause()/delete_option() then no-op against the cache (their underlying $wpdb update/delete affects zero rows), leaking a permanent wildcard pause into every later test in the process — which silently starved OutboxPrimitiveTest's processor tests of any fetch_pending() results. tearDown() now force-drops the cache entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * feat(events): single-door router (IAnnouncesIntegration) + AlreadyIntegrated re-raise guard + publish-time journey stamp EventRouter now branches on IAnnouncesIntegration (calling to_integration()) instead of IIntegrationEvent, so fat moments can dispatch themselves while publishing a distinct hand-derived twin. EventsUnitOfWork::record() rejects re-raising a stamped/hydrated integration event (AlreadyIntegrated) — that's replay territory, never raising. OutboxIntegrationEventBus stamps the event's journey (correlation_id/event_id) with the outbox-generated id right after write(), so the in-hand instance carries the same identity as its outbox row. * feat(events): TransportEnvelope — single unwrap point for journey transport keys Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * feat(listeners): IntegrationListener + integration_listener() ceremony; deprecate AsyncWordPressActionHandler * feat(process): IAwaitMechanism strategy — AwaitEvent refactor, Result widening, mechanism persistence + legacy fallback * feat(process): AwaitAll fan-in mechanism — key_by static extractor, mandatory timeout, idempotent accumulation * feat(process)!: mechanism-driven resume — accumulate-until-satisfied, GET_LOCK, envelope unwrap + journey stamp on wake, suspend-time registration guard ProcessRunner now delegates suspend/resume entirely to IAwaitMechanism: resume_on_event() routes via mechanism->accepts()/accumulate() under a per-process MySQL named lock, staying suspended on partial AwaitAll arrivals and resuming with mechanism->resume_argument() once satisfied. register_event()'s wake callback unwraps the TransportEnvelope and restores correlation/journey context before rebuilding the event, matching the integration_listener() ceremony. suspend_for_event() now persists the mechanism (match_criteria column is legacy-only) and throws AwaitedEventNotRegistered if the awaited event has no wake-up hook, schedules an alarm via as_schedule_single_action when timeout_seconds > 0. Found via TDD: the new suspend-time guard was originally being swallowed by execute_forward()/execute_compensation()'s generic Throwable catch and silently turned into a compensated 'failed' process. Added dedicated AwaitedEventNotRegistered catches that rethrow instead of compensating — a wiring bug shouldn't be treated as a business failure. * fix(process): stop run() relabelling AwaitedEventNotRegistered as a saga failure The dedicated rethrows in execute_forward/execute_compensation stopped one layer short: run()'s own generic Throwable catch still caught the guard on the way out, stamped the row 'failed', and dispatched ProcessFailed — exactly the business-failure relabeling the guard exists to prevent. Add the same AwaitedEventNotRegistered catch-and-rethrow ahead of run()'s generic catch, and a regression test asserting the persisted row is not 'failed' and no ProcessFailed action fires when the guard trips (verified red against the unfixed run() before applying the catch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * feat(process): wall-clock await timeout (stale guard, fail|proceed policies) + #[Awaits] boot registration * fix(process): handle_timeout arms resource governor + serializes via process lock Two review findings on the await-timeout handler: 1. started_at was never set, so the TIMEOUT_FAIL branch (which calls execute_compensation() directly, bypassing run()) ran the entire compensation cascade with the resource governor disabled — or against a stale started_at on a reused runner. handle_timeout() is an AS-action entry point like continue_scheduled(); arm the governor on entry. 2. handle_timeout mutated the same suspended row as resume_on_event with no lock: alarm racing the final event arrival was last-writer-wins. The find + ALL guards (status, step_index staleness, mechanism) now run inside with_process_lock() — a pre-lock read could be stale by the time the lock is won, defeating the guards. Also closes a coverage gap: new FakeGatherFailCompensatingProcess (completed step + #[Compensates] before the await) makes execute_compensation()'s loop genuinely iterate under a timeout — asserts undo ran with its checkpoint. * test(pipeline): end-to-end pipe — raise → outbox → AS → typed wake; CI triggers for release/** EventPipelineTest drives the full stack through real classes (EventRouter, OutboxIntegrationEventBus, OutboxProcessor, ActionSchedulerOutboxPublisher, ProcessRunner) with only persistence (FakeOutboxRepository, FakeProcessRepository) and Action Scheduler ($_test_scheduled_actions stub) faked. Reused the existing FakeOutboxRepository rather than writing a new one. Passed on first run — no production code changes needed. Widen phpunit.yml's push/pull_request branch triggers to master, v3-ddd, and release/** so CI runs on this line of work. * fix(pipeline-test): assert correlation VALUES survive the hop, not just keys Wire side: pin __correlation_id to the raise-time 'pipe-corr' and __event_id to the outbox-minted 'evt_1' in the AS job payload. Worker side: CorrelationContext::peek() is null post-wake by design (with() scope-exit resets ambient state — worker hygiene), so restore is proven on the woken event itself: resolution_key (the key_by extractor on the routing path) captures the hydrated instance, and the test asserts its stamped journey (correlation_id + event_id) matches the transport. The context is reset before do_action() to simulate a fresh AS worker, so the stamp can only originate from the envelope. Also asserts the saga row's correlation continuity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * fix(schema): migrate v4 — await_mechanism column for upgraded installs Task 7 added await_mechanism to the canonical long_processes schema and ProcessRepository writes it unconditionally, but DDD_SCHEMA_VERSION stayed at 3. ddd_maybe_migrate()'s fast path bails for any consumer already at v3, so dbDelta never re-runs and the column never lands — every process insert/update then silently fails (return value ignored) and sagas never persist. Fresh installs were unaffected (dbDelta covers the full schema on install), which is why existing tests passed. Bump DDD_SCHEMA_VERSION to 4 and add the v4 explicit migration entry using the existing v2/v3 idiom (ddd_add_column_if_missing), so consumers parked at v3 get healed on their next admin_init. Verified against the shared ddev DB: all real consumers were recorded at v3 with the column genuinely missing — this is not a theoretical gap. * chore(deps): sync composer.lock with require-dev additions wp-phpunit, yoast/phpunit-polyfills and symfony/yaml were added to composer.json without regenerating the lock; CI installs from lock and aborts on the drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN --------- Co-authored-by: Titus TC <titus@teamtangible.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0b43694 commit 625b3cb

63 files changed

Lines changed: 4402 additions & 338 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/phpunit.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ name: PHPUnit tests
22

33
on:
44
push:
5-
branches: [ master ]
5+
branches: [ master, v3-ddd, 'release/**' ]
66
pull_request:
7-
branches: [ master ]
7+
branches: [ master, v3-ddd, 'release/**' ]
88

99
jobs:
1010
test:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ htmlcov/
4949
# Claude session scratch
5050
.claude/worktrees/
5151
.playwright-cli/
52+
.superpowers/

composer.lock

Lines changed: 188 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ddd-src/Application/EventHandlers/AsyncWordPressActionHandler.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
namespace TangibleDDD\Application\EventHandlers;
44

55
/**
6+
* @deprecated 0.2.0 — the "async domain handler" is a category error (the AS hop
7+
* is another TIME, not another thread; serialization forces params into
8+
* record-land regardless of intent). Decompose into an IntegrationListener
9+
* (the policy) + a Command (the work, under command_audit). Removal: 0.3.0.
10+
*
611
* Event handler that automatically queues the actual handling
712
* to run asynchronously via Action Scheduler.
8-
*
13+
*
914
* When the domain event fires, it enqueues an async action.
1015
* The actual handle() method runs in a separate request.
1116
*/
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace TangibleDDD\Application\EventHandlers;
4+
5+
use TangibleDDD\Application\Commands\ICommand;
6+
use TangibleDDD\Domain\Events\IIntegrationEvent;
7+
8+
/**
9+
* A stateless automation policy: "whenever [fact], then [intention]."
10+
*
11+
* The whole job is get_command() — fact in, intention out, null = not my
12+
* business. All work belongs in the command's handler (audit, retry,
13+
* causation); a listener only translates. Auto-wired by namespace convention
14+
* \Application\IntegrationListeners\ (eager boot constructs via the container,
15+
* so ctor injection is available to subclasses that need it — the happy path
16+
* needs nothing).
17+
*/
18+
abstract class IntegrationListener {
19+
20+
/** @return class-string<IIntegrationEvent> */
21+
abstract protected function get_event_class(): string;
22+
23+
/** Fact in, intention out. Null = no reaction. */
24+
abstract protected function get_command(IIntegrationEvent $event): ?ICommand;
25+
26+
public function __construct() {
27+
\TangibleDDD\WordPress\integration_listener(
28+
static::get_event_class(),
29+
fn(IIntegrationEvent $event) => $this->get_command($event)
30+
);
31+
}
32+
}

ddd-src/Application/Events/EventRouter.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22

33
namespace TangibleDDD\Application\Events;
44

5+
use TangibleDDD\Domain\Events\IAnnouncesIntegration;
56
use TangibleDDD\Domain\Events\IDomainEvent;
6-
use TangibleDDD\Domain\Events\IIntegrationEvent;
77

88
/**
99
* Routes domain events to appropriate destinations.
1010
*
11-
* 1. All domain events go to the dispatcher (WordPress hooks)
12-
* 2. Integration events additionally go to the bus (outbox)
11+
* 1. All domain events go to the dispatcher (WordPress hooks).
12+
* 2. Announcing events additionally send their record to the bus.
1313
*/
1414
final class EventRouter {
1515
public function __construct(
@@ -20,8 +20,8 @@ public function __construct(
2020
public function publish(IDomainEvent $event): void {
2121
$this->dispatcher->dispatch($event);
2222

23-
if ($event instanceof IIntegrationEvent) {
24-
$this->bus->publish($event);
23+
if ($event instanceof IAnnouncesIntegration) {
24+
$this->bus->publish($event->to_integration());
2525
}
2626
}
2727
}

ddd-src/Application/Events/EventsUnitOfWork.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace TangibleDDD\Application\Events;
44

55
use TangibleDDD\Application\Exceptions\DomainEventAfterSealException;
6+
use TangibleDDD\Domain\Events\AlreadyIntegrated;
67
use TangibleDDD\Domain\Events\IDomainEvent;
78
use TangibleDDD\Domain\Events\IIntegrationEvent;
89
use TangibleDDD\Domain\Shared\IRecordsDomainEvents;
@@ -41,6 +42,9 @@ public function seal(): void {
4142
}
4243

4344
public function record(IDomainEvent $event): void {
45+
if ($event instanceof IIntegrationEvent && $event->event_id() !== null) {
46+
throw new AlreadyIntegrated(get_class($event), $event->event_id());
47+
}
4448
if ($this->sealed && !$event instanceof IIntegrationEvent) {
4549
throw new DomainEventAfterSealException(get_class($event));
4650
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace TangibleDDD\Application\Events;
4+
5+
use TangibleDDD\Application\Correlation\CorrelationContext;
6+
7+
/**
8+
* The journey's wire form. OutboxProcessor smears __-keys into the payload bag
9+
* for transport; this is the single place they are separated back out.
10+
* Used by the WP integration_action() helper, the listener ceremony, and the
11+
* process runner's wake path.
12+
*/
13+
final class TransportEnvelope {
14+
15+
private function __construct(
16+
public readonly array $payload,
17+
public readonly ?string $correlation_id,
18+
public readonly ?int $sequence,
19+
public readonly ?string $event_id,
20+
) {}
21+
22+
public static function unwrap(array $wrapped): self {
23+
$correlation_id = isset($wrapped['__correlation_id']) ? (string) $wrapped['__correlation_id'] : null;
24+
$sequence = isset($wrapped['__sequence']) ? (int) $wrapped['__sequence'] : null;
25+
$event_id = isset($wrapped['__event_id']) ? (string) $wrapped['__event_id'] : null;
26+
unset($wrapped['__correlation_id'], $wrapped['__sequence'], $wrapped['__event_id']);
27+
28+
return new self($wrapped, $correlation_id, $sequence, $event_id);
29+
}
30+
31+
/** Restore ambient correlation + stash this event as causation for dispatched commands. */
32+
public function restore_context(): void {
33+
if ($this->correlation_id !== null) {
34+
CorrelationContext::init($this->correlation_id);
35+
}
36+
if ($this->sequence !== null) {
37+
CorrelationContext::set_sequence($this->sequence);
38+
}
39+
if ($this->event_id !== null) {
40+
CorrelationContext::set_causation($this->event_id, 'integration_event');
41+
}
42+
}
43+
}

0 commit comments

Comments
 (0)