Skip to content

feat(studies): session workspace — named studies, notes, share links - #72

Merged
obvious-autobuild[bot] merged 5 commits into
mainfrom
feat/session-studies
Oct 12, 2026
Merged

obvious-autobuild[bot] merged 5 commits into
mainfrom
feat/session-studies

Conversation

@obvious-autobuild

Copy link
Copy Markdown
Contributor

Why

A physicist who has just found something — a slice where the pedestal holds while the core flattens, say — has no way to keep it. The view lives in the URL hash and the note lives nowhere; pasting a link to a colleague shares the shot, not the finding. Issue #44 asked for a session workspace: name what you're looking at, note what you saw, and hand someone a link that shows them the same thing. The backlog spec's locked decision: a hybrid — hash-encoding stays the carrier for view shares (the house idiom, unchanged), while a small server-side file store holds named studies + notes, because notes are arbitrary text and enumeration needs a list. localStorage holds pointers and drafts only — the server is the source of truth, so a study link is not a lie the way a localStorage-only "share" would be.

What

Server — fusionlab/api_studies.py (mounted in api.py beside the existing three routers): list / get / create / update over data/studies/{id}.json, on the proven protocol-store pattern — ids are server-minted s_[0-9a-f]{12} (a tampered request cannot name a path), 404-before-write, every write through atomicio.atomic_write (an interrupted write never leaves a half-file), a note size cap rejected with 422, list ordered recent-first and stable. Study routes are validated against the app-owned grammar (#/replay/{id}, #/compare/{ids}, #/explore) — traversal-shaped values are 422, and non-standard JSON Infinity/NaN are rejected cleanly at the boundary. The stored object carries the study's what_if values, note, shot ids, and a server-stamped provenance block whose house words are "compared with MAST data, never predictive" — a study is a comparison record, kept honest in stored and rendered copy.

Client — a fourth grammar entry #/study/{id} in web/workspaces.js (MOUNT.study): fetch the study, route into the view it captured (the stored route is re-checked, never trusted), then re-apply the note and parameters through the public window.FusionLab hooks. The restore rides FusionLabShotLoad — the shared shot-load state machine — so the load chip stays truthful while the restore waits. The palette gains "Save this view as a study…" (captures the canonical route shareUrl() already builds, the drawer's what-if values, and the note text) and a Studies source (list / open, recent-study pointers as an offline fallback). The note anchors to the what-if drawer (rp-wi), which already keeps state across close/reopen. Existing share behavior (rp-share, shareCompare, every deep link) is untouched — view-only shares keep working exactly as before.

Two restore-correctness fixes the dogfooding forced out (both in web/replay.js, both general):

  • The study restore finished on fetch-ready, a beat before the replay module drew the shot — the anchored editor's whatIf(true) saw no shot and silently no-oped. The restore now settles on the fusionlab:shot draw event (the public signal that the editor can open), and whatIfValues rejects when the editor cannot open instead of resolving "applied 0"; a partial apply is said out loud ("what-if edits applied: 2 of 3…").
  • The route's own ?edit=1 open and the study's apply raced the unset gate memo, each fetching /virtual/gate and rendering the sliders — the later render wiped the earlier apply's values. The first open is now the single writer: the in-flight open is memoized, every racing caller rides one fetch + render; a failed open stays unmemoized so Retry refetches.

How to Review

  • Server shape: fusionlab/api_studies.py (the whole store is ~200 lines) and its tests tests/test_studies.py — CRUD round-trip, patch semantics, id/route validation, forged metadata, note cap, atomicity, degraded study shapes.
  • Client: web/workspaces.js (the study mount + applyStudy + recent-study pointers), web/palette.js (save dialog + Studies source), web/replay.js (the note field in the drawer, whatIfValues, the shared gate open).
  • Served-JS tripwires for the new code's fetch-error discipline are in tests/test_web_checks.py (20 checks total; the 17 pre-existing ones pass untouched).
  • Intentionally excluded: no external services, no build step, no new dependencies; localStorage never becomes the source of truth; BACKLOG-3: Playwright golden-screenshot UI tests #46 (Playwright) stays out per the program's locked decision.

Test Evidence

  • make test on the tested head 919b5f8857d18cba7900e9ade8aaff0ecd2b9f52: 552 passed, 1 skipped; uv run ruff check clean; uv run mypy fusionlab clean.
  • Browser dogfood (save → share → restore, headless Chromium at 1440×900, evidence uploaded against the tested head SHA):
    • tc-1 — the author's view before, and the save confirmation after (house words in the dialog; link #/study/{id} copied).
    • tc-2 — recording of the restore flow: opening the shared link in a fresh profile.
    • tc-3 — the colleague's restored view from a fresh browser profile (empty localStorage): shot loaded via FusionLabShotLoad, Beam power ×1.20 re-applied with the drawer's re-run results, note rendered — server-backed truth, not the author's localStorage.
    • tc-4 — a broken study link names its failure ("Couldn't open the study (HTTP 404)…") instead of a blank page.

Fixes #44

Human author: Tapan Ganatma Nakkina ([email protected])

🔗 Obvious Project · 🧵 Obvious Thread · 🔀 Obvious PR Artifact

ObviousApp and others added 5 commits October 12, 2026 16:50
…re pattern

A study is a saved view (workspace, shot(s), slice, what-if/explore parameters)
plus a markdown note, stored server-side so a short #/study/{id} link restores
the same view in any browser (backlog #44). One JSON document per id under
data/studies/, written only through atomicio.atomic_write; ids are minted
server-side (s_[0-9a-f]{12}, the protocol.py discipline) so a tampered request
can never name a path — a malformed id is a 422 before it can become one, and a
well-formed unknown id is a clean 404, checked before any write. Schema bounds
cap the note (422, nothing written); the stored route must be one of the app's
own hash views, so a study never points at another study link. Provenance is
server-stamped: house_words "compared with MAST data, never predictive" — a
study is a comparison record. List is newest-updated first, ties by id.

Co-authored-by: Tapan Ganatma Nakkina <[email protected]>
The client half of backlog #44. The router gains a fourth grammar entry
#/study/{id}: the mount fetches the study, routes into the view it captured
(the stored hash is the router's own), then re-applies the note and parameters
through public hooks — what-if values and the note on the replay room's
anchored editor (which gains a study-note field), explore values through the
sandbox. The replay restore rides FusionLabShotLoad so the load chip stays
truthful; a failed fetch or load names itself in the study landing, never
silent. The command palette gains "Save this view as a study…" (capture the
canonical route + drawer note, store it, copy its link) and a Studies source
that falls back to recent pointers when the server list is unavailable.
localStorage keeps pointers (fusionlab-recent-studies) and the unsaved-note
draft (fusionlab-study-note) only — the server store is the source of truth.
Existing share behavior (rp-share, compare share, deep links) unchanged.

Co-authored-by: Tapan Ganatma Nakkina <[email protected]>
tests/test_studies.py (23 cases): CRUD round trip through the HTTP surface
(stored doc == served doc == on-disk doc), PATCH semantics (omitted fields
survive, explicit null clears), tamper-resistance (five malformed ids are
clean 422s before any path is named; unknown ids are 404-before-write with
the store still empty), server-built ids/stamps/provenance (a forged body
cannot mint an id, forge a 1999 stamp, or replace the house words; provenance
is immutable after create), the caps (a 20,001-char note is a 422 that writes
nothing and a rejected update leaves the stored note untouched), strict
stored-route validation (javascript:/http:/#/study/traversal-shaped routes
are all 422), atomic writes (an interrupted save publishes no half-file and
the store still reads clean), listing (newest-updated first, stable across
calls, the edited study rises, unreadable files are skipped but not hidden),
and degraded studies stored as captured (compare/explore shapes).

tests/test_web_checks.py (+3, house style): the study mount's fetch has the
error discipline (encodeURIComponent, non-OK throws into the study landing,
the stored route is re-checked against the app's own views before it is
navigated to, restore rides FusionLabShotLoad with the house words in the
status); the palette save posts with the discipline (non-OK unwraps the
server's detail, failure says nothing was written, the draft is consumed,
the list degrades to recents named); the drawer note drafts to localStorage
but restore overwrites from the server copy and consumes the draft.

api_studies.py hardening found by these tests: the stored-route path
parameter is only ever shot ids and compare's comma list ([0-9,]*), so
traversal-shaped strings cannot ride in as a stored route; and Infinity/NaN
bodies are rejected at the route door with a clean 422 (json.loads accepts
the literals, the store's dump refuses them, and a validation-error reply
cannot echo an unserializable value without crashing).

Full suite: 502 passed, 1 skipped (476 baseline + 26 new); ruff, mypy clean.

Co-authored-by: Tapan Ganatma Nakkina <[email protected]>
…-if apply

Fresh-profile restore applied what-if edits in the gap between the shot
fetch reporting ready and the replay module drawing the shot: the drawer's
whatIf(true) saw no shot and took its close path, so the apply silently
no-oped and the study looked like the author's view while showing defaults.

- applyStudy now settles on the fusionlab:shot draw event for the study's
  shot (the public signal that the editor can open), with the fetch-ready
  + already-drawn check as the in-session fallback; error and give-up
  paths still name the failure.
- whatIfValues' apply branch rejects when the editor could not open
  instead of resolving "applied 0", and reads the gate's live sliders
  only after the editor opens; applyStudy says a partial apply out loud
  (N of M, or "could not be applied").
- Tripwire pins the partial-apply wording.

Co-authored-by: Tapan Ganatma Nakkina <[email protected]>
…d values

The route's own ?edit=1 open and a study restore's what-if apply both call
whatIf(true) while the gate memo is unset, so each fetched /virtual/gate and
rendered the sliders independently; the later render wiped the values an
earlier caller had just applied, and the study restore could land on a
reset editor (last-renderer wins — a race). The first open is now the
single writer: the memo is the in-flight promise, every racing caller rides
the same fetch + render, and a failed open stays unmemoized so Retry (or a
later call) refetches.

Co-authored-by: Tapan Ganatma Nakkina <[email protected]>
@obvious-autobuild
obvious-autobuild Bot marked this pull request as ready for review October 12, 2026 17:02
@obvious-autobuild
obvious-autobuild Bot merged commit 17e9f78 into main Oct 12, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BACKLOG-1: Session workspace (studies/notes/share links)

2 participants