Commit 625b3cb
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
- .github/workflows
- ddd-src
- Application
- EventHandlers
- Events
- Process
- Domain/Events
- Infra
- Persistence
- Services
- ddd-wordpress
- docs
- superpowers
- plans
- specs
- tests
- Fakes
- Integration/Framework
- Unit
- EventHandlers
- Events
- Integration
- Process
- WordPress
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
| 5 | + | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| 52 | + | |
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 6 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
6 | 11 | | |
7 | 12 | | |
8 | | - | |
| 13 | + | |
9 | 14 | | |
10 | 15 | | |
11 | 16 | | |
| |||
Lines changed: 32 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
| 5 | + | |
5 | 6 | | |
6 | | - | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
12 | | - | |
| 11 | + | |
| 12 | + | |
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
23 | | - | |
24 | | - | |
| 23 | + | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| 6 | + | |
6 | 7 | | |
7 | 8 | | |
8 | 9 | | |
| |||
41 | 42 | | |
42 | 43 | | |
43 | 44 | | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
44 | 48 | | |
45 | 49 | | |
46 | 50 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 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 | + | |
0 commit comments