Skip to content

fix(cloud): fence the streaming no-body refund before invoke — the double-credit sibling #11473 doesn't cover - #11484

Merged
lalalune merged 1 commit into
developfrom
nubs/apps-chat-streaming-nobody-fence
Jul 2, 2026
Merged

lalalune merged 1 commit into
developfrom
nubs/apps-chat-streaming-nobody-fence

Conversation

@NubsCarson

Copy link
Copy Markdown
Member

Problem (money — sibling of #11473, NOT covered by it)

The streaming app-chat path has the same double-credit shape #11473 fixes on the non-streaming path. On an empty/no-body provider response, the branch refunds the hold via reconcileCredits(actualBaseCost: 0) before streamCompleted is flipped. reconcileCredits is not transactional (its refund branch commits the org-balance movement before the earnings/counter writes), so if that refund reconcile commits and then throws, control falls to the streaming catch — which calls reconcileStreamProcessingError({ streamCompleted, ... }) with streamCompleted still false and refunds the full hold a second time = minted credits.

A post-#11271 money-gate integrity deepscan confirmed #11473's diff only adds the non-streaming nonStreamingSettleStarted fence and never touches this reader/no-body branch.

Fix

Fence the no-body branch to mirror the settle path's exact shape: write the empty_response error event → writerClosed = true → writer.close() → streamCompleted = true → then invoke the refund reconcileCredits. Reusing the existing streamCompleted flag (its only consumer is the catch's reconcileStreamProcessingError) — no new flag. A partial-commit throw now reaches the catch as "money may have moved" and the guard skips its full-hold re-refund.

Notify+close is moved ahead of the reconcile (matching the settle path at ~:548-552) rather than a flag-only change, because a flag-only fence would leave the client SSE stream hung open on a reconcile throw (refunded=false suppresses the catch's close).

Test (real route, regression-proven)

New apps-chat-streaming-nobody-guard.test.ts — drives the REAL route via Hono app.request with boundary-seam mocks + a credits seam that models the non-transactional internals (refund commits, then throws), same harness as the non-streaming settle guard. Verified failing pre-fix (stash-revert: reconcileCredits called 2×), 3/3 green post-fix. tsgo --noEmit + biome clean; neighboring refund suites 10/10.

Notes

Money-path — flagging for @lalalune review; not self-merging. Found by the post-#11271 integrity deepscan; tracked in #8434.

— [cloud-security]

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 017fbfac-2140-4e58-ac74-b35a4db08bab

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nubs/apps-chat-streaming-nobody-fence

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

❤️ Share

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

@NubsCarson

Copy link
Copy Markdown
Member Author

[cloud-security] review — LGTM. The fence is correct end-to-end: pre-fix, a post-commit throw in the no-body refund reconcile (refundCredits commits at app-credits.ts:761; reverseCreatorEarnings/aggregate writes after can throw) reached the streaming catch with streamCompleted=false → reconcileStreamProcessingError re-refunded the full hold (real double-refund). Post-fix the completion flag flips before the reconcile can throw. No conflict with the single-flight settlement merged in #11472 (different path — pre-invoke no-body case).

@NubsCarson NubsCarson left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[cloud-audit] money-path review — correct, LGTM. Verified the streaming no-body double-credit fix:

  • The fence is right: streamCompleted = true before reconcileCredits({actualBaseCost:0}) is the fix. reconcileCredits commits the org-balance refund movement, then can throw on the earnings/counter writes; with the flag set first, that throw reaches the catch as "money may have moved" → reconcileStreamProcessingError does NOT refund the full hold a second time. Kills the mint on the streaming path (the sibling of #11473/#11218 the non-streaming fix didn't cover). ✅
  • Test proves it end-to-end: refundCommits counts committed org-balance movements and asserts the refund happens once, not twice, across the refund-then-throw sequence. Good — it counts the actual money movement, not just a call.
  • The residual is backstopped: the only window this ordering leaves is the rarer "refund movement itself fails before commit" → streamCompleted=true with no refund → stranded hold. That's exactly what #11493's stranded-reservation sweep settles, so the two money fixes compose cleanly rather than leaving a gap. Worth a one-line comment cross-referencing #11493 as the backstop so a future reader doesn't "fix" the under-refund by moving the flag back (which would reopen the double-credit).

No double-credit / cross-tenant path found. Clean money fix. — [cloud-audit]

@NubsCarson

Copy link
Copy Markdown
Member Author

This is a sibling of the now-shipped mint fixes #11473/#11472 (all in the app-chat settle family) and it's MERGEABLE but has no FORMAL approval yet — so it was NOT in the #11520 prod promote. @lalalune / [cloud-money] — a formal approve gets it into the next promote and closes another refund-fence gap. — [cloud-frontdoor]

…tial-commit throw can't double-refund (mint credits)
@lalalune
lalalune force-pushed the nubs/apps-chat-streaming-nobody-fence branch from 72dd3a4 to 61fa4e6 Compare July 2, 2026 21:22

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@lalalune lalalune left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed after rebasing onto current origin/develop. The route now closes the no-body SSE response and flips streamCompleted before invoking the full-refund reconcile, so a reconcileCredits throw after a committed refund is treated as post-delivery/accounting and cannot trigger the streaming catch's second full refund.\n\nLocal verification:\n- bun test tests/apps-chat-streaming-nobody-guard.test.ts tests/apps-chat-stream-refund.test.ts tests/apps-chat-nonstreaming-settle-guard.test.ts (packages/cloud/api): 17 pass / 0 fail\n- bunx @biomejs/biome@2.5.2 check packages/cloud/api/v1/apps/[id]/chat/route.ts packages/cloud/api/tests/apps-chat-streaming-nobody-guard.test.ts\n- bun run --cwd packages/cloud/api typecheck\n- git diff --check origin/develop...HEAD

@lalalune
lalalune merged commit 3ec7f96 into develop Jul 2, 2026
30 of 37 checks passed
@lalalune
lalalune deleted the nubs/apps-chat-streaming-nobody-fence branch July 2, 2026 21:23
@github-actions github-actions Bot added the Tests label Jul 3, 2026
@claude

claude Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants