Skip to content

Replay Outgoing Messages When Inbox Receives Duplicate#4067

Open
iancooper wants to merge 83 commits into
masterfrom
replay_on_seen
Open

Replay Outgoing Messages When Inbox Receives Duplicate#4067
iancooper wants to merge 83 commits into
masterfrom
replay_on_seen

Conversation

@iancooper

Copy link
Copy Markdown
Member

When an Inbox receives a duplicate message, we may choose to replay any outgoing messages that it triggered when it was originally called. This makes us idempotent, in that our behavior matches that of the original call. It also allows us to recreate messages for downstream consumers who might have failed, by triggering from upstream, thus allowing us to replay a whole workflow until we reach the point where it failed.

Closes #2541

iancooper and others added 2 commits April 17, 2026 18:40
Introduces the design for CausationId tracking and OnceOnlyAction.Replay,
enabling automatic replay of downstream outbox messages when the inbox
detects a duplicate command. Accepted after 7 rounds of adversarial review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…cate

Create and approve implementation task list (23 tasks) for the replay
outbox messages feature. Tasks cover structural prerequisites, core
behavior (CausationId propagation, UseInboxHandler replay logic,
pipeline validation), telemetry, DI registration, and all 18 persistent
store implementations. Minor ADR clarification for ReplayCausation
semantics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@iancooper iancooper self-assigned this Apr 17, 2026
@iancooper iancooper added 2 - In Progress feature request .NET Pull requests that update .net code V10.X labels Apr 17, 2026
codescene-delta-analysis[bot]

This comment was marked as outdated.

@claude

claude Bot commented Apr 17, 2026

Copy link
Copy Markdown

Code Review — PR #4067: Replay Outgoing Messages When Inbox Receives Duplicate

This PR contains design and specification documents only — no implementation code yet. The changes are: PROMPT.md (root), docs/adr/0057-replay-outbox-on-inbox-duplicate.md, and 12 spec files under specs/0027-replay-matching-outbox-events-when-inbox-has-already-seen/.


🔴 Critical: PROMPT.md should not be committed

PROMPT.md at the repo root is an AI session state artifact — it tracks which round of ADR review was completed, which spec phases are approved, and what "next step" the AI assistant should take. This is an internal working document for the development workflow tool, not project documentation.

Recommendation: Remove PROMPT.md from this PR and add it to .gitignore. Its content is already captured in the spec files and ADR that are correctly committed.


✅ ADR Quality

docs/adr/0057-replay-outbox-on-inbox-duplicate.md is thorough and follows the project's ADR conventions well:

  • Problem statement is clear: no mechanism to replay a failed workflow from an upstream trigger
  • Decision rationale is solid — using CausationId as a scoped, single-invocation concept (vs. CorrelationId for request-reply, vs. future WorkflowId) avoids conceptual collision
  • The SupportsCausationTracking() runtime schema check is a correct defensive choice — fail fast at startup rather than at runtime
  • The tidy-first prerequisite list (tasks 1–4) correctly separates structural refactors from behavioral changes per project conventions

Design Observations

Interface duplication on SupportsCausationTracking()

Both IAmACausationTrackingInbox and IAmACausationTrackingOutbox expose both sync and async variants:

bool SupportsCausationTracking();
Task<bool> SupportsCausationTrackingAsync(CancellationToken cancellationToken = default);

This is a schema check that typically only needs to run once at startup (or can be cached). Consider whether the async variant adds real value, or if a single sync check (cached after first call) is sufficient. Most schema-existence checks against relational databases complete in microseconds on a live connection. The async overload adds interface surface that every implementing store (all 18 of them) must provide.

ReplayCausation args parameter

void ReplayCausation(string causationId, RequestContext? requestContext,
    Dictionary<string, object>? args = null);

The args dictionary follows the existing Brighter convention for extensibility, which is fine. However, the ADR does not document what keys/values are valid. Adding a brief note about expected args (or confirming none are currently used) would help implementors.

UseInboxHandler prerequisite: switching from InitRequestContext() to this.Context

The ADR correctly flags this as a tidy-first change (Task 1), but notes it "affects the existing Throw and Warn paths as well." This is a subtle behavioral change: existing tests may have been written against the old private context. The task spec calls this out with "needs focused regression tests" — make sure the existing When_Handling_A_Command_Once_Only_With_Throw_Enabled and Warn tests still pass after the refactor, not just after the full implementation.

Describe() global inbox injection

Task 3 adds global inbox attributes to Describe() output to match Build(). The ADR notes this is a shared infrastructure change that "should be treated as a tidy-first structural change with its own focused test coverage." This is the right call — the existing pipeline validation test suite should be run as a regression gate before any behavioral tasks proceed.

Relay vs. Replay enum name

