houserules/.sisyphus/plans/modifier-profiles-t2.md

547 lines
24 KiB
Markdown

# Modifier Profiles — T2 Polish
## TL;DR
> **Quick Summary**: UX and robustness polish on top of shipped T1 modifier profiles. Adds copy/paste between pieces, a visual "this piece is modified" indicator on the live board, inline conflict-resolution in the editor, undo/redo in the editor, proper turn-boundary queuing server-side (replaces T1's immediate-apply simplification), two-player consent model for mid-game swaps (replaces host-only), and richer source-chain attribution in the pinned inspection panel.
>
> **Deliverables**:
> - Editor: copy/paste modifiers, undo/redo stack, inline conflict resolution UI
> - Board: subtle modifier-indicator badge on modified pieces (no hover required)
> - Server: turn-boundary queue (ADR-3 done properly), both-player consent for hot-swap
> - Inspection: enhanced source-chain breakdown in pinned panel
> - Full e2e vertical slice
> - Updated ADR + user docs
>
> **Estimated Effort**: Medium (15 tasks)
> **Parallel Execution**: YES — 4 waves
> **Critical Path**: T1 (turn-queue server) → T3 (consent) → T13 (e2e)
---
## Context
### Original Request
> "can we do t2 and t3? T2 first, then T3 (Recommended)"
### T1 Recap (shipped)
T1 (completed) delivered the foundation: 6 built-in modifier descriptors, per-type + per-instance scope, editor UI, library persistence, URL sharing, hover tooltip, pinned panel, server-side validation, immediate-apply hot-swap, host-only authority. Full vertical slice working with 18/18 Playwright tests.
### T2 Rationale
Two categories of work:
1. **UX polish**: features users will notice immediately — copy/paste, visual indicators, undo/redo, better conflict messages.
2. **Robustness fixes**: close the documented simplifications from T1's Implementation Retrospective — turn-boundary queue (not immediate) + two-player consent (not host-only).
### Architectural Decisions (inherited from T1 ADRs)
Most T2 work doesn't need new ADR decisions. Three small extensions:
**T2-ADR-1: Turn-boundary queue**
- Client sends `modifier-profile.update` at any time.
- Server enqueues to `room.pendingProfile`. Apply runs in the server's `onAfterMove` hook after next move is validated.
- If pending profile is invalid when apply fires → NACK to sender, clear pending, no broadcast.
- Multiple updates before apply: last-write-wins (replace pending).
- Replaces T1 simplification where swap applied immediately on receipt.
**T2-ADR-2: Two-player consent**
- Host sends `modifier-profile.propose` with candidate profile.
- Server broadcasts `modifier-profile.proposal-pending` to opponent with profile contents.
- Opponent sends `modifier-profile.consent` with approve/reject.
- If approve → apply at next turn boundary (T2-ADR-1). If reject → clear proposal, broadcast `modifier-profile.rejected`.
- Timeout: 60s → auto-reject.
- Replaces T1's unilateral host authority.
**T2-ADR-3: Undo/redo in editor**
- Editor maintains a snapshot stack of working-profile states.
- Every meaningful user action (add/delete/edit a modifier) pushes a snapshot.
- Cmd/Ctrl+Z undoes; Cmd/Ctrl+Shift+Z redoes.
- Stack capped at 50 snapshots.
- Cleared on save or cancel.
---
## Work Objectives
### Core Objective
Polish the T1 modifier-profile system with features users expect from a mature editor + close the documented T1 simplifications (immediate-apply, host-only) to ship production-grade semantics.
### Concrete Deliverables
**Editor UX** (`packages/chess/src/ui/`):
- `ModifierProfileEditor.tsx` — add undo/redo stack, toolbar with history controls
- `PerTypePanel.tsx` — add "Copy" + "Paste" buttons on each modifier row
- `PerInstancePanel.tsx` — add copy/paste between pieces ("copy from b1" → "paste to g1")
- `ConflictResolutionPanel.tsx` — NEW: shows validation errors inline with suggested fixes + auto-resolve options
**Board UX** (`packages/chess/src/ui/`):
- `ModifiedPieceIndicator.tsx` — NEW: small badge/glow on modified pieces
- `GameView.tsx` + `Board.tsx` — wire indicator into piece rendering
**Server** (`packages/server/src/`):
- `broadcast.ts` — `handleModifierProfileUpdate` now enqueues instead of applying
- `game-session.ts` — new `applyPendingProfile()` method called after `applyMove()`
- New handlers: `handleModifierProfilePropose`, `handleModifierProfileConsent`
- `rooms.ts` — `Room.pendingProfile`, `Room.proposalState`, `Room.proposalTimeoutHandle`
- `protocol.ts` — new messages: `modifier-profile.propose`, `modifier-profile.proposal-pending`, `modifier-profile.consent`, `modifier-profile.rejected`
**Inspection** (`packages/chess/src/ui/`):
- `ModifierPinnedPanel.tsx` — enhanced source breakdown:
- Per-instance entries labeled "(from layout square X)"
- Per-type entries labeled "(applies to all {color} {type}s)"
- Preset entries labeled "(from {preset name})"
- Base values labeled "(default)"
**Docs**:
- Update `docs/adr/modifier-profiles.md` — add T2-ADR-1, T2-ADR-2, T2-ADR-3 sections
- Update `docs/user/modifier-profiles.md` — document new features
**E2E** (`packages/chess/e2e/`):
- Extend `modifier-profiles.spec.ts` — 8 new scenarios
### Definition of Done
- [ ] `bun run check` green (typecheck + lint + vitest)
- [ ] Copy/paste between pieces: adding a per-instance modifier to b1, then copying to g1, produces identical entries at both squares
- [ ] Undo/redo: 5 actions then undo 3 times then redo 2 times produces state equal to 4-action state
- [ ] Visual indicator visible on modified pieces without requiring hover
- [ ] Turn-boundary queue: profile update during mid-move doesn't apply until after opponent's move resolves
- [ ] Two-player consent: black must approve before profile change broadcasts
- [ ] Proposal timeout: 60s no-response → auto-reject
- [ ] Inline conflict resolution shows specific error + suggested fix
- [ ] Source chain in pinned panel distinguishes all 4 sources
- [ ] Playwright e2e: 18 existing + 8 new = 26 scenarios all pass
- [ ] ADR + user docs updated
### Must Have
- All 8 UX/polish deliverables working end-to-end
- Turn-boundary queue replaces immediate-apply (no regression — hot-swap still works)
- Two-player consent with 60s timeout
- Editor undo/redo with 50-snapshot cap
- Shared schema between client/server (no drift)
### Must NOT Have (Guardrails)
- ❌ Custom modifier authoring (T3)
- ❌ Cross-piece aura effects (T3)
- ❌ Multi-profile stacking (post-T3)
- ❌ Editor persistence across sessions (reload = fresh editor, library is separate)
- ❌ Server-side undo (client-side only — no WS messages for undo state)
- ❌ Breaking changes to T1 WS messages (all additions are NEW messages; `modifier-profile.update` becomes an alias for `propose` + auto-approve when single-player)
- ❌ Mid-turn swap (turn-boundary gate is enforced)
- ❌ Client computing effective modifiers differently from server
### Must NOT Have (AI Slop Patterns)
- ❌ `as any`, `@ts-ignore`, or bypassing strict TS
- ❌ Generic names (`data`, `result`, `item`, `temp`)
- ❌ Empty catch blocks
- ❌ `console.log` in production code
- ❌ Premature abstraction
---
## Verification Strategy
### Test Decision
- **Infrastructure**: vitest + Playwright, same as T1.
- **Approach**: TDD for server + engine; Playwright scenarios for UX features.
### QA Policy
Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/modifier-profiles-t2/task-{N}-{slug}.{ext}`.
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Server robustness foundation — PARALLEL):
├── T1: T2-ADR documentation (append to existing ADR)
├── T2: Turn-boundary queue server-side
└── T3: Two-player consent protocol
Wave 2 (Editor features — PARALLEL):
├── T4: Undo/redo snapshot stack in editor
├── T5: Copy/paste modifiers between pieces
└── T6: Inline conflict resolution panel
Wave 3 (Board UX + Inspection polish — PARALLEL):
├── T7: Modified-piece indicator on live board
├── T8: Enhanced source-chain in pinned panel
└── T9: Consent UI (proposal notification + approve/reject buttons)
Wave 4 (E2E + Docs — PARALLEL):
├── T10: Playwright e2e additions (8 scenarios)
├── T11: ADR updates
└── T12: User docs updates
Wave FINAL (4 parallel reviewers):
├── F1: Plan compliance audit
├── F2: Code quality review
├── F3: Manual QA
└── F4: Scope fidelity check
```
### Dependency Matrix
| Task | Depends On | Blocks |
|------|------------|--------|
| 1 | — | 2-12 |
| 2 | 1 | 3, 10, 11 |
| 3 | 2 | 9, 10, 11 |
| 4 | 1 | 10 |
| 5 | 1 | 10 |
| 6 | 1 | 10 |
| 7 | 1 | 10 |
| 8 | 1 | 10 |
| 9 | 3 | 10 |
| 10 | 2-9 | F1-F4 |
| 11 | 1, 2, 3 | F1 |
| 12 | 4-9 | F1 |
---
## TODOs
- [x] 1. **T2-ADR documentation**
**What to do**:
- Append 3 new sections to `docs/adr/modifier-profiles.md`:
- `## T2-ADR-1: Turn-boundary queue` — full semantics, last-write-wins, NACK on invalid at apply time
- `## T2-ADR-2: Two-player consent` — proposal/consent flow, 60s timeout, auto-reject
- `## T2-ADR-3: Editor undo/redo` — snapshot stack, 50-item cap, cleared on save/cancel
**Must NOT do**: Don't write any code in this task.
**Recommended Agent Profile**: `writing` — no skills
**Parallelization**: Wave 1 (solo). Blocks: ALL. Blocked By: None.
**Acceptance Criteria**:
- [ ] File has 3 new T2-ADR sections
- [ ] `grep -c "^## T2-ADR-" docs/adr/modifier-profiles.md` → 3
**Commit**: `docs(adr): T2 polish architecture decisions`
- [x] 2. **Turn-boundary queue server-side**
**What to do**:
- Replace `handleModifierProfileUpdate`'s immediate-apply with queue semantics:
- Add `Room.pendingProfile?: ModifierProfile` field (may already exist from T1 — re-use or add)
- On receive: validate shape, store in `room.pendingProfile`, ack with `modifier-profile.queued`
- On `applyMove()` success: check pendingProfile, validate against post-move session, apply via `reconcileProfileSwap`, broadcast `modifier-profile.updated`, clear pending
- If pending profile invalid at apply time: NACK to original sender with specific error code, clear pending
- Last-write-wins: new pending replaces old pending before apply fires
**Must NOT do**: Don't break T1 e2e tests. Don't apply mid-move.
**Recommended Agent Profile**: `deep` — concurrency-sensitive
**Parallelization**: Wave 1 (PARALLEL with T1, T3). Blocks: T3, T10, T11. Blocked By: T1.
**References**:
- `packages/server/src/broadcast.ts` — current `handleModifierProfileUpdate`
- `packages/server/src/game-session.ts` — `applyMove`, post-move hook location
- `docs/adr/modifier-profiles.md` § T2-ADR-1
**Acceptance Criteria**:
- [ ] Update sent during move — profile does NOT apply until opponent completes their move
- [ ] 2 rapid updates → only the last is applied
- [ ] Invalid pending at apply time → NACK to sender, no broadcast
- [ ] `bun run test packages/server/src/ws.modifier-profile-update.test.ts` — all pass (updated)
**Commit**: `feat(server): turn-boundary queue for modifier profile updates`
- [x] 3. **Two-player consent protocol**
**What to do**:
- New WS messages in `protocol.ts`:
- `modifier-profile.propose` (client→server) — host sends candidate profile
- `modifier-profile.proposal-pending` (server→opponent) — notify with profile contents
- `modifier-profile.consent` (client→server) — opponent sends approve/reject
- `modifier-profile.rejected` (server→host) — proposal rejected or timed out
- `broadcast.ts`:
- `handleModifierProfilePropose` — store proposal + start 60s timeout
- `handleModifierProfileConsent` — on approve: promote to pendingProfile (feeds into T2 queue); on reject: clear + broadcast rejected
- On timeout: auto-reject
- `rooms.ts`:
- `Room.proposalState?: { profile, proposedBy, timeoutHandle, proposedAt }`
- Solo mode / host-only mode: `modifier-profile.update` alias auto-approves
**Must NOT do**: Don't change `modifier-profile.update` semantics in solo mode.
**Recommended Agent Profile**: `deep`
**Parallelization**: Wave 1. Blocks: T9, T10, T11. Blocked By: T2.
**References**:
- `docs/adr/modifier-profiles.md` § T2-ADR-2
**Acceptance Criteria**:
- [ ] Propose → opponent sees pending → approve → broadcast to both
- [ ] Propose → opponent rejects → only host sees rejected, no broadcast to board
- [ ] Propose → 60s timeout → auto-reject
- [ ] Solo mode: propose = immediate apply (no consent step)
**Commit**: `feat(server): two-player consent for modifier profile swaps`
- [x] 4. **Editor undo/redo snapshot stack**
**What to do**:
- In `ModifierProfileEditor.tsx`:
- `const [history, setHistory] = useState<ModifierProfile[]>([initialProfile])`
- `const [historyIndex, setHistoryIndex] = useState(0)`
- `const currentProfile = history[historyIndex]`
- Every mutation path goes through `pushSnapshot(newProfile)` which truncates forward history and caps at 50
- `undo()` / `redo()` adjust historyIndex
- Keyboard handlers for Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z
- Header toolbar with Undo/Redo buttons (disabled when at ends of stack)
- `data-testid="undo-button"`, `data-testid="redo-button"`
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Add 3 modifiers → undo 2 → profile has 1 modifier
- [ ] Undo then redo → same state
- [ ] Cap at 50 snapshots (add 51st → oldest dropped)
- [ ] Save or cancel clears history
**Commit**: `feat(ui): modifier editor undo/redo`
- [x] 5. **Copy/paste modifiers between pieces**
**What to do**:
- In `PerInstancePanel.tsx`:
- When a square is selected: "Copy modifiers from this square" button
- When a different square is selected after copy: "Paste modifiers here" button (disabled when clipboard empty)
- Clipboard is component-local state (not OS clipboard)
- Paste appends all clipboard entries to the target square, with the kind preserved
- In `PerTypePanel.tsx`:
- "Copy to clipboard" on each row
- "Paste" button appends from clipboard
- `data-testid="copy-modifier-b1"` etc.
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Add HP+2 to b1 → copy → paste on g1 → both have HP+2
- [ ] Paste when clipboard empty: button disabled
- [ ] Copy doesn't delete source
**Commit**: `feat(ui): copy/paste modifiers between pieces`
- [x] 6. **Inline conflict resolution panel**
**What to do**:
- New component `ConflictResolutionPanel.tsx`:
- Shows validation errors from `validateProfile()` inline
- Each error has a "Fix" button with a specific auto-resolve action:
- `E_PROFILE_NO_KING` → "Add king to e1" button (or suggest switching layout)
- `E_PROFILE_INVULN_KING` → "Remove invuln from king" button
- `E_PROFILE_ORPHAN_INSTANCE` (warning) → "Remove orphan entry" button
- `E_PROFILE_ATTR_LIMIT` → "Show affected pieces" + manual resolution
- Panel visible whenever profile has errors/warnings; hidden when clean
- Wire into `ModifierProfileEditor.tsx` — show panel at the top when dirty
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Create profile with invuln on king → panel shows error + Fix button
- [ ] Click Fix → error resolved
- [ ] Panel hidden when profile validates clean
**Commit**: `feat(ui): inline conflict resolution panel`
- [x] 7. **Modified-piece indicator on live board**
**What to do**:
- New component `ModifiedPieceIndicator.tsx`:
- Small colored dot (or glow/border) shown on pieces that have any active modifier
- Reads `MODIFIER_REGISTRY.list()` and checks `session.get(pieceId, attr)` for each
- If any defined → indicator visible
- CSS: absolute-positioned top-right corner of piece square, 8px dot
- Wire into `Board.tsx` — render indicator alongside piece
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Piece with modifier: indicator visible
- [ ] Piece without modifier: no indicator
- [ ] Indicator updates reactively on profile swap
**Commit**: `feat(ui): modified-piece indicator on board`
- [x] 8. **Enhanced source-chain in pinned panel**
**What to do**:
- Update `ModifierPinnedPanel.tsx`:
- For each modifier row, add source label:
- If piece's attr value matches a profile.perInstance entry at its square → "(per-instance: {square})"
- Else if matches a perType entry for this pieceType+color → "(per-type: all {color} {type}s)"
- Else if some active preset declared this attr → "(preset: {preset.name})"
- Else → "(default)"
- Reads the engine's active profile + presets via a new `getModifierSource(engine, pieceId, kind)` helper in `packages/chess/src/modifiers/source.ts`
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T1.
**Acceptance Criteria**:
- [ ] Per-instance modifier → source labels show "per-instance: b1"
- [ ] Per-type modifier → source labels show "per-type: all white knights"
- [ ] piece-hp preset → source labels show "preset: Hit Points"
**Commit**: `feat(ui): enhanced modifier source chain in pinned panel`
- [x] 9. **Consent UI (proposal notification + buttons)**
**What to do**:
- New component `ModifierProposalDialog.tsx`:
- Shows when opponent has pending proposal (`modifier-profile.proposal-pending` received)
- Displays summary of proposed profile changes
- Approve / Reject buttons
- 60s countdown timer
- Wire into `GameView.tsx` via WS message subscription
- Host sees a "Proposal sent — waiting for opponent" state after proposing
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T3.
**Acceptance Criteria**:
- [ ] Host proposes → opponent sees dialog within 500ms
- [ ] Approve → profile applies at next turn boundary
- [ ] Reject → dialog closes on both sides, host sees "Proposal rejected"
- [ ] Timeout → auto-reject
**Commit**: `feat(ui): consent dialog for modifier profile proposals`
- [x] 10. **Playwright e2e additions (8 feature scenarios + solo regression guards)**
**What to do**:
- Add 8 new T2-feature tests to `packages/chess/e2e/modifier-profiles.spec.ts`:
1. Editor undo/redo: add 3 modifiers, undo 2, redo 1
2. Copy modifier between squares (per-instance)
3. Paste button disabled when clipboard empty
4. Conflict panel shows error + Fix works
5. Modified-piece indicator visible without hover
6. Source chain distinguishes per-instance vs per-type
7. (multiplayer) Proposal → approve → both clients see update
8. (multiplayer) Proposal → reject → no update
- **Keep and expand `packages/chess/e2e/solo-smoke.spec.ts`** (already exists from T1 post-audit fix). Adds ongoing regression coverage for solo play — these tests caught the T1 Rules-Drawer-Esc bug. Ensure the following scenarios remain and are extended:
- play-solo button navigates to /game with fresh board
- drag pawn e2→e4 resolves
- 4-ply sequence (e4 e5 Nf3 Nc6) completes
- **Rules drawer opens, closes via Esc, board remains interactive** (the T1 regression guard)
- No console errors on solo game start
- Add 2 more solo-regression scenarios in T2 since T2 touches drawer/editor further:
- Rules drawer: backdrop click closes drawer (workaround path still works)
- Modifier editor: Esc closes editor without dismissing drawer underneath (nested modal ordering)
**Why both?** `solo-smoke.spec.ts` is the canary — it exercises the baseline game flow without any modifier-profile setup. `modifier-profiles.spec.ts` covers feature paths. Keeping both prevents T2/T3 work from silently regressing solo play.
**Recommended Agent Profile**: `unspecified-high`, skills: [`playwright`]
**Parallelization**: Wave 4. Blocks: F1-F4. Blocked By: T2-T9.
**Acceptance Criteria**:
- [ ] 26/26 modifier-profile tests pass (18 from T1 + 8 new)
- [ ] 7+ solo-smoke tests pass (5 original + 2 T2 additions)
- [ ] Multiplayer suite unchanged, still passes
- [ ] Total Playwright runtime < 120s
**Commit**: `test(e2e): T2 polish vertical slice + solo regression guards`
- [x] 11. **ADR updates — Implementation Retrospective T2 addendum**
**What to do**:
- Append to `docs/adr/modifier-profiles.md`:
- `## T2 Implementation Retrospective` section noting:
- T1's immediate-apply simplification resolved (T2-ADR-1)
- T1's host-only simplification resolved (T2-ADR-2)
- Editor UX parity with modern standards (undo/redo, copy/paste)
- Any deviations found during implementation
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 4. Blocks: F1. Blocked By: T1, T2, T3.
**Commit**: `docs(adr): T2 implementation retrospective`
- [x] 12. **User docs updates**
**What to do**:
- Update `docs/user/modifier-profiles.md`:
- Update "Hot-Swap" section: describe proposal/consent flow (remove T1's "host-only" note)
- Add "Editor Features" section: undo/redo (keyboard shortcuts), copy/paste, conflict resolution
- Add "Board Indicators" section: modified-piece glow
- Update "In-Play Inspection" section: mention enhanced source chain
- Update "Known Limitations" section: remove T1 items, add T3 preview
**Recommended Agent Profile**: `unspecified-low`
**Parallelization**: Wave 4. Blocks: F1. Blocked By: T4-T9.
**Commit**: `docs(user): T2 modifier profile features`
---
## Final Verification Wave
- [x] F1. **Plan Compliance Audit** — `oracle`
Verify all 12 tasks' deliverables exist. Verify T1 simplifications resolved. Check evidence files.
Output: `Must Have [N/N] | Must NOT Have [N/N] | ADR Decisions [3/3] | VERDICT: APPROVE/REJECT`
- [x] F2. **Code Quality Review** — `unspecified-high`
`bun run check`. Scan for slop patterns. Verify server-shared schemas.
Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass] | VERDICT`
- [x] F3. **Manual QA** — `unspecified-high` (+ `playwright`)
Execute all 26 Playwright scenarios. Multiplayer proposal/consent roundtrip. Undo/redo combos.
Output: `Scenarios [N/N] | Integration [pass] | VERDICT`
- [x] F4. **Scope Fidelity** — `deep`
No T3 features smuggled (no custom authoring, no auras, no multi-profile stacking). No breaking WS changes.
Output: `Tasks [N/N compliant] | Contamination [CLEAN] | VERDICT`
---
## Commit Strategy
1. `docs(adr): T2 polish architecture decisions`
2. `feat(server): turn-boundary queue for modifier profile updates`
3. `feat(server): two-player consent for modifier profile swaps`
4. `feat(ui): modifier editor undo/redo`
5. `feat(ui): copy/paste modifiers between pieces`
6. `feat(ui): inline conflict resolution panel`
7. `feat(ui): modified-piece indicator on board`
8. `feat(ui): enhanced modifier source chain in pinned panel`
9. `feat(ui): consent dialog for modifier profile proposals`
10. `test(e2e): T2 polish vertical slice`
11. `docs(adr): T2 implementation retrospective`
12. `docs(user): T2 modifier profile features`
## Success Criteria
```bash
bun run check # all green
bun run test packages/server/ # all pass (including updated ws tests)
bunx playwright test e2e/modifier-profiles.spec.ts # 26/26 pass
```
### Final Checklist
- [ ] All 9 feature tasks shipped end-to-end
- [ ] T1 simplifications (immediate-apply, host-only) fully resolved
- [ ] 26 Playwright e2e scenarios green
- [ ] ADR + user docs updated
- [ ] `bun run check` green
- [ ] F1-F4 all APPROVE
- [ ] User explicit "okay"