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):
- As soon as
jira.CreateAsyncreturns a non-null ticket, callidempotency.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 duplicateticket_keycan ever be recorded, and a concurrentReserve()for the same key by another host will seeticket_keyalready set and skip callingCreateAsyncagain entirely — this is what closes the race, not the fence check. - THEN recheck the leader fence. If lost, do NOT proceed to
UpdateThreadTicket,AddCommentAsync, orEnqueueConfirmReplyfor 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. - If
idempotency.Completereturns/no-ops because another host already completed first (check viaidempotency.Get(key)returning a different ticket key than the one just created), log that this host's ownticketis 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
CreateAsyncagain; (c) the losing host's orphan-ticket case logs a warning and does not crash or duplicate side effects. dotnet build FounderIntake.slnxanddotnet test FounderIntake.slnxmust be clean/green.- Do not commit. Leave changes uncommitted for review.
- End with a per-item fixed/how summary.