Skip to content

fix(payments): add atomic bounded payment lifecycle - #11

Merged
drewstone merged 32 commits into
mainfrom
fix/x402-priced-reservation
Aug 16, 2026
Merged

fix(payments): add atomic bounded payment lifecycle#11
drewstone merged 32 commits into
mainfrom
fix/x402-priced-reservation

Conversation

@drewstone

@drewstone drewstone commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Move durable payment execution after sandbox acquisition.
  • Add renewable payment and A2A execution leases.
  • Fence cross-worker cancellation with a durable task execution marker.
  • Reconcile x402 claiming rows when the provider has no operation.
  • Price complete conversation input and provider-hidden context bounds.
  • Require atomic nonce claims on every payment path.
  • Preserve the 0.7.1 verifyMpp and markSeen APIs while keeping old A2A stores safe.
  • Reject plain Cloudflare KV for payment claims unless an atomic backend is supplied.

Proof at 5e5a4c7

  • pnpm test — 21 files, 327 tests passed
  • pnpm typecheck — passed
  • pnpm build — passed
  • pnpm pack --dry-run — passed; package reports 0.8.0
  • git diff --check — passed
  • git merge-tree --write-tree origin/main HEAD — clean

Release

This is 0.8.0 because the atomic nonce contract and x402 recovery result are public contract changes.
No package publish or production deployment has occurred.

@drewstone

Copy link
Copy Markdown
Contributor Author

/review

@drewstone

Copy link
Copy Markdown
Contributor Author

/review

@drewstone

Copy link
Copy Markdown
Contributor Author

/review

@drewstone

Copy link
Copy Markdown
Contributor Author

/review

@drewstone

Copy link
Copy Markdown
Contributor Author

/review 2335d1589f801c77a56fb8521b305582e505ed7a

@drewstone

Copy link
Copy Markdown
Contributor Author

/review 2335d15

@drewstone

Copy link
Copy Markdown
Contributor Author

/review

@drewstone

Copy link
Copy Markdown
Contributor Author

/review

@drewstone

Copy link
Copy Markdown
Contributor Author

/review

@drewstone

Copy link
Copy Markdown
Contributor Author

/review a32c1f6

@drewstone

Copy link
Copy Markdown
Contributor Author

/review

@drewstone

Copy link
Copy Markdown
Contributor Author

/review exact

@drewstone

Copy link
Copy Markdown
Contributor Author

/review exact

@tangletools

Copy link
Copy Markdown

✅ No Blockers — 5e5a4c75

Review health 100/100 · Reviewer score 80/100 · Confidence 60/100 · 0 findings (none)

opencode GLM 5.2 opencode DeepSeek v4 Pro verifier:deepseek-flash aggregate
Readiness 95 95 80 80
Confidence 60 60 60 60
Correctness 95 95 55 55
Security 95 95 55 55
Testing 95 95 55 55
Architecture 95 95 55 55

Reviewer score is advisory once the run is complete and the verdict has no blockers.

Full multi-shot audit completed 0/0 planned shots over 0 changed files. Global verifier still owns final merge decision. | Full multi-shot audit completed 0/0 planned shots over 0 changed files. Global verifier still owns final merge decision. | The diff 9041c7f..5e5a4c7 is empty: the base argument resolves to a git tree object whose content is byte-for-byte identical to the HEAD commit tree (verified by hashing git ls-tree -r of both to the same blob hash). Neither reviewer produced findings, and there are no changed files to independently inspect, so no defect is evidence of any issue. Sco

No findings.


tangletools · 2026-08-15T08:35:05Z · trace

tangletools
tangletools previously approved these changes Aug 15, 2026

@tangletools tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Clean — 5e5a4c75

