Repository navigation
feat(contract): add a deposit-only emergency pause (ADR 0005) - #216
Conversation
There was no on-chain way to slow down a live bug: the only defence was telling integrators to stop. This adds a narrow lever instead — a single custodian set by the contract's constructor can block `create_escrow` and nothing else, so new exposure stops while every existing escrow can still be released, refunded, or timeout-refunded. Blocking outbound paths would have turned a possible loss into a certain lock, which is why the pause deliberately does not read the flag there (and the 16 new tests prove it). Records the decision in ADR 0005, adds the role to the threat model (T11, invariant I6), and regenerates/bumps the SDK to 0.4.0 for the new pause/unpause/is_paused/get_pause_admin surface. Closes DocForum#35 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
|
@Azintar This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-escrow.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared files like Automated message. It won't repeat unless the PR conflicts again after your next push. |
Adds a constructor-set custodian that can pause every deposit path (create_escrow and its variants, plus top_up) and nothing else, so new exposure stops while existing escrows can still be released, refunded or timeout-refunded. Pause/unpause are custodian-only, non-idempotent, and emit Paused/Unpaused events; is_paused/get_pause_admin are read-only. Rebased onto current main: the constructor is merged with the existing token allowlist (__constructor(allowed_tokens, pause_admin)), top_up is gated as the ADR requires of any deposit path, error codes are 26-28 to avoid colliding with existing codes, and the ADR is renumbered to 0011 so it does not clash with the protocol-fee ADR.
Keeps the branch fast-forwardable: this commit's tree is exactly the rebased (ADR 0011) tree and its second parent is that rebase, so main is an ancestor of the branch and the PR diff only contains this change.
|
@Azintar Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
@Azintar This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-escrow.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared docs like Automated message. It won't repeat unless the PR conflicts again after your next push. |
|
@Azintar This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-escrow.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared docs like Automated message. It won't repeat unless the PR conflicts again after your next push. |
|
@Azintar This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-escrow.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared docs like Automated message. It won't repeat unless the PR conflicts again after your next push. |
|
@Azintar This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-escrow.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared docs like Automated message. It won't repeat unless the PR conflicts again after your next push. |
|
@Azintar CI is failing on this branch against the latest TypeScript SDK (Bun), TypeScript SDK (Node 20), TypeScript SDK (Node 24) Also failing: React hooks package, TypeScript SDK (Node 22), Soroban contract (test + wasm build), TypeScript SDK (Deno), TypeScript SDK (Chromium), SDK bindings match contract WASM.
cd contracts/escrow && cargo build --target wasm32v1-none --release
stellar contract bindings typescript --wasm target/wasm32v1-none/release/docforum_escrow.wasm --output-dir /tmp/escrow-bindings --overwrite
cp /tmp/escrow-bindings/src/index.ts ../../sdk/src/generated/contract-client.tsThis PR also conflicts with |
The last merge of main (DocForum#257 pooled escrows, DocForum#193/DocForum#248 SDK work) left the branch broken: - contract: `DataKey::Pooled`/`Contribution` had been spliced into the `Unpaused` event struct; restore them to `DataKey`. - contract: `Paused`/`AlreadyPaused`/`NotPaused` collided with `NotPooled`/`FundingDeadlinePassed`/`FundingTargetReached` (26-28). Main's codes keep their on-chain values; the pause codes are appended as 35/36/37 (docs, ADR 0011, README, threat model, ROADMAP, changelog updated). - contract: gate the new pooled deposit paths (`create_pooled_escrow`, `contribute`) on the pause; `claim_refund` and `release` stay ungated. Three new tests in tests/pause.rs cover this. - tests: main's new tests (cancel, pooled, model, malicious_token) registered the contract with the old one-argument constructor; pass a pause admin. Snapshots regenerated with `cargo test`. - sdk: regenerate src/generated/contract-client.ts with the pinned stellar CLI 28.1.0 (it contained a broken hand merge); close the unterminated `getPauseAdmin` method in src/index.ts; drop the duplicate `test:unit` script key in package.json. - ci: the Go SDK local-network deploy passes `--pause_admin`.
Malware scan: passed, with notes for the reviewerNothing malicious was detected in
|
|
@Azintar This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-escrow.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared docs like Automated message. It won't repeat unless the PR conflicts again after your next push. |
…tion-emergency-pause-that-can-only # Conflicts: # contracts/escrow/test_snapshots/acceptance_requires_the_payee_and_only_works_while_funded.1.json # contracts/escrow/test_snapshots/callback_cannot_reenter_create_escrow.1.json # contracts/escrow/test_snapshots/callback_cannot_reenter_refund.1.json # contracts/escrow/test_snapshots/callback_cannot_reenter_release.1.json # contracts/escrow/test_snapshots/callback_error_reverts_release_but_does_not_block_refund.1.json # contracts/escrow/test_snapshots/cancel_after_release_is_rejected.1.json # contracts/escrow/test_snapshots/cancel_after_releaser_refund_is_rejected.1.json # contracts/escrow/test_snapshots/cancel_after_timeout_refund_is_rejected.1.json # contracts/escrow/test_snapshots/cancel_is_not_available_without_requires_acceptance.1.json # contracts/escrow/test_snapshots/cancel_only_returns_that_escrows_amount.1.json # contracts/escrow/test_snapshots/cancel_pays_the_sponsored_refund_to_not_the_funder.1.json # contracts/escrow/test_snapshots/cancel_requires_the_payers_signature.1.json # contracts/escrow/test_snapshots/cancel_stays_rejected_after_accept_even_much_later.1.json # contracts/escrow/test_snapshots/cancel_still_works_after_refund_after_has_passed_if_still_funded.1.json # contracts/escrow/test_snapshots/claim_refund_emits_an_event.1.json # contracts/escrow/test_snapshots/claiming_requires_the_contributors_signature.1.json # contracts/escrow/test_snapshots/configured_allowlist_accepts_listed_token_and_rejects_unlisted_token.1.json # contracts/escrow/test_snapshots/contribute_emits_an_event.1.json # contracts/escrow/test_snapshots/contribute_rejects_lying_transfer.1.json # contracts/escrow/test_snapshots/contribute_rejects_short_delivery.1.json # contracts/escrow/test_snapshots/contributor_cannot_claim_another_contributors_funds.1.json # contracts/escrow/test_snapshots/create_bounds_the_releaser_set_size.1.json # contracts/escrow/test_snapshots/create_escrow_accepts_deadline_strictly_in_the_future.1.json # contracts/escrow/test_snapshots/create_escrow_assigns_sequential_ids.1.json # contracts/escrow/test_snapshots/create_escrow_locks_funds_and_reports_funded.1.json # contracts/escrow/test_snapshots/create_escrow_rejects_condition_ref_that_is_too_long.1.json # contracts/escrow/test_snapshots/double_cancel_is_rejected_and_pays_out_once.1.json # contracts/escrow/test_snapshots/double_claim_is_rejected.1.json # contracts/escrow/test_snapshots/double_refund_pays_refund_to_once.1.json # contracts/escrow/test_snapshots/duplicate_approvals_from_one_releaser_cannot_satisfy_the_threshold.1.json # contracts/escrow/test_snapshots/each_listed_approver_must_actually_authorize_the_invocation.1.json # contracts/escrow/test_snapshots/empty_allowlist_preserves_open_token_behavior.1.json # contracts/escrow/test_snapshots/escrow_without_requires_acceptance_still_releases_and_refunds_as_before.1.json # contracts/escrow/test_snapshots/explicit_refund_to_equal_to_payer_behaves_like_the_default.1.json # contracts/escrow/test_snapshots/failed_claim_leaves_the_contribution_intact.1.json # contracts/escrow/test_snapshots/failed_payout_leaves_the_escrow_funded.1.json # contracts/escrow/test_snapshots/failed_round_refunds_every_contributor_exactly.1.json # contracts/escrow/test_snapshots/funder_calling_refund_after_the_deadline_cannot_steer_the_money_to_itself.1.json # contracts/escrow/test_snapshots/get_escrow_exposes_the_stored_parties_and_deadline.1.json # contracts/escrow/test_snapshots/get_escrow_reports_no_deadline_when_none_was_set.1.json # contracts/escrow/test_snapshots/invalid_contributions_are_rejected.1.json # contracts/escrow/test_snapshots/legacy_creation_keeps_acceptance_opt_out.1.json # contracts/escrow/test_snapshots/multiple_contributors_recorded_independently.1.json # contracts/escrow/test_snapshots/neither_funder_nor_refund_to_may_trigger_a_refund_before_the_deadline.1.json # contracts/escrow/test_snapshots/no_contributor_cap_each_claim_is_independent.1.json # contracts/escrow/test_snapshots/non_member_approver_is_rejected.1.json # contracts/escrow/test_snapshots/omitted_refund_to_defaults_to_payer.1.json # contracts/escrow/test_snapshots/omitted_refund_to_timeout_refund_still_goes_to_payer.1.json # contracts/escrow/test_snapshots/only_the_stored_payer_may_cancel.1.json # contracts/escrow/test_snapshots/opted_in_contract_payee_receives_release_callback.1.json # contracts/escrow/test_snapshots/ordinary_escrows_are_unaffected.1.json # contracts/escrow/test_snapshots/partial_funding_records_and_blocks_release.1.json # contracts/escrow/test_snapshots/payer_cancel_before_accept_refunds_payer_in_full.1.json # contracts/escrow/test_snapshots/pooled_creation_respects_the_token_allowlist.1.json # contracts/escrow/test_snapshots/pooled_creation_validation.1.json # contracts/escrow/test_snapshots/push_paths_and_top_up_reject_pooled_escrows.1.json # contracts/escrow/test_snapshots/quorum_escrow_stores_the_releaser_set_and_threshold.1.json # contracts/escrow/test_snapshots/quorum_release_invokes_opted_in_contract_payee.1.json # contracts/escrow/test_snapshots/race_accept_lands_first_then_cancel_fails_and_payee_keeps_the_escrow.1.json # contracts/escrow/test_snapshots/race_cancel_lands_first_then_accept_fails_and_payer_keeps_the_funds.1.json # contracts/escrow/test_snapshots/reaching_the_target_releases_the_pooled_total.1.json # contracts/escrow/test_snapshots/reentrant_transfer_cannot_double_release.1.json # contracts/escrow/test_snapshots/refund_entitlements_are_order_independent.1.json # contracts/escrow/test_snapshots/refund_entitlements_are_order_independent.2.json # contracts/escrow/test_snapshots/refund_entitlements_are_order_independent.3.json # contracts/escrow/test_snapshots/refund_rejects_after_release.1.json # contracts/escrow/test_snapshots/refund_rejects_unauthorized_caller.1.json # contracts/escrow/test_snapshots/refund_to_is_fixed_at_creation_and_visible_via_get_escrow.1.json # contracts/escrow/test_snapshots/refund_transfers_funds_back_to_payer_and_marks_refunded.1.json # contracts/escrow/test_snapshots/refund_with_approvals_rejects_duplicates_and_short_thresholds.1.json # contracts/escrow/test_snapshots/refund_with_approvals_returns_funds_to_the_payer.1.json # contracts/escrow/test_snapshots/release_and_refund_can_never_share_funds.1.json # contracts/escrow/test_snapshots/release_is_rejected_before_time_and_allowed_at_the_exact_boundary.1.json # contracts/escrow/test_snapshots/release_lock_does_not_restrict_releaser_refunds.1.json # contracts/escrow/test_snapshots/release_rejects_after_refund.1.json # contracts/escrow/test_snapshots/release_rejects_double_release.1.json # contracts/escrow/test_snapshots/release_rejects_unauthorized_caller.1.json # contracts/escrow/test_snapshots/release_still_pays_the_payee_not_refund_to.1.json # contracts/escrow/test_snapshots/release_time_equal_to_creation_time_is_valid_and_releasable_immediately.1.json # contracts/escrow/test_snapshots/release_time_equal_to_refund_deadline_is_valid.1.json # contracts/escrow/test_snapshots/release_transfers_funds_to_payee_and_marks_released.1.json # contracts/escrow/test_snapshots/release_wins_at_the_deadline_and_timeout_refund_then_fails.1.json # contracts/escrow/test_snapshots/release_with_approvals_is_gated_by_full_funding_too.1.json # contracts/escrow/test_snapshots/release_with_exactly_threshold_distinct_approvals_pays_the_payee.1.json # contracts/escrow/test_snapshots/release_with_fewer_than_threshold_approvals_is_rejected.1.json # contracts/escrow/test_snapshots/release_with_more_than_threshold_approvals_succeeds.1.json # contracts/escrow/test_snapshots/releaser_can_still_release_after_the_deadline.1.json # contracts/escrow/test_snapshots/releaser_cannot_pay_out_after_cancel.1.json # contracts/escrow/test_snapshots/releaser_refund_goes_to_refund_to_not_the_funder.1.json # contracts/escrow/test_snapshots/releaser_refund_still_works_before_deadline.1.json # contracts/escrow/test_snapshots/releasers_can_still_release_after_the_deadline.1.json # contracts/escrow/test_snapshots/repeated_contributions_accumulate_per_payer.1.json # contracts/escrow/test_snapshots/required_acceptance_blocks_release_until_payee_accepts.1.json # contracts/escrow/test_snapshots/rotation_rejects_contract_and_payee_as_new_releaser.1.json # contracts/escrow/test_snapshots/rotation_rejects_wrong_releaser_and_non_funded_escrows.1.json # contracts/escrow/test_snapshots/rotation_requires_both_stored_parties_and_updates_the_releaser.1.json # contracts/escrow/test_snapshots/settled_approvals_cannot_be_replayed.1.json # contracts/escrow/test_snapshots/settled_escrow_cannot_be_refunded_to_refund_to_afterwards.1.json # contracts/escrow/test_snapshots/single_caller_release_on_a_quorum_escrow_needs_the_approval_path.1.json # contracts/escrow/test_snapshots/single_releaser_escrow_behaves_as_a_one_of_one_quorum.1.json # contracts/escrow/test_snapshots/the_beneficiary_refund_to_never_has_to_sign.1.json # contracts/escrow/test_snapshots/timeout_refund_at_the_exact_deadline_goes_to_refund_to.1.json # contracts/escrow/test_snapshots/timeout_refund_by_a_stranger_goes_to_refund_to.1.json # contracts/escrow/test_snapshots/timeout_refund_by_stranger_succeeds_after_deadline.1.json # contracts/escrow/test_snapshots/timeout_refund_by_stranger_succeeds_at_the_exact_deadline.1.json # contracts/escrow/test_snapshots/timeout_refund_cannot_pay_anyone_but_the_payer.1.json # contracts/escrow/test_snapshots/timeout_refund_fails_after_release.1.json # contracts/escrow/test_snapshots/timeout_refund_fails_after_releaser_refund.1.json # contracts/escrow/test_snapshots/timeout_refund_rejected_before_deadline.1.json # contracts/escrow/test_snapshots/timeout_refund_still_needs_no_approvals_on_a_quorum_escrow.1.json # contracts/escrow/test_snapshots/timeout_refund_wins_at_the_deadline_and_release_then_fails.1.json # contracts/escrow/test_snapshots/top_up_adds_funds_to_the_same_funded_escrow.1.json # contracts/escrow/test_snapshots/top_up_after_cancel_is_rejected.1.json # contracts/escrow/test_snapshots/top_up_before_accept_is_fully_returned_by_cancel.1.json # contracts/escrow/test_snapshots/top_up_emits_an_event.1.json # contracts/escrow/test_snapshots/top_up_rejects_invalid_payer_amount_overflow_and_settled_state.1.json # contracts/escrow/test_snapshots/two_escrows_keep_their_own_refund_to.1.json # contracts/escrow/test_snapshots/without_deadline_releaser_refund_still_works.1.json # contracts/escrow/test_snapshots/without_deadline_stranger_refund_stays_unauthorized_even_forever_after.1.json # docs/testnet-deployments.md
|
@Azintar This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-escrow.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared docs like Automated message. It won't repeat unless the PR conflicts again after your next push. |
…-can-only Resolve docs/threat-model.md (keep main's multisig-SDK note on the releaser item and this PR's new pause-custodian item) and regenerate sdk/src/generated/contract-client.ts from the merged contract with stellar CLI 28.1.0 (scripts/check-generated-bindings.sh passes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@Azintar This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-escrow.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared docs like Automated message. It won't repeat unless the PR conflicts again after your next push. |
|
@Azintar I resolved this PR's earlier conflicts with What's needed:
Please |
…bump to 2.3.0 Follow-up to the upstream main merge (DocForum#254). Per ADR 0011 the pause stops new deposits only and never blocks outbound paths, so settle_many's release and refund ops ignore the pause flag exactly like single release/refund (invariant I8). Documents this in lib.rs, README and the threat model, adds pause tests showing settle_many release, refund and a mixed batch all succeed while paused (with their snapshots), bumps CONTRACT_VERSION to 2.3.0 (main is 2.2.0) and applies cargo fmt.
|
@Azintar This branch was updated with Please |
|
@Azintar Heads-up: #268 just changed how contract PRs work in this repo. Your PR changes
This should also make your next merge from |
|
@Azintar This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-escrow.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared docs like Automated message. It won't repeat unless the PR conflicts again after your next push. |
The last merge of main (DocForum#169/DocForum#199) left the branch uncompilable and its tests failing: - `Error` lost `Paused`, `AlreadyPaused` and `NotPaused` while `lib.rs` still returns them (E0599). Re-added with the provisional codes 3500-3502 that contract-changes.d/README.md asks for, since main already spent 35 (`InvalidFee`) and 36 (`Undeliverable`); the maintainer fold assigns the final numbers. README, ADR 0011, ROADMAP, the threat model and the changelog fragment no longer claim 36-38. - `use soroban_sdk::{...}` was spliced into itself, so every imported name was defined twice (E0252). - main's newer `tests/transfer_payee.rs` and `tests/undeliverable.rs` still registered the contract with the old one-argument constructor, so all 14 of their tests panicked ("invalid number of input arguments: 2 expected, got 1"). They now pass a pause admin and their snapshots are regenerated. - Added the required `contract-changes.d/35-emergency-pause.md` fragment (`bump: minor`); `CONTRACT_VERSION` stays at main's 2.3.0, as the new CI rule requires. `cargo test` passes for every suite in contracts/escrow (pause 23, create_escrow 7, release_refund 8, timeout_refund 17, top_up 3, cancel 18, payee_acceptance 3, pooled 19, settle_many 13, liabilities 14, protocol_fee 17, quorum 19, transfer_payee 12, undeliverable 2, token_allowlist 2, token_edge_cases 1, malicious_token 7, sponsored_refund 16, releaser_rotation 3, release_callback 6, time_locked_release 5, model 4). Generated with Codebuff 🤖 Co-Authored-By: Codebuff <noreply@codebuff.com>
|
@Azintar Thanks for repairing the pause merge. The contract checks now pass. What's left is Don't fix it by hand. Regenerate the file from your merged contract with stellar CLI 28.1.0: Then push. Everything else on this PR is green. |
|
@afeezorobsco-cyber |
|
@Azintar The latest merge from
|
…ts.rs The merge of main (DocForum#238, lifecycle events) left two jobs failing: - `sdk/src/generated/contract-client.ts` was hand-mangled: the `/**` that opens the `transfer_payee` doc block was lost (TS1131), and the file predated the provisional pause error codes. It is now exactly what the pinned stellar CLI (28.1.0, scripts/stellar-cli-version) generates from the current contract WASM, so "SDK bindings match contract WASM" passes too. Never hand-edited. - main's `contracts/escrow/tests/events.rs` still registered the contract with the one-argument constructor, so its 13 tests panicked with `Error(WasmVm, InvalidAction)` ("2 expected, got 1"). All five `env.register` sites now pass a pause admin, and their snapshots are regenerated for the `PauseAdmin`/`Paused` instance entries. `cargo test` passes for every suite in contracts/escrow (events 13, pause 23, create_escrow 7, release_refund 8, timeout_refund 17, top_up 3, cancel 18, payee_acceptance 3, pooled 19, settle_many 13, liabilities 14, protocol_fee 17, quorum 19, transfer_payee 12, undeliverable 2, token_allowlist 2, token_edge_cases 1, malicious_token 7, sponsored_refund 16, releaser_rotation 3, release_callback 6, time_locked_release 5, model 4). Generated with Codebuff 🤖 Co-Authored-By: Codebuff <noreply@codebuff.com>
Brings in DocForum#238 (lifecycle events) and DocForum#270, the contract-change fold that bumped CONTRACT_VERSION to 2.4.0 and renumbered main's provisional `InvalidNewPayee` from 4500 to 37. The only conflict was sdk/src/generated/contract-client.ts, resolved as the repo requires: regenerated from the merged contract WASM with the pinned stellar CLI 28.1.0 (scripts/stellar-cli-version), never hand-merged. The contract-changes.d and changelog.d fragments that DocForum#270 folded are dropped; this PR's own 35-emergency-pause fragments stay, and the pause error codes stay provisional (3500-3502) for the next fold. Generated with Codebuff 🤖 Co-Authored-By: Codebuff <noreply@codebuff.com>
|
@afeezorobsco-cyber please review |
|
@Azintar Thanks, that fixed it: the contract tests, the Please don't reorder it by hand. Regenerate the file with the pinned CLI ( |
The bindings committed with the merge were generated from a WASM built with the sandbox's stable Rust (1.99), but the repo pins 1.96 in rust-toolchain.toml and CI builds with it. The compiler changes the order of the ContractSpec entries in the WASM, so the committed file had the PayeeRefunded event spec in a different slot than CI's regeneration and "SDK bindings match contract WASM" failed. Rebuilt the WASM with the pinned 1.96.0 toolchain and regenerated with stellar CLI 28.1.0 as scripts/check-generated-bindings.sh does, so the diff is only that one entry moving; the script now reports OK. Generated with Codebuff 🤖 Co-Authored-By: Codebuff <noreply@codebuff.com>
|
@afeezorobsco-cyber pr is now mergeable |
afeezorobsco-cyber
left a comment
There was a problem hiding this comment.
Malware scan clean, all checks green, bindings regenerated. Thanks!
There was no on-chain way to slow down a live bug: the only defence was telling integrators to stop. This adds a narrow lever instead — a single custodian set by the contract's constructor can block
create_escrowand nothing else, so new exposure stops while every existing escrow can still be released, refunded, or timeout-refunded. Blocking outbound paths would have turned a possible loss into a certain lock, which is why the pause deliberately does not read the flag there (and the 16 new tests prove it).Records the decision in ADR 0005, adds the role to the threat model (T11, invariant I6), and regenerates/bumps the SDK to 0.4.0 for the new pause/unpause/is_paused/get_pause_admin surface.
Closes #35
🤖 Generated with Codebuff
What changed
Which ROADMAP.md item does this close/progress?
(required — see AGENTS.md doc-update rules)
Docs updated in this PR (check all that apply)