0.2.0: integration-event taxonomy partition + await mechanisms (fan-in sagas)#9
Merged
Conversation
… 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
…ly 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).
…ationEvent 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
…egrated 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.
…nsport keys Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN
…y; deprecate AsyncWordPressActionHandler
… widening, mechanism persistence + legacy fallback
…andatory timeout, idempotent accumulation
… 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.
…aga 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
…licies) + #[Awaits] boot registration
…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.
…I 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.
…st 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
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.
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
titus-toia
added a commit
that referenced
this pull request
Jul 10, 2026
#10) * Add tangible-ddd skill (framework's own canonical copy) The DDD patterns skill lived in the lms-monorepo consumer; move the source of truth into the framework repo it documents. Includes the recent guidance additions: - Hard Rules: a command/handler never launches a command (->send()). Feeling the urge signals a modeling gap upstream; reactions are inline work or an integration event, never a nested command. Explains the why (sync in-transaction publish makes ->send() a nested-tx + EventsUnitOfWork.reset() hazard). - Async domain events demoted in favour of integration events: deferring a reaction out of the originating transaction is crossing a consistency boundary, so it's an integration event. AsyncWordPressActionHandler marked rare/last-resort with a caveat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * build(v3-ddd): include datastream v3 in ddd e2e via .reference autoload Goal: ddd's e2e/integration tests must be able to execute the real consumer (tangible-datastream v3) against the working framework. - post-install/update script clones tangible-datastream@v3-ddd into .reference/tangible-datastream (mirrors the .reference/tangible-cred convention in the ddd skill); non-fatal so CI without SSH stays green. - autoload-dev PSR-4 maps Tangible\Datastream\ -> .reference/.../src so phpunit autoloads datastream classes in-process. Not a composer require/require-dev on the consumer -> no dependency cycle (datastream requires tangible/ddd), no chicken-and-egg with path repos. Tip for local work: symlink .reference/tangible-datastream to your working copy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * fix(persistence): remove illegal parameter narrowing in BehaviourWorkflowRepository::save() PHP 8 rejects a child-class override that narrows the parameter type from the parent's save(Aggregate) to save(BehaviourWorkflow) — this is a Liskov violation and triggers a fatal on class load. The override was redundant (body was parent::save($workflow)) so removing it is safe. The IBehaviourWorkflowRepository interface's save(BehaviourWorkflow) signature is still satisfied via the parent's broader save(Aggregate) through the PHP type-contravariance rule. Discovered during slice 8 of tangible-datastream v3 (DeliveryEscalationHandler integration test first triggered class-loading of BehaviourWorkflowRepository). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * fix(typedlist): re-index sequential lists in filter() (fixes filtered iteration + BehaviourWorkflow fork) array_filter() preserves array keys. For sequential (non-associative) lists this leaves non-contiguous integer keys after filtering, but the iterator walks $_position 0,1,2,... and valid() does isset($_list[$_position]). A filtered list with keys {1,3} makes valid() return false at position 0, causing the list to iterate as empty. WorkItemList::failed() → filter(clone:true) is the hot path: non-contiguous failures in a batch were silently dropped during BehaviourWorkflow fork transfer. Fix: in filter(), after array_filter(), call array_values() when $_is_associated is false (sequential mode). Associative lists keep their keys intact — $_is_associated is only set true in offsetSet() when a non-empty index is provided, so this is safe for all existing callers (ksort tests, string-keyed lists, etc.). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * feat(loader): Action-Scheduler-style version negotiation so multiple plugins can bundle ddd (newest-wins, fixes redeclare) Mirror Action Scheduler's multi-copy coexistence pattern: - Tangible_DDD_Versions registry (class_exists-guarded singleton) with register(), latest(), initialize_latest(), all_registered(), winner(), unmet_minimums() diagnostics - Version-named tangible_ddd_register_0_2_0_dev() (function_exists-guarded) hooks at plugins_loaded pri 0; initialize_latest() fires at pri 1 - Winner's tangible_ddd_initialize_0_2_0_dev() prepends an spl_autoload for TangibleDDD\ (beats any consumer psr-4) and require_once's procedural ddd-wordpress/*.php files exactly once - composer.json autoload.files reduced to ["tangible-ddd.php"] — removes the heavy ddd-wordpress eager-loads that caused Cannot redeclare fatals - composer autoload.psr-4 drops TangibleDDD\WordPress\ (now owned by the winner's initializer); keeps TangibleDDD\ -> ddd-src/ for dev/test - Version bumped to 0.2.0-dev - 9 new unit tests for Tangible_DDD_Versions: newest-wins, multi-load-safety, min-version/unmet_minimums, single-copy, and diagnostic accessors - All 215 tests pass (206 pre-existing + 9 new) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * refactor(loader): separate consumer min-version (require_version) from the copy pool A consumer is not a ddd copy and must never enter winner selection. register() now takes only (version, path, callback); a new require_version(consumer, min) records consumer minimums in a separate $requirements map that unmet_minimums() checks against the winner. Removes the prior smell where a consumer was registered as a fake version ('tangible-datastream-consumer') into the same pool latest() ranks. Tests assert a requirement never pollutes all_registered()/latest(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * feat(integration): add real-DB E2E harness + command-bus test - Bootstrap boots WP against db_test, then initialises the datastream DI container from .reference/tangible-datastream as the host. - IntegrationTestCase: per-test SET autocommit=0 + START TRANSACTION / ROLLBACK isolation for plain DML tests. - CommandIntegrationTestCase: no outer transaction (ITransactionalCommand causes TransactionMiddleware to COMMIT, which would auto-commit any outer tx); cleanup_rows() DELETE pattern instead; resets EventsUnitOfWork + CorrelationContext singletons each test. - CommandBusE2ETest: drives RecordCapturedEventCommand through the full pipeline — 15 assertions covering row persistence, JSON round-trip, EventCaptured WP action, and repository reconstitution. - Added symfony/yaml to require-dev (needed by Symfony DI YAML loader). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * add E2E integration tests for outbox primitives and WorkflowHandler fork - OutboxPrimitiveTest: exercises the full outbox lifecycle (write → completed, mark_failed, move_to_dlq, OutboxProcessor success/DLQ paths) against real DB. - BehaviourWorkflowForkTest: drives a WorkflowHandler with non-contiguous partial failures; asserts the child workflow is created with only the failed items reset to pending, and successful items retain done status on the parent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * docs(ddd-skill): integration-event consumers are thin listeners that dispatch a Command Add 'Consuming one' guidance to the Integration Events section: thin listener -> Command, includes/hooks/integration/ by bounded context, the fat-IEventHandler anti-pattern, and the return-verdict-not-throw rule (a throw inside TransactionMiddleware rolls back the log row). Two checklist items reinforce it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * feat(outbox): fire additive DLQ action when entry dead-letters Consumers can react to a dead-lettered outbox entry via the prefixed {prefix}_outbox_dlq action (plus a generic tangible_ddd_outbox_dlq fallback) fired right after move_to_dlq. Additive and guarded by function_exists(do_action); existing consumers are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * fix(correlation): scope correlation so processes + their commands share one trace CorrelationMiddleware reset the correlation context unconditionally after every command. That shredded two real cases: 1. A LongProcess step that dispatches multiple commands (the designed Result->commands pattern): the first command's reset wiped the saga correlation, so every later command — and the process's own post-dispatch work — got a fresh, unrelated id. Multi-command sagas could not be traced. 2. ProcessRunner continuation/resume called CorrelationContext::init() with no reset; since Action Scheduler reuses one worker for many callbacks, the process correlation leaked into the next unrelated callback (false trace attribution). Fix: add a scope STACK to CorrelationContext (enter/leave/with/depth). The context is only torn down when the OUTERMOST scope exits; nested scopes restore their parent's correlation. CorrelationMiddleware now enter()/leave()s instead of init/reset — top-level command behaviour is unchanged (enter then leave at depth 0 == old init + reset), but a command dispatched inside a process scope no longer wipes it. ProcessRunner wraps continuation + resume in CorrelationContext::with(), scoping the run and clearing it on exit. The stack is intentionally not a bare counter: frames can later carry a span id + kind (command|process|workflow) for parent/causation edges without reworking this class. Regression tests: multi-command-in-scope shares correlation; resumed process doesn't leak; top-level command still clears. ddd unit 218/218; datastream unit 227 + integration 210 unaffected (loads this copy via symlink). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * feat(schema): version-gated dbDelta auto-migration Convert table install to dbDelta so existing installs heal additive schema changes, gated by a framework DDD_SCHEMA_VERSION + a per-prefix installed-version option, run on admin_init (robust to non-WP-updater deploys). Hybrid: dbDelta for additive, explicit migrations for the rest. Adds the causation columns (causation_id/causation_type + idx_causation) to command_audit as schema v2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * feat(trace): command causation + assoc-aware boundary CorrelationContext gains a causation slot (parent edge: integration_event | long_process); CommandAuditMiddleware records it then consumes it so it can't bleed. The integration boundary now stamps causation from __event_id (previously dropped) instead of discarding it, and preserves associative payloads (array_is_list gate) while still spreading positional lists — backwards compatible with every existing consumer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * feat(observability): infrastructure-event tier Formalize the framework's out-of-band "machinery reporting on itself" events: IInfrastructureEvent + base (self-dispatching, guarded) + four classes (OutboxDeadLettered, OutboxAttemptFailed, ProcessFailed, WorkflowFailed). infrastructure_action() listen helper restores the carried correlation + causation before the callback, so a reaction rejoins the originating trace instead of being born orphaned. Wire emits: OutboxProcessor (dead-letter + attempt-failed), ProcessRunner (process-failed, carries long_process causation on step-dispatched commands), WorkflowHandler (workflow-failed, opt-in via $infra_config). Fix move_to_dlq to store the true final error (the final attempt skips mark_failed, so the row's last_error was the prior attempt's). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * feat(ddd): tangible-ddd as a consumer of itself + operational commands Stand up the framework's own DDD stack so it can own bus-dispatched, self- auditing operational commands: - Infra/Config (prefix 'tangible_ddd') → its own six tables (wp_tangible_ddd_*) - ddd-wordpress/self/ DI container (index.php + services.yaml importing the framework template + tactician.yaml + a correct HandlerClassNameInflector; the framework's own ddd-wordpress/di/ copies are stale vs the installed tactician layout) - Application/Commands/Command base (CommandBusAware → self di()) - 4 operational command+handler pairs: ReplayDeadLetter, DiscardDeadLetter, RetryDelivery, PurgeOutbox — prefix-parameterized, operate on a TARGET consumer's outbox/dlq via $wpdb, self-audit into tangible_ddd.command_audit - Application/Support/ConsumerTables (validated prefix → table name) - self-consume hook in tangible-ddd.php at plugins_loaded pri 20: winner-pathed, once, try/catch (must never fatal consumers), registers migration hooks Verified on ddev: PurgeOutbox + ReplayDeadLetter dispatch through the bus, run their handlers, and self-audit (source=cli / user). Local checkpoint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * docs(dashboard): build outline + preserved design iterations - docs/dashboard/BUILD-OUTLINE.md: grounded scope doc (ground truth, surfaces, relationship/UX nav graph, read-layer/REST/Heartbeat plan, phases, open Qs) - docs/dashboard/iterations/: archived design explorations (4-direction studies, kitchen sinks, logo explorations, Warm Blueprint screens, trace explorer) + the original dark dddash prototype, with an index mapping to live artifacts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * docs(topology): multi-item behaviour topology — plurality-aware behaviours Capture the execution architecture behind multi-ledger-item behaviours: the interface lego (ILedger, IOutcomeInterpreter, VerdictMap, IItemSelector, IItemAction, map/fold/partition topologies, LedgerBehaviourRunner); the push-context-to-the-edges abstraction test (blanket response = uniform VerdictMap); and the proof that MultiBoardReportingProcess is the same shape at board granularity. YAGNI extraction order: IOutcomeInterpreter+VerdictMap seam first (two callers today), rule-of-three for the rest. Design capture, not in build scope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * docs(topology): per-format decoder edge + Behaviour<T,Cardinality> + reset-behaviour build decision Add Q1 (JMESPath/XPath/CSV decoder edge, normalize-to-record-stream, single locator field) and Q2 (Behaviour<ItemType,Cardinality>, the UserTriggeredRetry <Earning,One> footgun, PHP encoding via item_type()/cardinality()/accepts(Ledger) + PHPStan @template + UX filtering). Record the decision to build a concrete ResetUserCourseProgressBehaviour (Behaviour<Earning,Many>, code-only no UI) to satisfy plurality without the generic lego. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * fix(behaviours): restore batch-accumulator getters on BehaviourExecutionResult get_all_success_batch / get_all_error_batch / get_unresolved_error_batch were added in 4d0ba9e then dropped in 1f6ea33's refactor toward a work-item ledger — but the ledger migration never landed and cred's execute_batch still calls them, so the batch/fork path fataled once should_batch was un-inverted. Restored verbatim; they recurse over $history, which the current model still maintains. The work-item ledger (items->done()/failed()) remains the longer-term direction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * test(workflow): decisive saga e2e de-risk — SagaThroughWorkflowHandlerTest Drive a multi-phase ISagaBehaviour through WorkflowHandler end-to-end using in-memory repos and a phase-switching concrete subclass. This is the go/no-go gate for the cred multi-phase saga migration. Results (5 tests, 39 assertions): A1 GREEN — phase advance: saga flows 1→2→3→complete_saga(), never stalls A2 GREEN — waiting + re-entry: same item identity found on re-run (no dup) A3 GREEN — waiting reschedule: engine does NOT auto-reschedule on waiting A4 GREEN — preempted: maps to WorkItemStatus::skipped → step resolves as completed → phase advances normally A5 RED — cancelled: execute_one(cancelled) is washed to WorkItemStatus::skipped → aggregate_status()=done → result_from_item_status(done)=completed → maybe_advance increments phase instead of calling complete_saga(). All 3 phases executed; cancelled intent lost. MINIMAL FIX (not applied — report only): WorkflowHandler::result_from_item_status() (or resolve_step_state) must distinguish "all items were skipped due to cancel" from "all items succeeded". Add WorkItemStatus::cancelled and map execute_one(cancelled)→cancelled in status_from_result(); then aggregate_status() can return cancelled, and result_from_item_status returns BehaviourExecutionStatus::cancelled for correct saga-abort. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * fix(behaviours): preserve cancelled through the work-item ledger (saga abort) The de-risk saga-through-WorkflowHandler test found cancelled was lost: no WorkItemStatus::cancelled, so execute_one(cancelled) collapsed to skipped→done→ completed and maybe_advance INCREMENTED the phase instead of aborting the saga. Add WorkItemStatus::cancelled + round-trip it (status_from_result, aggregate_status priority, result_from_item_status) so maybe_advance receives cancelled and calls complete_saga(). Saga gate test flipped green; full suite 244/244, no regressions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * fix(logging): Redactor logs enum value/name, not [ClassName] process_object handled DateTimeInterface / to_json() / JsonSerializable but fell through to '[' . get_class() . ']' for enums, so command-audit params showed e.g. a schedule as "[…\EndpointDefinitionSendingSchedule]" instead of "every_4_h". Add BackedEnum (->value) and UnitEnum (->name) cases. +2 regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * fix(behaviour-items): allow 'cancelled' in work-item status ENUM WorkItemStatus::cancelled is written by WorkItemRepository::save() but the DDL ENUM omitted it, truncating cred Stop-workflow ledger rows. Add it. Integration test created at tests/Integration/Persistence/WorkItemRepositoryCancelledTest.php. Harness path taken: DDEV integration DB unavailable locally; test file is ready for when the harness runs (ddev exec phpunit -c phpunit.integration.xml). DDL grep verification: status ENUM now includes 'cancelled' as sixth value. Unit suite: 246 tests, 587 assertions — all pass, no regression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * fix(workflow): partial-failure fork gate + InvariantException handling Two behavioral fixes: 1. Fork only on partial failure. maybe_fork_or_fail() was forking on ANY failed item in a BatchableBehaviourConfig. When ALL items fail (e.g. single-earning batch), forking is pointless — the full workflow should retry via needs_rescheduling. Gate added: fork only when count(items) > count(failed_items) (some items succeeded alongside the failures). 2. Catch InvariantException from execute_one in handle_workflow. When execute_one throws InvariantException (e.g. unrecognised behaviour type), the exception previously propagated uncaught. Now handle_workflow catches it, calls workflow->fail(), saves, and returns — matching the semantic intent of "invalid behaviour type → workflow fails gracefully". 3. MySQL ENUM fix in OutboxRepository. Schema: ENUM('event','command'). Code was inserting 'integration_event'. MySQL STRICT_TRANS_TABLES = ERROR 1265. Fixed to 'event' in write() and entry_from_row() fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * fix(workflow): log InvariantException before failing workflow Programming errors caught by the InvariantException guard were silently swallowed — the workflow was marked failed with no trace in the logs. Add an error_log() call (matching the codebase's sprintf+prefix style) so operators can see workflow id and exception message in the error log. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * fix(test): correct WorkflowHandlerTest for new all-fail fork gate Renames test_forking_on_batchable_failure → test_all_failed_batchable_reschedules_instead_of_forking. The old test asserted that all-failed batchable steps fork a child workflow, which is the behavior Task 7 intentionally removed. The new test asserts the approved contract: all-fail → no fork, whole workflow rescheduled for step-level retry. Adds test_partial_failure_forks_child_workflow to lock the gate's positive case (partial success → fork). Suite: 247 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * feat(schema): promote correlation_id to behaviour_workflows (v3) - tables.php: add `correlation_id CHAR(36) NULL` + `KEY idx_correlation` to install_behaviour_workflow_tables(), matching the style of command_audit and long_processes - migrations.php: bump DDD_SCHEMA_VERSION 2→3; add explicit v3 migration (ddd_add_column_if_missing after is_failed + ddd_add_index_if_missing) - BehaviourWorkflowRepository::persist(): stamp correlation_id from CorrelationContext::peek() on every save (NULL-safe; mirrors how WorkflowFailed reads context without generating a spurious id) Verified: 247 unit tests green; live migration confirmed wp_tangible_datastream_behaviour_workflows and wp_tgbl_cred_behaviour_workflows both gain column + index on admin_init. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * feat(outbox): relay pause primitive + move OutboxProcessor to Infra\Services Framework support for the datastream delivery pause (panic button), plus a layer correction and an e2e harness repair surfaced by the consumer audit. All four suites green (ddd 247 unit / 12 e2e). Relay pause (fold-in): - IOutboxRepository gains set_pause(holder, selector, until) / clear_pause / is_paused. fetch_pending excludes paused event types; a '*' selector holds everything. Holds are option-backed, per-context via IDDDConfig, multi-holder (release by holder), with -1 = indefinite or a unix-ts auto-expiry. Pause is a relay-lifecycle state: rows are never touched — paused rows stay pending and drain when released. Layer fix: - Move OutboxProcessor + ProcessingResult from Application\Outbox to Infra\Services. It is a transactional-outbox relay (transport/persistence mechanics, no domain), not an application use case; its adapters already live in Infra\Services. Refs updated: hooks.php, ddd-wordpress DI, and tests. E2e harness: - Integration bootstrap loads wp-admin/includes/upgrade.php so dbDelta() is defined (table installers were fataling), and requires/calls the datastream RuleNodeRegistrar (register() moved out of the Domain RuleRegistration). Tests: OutboxPauseTest (exact-type held + clear restores; wildcard holds all). FakeOutboxRepository implements the new methods. Design: docs/outbox-pause-design.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(events): 0.2.0 integration-event taxonomy + await mechanisms design - Full spec: partition taxonomy (DomainEvent raisable / IntegrationEvent derived-only), self-publishers, RecordBehaviour codec, journey slots + AlreadyIntegrated guard, IAnnouncesIntegration single-door router, IAwaitMechanism/AwaitAll fan-in, #[Awaits], runner repair, hard-break migration + deploy choreography, decision record (~20 rejected alts) - Handoff doc (integration-event-evolution.md) for parallel sessions - SKILL.md: 0.2.0 banner + forward-notes on dying v0.1 patterns - Also: dashboard-drill plan + consumer-review issues docs (other sessions), e2e report update, gitignore session scratch dirs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN * 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> --------- Co-authored-by: Titus TC <titus@teamtangible.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
The 0.2.0 event-architecture rebuild, per the spec in
docs/superpowers/specs/2026-07-03-integration-event-taxonomy-and-await-mechanisms-design.md(see also the handoff narrativedocs/integration-event-evolution.md). 18 commits, 11 planned tasks, each implemented+reviewed by isolated subagents with fix loops, plus a final whole-branch review.Deliberately reverse-incompatible. Consumers are obligated to migrate (spec §7).
Architecture
Eventroot → raisableDomainEvent/ severedIntegrationEvent(derived-only record, NOT anIDomainEvent— twins are unraisable at type level). Born-scalar facts opt in explicitly as self-publishers (extends DomainEvent implements IIntegrationEvent, IAnnouncesIntegration { use IntegrationBehaviour; }).IntegrationBehaviourtrait — the codec: strictscalarise(throwsNonReversibleValue— the membrane now refuses cheques it can't cash), totalfrom_payloadhydration (ctor is the schema), nullable journey slots (correlation_id/event_id, stamped at publish AND hydrate — the "already integrated" bit) doubling as theAlreadyIntegratedre-raise guard inEventsUnitOfWork::record().instanceof IAnnouncesIntegration → bus->publish($event->to_integration())— fat moments announce twins, self-publishers announce themselves.TransportEnvelope— one unwrap point for__-transport keys; correlation/causation restored on every consumption path.IntegrationListener— thin policy base (get_event_class()+get_command(): ?ICommand), auto-wired via\Application\IntegrationListeners\;AsyncWordPressActionHandlerdeprecated (removal 0.3.0).IAwaitMechanismstrategy;AwaitEventrefactored on (1-of-1, byte-compatible);AwaitAllfan-in (process-ownedkey_bystatic extractor, idempotent accumulation, mandatory wall-clock timeout withfail|proceedpolicies, stale-alarm guard). Runner: accumulate-until-satisfied,GET_LOCKper process, envelope unwrap + journey stamp on wake,AwaitedEventNotRegisteredsuspend-time guard (never relabelled as saga failure).#[Awaits]class attribute replaces per-saga YAML.await_mechanismcolumn + v4 migration (legacy suspended rows reconstructAwaitEvent— zero-migration resume).Evidence
EventPipelineTest) — raise → router → outbox → processor wrap → AS → hook → typed hydrated wake → fan-in saga completes; correlation/event-id VALUES asserted across the hop. Passed first run against the real stack.run()no longer relabels registration-guard trips as business failures;handle_timeoutarms the resource governor + serializes via process lock; schema v4 migration (Critical — column would never reach upgraded installs; live-verified against the shared ddev DB).IssueRetroactiveAccreditationsOnAgencyJoinHandlerdecomposition, explicit composer pin, outbox-drain deploy.v3-ddd: currently FATALS against this branch —DestinationVerified/EventReadyForDelivery(autoload:IEventFromArgsrequiresaction()gone from severed base; publish: private ctor props vs trait codec) + its saga's YAML-awaited event. Composer pin floats ("*") so the shared checkout is the exposure — the local site's WP-CLI/admin_init breaks while this branch is checked out. Migrate datastream like cred, or pin, before landing anywhere datastream is active.Backlog (triaged non-blockers from review)
revive()hardcodesDateTimeImmutableforDateTimeparams; union types uncoerced.OutboxRepository::fetch_pendingwildcard-pause returns before internal COMMIT → stale options-cache pause under rolled-back txn.TransportEnvelope(string)cast on pathological non-scalar__correlation_id;correlation_id=''stamp when event_id set but correlation null.ProcessRepository::save()triple getter call; corrupted_classJSON doesn't fall through to legacy.AwaitAll::accumulate()soft-invariant (accepts-first contract).resume_argumentcleared on normal return only; guard-trip leaves row 'running' (documented ops signature); no suspend→resume→suspend test.GET_LOCKheld across resumedrun()— load-check before high fan-in traffic; fail-open whenget_varreturns NULL (consider!== '1').undo_index=-1ambiguity — resources check afteradvance_compensationcan strand saga post-final-compensation (fix sketch in ledger).#[Awaits]repeatable untested ×2;key_byhydrate could be constrained to the owning process class.🤖 Generated with Claude Code
https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN