Status: HISTORICAL AUDIT. These findings informed subsequent framework releases. Read current source, tests, and the operational guides before acting on any class name or wiring recommendation preserved here.
Date: 2026-06-24
This note captures framework-level issues found while reviewing real consumers,
mainly tangible-datastream v3 and tangible-cred. It is deliberately not a
datastream product critique. The point is to identify DDD framework contracts
that consumers already rely on, or are likely to rely on.
Each DDD-consuming plugin owns its own DDD table set. The framework is shared
code, but storage is scoped by each consumer's IDDDConfig.
So:
tangible-datastreamhas its own command audit, outbox, DLQ, process, and workflow tables.tangible-credhas its own equivalent table set.correlation_idis not shared storage.correlation_idis a propagated trace token that may appear in multiple plugin table sets if an integration/outbox/process boundary carries it across.
For an inspector dashboard, normal list views should be scoped to one context.
Only explicit trace mode should fan out across registered contexts by
correlation_id.
install_outbox_tables() creates message_kind ENUM('event','command'), but
OutboxRepository::write() inserts integration_event.
Files:
ddd-wordpress/tables.phpddd-src/Infra/Persistence/OutboxRepository.php
Why this matters:
Consumers such as datastream publish delivery intents through
OutboxIntegrationEventBus. If MySQL rejects the insert, the repository still
returns a generated event id because it does not check $wpdb->insert().
That can make capture -> match -> outbox appear successful while no outbox row
exists.
Required fix:
- Either change the enum to include
integration_event, or writeevent. - Check the return value of
$wpdb->insert(). - Throw a framework exception with
$wpdb->last_errorwhen insert fails. - Add an integration test that installs the real table and writes a real integration event.
OutboxProcessor::process_batch() calculates $new_attempts = $entry->attempts + 1.
When that reaches max_attempts, it calls move_to_dlq($entry->event_id).
move_to_dlq() then reads the old row and stores the old attempts and old
last_error, not the current exception that caused the DLQ transition.
Files:
ddd-src/Application/Outbox/OutboxProcessor.phpddd-src/Infra/Persistence/OutboxRepository.php
Why this matters:
The DLQ row is supposed to be the forensic record of the terminal failure. At the moment it can undercount attempts and preserve the previous error instead of the final one.
Required fix:
- Pass current exception message and final attempt count into
move_to_dlq(). - Or call a repository method that atomically records the final failure and moves the row.
- Test the final attempt count and final error stored in DLQ.
OutboxRepository::get_stats() queries:
SELECT COUNT(*) FROM integration_dlq WHERE resolved_at IS NULLThe DLQ table schema does not define resolved_at.
Files:
ddd-wordpress/tables.phpddd-src/Infra/Persistence/OutboxRepository.php
Why this matters:
Any dashboard, health check, or operational screen that calls get_stats() can
fail against the real schema.
Required fix:
- Add
resolved_at DATETIME NULLand related indexes if unresolved/resolved DLQ state is a real concept. - Or remove the
resolved_atpredicate and model all DLQ rows as unresolved. - Add a schema-backed test for
get_stats().
CorrelationContext is static request/process state. CorrelationMiddleware
initializes a correlation id if none exists, then unconditionally resets all
correlation state in finally.
Files:
ddd-src/Application/Correlation/CorrelationContext.phpddd-src/Application/Correlation/CorrelationMiddleware.phpddd-wordpress/integration-events.php
Correct model:
- New top-level command: generate random
correlation_id. - Outbox/integration/process boundary: restore persisted
correlation_id. - Command audit/outbox/process rows persist that value.
- Command completion should not accidentally destroy a boundary-level trace context that still wraps the current callback.
Current problem:
Two successive commands from the same integration callback are allowed but smelly. Today, the first command can reset the restored integration correlation, causing the second command to generate a new unrelated correlation id.
Required fix:
- Introduce scoped correlation management.
- Integration handlers should restore correlation around the whole callback.
- Command middleware should preserve an existing outer correlation and clear only command-local state on exit.
- A stack/scope API is enough; do not use this to support nested command dispatch.
Suggested shape:
CorrelationContext::with($correlation_id, function () {
// Boundary adapter work here.
});The framework currently has no runtime guard against command-in-command.
Architectural law:
Command dispatch is an application boundary. Command handlers may call domain services, repositories, ports, outbox/event bus, and ordinary application services. Command handlers must not dispatch commands.
Allowed:
- REST controller -> command
- WP hook -> command
- CLI/cron/admin action -> command
- Integration event callback -> command
- Boundary callback -> multiple successive commands, with a smell
Forbidden:
- Command handler -> command
Required fix:
- Add a command dispatch guard middleware, preferably outermost.
- Track command execution depth in static framework state.
- Throw
NestedCommandDispatchForbiddenif depth is already greater than zero. - Add tests proving successive boundary commands are allowed but nested command dispatch fails.
TangibleDDD\WordPress\integration_action() extracts correlation metadata and
then returns array_values($wrapped). This works for positional-scalar
integration events, but it destroys associative payload shape.
Datastream had to implement its own tds_integration_action() helper because
EventReadyForDelivery::from_args() expects a named associative payload.
Files:
ddd-wordpress/integration-events.phptangible-datastream/includes/hooks/integration/index.php
Required fix:
Add an official associative-payload helper or event reconstruction path.
Possible APIs:
integration_action_assoc(EventClass::class, function (array $payload) {});or:
integration_event(EventClass::class, function (EventClass $event) {});The second shape is cleaner if the framework can standardize event reconstruction.
ProcessRunner::continue_scheduled() and resume_on_event() call
CorrelationContext::init($process->correlation_id()), then run the process.
There is no obvious scoped restoration or reset after the process run.
Files:
ddd-src/Application/Process/ProcessRunner.php
Why this matters:
Action Scheduler can execute multiple callbacks in the same PHP process. A process continuation should not leave correlation static state behind for unrelated callbacks.
Required fix:
- Use the same scoped correlation API proposed above.
- Wrap process continuation/resume in
CorrelationContext::with(...).
OutboxRepository::fetch_pending() uses FOR UPDATE SKIP LOCKED.
File:
ddd-src/Infra/Persistence/OutboxRepository.php
Why this matters:
This is correct for modern MySQL/MariaDB versions that support it, but it is a runtime compatibility boundary. If the supported WordPress host matrix includes older database engines, outbox processing can fail.
Required fix:
- Document minimum database version.
- Or add a fallback locking strategy.
- Add a health check that confirms the configured DB supports the chosen lock syntax.
The generic DDD drill inspector should treat a plugin context as a storage boundary, not as a trace boundary.
Normal tabs:
- One selected context.
- No SQL unions across plugin table sets.
- Query that context's command audit, outbox, DLQ, long process, workflow, and storage state.
Trace tab:
- User provides a
correlation_id. - Inspector fans out across registered DDD contexts.
- Each context returns normalized trace fragments from its own tables.
- The UI merges/sorts fragments in memory.
Good trace fragment shape:
[
'context' => 'tangible-datastream',
'kind' => 'command|outbox|dlq|process|workflow|app',
'occurred_at' => '...',
'correlation_id' => '...',
'local_id' => '...',
'related_ids' => [],
'summary' => '...',
'payload_preview' => [],
]Useful inspector smell rules:
- Same boundary callback emits multiple command audit rows: warning.
- Command dispatched while another command is active: error.
- Outbox row missing
correlation_id: error. - Outbox row missing
command_id: warning unless process/system-created. - DLQ row missing final error/final attempt: error.
- Suspended LongProcess waiting for an unregistered event type: error.
- Cross-context
correlation_id: highlight as a useful propagated trace, not a bug.
- Fix outbox
message_kindmismatch and insert error handling. - Fix DLQ final failure persistence.
- Fix
get_stats()/ DLQ schema mismatch. - Add command dispatch guard.
- Add scoped correlation context and apply it to integration actions and process continuation.
- Add official associative/event-object integration callback helper.
- Document or harden the outbox locking compatibility boundary.