Full multi-shot audit completed 0/0 planned shots over 0 changed files. Global verifier still owns final merge decision. | Full multi-shot audit completed 0/0 planned shots over 0 changed files. Global verifier still owns final merge decision. | The diff 9041c7f..5e5a4c7 is empty: the base argument resolves to a git tree object whose content is byte-for-byte identical to the HEAD commit tree (veri

Full immutable report for this review: trace

Summary comment for this run: full summary


tangletools · 2026-08-15T08:35:05Z · immutable trace

@tangletools

Copy link
Copy Markdown

✅ No Blockers — 58fe4790

Review health 100/100 · Reviewer score 53/100 · Confidence 70/100 · 7 findings (3 medium, 4 low)

deepseek: Correctness 53 · Security 53 · Testing 53 · Architecture 53

Reviewer score is advisory once the run is complete and the verdict has no blockers.

Full multi-shot audit completed 2/2 planned shots over 41 changed files. Global verifier still owns final merge decision.

🟠 MEDIUM Cross-worker tasks/cancel is refused while another worker holds the execution fence — src/a2a/handler.ts

Guard (hasActiveTaskExecution(task) && !cancels.hasController(task.id)) returns TASK_NOT_CANCELABLE ('being finalized') whenever the durable gatewayExecution marker lease is live but this process has no AbortController. In a multi-worker deployment — the exact scenario the new createIfAbsent/compareAndSet machinery exists for — a cancel request routed to a worker that is NOT running the task always hits this branch, because hasActiveTaskExecution reads the durable marker (written by claimTaskExecution and renewed every 5s by the runner) while cancels.hasController is in-process only. The correct durable path (CAS the task to 'canceled', which makes the runner's renewTaskExecution throw TaskExecutionCanceledError and abort its stream) is never reached. tests/pr11-regressions.test.ts:171 o

🟠 MEDIUM In-flight heartbeat renewal can race the finalization CAS and drop a completed response — src/a2a/handler.ts

The heartbeat callback at handler.ts:350 does workingTask = await renewTaskExecution(...), and dispatch.ts:1257-1267 runs it via setInterval with an un-awaited in-flight promise; the generator's finally (dispatch.ts:1343) clears the interval but does not await the pending renewal. If a heartbeat read the pre-finalization 'working' row and its compareAndSet lands after the sandbox stream drained but before compareAndSetTask(workingTask, finalizingTask) at handler.ts:425, the finalization CAS fails (payload mismatch), the code throws 'A2A task changed before payment settlement', the response text is discarded, and the task is marked 'failed' with the payment retained (releasePaymentAfterFailure(..., workObserved=true) puts it in 'retained' state) rather than settled. Narrow timing window,

🟠 MEDIUM Fallback retry reconstructs exact x402 charge from a lossy float, can strand a captured payment — src/payment-recovery-worker.ts

On the first fallback settle, fallback = !record.usage is true, so settleRecoveredPayment passes paymentAmount: BigInt(record.attribution.requiredAmount) (line 302-303), an exact bigint. settleAndRecord then calls markRecoverySettling, which persists the synthesized quotedCeilingUsage (all-zero tokens + providerCostUsd float) into the durable record's usage field and sets settlementBasis 'quoted-ceiling' (dispatch.ts:1425,1534-1544). If settlePayment succeeds but a later step fails (recordUsage or markRecoveryReconciled throws), the row stays 'settling' with usage now set. On retry, fallback = !record.usage is now FALSE, so paymentAmount is no longer p

🟡 LOW Cancel path retains the gatewayExecution fence marker in the terminal task — src/a2a/handler.ts

The canceled task is built as { ...task, status: { state: 'canceled', ... } } directly rather than via withStatus, so clearTaskExecution (invoked by withStatus for every terminal transition, handler.ts:2454) is skipped. completeCanceledTask's no-usage branch then reuses that stored task as its base, so the gatewayExecution marker (containing the payment requestId and lease) persists in the stored task and is serialized into the push webhook body until its 5-minute lease expires. Inconsistent with completed/failed/input-required transitions, which all clear the fence. Impact: minor metadata leak into webhooks and an inconsistent terminal shape; no data loss. Fix: construct the canceled status through withStatus so the fence is cleared uniformly.

🟡 LOW Push webhook SSRF hardening covers redirects but not direct internal hosts — src/a2a/push-notifications.ts

The new check enforces https + no embedded credentials and sets redirect: 'manual', which blocks redirect-based SSRF to internal HTTP services or metadata endpoints (tests/pr11-regressions.test.ts:727 covers this). However the host itself remains attacker-controlled: a client can register https://<internal-host>/ via tasks/pushNotificationConfig/set (the set handler only checks isHttpsUrl, same constraints) and the gateway will POST the task envelope to an internal HTTPS service. The redirect:'manual' flag does nothing against a URL that points directly at an internal host. Blind (response is only logged, not returned), and https-only rules out the common HTTP metadata endpoints, but internal HTTPS services remain reachable. Fix if hardening further: resolve the host and reject loopbac

🟡 LOW options.now ignored when computing lease/retry timestamps — src/payment-recovery-worker.ts

recoverPayment computes scanNow = options.now ?? recoveryNow(options) at line 80, but then recomputes const now = recoveryNow(options) at line 84 and const failedAt = recoveryNow(options) at line 102. recoveryNow only consults options.clock, never options.now, so a caller passing now without clock gets wall-clock lease expiry and retry timestamps while the due-check uses the supplied now. r

🟡 LOW reconciledAt uses Date.now() instead of injected clock — src/payment-recovery-worker.ts

recoverUnknownMppCharge sets reconciledAt: Date.now() (line 239) and completeRecord sets reconciledAt: Date.now() (line 391), bypassing the injected clock/now used everywhere else. Under vi.useFakeTimers or a custom clock these fields disagree with nextAttemptAt/updatedAt, and the recovered-record timestamps are non-deterministic. recoverUnknownMppCharge does not even receive the now parameter from reconcileLeased. Fix: thread now through these helpers and use it in place of Date.now().


tangletools · 2026-08-15T09:25:47Z · trace

tangletools
tangletools previously approved these changes Aug 15, 2026

@tangletools tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Approved — 7 non-blocking findings — 58fe4790

Full multi-shot audit completed 2/2 planned shots over 41 changed files. Global verifier still owns final merge decision.

Full immutable report for this review: trace

Summary comment for this run: full summary


tangletools · 2026-08-15T09:25:47Z · immutable trace

@tangletools

Copy link
Copy Markdown

⚠️ Review Incomplete — 6381dd9e

At least one required reviewer lane failed closed. No approval or request-changes review was published. This is a reviewer run failure, not a PR quality score.

Trigger a fresh review on the current PR head.

tangletools · 2026-08-15T09:56:47Z

@tangletools

Copy link
Copy Markdown

⚠️ Review Incomplete — 43897380

At least one required reviewer lane failed closed. No approval or request-changes review was published. This is a reviewer run failure, not a PR quality score.

Trigger a fresh review on the current PR head.

tangletools · 2026-08-15T10:42:25Z

@drewstone

Copy link
Copy Markdown
Contributor Author

/review exact

@drewstone

Copy link
Copy Markdown
Contributor Author

/review exact

@drewstone

Copy link
Copy Markdown
Contributor Author

/review exact de86465

@drewstone
drewstone merged commit c7e4fb6 into main Aug 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants