HANDOFF NOTE: project "scheduler" (only this project; archive-* records are unrelated and discarded)

1. Interface and algorithm contract (fixed)
- Implement evaluate(payload) -> {selected:[id], blocked:[id], rejected:[id]}.
- payload.now is an offset-aware ISO datetime. payload.appointments is a list of rows with id, start (offset-aware ISO), duration (positive minutes), priority (integer) and cancelled (boolean).
- Do not mutate the input. Use zoneinfo or an equivalent for timezone conversion.

Processing steps:
1. Deduplicate by id. The id keeps the position of its first insertion. The retained value is the first or last occurrence according to duplicate_policy. The retained row's original index is the actual original index of that occurrence.
2. Process in stable order by priority. Descending if priority_high_first is true, otherwise ascending. Ties are broken by original index.
3. All start/end arithmetic uses UTC elapsed minutes, not local wall-clock minutes.
4. Validity checks (any failure puts the id in rejected):
   - duration > 0 and duration <= duration_max.
   - start >= now + lead_minutes.
   - Convert start to the configured timezone. Reject if its local date equals blackout_day.
   - Cutoff is on the start's local calendar day at cutoff_hour:00 in that zone. The end must be <= cutoff if cutoff_inclusive, otherwise < cutoff.
5. Cancelled appointments:
   - If cancelled_blocks is false, a cancelled appointment is rejected.
   - If true, it can reserve capacity and is listed in blocked instead of selected.
6. Capacity: an appointment is accepted only if fewer than capacity already-accepted intervals overlap it. This counts intersecting accepted intervals, not peak concurrency.
   - If adjacency_allowed, intervals sharing only an endpoint do not overlap. If false, they do overlap.
7. Output: sort each list by id or by retained original index according to report_order. All invalid or conflicting ids go to rejected.

2. CURRENT settings (after Updates 1-5)
- cutoff_hour = 18
- cutoff_inclusive = false
- lead_minutes = 60
- timezone = "UTC"
- adjacency_allowed = false (Update 4 overrode the earlier true)
- cancelled_blocks = false (Update 5 overrode the earlier true)
- duplicate_policy = "last" (Update 5 overrode the earlier "first")
- duration_max = 90 (Update 4 overrode the earlier 120)
- capacity = 2 (Update 3)
- report_order = "input", meaning retained original index (Update 3)
- blackout_day = "2026-11-01" (Update 5)
- priority_high_first = true (Update 5)

3. Consequences and notes
- With cancelled_blocks false, cancelled rows always go to rejected, so blocked is always empty under the current settings. The blocked logic must still be implemented for configurability.
- With timezone UTC, local day equals UTC day. The code must still use zoneinfo so other zones work.
- With adjacency_allowed false, touching intervals count as overlapping, so with capacity 2 a third touching interval is rejected.
- Cancelled rows are rejected before any capacity reservation when cancelled_blocks is false. Rows rejected for other reasons do not reserve capacity.

4. Unresolved or unspecified items (no decisions made)
- Order of validity checks: this does not change output membership, since every failure goes to rejected. A cancelled row that also fails validity is ambiguous: whether it goes to rejected or blocked when cancelled_blocks is true is not stated. The reasonable assumption is that validity failures take precedence, so it is rejected. Confirm this.
- Whether an invalid duplicate row (the discarded one) matters: discarded duplicates are simply dropped, not reported.
- Malformed input handling (naive datetimes, non-integer priority, bad types) is unspecified.
- The blackout_day format is a local ISO date string, compared to the start's local date.
- Further updates may come. Each replaces only the keys it names.