**Current requirements**

- `plan(rows, budget_cents, excluded_channels=())` returns exactly four keys: `selected_ids`, `spent_cents`, `remaining_cents`, `skipped_ids`.
- `budget_cents` must be a nonnegative integer. Negatives, non-integers and booleans raise `ValueError`.
- Only the first occurrence of each id counts, and deduplication happens before filtering.
- Archived rows are excluded, and a missing `archived` means false.
- Channel exclusions compare `strip().casefold()` values. A missing channel is an empty string, and an empty exclusion list excludes nothing.
- A row's cost comes from `price` (a dollar string, converted to cents with Decimal and ROUND_HALF_UP per row) or from legacy `cost_cents`. Invalid costs raise `ValueError`, and only eligible rows are validated, before budgeting.
- Eligible rows are traversed by descending integer priority (missing priority means zero), with ties keeping input order.
- A row is selected if its cost is at most the remaining budget. Otherwise it is skipped and later rows are still considered.
- Money uses no floats, and inputs are never mutated.

**Superseded requirement:** the original input-order traversal was replaced by priority-order traversal.

**Behaviors**

- **Archived first duplicate:** the id is marked seen, the archived row is excluded, and every later row with that id is ignored. The id appears in neither `selected_ids` nor `skipped_ids`.
- **Budget equals cost:** the row is selected, because the rule is "at most". Remaining budget becomes zero for that cost.
- **Both `price` and `cost_cents`:** `price` wins and `cost_cents` is ignored. If `price` is invalid, `ValueError` is raised even when `cost_cents` is valid.