Skip to content

fix(issue-221): reject over-capture and require actualAmount on successful reports - #223

Open
JonasBaeumer wants to merge 4 commits into
mainfrom
fix/issue-221-settlement-contract
Open

JonasBaeumer wants to merge 4 commits into
mainfrom
fix/issue-221-settlement-contract

Conversation

@JonasBaeumer

Copy link
Copy Markdown
Owner

Summary

Hardens the POST /v1/agent/result → settleIntent settlement contract: over-capture reports (actualAmount > reserved) are now rejected with an audit trail and no state change, and success: true without actualAmount is a 400 at the REST boundary instead of silently settling 0 and refunding the whole pot. Closes #221

Chosen policy (over-capture): reject. The virtual card's network-level spending limit means the card cannot legitimately be charged above the reservation, so a report claiming more is by definition wrong — buggy or malicious worker. The report is refused (422), an OVER_CAPTURE_REJECTED AuditEvent records both amounts, and the intent stays CHECKOUT_RUNNING (no settlement, no card cancellation) so a corrected report can follow. This is the conservative default the issue frames and the direction leaned toward in the #216 review.

Closes #221

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Tests only
  • Docs / config

Module(s) affected

  • Contracts (src/contracts/)
  • DB / Prisma (prisma/, src/db/)
  • API Gateway (src/api/, src/app.ts)
  • Orchestrator (src/orchestrator/)
  • Payments (src/payments/)
  • Policy / Approval (src/policy/, src/approval/)
  • Ledger (src/ledger/)
  • Queue / Worker (src/queue/, src/worker/)
  • Telegram (src/telegram/)
  • Tests / QA
  • Docs / Tooling

Checklist

  • npm test passes locally (425 tests, 38 suites; was 412 on main)
  • New or changed logic has unit tests
  • Integration tests added/updated if DB or Redis is touched — unit-level coverage via existing mocking patterns; see notes on the ledger-invariant test below
  • No PAN, CVC, or card expiry is logged or stored (security rule)
  • No new cross-module file edits (used function imports instead)
  • Types added/updated in src/contracts/ if shared across modules (OverCaptureError in src/contracts/ledger.ts)
  • .env.example updated if new env vars are introduced (no new env vars)

What changed

  1. Route guard (src/api/routes/agent.ts): on success: true, the route loads the intent's pot and compares actualAmount against pot.reservedAmount (the number reserveForIntent deducted from mainBalance and booked as the RESERVE ledger entry). If above, it writes the audit event and returns 422 naming both amounts — before completeCheckout, settleIntent, card cancellation, or the metadata write.
  2. Validator (src/api/validators/agent.ts): agentResultSchema now superRefines actualAmount as required when success is true. Failure reports without an amount are unchanged.
  3. Defense in depth (src/ledger/potService.ts): settleIntent throws the new OverCaptureError before any write when actualAmount > pot.reservedAmount, so no future caller can bypass the route check.

Per-behavior verification

Each regression test was proven to fail with the test in place but the src fix stashed (git stash push -- src), then pass after popping:

Behavior Test Fails without fix
Over-capture → 422, both amounts named, no transition/settlement/card-cancel, intent stays CHECKOUT_RUNNING wiring.test.ts › "rejects actualAmount above the reserved amount with 422 and no side effects" ✅ (was 200/DONE)
Over-capture → OVER_CAPTURE_REJECTED AuditEvent with actor/agentId + both amounts wiring.test.ts › "records an audit event for the rejected over-capture report" ✅
success: true without actualAmount → 400, nothing settled wiring.test.ts › "rejects success: true with no actualAmount as 400 and settles nothing" ✅ (was 200, settled 0, refunded pot)
Schema rejects success-without-amount validators.test.ts › "rejects success without actualAmount" ✅
settleIntent guard throws OverCaptureError, zero writes potService.test.ts › "throws OverCaptureError when actualAmount exceeds reservedAmount, with no writes" ✅

Boundary + invariant pins (pass on main too, added to lock behavior in): settling exactly reservedAmount still succeeds (route + potService tests), and a parameterized ledger-invariant test in potService.test.ts asserts reserved − settled − returned surplus = 0 across settlements of 0, 1, partial, reserved−1, and full reservation — implemented at unit level with the existing $transaction mocking pattern (it checks the amounts settleIntent writes, not real DB rows). A DB-backed version of the same invariant would belong in tests/integration/, which needs Docker and runs in CI only.