The ADR (round 4 finding #3) notes that the requirements use "e.g." so Replay is not mandated. The name Replay is clear and consistent with the feature concept — this is fine as-is.


Spec Completeness

The tasks.md (23 tasks) is very well structured with clear TDD commands, file locations, and dependency ordering. A few observations:

  • Task 4 (expose Outbox from IAmAnOutboxProducerMediator) is a public API surface change. The ADR should explicitly document this interface change as a breaking/additive decision, not just as an implementation prerequisite.
  • Tasks 19–22 (all 18 persistent stores) are large in scope. The task list correctly gates these behind base test classes (Task 18), but there should be an explicit note about whether the persistent store migration SQL is expected in this PR or arrives separately. The ADR says "a separate PR provides migration tooling" — the tasks.md should reference that dependency.
  • Task 16 (add telemetry to existing Throw/Warn/Add paths) is labeled a "tidy improvement" but is included in the same tasks.md. Consider whether this should be a separate spec/PR to keep scope clear.

Summary

Area Status
PROMPT.md in repo root ❌ Should be removed
ADR quality and completeness ✅ Well-written
Interface design ⚠️ SupportsCausationTracking async variant worth revisiting
Tidy-first discipline ✅ Correctly scoped
Task list TDD discipline ✅ Follows project /test-first conventions
Scope creep (Task 16) ⚠️ Consider separating telemetry tidy from feature work
Persistent store migration dependency ⚠️ Needs explicit cross-PR reference in tasks.md

The core design is sound. The main ask is removing PROMPT.md before merge; the interface and scope observations are lower priority suggestions.

iancooper and others added 2 commits April 20, 2026 17:26
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
codescene-delta-analysis[bot]

This comment was marked as outdated.

@claude

claude Bot commented Apr 20, 2026

Copy link
Copy Markdown

Code Review — PR #4067: Replay Outgoing Messages When Inbox Receives Duplicate

Overview

This PR is a specification and design phase PR only — it contains requirements, ADR 0057, and task definitions for the Replay-on-Inbox-Duplicate feature. No production code has been written yet (the PROMPT.md explicitly states "Implementation — not started"). The 1606 additions are almost entirely documentation and spec artefacts.


Issues

🔴 PROMPT.md Should Not Be Merged

PROMPT.md is a Claude Code session-resume artefact containing internal workflow state (task review history, implementation notes). It is simultaneously committed and gitignored in this PR, which means it will persist in the repository's git history even after being ignored for future commits.

Recommendation: Remove PROMPT.md from the branch before merging. The .gitignore entry is the right call, but the file itself should be deleted so it does not land in master's history.

🟡 README.md for Spec 0027 Shows Tasks as Incomplete

specs/0027-.../README.md shows:

- [ ] Tasks (`tasks.md`)

...but .tasks-approved is committed in the same PR, meaning tasks are approved.

Recommendation: Update the README checkbox to [x] Tasks.

🟡 Inconsistent specs/.current-specspecs/.current_spec Rename

The PR deletes specs/.current-spec (hyphen) and updates specs/.current_spec (underscore). This is a housekeeping fix but worth a brief mention in the PR description so reviewers understand the intent.


ADR 0057 Quality

The ADR is thorough and well-reasoned. Specific strengths:

  • Non-breaking opt-in design via role interfaces (IAmACausationTrackingInbox, IAmACausationTrackingOutbox) is the right approach. Third-party implementations continue to work unchanged.
  • Pipeline validation at startup using Specification<HandlerPipelineDescription> is consistent with ADR 0053 and catches misconfiguration before any message is processed.
  • Tidy-first prerequisites are clearly separated from behavioral changes — switching UseInboxHandler to use the pipeline's this.Context before adding the Replay path is correct.
  • Race condition acknowledged (sweeper dispatches while ReplayCausation clears DispatchedAt): the decision to accept this in favour of downstream inbox deduplication is sound and correctly documented.

One design consideration worth flagging:

SupportsCausationTracking() I/O cost at startup: The ADR describes this as a "runtime schema check" — for relational stores, this almost certainly requires a round-trip to the database (e.g. querying INFORMATION_SCHEMA). Pipeline validation runs at startup, and if the check is synchronous and hits the DB, it adds latency to application startup. For async stores this is worse as there will be a sync-over-async call path. The ADR should document whether this check is cached after the first call, and whether the async variant (SupportsCausationTrackingAsync) is used during async pipeline validation.


Spec / Requirements Quality

  • Requirements are clear and well-scoped.
  • Out-of-scope items (saga orchestration, immediate-send replay, migration tooling) are explicitly listed — good.
  • AC-8 covers all persistent stores, which aligns with the ADR's 18-store list.
  • Minor typo in requirements.md line 507: "Brigter consumer" and "te re-execution" / "re never actioned" — these don't affect correctness but should be fixed before merge.

Summary

Area Status
PROMPT.md in commit history ❌ Remove before merge
specs/0027/README.md tasks checkbox ⚠️ Update to [x]
ADR 0057 design quality ✅ Solid
SupportsCausationTracking() startup I/O cost ⚠️ Document caching/async behaviour
Requirements completeness ✅ Good — minor typos only
Non-breaking / opt-in design ✅ Correct approach

The design is sound and the specification artefacts are high quality. The main blocker before merging is removing PROMPT.md from the branch history.


Review generated by Claude Code

codescene-delta-analysis[bot]

This comment was marked as outdated.

@claude

claude Bot commented Apr 20, 2026

Copy link
Copy Markdown

Code Review — PR #4067: Replay Outgoing Messages When Inbox Receives Duplicate

Note: This PR is a specification/planning PR — it contains no implementation code. All changes are documentation, ADR, and spec artifacts. The review below is therefore focused on the design quality and planning artifacts.


What's in this PR

  • .gitignore updated to exclude PROMPT.md
  • PROMPT.md — a Claude Code session resume file (accidentally committed)
  • docs/adr/0057-replay-outbox-on-inbox-duplicate.md — comprehensive ADR (624 lines)
  • specs/0027-replay-matching-outbox-events-when-inbox-has-already-seen/ — full spec: requirements, tasks (23 tasks), design review, approval markers
  • Review/approval artifacts for specs 0022 and 0026
  • .current_spec pointer updated; duplicate .current-spec (hyphen) file deleted

Issues

PROMPT.md should not be committed

The .gitignore update adds PROMPT.md, which is correct going forward, but the file itself is now tracked and is part of this PR's diff. It contains Claude Code internal session state (task history, round-by-round review notes, implementation roadmap). This should be removed from git tracking:

git rm --cached PROMPT.md

Without this, the session file becomes permanent history and will appear in everyone's working trees on checkout.


ADR Quality — docs/adr/0057-replay-outbox-on-inbox-duplicate.md

The ADR is well-structured and thorough. The following points are worth addressing before or during implementation:

1. DescribePipelines() also needs InboxConfiguration

The ADR correctly identifies that ValidatePipelines() creates PipelineBuilder without InboxConfiguration. However, DescribePipelines() (line 89 in BrighterPipelineValidationExtensions.cs) has the same gap. The review-design.md notes this at score 35. Since DescribePipelines() is a diagnostic tool used by operators to inspect pipeline state, giving it an incomplete view of the pipeline (missing global inbox attributes) makes its output misleading. Worth fixing as part of Task 3.

2. SupportsCausationTracking() synchronous schema check at startup

SupportsCausationTracking() is described as a "permanent runtime schema check." For persistent stores this likely means a DB query (e.g. checking if the CausationId column exists). When called during ValidatePipelines() at startup, this blocks application startup for every registered pipeline. For stores with slow connections (cloud-hosted DBs, cold containers), this could meaningfully extend startup time. The ADR should document the expected performance characteristics of this check, or note that implementations should cache the result.

3. Race condition framing could be clearer

The ADR describes the race condition — sweeper dispatches while ReplayCausation is clearing — and correctly notes it's acceptable because downstream inboxes handle duplicates. However, the framing implies only a single delivery. In practice, a message could be delivered twice: once by the in-flight sweep, and once after the dispatch state is cleared. The mitigation should explicitly say "downstream consumers with OnceOnly inboxes handle the duplicate." Without inboxes downstream, this becomes a real correctness concern. Worth adding a note about the requirement for downstream idempotency.

4. CausationId defaults to request.Id

Using the command's Id as the default CausationId is convenient, but the implications should be documented: if request.Id is not truly unique across handler invocations (e.g. deterministic IDs in tests or retried commands with the same ID), two different handler invocations could share the same CausationId, causing one invocation's replay to clear another's outbox messages. The ADR should note this assumption.


Requirements (requirements.md)

Minor: two typos in the Problem Statement — "Brigter consumer" → "Brighter consumer" and "those that we re never actioned" → "were never actioned". Small but this document is the canonical source of truth for the feature.


Tasks (tasks.md)

The task structure is clean with clear TDD enforcement. A few observations:

Task 3 — DescribePipelines() not in scope

As noted above, DescribePipelines() has the same InboxConfiguration gap as ValidatePipelines(). Consider adding it to Task 3 to fix both paths together.

Task 14 — 7 validation scenarios in one test file

The review-tasks.md (score 50) flags that Task 14 packs 7 scenarios into one test file and recommends [Theory]/[InlineData]. This is the right call — enforce it explicitly in the task description so the implementing agent follows it, rather than leaving it as a "recommendation."

Task 23 — "run all core tests" is underspecified

Task 23 says "Build + run all core tests" without listing which projects constitute "core." Given the breadth of this change (UseInboxHandler, PipelineBuilder, validation, InMemoryInbox/Outbox), consider enumerating the minimum test suite: Paramore.Brighter.Core.Tests, the OnceOnly suite, and the Validation suite at minimum.

Dependency on schema migration PR

The ADR notes a separate schema migration PR must land on master before this feature. This external dependency is not tracked in the task list. Suggest adding a prerequisite note or a blocking PR link so the implementation isn't merged ahead of its schema dependency.


Summary

Area Status
PROMPT.md in repo Must fix — remove from git tracking
ADR structure & completeness Good — minor gaps noted
DescribePipelines() gap Low priority — fix in Task 3
SupportsCausationTracking() startup cost Needs documentation
Race condition clarity Needs a note on downstream idempotency requirement
Requirements typos Trivial — worth fixing
Task 14 scenario structure Clarify [Theory] expectation in task
Task 23 test scope Enumerate specific suites
Schema migration PR dependency Track explicitly

The design itself is sound — the role interface approach (IAmACausationTrackingInbox/Outbox) is non-breaking and consistent with Brighter's existing patterns. The decision to use the outbox sweeper for re-dispatch rather than building a new dispatch path is the right call. The CausationId concept is well-scoped and distinct from CorrelationId/JobId.

# Conflicts:
#	.gitignore
#	specs/.current-spec
#	specs/.current_spec
codescene-delta-analysis[bot]

This comment was marked as outdated.

@claude

claude Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review — PR #4067: Replay Outgoing Messages When Inbox Receives Duplicate

Thanks for the very thorough design work — ADR 0057 is well structured (clear Forces, Alternatives Considered, Consequences, Risks table), the role-interface approach (IAmACausationTrackingInbox/IAmACausationTrackingOutbox) is consistent with Brighter's IAmA* conventions, and the opt-in/non-breaking framing is sound. The task breakdown follows the mandated TDD-first workflow and maps cleanly back to the FRs.

Most of my feedback is about scope/hygiene of this PR, since it ships only design+spec docs — no production code or tests yet.

🔴 Blocking / should-resolve-before-merge

  1. Closes #2541 with no implementation. This PR contains the ADR, requirements, and task list but none of the 23 implementation tasks. Merging as-is will auto-close [Feature] Force Replay of Outbox on seeing duplicate Processed Inbox Message #2541 even though the feature isn't built. Suggest either dropping Closes (use "Part of [Feature] Force Replay of Outbox on seeing duplicate Processed Inbox Message #2541" / "Design for [Feature] Force Replay of Outbox on seeing duplicate Processed Inbox Message #2541") or keeping the issue open until the implementation PR lands.

  2. PROMPT.md is committed and added to .gitignore. Patch 1 adds PROMPT.md (tracked), patch 3 adds it to .gitignore. Adding to .gitignore does not untrack an already-tracked file, so it stays in the repo. Per CLAUDE.md, PROMPT.md is intended as temporary cross-conversation state — it carries session-resume notes, review-round history, and a stale status block. Recommend git rm --cached PROMPT.md (and commit) if the intent is to ignore it, or drop it from the PR entirely. As-is the two changes contradict each other.

  3. Unrelated spec state bleeding into this PR. The diff touches files belonging to other specs:

    • specs/0022-Defer-Message-Action-Backstop-Handler/review-tasks.md (new)
    • specs/0026-timed-outbox-archiver-sync-fallback/review-tasks.md and .tasks-approved (new)
    • flips to both specs/.current-spec and specs/.current_spec

    CLAUDE.md's "Change Scope" rule says not to make changes beyond what was requested. These look like workflow scratch state for unrelated specs and should be split out so this PR is just spec 0027.

  4. Two divergent "current spec" files: .current-spec (hyphen) and .current_spec (underscore). The PR edits the hyphen one, then deletes it, and edits the underscore one. This looks like a typo'd duplicate. Worth settling on a single canonical filename so the spec tooling doesn't read stale state.

🟡 Design observations (for the implementation PR)

These don't block a docs PR but are worth tracking against the tasks:

  • ADR InboxItem example uses primary-constructor syntax (ADR ~line 339) while the real src/Paramore.Brighter/InMemoryInbox.cs uses a traditional constructor. review-design.md already flagged this (score 45) but the ADR text wasn't updated — an implementer following the snippet could needlessly refactor the class. Add an "illustrative only" note or align it.
  • Replay no-op when tracking unsupported at runtime. The Consequences section acknowledges Replay silently does nothing if inbox/outbox don't support causation tracking and pipeline validation is the safety net — but several validation branches emit Warnings, not Errors (no outbox, schema not yet migrated). Confirm a Warning is loud enough that a misconfigured Replay is actually noticed, given the runtime behavior is a silent skip.
  • Double-dispatch race (sweeper dispatching while ReplayCausation clears state) is accepted as "downstream inbox dedup handles it." Reasonable, but worth an explicit test asserting idempotency expectations so it's a documented contract, not an assumption.
  • review-tasks.md finding Support management and Monitoring of ServiceActivator instances #6 notes UseInboxHandlerAsync calls base.InitializeFromAttributeParams() twice. Good to fold that fix into Task 1's tidy-first pass as noted.

🟢 Nits

  • requirements.md Problem Statement has several typos: "Brigter consumer", "te re-execution", "those that we re never actioned", "enabled instead.." (double period), "This a terminal step". Minor, but these are the authoritative requirement statements.
  • Terminology mixes TimeFlushed (in-memory) vs DispatchedAt/Dispatched (persistent) — partially reconciled in patch 2; consider a one-line glossary so implementers don't conflate them.

Test coverage

N/A for this PR (docs only). The task list correctly front-loads in-memory /test-first tasks before persistent stores and routes outbox store tests through the Liquid generator — good adherence to the mandatory TDD workflow.


Summary: The design is strong and review-ready. Before merge I'd (1) fix Closes #2541 so the issue isn't prematurely closed, (2) resolve the PROMPT.md tracked-vs-ignored contradiction, and (3) split out the unrelated 0022/0026 spec files and the duplicate .current_spec/.current-spec so this PR is scoped to spec 0027 only.

🤖 Generated with Claude Code

codescene-delta-analysis[bot]

This comment was marked as outdated.

@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

PR Review — #4067 Replay Outgoing Messages When Inbox Receives Duplicate

Reviewed against the repo's CLAUDE.md and spec workflow conventions. Overall: the design work here is excellent — but this PR is design/spec only and ships no implementation, and it carries a few hygiene/scope problems that should be fixed before merge.

Scope of the change

This PR touches 0 files under src/ or tests/. It adds:

  • docs/adr/0057-replay-outbox-on-inbox-duplicate.md (the design)
  • specs/0027-.../ requirements, tasks, review docs, and approval markers
  • PROMPT.md, a .gitignore line, and updates to spec marker files
  • review-task files for unrelated specs 0022 and 0026

That's consistent with the spec workflow (Requirements → ADR → Tasks, all approved), but the PR is framed as the feature itself. See the first blocking point.


🔴 Blocking / should-fix

  1. Closes #2541 will auto-close the issue, but the feature isn't built. This PR delivers the spec/design only (Implementation is [ ] not started in PROMPT.md and tasks.md). Merging it will close [Feature] Force Replay of Outbox on seeing duplicate Processed Inbox Message #2541 even though no behavior exists yet. Either drop Closes (use Refs #2541), or hold the merge until the 23 implementation tasks land.

  2. PROMPT.md is committed and listed in .gitignore (line 346). These contradict each other — the commit "chore: add PROMPT.md to .gitignore" signals intent not to track it, yet it's in the PR. Per CLAUDE.md, PROMPT.md is local, conversation-spanning working state, not a repo artifact. Run git rm --cached PROMPT.md and drop it from the PR (the .gitignore entry can stay).

  3. Unrelated spec files included — scope creep. specs/0022-Defer-Message-Action-Backstop-Handler/review-tasks.md, specs/0026-timed-outbox-archiver-sync-fallback/review-tasks.md, and specs/0026-.../.tasks-approved belong to other features. CLAUDE.mdChange Scope: "Do NOT change … beyond what was explicitly requested." Please split these out.

  4. Two competing marker files: specs/.current-spec (hyphen) and specs/.current_spec (underscore). This PR empties one and updates the other (the merge history shows a conflict between them). Having both is confusing and will keep colliding. Consolidate on a single convention and delete the other.


🟡 Minor

  1. Typos in requirements.md (Problem Statement, line 9; Proposed Solution, line 23): "Brigter", "te re-execution", "we re never actioned", "instead..". Worth a cleanup since this is the authoritative requirements doc.

  2. Two low-scored items from your own review-design.md remain open and are worth carrying into implementation: the InboxItem example uses primary-constructor syntax while the real class doesn't (don't let an implementer refactor it), and DescribePipelines() should also receive InboxConfiguration for consistency with ValidatePipelines().


