**Current requirements**

- `plan(rows, budget_cents, excluded_channels=())` returns exactly four keys: `selected_ids`, `spent_cents`, `remaining_cents`, `skipped_ids`.
- The budget must be a nonnegative integer. Negative, non-integer and boolean budgets raise `ValueError`.
- Only the first row for each id counts, and deduplication happens first.
- Archived rows are excluded, and a missing `archived` means false.
- Excluded channels are matched after `strip().casefold()`. A missing channel is an empty string.
- Costs come from `price` (a dollar string converted to cents with `ROUND_HALF_UP`, per row) or from legacy `cost_cents`. Invalid costs on eligible rows raise `ValueError` before selection.
- Eligible rows are considered by descending priority (missing means 0), with ties keeping input order.
- A row is selected if its cost is at most the remaining budget. Otherwise it goes to `skipped_ids`.
- Inputs are never mutated, and money never uses floats.

**Superseded:** the original input-order traversal rule was explicitly replaced by priority ordering. The "no effect" placeholder for `excluded_channels` was likewise replaced by the real feature.

**Behaviors**

- **Archived first duplicate:** the id is still recorded as seen. The archived row is excluded, and every later row with that id is ignored. None of them appears in `skipped_ids`.
- **Budget equals cost:** the row is selected, because the check is `cost <= remaining`. Remaining budget drops to zero.
- **Both `price` and `cost_cents`:** `price` wins. `cost_cents` is ignored and not validated.