Skip to content

fix(server): stop replaying a command receipt for a different aggregate - #5246

Open
ostapondo wants to merge 2 commits into
pingdotgg:mainfrom
ostapondo:fix/orchestration-command-id-conflict
Open

fix(server): stop replaying a command receipt for a different aggregate#5246
ostapondo wants to merge 2 commits into
pingdotgg:mainfrom
ostapondo:fix/orchestration-command-id-conflict

Conversation

@ostapondo

@ostapondo ostapondo commented Aug 2, 2026

Copy link
Copy Markdown

What Changed

Command dispatch now checks that an existing receipt belongs to the same aggregate as the incoming command before replaying it. On a mismatch it fails with a new typed OrchestrationCommandIdConflictError instead of returning the original command's accepted sequence. A genuine retry (same command id, same aggregate) still replays the stored receipt unchanged.

No schema or migration changes: receipts already store aggregateKind/aggregateId, the engine just never compared them.

Why

Fixes #5231. The engine deduplicates commands by command id alone. If an accepted command id is reused for a different thread (client retry bug, id collision), dispatch returns HTTP 200 with the first command's sequence while creating nothing on the target thread — the caller reports delivery that never happened.

Verified with two new engine tests running the production persistence layers (real SQLite event store + receipt repository):

  • reusing an accepted command id against another thread now rejects, and the target thread stays untouched — this test fails on main (dispatch resolves with the first command's sequence)
  • a genuine retry still resolves to the same sequence without duplicating the user message

Both were mutation-tested: removing the aggregate comparison flips the first test red, forcing the conflict path flips the second red.

Not covered (called out in the issue as the fuller fix): reusing a command id with the same aggregate but a different payload or command type still replays the receipt. Receipts store no command fingerprint, so detecting that needs a new column; happy to follow up if you want it, but it seemed wrong to grow the receipt schema in this PR.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes — no UI changes
  • I included a video for animation/interaction changes — no animation/interaction changes

Note

Medium Risk
Changes orchestration command deduplication in the engine, which affects whether clients see success vs failure for retried or colliding command ids; scope is focused and covered by integration tests.

Overview
Command dispatch no longer treats a stored receipt as proof of success when the incoming command targets a different aggregate than the receipt. Before replaying an accepted receipt, the engine now compares aggregateKind and aggregateId on the receipt with the incoming command; a mismatch yields a new OrchestrationCommandIdConflictError instead of returning the original sequence.

Genuine retries (same command id and same aggregate) still replay the accepted receipt and keep a single user message. Read-model reconciliation after dispatch failure is skipped for this conflict error, matching behavior for previously rejected commands.

Tests cover both the conflict path (second thread stays untouched) and the retry path (same sequence, no duplicate message).

Reviewed by Cursor Bugbot for commit 4c0aa1e. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Reject command dispatch when a command ID is reused for a different aggregate

  • Adds OrchestrationCommandIdConflictError in Errors.ts to represent a command ID being reused across different aggregates.
  • In OrchestrationEngine.ts, when an existing receipt is found for a command ID, the engine now checks whether the receipt's aggregate matches the incoming command's aggregate and returns the conflict error if they differ.
  • Read model reconciliation is skipped for OrchestrationCommandIdConflictError, matching existing behavior for OrchestrationCommandPreviouslyRejectedError.
  • Re-dispatching the same command ID for the same aggregate still replays the accepted receipt as before.

Macroscope summarized 4c0aa1e.

Summary by CodeRabbit

  • Bug Fixes
    • Prevented duplicate processing when an accepted command is submitted again.
    • Reusing a command ID for a different conversation or thread now returns a clear conflict error.
    • Duplicate accepted commands preserve the original sequence and do not create additional user messages.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The orchestration engine now rejects reuse of an accepted command ID for a different aggregate. It returns aggregate details in a typed error, preserves identical-command receipt replay, skips reconciliation for conflicts, and adds integration coverage for both cases.

Changes

Orchestration command receipt validation

Layer / File(s) Summary
Conflict error contract
apps/server/src/orchestration/Errors.ts
Adds OrchestrationCommandIdConflictError with command and receipt aggregate identifiers, and includes it in OrchestrationDispatchError.
Receipt conflict dispatch handling
apps/server/src/orchestration/Layers/OrchestrationEngine.ts, apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
The engine compares existing receipt aggregates with incoming commands. Matching accepted commands replay the original receipt, while mismatches return a conflict and skip reconciliation. Tests cover both behaviors.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: juliusmarminge, justsomelegs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR rejects reused command IDs when aggregate kind or ID differs and preserves same-aggregate retry behavior required by issue #5231.
Out of Scope Changes check ✅ Passed The changes are limited to conflict handling, error typing, and focused orchestration tests within the linked issue scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly identifies the fix for replaying a command receipt across different aggregates.
Description check ✅ Passed The description explains the change, motivation, testing, scope limits, and checklist status.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Aug 2, 2026
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Aug 2, 2026
@macroscopeapp

macroscopeapp Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved 185a542

Straightforward bug fix that adds a validation check to prevent command receipts from being incorrectly replayed for a different aggregate. Limited scope, clear intent, and comprehensive test coverage for both success and failure paths.

You can customize Macroscope's approvability policy. Learn more.

@ostapondo

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts`:
- Around line 1345-1362: Extend the conflict case in the orchestration test
after the rejected system.run call to read the state for thread-conflict-b and
assert that it remains unchanged, specifically with no user message created.
Reuse the existing read-model/query helpers and fixtures used by the surrounding
tests rather than adding new test infrastructure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8d03f288-021a-4d25-8b8e-e0c90e913b66

📥 Commits

Reviewing files that changed from the base of the PR and between e60821f and 7bfb797.

📒 Files selected for processing (3)
  • apps/server/src/orchestration/Errors.ts
  • apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
  • apps/server/src/orchestration/Layers/OrchestrationEngine.ts

Comment thread apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
@macroscopeapp
macroscopeapp Bot dismissed their stale review August 2, 2026 20:16

Dismissing prior approval to re-evaluate 185a542

The orchestration engine deduplicates commands by command id alone: when a
receipt exists, dispatch returns the stored result sequence without checking
which aggregate the receipt belongs to. Reusing an accepted command id against
another thread therefore reports success while creating nothing on the target
thread.

Compare the receipt's stored aggregate with the incoming command's aggregate
and fail dispatch with a typed conflict error on mismatch. A genuine retry
(same command id, same aggregate) still replays the stored receipt.
@ostapondo
ostapondo force-pushed the fix/orchestration-command-id-conflict branch from 185a542 to 4c0aa1e Compare August 3, 2026 09:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M 30-99 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Reusing an accepted commandId for another thread returns false success

1 participant