Skip to content

WING-131 — decouple ticket persistence from leader-fence ownership

Residual risk from FLT-75 review round 7 (see FLT-75-REVIEW-ROUND6.md/ROUND7 findings in git history on the FLT-75 branch, already merged to main): if intake-host leadership changes while ConversationResponder.cs is awaiting jira.CreateAsync, and the create succeeds just as the post-call fence check fails, the current code abandons the result without recording it. A new leader then reclaims the (round-5) abandoned-reservation-retry path, creates a second ticket, and only the second is ever recorded — the first is a real, dangling Jira ticket.

Root fix

IdempotencyStore.Complete(key, ticketKey) already only writes ticket_key when it is currently NULL (see its SQL: WHERE idempotency_key=$key AND ticket_key IS NULL). That is itself a correct CAS — the store is already safe against two concurrent completions racing. The bug is that ConversationResponder.cs currently gates the Complete call behind the leader-fence recheck, so a real, successful Jira ticket can go unrecorded.

Change ConversationResponder.cs around the Jira-create block (currently ~line 227-250):

  1. As soon as jira.CreateAsync returns a non-null ticket, call idempotency.Complete(envelope.IdempotencyKey, ticket.Key) UNCONDITIONALLY — before any fence recheck, regardless of leadership status. This is safe: if a concurrent host already completed the same key first, this call is a no-op (0 rows affected); if this host is first, it wins. Either way, no duplicate ticket_key can ever be recorded, and a concurrent Reserve() for the same key by another host will see ticket_key already set and skip calling CreateAsync again entirely — this is what closes the race, not the fence check.
  2. THEN recheck the leader fence. If lost, do NOT proceed to UpdateThreadTicket, AddCommentAsync, or EnqueueConfirmReply for this result — log it (info level: "Jira ticket {Key} created but fence lost after create; suppressing side effects, another host owns delivery") and return. The fence still gates all externally visible, replayable side effects (Slack/email reply, Jira comment, thread metadata), just not the durable idempotency record itself.
  3. If idempotency.Complete returns/no-ops because another host already completed first (check via idempotency.Get(key) returning a different ticket key than the one just created), log that this host's own ticket is an orphan (real Jira issue with no reservation pointing to it) at WARNING level so it's discoverable — do not attempt to delete or modify the orphaned Jira issue.

Also fix

The completed-ticket recovery branch (~line 157-160, the one already patched in round 6/7 to call CloseActiveEnvelope) should behave consistently: if this host's own Complete call was the no-op (lost the race), it must still transition through the SAME reconciliation path as a normal already-completed recovery (state → Done, envelope closed) rather than silently returning — the thread must not get stuck.

Constraints

  • C# only, follow existing code style (this repo, FounderIntake.Host/FounderIntake.Core).
  • Add/update tests in FounderIntake.Tests covering: (a) fence lost after successful create still persists the idempotency record; (b) a second host's concurrent create attempt after the first already completed sees the existing ticket key and does not call CreateAsync again; (c) the losing host's orphan-ticket case logs a warning and does not crash or duplicate side effects.
  • dotnet build FounderIntake.slnx and dotnet test FounderIntake.slnx must be clean/green.
  • Do not commit. Leave changes uncommitted for review.
  • End with a per-item fixed/how summary.