Design assessment (ADR 0057)

The ADR is genuinely strong — 7 review rounds show. I spot-checked its claims against the current code and they hold up:

  • OnceOnlyAction is a 2-value enum (Throw, Warn) — adding Replay is additive ✅
  • UseInboxHandler does use a private InitRequestContext() today (lines 81/111) — the tidy-first switch to this.Context is a real and correct prerequisite ✅
  • IAmAnOutboxProducerMediator exists and the Outbox property addition is sound ✅

The opt-in / non-breaking strategy, role interfaces (IAmACausationTracking*), reuse of the existing sweeper, and the startup pipeline validation are all well-judged. Alternatives and risks (double-dispatch under sweeper race, inbox-Add-after-outbox-write failure) are addressed honestly.

Two things to watch during implementation:

  • Instance identity between validation and runtime. Validation reads the outbox via IAmAnOutboxProducerMediator.Outbox, while UseInboxHandler receives IAmACausationTrackingOutbox? via constructor DI. These are two resolution paths — make sure they resolve the same instance, or SupportsCausationTracking() could pass at startup against one object while replay runs against another. A test asserting instance identity would be cheap insurance.

  • Cross-PR sequencing. The ADR depends on a separate schema-migration-tooling PR being "merged into master before this feature." That ordering constraint is invisible from this PR; calling it out in the PR description (with the dependent PR number) would prevent a premature merge.


