src/
├── main.rs
+│ └── mod prefs; routes: /prefs (GET+PUT, JWT), /timezones (GET, public)
├── lib.rs
+│ └── pub mod prefs
+└── prefs.rs # user_ai_prefs surface: GET /prefs, PUT /prefs (3-state partial), GET /timezones
on(put prefs)
if display_tz present && !Tz::from_str(trimmed)
return 400 # nothing written, not even other valid fields
for each field in (voice, library, display_tz)
omitted -> existing row value (or None)
null -> None
value -> Some(v)
if all omitted
return 200 current row # no-op, no row created, updated_at untouched
upsert(merged, tz_offset_minutes = existing verbatim)
return 200 {voice, library, display_tz, updated_at}
GET /timezones: chrono_tz::TZ_VARIANTS — exactly the domain Tz::from_str
accepts, so the picker list and the PUT validation can't drift apart.
Evidence
14 new tests green: cargo test --lib prefs:: → 14 passed (3 GET /prefs,
7 PUT /prefs incl. auth-gate, 2 GET /timezones, 2 pure merge_patch).
The 3-state wire semantics are the subtle part: serde's derive maps an
explicit JSON null on Option<Option<T>> to the outer None, making
"clear" indistinguishable from "omitted". The hand-rolled TriState
visitor keeps the three apart; put_prefs_explicit_null_clears_field
fails (voice stays set) without it.
Door: Two-way — additive routes only. No migration, no changed behavior on
any existing endpoint. Revert removes the three routes.
Blast Radius: Minimal. New routes only. The user_ai_prefs row is shared
with the existing capture_prefs writer (POST /reels); both writers upsert the
same single row, and the frozen legacy tz_offset_minutes column is preserved
verbatim on every write from this surface.
## Summary
```diff
src/
├── main.rs
+│ └── mod prefs; routes: /prefs (GET+PUT, JWT), /timezones (GET, public)
├── lib.rs
+│ └── pub mod prefs
+└── prefs.rs # user_ai_prefs surface: GET /prefs, PUT /prefs (3-state partial), GET /timezones
```
```text
on(put prefs)
if display_tz present && !Tz::from_str(trimmed)
return 400 # nothing written, not even other valid fields
for each field in (voice, library, display_tz)
omitted -> existing row value (or None)
null -> None
value -> Some(v)
if all omitted
return 200 current row # no-op, no row created, updated_at untouched
upsert(merged, tz_offset_minutes = existing verbatim)
return 200 {voice, library, display_tz, updated_at}
```
GET /timezones: `chrono_tz::TZ_VARIANTS` — exactly the domain `Tz::from_str`
accepts, so the picker list and the PUT validation can't drift apart.
## Evidence
- 14 new tests green: `cargo test --lib prefs::` → 14 passed (3 GET /prefs,
7 PUT /prefs incl. auth-gate, 2 GET /timezones, 2 pure merge_patch).
- The 3-state wire semantics are the subtle part: serde's derive maps an
explicit JSON null on `Option<Option<T>>` to the outer `None`, making
"clear" indistinguishable from "omitted". The hand-rolled `TriState`
visitor keeps the three apart; `put_prefs_explicit_null_clears_field`
fails (voice stays set) without it.
- `cargo clippy --all-targets` clean; `cargo fmt --check` clean.
- Full suite: 1436 passed / 0 failed (14 binaries)
## Merge Danger
**Door:** Two-way — additive routes only. No migration, no changed behavior on
any existing endpoint. Revert removes the three routes.
**Blast Radius:** Minimal. New routes only. The `user_ai_prefs` row is shared
with the existing `capture_prefs` writer (POST /reels); both writers upsert the
same single row, and the frozen legacy `tz_offset_minutes` column is preserved
verbatim on every write from this surface.
- GET /prefs (JWT): current row or empty {voice, library, display_tz,
updated_at} shape — snake_case, 200 with nulls when no row
- PUT /prefs (JWT): three-state partial update (omitted=unchanged,
explicit null=clear, value=set) via hand-rolled TriState deserializer —
serde derive conflates omitted with explicit null on Option<Option<T>>
- display_tz validated against chrono-tz before any write; invalid → 400,
nothing written. tz_offset_minutes (frozen legacy) preserved verbatim
- GET /timezones (public): chrono_tz::TZ_VARIANTS — exactly the domain
Tz::from_str accepts, so picker list and validation can't drift
- No migration; existing columns only. capture_prefs (POST /reels) untouched
- 14 tests: 3 GET /prefs, 7 PUT /prefs (incl. auth-gate), 2 /timezones,
2 pure merge_patch. Full suite 1436 passed / 0 failed
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
GET /timezones:
chrono_tz::TZ_VARIANTS— exactly the domainTz::from_straccepts, so the picker list and the PUT validation can't drift apart.
Evidence
cargo test --lib prefs::→ 14 passed (3 GET /prefs,7 PUT /prefs incl. auth-gate, 2 GET /timezones, 2 pure merge_patch).
explicit JSON null on
Option<Option<T>>to the outerNone, making"clear" indistinguishable from "omitted". The hand-rolled
TriStatevisitor keeps the three apart;
put_prefs_explicit_null_clears_fieldfails (voice stays set) without it.
cargo clippy --all-targetsclean;cargo fmt --checkclean.Merge Danger
Door: Two-way — additive routes only. No migration, no changed behavior on
any existing endpoint. Revert removes the three routes.
Blast Radius: Minimal. New routes only. The
user_ai_prefsrow is sharedwith the existing
capture_prefswriter (POST /reels); both writers upsert thesame single row, and the frozen legacy
tz_offset_minutescolumn is preservedverbatim on every write from this surface.
- GET /prefs (JWT): current row or empty {voice, library, display_tz, updated_at} shape — snake_case, 200 with nulls when no row - PUT /prefs (JWT): three-state partial update (omitted=unchanged, explicit null=clear, value=set) via hand-rolled TriState deserializer — serde derive conflates omitted with explicit null on Option<Option<T>> - display_tz validated against chrono-tz before any write; invalid → 400, nothing written. tz_offset_minutes (frozen legacy) preserved verbatim - GET /timezones (public): chrono_tz::TZ_VARIANTS — exactly the domain Tz::from_str accepts, so picker list and validation can't drift - No migration; existing columns only. capture_prefs (POST /reels) untouched - 14 tests: 3 GET /prefs, 7 PUT /prefs (incl. auth-gate), 2 /timezones, 2 pure merge_patch. Full suite 1436 passed / 0 failed