How to test

npm test -- --testPathPatterns='wiring|validators|potService'

Manually (dev stack up, intent in CHECKOUT_RUNNING with a pot reserved at e.g. 10000):

# over-capture → 422 + audit event, intent stays CHECKOUT_RUNNING
curl -s -X POST localhost:3000/v1/agent/result \
  -H 'Content-Type: application/json' -H 'X-Worker-Key: local-dev-worker-key' \
  -d '{"intentId":"<id>","success":true,"actualAmount":15000}'

# success without amount → 400
curl -s -X POST localhost:3000/v1/agent/result \
  -H 'Content-Type: application/json' -H 'X-Worker-Key: local-dev-worker-key' \
  -d '{"intentId":"<id>","success":true}'

# corrected report still works afterwards → 200 DONE
curl -s -X POST localhost:3000/v1/agent/result \
  -H 'Content-Type: application/json' -H 'X-Worker-Key: local-dev-worker-key' \
  -d '{"intentId":"<id>","success":true,"actualAmount":9000}'

Audit trail: GET /v1/debug/intents/<id> shows the OVER_CAPTURE_REJECTED event.

Notes for reviewer

🤖 Generated with Claude Code

https://claude.ai/code/session_01QTR7UwtzY7wvD4c9YjT38e

…ssful reports

Harden the POST /v1/agent/result -> settleIntent settlement contract:

- Over-capture policy (reject): a success report with actualAmount above
  the pot's reservedAmount is rejected with 422, an OVER_CAPTURE_REJECTED
  AuditEvent is recorded, and the intent stays CHECKOUT_RUNNING with no
  settlement and no card cancellation, so a corrected report can follow.
  The virtual card's network-level spending limit means the card cannot
  be charged above the reservation, so such a report is by definition
  wrong (buggy or malicious worker).
- agentResultSchema: actualAmount is now conditionally required when
  success is true (zod superRefine), closing the settle-0-and-refund
  hole for direct REST callers.
- Defense in depth: settleIntent itself throws OverCaptureError (new,
  src/contracts/ledger.ts) before any write when actualAmount exceeds
  reservedAmount, so no future caller can bypass the route check.

Regression tests for each behavior (each proven to fail without its
fix), plus a ledger-invariant test: reserved - settled - returned = 0
after any accepted settlement.

Closes #221

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTR7UwtzY7wvD4c9YjT38e
@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 5 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: 3aede473-af2e-4ade-8b51-a5de62c78d37

📥 Commits

Reviewing files that changed from the base of the PR and between d0bfe0b and f552a47.

📒 Files selected for processing (11)
  • docs/api.md
  • docs/openclaw.md
  • openclaw.md
  • src/api/routes/agent.ts
  • src/api/validators/agent.ts
  • src/contracts/ledger.ts
  • src/ledger/potService.ts
  • tests/integration/e2e/errorPaths.test.ts
  • tests/unit/api/validators.test.ts
  • tests/unit/api/wiring.test.ts
  • tests/unit/ledger/potService.test.ts

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.

@JonasBaeumer

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9f0da0b2f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/api/validators/agent.ts
Comment thread src/api/routes/agent.ts
// by definition wrong (buggy or malicious worker). Reject it, audit it, and
// leave the intent in CHECKOUT_RUNNING so a corrected report can follow.
const pot = await prisma.pot.findUnique({ where: { intentId } });
if (pot && reportedAmount > pot.reservedAmount) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Centralize the over-capture rule

The same actualAmount > reservedAmount business rule is now implemented independently here and in settleIntent (src/ledger/potService.ts:73). If the boundary or policy is later changed in only one place, the route can accept a report, transition the intent to DONE via completeCheckout, and then have settleIntent reject it, leaving intent and ledger state inconsistent; use one shared predicate/assertion so both enforcement points cannot drift.

