Repository navigation
feat: authorization locks (supersedes #132) - #226
Conversation
Re-implement authorization locks (PR #132) on current main. A lock reserves part of one wallet asset account (the swig wallet address for native SOL, or a token account it owns) until an expiry slot. While active, no role, including All, can move that account below the reserved amount. - Store locks as a typed tail entry (TailKind::AuthorizationLocks) so every existing tail-preserving realloc path carries them. SavedTail is now heap-backed to fit the larger tail. - AddAuthorizationLockV1 (24) / RemoveAuthorizationLockV1 (25) and the ManageAuthorizationLocks permission (27). Delegated locks are bounded by the role's own spend limits for the asset and a max duration. - Enforce locks in SignV2 on both the unrestricted and limited paths, and check token account identity (authority, delegate, close authority) for locked accounts, including frozen ones. - Only the creating role can release an active lock (capture = release and spend in one transaction). Block removing, rewriting, or taking over the creator role while it has active locks, except revoking its ManageAuthorizationLocks permission. Block CloseSwig and closing locked token accounts. - Reject Token-2022 mints with a permanent delegate. - Interface builders, rust-sdk support, and tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A role's active authorization locks now count against its own spending limits for as long as they are active, not only at creation. After SignV2 (and CloseTokenAccountV1) charge a limited role, its remaining limits for each mint must still cover its active locks on that mint, so it cannot spend the allowance that backs its reservations. Capacity now reflects what SignV2 would allow at the current slot: one-shot limits use their remaining amount and recurring limits use what is left in the current window, or the full allowance once the window would reset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Destination limits let a role authorize spends to specific accounts, and authorization locks reserve spend capacity against the role's limits. Three related bugs: 1. A role could be granted the same destination-limit permission twice for the same destination. SignV2 only ever charges the first match, so the duplicate is inert, but it stays listed in the role and inflates how much spend capacity an authorization lock can reserve. Creating a duplicate is now rejected with DuplicateDestinationLimit (1014). 2. When a role holds both a one-shot limit and a recurring limit for the same destination (or for the same general asset), SignV2 charged only the recurring ledger, so the one-shot total never decreased. Both ledgers now charge in the same transaction: the debit must fit the recurring window and the one-shot budget, and both count down. 3. Lock reservation capacity summed a pair's one-shot and recurring amounts, and ignored the recurring half of a general pair. Capacity is now the honest smaller bound: for each destination, the pair's min(one-shot, recurring), capped by the same rule for general limits. Nothing changes for roles that hold only one limit of each kind, and already-created wallets are unaffected by the dedup check (it runs on authority creation and updates only).
…nations SignV2 end-to-end tests for a role stacking a general SOL limit with destination pairs on several destinations: the recurring window binds first, the destination one-shot drains across windows, the general pool tracks every successful send, and unchanged ledgers prove rejections are atomic. Lock reservation capacity honors the same minimum rule when limit pairs stack with a general limit.
fix: dedupe destination limits and charge paired spend limits together
Covers a role with a global SOL limit and two destinations that each hold a lifetime and a window destination limit: independent per-destination ledgers, the global limit binding across destinations, atomic rejection, accumulation within one window, uncovered destinations, and SignV2 compute for the matrix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Carry the destination key and recurrence inside LimitKind::Destination instead of alongside it, so the missing-key case cannot be expressed and the on-chain panic path disappears. Also drops the second permission() lookup per destination action. Behavior is unchanged: per destination key, the first one-shot and first recurring limit count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Duplicate keyed spend limits are now rejected only when they are added: ReplaceAll checks the full new action list and AddActions checks the appended actions against the role's existing ones. Remove operations no longer re-validate the remaining actions, so roles created before this rule keep any duplicates they hold and stay updatable. The same rule now covers general token limits: a role may hold at most one TokenLimit and one TokenRecurringLimit per mint (DuplicateTokenLimit, 1015). General SOL and stake limits were already non-repeatable. Adds backward_compat_test, a differential test that builds wallets with a previous program build and compares every outcome and final account state against an upgraded program. It runs when SWIG_COMPAT_OLD_SO points at the previous build. Against main, the only changes are rejected new duplicates and paired limits now charging both twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ration - Every AddAuthorizationLockV1 now needs ManageAuthorizationLocks, and its max_duration_slots bounds every lock, including for All and AllButManageAuthority. Those roles still lock up to the wallet balance; other roles stay bounded by their own spend limits. - A role may not set or change its own max_duration_slots via UpdateAuthorityV1 (new AuthorizationLockDurationSelfUpdate error). Dropping the action is allowed. The root is exempt since no other role can manage it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict resolutions: - Instructions: main's CreateReservedV1 keeps 24; AddAuthorizationLockV1 and RemoveAuthorizationLockV1 move to 25 and 26. IDL regenerated. - SwigStateError: main's InvalidSeedPrefix keeps 1012; the lock and duplicate-limit errors shift to 1013-1016. - Tail: AuthorizationLocks keeps kind 3 (reserved by #239) alongside ConfigSeedPrefix at 4. MAX_TAIL_LEN covers all four kinds; SavedTail stays heap-backed since a full lock entry is ~2.8 KB. - close_token_account_v1 uses main's config_signer plus the lock guard. - The max-size tail test now includes a full 32-lock entry across all 24 orderings. - SignV2 token-path CU baselines +2 (6289, 7220) for the extra tail kind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ProgramExec authenticates against the preceding instruction's first two accounts and expects them to be the config and the wallet address at indexes 0 and 1. Both lock instructions had the payer at index 1, so a ProgramExec role could never authorize a lock. Reorder the (unreleased) lock layouts so the wallet address comes second: - AddAuthorizationLockV1: swig, swig_wallet_address, payer, system_program, asset_account, [mint], authority accounts - RemoveAuthorizationLockV1: swig, swig_wallet_address, payer, system_program, [rent claimer], authority accounts RemoveAuthorizationLockV1 now verifies that the wallet address is the swig's canonical wallet PDA (InvalidSeedSwigAccount), as add already did, so the ProgramExec binding is meaningful. Interface: reorder the builders, add swig_wallet_address to every remove builder, and add new_with_program_exec / new_with_program_exec_ix_index for both instructions. rust-sdk passes the wallet address through to remove. The IDL is updated by hand to match. Tests cover ProgramExec and ProgramExecSession roles adding and removing locks, a wrong wallet address on remove, and ProgramExec proofs whose accounts don't match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
edo-chan
left a comment
There was a problem hiding this comment.
Heap allocation concern in the lock-capacity check:
edo-chan
left a comment
There was a problem hiding this comment.
Suggestion to aggregate active authorization locks once per SignV2 and reuse the totals.
edo-chan
left a comment
There was a problem hiding this comment.
Concern about long-lived locks preventing revocation of a compromised developer role.
| if authorization_locks::role_has_active_locks( | ||
| parts.tail, | ||
| remove_authority_v1.args.authority_to_remove_id, | ||
| Clock::get()?.slot, | ||
| )? { | ||
| return Err(SwigError::AuthorizationLockActive.into()); |
There was a problem hiding this comment.
Can a long-lived lock make a rogue developer role effectively non-revocable for the duration of the lock?
For example:
- Role 0 is the user/root.
- Role 1 is a developer already granted spending permissions and
ManageAuthorizationLockswith a longmax_duration_slots. - The developer goes rogue and creates a tiny lock, even one base unit, with expiry at its granted maximum.
Role 0 then cannot remove role 1 because this guard returns AuthorizationLockActive. The corresponding guards in UpdateAuthorityV1 and ReplaceAuthorityV1 also prevent reducing its spending permissions or replacing its signer. Removing only ManageAuthorizationLocks is allowed, but that only stops new locks: the existing lock and the developer's broader spending permissions remain until the lock is released or expires.
This does not freeze the entire wallet; the concern is that a tiny reservation prevents the user from revoking a compromised developer's much larger spending authority, which remains usable within its limits while the role can authenticate.
Is that the intended trust contract? Can we preserve the existing payment reservation while allowing the user to revoke the developer's unrelated spending authority, rather than protecting the entire role for the lock's lifetime?
There was a problem hiding this comment.
What if removing a role also atomically removes all authorization locks created by that role?
That would let the user fully revoke a rogue developer without waiting for its locks to expire, while leaving other roles’ locks untouched. The tradeoff is that existing payment holds would become cancellable through role removal, so we should make that behavior—and who is authorized to trigger it—explicit.
There was a problem hiding this comment.
manage authority can do this but i think auth locks should hold they need to be a strong guarantee
not something the user who likley has manage authority can remove this can never be used for credit with soft guarantees
A lock now behaves like a card authorization hold instead of binding every role, the creator included: - Other roles, All/AllButManageAuthority included, may spend only the account balance minus the active locks they do not hold. The SignV2 guard floor for an account is min(before, sum of active locks whose role_id is not the signer's). - The holder may spend its own reserved funds. After execution SignV2 captures the signer's locks on each guarded account by the account's net outflow (before - after, never negative), oldest lock first, never below zero, and writes the new amounts back to the config tail in place. - Limits are charged on spend, not at lock time. The remaining-capacity check now runs after charging and capturing, so a limited role cannot keep locks its limits can no longer cover. - A lock captured to zero stays stored with amount 0: it is inactive (is_active requires a non-zero amount) and prunable like an expired lock. SignV2 has no payer or rent-claimer account to refund freed rent to, so it never resizes the config; RemoveAuthorizationLockV1 still reclaims the space and routes rent as before. Identity checks on locked token accounts, lock creation, release, pruning, and instruction layouts are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SignV2 lock handling, same semantics: - One pass over the raw lock records via a zero-copy LockRecord view, with fixed-size arrays (at most 32 locks) instead of per-lock Vecs; the guard is heap-allocated once, and only when a lock matters to the transaction. - No guard and no further work when the tail has no lock entry, or no active lock is on a writable account and the signer holds none. - Captures write only the lowered amount bytes, verified by lock id, through the config account; the config is never resized and no lamports move. - Wallet balances are compared in raw lamports; the rent sysvar is read at most once, and only when another role's lock must bound an actual outflow (it was read twice per locked SOL account). - The capacity re-check reads the captured amounts back from the records in one pass without allocating. Tests: exact available-amount boundaries (+1 fails with AuthorizationLockViolated) for All and limited non-holders on SOL, SPL Token and Token-2022; binding-limit roles; partial captures; expired and captured-to-zero locks; Bob 50 / Carol 30 / others; several spends in one or sequential transactions; capture to zero leaves the config length, lamports and record in place; SignV2 accounts are identical with or without locks; a per-scenario compute table with budgets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
edo-chan
left a comment
There was a problem hiding this comment.
Reviewed acce97a. CI passes, and the local SBF/LiteSVM CU sweep from 0 through 32 locks passed for both spending unreserved funds and capturing the signer's own locks, using a single six-decimal token account with three repetitions per case.
The existing role-revocation design discussion remains open: #226 (comment)
ManageAuthority (or All, or the role itself) can already remove a role once none of its authorization locks is active: expired or captured to zero. RemoveAuthorityV1 now also drops that role's inactive lock records in the same instruction, shrinking the tail and refunding their rent with the role's to the payer or the configured rent claimer. Other roles' locks and the lock-id counter are preserved. Removal stays blocked while the role has any active lock. Role ids come from a monotonic counter and lock ids are never reused, so nothing could have inherited the records; pruning keeps the tail free of orphaned records and returns their rent. Tests cover ManageAuthority-only, All, self, and AllButManageAuthority actors; expiry and capture to zero; size, lamports, and rent-claimer reconciliation; a new role inheriting nothing; Update/ReplaceAuthority on an inactive holder; and a holder with one expired and one active lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AddAuthorizationLockV1, RemoveAuthorizationLockV1, and RemoveAuthorityV1's pruning rebuilt the whole tail: they validated it two to four times, decoded every record into a Vec, re-serialised all of them, and copied the result back, about 265 CU per stored lock. They now validate once, read the raw records in a single pass, and edit in place: add appends one record and moves only the bytes after the lock entry, remove closes the gap, and role removal compacts the role's records in its saved tail copy. RemoveAuthorizationLockV1 also passes its already-verified wallet address to the rent-claimer refund check instead of re-deriving it with find_program_address (about 2k CU). CloseSwigV1 and RemoveAuthorityV1 read the Clock once, CloseTokenAccountV1 splits the config once and scans raw records, UpdateAuthorityV1 reuses its tail validation, and the empty-tail lock checks return early. Semantics are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lock_instruction_cu_table measures AddAuthorizationLockV1 (SOL, SPL, Token-2022; All and limited; 0 to 31 existing locks), RemoveAuthorizationLockV1 (creator release and admin prune, with and without a rent claimer, 1 and 32 locks), Remove/Update/ReplaceAuthority on an inactive-lock holder, and CloseTokenAccount/CloseSwig with locks, and budgets the per-lock cost so a whole-tail rebuild cannot creep back. admin_cu_comparison measures the same admin instructions without locks using only builders main also has, so it can be run there unchanged. Test wallets use a fixed swig id so PDA bump searches, and the measured costs, are deterministic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the security-review sync (#245) and crate publishing (#243, #244, #246). Conflict resolutions keep both sides: - Lock instructions now authenticate through counter::authenticate_role, so they get the shared secp counter (duplicate-key replay) fix. - Close/replace/update/remove authority keep main's authenticate_role and use its returned slot for the lock checks. - Lock rent math uses the SIMD-0194 minimum_balance helper. - SignV2 keeps main's wallet-PDA binding and isolation; the lock guard still snapshots before CPIs and settles after isolation validates. - CU comparison baselines take main's values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's ceilings are exact measurements of its own SignV2 (1-3 CU of headroom). This branch adds the lock-guard check on every SignV2 and charges both members of a one-shot/recurring limit pair. Measured with the pinned verifiable build (CI run 41861486794): All +5, limited roles +18 to +31 CU. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
edo-chan
left a comment
There was a problem hiding this comment.
Approved the final merged head after independent source review and green CI. Per-CPI destination enforcement and paired limit charging are both retained; lock settlement follows accounting. Standard 946, ProgramScope 264, SDK 411 (3 existing skips), and stake 955 passed. The four restricted CU ceilings equal the approved pinned-build measurements. Existing documented lock guarantees and nonblocking follow-ups remain unchanged.
Current merge validation (2026-11-05): base
d6e99e8dd85462774464da21c63901a8873fa97f, head9c5a3916790e77efd8c4b77ecc801820ac46d529. Includes #247's per-CPI destination enforcement while retaining paired limit charging and authorization-lock settlement. Independent source review passed on the final head; local program/state units: 194 passed, formatting and Clippy passed.Final-head CI passed: standard 946, ProgramScope 264, SDK 411 (3 existing skips), stake 955. Lint, protocol/interface verification, release CLI and Test Results are green. Restricted CU ceilings match the approved pinned-build measurements: SOL 6371, SOL destination 6775, token 7475, token destination 8006; All retains 5080 (measured 5077). No additional margin or disabled assertions.
Validation scope: existing CI owns runtime suites. This does not claim a new real-Secp256r1 add/remove-lock test or differential old-binary compatibility with
SWIG_COMPAT_OLD_SO; neither was separately exercised. Existing CloseTokenAccountV1 pair-selection behavior remains a separate pre-existing follow-up.Supersedes #132. That branch was 283 commits behind main and conflicted with nearly every file it touched, so this re-implements authorization locks on top of main's typed tail framework.
What it does
A lock reserves
amountof one wallet asset account untilexpiry_slot. The asset is the swig wallet address for native SOL (all-zero mint) or one token account the wallet address owns. Locks work like card authorization holds:All,AllButManageAuthorityand the owner, can spend only the account's balance minus the active locks it does not hold.before - after, never negative) is taken off Bob's locks on that account, oldest (lowestlock_id) first, never below 0. The new amounts are written back to the config tail. Any outflow beyond Bob's locks came from free balance, and the floor in rule 1 has already protected other roles' locks. Inflows never raise a lock.All/AllButManageAuthority) but uses up no limit. When Bob spends, his normal limits are charged. After charging and capturing, his remaining active locks must still fit his remaining capacity.Deposits are never blocked. A card flow is: place a hold, then spend against it. The spend captures the hold, and releasing it voids whatever is left.
Example with mixed holders: the balance is 100, Bob holds 50 and Carol holds 30. Bob may end at ≥ 30, Carol at ≥ 50, and every other role at ≥ 80.
Protocol changes
AddAuthorizationLockV1(25),RemoveAuthorizationLockV1(26)ManageAuthorizationLocks { max_duration_slots }(27)TailKind::AuthorizationLocks = 3, of 88-byte records, max 32 locks. Lock ids are never reused. Every existing tail-preserving realloc path carries the entry.SavedTailmoved to the heap so the larger tail fits.LockGuardruns on both SignV2 paths, the unrestrictedAll/AllButManageAuthoritypath and the limited one. After execution, each writable locked account must satisfybalance >= min(balance_before, reserved by locks the signer does not hold). For every signer, the holder included, token authority, delegate and close authority must stay unchanged, and a frozen account stays guarded.amount == 0. It is inactive (is_activerequires a non-zero amount) and can be pruned like an expired lock: the creator can remove it at any time, andAll/AllButManageAuthority/ManageAuthority/ManageAuthorizationLocksroles can remove it too. The reason is that SignV2 has no payer or rent-claimer account, so it has nowhere to send freed rent that respects the rent claimer.RemoveAuthorizationLockV1reclaims the space with the usual rent routing. Leaving the excess lamports in the config would instead blockCloseSwigV1(WalletNotEmpty). Tail validation now accepts zero amounts, and creation still rejects them.Account layout and ProgramExec support
ProgramExec authenticates against the preceding instruction's first two accounts and requires them to be the config (index 0) and the wallet address (index 1). Both lock instructions now put the wallet address second, so ProgramExec roles can add and remove locks. ProgramExecSession roles authorize locks with their session key once the program has opened a session.
Authority accounts follow: the Ed25519 signer, the instructions sysvar for secp256r1 and ProgramExec, nothing extra for secp256k1.
RemoveAuthorizationLockV1now also checks that the wallet address is the swig's canonical wallet PDA (InvalidSeedSwigAccount), just as add does. The interface addsnew_with_program_execandnew_with_program_exec_ix_indexfor both instructions, and every remove builder now takesswig_wallet_address.Creation rules
ManageAuthorizationLocks, including forAllandAllButManageAuthority. No lock may outlast the role'smax_duration_slots.AllorAllButManageAuthoritycan lock up to the account's lockable balance. Any other role can lock any token up to its own spend capacity for that asset (the same limits SignV2 would charge).max_duration_slotsthroughUpdateAuthorityV1(AuthorizationLockDurationSelfUpdate, 90). It may drop the action. The root is exempt because no other role can manage it.PermanentDelegateare rejected.Limits are charged on spend
AuthorizationLockExceedsSpendLimit, so a role can't hold a reservation it could never spend.Protecting the creator
ManageAuthority-only role, anAllrole, or the role itself can remove it, rewrite it or replace its signer. RemoveAuthorityV1 also prunes the removed role's inactive lock records in the same instruction. It shrinks the tail and refunds their rent along with the role's, to the payer or to the configured rent claimer. Other roles' locks and the lock-id counter are preserved. Role ids come from a monotonic counter and lock ids are never reused, so no new role can inherit a record. One active lock still blocks all three operations.AllButManageAuthoritystill cannot remove roles at all.ManageAuthorizationLocksaction. Without this, a delegate could stay irrevocable by renewing locks forever. Its existing locks still run to expiry and can still be captured.Differences from #132
Allroles return early there, so they could ignore locks; this PR enforces on both paths.balance_account.SetAuthority,Approveor wSOL close.Paired limits (merged from #228)
destination_limit_matrix_test.rscovers a global limit plus two destinations, each with a lifetime and a per-window limit. It focuses on several transfers inside one SignV2: atomic rejection, accumulation, and uncovered destinations. It also reports compute. With a global limit and two pairs, one SOL transfer costs 6,292 CU in SignV2, against 5,155 for the global limit alone.Merged with main's security sync (#245)
c0b621bmerges main throughe17b7ca, which includes the deployed security-review backports (#245) and crate publishing (#243, #244, #246). All of main's changes are kept. The six conflicts (CloseSwig, CloseTokenAccount, RemoveAuthority, SignV2,util/rent.rsand the CU comparison test) keep both sides:counter::authenticate_role, which keeps same-key Secp256k1/Secp256r1 roles on one counter. AddAuthorizationLockV1 and RemoveAuthorizationLockV1 now use it too, and the lock checks in Close/Remove/Update/ReplaceAuthority use the slot it returns. New test:secp256k1_lock_signatures_cannot_be_replayed_through_a_same_key_role. The lock instructions already signed the acting role id, so a swapped role id was rejected before; it now fails on the shared counter first, the same way main's other instructions do.minimum_balancehelper.AllButManageAuthorityclose permission are kept; a locked token account still can't be closed by anyone.CU ceilings re-baselined. Main's SignV2 ceilings are exact measurements of its own SignV2, with 1-3 CU of headroom. This branch adds the lock-guard check to every SignV2 and charges both members of a one-shot/recurring limit pair, so the ceilings moved to this branch's numbers from the pinned verifiable build (CI run 41861486794), the same way main records them:
Restructured variants (single-pass pair lookup, forced inlining, an out-of-band guard slot, an earlier snapshot) moved these by only a few CU either way on the verifiable build, so none were kept.
Post-merge results (main at
e17b7caalongside, same machine):Cost
Transaction CU (compute-budget instruction included), measured by
lock_instruction_cu_table,sign_v2_lock_cu_tableandadmin_cu_comparisonwith a fixed swig id so PDA bump searches are deterministic. "Before" is30bcfc0. "After" is4628591, where the lock-tail edits happen in place, with a single validation, a single pass over the raw records and noVec.Against
origin/main(af05048) with no locks:These numbers predate the merge with #245; see the section above for SignV2 against current main.
Verdict. The worst path is a limited signer capturing 32 locks in one SignV2, at about 13.9k CU. That is about 7% of the 200k per-instruction default and 1% of the 1.4M per-transaction maximum, against about 5.4k for a lock-free limited transfer. Every lock-management and admin path stays under 10k at the maximum lock count. The 32-lock maximum is kept: lowering it to 16 would save only about 1.6–3k CU in that worst case and nothing for ordinary wallets.
SignV2's limited path keeps hashing the whole config account, lock records included. Leaving the records out would save about 2.7k CU at 32 locks. But it would add 12–30 CU to every limited SignV2, including lock-free wallets, and it would weaken a defence-in-depth integrity check.
Tests
program/tests/authorization_lock_test.rs. Non-holders spend exactly the available amount, and one unit more fails withAuthorizationLockViolated. This is covered for All and limited roles with no lock permissions, on SOL, SPL Token and Token-2022, and also covers roles whose own limit binds first, partial captures lowering others' floor, expired and captured-to-zero locks, the Bob 50 / Carol 30 / others 20 boundaries, and several spends in one or sequential transactions. Removal coverage:ManageAuthority-only,All, self andAllButManageAuthorityactors, after expiry and after capture to 0. It checks size, lamports and rent-claimer reconciliation, that a new role inherits nothing, Update/ReplaceAuthority on an inactive holder, and that a holder with one expired and one active lock is still blocked. Card-authorization coverage:Allroles can't spend into a holder's lock;create_account, burn, wSOL close, SetAuthority/Approve, freeze then thaw-and-drain, Token-2022 permanent delegate, and a renewing delegate).4628591:cargo fmt --checkis clean.Not in this PR
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.