---
phase: 04-shared-lists-live-sync
plan: '07'
subsystem: api
tags: [mariadb, drizzle, fractional-indexing, collation, security, authorization]

# Dependency graph
requires:
  - phase: 04-03
    provides: list CRUD routes + listShares schema
  - phase: 04-05
    provides: fractional-rank reorder PATCH route for list items
provides:
  - 'list_items.rank column with COLLATE utf8mb4_bin (migration 0002)'
  - 'owner-only guard on PATCH /api/lists/:id isShared mutations'
  - 'rank-collation regression test (LIST-03)'
  - 'T-04-08 negative test: sharee sending { isShared } receives 403'
affects: [04-verification, 04-security]

# Tech tracking
tech-stack:
  added: []
  patterns:
    - 'Drizzle customType for MySQL column-level COLLATE (no first-class option in drizzle 0.45.x)'
    - 'TDD RED commit (test:) before GREEN commit (feat:/fix:) per phase-04 convention'

key-files:
  created:
    - apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql
  modified:
    - apps/api/src/db/schema.ts
    - apps/api/src/routes/lists.ts
    - apps/api/tests/routes/lists.test.ts

key-decisions:
  - 'D-04-07-collation: Drizzle 0.45.x has no first-class collation option on varchar; used customType to emit varchar(255) COLLATE utf8mb4_bin — keeps schema-as-code and generate+migrate workflow intact'
  - 'D-04-07-guard-placement: isShared owner guard placed immediately after the access check, before any updateValues construction, so the body is never parsed for non-owners'

patterns-established:
  - 'customType pattern for MySQL column collation: define a named factory (varcharBin) in schema.ts that emits the full SQL type string including COLLATE'
  - 'Owner-only guard idiom: if (patch.sensitiveField !== undefined && !access.isOwner) return 403 — mirrors the existing DELETE owner check'

requirements-completed: [LIST-03]

# Metrics
duration: 6min
completed: '2026-06-09'
---

# Phase 04 Plan 07: Gap-Closure (LIST-03 Rank Collation + T-04-08 Owner Guard) Summary

**Closed LIST-03 drag-to-top bug via utf8mb4_bin migration on list_items.rank, and closed T-04-08/T-04-05 elevation-of-privilege by adding an owner-only guard before the isShared reconciliation block.**

## Performance

- **Duration:** ~6 min
- **Started:** 2026-06-09T18:18:10Z
- **Completed:** 2026-06-09T18:23:42Z
- **Tasks:** 2 (each TDD: RED commit + GREEN commit)
- **Files modified:** 4 (schema.ts, migration SQL, lists.ts, lists.test.ts)

## Accomplishments

- `list_items.rank` now carries `COLLATE utf8mb4_bin` — uppercase fractional-indexing ranks (`Zz`) sort before lowercase ranks (`a0`) in DB `ORDER BY`, matching JS string order. Drag-to-top persists across refetch.
- Migration `0002_yielding_mattie_franklin.sql` is a single non-destructive `ALTER TABLE list_items MODIFY COLUMN rank varchar(255) COLLATE utf8mb4_bin NOT NULL` — no DROP, no TRUNCATE, no length or nullability change. Applied via `db:migrate` (never `db:push`).
- `PATCH /api/lists/:id` now returns `403` when a non-owner sharee sends `{ isShared }`, and `list_shares` is never mutated by a sharee. Threats T-04-08 and T-04-05 closed.
- 3 new regression tests added (collation regression + 2 sharee-403 paths). Full suite: 184 tests, 0 failures (was 181).

## Task Commits

1. **Task 1 RED — collation regression test** - `ece663d` (test)
2. **Task 1 GREEN — schema + migration** - `9b86061` (feat)
3. **Task 2 RED — sharee-403 tests** - `931f767` (test)
4. **Task 2 GREEN — owner-only guard** - `c0bd6d7` (fix)

## Files Created/Modified

- `apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql` — New additive migration: ALTER TABLE list_items MODIFY rank to COLLATE utf8mb4_bin
- `apps/api/src/db/schema.ts` — Added `varcharBin` customType factory; replaced `listItems.rank` from `varchar('rank', { length: 255 })` to `varcharBin('rank').notNull()`; added `customType` to imports
- `apps/api/src/routes/lists.ts` — Added owner-only guard (`if (patch.isShared !== undefined && !access.isOwner) return 403`) after access check; updated stale comment on the reconciliation block
- `apps/api/tests/routes/lists.test.ts` — Added collation regression test in reorder describe block; added two T-04-08 tests in PATCH describe block

## Decisions Made

- **D-04-07-collation:** Drizzle 0.45.x does not expose a `collation` option on `varchar`. Used `customType` from `drizzle-orm/mysql-core` to define a `varcharBin` factory that emits `varchar(255) COLLATE utf8mb4_bin` as the SQL type string. This keeps schema-as-code and lets `db:generate` produce the correct `MODIFY COLUMN` statement.
- **D-04-07-guard-placement:** The guard is placed immediately after the `if (!access.allowed)` block and before `updateValues` construction — ensuring neither the `isShared` write nor the reconciliation block runs for non-owners.

## Deviations from Plan

None — plan executed exactly as written. The `customType` approach for collation was anticipated by the plan's guidance ("add the utf8mb4_bin collation via drizzle's column collation option"), and `customType` is the correct mechanism when drizzle's built-in types lack a first-class option.

## Must-Haves Verification

| Must-Have                                                                            | Status                                                               |
| ------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| listItems.rank gets explicit COLLATE utf8mb4_bin with a migration                    | PASS — migration 0002; DB reports utf8mb4_bin via information_schema |
| PATCH isShared reconciliation runs ONLY for the list owner (access.isOwner === true) | PASS — guard at lists.ts:336                                         |
| Non-owner sharee sending { isShared } receives 403, list_shares never mutated        | PASS — T-04-08 tests assert 403 + unchanged shares                   |
| Regression test for rank collation + negative sharee-403 test                        | PASS — 3 new tests in lists.test.ts                                  |

## Issues Encountered

- MySQL client (`mysql`) is not installed on the dev host. Verified live DB collation via `node --input-type=module` with direct `mysql2` connection instead of the CLI. Result was confirmed: `[{"COLUMN_NAME":"rank","COLLATION_NAME":"utf8mb4_bin"}]`.

## User Setup Required

None — migration is applied automatically via `db:migrate`. The dev MariaDB was migrated in-place during execution.

## Next Phase Readiness

- Phase 4 is now complete: all 14 security threats closed, LIST-03 gap resolved, full suite green (184/184).
- 04-SECURITY.md can be updated to `threats_open: 0`.
- 04-VERIFICATION.md LIST-03 gap entry can be marked resolved.
- Phase 5 (push notifications) is unblocked.

## Self-Check: PASSED

All files found. All commits verified.

---

_Phase: 04-shared-lists-live-sync_
_Completed: 2026-06-09_
