diff --git a/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
new file mode 100644
index 0000000..1b24d64
--- /dev/null
+++ b/.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md
@@ -0,0 +1,702 @@
+# Phase 07.3: Security — ES256 Algorithm Upgrade - Research
+
+**Researched:** 2026-06-05
+**Domain:** JWT cryptographic algorithm upgrade, ECDSA P-256, FastAPI lifespan startup hooks, Vue 3 login form
+**Confidence:** HIGH
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+**D-01:** Both `JWT_PRIVATE_KEY` and `JWT_PUBLIC_KEY` are **required** env vars. Both store the PEM key **base64-encoded** as a single line — shell-safe, no newline-escape complications. `config.py` decodes each value before passing to PyJWT.
+
+**D-02:** `create_access_token` and `create_password_reset_token` sign with the private key using `algorithm="ES256"`. `decode_access_token` and `decode_password_reset_token` verify with the public key using `algorithms=["ES256"]`.
+
+**D-03:** `settings.secret_key` is **removed from all JWT code** after ES256 is in place. It is no longer used for JWT signing. The `SECRET_KEY` env var stays in docker-compose for any future non-JWT HMAC needs (Phase 7.4 fingerprinting), but `services/auth.py` must not reference it.
+
+**D-04:** On FastAPI `lifespan` startup, compare the `jwt_algorithm` key in the `system_settings` table (or its absence) against the current algorithm `"ES256"`. If the stored value differs (e.g., was `"HS256"` or is missing), bulk-revoke all `RefreshToken` rows by executing `UPDATE refresh_tokens SET revoked = true WHERE revoked = false`, then upsert `jwt_algorithm = "ES256"` into `system_settings`. This is idempotent — safe to restart.
+
+**D-05:** Use the existing `system_settings` upsert pattern from `backend/services/ai_config.py` (`seed_system_settings_from_env`) as the reference for reading/writing system settings in the startup hook.
+
+**D-06:** Document key generation as a **Python one-liner** in `README.md` using the `cryptography` library (already a project dependency). The snippet prints both base64-encoded keys ready to paste into `.env`. No extra tooling required.
+
+**D-07:** `docker-compose.yml` references `${JWT_PRIVATE_KEY}` and `${JWT_PUBLIC_KEY}` from the `.env` file — same pattern as the existing `${SECRET_KEY}`. No placeholder values in the repo.
+
+**D-08:** Access token TTL: **15 minutes** — unchanged and non-negotiable (CLAUDE.md).
+
+**D-09:** Default refresh token TTL: **16 hours** (new config setting `refresh_token_expire_hours: int = 16`).
+
+**D-10:** "Remember me" opt-in: **30 days** (existing `refresh_token_expire_days: int = 30` kept as-is).
+
+**D-11:** `POST /api/auth/login` request body gains `remember_me: bool = False`. `create_refresh_token` gains `remember_me: bool = False` param — selects between `timedelta(hours=settings.refresh_token_expire_hours)` and `timedelta(days=settings.refresh_token_expire_days)`.
+
+**D-12:** `LoginView.vue` gains a "Stay signed in for 30 days" checkbox that passes `remember_me` to `authStore.login()`. The store passes it through to the API call.
+
+### Claude's Discretion
+
+None specified.
+
+### Deferred Ideas (OUT OF SCOPE)
+
+- **Phase 7.4 — Token fingerprinting / token binding**: Add `fgp` (fingerprint) claim = `hmac(key, User-Agent + Accept-Language)[:16]`.
+- **Key rotation ceremony**: Dual-key overlap period for production P-256 key rotation.
+
+
+---
+
+## Project Constraints (from CLAUDE.md)
+
+| Directive | Impact on Phase 7.3 |
+|-----------|---------------------|
+| JWT access token in Pinia memory only — never localStorage | No change; access token delivery pattern unchanged |
+| Access token TTL: 15 minutes maximum (non-negotiable) | D-08 locked; `access_token_expire_minutes` stays at 15 |
+| Refresh token: httpOnly Strict cookie; rotated on every use | Cookie-setting helpers must pass `remember_me` through |
+| Admin endpoints never return `password_hash`, `credentials_enc`, or document content | Not directly relevant; no new admin endpoints |
+| No raw string interpolation in DB queries | Bulk revoke uses `text("UPDATE …")` — parameterized; no user input injected |
+| Service layer raises `ValueError`, never `HTTPException` | ES256 config validation in `config.py` validates before service layer, not inside it |
+| No function in `services/auth.py` imports `HTTPException` | ES256 key decode failures must raise `ValueError` or let startup crash with a descriptive message |
+| Existing test gate: `pytest -v` zero failures before phase advances | `test_settings_has_jwt_config` asserts `refresh_token_expire_days == 30` — this passes since we keep that field; new `refresh_token_expire_hours` assertion needed |
+
+---
+
+## Summary
+
+Phase 7.3 replaces HS256 with ES256 (ECDSA P-256) across the four JWT signing/verification sites in `backend/services/auth.py`, adds a first-boot startup hook to bulk-revoke all active refresh tokens whenever the algorithm changes, and introduces a "remember me" opt-in that changes the default refresh token TTL from 30 days to 16 hours.
+
+**The algorithmic change is surgical.** PyJWT 2.13.0 (installed and verified on this system) supports ES256 natively via `jwt.algorithms.ECAlgorithm`. The `cryptography` library (already a project dependency, `>=41.0.0`) provides the P-256 key generation primitives. No new packages are required. The upgrade touches 4 lines in `services/auth.py`, 3 settings in `config.py`, 2 lines in `docker-compose.yml`, and one `README.md` key-generation snippet.
+
+**The startup token rotation is idempotent.** It uses the `system_settings` table's `provider_id` uniqueness to track the deployed algorithm (`provider_id = "jwt_algorithm"`, `model_name = "ES256"`). On every boot, the lifespan reads this value; if it differs from `"ES256"`, it bulk-revokes all active refresh tokens and writes the new value. Subsequent restarts skip the revocation. The bulk revoke uses a single raw `UPDATE refresh_tokens SET revoked = true WHERE revoked = false` via `session.execute(text(...))` — no Python-layer iteration, consistent with the project pattern for bulk operations.
+
+**The "remember me" change is a controlled TTL expansion.** The default session drops from 30 days to 16 hours (`refresh_token_expire_hours: int = 16` added to config). An opt-in `remember_me: bool = False` field on the `LoginRequest` Pydantic model controls which TTL path `create_refresh_token` takes. Both the cookie `max_age` and the `expires_at` DB column must be updated consistently.
+
+**Primary recommendation:** Implement in three independent but ordered waves: (1) ES256 key infrastructure + 4 signing sites, (2) startup rotation check, (3) remember-me TTL + frontend checkbox. Each wave is independently testable.
+
+---
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| JWT signing key pair storage | Backend config (env vars) | — | Private key must never leave the backend; env var is the standard secret injection pattern |
+| JWT encoding (ES256) | Backend service (`services/auth.py`) | — | All token creation is encapsulated in the service layer per CLAUDE.md rules |
+| JWT decoding / verification | Backend deps (`deps/auth.py` → `services/auth.py`) | — | `get_current_user` dependency calls `decode_access_token` from service layer |
+| Startup token rotation | Backend lifespan (`main.py`) | DB (`system_settings`) | Lifespan owns startup tasks; DB tracks idempotency state |
+| Refresh token TTL selection | Backend service (`services/auth.py:create_refresh_token`) | Backend API (`api/auth.py:login`) | Service makes TTL decision; API passes the flag through |
+| Cookie max_age alignment | Backend API (`api/auth.py:_set_refresh_cookie`) | — | Cookie must mirror DB `expires_at` TTL exactly |
+| "Stay signed in" checkbox UI | Frontend view (`LoginView.vue`) | Frontend store (`stores/auth.js`) | View owns form state; store owns API call forwarding |
+
+---
+
+## Standard Stack
+
+### Core (already installed — no new packages)
+
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| PyJWT | `>=2.8.0` (2.13.0 installed) | JWT encode/decode with ES256 | Official Python JWT library; `ECAlgorithm` class confirmed present; ES256 encode/decode verified working on this system [VERIFIED: pip show PyJWT] |
+| cryptography | `>=41.0.0` | P-256 key generation and PEM serialization | Already project dependency for HKDF/Fernet; `ec.generate_private_key(ec.SECP256R1())` and `serialization.Encoding.PEM` APIs verified working on this system [VERIFIED: runtime test] |
+
+### No New Packages Required
+
+All cryptographic primitives for ES256 are covered by the two libraries above, both already pinned in `requirements.txt`. No additional installs needed for this phase.
+
+**Version verification:**
+```bash
+pip show PyJWT # → 2.13.0
+pip show cryptography # → already installed (41.x or higher)
+```
+
+---
+
+## Package Legitimacy Audit
+
+No new packages are introduced in this phase. All cryptographic work uses `PyJWT` (already pinned) and `cryptography` (already pinned). Slopcheck is not required.
+
+| Package | Status | Notes |
+|---------|--------|-------|
+| PyJWT | Already in requirements.txt — no change | ES256 support confirmed [VERIFIED: runtime test] |
+| cryptography | Already in requirements.txt — no change | P-256 key generation confirmed [VERIFIED: runtime test] |
+
+**Packages removed due to slopcheck verdict:** none
+**Packages flagged as suspicious:** none
+
+---
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+.env file
+ JWT_PRIVATE_KEY (base64 PEM)
+ JWT_PUBLIC_KEY (base64 PEM)
+ │
+ ▼
+config.py (Settings)
+ jwt_private_key: str → base64.b64decode().decode() → PEM string
+ jwt_public_key: str → base64.b64decode().decode() → PEM string
+ refresh_token_expire_hours: int = 16
+ refresh_token_expire_days: int = 30
+ │
+ ├──► services/auth.py
+ │ create_access_token() jwt.encode(payload, private_pem, algorithm="ES256")
+ │ decode_access_token() jwt.decode(token, public_pem, algorithms=["ES256"])
+ │ create_password_reset_token() jwt.encode(payload, private_pem, algorithm="ES256")
+ │ decode_password_reset_token() jwt.decode(token, public_pem, algorithms=["ES256"])
+ │ create_refresh_token(remember_me=False)
+ │ TTL = timedelta(hours=16) if not remember_me
+ │ = timedelta(days=30) if remember_me
+ │
+ ├──► main.py lifespan (startup)
+ │ ES256 rotation check:
+ │ SELECT model_name FROM system_settings WHERE provider_id = 'jwt_algorithm'
+ │ if missing or != 'ES256':
+ │ UPDATE refresh_tokens SET revoked = true WHERE revoked = false
+ │ UPSERT system_settings (provider_id='jwt_algorithm', model_name='ES256')
+ │
+ └──► api/auth.py
+ LoginRequest.remember_me: bool = False
+ _set_refresh_cookie(response, raw_token, remember_me)
+ max_age = 30*86400 if remember_me else 16*3600
+
+Frontend:
+ LoginView.vue
+ [ ] Stay signed in for 30 days ← new checkbox
+ submitPassword() / submitTotp() / submitBackupCode()
+ → authStore.login(email, password, { ..., rememberMe: bool })
+
+ stores/auth.js
+ login(email, password, options)
+ api.login({ ..., remember_me: options.rememberMe ?? false })
+```
+
+### Recommended Project Structure
+
+No new directories. Changes are concentrated in:
+```
+backend/
+├── config.py # +jwt_private_key, +jwt_public_key, +refresh_token_expire_hours
+├── services/auth.py # 4 jwt.encode/decode sites; create_refresh_token TTL
+├── main.py # lifespan: +ES256 rotation block
+├── api/auth.py # LoginRequest +remember_me; _set_refresh_cookie +remember_me
+frontend/
+├── src/views/auth/LoginView.vue # +checkbox, +rememberMe ref, pass through to store
+└── src/stores/auth.js # login() +rememberMe param pass-through
+```
+
+### Pattern 1: ES256 Key Decode in config.py
+
+```python
+# Source: verified via runtime test + PyJWT docs
+import base64
+
+class Settings(BaseSettings):
+ # ... existing fields ...
+ jwt_private_key: str = "" # base64-encoded PEM — required in production
+ jwt_public_key: str = "" # base64-encoded PEM — required in production
+ refresh_token_expire_hours: int = 16 # default short session (D-09)
+ # refresh_token_expire_days: int = 30 already exists — keep unchanged
+
+ def get_jwt_private_key_pem(self) -> str:
+ """Decode base64-encoded private key PEM string for PyJWT."""
+ return base64.b64decode(self.jwt_private_key).decode()
+
+ def get_jwt_public_key_pem(self) -> str:
+ """Decode base64-encoded public key PEM string for PyJWT."""
+ return base64.b64decode(self.jwt_public_key).decode()
+```
+
+**Alternative (simpler, no property):** Decode inline at call site in `services/auth.py`:
+```python
+import base64
+private_pem = base64.b64decode(settings.jwt_private_key).decode()
+public_pem = base64.b64decode(settings.jwt_public_key).decode()
+```
+
+Either approach is acceptable. The inline pattern avoids adding methods to Settings and is consistent with how other base64 fields (like cloud credentials) are handled in this codebase.
+
+### Pattern 2: ES256 JWT Signing/Verification (4 sites)
+
+```python
+# Source: verified via runtime test — PyJWT 2.13.0 + cryptography 41.x
+import base64, jwt
+from config import settings
+
+# BEFORE (HS256):
+# return jwt.encode(payload, settings.secret_key, algorithm="HS256")
+# payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
+
+# AFTER (ES256):
+def create_access_token(user_id: str, role: str) -> str:
+ # ... payload construction unchanged ...
+ private_pem = base64.b64decode(settings.jwt_private_key).decode()
+ return jwt.encode(payload, private_pem, algorithm="ES256")
+
+def decode_access_token(token: str) -> dict:
+ public_pem = base64.b64decode(settings.jwt_public_key).decode()
+ try:
+ payload = jwt.decode(token, public_pem, algorithms=["ES256"])
+ except jwt.ExpiredSignatureError as exc:
+ raise ValueError("Token has expired") from exc
+ except jwt.PyJWTError as exc:
+ raise ValueError(f"Invalid token: {exc}") from exc
+ # ... type check unchanged ...
+ return payload
+```
+
+The same pattern applies identically to `create_password_reset_token` (sign) and `decode_password_reset_token` (verify).
+
+### Pattern 3: Startup Token Rotation (lifespan hook)
+
+```python
+# Source: D-04/D-05 from CONTEXT.md; modeled after seed_system_settings_from_env pattern
+from sqlalchemy import text, select
+from db.models import SystemSettings
+
+async def _rotate_tokens_on_algorithm_change(session: AsyncSession) -> None:
+ """Idempotent ES256 migration: bulk-revoke tokens if algorithm changed."""
+ stmt = select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
+ result = await session.execute(stmt)
+ row = result.scalar_one_or_none()
+
+ current_alg = row.model_name if row is not None else None
+ if current_alg == "ES256":
+ return # Already migrated — skip
+
+ # Algorithm changed or first boot: bulk-revoke all active refresh tokens
+ await session.execute(
+ text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")
+ )
+
+ # Upsert the algorithm marker
+ if row is None:
+ import uuid as _uuid
+ session.add(SystemSettings(
+ id=_uuid.uuid4(),
+ provider_id="jwt_algorithm",
+ model_name="ES256",
+ context_chars=0,
+ is_active=False,
+ api_key_enc=None,
+ base_url=None,
+ ))
+ else:
+ row.model_name = "ES256"
+
+ await session.commit()
+```
+
+This function slots into `main.py:lifespan` after the AI seed block, wrapped in the same `try/except` pattern (so a missing `system_settings` table before migrations doesn't crash startup).
+
+### Pattern 4: remember_me TTL Selection
+
+```python
+# Source: D-09/D-10/D-11 from CONTEXT.md
+
+async def create_refresh_token(
+ session: AsyncSession,
+ user_id: uuid.UUID,
+ remember_me: bool = False, # NEW PARAM
+) -> str:
+ raw = secrets.token_urlsafe(32)
+ token_hash = hashlib.sha256(raw.encode()).hexdigest()
+ now = datetime.now(timezone.utc)
+ # Select TTL based on remember_me
+ if remember_me:
+ ttl = timedelta(days=settings.refresh_token_expire_days) # 30 days
+ else:
+ ttl = timedelta(hours=settings.refresh_token_expire_hours) # 16 hours
+ row = RefreshToken(
+ id=uuid.uuid4(),
+ user_id=user_id,
+ token_hash=token_hash,
+ expires_at=now + ttl,
+ revoked=False,
+ )
+ session.add(row)
+ await session.commit()
+ return raw
+```
+
+The `_set_refresh_cookie` helper also needs a `remember_me` param to set `max_age` consistently:
+```python
+def _set_refresh_cookie(response: Response, raw_token: str, remember_me: bool = False) -> None:
+ max_age = (
+ settings.refresh_token_expire_days * 86400
+ if remember_me
+ else settings.refresh_token_expire_hours * 3600
+ )
+ response.set_cookie(
+ key="refresh_token",
+ value=raw_token,
+ httponly=True,
+ secure=True,
+ samesite="strict",
+ path="/api/auth/refresh",
+ max_age=max_age,
+ )
+```
+
+### Pattern 5: Key Generation One-Liner (for README.md)
+
+```python
+# Source: verified via runtime test — cryptography library
+python3 -c "
+from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.hazmat.primitives import serialization
+import base64
+k = ec.generate_private_key(ec.SECP256R1())
+priv = base64.b64encode(k.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())).decode()
+pub = base64.b64encode(k.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)).decode()
+print(f'JWT_PRIVATE_KEY={priv}')
+print(f'JWT_PUBLIC_KEY={pub}')
+"
+```
+
+This outputs two single-line values ready to paste into `.env`. The `cryptography` library is already a project dependency so no install is needed.
+
+### Anti-Patterns to Avoid
+
+- **Caching decoded PEM strings as module-level globals:** If the env var changes between tests or deployments and the module was already imported, stale keys would be used. Decode inline at each call site or in a `@cached_property` that is tied to the Settings instance.
+- **Passing `settings.secret_key` to any JWT function after ES256 is deployed:** D-03 explicitly removes this coupling. If any site still uses `secret_key`, tests will catch it (HS256 tokens rejected by ES256 verifier).
+- **Calling `create_refresh_token` in `rotate_refresh_token` without forwarding `remember_me`:** `rotate_refresh_token` calls `create_refresh_token(session, row.user_id)` internally. This internal call should NOT receive `remember_me` — token rotation preserves the session type from the original login. The new `remember_me` param defaults to `False`, so the rotation call is unchanged; a rotated token always gets the default 16-hour TTL (which is acceptable security behavior — the client gets a fresh cookie with the new token).
+- **Inserting a `jwt_algorithm` row into `system_settings` with `is_active=True`:** This would make the AI provider loader (`load_provider_config`) potentially attempt to use "jwt_algorithm" as a provider. Always insert with `is_active=False`.
+- **Using `model_name` for arbitrary key-value storage without documenting the overloading:** The `SystemSettings.model_name` column is `NOT NULL TEXT` — it can hold "ES256" safely. Document in the startup function's docstring that this is a metadata row, not an AI provider row.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| ECDSA key generation | Custom byte manipulation | `ec.generate_private_key(ec.SECP256R1())` from `cryptography` | cryptography handles FIPS-compliant randomness, correct curve encoding, and PEM serialization |
+| ES256 JWT signing | Manual ECDSA signature + base64url encode | `jwt.encode(payload, pem, algorithm="ES256")` from PyJWT | PyJWT handles all RFC 7518 compliance, header formatting, and signature encoding |
+| ES256 JWT verification | Manual signature extraction + ECDSA verify | `jwt.decode(token, pem, algorithms=["ES256"])` from PyJWT | PyJWT validates expiry, algorithm, and signature in one call; algorithm-restriction list prevents downgrade attacks |
+| Bulk token revocation | Python loop loading all RefreshToken rows | `session.execute(text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false"))` | Single SQL statement vs. N Python-layer round trips; consistent with project bulk-operation pattern |
+
+**Key insight:** The `cryptography` + `PyJWT` combination handles all ECDSA P-256 complexity. There is no case in this phase where custom cryptographic code is appropriate.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: PEM Newlines in Environment Variables
+**What goes wrong:** A raw PEM string contains newlines. Shell `.env` files and Docker Compose do not handle multi-line values well — the variable gets truncated at the first newline.
+**Why it happens:** PEM format is inherently multi-line. Naive `JWT_PRIVATE_KEY=$(cat private.pem)` breaks.
+**How to avoid:** Store as base64-encoded single line (D-01). `base64.b64encode(pem_bytes).decode()` produces a single-line string with no newlines (standard base64 with no padding issues for a ~PKCS8 key). Decode at use with `base64.b64decode(settings.jwt_private_key).decode()`.
+**Warning signs:** `binascii.Error: Invalid base64-encoded string` or `jwt.exceptions.DecodeError: Invalid header string` at startup.
+
+### Pitfall 2: Algorithm Confusion / Downgrade Attack During Transition
+**What goes wrong:** Old clients hold HS256 tokens. After ES256 deployment, `decode_access_token` now calls `jwt.decode(token, pub_pem, algorithms=["ES256"])`. An HS256 token presented to the ES256 verifier raises `InvalidAlgorithmError` which maps to 401 — correct behavior.
+**Why it happens:** Clients with a valid HS256 access token (up to 15 min old) will get 401 until their token expires. This is expected and correct — they will auto-refresh via the httpOnly cookie, which triggers the rotation. The startup hook bulk-revokes all refresh tokens, forcing re-login.
+**How to avoid:** Accept this brief disruption (max 15 minutes for any in-flight session). Document in README.md. No dual-algorithm support is needed since the bulk-revoke forces re-login on first boot.
+**Warning signs:** Users report "logged out" immediately after deploy — this is expected.
+
+### Pitfall 3: SystemSettings Model Incompatibility for jwt_algorithm Row
+**What goes wrong:** `SystemSettings.model_name` is `NOT NULL` with `default=""`. `context_chars` is `NOT NULL` with `default=8000`. Inserting a `jwt_algorithm` marker row requires satisfying these constraints without meaningful values.
+**Why it happens:** The table was designed for AI provider config, not generic key-value storage.
+**How to avoid:** Insert with `model_name="ES256"`, `context_chars=0`, `is_active=False`, `api_key_enc=None`, `base_url=None`. This satisfies all NOT NULL constraints. Use `is_active=False` so the AI provider loader (`load_provider_config`) never returns this row. Verified against the model definition at `db/models.py` line 340.
+**Warning signs:** `sqlalchemy.exc.IntegrityError` on NOT NULL violation if `model_name` is omitted.
+
+### Pitfall 4: _set_refresh_cookie Called in Refresh Endpoint Without remember_me
+**What goes wrong:** `_set_refresh_cookie` is called in both the `login` endpoint (new `remember_me` param) and the `refresh_token` endpoint (token rotation, no `remember_me`). If the refresh endpoint passes `remember_me=False` (default), rotating a 30-day session silently downgrades it to 16 hours.
+**Why it happens:** Token rotation in `rotate_refresh_token` doesn't know what TTL the original token had — `expires_at` is on the old row, which is being revoked.
+**How to avoid:** For the rotation endpoint, preserve the original cookie's `max_age` by reading the remaining TTL from the new token's `expires_at` and computing `max_age = int((row.expires_at - now).total_seconds())` on the newly-created row. Alternatively, accept that rotated sessions default to 16 hours (acceptable since the user can re-login with remember_me). The CONTEXT.md does not require preservation of remember_me state across rotation, so the simpler approach (default TTL on rotation) is acceptable.
+**Warning signs:** Users who selected "remember me" get logged out after 16 hours despite the checkbox.
+
+> **Decision required by planner:** Either preserve the TTL on rotation (complex — needs to pass remember_me through rotate_refresh_token) or accept 16-hour default on rotation (simple and acceptable). The CONTEXT.md is silent on this. The simpler approach is recommended for this phase.
+
+### Pitfall 5: Existing Test Asserts on Settings Value
+**What goes wrong:** `backend/tests/test_task1_models_config.py:31` asserts `settings.refresh_token_expire_days == 30`. Adding `refresh_token_expire_hours` does not break this assertion since the field value is unchanged.
+**Why it happens:** Phase 2 locked the TTL value in a test. Phase 7.3 adds a new field but doesn't change the old one.
+**How to avoid:** The test continues to pass. A new test should assert `settings.refresh_token_expire_hours == 16` and that `refresh_token_expire_days` remains 30.
+**Warning signs:** Test file `test_task1_models_config.py` — check that no assertion fails after adding `refresh_token_expire_hours`.
+
+### Pitfall 6: Lifespan Startup Order — System Settings Table May Not Exist
+**What goes wrong:** The startup rotation check queries `system_settings`. On a fresh container before migrations run, the table doesn't exist and the query raises.
+**Why it happens:** Docker healthchecks ensure DB is ready, but the migration step may not have run yet (developer workflow).
+**How to avoid:** Wrap the ES256 rotation block in the same `try/except Exception` pattern already used for `seed_system_settings_from_env` in `main.py:lifespan` (lines 172–180). Log a warning and skip if the table is missing. On the next boot (after migrations), the check runs successfully.
+**Warning signs:** `sqlalchemy.exc.ProgrammingError: relation "system_settings" does not exist` at startup.
+
+---
+
+## Code Examples
+
+Verified patterns from official sources:
+
+### Full ES256 Signing Chain (verified at research time)
+```python
+# Source: verified via runtime test on this system (PyJWT 2.13.0 + cryptography 41.x)
+import base64, jwt, uuid, datetime as dt
+
+private_pem = base64.b64decode(settings.jwt_private_key).decode()
+public_pem = base64.b64decode(settings.jwt_public_key).decode()
+
+# Sign
+now = dt.datetime.now(dt.timezone.utc)
+payload = {
+ "sub": str(user_id),
+ "role": role,
+ "typ": "access",
+ "jti": str(uuid.uuid4()), # Phase 7.2 adds this claim; preserved in ES256
+ "iat": now,
+ "exp": now + timedelta(minutes=settings.access_token_expire_minutes),
+}
+token = jwt.encode(payload, private_pem, algorithm="ES256")
+
+# Verify
+decoded = jwt.decode(token, public_pem, algorithms=["ES256"])
+# algorithms=["ES256"] is a whitelist — HS256 tokens raise InvalidAlgorithmError (verified)
+```
+
+### Bulk Revoke + System Settings Upsert (startup)
+```python
+# Source: D-04/D-05 from CONTEXT.md; SQLAlchemy text() pattern from codebase
+from sqlalchemy import text, select
+from db.models import SystemSettings
+
+async with AsyncSessionLocal() as session:
+ result = await session.execute(
+ select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
+ )
+ row = result.scalar_one_or_none()
+
+ if row is None or row.model_name != "ES256":
+ await session.execute(
+ text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")
+ )
+ if row is None:
+ session.add(SystemSettings(
+ id=uuid.uuid4(),
+ provider_id="jwt_algorithm",
+ model_name="ES256",
+ context_chars=0,
+ is_active=False,
+ ))
+ else:
+ row.model_name = "ES256"
+ await session.commit()
+```
+
+### LoginRequest with remember_me (backend)
+```python
+# Source: CONTEXT.md D-11; modeled on existing LoginRequest pattern
+class LoginRequest(BaseModel):
+ email: EmailStr
+ password: str
+ totp_code: Optional[str] = None
+ backup_code: Optional[str] = None
+ remember_me: bool = False # NEW: opt-in to 30-day session
+```
+
+### Login form checkbox (Vue 3 Options API → script setup)
+```vue
+
+
+
+
+
+
+```
+
+```javascript
+// In script setup, add:
+const rememberMe = ref(false)
+
+// Update submitPassword():
+const result = await authStore.login(email.value, password.value, {
+ rememberMe: rememberMe.value,
+})
+```
+
+### Auth store login() update
+```javascript
+// Source: CONTEXT.md D-12; extends existing stores/auth.js login() action
+async function login(email, password, options = {}) {
+ // ...
+ const data = await api.login({
+ email,
+ password,
+ totp_code: options.totpCode ?? null,
+ backup_code: options.backupCode ?? null,
+ remember_me: options.rememberMe ?? false, // NEW
+ })
+ // ... rest unchanged
+}
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| HS256 (symmetric HMAC) | ES256 (ECDSA P-256) asymmetric | This phase | Public key cannot forge tokens; leaked public key (safe to distribute) cannot impersonate users |
+| 30-day default refresh TTL | 16-hour default; 30-day opt-in | This phase | Sessions expire overnight by default; lower risk for unattended sessions |
+| `algorithms=["HS256"]` in decode | `algorithms=["ES256"]` whitelist | This phase | PyJWT whitelist prevents algorithm confusion attacks; HS256 tokens immediately rejected |
+
+**Deprecated/outdated:**
+- `settings.secret_key` as JWT signing key: After this phase, `services/auth.py` no longer references `secret_key` for JWT operations. The field stays in config for Phase 7.4 HMAC fingerprinting use.
+
+---
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | The `model_name` field of `SystemSettings` being repurposed as a value store for `jwt_algorithm` will not trigger unexpected behavior in `load_provider_config()` since `is_active=False` prevents selection | Startup rotation | Low — verified that `load_provider_config` filters by `is_active IS TRUE`; `is_active=False` row is never returned as active provider |
+| A2 | `rotate_refresh_token()` (called for normal token rotation, not the startup bulk-revoke) calls `create_refresh_token(session, row.user_id)` without `remember_me` — new default TTL (16h) will apply on every rotation | Pitfall 4 | Low risk for security (shorter is better); risk for UX (remember-me users get 16h after first rotation). Accepted per Pitfall 4 note. |
+| A3 | Phase 7.2 adds a `jti` claim to access tokens. Since Phase 7.3 depends on Phase 7.2, the `create_access_token` payload will already contain `jti` when Phase 7.3 is implemented. The ES256 upgrade must preserve the `jti` field. | Code Examples | Low — verified: PyJWT preserves all custom claims through ES256 encode/decode. |
+
+**If this table is empty:** All claims in this research were verified or cited. — Not applicable; three low-risk assumptions documented above.
+
+---
+
+## Open Questions
+
+1. **TTL preservation across refresh token rotation**
+ - What we know: `rotate_refresh_token` calls `create_refresh_token` internally with no `remember_me` param (default=False → 16 hours)
+ - What's unclear: Should a user who selected "stay signed in 30 days" get a new 30-day token on each rotation, or is 16-hour degradation acceptable?
+ - Recommendation: Accept 16-hour degradation on rotation for Phase 7.3 (simpler implementation). If users report confusion, Phase 7.4 or a follow-up can add TTL preservation by reading original `expires_at` or adding a `remember_me` column to `RefreshToken`.
+
+2. **Test `test_settings_has_jwt_config` assertion on `refresh_token_expire_days == 30`**
+ - What we know: The test at `backend/tests/test_task1_models_config.py:31` asserts the existing field equals 30. This field is NOT changed.
+ - What's unclear: Nothing — this test continues to pass. A new test should assert `refresh_token_expire_hours == 16`.
+ - Recommendation: Add assertion for `refresh_token_expire_hours` in the same test function or a new one.
+
+---
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| PyJWT | ES256 encoding/decoding | Yes | 2.13.0 | — |
+| cryptography | P-256 key generation, PEM serialization | Yes | (>=41.0.0 pinned) | — |
+| Python 3.12 | All backend code | Yes | 3.12 | — |
+| PostgreSQL | `system_settings` table for algorithm tracking | Yes (Docker Compose) | 17 | — |
+
+**Missing dependencies with no fallback:** None.
+**Missing dependencies with fallback:** None.
+
+---
+
+## Validation Architecture
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | pytest + pytest-asyncio |
+| Config file | `backend/pytest.ini` or `pyproject.toml` |
+| Quick run command | `cd backend && pytest tests/test_auth_api.py tests/test_task1_models_config.py -x -v` |
+| Full suite command | `cd backend && pytest -v` |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| ES256-01 | `create_access_token` uses ES256 algorithm | unit | `pytest tests/test_auth_es256.py::test_access_token_uses_es256 -x` | ❌ Wave 0 |
+| ES256-02 | `decode_access_token` rejects HS256 tokens | unit | `pytest tests/test_auth_es256.py::test_hs256_token_rejected -x` | ❌ Wave 0 |
+| ES256-03 | `create_password_reset_token` uses ES256 | unit | `pytest tests/test_auth_es256.py::test_reset_token_uses_es256 -x` | ❌ Wave 0 |
+| ES256-04 | Startup rotation bulk-revokes on algorithm change | integration | `pytest tests/test_auth_es256.py::test_startup_rotation_revokes_tokens -x` | ❌ Wave 0 |
+| ES256-05 | Startup rotation is idempotent (second boot skips) | integration | `pytest tests/test_auth_es256.py::test_startup_rotation_idempotent -x` | ❌ Wave 0 |
+| RM-01 | Default login issues 16-hour refresh token | integration | `pytest tests/test_auth_es256.py::test_default_ttl_16_hours -x` | ❌ Wave 0 |
+| RM-02 | remember_me=True issues 30-day refresh token | integration | `pytest tests/test_auth_es256.py::test_remember_me_ttl_30_days -x` | ❌ Wave 0 |
+| RM-03 | remember_me=True sets cookie max_age=30*86400 | integration | `pytest tests/test_auth_es256.py::test_remember_me_cookie_max_age -x` | ❌ Wave 0 |
+| CFG-01 | Settings has `jwt_private_key`, `jwt_public_key`, `refresh_token_expire_hours` | unit | `pytest tests/test_task1_models_config.py::test_settings_has_jwt_config -x` | ✅ (needs extension) |
+
+### Sampling Rate
+
+- **Per task commit:** `cd backend && pytest tests/test_auth_es256.py tests/test_task1_models_config.py -x -v`
+- **Per wave merge:** `cd backend && pytest -v`
+- **Phase gate:** Full suite green before `/gsd:verify-work`
+
+### Wave 0 Gaps
+
+- [ ] `backend/tests/test_auth_es256.py` — covers ES256-01 through RM-03 (8 new tests)
+- [ ] Extend `backend/tests/test_task1_models_config.py::test_settings_has_jwt_config` to assert `refresh_token_expire_hours == 16`
+
+---
+
+## Security Domain
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes | ES256 asymmetric tokens; `algorithms=["ES256"]` whitelist prevents downgrade |
+| V3 Session Management | yes | Refresh token TTL split (16h default / 30d opt-in); bulk-revoke on algorithm change |
+| V4 Access Control | no | No new access control logic |
+| V5 Input Validation | yes | `remember_me: bool = False` typed Pydantic field; `base64.b64decode` validates key format at startup |
+| V6 Cryptography | yes | ECDSA P-256 (256-bit EC key) via `cryptography` library; no hand-rolled crypto |
+
+### Known Threat Patterns for JWT + ES256
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Algorithm confusion: HS256 token presented to ES256 verifier | Spoofing | `algorithms=["ES256"]` whitelist in `jwt.decode()` — raises `InvalidAlgorithmError` (verified) |
+| None algorithm attack | Spoofing | PyJWT explicitly requires algorithms list; `algorithms=["ES256"]` prevents `alg=none` |
+| Leaked public key token forgery | Spoofing | ES256 asymmetric design: public key cannot sign — forgery requires the private key |
+| Private key exposure in env | Elevation of Privilege | Base64 single-line in `.env`; `.gitignore` enforces exclusion; no defaults in code (D-07) |
+| Stale HS256 tokens after upgrade | Spoofing | Bulk refresh token revocation forces re-login; 15-min access token TTL self-expires |
+| remember_me session indefinitely reused | Elevation of Privilege | RFC 9700 family revocation on reuse still applies; 30-day TTL is bounded |
+
+### Security Gate Requirements
+
+Before phase advances:
+- [ ] `bandit -r backend/` — zero HIGH severity findings
+- [ ] `pip audit` — zero critical/high CVEs
+- [ ] `npm audit --audit-level=high` — zero high/critical
+- [ ] HS256 token presented to ES256 verifier → 401 (negative test `test_hs256_token_rejected`)
+- [ ] Startup rotation test: tokens revoked on algorithm change, skipped on same algorithm
+- [ ] `services/auth.py` grep confirms zero references to `settings.secret_key` after implementation
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+
+- PyJWT 2.13.0 installed on system — `ECAlgorithm`, ES256 encode/decode verified via runtime execution
+- `cryptography` library installed — `ec.generate_private_key(ec.SECP256R1())`, `serialization.Encoding.PEM` verified via runtime execution
+- `backend/services/auth.py` — 4 HS256 sites confirmed at lines 99, 109, 132, 141 (read directly)
+- `backend/config.py` — current settings layout confirmed (read directly)
+- `backend/main.py` — lifespan function structure confirmed at line 136 (read directly)
+- `backend/services/ai_config.py` — `seed_system_settings_from_env` upsert reference pattern (read directly)
+- `backend/db/models.py` — `SystemSettings` schema confirmed at lines 340–377; `RefreshToken.revoked` at line 102 (read directly)
+- `backend/api/auth.py` — `LoginRequest`, `_set_refresh_cookie`, login handler confirmed (read directly)
+- `frontend/src/views/auth/LoginView.vue` — step-based login form, `authStore.login()` at line 228 (read directly)
+- `frontend/src/stores/auth.js` — `login()` action confirmed (read directly)
+- `.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md` — all 12 decisions confirmed (read directly)
+- `backend/tests/test_task1_models_config.py:31` — existing assertion on `refresh_token_expire_days == 30` confirmed (read directly)
+
+### Secondary (MEDIUM confidence)
+
+None required — all findings verified from code or runtime tests.
+
+### Tertiary (LOW confidence)
+
+None — no claims rely on WebSearch-only sources.
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+
+- Standard stack: HIGH — PyJWT ES256 and cryptography P-256 generation both verified via runtime tests on the installed versions
+- Architecture: HIGH — all 6 change sites read directly from codebase; data flow confirmed end-to-end
+- Pitfalls: HIGH — pitfalls derived from concrete code inspection (existing constraints, test assertions, and the SystemSettings schema)
+
+**Research date:** 2026-06-05
+**Valid until:** 2026-07-05 (stable libraries; PyJWT API is backward-compatible since 2.0)