Summary: The design is approved-quality and the task breakdown is thorough. Before merge, please address the four blocking items — most importantly, decide whether this is the spec-only PR (then don't Closes #2541) or wait for implementation, and remove PROMPT.md + the unrelated 0022/0026 files.

🤖 Generated with Claude Code

iancooper and others added 5 commits June 23, 2026 12:50
…relational store tasks

Fold the inbox/outbox CausationId schema change into this spec via
BoxProvisioning instead of a deferred separate PR:

- requirements.md: schema evolution is in-scope via BoxProvisioning
  (data backfill stays out of scope); AC9 + Performance NFR now assert
  the column, the outbox CausationId index, and SupportsCausationTracking().
- ADR 0057: new "Schema Evolution via BoxProvisioning" section covering the
  three store classes (catalog-based relational, Spanner provisioner, NoSQL
  schemaless); per-backend catalog versions (outbox V8; inbox V3 for
  MsSql/MySql/Sqlite, V2 for the V1-only Postgres inbox); Spanner
  VLatestOutbox/VLatestInbox constant bumps + cross-backend drift test;
  Spanner SupportsCausationTracking() as a live column probe.
- tasks.md: split Task 19 -> 19a (atomic structural schema) + 19b-19f
  (per-backend IAmACausationTrackingInbox, test-first); Task 21 -> 21a
  (atomic structural schema + index) + 21b (Liquid generator) + 21c-21g
  (per-backend IAmACausationTrackingOutbox, test-first). The schema layer is
  atomic because SpannerVLatestDriftAgainstRelationalCatalogTests pins
  VLatest* to every relational catalog count.

Includes the 2026-06-17 focused re-review findings
(review-{requirements,design,tasks}.md); all threshold findings addressed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…strumentationOptions

Task 1 (structural/tidy-first):
- Part A: UseInboxHandler/UseInboxHandlerAsync use the pipeline's shared
  IRequestContext (this.Context) instead of a private InitRequestContext(),
  so RequestContext.Bag is shared across the pipeline (prereq for CausationId).
- Part B: expose instrumentationOptions as a protected InstrumentationOptions
  property on RequestHandler<T>/RequestHandlerAsync<T> so derived handlers can
  gate telemetry on InstrumentationOptions.Brighter.
- Tidy: remove duplicate base.InitializeFromAttributeParams() call in async handler.

Behaviour-preserving: OnceOnly tests 10/10 green (net9.0 + net10.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 2 (structural/tidy-first): non-positional RequestHandlerAttribute? Attribute
init-property on PipelineStepDescription, populated at both Describe() construction
sites, so validation rules can inspect attribute properties (e.g. OnceOnlyAction).

Behaviour-preserving: Validation tests 90/90 green (net9.0 + net10.0).
Also ticks Task 1 in tasks.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…arity with Build()

Task 3 (structural/tidy-first):
- PipelineBuilder.Describe() now injects the global inbox UseInbox(Async)Attribute
  for handlers, using the same guards as Build()'s AddGlobalInboxAttributes
  (HasNoInboxAttributesInPipeline / HasExistingUseInboxAttributesInPipeline) via a
  reflection-only TryCreateGlobalInboxAttribute helper, so descriptions don't drift.
- BrighterPipelineValidationExtensions resolves InboxConfiguration from
  IAmConsumerOptions and passes it to the validate/describe PipelineBuilder
  (null when no consumer options, matching the runtime).

Behaviour-preserving: Validation + Pipeline tests 131/131 green (net9.0 + net10.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 4 (structural/tidy-first): add read-only IAmAnOutbox? Outbox to the mediator
interface and implement it in OutboxProducerMediator as the sync or async outbox,
so pipeline validation can inspect the outbox (e.g. for causation-tracking support).

Behaviour-preserving: core + core-tests build clean (net9.0 + net10.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

# Conflicts:
#	src/Paramore.Brighter/OutboxProducerMediator.cs
@iancooper

Copy link
Copy Markdown
Member Author

@lillo42 Might be worth you casting an eye over the generated tests for causation in this one

…ategory trait)

Merging master brought in the generated-test template that emits an xUnit
[Trait("Category", "<provider>")] attribute. The committed Causation outbox
tests predated that template change, so regenerating adds the missing trait
to all 12 provider variants (Spanner already had it). Generated output only —
no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +628 to +648
foreach (var item in queryResponse.Items)
{
if (!item.TryGetValue("MessageId", out var messageId))
continue;

// Restore the outstanding marker (from the still-present CreatedTime) and clear the
// dispatched state so the sweeper resends the message.
var updateItemRequest = new UpdateItemRequest
{
TableName = _configuration.TableName,
Key = new Dictionary<string, AttributeValue>
{
{ "MessageId", new AttributeValue { S = messageId.S } }
},
UpdateExpression = "SET OutstandingCreatedTime = CreatedTime REMOVE DeliveryTime, DeliveredAt",
ConditionExpression = "attribute_exists(MessageId)"
};

await _client.UpdateItemAsync(updateItemRequest, cancellationToken)
.ConfigureAwait(ContinueOnCapturedContext);
}
Comment on lines +641 to +661
foreach (var item in queryResponse.Items)
{
if (!item.TryGetValue("MessageId", out var messageId))
continue;

// Restore the outstanding marker (from the still-present CreatedTime) and clear the
// dispatched state so the sweeper resends the message.
var updateItemRequest = new UpdateItemRequest
{
TableName = _configuration.TableName,
Key = new Dictionary<string, AttributeValue>
{
{ "MessageId", new AttributeValue { S = messageId.S } }
},
UpdateExpression = "SET OutstandingCreatedTime = CreatedTime REMOVE DeliveryTime, DeliveredAt",
ConditionExpression = "attribute_exists(MessageId)"
};

await _client.UpdateItemAsync(updateItemRequest, cancellationToken)
.ConfigureAwait(ContinueOnCapturedContext);
}

await GenerateAsync(
configuration,
Path.Combine("Outbox", prefix, "Generated", "Causation"),
await GenerateAsync(
configuration,
Path.Combine("Outbox", prefix, "Generated", "Causation"),
Path.Combine("Outbox", "Causation"),

await GenerateAsync(
configuration,
Path.Combine("Outbox", prefix, "Generated", "Causation"),
await GenerateAsync(
configuration,
Path.Combine("Outbox", prefix, "Generated", "Causation"),
Path.Combine("Outbox", "Causation"),
Comment on lines +226 to +235
catch (Exception ex)
{
findings.Add(ValidationResult.Fail(new ValidationError(
ValidationSeverity.Warning,
source,
$"OnceOnlyAction.Replay could not verify causation-tracking support on the {store} store — " +
$"the schema capability probe failed ({ex.GetType().Name}: {ex.Message}). Ensure the store is " +
"reachable and its schema is migrated before relying on Replay")));
return null;
}
@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown

Code Review — Replay Outbox Messages on Inbox Duplicate (#4067)

Reviewed against ADR 0057 / spec 0027. This is a large, well-engineered change: the role-interface design (IAmACausationTrackingInbox / IAmACausationTrackingOutbox) is clean and opt-in, the memoized column-existence probe avoids per-deposit cost while keeping deposits working against un-migrated schemas, the replay SQL is correctly parameterized on every backend, and test coverage (base tests, per-backend, end-to-end, validation) is thorough. Migration versioning is internally consistent (outbox V7->V8, inbox V2->V3, Postgres inbox V1->V2, Spanner VLatestOutbox=8 / VLatestInbox=3) with sound idempotency guards. Issues worth addressing, most important first.

🔴 Spanner bulk outbox insert throws for the common null-causation case

SpannerOutbox.CreateSqlParameter special-cases the causation parameter by exact name to force an explicit SpannerDbType (required so a null -> DBNull.Value binds), but it only matches "@CausationId"src/Paramore.Brighter.Outbox.Spanner/SpannerOutbox.cs:99. The bulk insert path names its per-row parameters @p{i}_CausationIdsrc/Paramore.Brighter/RelationDatabaseOutbox.cs:1615. On Spanner the CausationId column always exists (ships in the DDL) so includeCausation is always true; in the normal (non-replay) case causationId == null. The bulk path then creates a SpannerParameter with DBNull.Value and no SpannerDbType — exactly the untyped-null case the single-Add special case exists to prevent — so a bulk Add/AddAsync of multiple messages throws. Not caught by the base suite, which only exercises bulk deposit with a non-null causation id. Other relational backends tolerate untyped DBNull and are unaffected. Fix: bind the causation parameter via the typed two-arg CreateSqlParameter(name, DbType.String, causationId) on both single and bulk paths (routes through ToSpannerDbType) and drop the name-based special case; add a bulk-deposit-with-null-causation test for Spanner.

🟠 Inbox read path (GetCausationId) is not gated on column existence — breaks the AC10 graceful-degradation promise

The write path degrades on an un-migrated table via the memoized CausationColumnExists() probe, but the read path guards only on CausationQueries is nullsrc/Paramore.Brighter/RelationalDatabaseInbox.cs:320-351. And UseInboxHandler calls GetCausationId on a Replay duplicate without first checking SupportsCausationTracking() — UseInboxHandler.cs:122-126 (and UseInboxHandlerAsync.cs:126-132). So for a backend that implements the causation query interface but whose table has not been migrated (precisely the mixed state the write-path gate tolerates), enabling OnceOnlyAction.Replay makes a duplicate command run SELECT ... [CausationId] ... and throw invalid-column instead of no-opping. Pipeline validation only emits a Warning here (not an Error), so startup succeeds and the failure surfaces at runtime on the first duplicate. Gating GetCausationId on CausationColumnExists() (return null when absent) fixes this and also protects ReplayCausation.

🟡 The feature silently no-ops unless every handler threads its RequestContext into Post/DepositPost

The causation id is stamped into the pipeline RequestContext.Bag by UseInboxHandler, and the outbox Add reads it from the bag — but only if the forwarding handler passes its own Context down. CommandProcessor.InitRequestContext creates a fresh context when none is supplied (CommandProcessor.cs:1533, requestContext ?? _requestContextFactory.Create()). A handler using the idiomatic _commandProcessor.Post(evt) (no context) produces outbox messages with a null CausationId, and a later Replay finds nothing — a silent no-op. The ProcessAndForwardHandler test double does thread Context and its XML comment explains why, but this is an easy footgun and nothing validates it. Please surface it prominently in the user-facing docs/release notes and consider whether a clearer signal is feasible.

🟢 Minor

  • DynamoDB: MessageItem hardcodes the GSI annotation indexName: "Causation" while the probe/query read the configurable DynamoDbConfiguration.CausationIndexName — overriding the config name silently breaks replay. Consistent with the pre-existing Outstanding/Delivered index pattern, so a latent trap rather than a regression.
  • DynamoDB inbox: GetCausationId comment says "we call GetCausationIdAsync" (implying a span is added there); neither method creates a span. Misleading comment only.
  • Torn read on bool? _causationColumnExists (RelationalDatabaseInbox / RelationDatabaseOutbox / DynamoDbOutbox): the value-never-changes argument is value-correct, but a concurrent reader on a weak memory model (ARM) can observe a torn hasValue=true/value=false during the single first write, transiently skipping causation for one message. An int flag with Volatile.Read/Write would close the hole the comment acknowledges.
  • Memoized absent is cached for the instance lifetime — a mid-process migration requires a restart. Documented and acceptable.
  • Duplicate spec pointer files: both specs/.current-spec and specs/.current_spec (hyphen and underscore) are added with identical content — looks like an accidental duplicate.
  • Pre-existing (not this PR): src/Paramore.Brighter.Outbox.MsSql/SqlOutboxBuilder.cs is missing a comma between [SpecVersion] NVARCHAR(255) NULL and PRIMARY KEY ( [Id] ) in the CREATE TABLE. The new [CausationId] line sits above it and the index is a separate statement, so it is unaffected — flagging for awareness since FreshInstallDdl sources this builder.

🟢 Verified correct

Replay SQL (Dispatched = NULL WHERE CausationId = @CausationId on relational; the symmetric SET OutstandingCreatedTime = CreatedTime REMOVE DeliveryTime, DeliveredAt on DynamoDB, with pagination and attribute_exists guard); full parameterization of all queries; column-existence probes (sys.columns, information_schema, pg_attribute, pragma_table_info) mapping via HasRows; single-Add parameter counts; migration idempotency guards; RequestContext.CreateCopy() copies the new InstrumentationOptions; the source-breaking IRequestContext.InstrumentationOptions addition is clearly documented in the release notes; DI registration correctly registers the outbox under the role interface.

Reviewed with assistance from parallel analysis of the persistent-store implementations.

iancooper and others added 3 commits July 3, 2026 10:56
…ths (PR #4067 review)

Spanner's single-arg CreateSqlParameter only forced an explicit SpannerDbType
for the literal "@CausationId", but the bulk insert path names its per-row
params "@p{i}_CausationId". Since Spanner ships the CausationId column in its
DDL, includeCausation is always true; in the normal (non-replay) case the
causation id is null, so the bulk path produced an untyped DBNull that Spanner
rejects — a bulk Add of >1 message threw.

Bind the causation parameter via the typed two-arg CreateSqlParameter(name,
DbType.String, value) on the single-Add, bulk-Add and replay paths (matching
how the bulk insert already types every other column), and drop Spanner's now
-redundant name-based special case. Other relational backends tolerate untyped
DBNull and are unaffected; verified via SQLite outbox 86/86 + causation 24/24.

Adds a Spanner bulk-deposit-with-null-causation regression test (Category
=Spanner, CI-excluded; runs against the emulator).

Also removes the accidental duplicate spec pointer specs/.current_spec
(underscore); specs/.current-spec (hyphen) is the canonical name the /spec
skills read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
 review)

The inbox write path degrades on an un-migrated table via the memoized
CausationColumnExists() probe, but GetCausationId/GetCausationIdAsync guarded
only on "CausationQueries is null". Every backend implements the causation
query interface, so on a backend whose table has not been migrated (the mixed
state the write-path gate tolerates) a Replay duplicate — UseInboxHandler calls
GetCausationId without first checking SupportsCausationTracking() — ran
SELECT ... [CausationId] ... and threw invalid-column instead of no-opping,
breaking the AC10 graceful-degradation promise. Pipeline validation only warns
here, so startup succeeds and the failure surfaces at runtime on first duplicate.

Gate both read methods on CausationColumnExists()/CausationColumnExistsAsync()
(which already return false when the query interface is absent), returning null
when the column is missing. This also protects ReplayCausation.

Regression test: un-migrated (V2) SQLite inbox returns null from GetCausationId
instead of throwing. SQLite legacy+causation 27/27, Core OnceOnly 40/40, full
SQLite suite 151/151.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…index trap (PR #4067 review)

- release_notes: add a "Usage requirement" call-out that Replay only works when
  handlers thread their RequestContext through Post/DepositPost; a context-less
  Post creates a fresh context, drops the causation id, and makes Replay a silent
  no-op. Includes a ❌/✅ handler example.
- DynamoDbConfiguration.CausationIndexName: <remarks> warning that the value must
  stay "Causation" because the matching GSI hash key on MessageItem is a compile
  -time-constant annotation that cannot read the config; overriding it silently
  breaks replay (same latent trap as the Outstanding/Delivered index names).
- DynamoDbInbox.GetCausationId: correct the misleading comment that implied
  GetCausationIdAsync adds a span (neither method does today).

Docs/comments only — no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown

Code Review — PR #4067: Replay Outbox Messages on Inbox Duplicate Detection

Reviewed against ADR 0057 and the merge base. This is a large, well-engineered feature (189 files) with a thorough ADR, an opt-in / non-breaking design, role interfaces that follow the existing IAmA* conventions, and strong core test coverage. The write-path gate (memoized live-schema probe) and the pipeline-validation rule are nicely done — the validation rule even improves on the ADR by wrapping the probe in try/catch and distinguishing a "Replay Skipped" telemetry event from a successful replay.

Below are the issues found, ranked. Nothing here is a blocker for the design; the notable ones are runtime-robustness gaps in mixed / partial states.

🔴 High / Medium

1. Relational ReplayCausation is not gated on the live-schema probe → runtime SQL error in mixed-migration state.
src/Paramore.Brighter/RelationDatabaseOutbox.cs (ReplayCausation ~1049, ReplayCausationAsync ~1073) guard only on if (CausationQueries is null) return;. Every other write path (Add, bulk Add, and the inbox GetCausationId) deliberately gates on the live CausationColumnExists() probe precisely because a backend implements IRelationalDatabaseOutboxCausationQueries statically even when its table is un-migrated.

Scenario: inbox migrated (has CausationId), outbox not migrated (interface present, column absent) — e.g. a user who provisions/migrates only the inbox. UseInboxHandler gets a non-null causation id from the inbox and calls _outbox.ReplayCausation(...), which issues UPDATE {0} SET Dispatched = NULL WHERE CausationId = @CausationId against a table with no such column, throwing "invalid column name CausationId" at runtime. This is exactly the graceful-degradation case the write-path gate was built to prevent, and pipeline validation only emits a non-blocking Warning for the un-migrated outbox, so startup proceeds. Suggest adding || !CausationColumnExists() (and the async equivalent) to the early return.

2. Handler replay callsite mirrors the same gap.
UseInboxHandler.cs:122-126 / UseInboxHandlerAsync.cs:126-133 call _outbox.ReplayCausation(...) whenever the inbox returns a causation id, checking only _outbox is not null — never _outbox.SupportsCausationTracking(). Guarding either here or inside ReplayCausation (see #1) closes the mixed-migration hole.

3. DynamoDB ReplayCausationAsync aborts the entire replay on ConditionalCheckFailedException (no catch-and-continue).
src/Paramore.Brighter.Outbox.DynamoDB/DynamoDbOutbox.cs:642-643 and .V4/DynamoDbOutbox.cs:655-656. The per-item UpdateItem uses ConditionExpression = "attribute_exists(MessageId)" but the surrounding try has only a finally. MarkDispatchedAsync catches this exact exception; ReplayCausationAsync does not. Because the Causation GSI is always eventually consistent, the query can return a MessageId for an item that was since swept/purged/TTL-deleted (or deleted concurrently). The condition fails, the exception propagates, and the pagination loop unwinds — remaining messages in the causation are silently never re-dispatched. Suggest catch-and-continue like MarkDispatched.

🟡 Low

4. Torn read of the memoized bool? probe under concurrent first-probe.
_causationColumnExists (relational inbox/outbox) and _causationIndexExists (DynamoDB outbox) are unsynchronized Nullable<bool> — a two-field struct. The code comment argues the race is harmless "because the value never changes once written," but a concurrent reader can observe HasValue=true while value is still the default false (struct assignment is not atomic). At startup, pipeline validation may probe on several threads; the window can yield a spurious false, i.e. a transient "does not support causation tracking" finding or skipped replay. It self-heals, but consider an int + Interlocked or a lock if you want to close it.

5. DynamoDB replay does not reset/extend ExpiresAt (TTL).
ReplayCausation restores outstanding state but leaves the original TTL. On a TTL-configured outbox, a message whose TTL has already elapsed may be deleted before/shortly after replay and never re-dispatched. Only affects outboxes with TimeToLive set.

6. CausationColumnExistsCommand probes are not schema-qualified.
MsSql OBJECT_ID, Postgres to_regclass, MySql table_name all resolve against the connection default schema/DB. A box table in a non-default schema can be probed as "column absent" even when present, silently disabling tracking. This mirrors the existing unqualified FROM {0} runtime queries, so it is consistent rather than a regression — but the feature inherits the limitation.

7. MsSql fresh-install index is not idempotent.
src/Paramore.Brighter.Outbox.MsSql/SqlOutboxBuilder.cs:135 emits a bare CREATE INDEX [idx_..._CausationId] with no sys.indexes / IF NOT EXISTS guard, whereas the V8 migration uses a guarded EXEC(...) and the Sqlite/Postgres/Spanner builders use IF NOT EXISTS. Safe today only because the MsSql CREATE TABLE is itself unguarded (so GetDDL runs on a true fresh install), but it is the odd one out.

8. timeoutInMilliseconds is accepted but ignored by DynamoDB GetCausationId / GetCausationIdAsync (consistent with other DynamoDB methods, but silently non-functional).

9. IRequestContext gained a new InstrumentationOptions member. This is technically a source/binary-breaking change for any external implementer of the interface (no default interface implementation). Likely acceptable given the interface role, but worth calling out for a minor-version bump.

🧪 Test coverage

Coverage is strong overall — sync+async replay, no-outbox terminal step (both), causation-id defaulting, the validation rule five checks plus probe-throws-degrades-to-Warning, the legacy/un-migrated-schema gate per relational backend, the DynamoDB "no GSI" gate, and base tests inherited by every store. Notable gaps:

  • Causation-id preservation/chaining is untested — the if (!requestContext.Bag.ContainsKey(CausationId)) guard (the actual "linking" semantic) is never exercised by seeding a parent causation id into the bag before Send to verify it is not overwritten. Given the feature name, this is the most worthwhile gap to close.
  • Async "Replay Skipped" (null-causation) telemetry, no-instrumentation, and no-span cases are only tested on the sync handler.
  • The handler-level "inbox present but not IAmACausationTrackingInbox" false branch is only covered at config time (validation rule), never at runtime.
  • Store-level replay of an unknown causation id (matches no rows — expected safe no-op).

Overall this is a solid, carefully-scoped implementation. The highest-value follow-ups are gating relational ReplayCausation on the live probe (#1/#2) and making DynamoDB replay resilient to vanished items (#3), since both concern real partial / eventually-consistent states that validation only warns about.

🤖 Review assisted by Claude Code (Opus 4.8, 1M context).

iancooper and others added 5 commits July 4, 2026 15:39
…(PR #4067 review)

ReplayCausation/ReplayCausationAsync guarded only on "CausationQueries is
null", but every relational backend implements the causation query interface
even when its table is un-migrated. In the mixed-migration state the write-path
gate was built for -- inbox migrated (has CausationId), outbox NOT migrated
(interface present, column absent) -- UseInboxHandler gets a real causation id
from the inbox and calls ReplayCausation, which issued
UPDATE {0} SET Dispatched = NULL WHERE CausationId = @CausationId against a table
with no such column, throwing "invalid column CausationId" at runtime. Pipeline
validation only warns for the un-migrated outbox, so startup proceeds and the
failure surfaces on first duplicate.

Gate both replay paths on the live CausationColumnExists()/Async probe (which
already returns false when the query interface is absent), matching Add, bulk
Add and the inbox GetCausationId. Closes review findings #1 and #2 (the handler
callsite hole is closed at the base, so no per-callsite guard is needed).

Regression test: un-migrated (V7) SQLite outbox no-ops on ReplayCausation
instead of throwing. SQLite legacy 9/9, causation+outbox 97/97.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#4067 review)

ReplayCausationAsync issues a per-item conditional UpdateItem
(attribute_exists(MessageId)) for every MessageId the Causation GSI returns, but
the surrounding try had only a finally. Because the GSI is eventually
consistent, the query can return a MessageId for an item since swept / purged /
TTL-deleted / concurrently deleted; the condition then fails,
ConditionalCheckFailedException propagates, the pagination loop unwinds, and the
remaining messages in the causation are silently never re-dispatched.
MarkDispatchedAsync already catches this exact exception; replay did not.

Catch ConditionalCheckFailedException around the per-item UpdateItem and
continue, so a vanished item is skipped and the rest of the causation is still
replayed. Applied to both the V3 and V4 DynamoDbOutbox. Closes review finding #3.

Regression test drives replay through an AmazonDynamoDBClient subclass that
deletes the first GSI-returned item before its update runs, deterministically
reproducing the race; asserts no throw and the surviving message is outstanding
again. DynamoDB V3 outbox 35/35, V4 causation 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eview)

The UseInboxHandler guard "if (!Bag.ContainsKey(CausationId))" is the actual
linking semantic the feature is named for, but only its true branch (default the
id to the command's own id) was exercised. Add a characterization test that
seeds a parent causation id into the shared context bag before Send and asserts
the handler does NOT overwrite it -- both in the bag and in the stored inbox
entry -- so a child command is linked to its parent's causation. Closes the
highest-value test gap called out in the review. No prod code; green on first
run. Core UseInboxHandler causation 2/2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#4067 review)

SqlOutboxBuilder.GetDDL emitted a bare CREATE INDEX [idx_..._CausationId] with
no guard, whereas the V8 migration catalog uses a sys.indexes IF NOT EXISTS +
EXEC('CREATE INDEX ...') form and the Sqlite/Postgres/Spanner builders use
CREATE INDEX IF NOT EXISTS. Safe today only because the MsSql CREATE TABLE is
itself unguarded (so GetDDL runs on a true fresh install), but it was the odd
one out. Wrap the index in the same sys.indexes guard for parity so re-running
the DDL is idempotent; EXEC-wrapped so it compiles at run time. Closes review
finding #7.

Existing builder-vs-migration assertions are substring (Contains idx_..._
CausationId) and stay green. MsSql outbox project builds clean net9.0; DB-backed
run deferred (no MSSQL container this session).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…xt break (PR #4067 review)

Documentation-only follow-ups from the review:

- #6: note on IRelationalDatabaseOutboxCausationQueries.CausationColumnExistsCommand
  that the backend probes (OBJECT_ID / to_regclass / information_schema) resolve
  against the connection default schema, so a box table in a non-default schema can
  read as "column absent" -- a shared limitation of the unqualified box SQL, not a
  regression.
- #8: remarks on DynamoDbInbox.GetCausationId(Async) that timeoutInMilliseconds is
  accepted for interface compatibility but ignored (DynamoDB handles timeout/retries),
  consistent with the other methods on the store.
- #9: remarks on IRequestContext.InstrumentationOptions + an ADR 0057 Negative-
  consequences note that adding the member is source/binary-breaking for external
  implementers (no default interface impl) and should ship in a minor bump.

#5 (ADR context_key tag) needed no change: the observability table already lists only
request.id / causation_id, matching the emitted tags.

No behaviour change. Core + DynamoDB inbox projects build clean net9.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown

Code Review: Replay Outbox Messages on Inbox Duplicate (#4067)

Reviewed the core mechanism (UseInboxHandler/Async, InMemoryInbox/Outbox, RelationDatabaseOutbox/RelationalDatabaseInbox), the new role interfaces, DI wiring, pipeline validation, and the test surface. This is a large, carefully-executed change — the ADR is thorough, the schema probe is defensive and well-documented, breaking changes are called out in release_notes.md, and test coverage is broad (base tests + generated outbox tests + per-store inbox tests + legacy/un-migrated + migration/provisioning tests). Nicely done.

A few things worth a look before merge.

1. Async telemetry events may be silently dropped on the duplicate paths (correctness, low severity — telemetry only)

RequestContext.Span is thread-affine — the getter keys on Thread.CurrentThread.ManagedThreadId (RequestContext.cs:114-126). In UseInboxHandlerAsync.HandleAsync, the Throw/Warn/Replay branches read Context?.Span after the await _inbox.ExistsAsync(...) (UseInboxHandlerAsync.cs:108, :115, :123). With ContinueOnCapturedContext == false the continuation typically resumes on a different thread-pool thread, so Span returns null and the AddEvent(...) never fires — meaning the "Duplicate Throw/Warn/Replay" events the ADR promises are lost in the common async case.

Note the Add path deliberately captures addSpan = Context?.Span (:145) to survive the base.HandleAsync/AddAsync awaits — but even that capture is after the ExistsAsync await, so it is subject to the same loss. Suggest capturing the span once, before the first await, and reusing that captured value on every path (Throw/Warn/Replay/Add) for consistent behaviour. Functional replay is unaffected (it uses requestContext, not the span) — this is observability only.

2. Default causation id is not unique per handler invocation (design)

On first handling the causation id defaults to request.Id.Value (UseInboxHandler.cs:92-93). Both the inbox entry and every downstream outbox message are then tagged with command.Id. If the same message id is processed by more than one [UseInbox] handler (e.g. an event with multiple subscribers, each with its own inbox/context key), they all share causation id = command.Id. GetCausationId is context-scoped, but the value it returns is identical across contexts, and ReplayCausationCommand matches on that value alone (UPDATE ... WHERE CausationId = @CausationId). So a replay triggered by one handler resends the outbox messages of all handlers that share the command id.

This is at odds with the ADR's framing that causation is "scoped to a single handler invocation." It is fine as long as callers who need isolation set a unique causation id in the bag themselves — but the default does not guarantee that scoping. Worth either documenting the caveat prominently or scoping the default (e.g. incorporating the context key) so the out-of-the-box behaviour matches the stated design.

3. Custom IRequestContext silently degrades replay (documented, minor)

var requestContext = Context as RequestContext ?? new RequestContext { ... } (UseInboxHandler.cs:90): if a custom IAmARequestContextFactory returns a non-RequestContext IRequestContext, the causation id is stamped into a throwaway context that downstream handlers never see, so Replay becomes a no-op. The code comment acknowledges this, but since it fails silently (no warning/log), a user on a custom context would get no replay and no signal. Consider a one-time debug/warn log when this fallback is hit and Replay is configured.

Minor

  • Deferred DI factory (ServiceCollectionExtensions.cs:464-466) returns a null-forgiving cast, so GetRequiredService<IAmACausationTrackingOutbox>() returns null rather than throwing for a non-tracking outbox. Documented in the comment; just flagging that it violates the usual GetRequiredService contract for anyone who resolves it directly.
  • The memoized _causationColumnExists probe is never invalidated, so a mid-process schema migration requires a restart. Documented in the field comment — reasonable, just confirming it is intentional.

Positives

  • Exception-safe schema probe wrapper (SupportsCausationTrackingSafely) so a broken store probe cannot take down validation.
  • Graceful degradation when schema is un-migrated (AC10) is consistently applied across read/write/replay on both inbox and outbox, with matching legacy tests.
  • Breaking changes (IRequestContext.InstrumentationOptions, IAmABoxMigration, etc.) are clearly documented in release notes with migration guidance.

Overall this is solid, well-tested work. Item #1 is the only concrete bug (and it is telemetry-only); #2 is the one I would most want a maintainer decision on before merge.

Automated review — please verify findings against intent.

iancooper and others added 3 commits July 6, 2026 10:15
…xt replay (PR #4067 review)

Addresses three points from the PR #4067 code review:

1. Async telemetry events were silently dropped on the Throw/Warn/Replay
   duplicate paths: RequestContext.Span is thread-affine, and the branches
   re-read Context?.Span after the ExistsAsync await, so on a thread-pool
   continuation (ContinueOnCapturedContext == false) the span was null and
   the AddEvent never fired. Capture the span once before the first await and
   reuse it on every path (Throw/Warn/Replay/Add). Mirror the single capture
   in the sync handler for parity.

2. Default causation id is command.Id, so a message id handled by multiple
   [UseInbox] handlers shares it and a replay resends all of their outbox
   messages. This is intended (replay all downstream effects to recreate the
   original state); documented the caveat in ADR 0057, noting UseInbox is in
   practice applied to a single-handler Command, and the Bag escape hatch.

3. A custom IRequestContext (not a RequestContext) degrades Replay to a silent
   no-op. Emit a one-time Warning (CustomContextDisablesReplay) the first time
   the fallback is hit while OnceOnlyAction.Replay is configured.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown

Code Review — PR #4067: Replay outbox messages on inbox duplicate detection

Reviewed the core feature (OnceOnlyAction.Replay, causation tracking through the inbox → outbox), the UseInboxHandler/UseInboxHandlerAsync changes, the relational + NoSQL store implementations, the DI wiring, pipeline validation, and the migration catalogs. This is a large, carefully-executed change (ADR 0057, 7 rounds of review) and the overall quality is high — role interfaces are cleanly separated, the mixed-migration ("upgraded binaries, un-migrated schema") case is handled thoughtfully in the relational path, telemetry is consistent across sync/async, and test coverage is broad (legacy-schema tests, validation tests, end-to-end replay tests, per-backend tests). The comments explaining the tricky bits (span thread-affinity, _causationColumnExists memoization, ??= caching) are excellent.

A few points below, one worth addressing before merge.

1. (Medium) DynamoDB ReplayCausationAsync doesn't guard against a missing causation GSI — asymmetric with the relational path

RelationDatabaseOutbox.ReplayCausation[Async] deliberately gates on the live-schema probe and no-ops when the CausationId column is absent (the AC10 "inbox migrated, outbox not" case):

if (CausationQueries is null || !CausationColumnExists())
    return;

The DynamoDB outboxes (both Outbox.DynamoDB and Outbox.DynamoDB.V4) do not apply the equivalent guard — ReplayCausationAsync issues a QueryAsync against _configuration.CausationIndexName unconditionally:

  • src/Paramore.Brighter.Outbox.DynamoDB/DynamoDbOutbox.cs:599
  • src/Paramore.Brighter.Outbox.DynamoDB.V4/DynamoDbOutbox.cs:612

In a mixed state (inbox has the CausationId attribute — the Dynamo inbox is schemaless and SupportsCausationTracking() always returns true, so GetCausationId returns a non-null id — but the outbox table was provisioned before the Causation GSI was added), the handler reaches ReplayCausationAsync, which then queries a non-existent index and DynamoDB throws a ValidationException. That exception unwinds the handler pipeline on every duplicate, whereas the relational backends degrade gracefully to a no-op in exactly this scenario.

Both outboxes already have the _causationIndexExists probe (SupportsCausationTrackingAsync) available; gating the replay on it (return early if the index is absent, mirroring the relational no-op and the existing ConditionalCheckFailedException tolerance) would restore parity. Startup validation only warns for the mixed state, so it doesn't prevent this at runtime.

There's a When_a_dynamodb_outbox_table_lacks_the_causation_index_should_not_claim_causation_support test asserting SupportsCausationTracking() is false, but no test drives ReplayCausationAsync against a table whose GSI is missing — worth adding alongside the guard.

2. (Minor) .gitignore no longer ignores PROMPT.md

The diff removes the # Claude Code session state / PROMPT.md ignore entry. CLAUDE.md still references PROMPT.md as a place for temporary cross-conversation state, so dropping the ignore makes it easy to accidentally commit session scratch state. If this removal was intentional (e.g., PROMPT.md is now meant to be tracked), ignore this; otherwise consider keeping the entry.

3. (Nit) Sync-over-async in the NoSQL replay entry points

DynamoDbOutbox.ReplayCausation (.GetAwaiter().GetResult()) and FirestoreOutbox.ReplayCausation (BrighterAsyncContext.Run) block on the async path. This matches the existing convention in those classes, so it's consistent rather than a regression — just flagging it as the usual sync-over-async deadlock caveat for the sync UseInboxHandler path.

Things I checked that looked good

  • Relational replay adds an index on CausationId in the V8 migration (idx_{table}_CausationId), so UPDATE ... WHERE CausationId = @id won't full-scan.
  • _causationColumnExists memoization is correct for both sync (??=) and async (explicit cache check), and the "requires restart after mid-process migration" limitation is documented.
  • The handler correctly falls back to a fresh RequestContext (with a one-time warning) when a custom IRequestContext can't carry the causation id, and captures the span before the first await.
  • Pipeline validation (ReplayRequiresCausationTracking) correctly distinguishes Error (role not implemented) vs Warning (role present, schema not migrated / no outbox), and wraps the probe so an unreachable store doesn't crash host startup.
  • Replay path returns the duplicate command without re-adding it to the inbox — correct.

Nice work overall — the mixed-migration handling in particular is very thorough. Addressing #1 would bring the DynamoDB backends up to the same runtime robustness as the relational ones.

🤖 Generated with Claude Code

@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown

Code Review — Replay Outgoing Messages When Inbox Receives Duplicate (#4067)

Thanks for a substantial, well-structured feature. I reviewed the core mechanics (the sync/async UseInboxHandler, RelationDatabaseOutbox/RelationalDatabaseInbox, the in-memory boxes, DI wiring, pipeline validation) and spot-checked the per-backend query/migration catalogs and the DynamoDB path. Overall this is high quality: spec-driven (ADR 0057 / spec 0027), heavily documented, and with broad test coverage.

What's strong

  • Graceful degradation is thorough. Live-schema probes (CausationColumnExists, SupportsCausationTracking) gate every write/replay path, so a mixed state (inbox migrated, outbox not) safely no-ops rather than erroring against an absent column. The legacy "lack causation column" tests across every backend confirm this.
  • Idempotent migrationsADD COLUMN IF NOT EXISTS (Postgres), IdempotencyCheckSql gating (SQLite/MSSQL), plus replay indexes. Safe on chain replay.
  • Telemetry distinguishes Replay from Replay Skipped (no causation id found), which will save operators from misreading a no-op as a successful resend.
  • The async span-capture comment/fix (capturing Context?.Span before the first await because RequestContext.Span is thread-affine) is a genuinely subtle correctness point handled correctly.
  • DynamoDB replay handles GSI pagination and eventual-consistency vanished items (ConditionalCheckFailedException) instead of unwinding the loop.
  • Removing the duplicated base.InitializeFromAttributeParams(...) call in the async handler is a nice incidental cleanup.

Things worth confirming / addressing

1. Binary-breaking interface changes — release/versioning.
IRequestContext.InstrumentationOptions and IAmAnOutboxProducerMediator.Outbox are added as members with no default interface implementation, so any external type implementing these interfaces directly breaks at both source and binary level. The XML doc on InstrumentationOptions says "minor-version bump," but a binary-breaking interface change is conventionally a major version per SemVer. Please confirm this lands in a release that allows the break (or consider a default interface method where the target frameworks permit).

2. Log-level change is a behavior change beyond feature scope.
UseInboxHandler (sync) changes CommandHasAlreadyBeenSeenAsWarning from LogLevel.Debug to LogLevel.Warning. It aligns sync with the async handler (already Warning) and matches the intent of OnceOnlyAction.Warn, so it looks deliberate — but it silently changes logging output for existing Warn users. Per CLAUDE.md's "Change Scope" guidance, worth calling out explicitly in the release notes as an intentional fix.

3. Causation-inheritance / replay-scope semantics.
The handler stamps bag[CausationId] = command.Id only when the key is absent. Today nothing else populates that bag, so causation == the command's own id and replay is nicely scoped to exactly the messages that handling produced. But the moment causation propagation is added (e.g. from message headers / a workflow), a single duplicate would reset the dispatched state of every outbox row sharing that causation id — including sibling/upstream messages. The PR description ("replay a whole workflow") suggests this may be intended; if so, please document the replay-scope semantics near OnceOnlyAction.Replay / ReplayCausation so it isn't a surprise later.

4. Replay is unconditional and untransacted.
ReplayCausation resets dispatched state for the whole causation on every duplicate hit, regardless of whether those messages were already re-dispatched. That's consistent with at-least-once delivery, but a duplicate storm will repeatedly reset-and-resend. Fine as designed — just flagging it as a property to be aware of.

5. Validation runs a live DB probe at startup.
ReplayRequiresCausationTracking calls SupportsCausationTracking() during pipeline validation, which issues a schema round-trip at host startup and requires the store to be reachable. It's correctly wrapped in SupportsCausationTrackingSafely (probe failure → Warning, not a crash), so this is acceptable — noting it for anyone surprised by a DB call during validation.

Nits

  • s_warnedAboutCustomContext is a static field on the generic UseInboxHandler<T>, so the "Set once, process-wide" comment is slightly inaccurate — it's once per closed generic T. Harmless, but the comment overstates it.
  • Column-type inconsistency: Postgres inbox uses character varying(256) for causationid while the outbox uses character varying(255). Ids are GUID-length in practice, so it doesn't bite, but consider aligning.

None of these are blockers on correctness of the happy path, which looks sound end-to-end (bag stamped before base.Handle → outbox rows carry the causation → inbox stores it → duplicate replays exactly those rows). The main ask before merge is a decision on item 1 (SemVer) and a doc note on items 2 and 3.

Automated review — I built understanding from the diff and source; I was not able to run a local build in this environment, so I'm relying on CI for compile/test verification.

@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown

Code Review: Replay Outgoing Messages When Inbox Receives Duplicate (#4067)

I reviewed the core design and implementation (inbox/outbox handlers, the causation-tracking role interfaces, the relational + DynamoDB + in-memory stores, DI registration, and pipeline validation). This is a large but very well-executed change — the ADR-0057 write-up, the backward-compatibility gating, the telemetry, and the test coverage (base tests + generated + a genuine end-to-end pump test) are all excellent. Feedback below is mostly nuance and a couple of footguns worth surfacing.

Strengths

  • Backward compatibility is handled carefully. The memoized live-schema probe (_causationColumnExists / _causationIndexExists) gates the write path on actual column/GSI existence rather than the static driver capability, so an upgraded-but-un-migrated store keeps depositing unchanged. The reasoning is documented inline where it matters.
  • Concurrency edge cases are respected. The async handlers capture Context?.Span before the first await (span is thread-affine), and DynamoDB's ReplayCausationAsync paginates the GSI and swallows ConditionalCheckFailedException for items that vanished from an eventually-consistent index. Both are subtle and correct.
  • Validation fails fast. ReplayRequiresCausationTracking distinguishes Error (role not implemented) from Warning (role present but schema not migrated / no outbox), and wraps the probe in SupportsCausationTrackingSafely so an unreachable store can't crash host startup.

Concerns worth a look

1. The biggest footgun — silent no-op if Context isn't threaded through Post/DepositPost. You've documented this prominently in the release notes (👏), but it deserves emphasis: replay correctness depends on every message-producing handler forwarding its Context to Post/DepositPost. Forgetting it stores a null CausationId and a later Replay finds nothing — no error, no warning. Given how idiomatic _commandProcessor.Post(evt) (no context) is across existing Brighter code and docs, this will bite people. Consider whether a diagnostic is feasible — e.g. when OnceOnlyAction.Replay is configured and an outbox Add happens with a null CausationId in a context that had one available, log once at Warning. Not blocking, but the silent failure mode is the thing users will actually hit.

2. DynamoDbConfiguration.CausationIndexName is a settable property that must never be set. The GSI hash-key annotation on MessageItem.CausationId uses a compile-time constant "Causation", so overriding this property silently re-points the probe/query at a GSI the model never declares → replay silently finds nothing. The XML remarks warn against it, but exposing a public setter that can only break things is a trap (contrast the Outstanding/Delivered index names, which are meaningfully overridable). Consider making it get-only / a const, or at least not offering a setter.

3. Extra probe round-trip on first deposit for all relational users. Add/AddAsync now call CausationColumnExists[Async]() unconditionally, so every relational inbox/outbox instance issues one extra schema-probe query on its first deposit — even for users who never touch Replay. It's memoized per instance so aggregate cost is negligible, but the ADR's "byte-for-byte identical deposit behaviour" claim is only true of the INSERT itself; there's a new (small) transient-failure surface on the very first deposit (if the probe throws, that first Add fails and retries next call). Worth being precise about in the notes.

4. GetRequiredService<IAmACausationTrackingOutbox>() can return null in the factory-based AddProducers overload. Because the descriptor is always registered (factory returns (outbox as IAmACausationTrackingOutbox)!), a non-tracking outbox yields null from GetRequiredService rather than the usual throw. You've documented this and the sole consumer uses GetService + null-check, so it's safe today — but a future caller reasonably trusting GetRequiredService to be non-null would be surprised. A tiny risk given the note.

5. Multi-subscriber causation collision. As the ADR notes, because the default causation id is command.Id and ReplayCausation matches on value alone, a duplicate delivered to one [UseInbox] handler replays the outbox messages of every handler that shared that command id. This is intended (drive the whole workflow forward) and [UseInbox] is command-oriented in practice, but it's an operational sharp edge worth keeping visible in user-facing docs, not just the ADR.

Minor

  • InMemoryOutbox.ReplayCausationAsync runs the sync body inline and only routes cancellation through the TCS; a synchronous throw from ReplayCausation would propagate directly rather than via the returned task. Harmless for in-memory, just slightly inconsistent with the TCS pattern.
  • IRequestContext.InstrumentationOptions is correctly flagged as a source/binary-breaking addition for external IRequestContext implementers (no default interface member on netstandard2.0). Good that it's called out for versioning.

Tests

Coverage looks strong: base tests exercised across every persistent backend, generated causation outbox tests, validation tests (including the "replay configured without causation tracking" error case), telemetry assertions, and a real end-to-end pump test that posts a duplicate and asserts the outbox replays. The DynamoDB "vanished item" and legacy/un-migrated-schema paths are explicitly covered too. Nice.

Overall: high-quality, well-documented work. Nothing here is a blocker; items 1 and 2 are the ones I'd most want addressed (or explicitly accepted) before merge.

Reviewed with Claude Code.

Comment thread src/Paramore.Brighter/Inbox/Handlers/UseInboxHandler.cs Fixed
Comment thread src/Paramore.Brighter/Inbox/Handlers/UseInboxHandlerAsync.cs Fixed
… (PR #4067 review)

Addresses the second PR #4067 review:

1. DynamoDB ReplayCausationAsync (V1 and V4) queried the Causation GSI
   unconditionally. In the "inbox migrated, outbox not yet" mixed state
   (AC10) the Dynamo inbox is schemaless so GetCausationId returns a non-null
   id, the handler reaches ReplayCausationAsync, and a table provisioned
   before the Replay feature has no Causation GSI — so the query threw a
   ValidationException that unwound the handler pipeline on every duplicate.
   Gate replay on the memoized SupportsCausationTrackingAsync probe and
   degrade to a no-op, matching the relational path. Adds a regression test
   in both V1 and V4 that drives replay against a GSI-less table (6/6 green
   against DynamoDB Local).

3. Documented the sync-over-async in the NoSQL replay entry points
   (DynamoDbOutbox.ReplayCausation x2, FirestoreOutbox.ReplayCausation) with a
   comment explaining why they block on the async-only SDKs and that this
   matches the existing convention in those classes.

(#2 no change: PROMPT.md is still ignored via the "AI agent session state"
.gitignore entry; the branch only dropped a duplicate copy.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review: Replay outbox messages on inbox duplicate detection (#4067)

Reviewed against the repo's CLAUDE.md/.agent_instructions conventions. This is a large, well-structured feature (ADR 0057, spec 0027) that adds OnceOnlyAction.Replay plus CausationId tracking across every inbox/outbox backend. Overall the code quality is high and the design is carefully thought through — the role-interface split, the backward-compatibility handling, the telemetry, and the pipeline validation are all solid. Comments below are mostly minor observations and a couple of things worth confirming.

Strengths

  • Clean role-interface design. IAmACausationTrackingInbox / IAmACausationTrackingOutbox are optional role interfaces layered onto the existing stores, so non-tracking implementations keep working. Good adherence to Responsibility-Driven Design.
  • Backward compatibility is handled thoughtfully. New CausationId columns are nullable; the "inbox migrated, outbox not" mixed state is explicitly handled by gating ReplayCausation on a live-schema probe (CausationColumnExists) rather than the static query interface (AC10). This is exactly the right call and is well-commented.
  • Async telemetry correctness. Capturing span before the first await in UseInboxHandlerAsync (because RequestContext.Span is thread-affine) is a subtle but correct detail, and the comment explains why.
  • Test coverage is excellent. Base tests, per-backend generated causation tests, validation tests, telemetry tests, mixed-migration/legacy seeders, DynamoDB "vanished item" edge case, and an end-to-end replay test. This is comprehensive.
  • Pipeline validation (ReplayRequiresCausationTracking) gives users an actionable startup error/warning when Replay is configured without the necessary support, and defensively swallows probe failures so validation can't crash host startup.

Observations / things to confirm

  1. Telemetry can report "Replay" when nothing was replayed (mixed-migration state). In UseInboxHandler(.Async), WriteReplayEvent distinguishes "...Duplicate Replay" vs "...Duplicate Replay Skipped" solely on causationId is null. But RelationDatabaseOutbox.ReplayCausation / DynamoDbOutbox.ReplayCausationAsync silently no-op when the outbox schema lacks the column/index while the inbox still returns a non-null causation id. In that state the span records "Duplicate Replay" even though the outbox resent nothing — an operator would misread it as a successful replay. Consider having ReplayCausation signal whether it actually did work (e.g. return rows-affected / a bool) so the event can reflect the no-op, or at least document this gap. (UseInboxHandler.cs:186, UseInboxHandlerAsync.cs:198)

  2. s_warnedAboutCustomContext comment says "process-wide", but it's per-closed-generic-type. Static fields on UseInboxHandler<T> are per T, so the "custom context disables Replay" warning fires once per message type, not once per process. Not a bug (the intent — keep the warning off the hot path — still holds), but the comment overstates the dedup scope. (UseInboxHandler.cs:48-50)

  3. Deferred DI registration returns null via a null-forgiving !. In the factory-based AddProducers overload, IAmACausationTrackingOutbox is registered as sp => (sp.GetRequiredService<IAmAnOutbox>() as IAmACausationTrackingOutbox)!, so GetRequiredService<IAmACausationTrackingOutbox>() returns null rather than throwing for a non-tracking outbox. The comment documents this and the only consumer uses optional resolution, so it's safe — but it's a footgun for any future consumer that reasonably expects GetRequiredService to throw. (ServiceCollectionExtensions.cs:464-466)

  4. Memoized schema probe is never invalidated. _causationColumnExists (relational) and _causationIndexExists (DynamoDB) are cached for the store's lifetime, so a store instance constructed before provisioning caches "absent" and a mid-process migration requires a restart. This is documented in the code comments; just make sure it's surfaced in user-facing docs/release notes so operators migrating a live system know a restart is required for Replay to activate.

  5. ReplayCausationCommand is an unbounded single UPDATE. UPDATE {0} SET Dispatched = NULL WHERE CausationId = @CausationId resets every message for a causation in one statement (no batching/limit). Fine for typical causation fan-out, but a pathological causation with a very large number of messages would produce one large write. There is an index on causationid (good), so the predicate is covered; worth being aware of for high-fan-out workflows.

  6. Sync span vs. requestContext divergence (very minor). In the sync UseInboxHandler, telemetry uses span = Context?.Span while inbox/outbox calls use the local requestContext, which in the custom-context fallback is a fresh RequestContext { Span = Activity.Current }. The two spans could differ in that fallback path. Thread-affinity isn't a concern on the sync path, so this is cosmetic, but the sync/async paths aren't quite symmetric here.

Correctness spot-checks that look right

  • Causation id is consistent end-to-end: the bag is stamped with request.Id (when absent), the inbox stores it, produced outbox messages carry it, and ReplayCausation matches on it. Inbox/outbox causation values line up for the same handling.
  • InMemoryOutbox.ReplayCausation resets TimeFlushed = DateTimeOffset.MinValue, which is exactly the sentinel OutstandingMessages tests for — correct.
  • DynamoDB replay tolerates the eventually-consistent GSI listing a vanished base item (ConditionalCheckFailedException swallowed per-item) and paginates correctly.

Nice work — this is a substantial, careful feature. The main thing I'd want addressed before merge is item #1 (the telemetry can misreport a no-op replay as a successful one in the mixed-migration state); the rest are minor or confirm-and-document.

Reviewed with Claude Code.

…review #1)

WriteReplayEvent chose "Duplicate Replay" vs "Duplicate Replay Skipped"
solely on causationId being null. In the mixed-migration state (inbox
migrated, outbox not) the inbox still returns a non-null causation id
while ReplayCausation silently no-ops against the absent column/index,
so the span recorded a successful replay when nothing was resent.

Make ReplayCausation / ReplayCausationAsync return bool signalling
whether a replay was actually performed (false = no-op because the live
schema lacks causation tracking). Both handlers pass the result to
WriteReplayEvent, which now emits the Skipped event when nothing was
replayed. Updated all 6 outbox implementations and the validation test
double; added sync+async handler tests for the mixed-state no-op and
strengthened both DynamoDB mixed-state tests to assert the false return.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 8, 2026

Copy link
Copy Markdown

Code Review — PR #4067: Replay outbox messages on inbox duplicate detection

Reviewed the core library changes (I focused on src/Paramore.Brighter/** rather than the ~150 generated/migration test files, which look mechanically consistent). Overall this is a high-quality, well-engineered PR: the spec-driven workflow shows, the optional role interfaces (IAmACausationTrackingInbox / IAmACausationTrackingOutbox) preserve backward compatibility, the mixed-migration-state handling is carefully thought through, and the inline comments explaining non-obvious decisions (span capture before await, the memoization race, the live-schema gate vs. the static query interface) are excellent. Nice catch fixing the duplicate base.InitializeFromAttributeParams(...) call in the async handler too.

Below are observations, mostly minor — nothing I'd consider a blocker.

Design & correctness

  1. IRequestContext.InstrumentationOptions is a binary/source-breaking change. Adding an interface member with no default implementation breaks any external type implementing IRequestContext directly. The XML remarks already call this out and recommend a minor-version bump — good. Please make sure this is surfaced prominently in release_notes.md / migration notes, since it's easy for a downstream implementer to miss.

  2. Sync handler log-level change (Debug → Warning) for the Warn action. CommandHasAlreadyBeenSeenAsWarning was previously logged at Debug; it now logs at Warning. This aligns the sync handler with the async handler (which was already Warning), so it looks like an intentional bugfix — but it's a behavior change not mentioned in the PR description, and per the repo's "change scope" guidance it's worth an explicit confirmation that it's intended.

  3. Handler now passes the shared pipeline Context to _inbox.Add. Previously InitRequestContext() always handed the inbox a fresh RequestContext { Span = Activity.Current }; now it passes Context as RequestContext (falling back to a fresh one only for custom contexts). This is necessary for the causation id to flow, but it is a semantic change — the inbox Add now sees the live pipeline context (full Bag, policies, etc.) rather than an isolated one. Intentional, just flagging it for awareness.

Minor / nits

  1. s_warnedAboutCustomContext is not actually "process-wide". The comment says "Set once, process-wide", but a static field in a generic type UseInboxHandler<T> has one instance per closed generic type, so the warning will fire once per request type T, not once per process. Not harmful, but either the comment should be corrected or the flag moved to a non-generic holder if truly once-per-process is desired.

  2. Replay telemetry reports success on a zero-row replay. WriteReplayEvent emits "UseInboxHandler Duplicate Replay" whenever replayed == true, but replayed only means the outbox ran the reset — a causation whose messages matched zero rows still reports a successful replay rather than a skip. Consider whether operators would find "replayed 0 messages" misleading vs. the existing "...Replay Skipped" event.

  3. Memoized _causationColumnExists is never invalidated. A store constructed before schema provisioning caches "absent" and requires a process restart to pick up a later migration. This is explicitly documented in the code comment (good) — just ensure the operational caveat also lands in the user-facing docs, since "I migrated but replay is still a no-op until restart" is a plausible support question.

  4. First Add pays a schema-probe round trip on a separate connection. The initial CausationColumnExists() probe runs via ReadFromStore/ReadFromStoreAsync, i.e. on its own connection outside any ambient user transaction passed via transactionProvider. Fine for an information_schema-style read, but worth being aware of if anyone later expects the probe to participate in the caller's transaction.

  5. Startup validation only calls the sync SupportsCausationTracking() probe (SupportsCausationTrackingSafely takes Func<bool>), even for async pipelines — so a relational store opens a synchronous connection during host startup. It's wrapped in try/catch and degraded to a Warning, so it won't crash startup, but async-only environments will still take the sync path here.

  6. Housekeeping: the diff touches both specs/.current-spec (hyphen) and specs/.current_spec (underscore, now emptied). Looks like a stray/duplicated tracking file worth cleaning up.

Tests

Test coverage is a real strength — the base-test pattern (CausationTrackingInboxBaseTests / CausationTrackingOutboxBaseTests) plus generated per-backend suites gives broad coverage, and When_a_seen_message_is_replayed_end_to_end is a genuine end-to-end exercise through a real pump/bus rather than a seam. Good coverage of the "un-migrated schema" degradation path (When_..._lack_causation_column_should_still_add_and_retrieve), the vanished-item race for DynamoDB, and the validation error path. 👍

Nice work overall — the concerns above are polish, not correctness blockers.

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
@claude

claude Bot commented Jul 8, 2026

Copy link
Copy Markdown

Code Review — PR #4067: Replay outbox messages on inbox duplicate detection

Reviewed against the design in docs/adr/0057-* and the guardrails in CLAUDE.md. This is a large, well-structured change: the role-interface split (IAmACausationTrackingInbox / IAmACausationTrackingOutbox), the opt-in schema evolution via BoxProvisioning, the runtime SupportsCausationTracking() probe, and the startup pipeline validation are all thoughtful, non-breaking, and consistent with the existing IAmA* conventions. Test coverage is broad (base tests reused across every persistent store, plus telemetry, validation, and end-to-end tests). Nice work overall.

One blocking concern and a few smaller notes.

🔴 Blocking — likely compile error in both inbox handlers

src/Paramore.Brighter/Inbox/Handlers/UseInboxHandler.cs:100 and src/Paramore.Brighter/Inbox/Handlers/UseInboxHandlerAsync.cs:102:

private static bool s_warnedAboutCustomContext;   // line 51
...
if (_onceOnlyAction is OnceOnlyAction.Replay
    && Interlocked.CompareExchange(ref s_warnedAboutCustomContext, 1, 0) == 0)

Interlocked.CompareExchange has no overload that takes ref bool — the overloads are int, uint, long, ulong, float, double, IntPtr, UIntPtr, object, and T where T : class. Because ref/out arguments require an exact type match (no implicit conversion of the referent), ref bool cannot bind to the ref int overload, and the 1/0 operands plus the == 0 comparison rule out the generic reference-type overload. This should fail to compile (CS1503 / CS0411).

The usual idiom is to make the flag an int:

private static int s_warnedAboutCustomContext;

(Note: I could not run dotnet build in this review sandbox to confirm, so please double-check against CI — but I am fairly confident this does not compile as written.)

🟡 Minor / worth a look

  1. DI role registration is inconsistent between the two outbox registration overloads. In the eager overload the IAmACausationTrackingOutbox descriptor is registered only when the outbox implements the role (ServiceCollectionExtensions.cs:332), so GetRequiredService<IAmACausationTrackingOutbox>() throws for a non-tracking outbox. In the deferred overload (:464) the descriptor is always registered and the factory returns null for a non-tracking outbox, so GetRequiredService returns null instead of throwing. It is documented and the only consumer uses the optional ctor arg, so it is not a bug — but the divergent resolution semantics for the same service type are a latent trap for a future consumer. Consider aligning both paths.

  2. Null causation id added as a telemetry tag. In WriteReplayEvent, { BrighterSemanticConventions.CausationId, causationId } is added even when causationId is null. Harmless, but you may prefer to omit the tag when there is nothing to record so the skipped-replay event does not carry a null attribute.

  3. Multiple-subscriber causation collision is documented as intended (default causation id = command.Id, so a replay from one [UseInbox] handler resends the outbox messages of every handler sharing that id). The ADR justifies this well for the Command case; worth surfacing in the user-facing docs/release notes too, since an event delivered to multiple subscribers with the same id is an easy footgun.

  4. WriteToStore(null, ...) in ReplayCausation deliberately runs the UPDATE ... SET Dispatched = NULL outside any ambient transaction provider. Reasonable for a standalone replay, but worth a one-line comment noting the intent, since every other write path threads a provider through.

Things I checked and liked

  • The live-schema probe (CausationColumnExists) is memoized and correctly gates both the Add path and ReplayCausation, giving a clean no-op (returning false) in the "inbox migrated, outbox not" mixed state rather than issuing SQL against an absent column.
  • Migration adds CausationId as a nullable column plus an idempotent replay index — non-breaking for un-migrated stores.
  • The replayed bool threaded back into telemetry correctly distinguishes "replayed" from "skipped/no-op".
  • Span captured once before the first await in the async handler, with a clear comment explaining the thread-affinity rationale.

Once the Interlocked field type is fixed (assuming it is confirmed), this looks solid.

Automated review by Claude Code.

{
// Silent replay degradation is a PITA to diagnose, so warn (once) when a custom context means
// Replay cannot flow the causation id to the outbox and will therefore be a no-op.
if (_onceOnlyAction is OnceOnlyAction.Replay && Interlocked.CompareExchange(ref s_warnedAboutCustomContext, 1, 0) == 0)
// Silent replay degradation is a PITA to diagnose, so warn (once) when a custom context means
// Replay cannot flow the causation id to the outbox and will therefore be a no-op.
if (_onceOnlyAction is OnceOnlyAction.Replay &&
Interlocked.CompareExchange(ref s_warnedAboutCustomContext, 1, 0) == 0)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3 - Done feature request .NET Pull requests that update .net code V10.X

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Force Replay of Outbox on seeing duplicate Processed Inbox Message

2 participants