reshape: move OHM transcripts under ohm/ for org-wide multi-app layout; add org-wide root README

This commit is contained in:
2026-06-03 06:54:38 -07:00
parent bd21825bc2
commit 3713299f08
73 changed files with 200 additions and 181 deletions
@@ -0,0 +1,165 @@
# Session 0022.0 — Transcript
> Date: 2026-05-28
> Goal: Wave-11 driver — ship the two v0.21.0-disjoint roadmap items the
> operator picked (#26 + #29) via parallel worktrees; then, as the session
> evolved, ship a session-ID self-allocation **protocol amendment**, execute
> the **#1 VM rename** (`ohm-app` → `ohm-rfc-app`), and drain the deploy
> queue (incl. a concurrent session's v0.24.0/#27).
>
> Outcome: **Three rfc-app versions live on OHM — v0.22.0 (#26), v0.23.0
> (#29), v0.24.0 (#27, built by session 0025) — on a fully renamed
> `ohm-rfc-app` VM. Protocol amendment shipped: sessions now self-allocate
> their ID race-free via git, with in-progress visibility + checkpointing.
> OHM healthy at v0.24.0 throughout (one ~3-min planned downtime for the
> instance rename).**
>
> Subsessions: [0022.1](./SESSION-0022.1-TRANSCRIPT-2026-05-28T12-04--2026-05-28T12-28.md) (#26), [0022.2](./SESSION-0022.2-TRANSCRIPT-2026-05-28T12-04--2026-05-28T12-18.md) (#29).
---
## Plan (as it settled, with the operator)
- [x] Pick v0.21.0-disjoint Wave-11 items → operator chose **both** #26 (→v0.22.0) and #29 (→v0.23.0); parallel build, serial deploy, proceed autonomously.
- [x] Build via **manual** git worktrees (Agent `isolation:worktree` is broken) — one subagent per item, strict file ownership.
- [x] Integrate, tag, push both releases (origin + benstull); bump ohm-rfc pin.
- [x] **Protocol amendment** (operator request mid-session): session IDs self-allocated race-free via git; in-progress filename marker; checkpoint the transcript + a plan block.
- [x] **#1 VM rename** `ohm-app``ohm-rfc-app` (operator request) — full scope incl. GCP instance.
- [x] Deploy the queue: v0.22.0 → v0.23.0 → v0.24.0 (#27, session 0025).
- [x] ROADMAP strikes (#1, #26, #29); finalize + publish transcript.
---
## Pre-session state
- rfc-app `main` @ `2ac20b1`, VERSION 0.21.0; tags through v0.21.0. OHM live at v0.21.0 (deploys.id=29, aborted-but-healthy per the §19.2 SSE-watchdog note).
- ohm-rfc pin `0.21.0` @ `8a16cfa`. flotilla `main` @ `c8804a6`.
- Legacy `ohm-app` VM/user/dir/unit still in place (the #1 rename was an open candidate).
- Concurrency: prompt asserted "you are 0022"; sessions 0019/0020/0021 spoken for; a `SESSION-0024.0` already existed locally — the prompt-assigned-number scheme had visibly drifted (this seeded the protocol amendment).
---
## Turn-by-turn arc
### Arc 1 — Setup + item pick
Read SESSION-PROTOCOL.md + ROADMAP.md. Pulled all three repos. Confirmed deploy lock free (top row aborted, not in_progress) and no concurrent branch had claimed v0.22.0/#26/#29. Asked the operator: which item(s) + deploy gate. Operator chose **both #26 and #29**, parallel build / serial deploy, **proceed autonomously** through deploy.
### Arc 2 — Parallel build (subagents 0022.1, 0022.2)
`isolation:worktree` is broken (confirmed-known), so I created two **manual** worktrees off `main`, symlinked `frontend/node_modules` + `backend/.venv` into each (verified pytest collects 332), and dispatched two general-purpose subagents with strict file-ownership boundaries (no shared-file collisions; #29 owns App.jsx's non-header sign-in/route region, #26 owns the propose/RFC/PR components; migrations 021 vs 022). Both wrote their own §5 subsession transcripts before reporting.
- **0022.1 / #26**: found the framework's `rfcs`/PR surfaces are Gitea-backed **caches** rebuilt by the reconciler — so a cache column alone always reads NULL. Designed a canonical reconcile-proof side table `proposed_use_cases` (+ the literal cache columns for parity). 336 tests green.
- **0022.2 / #29**: `user_session_state` table; folded the read onto `/api/auth/me`; redirect gated on the Amplitude `identify` (#21 Part C ordering). 336 tests green.
Integrated #26 → main, bumped VERSION+package.json to 0.22.0, CHANGELOG, tagged v0.22.0, pushed origin+benstull. Rebased #29 on the new main (clean — disjoint api.py regions held), merged, tagged v0.23.0, pushed. 340 tests green with both. ohm-rfc pin → 0.22.0.
### Arc 3 — Deploy blocked, then a false alarm (the rabbit hole)
First `flotilla deploy` died on **expired GCP ADC** (`Reauthentication is needed`) — failed before the lock, no harm. Operator refreshed ADC (`gcloud auth application-default login`). Retried → **`preflight: install dir /opt/ohm-app missing on ohm-app`**. This sent me to inspect the VM, where I found `/opt/ohm-app` **perfectly healthy** and the service running — nothing renamed. Root cause: at deploy time the **`gcloud` CLI** user creds were *still* expired (only ADC had been refreshed); flotilla's preflight runs `test -d` **via `gcloud compute ssh`**, and `deploy.py:355` treats *any* non-zero return as "dir missing" — so a 1-second `gcloud` reauth failure masqueraded as a catastrophic missing-install-dir. **§19.2 candidate.** Operator ran `gcloud auth login`; the false alarm cleared.
### Arc 4 — Protocol amendment: session-ID self-allocation
Operator request: stop relying on the prompt's session number; claim it race-free via git, push a placeholder at start, finalize in place. Built `~/git/ohm-infra/scripts/claim-session-id.sh` (clones the public repo, computes `max(NNNN)+1` never filling gaps, pushes a placeholder — push *is* the atomic claim, with resync-and-retry on rejection; PR fallback documented). Extended `publish-transcript.sh` (accept `INPROGRESS` end token; replace same-`NNNN.M` variants in place at finalize). Amended `SESSION-PROTOCOL.md` (§7 + session-start gesture + §4 note) and flotilla `CLAUDE.md` (commit `85f01f8`). Claimed **0022** with the new script (validated the real push path). Later, per a follow-up: the claim script now **lists active `--INPROGRESS` sessions** so a starting session sees what's in flight; documented the **checkpoint convention** (a `## Plan` block + re-publishing the in-progress transcript at each checkpoint) and the **hooks** automation path (`SessionStart`/`Stop`/`SessionEnd`), with the honest finding that Claude has **no** built-in cross-session coordination — the shared-repo scheme is the right bespoke fill; hooks only *trigger* it.
### Arc 5 — #1 VM rename `ohm-app` → `ohm-rfc-app`
Operator: "we want the dir and service to be ohm-rfc-app" → chose the **full** rename incl. the GCP instance. Verified the external IP is a **reserved static** (`ohm-app-ip`/136.116.40.66) → instance stop/start is DNS-safe. Mapped every dependency: install dir (= service-user home), unit, user/group, nginx `root`, the venv's absolute-path shebangs, and the `DATABASE_PATH`/`PHILOSOPHY_PATH` overlays (+ live `.env`) — the production DB lives at `/opt/ohm-app/backend/data/ohm-app.db`.
- **Stage 1 (in-VM, instance running)**: stop service → `mv` dir → rename user/group → repair venv shebangs + `.env` paths in place → new `ohm-rfc-app.service` → nginx `root` → reload. Verified healthy (`v0.21.0 ok`) under the new layout.
- **Stage 2 (instance)**: `stop``set-name ohm-rfc-app``start`. Internal + reserved external IP preserved. Polled back to healthy.
- **Stage 3 (flotilla)**: surgical in-place `UPDATE` of the 4 `target_vm_*` fields in the state DB (no `deployment update` verb exists → §19.2; backed the DB up first; kept name `ohm-rfc-app` so overlay/secret rows stayed linked); repointed `DATABASE_PATH`/`PHILOSOPHY_PATH` overlays; aligned `scripts/register-ohm.sh` (flotilla `c26f251`).
- Deployed **v0.22.0** (deploys.id=31) onto the renamed layout — all 9 phases clean, **no SSE-watchdog recurrence**. Confirmed migrations 021/022 applied on the production DB and the new tables/column exist — **no data loss** (DB moved with the dir). Then **v0.23.0** (deploys.id=32).
### Arc 6 — Drain the queue: v0.24.0 (#27)
Operator: "the deploy queue is v0.22.0 → v0.23.0 → v0.24.0." v0.24.0 = **#27 Haiku tag suggestions** (built by concurrent session 0025). Verified the required `ANTHROPIC_API_KEY` was already bound (Secret Manager version 1, enabled) — **no secret-byte gesture needed**, and #27 degrades to silence if absent. Bumped pin → 0.24.0, deployed (deploys.id=33), health `0.24.0`.
### Arc 7 — Close-out
Struck #1/#26/#29 in the ROADMAP (ohm-rfc `ab351c5`). Delivered the next-session handoff prompt to the operator in chat (before publish, per §3). Published this transcript + the two subsession transcripts — the finalize **validated the §7 replace-in-place in production**: publishing `…--2026-05-28T13-53.md` git-rm'd the `…--INPROGRESS.md` placeholder in one commit (`14b241c`), leaving exactly one driver transcript for 0022. Removed the two rfc-app worktrees + their merged feature branches and the stale local placeholder.
### Arc 8 — Final re-publish
Operator: "update your transcript and publish and we're done." Updated this transcript to record arcs 78 and re-published (idempotent content-update on the same filename). The operator re-confirmed close-out; this transcript is the final, published record of Session 0022.0. **Session closed.**
---
## Cut state (end of session)
| | |
| --- | --- |
| rfc-app | `main` @ v0.24.0; tags v0.22.0 (`7d8371d`), v0.23.0 (`daebb54`) pushed origin+benstull (v0.24.0 by session 0025) |
| ohm-rfc | pin `0.24.0`; ROADMAP `ab351c5` (struck #1/#26/#29) |
| flotilla | `main` @ `c26f251` (CLAUDE.md `85f01f8`, register-ohm.sh `c26f251`); §13 record → ohm-rfc-app; no flotilla code change |
| OHM live | **v0.24.0, healthy** (deploys.id=33) on renamed VM `ohm-rfc-app` |
| VM | instance/user/dir(`/opt/ohm-rfc-app`)/unit(`ohm-rfc-app.service`) renamed; static IP preserved |
| ohm-infra | `claim-session-id.sh` (new), `publish-transcript.sh` (extended), `SESSION-PROTOCOL.md` §7 (new) — not a git repo; saved in place |
| Wave 11 ledger | Status |
| --- | --- |
| #26 use-case field (v0.22.0, id=31) | ✅ shipped |
| #29 sign-in resume (v0.23.0, id=32) | ✅ shipped |
| #27 Haiku tags (v0.24.0, id=33; built by 0025) | ✅ deployed by 0022 |
| #1 VM rename | ✅ shipped (ops) |
| Session-ID self-allocation amendment | ✅ shipped |
---
## §19.2 candidates surfaced
1. **flotilla preflight conflates SSH/auth failure with "install dir missing."** `deploy.py:355` raises the alarming `install dir … missing (fresh-VM provisioning is out of v1)` on *any* non-zero `ssh_runner` return — including exit-255 connection/auth failures. Cost me a VM-rename rabbit hole. Fix: distinguish `SshError`/255 (connection) from a `test -d` return of 1 (genuinely missing). Bundles with the long-standing **`flotilla deploy reconcile`** gesture + **SSE pre-drain** (v1.2.0 candidate). Note: the SSE watchdog did **not** recur across deploys.id=31/32/33.
2. **No `deployment update` verb.** Updating the §13 record after the VM rename required a direct SQLite `UPDATE` (state DB backed up first). A `deployment update <name> --vm-name … --vm-install-dir …` would make §13 edits a first-class gesture instead of hand-editing state.
3. **Cosmetic rename leftovers.** The reserved address is still named `ohm-app-ip` and the DB file is still `ohm-app.db` (both only console/path labels; renaming the address needs release+recreate, the DB file needs another move — neither worth the risk now). Flagged for completeness.
---
## What lands on the operator's plate
1. **Hooks (opt-in).** Offered to wire `SessionStart`/`Stop`/`SessionEnd` into `.claude/settings.json` to automate claim + checkpoint + finalize. Caveat: unconditional `SessionStart` claim would push spurious IDs for trivial read-only sessions — gate it or accept empty-but-finalized slots. Left as documented opt-in pending your call.
2. **sessions.json title for 0022** (publish adds an empty title) — and the legacy-prefix/empty-title cleanup pass noted in earlier handoffs.
3. **§19.2 #1 above** — a flotilla v1.2.0 (preflight error-classing + deploy-reconcile + SSE pre-drain) is now well justified.
4. Standing ops: DMARC Phase B (~2026-06-04, ≥1wk clean rua), stale `wiggleverse/meta` hook, #21 Part A Amplitude audit (~2026-06-04), #22 pro-consent copy (operator-drafted + counsel).
---
## Prompt the operator can paste into the next Claude Code session
```
You are an OHM roadmap driver session, running in parallel with others.
CLAIM YOUR SESSION ID FIRST (your number is not assigned here):
~/git/ohm-infra/scripts/claim-session-id.sh --start <now, YYYY-MM-DDTHH-MM PST>
It computes the next-free NNNN from the published wiggleverse/ohm-session-history
repo, pushes a placeholder transcript (atomic race-safe claim), PRINTS the
active in-progress sessions so you can see what else is running, and writes a
local placeholder you finalize at session end. Keep a `## Plan` block at the
top of that transcript and re-publish it at each checkpoint. Subagents are
NNNN.1, NNNN.2, …. Read ~/git/ohm-infra/SESSION-PROTOCOL.md §7 + ohm-rfc/
ROADMAP.md (PULL first) once at start.
STATE (end of session 0022, 2026-05-28):
- OHM live at rfc-app v0.24.0 on the RENAMED VM `ohm-rfc-app` (instance/user/
dir /opt/ohm-rfc-app/unit ohm-rfc-app.service; static IP preserved). #1 VM
rename is DONE. Deploys are unblocked and the lock is free.
- Shipped this session: v0.22.0 (#26 propose use-case field), v0.23.0 (#29
sign-in state resume), and the deploy of v0.24.0 (#27 Haiku tags, built by
session 0025). Session-ID self-allocation protocol amendment is live.
- gcloud CLI + ADC are authed. ANTHROPIC_API_KEY is bound.
PICK ONE disjoint roadmap item and build it on a MANUAL git worktree (Agent
isolation:worktree is BROKEN — git worktree add + symlink frontend/node_modules
+ backend/.venv). Open candidates: #28 (PR cross-references — large, its own
session), #31b / #25-redesign (need operator screenshots), #20/#18 ops (DMARC
Phase B ~2026-06-04, stale meta hook, bounce wiring), #21 Part A Amplitude
audit (~2026-06-04), #22 pro-consent copy (needs operator-drafted text). A
strong infra candidate: flotilla v1.2.0 — fix the preflight that misreports an
SSH/auth failure as "install dir missing" (deploy.py:355), add a
`deployment update` verb, add `flotilla deploy reconcile` + SSE pre-drain
(§19.2 from this session).
AVOID colliding with shipped surfaces: #26's ProposeModal/PRModal/RFCView/
PRView/ProposalView + `proposed_use_cases`; #29's App.jsx sign-in/route hook +
`/api/auth/me` + lib/useLastState.js; #27's ProposeModal suggestion row +
suggest-tags endpoint; the v0.21.0 polish surfaces (tokens.css, App.css,
index.css, App.jsx header, Inbox.*, Docs*).
Hard rules: never ask for secret bytes (give the `pbpaste | flotilla secret set
ohm-rfc-app <KEY>` gesture); coordinate the serialized deploy lock + version
slot with the operator (check rfc-app VERSION on latest main, rebase, take the
next free slot); subagents write their own §5 subsession transcripts; finalize
+ publish your transcript LAST (deliver the handoff prompt before publish).
Claim your ID, then go. The session after you takes the next free ID.
```
@@ -0,0 +1,46 @@
# Session 0022.1 — Transcript
> Parent: SESSION-0022.0-TRANSCRIPT-…md
> Date: 2026-05-28
> Goal: implement roadmap #26 (optional use-case field) as rfc-app v0.22.0 feature code
> Outcome: **Shipped to branch `feature/v0.22.0-usecase-field` (commit 7c6c906). Optional `proposed_use_case` field added to propose-RFC + propose-PR surfaces end-to-end. Backend 336 passed (332 baseline + 4 new), frontend build green.**
## Pre-session state
- Worktree `/Users/benstull/git/rfc-app-v0.22.0-usecase`, branch `feature/v0.22.0-usecase-field` based on main @ 2ac20b1 (Merge feature/v0.21.0-ux-polish).
- migrations 001020 present (no 016 — gap predates this work). 021 reserved for me.
- Baseline: 332 backend tests.
## Turn-by-turn arc
1. Explored the propose surfaces. **Key finding: the roadmap's mental model doesn't match the architecture.** The roadmap names "the rfcs table" and "the PR-metadata table" for a new column, but:
- The propose-RFC "Why is this RFC needed?" field is named `pitch` (frontend `ProposeModal.jsx` + `ProposeBody.pitch` in `api.py`), serialized into the RFC entry **body** and the Gitea PR description. Truth is Gitea, not a DB row.
- The propose-PR surface (`PRModal.jsx` + `OpenPRBody` in `api_prs.py`) has a single free-text `description` field — that IS the "why is this change needed" the roadmap refers to. Also Gitea-truth.
- `cached_rfcs` / `cached_prs` (002_cache.sql) are **caches**, rebuilt from Gitea by `cache.py`'s reconciler. The write path is endpoint → Gitea → reconcile. Since `cache.py` (which I may NOT edit) doesn't carry a `proposed_use_case` field, a cache column alone would always read NULL after a propose/open.
2. **Decision:** added the nullable cache columns per the roadmap's literal shape (migration 021), AND a dedicated canonical, reconcile-proof side table `proposed_use_cases` (scope ∈ {'rfc','pr'}, keyed by pr_number, UNIQUE(scope,pr_number)) that my endpoint code writes directly and the read endpoints query. The side table is the true source read at view time; cache columns are mirrored for parity / future reconciler use. This keeps everything inside my file ownership (no `cache.py`/`db.py`/`entry.py` edits).
3. Backend wiring:
- `ProposeBody` + `OpenPRBody` gain `proposed_use_case: str | None = Field(default=None, max_length=8000)` (8000 matches the existing PR description bound; pitch is unbounded but 8000 is generous).
- `propose_rfc` (api.py ~line 700) writes the side-table row + mirrors onto `cached_prs` after `cache.refresh_meta_pulls`, only when the stripped value is non-empty (blank/whitespace == "left blank", no row).
- `open_pr` (api_prs.py, after `_refresh_after_pr_write`) does the same for scope 'pr'.
- Reads: `_proposal_use_case` helper feeds `list_proposals` + `get_proposal`; `get_rfc` looks up by slug (latest 'rfc' row, since the idea PR closes on merge); `_pr_use_case` (module fn) feeds `get_pr`.
4. Frontend: `api.js` (`proposeRFC`, `openPR` send `proposed_use_case` or null); `ProposeModal.jsx` + `PRModal.jsx` add optional textareas below the existing required fields with "(optional)" labels and field-help; `ProposalView.jsx`, `RFCView.jsx` (main view only), `PRView.jsx` render with muted italic "left blank" treatment when null. Reused existing classes (`entry-body`, `field-help`, `pr-description`) + inline component-scoped styles only — no App.css/index.css/tokens.css edits.
5. Tests: 4 new tests in `test_proposed_use_case_vertical.py` reusing FakeGitea/seed helpers. All green on first run. Full suite 336 passed.
6. Frontend build initially failed on missing `VITE_APP_NAME` (deployment overlay var, not in the worktree) — re-ran `npm run build` with `VITE_APP_NAME`/`VITE_BETA_CONTACT` set inline; built clean. No .env committed.
## Cut state
- Branch `feature/v0.22.0-usecase-field` @ **7c6c906**.
- Files changed: backend/app/api.py, backend/app/api_prs.py, frontend/src/api.js, frontend/src/components/{ProposeModal,PRModal,ProposalView,RFCView,PRView}.jsx, + NEW backend/migrations/021_proposed_use_case.sql, + NEW backend/tests/test_proposed_use_case_vertical.py.
- Migration number: 021.
- Backend: 336 passed (332 baseline + 4 new). Frontend: build green.
## What the driver needs to know
- **CHANGELOG `Upgrade steps:` proposal:** `none — additive nullable column + new side table, migration 021 auto-applies on deploy (run_migrations globs migrations/*.sql).`
- **CHANGELOG entry summary:** "#26: Optional 'What will you be using this for?' capture on the propose-RFC and propose-PR surfaces. New optional `proposed_use_case` free-text field alongside the existing required justification on both modals; persisted, returned, and displayed (with a muted 'left blank' treatment when absent). New migration 021; new canonical `proposed_use_cases` table."
- **api.py regions I edited (merge-overlap awareness vs 0022.2):**
- `ProposeBody` class (~line 49).
- `propose_rfc` endpoint — the block right after `await cache.refresh_meta_pulls(...)` / before `return {"pr_number": ...}` (~line 707).
- `list_proposals` + `get_proposal` (~lines 566640): added `_proposal_use_case` nested helper + a `proposed_use_case` key in each return.
- `get_rfc` (~line 558): changed `return _serialize_rfc(row)` into a payload + slug lookup.
- I did NOT touch `_serialize_rfc` / `_entry_payload` / `_serialize_rfc` list-builder (the catalog `list_rfcs` loop is untouched).
- **api_prs.py:** `OpenPRBody` (~line 42), `open_pr` (post-refresh block), `get_pr` return dict (added one key), new module-level `_pr_use_case` before `_pr_capabilities`.
- **Architectural note for the driver:** the roadmap's "rfcs table / PR-metadata table column" framing was reconciled against the real Gitea-truth + cache-mirror architecture by adding a dedicated `proposed_use_cases` canonical table (the durable source) plus the literal cache columns. If a future change teaches `cache.py`'s reconciler to carry the field, the cache columns are ready; until then the side table is authoritative. No `cache.py`/`db.py` edits were needed or made.
## §19.2 candidates surfaced
- none. (Possible future: teach the reconciler to round-trip propose-time metadata through a Gitea trailer so the cache columns become self-sufficient and the side table can be retired — but that's speculative and out of scope.)
@@ -0,0 +1,138 @@
# Session 0022.2 — Transcript
> Parent: SESSION-0022.0-TRANSCRIPT-…md
> Date: 2026-05-28
> Goal: implement roadmap #29 (server-side sign-in state resume) as rfc-app v0.23.0 feature code
> Outcome: **Done. Branch `feature/v0.23.0-signin-resume` @ 77010ea. Backend 336 passed (332 baseline + 4 new). Frontend build green. Migration 022 auto-applies. Profile-toggle UI deferred (column + default-on behavior ship now).**
## Pre-session state
- Forked subagent of OHM driver Session 0022.0. Isolated worktree at
`/Users/benstull/git/rfc-app-v0.23.0-signin-resume`, branch
`feature/v0.23.0-signin-resume` based on main @ 2ac20b1.
- Concurrent subagent 0022.1 owns propose/PR regions of api.py +
several frontend components + migration 021. Strict file ownership.
- Baseline backend suite: 332 passed.
## Turn-by-turn arc
1. Read SESSION-PROTOCOL.md and confirmed worktree/branch. Noted the
protocol says subagents don't write their own transcript, but the
subsession prompt explicitly requires a §5 transcript file
(unpublished) — followed the prompt.
2. Inspected migrations. `users.id` is `INTEGER PRIMARY KEY
AUTOINCREMENT` (001, rebuilt in 012). Timestamp convention is
`TEXT NOT NULL DEFAULT (datetime('now'))` (users.last_seen_at,
device_trust.last_seen_at). No native JSONB — other JSON stored as
TEXT via json.dumps/loads (permission_events.details,
actions.details). There is NO `user_sessions` table; sessions are
signed cookies (`rfc_session`), so a new `user_session_state` table
keyed on user_id was the right shape.
3. Read api.py `/me` region: auth surface uses `auth.require_user`
(401 for anon), `/api/auth/me` already does a single-round-trip
payload (beta + passcode state). db.conn() is `isolation_level=None`
(autocommit) — no explicit commit needed, matching every other
write endpoint.
4. Read auth.py `require_user` / `current_user` / SessionUser.
5. Read the frontend: Login.jsx does a hard `window.location.assign`
after sign-in, so the app re-boots and App.jsx's `getMe()` reloads
`me`. The #21-Part-C `identify` fires in an App.jsx effect when
`me.user.id` first appears. Decision: the resume redirect must live
in App.jsx (after identify), NOT in Login.jsx (which I don't own and
must not reorder).
### Decisions
- **Table:** new `user_session_state` (migration 022), one row per user
(per-user, not per-device — roadmap safe default). Columns:
`user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE`,
`last_route TEXT`, `last_route_state TEXT` (JSON-as-TEXT, nullable),
`resume_enabled INTEGER NOT NULL DEFAULT 1`,
`last_updated_at TEXT NOT NULL DEFAULT (datetime('now'))`.
- **/api/auth/me wiring choice:** folded `resume_enabled`, `last_route`,
and decoded `last_route_state` onto the EXISTING `/api/auth/me`
payload rather than a new GET — smallest/cleanest diff, the frontend
already fetches it on boot. When `resume_enabled=0`, `last_route` is
handed back as null (stored row preserved so re-enabling resumes).
- **PUT /api/me/last-state:** `require_user` (anon → 401). Upsert via
`INSERT … ON CONFLICT(user_id) DO UPDATE`. No-ops (stored=False) when
an existing row has `resume_enabled=0`.
- **Resume redirect ordering:** added `identifyReady` state in App.jsx,
flipped true at the end of the identify effect (and immediately on an
anonymous `me`). useLastState gates its one-time redirect on it →
redirect always after identify. Redirect only fires when the app
booted on `/` (the hard sign-in landing), so a deep-link/refresh
mid-RFC is never yanked.
- **Stale state:** no server special-casing. `navigate(last_route,
{replace:true})` lands on whatever renders today; existing routing
falls through to catalog/empty-state for missing/unreadable RFCs.
Non-resumable prefixes (/login, /welcome, /invites/, /invitations/)
and "/" are skipped in the hook.
- **Privacy:** route + light view state only, never draft buffers.
Documented in migration 022 column comment, LastStateBody comment,
and new SPEC §6.8.
### Deferred
- Profile-settings opt-out toggle UI. The `resume_enabled` column +
default-on behavior + the disable semantics (PUT no-op, /me hides
route) all ship now; only the client UI to flip the flag is deferred.
The vertical test flips it via direct SQL to prove the behavior.
### Test outcomes
- New `test_session_resume_vertical.py`: 4 passed (auth-required,
upsert/read-back+overwrite+state-JSON, per-user isolation,
resume_enabled=0 disable).
- Full backend suite: 336 passed (332 baseline + 4).
- Frontend `npm run build`: green (needed `VITE_APP_NAME` set for the
build — pre-existing requirement, no default shipped; used `VITE_APP_NAME=OHM`
for the build check only, not committed). Pre-existing chunk-size
warning unchanged.
## Cut state
- Branch `feature/v0.23.0-signin-resume` @ **77010ea**.
- Files changed (7, +459):
- `backend/migrations/022_user_session_state.sql` (new)
- `backend/app/api.py` (LastStateBody model; /api/auth/me payload
additions; new PUT /api/me/last-state handler)
- `backend/tests/test_session_resume_vertical.py` (new)
- `frontend/src/lib/useLastState.js` (new hook)
- `frontend/src/api.js` (putLastState client call)
- `frontend/src/App.jsx` (import + hook call + identifyReady)
- `SPEC.md` (new §6.8)
- NOTE: worktree symlinks `backend/.venv` + `frontend/node_modules` are
untracked and were NOT committed (they are not gitignored here, so
watch for them on any future `git add -A` — add specific paths).
## What the driver needs to know
- **Proposed CHANGELOG `Upgrade steps:` text:**
> Migration `022_user_session_state.sql` auto-applies on next boot
> (creates the per-user `user_session_state` table). No new env vars.
> No operator action required. New behavior: signing in now lands the
> user on their most-recently-viewed route instead of the empty-state
> home; a brand-new user (no recorded state) still lands on home.
> Resume is per-user and on by default; the stored state is route +
> light view state only (never draft contents — see SPEC §6.8). The
> per-user opt-out flag ships at the column level (`resume_enabled`);
> the profile-settings toggle UI to flip it is a follow-up.
- **api.py regions edited (merge-overlap awareness):** all additions are
in the `/me`/auth region — (1) a new `LastStateBody` Pydantic model
inserted just above `class BetaRequestBody` (~line 65); (2) extra
fields appended to the `auth_me` return dict (~line 350, the existing
`/api/auth/me` handler); (3) a new `put_last_state` handler inserted
immediately AFTER `submit_beta_request`'s `return {"ok": True}`
(~line 435). All far from the propose/PR endpoints 0022.1 edits — 3-way
merge should be clean.
- **App.jsx regions edited:** import line (added line ~5); a new
`identifyReady` useState in the state block (~line 50); a
`setIdentifyReady(true)` line inside the existing identify effect +
a tiny new effect right after it (~line 100); the `useLastState({...})`
call right after the `getMe()` effect (~line 130). **Header region
(lines ~220235, btn-signin-header / nav Links / <header>) was NOT
touched** — Session 0019 fast-follow safe.
- **Deferred:** profile opt-out toggle UI (see above).
## §19.2 candidates surfaced
- The frontend debounce interval (~1s) and the NON_RESUMABLE_PREFIXES
list in useLastState.js are hard-coded frontend constants; if other
deployments want them configurable that's a candidate, but it's not
flotilla-shaped (no OHM-specific value).
- A generic "remember per-user view state" surface could share substrate
with the device-trust `last_seen_at` refresh pattern; if a future
flotilla-core extraction wants a session/state module, this table is a
natural member alongside device_trust (already flagged §19.2 in 017).