fix(cutover): harden migration + rollback from ENG-461 rehearsal findings (ENG-401/483/484)#433
Conversation
The rollback reverse-payment paid back destinationAmountUsdtMicros (the forward target), but the USDT wallet legitimately holds slightly less: the un-reimbursed forward routing fee plus the balance read rounding up (e.g. 0.443691959514 read as 443692 micros). Paying the target verbatim overspends the wallet by a few hundred micros and IBEX rejects it with 400 Bad Request — leaving the account pointer-reverted but funds stranded. Fix: reverse min(spendable, target) using a new floored balance read (readDestinationSpendableUsdtMicros), so the reverse amount never exceeds what's actually spendable. Fail closed only when short by more than the existing 1-cent tolerance — the signature of genuine post-cutover spend; within tolerance, reverse the full spendable balance (user made whole minus sub-cent dust). Validated end-to-end on TEST against real IBEX (ENG-401): pointer restored -> reverse payment succeeds -> rolled_back, funds returned to legacy USD. Refs ENG-401
…rottle An unthrottled run-batch (--limit 300, no delay) mass-failed 233/297 accounts in the ENG-461 TEST rehearsal with "FetchError: Too Many Requests". Two gaps: 1. Balance reads (getRawAccountDetails) were the one IBEX call path not wrapped in withIbexRateLimitRetry — and the wrapper only handled RETURNED errors, while the IBEX client surfaces 429s on some paths as thrown FetchError rejections. Now every account-details read (balance readers, balance verifier, fee service, rollback shortfall reader) goes through the retry, and the wrapper retries thrown rate-limit rejections as well as returned ones. 2. run-batch defaulted to --step-delay-ms 0 (unthrottled). Default is now 2000ms (~30 accounts/min, the empirically safe IBEX rate from the rehearsal). Pass --step-delay-ms 0 explicitly to disable. Refs ENG-483
…ommand Two operator gaps from the ENG-461 TEST rehearsal: 1. Dust fees: IBEX rejects payments below its minimum payable amount, so sub-cent fee reimbursements (88-515 micros observed) 400'd at the pay step and stranded 9 migrations in requires_operator_review after their money had already moved. Fees under one cent are now absorbed instead of reimbursed (invisible to users): the balance_move_verified handler skips straight to fee_reimbursed, recording the absorbed micros for audit (feeAmountUsdtMicros kept, no payment transaction id). 2. retry-failed: failures previously required manual mongo surgery to re-drive. New CLI command resets `failed` migrations to their last safe status. Safe by construction — the runner routes money-moving failures to requires_operator_review, so `failed` records have no ambiguous side effects. Resume is field-driven: pointer flipped → pointer_flipped (preserving previousDefaultWalletId for rollback correctness); otherwise → not_started with stale progress cleared. Defense in depth: anything with a recorded payment transaction is skipped. Supports --account-id and --dry-run. transitionMigration gains an optional `clear` ($unset) arg for the retry reset; state machine gains failed → not_started | pointer_flipped. Refs ENG-484
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getTreasuryWalletId returned the funder's DEFAULT wallet. On envs where that default is BTC, USDT fee reimbursements (and rollback top-ups) 404 at IBEX — which stranded every funded account in the ENG-461 rehearsal until the TEST funder's default was manually flipped to USDT. The cutover treasury resolver now finds the funder's USDT wallet regardless of which wallet is the default (mirroring the dealer resolver's find-by-currency pattern), so prod does not depend on the default-flip operational step. If the funder has no USDT wallet at all, it returns a descriptive error naming the missing prerequisite instead of a bare IBEX 404 at payment time. The global funderWalletResolver is unchanged — other funder flows keep their existing default-wallet semantics. Refs ENG-482
…migration The pointer-flip step saves the whole account document, so mongoose re-validates every field: accounts with pre-existing invalid data fail mid-migration even though the cutover never touched those fields. The ENG-461 rehearsal hit two live: an empty username (min length 3) failed an account at pointer flip, and a null statusHistory status blocked the funder default-wallet update. New `audit-accounts` CLI command sweeps every account document through validateSync() and reports the ones that would fail, with per-field messages. Runbook D1 prerequisite: run before `prepare`; every issue is an account to fix or lock before the run. Refs ENG-484
islandbitcoin
left a comment
There was a problem hiding this comment.
Deep review: 8 finder angles (line-by-line, removed-behavior, cross-file tracing, reuse, simplification, efficiency, altitude, conventions) + adversarial verification of every candidate against the branch. The core fixes verified correct — the spendable-floor rollback math, the thrown-429 retry wrapper, retry-failed's money-moved guards, and the $unset transition plumbing all check out. Inline comments below are the surviving findings.
Two follow-up notes that don't map to a diff line:
- 429 detection string-matches error messages because
IbexErrordiscards the structured.status(httpCodeis commented out insrc/services/ibex/errors.ts). FixinghttpCodepropagation in the IBEX service layer would make detection=== 429for all IBEX callers — the bridge error mapper already does exactly this for its 429s. src/servers/exporter.ts:164still gauges the funder default wallet. Since ENG-482's whole point is that the treasury is now a non-default USDT wallet, treasury drain during the cutover is invisible to thefunderbalance metric and its alerts.
| // ENG-484: IBEX rejects payments below its minimum payable amount, so | ||
| // sub-cent fee reimbursements (88–515 micros observed in the ENG-461 | ||
| // rehearsal) 400 at the pay step and strand the migration. Fees under one | ||
| // cent are absorbed instead of reimbursed — invisible to users. | ||
| export const isSubMinimumFeeReimbursementAmount = ( | ||
| feeAmountUsdtMicros: string, | ||
| ): boolean | InvalidCashWalletCutoverAmountError => | ||
| usesNoAmountFeeReimbursementInvoice(feeAmountUsdtMicros) |
There was a problem hiding this comment.
Fee absorption threshold swallows fees the old code successfully reimbursed (medium)
On main, only feeAmountUsdtMicros === "0" was skipped — fees of 1–9999 micros were reimbursed via the no-amount invoice path, which exists precisely because fixed invoices under 10,000 micros can't be created (the pre-existing spec proves it paid 4735 micros). This alias absorbs everything under 10,000, but the rehearsal only observed IBEX 400s at 88–515 micros: the 516–9999 band is absorbed with no evidence it ever failed (~19× the observed failure band).
It also welds a customer-funds policy ("which fees do users eat") to an invoice-plumbing constant — tuning CUTOVER_MIN_FIXED_USDT_INVOICE_MICROS for invoice-format reasons would silently widen absorption — and it makes the no-amount branch in createCashWalletMigrationFeeReimbursementInvoice dead code for new runs.
| // ENG-484: IBEX rejects payments below its minimum payable amount, so | |
| // sub-cent fee reimbursements (88–515 micros observed in the ENG-461 | |
| // rehearsal) 400 at the pay step and strand the migration. Fees under one | |
| // cent are absorbed instead of reimbursed — invisible to users. | |
| export const isSubMinimumFeeReimbursementAmount = ( | |
| feeAmountUsdtMicros: string, | |
| ): boolean | InvalidCashWalletCutoverAmountError => | |
| usesNoAmountFeeReimbursementInvoice(feeAmountUsdtMicros) | |
| // ENG-484: IBEX rejects payments below its minimum payable amount, so | |
| // sub-minimum fee reimbursements (88–515 micros observed in the ENG-461 | |
| // rehearsal) 400 at the pay step and strand the migration. Absorb only fees | |
| // below IBEX's minimum payable — NOT the fixed-invoice threshold above: the | |
| // no-amount invoice path exists precisely to pay 1–9999-micro amounts and | |
| // reimbursed 4735-micro fees successfully before ENG-484. | |
| const CUTOVER_MIN_PAYABLE_USDT_MICROS = 1_000n // TODO: confirm IBEX's real minimum | |
| export const isSubMinimumFeeReimbursementAmount = ( | |
| feeAmountUsdtMicros: string, | |
| ): boolean | InvalidCashWalletCutoverAmountError => { | |
| if (!/^\d+$/.test(feeAmountUsdtMicros)) { | |
| return new InvalidCashWalletCutoverAmountError( | |
| `Invalid non-negative integer amount: ${feeAmountUsdtMicros}`, | |
| ) | |
| } | |
| return BigInt(feeAmountUsdtMicros) < CUTOVER_MIN_PAYABLE_USDT_MICROS | |
| } |
(Set the constant to IBEX's actual minimum once confirmed, and update the worker.spec.ts expectations — "9999" should become payable again via the no-amount path.)
| if (feeAmountUsdtMicros === "0") { | ||
| const subMinimum = isSubMinimumFeeReimbursementAmount(feeAmountUsdtMicros) | ||
| if (subMinimum instanceof Error) return subMinimum | ||
| if (subMinimum) { |
There was a problem hiding this comment.
Absorbed fees will mass-flag false “shortfall” rows in the reconciliation audit (medium)
computeCutoverBalanceAudit (operator-dashboard.ts:504–552, untouched by this PR) compares the destination delta against destinationAmountUsdtMicros with zero tolerance and flags status: "shortfall" on any positive deficit. After the forward move the destination wallet holds exactly target − fee, and only the fee reimbursement (paid to the destination wallet) topped it back up.
With fees now absorbed, every completed migration with an absorbed fee (88–9999 micros — widespread in the rehearsal) renders as a red shortfall row during the post-run reconciliation hold — drowning real shortfalls exactly when the runbook says to look.
Suggest giving the audit the same sub-cent tolerance the rollback got, or subtracting recorded-but-unpaid feeAmountUsdtMicros (i.e. feeReimbursementPaymentTransactionId unset) from the expected minimum.
| const reverseAmount = spendable < target ? spendable : target | ||
| const shortfall = target - reverseAmount | ||
| if (shortfall > ROLLBACK_SHORTFALL_TOLERANCE_USDT_MICROS) { | ||
| return new CashWalletMigrationFailedError( | ||
| `USDT balance ${usdtBalance} no longer covers reverse amount ${current.destinationAmountUsdtMicros}`, | ||
| `USDT balance ${spendableStr} is short of reverse target ${target} by ${shortfall} micros (> tolerance) — likely spent post-cutover`, | ||
| ) | ||
| } | ||
| const reverseAmountStr = reverseAmount.toString() |
There was a problem hiding this comment.
Rollback can attempt a 0-micro or sub-minimum-payable reverse payment (low)
There's no lower bound on reverseAmount. A 1-cent account (target = 10000) with a drained wallet gives shortfall = 10000, which is not > the 10,000 tolerance — the gate passes and payInvoice is called with "0". Even the normal 1-cent rollback (only the 88–515-micro forward fee missing) produces a reverse amount of ~9,500–9,900 micros — below the very minimum this PR documents in ENG-484 — so IBEX 400s and the migration lands in requires_operator_review with a misleading payment error instead of the old clear fail-closed message.
| const reverseAmount = spendable < target ? spendable : target | |
| const shortfall = target - reverseAmount | |
| if (shortfall > ROLLBACK_SHORTFALL_TOLERANCE_USDT_MICROS) { | |
| return new CashWalletMigrationFailedError( | |
| `USDT balance ${usdtBalance} no longer covers reverse amount ${current.destinationAmountUsdtMicros}`, | |
| `USDT balance ${spendableStr} is short of reverse target ${target} by ${shortfall} micros (> tolerance) — likely spent post-cutover`, | |
| ) | |
| } | |
| const reverseAmountStr = reverseAmount.toString() | |
| const reverseAmount = spendable < target ? spendable : target | |
| const shortfall = target - reverseAmount | |
| if (shortfall > ROLLBACK_SHORTFALL_TOLERANCE_USDT_MICROS) { | |
| return new CashWalletMigrationFailedError( | |
| `USDT balance ${spendableStr} is short of reverse target ${target} by ${shortfall} micros (> tolerance) — likely spent post-cutover`, | |
| ) | |
| } | |
| // IBEX rejects payments below its minimum payable amount (ENG-484): a | |
| // drained or 1-cent wallet can pass the tolerance gate with a 0- or | |
| // sub-minimum reverse amount — fail closed with a clear message instead | |
| // of a doomed pay attempt and a misleading IBEX 400. | |
| if (reverseAmount < ROLLBACK_MIN_PAYABLE_USDT_MICROS) { | |
| return new CashWalletMigrationFailedError( | |
| `Reverse amount ${reverseAmount} micros is below the minimum payable — target ${target}, spendable ${spendableStr}; resolve manually`, | |
| ) | |
| } | |
| const reverseAmountStr = reverseAmount.toString() |
(Define ROLLBACK_MIN_PAYABLE_USDT_MICROS next to ROLLBACK_SHORTFALL_TOLERANCE_USDT_MICROS at the top of this file.)
|
|
||
| const reverseAmount = spendable < target ? spendable : target | ||
| const shortfall = target - reverseAmount | ||
| if (shortfall > ROLLBACK_SHORTFALL_TOLERANCE_USDT_MICROS) { |
There was a problem hiding this comment.
Boundary band where the treasury reimburses post-cutover user spending (low)
Step 3's legacy top-up uses the same 10,000-micro strict-greater threshold on sourceBalance − current. With user spend s, un-reimbursed forward fee f, and reverse-payment receive dust d: when f+s ≤ 10000 here but f+s+d > 10000 at step 3, the treasury pays the whole f+s+d — including the user's own spend (e.g. s=9600, f=300, d=150 → treasury pays 10,050 micros, 9,600 of it the user's own consumption). The old code failed closed on any shortfall, so this is a categorical change.
Exposure is bounded at ~1 cent + fee + dust per account, so it may be an acceptable tradeoff — but it deserves an explicit comment here, or subtract the step-2 shortfall (known spend) from the step-3 top-up.
| // Operator retry (ENG-484): `failed` only ever comes from non-ambiguous | ||
| // statuses (the runner routes ambiguous ones to requires_operator_review), | ||
| // so re-driving is safe. Two resume points: `not_started` re-walks the whole | ||
| // pre-money path; `pointer_flipped` resumes a post-pointer failure without | ||
| // touching previousDefaultWalletId. |
There was a problem hiding this comment.
This comment's justification is false for legacy_zero_verified (low — comment fix)
legacy_zero_verified is missing from the runner's AMBIGUOUS_SIDE_EFFECT_STATUSES, so a failure there — after the balance move AND pointer flip — routes to failed. Money-moved migrations do reach failed. The widening is still safe today because retry-failed's guards hold (verified: balanceMovePaymentTransactionId is persisted in the same transition that enters balance_move_sending, and pointer-flipped records previousDefaultWalletId → resume at pointer_flipped), but the comment shouldn't promise what the machine doesn't enforce:
| // Operator retry (ENG-484): `failed` only ever comes from non-ambiguous | |
| // statuses (the runner routes ambiguous ones to requires_operator_review), | |
| // so re-driving is safe. Two resume points: `not_started` re-walks the whole | |
| // pre-money path; `pointer_flipped` resumes a post-pointer failure without | |
| // touching previousDefaultWalletId. | |
| // Operator retry (ENG-484). NOTE: `failed` CAN hold money-moved migrations — | |
| // a failure during legacy_zero_verified (balance moved AND pointer flipped) | |
| // routes to `failed` because that status is not in the runner's | |
| // AMBIGUOUS_SIDE_EFFECT_STATUSES. These edges are only safe behind | |
| // retry-failed's guards: skip any migration with a recorded payment | |
| // transaction id, and resume at `pointer_flipped` (never `not_started`) | |
| // when previousDefaultWalletId is set. |
Deeper fix worth considering: add legacy_zero_verified to AMBIGUOUS_SIDE_EFFECT_STATUSES in runner.ts so post-pointer failures go to operator review like the other money-moved statuses.
| ] | ||
| : ["lastError"], | ||
| }) | ||
| if (transitioned instanceof Error) return transitioned |
There was a problem hiding this comment.
A mid-loop race aborts the run and discards the report of migrations already reset (low)
If a listed migration is concurrently moved out of failed (e.g. an operator rollback-request — failed is rollbackable), the guarded findOneAndUpdate matches nothing, transitionMigration returns CouldNotUpdateError, and this returns immediately — but earlier resets are already committed (each transition is an independent write). The operator's CLI shows only an error with no migrationIds/counts for what was already re-armed.
| if (transitioned instanceof Error) return transitioned | |
| if (transitioned instanceof Error) { | |
| // Lost a race (e.g. a concurrent rollback-request moved this migration | |
| // out of `failed`). Skip it and keep going — aborting would discard | |
| // the report of migrations already reset. | |
| report.eligible -= 1 | |
| report.migrationIds.pop() | |
| report.resumedAt[resumeStatus] = (report.resumedAt[resumeStatus] ?? 1) - 1 | |
| continue | |
| } |
(Nicer still: a conflicts counter on the report type so skipped races are visible.)
| getTreasuryWalletId: | ||
| deps.getTreasuryWalletId ?? | ||
| (async (): Promise<WalletId | ApplicationError> => { | ||
| const funderDefaultWalletId = await funderWalletId() | ||
| const funderWallet = await walletsRepo.findById(funderDefaultWalletId) | ||
| if (funderWallet instanceof Error) return funderWallet | ||
| if (funderWallet.currency === WalletCurrency.Usdt) return funderWallet.id | ||
|
|
||
| const funderWallets = await walletsRepo.listByAccountId(funderWallet.accountId) | ||
| if (funderWallets instanceof Error) return funderWallets | ||
| const usdtWallet = funderWallets.find( | ||
| (wallet) => wallet.currency === WalletCurrency.Usdt, | ||
| ) | ||
| if (usdtWallet === undefined) { | ||
| return new CashWalletMigrationFailedError( | ||
| "Funder account has no USDT wallet — create and fund the cutover treasury before running the cutover (ENG-482)", | ||
| ) | ||
| } | ||
| return usdtWallet.id | ||
| }), |
There was a problem hiding this comment.
Treasury resolution is uncached — per-migration mongo reads replacing a memoized value (low)
The old resolver was getFunderWalletId, which memoizes after one ledger lookup. This closure runs findById + (in the BTC-default case ENG-482 explicitly supports) listByAccountId on every call — and it's called per migration at the fee step and per rollback top-up: ~1,200 identical queries across a 600-account run, resolving an id that is invariant for the whole run. A transient mongo blip mid-run now fails a migration step that the memoized value couldn't.
| getTreasuryWalletId: | |
| deps.getTreasuryWalletId ?? | |
| (async (): Promise<WalletId | ApplicationError> => { | |
| const funderDefaultWalletId = await funderWalletId() | |
| const funderWallet = await walletsRepo.findById(funderDefaultWalletId) | |
| if (funderWallet instanceof Error) return funderWallet | |
| if (funderWallet.currency === WalletCurrency.Usdt) return funderWallet.id | |
| const funderWallets = await walletsRepo.listByAccountId(funderWallet.accountId) | |
| if (funderWallets instanceof Error) return funderWallets | |
| const usdtWallet = funderWallets.find( | |
| (wallet) => wallet.currency === WalletCurrency.Usdt, | |
| ) | |
| if (usdtWallet === undefined) { | |
| return new CashWalletMigrationFailedError( | |
| "Funder account has no USDT wallet — create and fund the cutover treasury before running the cutover (ENG-482)", | |
| ) | |
| } | |
| return usdtWallet.id | |
| }), | |
| getTreasuryWalletId: | |
| deps.getTreasuryWalletId ?? | |
| (() => { | |
| let cached: WalletId | undefined | |
| return async (): Promise<WalletId | ApplicationError> => { | |
| if (cached !== undefined) return cached | |
| const funderDefaultWalletId = await funderWalletId() | |
| const funderWallet = await walletsRepo.findById(funderDefaultWalletId) | |
| if (funderWallet instanceof Error) return funderWallet | |
| if (funderWallet.currency === WalletCurrency.Usdt) { | |
| cached = funderWallet.id | |
| return cached | |
| } | |
| const funderWallets = await walletsRepo.listByAccountId(funderWallet.accountId) | |
| if (funderWallets instanceof Error) return funderWallets | |
| const usdtWallet = funderWallets.find( | |
| (wallet) => wallet.currency === WalletCurrency.Usdt, | |
| ) | |
| if (usdtWallet === undefined) { | |
| return new CashWalletMigrationFailedError( | |
| "Funder account has no USDT wallet — create and fund the cutover treasury before running the cutover (ENG-482)", | |
| ) | |
| } | |
| cached = usdtWallet.id | |
| return cached | |
| } | |
| })(), |
Also note: .find() takes the first USDT wallet in unsorted listByAccountId order. Today addWalletIfNonexistent dedupes on (type, currency) so duplicates shouldn't exist, but there's no unique index enforcing it — a defensive error when more than one USDT wallet matches would prevent paying from an unfunded duplicate.
- Fee absorption decoupled from the fixed-invoice constant: new CUTOVER_MIN_PAYABLE_USDT_MICROS = 2500 (empirically measured on TEST: 600 micros -> IBEX 400, 650+ pays — IBEX's floor is the LN 1-sat minimum, which floats with BTC price; 2500 covers it to ~$250k/BTC and stays under a cent). The 2500-9999 band reimburses again via the no-amount invoice path, which is live code once more. - Rollback: fail closed with a clear message when the reverse amount is below the minimum payable (drained/1-cent wallets previously produced a doomed pay attempt and a misleading IBEX 400 in review). - Rollback: documented the accepted step-3 boundary tradeoff where the treasury can reimburse up to ~1 cent of post-cutover user spend. - State machine: corrected the retry-edge comment — failed CAN hold money-moved migrations (legacy_zero_verified failures); the edges are safe behind retry-failed's guards, now stated explicitly. - retry-failed: a lost race (migration concurrently moved out of failed) no longer aborts the run and discards the report — it's skipped and surfaced via a new conflicts counter. - Treasury resolver: memoized for the life of the run (was per-migration mongo reads of an invariant id) and fails closed on duplicate funder USDT wallets instead of paying from an arbitrary one. - Reconciliation audit: absorbed (recorded-but-unpaid) fees no longer render as false shortfall rows — the allowance is exactly the recorded fee, and only when no reimbursement transaction exists. 12 suites / 68 tests green (5 new).
|
All review findings addressed in
Follow-up notes filed as ENG-485 (IbexError 12 suites / 68 tests green. |
Summary
Hardening from the ENG-461 TEST cutover (real-money rehearsal → actual cutover, 297/297 complete). Every fix was hit live and root-caused on TEST; the rollback fix is validated against real IBEX there.
1. Rollback reverse payment overspend → IBEX 400 (ENG-401)
The reverse step paid back
destinationAmountUsdtMicros(the forward target), but the USDT wallet legitimately holds slightly less: the un-reimbursed forward routing fee plus the balance read rounding up (e.g. real 0.443691959514 → read 443692). Paying the target verbatim overspends → IBEX 400 → account stranded pointer-reverted with funds in USDT.Fix: new floored
readDestinationSpendableUsdtMicros; reversemin(spendable, target); fail closed only on a shortfall beyond the 1-cent tolerance (signature of genuine post-cutover spend). Validated on TEST: $0.52 account →rolled_back, funds returned to legacy USD, pointer restored.2. IBEX 429s mass-fail balance reads (ENG-483)
Unthrottled
run-batchmass-failed 233/297 accounts withFetchError: Too Many Requests— balance reads were the one IBEX path not wrapped inwithIbexRateLimitRetry, and the wrapper only handled returned errors while some client paths throw FetchError. Now every account-details read retries, thrown 429s retry too, andrun-batchdefaults to--step-delay-ms 2000(~30/min, the empirically safe rate;0still available explicitly).3. Dust fees + retry-failed operator command (ENG-484)
fee_reimbursed, absorbed micros recorded for audit).retry-failedCLI command replaces manual mongo surgery: resetsfailedmigrations to their last safe status, field-driven (pointer_flippedwhen the pointer moved, elsenot_startedwith stale progress cleared). Never touches records with a payment transaction. Supports--account-id/--dry-run.4. Treasury resolves to the funder's USDT wallet (ENG-482)
getTreasuryWalletIdreturned the funder's default wallet; where that default is BTC, USDT fee reimbursements 404 at IBEX — which stranded every funded account in the rehearsal until the TEST funder's default was manually flipped. The cutover treasury resolver now finds the funder's USDT wallet regardless of default (mirroring the dealer resolver's find-by-currency pattern), with a descriptive error when the funder has no USDT wallet. The globalfunderWalletResolveris unchanged.5.
audit-accountsdata-quality preflight (ENG-484)Pointer flip saves the whole account document, so mongoose re-validates every field — accounts with pre-existing invalid data (empty username, null statusHistory) fail mid-migration. Hit twice live on TEST; the same artifact was also found on the dealer + bankowner system accounts. New
audit-accountscommand sweeps all account documents throughvalidateSync()and reports what would fail — a runbook D1 prerequisite beforeprepare.Test plan
test/flash/unit/app/cash-wallet-cutover/), 14 newrolled_back, funds returned)🤖 Generated with Claude Code