AGENTS.md reference: AGENTS.md:L5-L8

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The duplication is deliberate defense in depth, and the drift scenario cannot produce inconsistent state today or silently in the future:

  1. No failing input exists now. Both checks are the identical comparison actualAmount > pot.reservedAmount against the same Pot row, and the route returns 422 before completeCheckout runs — so there is no input today where the route accepts and settleIntent rejects after a DONE transition.
  2. Drift is pinned by tests at both layers, which is stronger than a shared predicate. This PR adds boundary tests on each enforcement point independently: the wiring test asserts 422 above the reservation and 200 at exactly-equal at the route, and the potService test asserts OverCaptureError at reserved+1 and success at exactly-equal inside settleIntent. If either implementation's boundary is later changed unilaterally, that layer's own test fails — a shared a > b predicate would not prevent the more likely drift mode (a call site being removed or reordered), while the tests catch both.
  3. Even in the hypothetical where the route check were bypassed entirely, the settleIntent guard throws before any pot/user/ledger write (asserted by test), so the ledger can never book an over-capture — the inconsistency is limited to an intent status, not funds.

Extracting a one-line comparison would add indirection without adding a guarantee the tests don't already provide, so leaving both explicit checks in place. Leaving this thread unresolved for the maintainer's judgement.

…re response

Codex review finding on PR #223: docs/api.md and docs/openclaw.md still marked
actualAmount as optional and omitted the new 422 over-capture rejection from the
error tables for POST /v1/agent/result. Both endpoint contracts now state the
conditional requirement and the 422 behavior (intent stays CHECKOUT_RUNNING).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTR7UwtzY7wvD4c9YjT38e
@JonasBaeumer

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e10928cdd7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/api/validators/agent.ts
…ment

Codex review finding on PR #223: the 409 invalid-state test posted
{ success: true } without actualAmount, which the refined agentResultSchema now
rejects with 400 before the intent lookup. Adding actualAmount restores the
test's purpose (reaching the CHECKOUT_RUNNING state guard) and also fixes a
cascading failure: the unconsumed mockResolvedValueOnce intent leaked into the
'404 for unknown intent' test, turning its 404 into a 403.

Verified: errorPaths suite standalone was 14/16 before this change and is
16/16 after (16/16 on the base commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTR7UwtzY7wvD4c9YjT38e
@JonasBaeumer

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e94da9790

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/openclaw.md
Comment thread src/api/routes/agent.ts
await completeCheckout(intentId, actualAmount ?? 0, request.agentId);
await settleIntent(intentId, actualAmount ?? 0);
// agentResultSchema guarantees actualAmount is present when success is true
const reportedAmount = actualAmount ?? 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Encode the success amount instead of defaulting it

For every request reaching this branch, agentResultSchema.superRefine has already established that actualAmount is present, so ?? 0 is unreachable defensive scaffolding and preserves the exact zero-settlement behavior this change is intended to eliminate if validation and routing ever drift. Model the schema as a discriminated union so success: true narrows actualAmount to number, then pass it directly rather than retaining a silent financial default.

AGENTS.md reference: AGENTS.md:L5-L8

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

No constructible input reaches the ?? 0 with actualAmount undefined: agentResultSchema.superRefine rejects success: true without actualAmount with a 400 before the handler body runs, so the branch under discussion only ever sees a present number. The zero-settlement path this PR eliminates is pinned by a dedicated wiring test ("rejects success: true with no actualAmount as 400 and settles nothing", tests/unit/api/wiring.test.ts), which asserts both the 400 and that settleIntent is never called — any validation/routing drift that re-opened the settle-0 path would fail that test before the fallback could matter.

The discriminated-union rewrite would change no observable behavior (same accepted and rejected inputs, same 400s), so there is no regression test that could fail without it — it is a type-narrowing refactor, not a defect fix, and it would also replace the current targeted error message ("actualAmount is required when success is true") with Zod's generic missing-field error. The ?? 0 exists solely because superRefine does not narrow the static type; the comment directly above it documents the schema guarantee. Leaving as is; thread stays open for the maintainer.

Codex review finding on PR #223: the top-level openclaw.md (a divergent copy of
docs/openclaw.md) still marked actualAmount optional and omitted the 422
over-capture response. Applied the same contract updates as e10928c. Whether to
replace this duplicate with a pointer to docs/openclaw.md is left as a
maintainer decision outside this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTR7UwtzY7wvD4c9YjT38e
@JonasBaeumer

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: f552a47a68

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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.

Harden the /v1/agent/result settlement contract: over-capture policy and success-without-amount

1 participant