Skip to content

0.2.0: integration-event taxonomy partition + await mechanisms (fan-in sagas)#9

Merged
titus-toia merged 19 commits into
v3-dddfrom
release/0.2.0
Jul 10, 2026
Merged

0.2.0: integration-event taxonomy partition + await mechanisms (fan-in sagas)#9
titus-toia merged 19 commits into
v3-dddfrom
release/0.2.0

Conversation

@titus-toia

Copy link
Copy Markdown
Contributor

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 narrative docs/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

  • Partition: Event root → raisable DomainEvent / severed IntegrationEvent (derived-only record, NOT an IDomainEvent — twins are unraisable at type level). Born-scalar facts opt in explicitly as self-publishers (extends DomainEvent implements IIntegrationEvent, IAnnouncesIntegration { use IntegrationBehaviour; }).
  • IntegrationBehaviour trait — the codec: strict scalarise (throws NonReversibleValue — the membrane now refuses cheques it can't cash), total from_payload hydration (ctor is the schema), nullable journey slots (correlation_id/event_id, stamped at publish AND hydrate — the "already integrated" bit) doubling as the AlreadyIntegrated re-raise guard in EventsUnitOfWork::record().
  • Single-door router: 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\; AsyncWordPressActionHandler deprecated (removal 0.3.0).
  • Await mechanisms: IAwaitMechanism strategy; AwaitEvent refactored on (1-of-1, byte-compatible); AwaitAll fan-in (process-owned key_by static extractor, idempotent accumulation, mandatory wall-clock timeout with fail|proceed policies, stale-alarm guard). Runner: accumulate-until-satisfied, GET_LOCK per process, envelope unwrap + journey stamp on wake, AwaitedEventNotRegistered suspend-time guard (never relabelled as saga failure). #[Awaits] class attribute replaces per-saga YAML.
  • Schema: await_mechanism column + v4 migration (legacy suspended rows reconstruct AwaitEvent — zero-migration resume).

Evidence

  • Unit: 290/290 (703 assertions). Integration (ddev, real WP+MySQL): 12/12.
  • End-to-end pipe test (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.
  • Notable fixes from review loops: run() no longer relabels registration-guard trips as business failures; handle_timeout arms the resource governor + serializes via process lock; schema v4 migration (Critical — column would never reach upgraded installs; live-verified against the shared ddev DB).

⚠️ Consumer-migration blockers (by design + one discovered)

  • tangible-cred: planned migration (separate plan): rewrite 9 events (scalar-native/twin/demote), 5 hook files → listeners, IssueRetroactiveAccreditationsOnAgencyJoinHandler decomposition, explicit composer pin, outbox-drain deploy.
  • tangible-datastream v3-ddd: currently FATALS against this branchDestinationVerified/EventReadyForDelivery (autoload: IEventFromArgs requires action() 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)

  1. Codec revive() hardcodes DateTimeImmutable for DateTime params; union types uncoerced.
  2. Pre-existing, pause lane: OutboxRepository::fetch_pending wildcard-pause returns before internal COMMIT → stale options-cache pause under rolled-back txn.
  3. TransportEnvelope (string) cast on pathological non-scalar __correlation_id; correlation_id='' stamp when event_id set but correlation null.
  4. ProcessRepository::save() triple getter call; corrupted _class JSON doesn't fall through to legacy.
  5. AwaitAll::accumulate() soft-invariant (accepts-first contract).
  6. Runner: resume_argument cleared on normal return only; guard-trip leaves row 'running' (documented ops signature); no suspend→resume→suspend test.
  7. GET_LOCK held across resumed run() — load-check before high fan-in traffic; fail-open when get_var returns NULL (consider !== '1').
  8. Pre-existing: undo_index=-1 ambiguity — resources check after advance_compensation can strand saga post-final-compensation (fix sketch in ledger).
  9. #[Awaits] repeatable untested ×2; key_by hydrate could be constrained to the owning process class.
  10. v4 migration idempotent-skip path untested (same coverage as v2/v3).

🤖 Generated with Claude Code

https://claude.ai/code/session_011L6cwv6s34nJew65PC7jKN

titustangible and others added 19 commits July 6, 2026 20:37
… 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
… widening, mechanism persistence + legacy fallback
… 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
…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 titus-toia merged commit 625b3cb into v3-ddd Jul 10, 2026
2 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants