Storage and state
Own-data entry and portable Study files
Bench Import data selects the existing Quick start Your data view;
its registry routes open the existing app importers. Study recovery is a
separate disclosure and accepts the existing .drybench-study.json format.
Fresh Bench launches wait for pending-work acknowledgement before clearing
the tab's research context; a later interaction, navigation, unmount or scope
epoch cancels an older launch. Study-started work keeps its explicit context.
No Study or result is created by choosing an entry route.
The existing recovery controls and Reopenable Study file export choice
share downloadStudyRecovery / downloadRecoverySnapshot. They guard before
the pending-work barrier and after every asynchronous boundary, capture one
full runtime bundle, preserve stock review, and serialize that exact bundle.
Report-section selections never enter this operation. Format 6, strict
validation and atomic restore are unchanged. Success reports only that a
download started. Recovery review is scoped to the captured account epoch.
The dismissible storage line waits for the database mode. Memory fallback shows a persistent warning even if routine storage information was dismissed. Account-backup controls distinguish a queue that is loading or unreadable, local work without a confirmed backup, pending/failed uploads, and an acknowledged snapshot with its date. Later edits require another snapshot; signing in never establishes sync or backup success.
Retained paper reader boundaries
src/lib/literature/evidence-reader.ts holds the unchanged strict retained
paper-evidence reader, field constants and types. The original evidence.ts
re-exports its API and keeps CSV assembly; evidence-text.ts holds the
unchanged text projection. Frozen citation validation and example preparation
use the reader directly, while report assembly imports the text projection
directly. Validation still checks source identity, supporting passages,
full-text provenance and reviewed extraction provenance; report qualifications
and content hashes are unchanged.
Bench execution records
Bench execution commands import the existing membership transform and link-ID
helper directly from their pure leaves. Strict work-manifest validation lives
in src/lib/workspace/work-model.ts, with its original export retained by
work.ts; both the store and execution commands use that same validator.
These boundaries leave replay, exact-source, scope, revision, hash and atomic
commit checks unchanged.
Feature 14 stores mine:execution/<UUID-v4> Objects separately from saved
tasks and scientific results. The version 1 body retains its owning Study and
optional Experiment, a fixed creation operation, immutable original identity,
an optional current source link, one frozen plan and an append-only entry list.
The strict reader verifies retained plan, binding and historical operation
hashes and projects state by replaying researcher actions. It never runs the
scientific planner or reads a current source.
The measured command service owns strict report preparation: it shares the
lossless JSON guard, detaches synchronously, verifies the historical record,
then loads the pure retained formatter. The scoped UI service checks read access
and the captured epoch before and after preparation. Accessor/sparse-array
inputs remain refusals; the report widget imports the formatter's marker so
its complete load remains measured without another command-service copy.
Printed dispense rows use a five-column well/role/source/volume/mark table and
a separate three-column well/concentration/carrier table. Both retain every
well in order, including all 384 rows; the JSON record stays unchanged.
The scoped service separately reads current Study/Experiment names and source
availability/revisions for navigation. Those transient labels and change
notices are outside the frozen body, hash and reducer, and never mutate history.
bench-execution-commands.ts prepares from authoritative saved Work/Object,
association and destination snapshots. Creation rechecks those bytes and
revisions in one transaction with the new Object, membership and Study history.
Append preparation hashes outside the write transaction; persistence rereads
the complete authoritative Object before committing. Fixed operation IDs make
identical retries discoverable before stale-revision checks. Scope epochs,
capability checks and transaction rollback prevent late writes to a different
workspace. A failed acknowledgement retains the original command for checking.
Generic Object writers, aliases, versions, cleanup and relationship mutation cannot replace execution data. Pin, tags and rename preserve its authoritative body transactionally. Dedicated revision-checked removal and restoration retain the body. The owning context prevents empty-Study deletion even if membership has been damaged. Bounds are 800 frozen steps, 5,000 entries and 2 MiB canonical body bytes; exhaustion refuses another entry without discarding existing data.
Study recovery format 5 introduced session-bearing recovery and remains an accepted historical format; current exports always emit format 6, including when sessions are present. The session validator requires format 5 or later. Recovery verifies records before writing, remaps only current Study/Experiment/source links and retains original plan receipts, origin identity, entry IDs and hashes exactly. Unavailable source links are explicit; source histories are not pulled into the package merely because a frozen receipt names them. Removed session bodies travel with their tombstones. Readers without session-format support refuse session-bearing bundles; a reader limited to format 5 cannot read format 6. Restored reminders require explicit takeover and remaining-time review in the view; import does not fabricate a timer action.
Drybench keeps a person's work in their own browser: one IndexedDB database holds projects, Studies, objects and Workspace records, while localStorage, sessionStorage and the #s= URL fragment hold preferences, tab context and shareable view state. This page describes those layers, the record shapes, how ownership scopes keep anonymous and account work apart, and how to change a stored shape without losing data.
Bench workflow — 23 October 2026
The visible destinations are Bench, Studies and Library. Internal object, manifest and snapshot types keep their existing names. /o#work lists every current WorkManifest, including activities without a Study and archived Study work, with app/Study/result filters and existing guarded resume. All current scientific WorkManifest writers are object-backed. Project/draft references remain envelope types used by tests and portable readers; this pass does not invent app resume grammars for unsupported references. Current Sequence/Assay analysis-batch projects appear under App projects and open through the existing batch store validator and panel. Opening does not analyse inputs or write scientific results.
New app-state backing records created by saveObjectBackedWork and duplication are carried: true. They appear under Work, rather than as Library peers. Deliberate Keep, Pin or Add to Study can promote a reusable item through existing verbs. Experiments are optional Work grouping and are excluded from Library. Explicit Save promotes declared primary Protein/Molecule subjects even without a Study, without pinning them or promoting every consumed binding. Subject membership and work commits retain their atomic transaction and original ownership checks. Add to Study links only the scientific item; it no longer offers an additional saved view.
WorkCaptureControls now orchestrates one explicit Save: commit the editable work, then call this render's eligible capture adapter and atomically record the exact immutable result and inputs. An absent callback saves work only. Assay can save its setup while a fit runs, but supplies no result callback during that interval. Combination analyses requiring reason review keep that explicit review before recording. Protein reference/evidence workflows retain their specific review and capture rules. Failure after a successful work commit explicitly reports partial success; snapshot stale rejection is unchanged. No timer or page opening records a result.
save-checkpoint.ts compares only the latest result of the same work/scope. Initial reuse is limited to stock/dilution/serial/master-mix Calc plans and the CSO-listed deterministic Sequence operations. It verifies result/input hashes and exact input identities, retaining schemas, settings, output, methods, data versions, reporting, qualifications, provenance, nested dates and bound-source revisions. Only generated envelope IDs/dates and an otherwise identical primary checkpoint revision are ignored. Observation/unknown origins, other schemas, explicit Save another result and reason-reviewed capture append instead. Reuse keeps the original result, date and exact inputs unchanged. A concurrent work or result-pointer change refuses reuse.
Study sections are Overview, Work, Results and Materials & sources. Overview shows selected result highlights, the editable conclusion, next steps and expandable notes/meeting views. Work shows editable activity cards, with optional Experiments; Results shows projected immutable findings and separately labelled findings from papers. The schema-aware demand-loaded projector retains withheld/refused results and qualifications, without running scientific engines. Materials & sources contains explicit reusable memberships. The example is example-study@4: seven private Work states, three real result captures, six Library materials/sources (including one paper and its reviewed method finding), and two optional Experiments; Sequence uses sequence-workspace v2 with primerSets. It seeds no legacy StudyData.views. The CEO waived backwards-compatibility UI for this redesign; no destructive data reset is performed.
Study workflow follow-ups — 23 October 2026
createStudyInclusion adds canonical workspaceAssociations for an existing activity and only the exact result IDs explicitly selected. Original manifests, ownership contexts, snapshots, dates and hashes remain unchanged; future results do not join automatically. A second Study filters outcome/history through its own result memberships. The operation advances the destination's guarded membershipRevision only for a real membership change. Before committing, a virtual selection goes through the complete recovery-bundle dependency validator; all point and collection reads are checked again inside the write transaction. Missing editable state, named versions or retained original bytes, changed dependencies, stale destination revisions and scope changes refuse the operation atomically. Removing an inclusion tombstones its edges only and refuses while a Study/Experiment conclusion cites its results. Original owning-Study work cannot be removed through this command.
Schema 5 replaces the independent brief next-step string/link with nextTodoId, a pointer to one unfinished todos item. The header, brief and Bench use studyNextTask. Completion/deletion or unlinking a removed activity clears the pointer without promoting another task or recording a result. Objective editing changes only the objective. Existing local schema-4 data can normalise in memory to one retained checklist item; exports normalise their copy without changing the stored source. One function does this for every reader (migrateStudyStep and currentStudyData in objects/study-migration.ts): the project store, recovery files of Study schemas 1 to 4, which are checked and restored at schema 5 (CW-03), and the Workspace commands that rewrite a Study row inside their own transaction, which read it through readStudyRow (src/lib/workspace/study-lifecycle.ts: one Save's materials, task move and duplicate, repeat setup, bench execution, empty-Study delete) so the current check never drops the step (CW-04).
StudyConclusion.paperFindings retains bounded frozen citations {ref, revision, evidence, contentHash} separately from resultIds. Evidence uses the Literature validator and its hash is verified before saving/exporting/restoring. Newly cited notes must still match their live Study membership/revision; an identical already-retained citation can remain after source edits/deletion. Reselecting the source explicitly replaces only the draft citation. Citation actions never save the conclusion. Source passages, dates, licences, full-text context and assistance provenance remain in supporting report details; limitations and retracted/preprint/pasted-source qualifications remain visible beside findings.
Recovery now writes bundle format 6 and accepts supported earlier formats. Omitted private paper-note pointers must appear in retention.missingObjects; restore always assigns fresh private identities, never a coincident destination ID. Included work from another Study carries only that original Study/Experiment's historical label/identity through historicalContext or a validated result transfer envelope. No unrelated source Study body travels, and historical identities are never used as navigation destinations. Immutable scientific bodies retain their original values through the normal transfer mapping/hash process. No database-store version change is required.
Where state lives
Protein's retained structure evidence uses the existing immutable input/result
stores, one protein-explorer.structure-evidence-input for one
protein-explorer.structure-evidence-result v1. Experimental inputs and historical predicted inputs use schema/payload/adapter v1 with capture 1.0.0 or 2.0.0 (new experimental captures use 2.0.0). New exact predicted inputs use schema/payload v2, adapter protein-structure-evidence/2 and capture 3.0.0. Mixed tuples refuse; result/output schema remains v1. Its bounded PNG is inline in
the result; source, selection, rendering and interpretation facts are in the
input. Exact receipts keep bound actual source/current sequence hashes, canonical confidence hash, source asset identity, settled mapping status and paired exact runs separately from coordinate-byte identity and actual applied targets. Pending correspondence refuses capture. Old records retain offset-era semantics with no migration. The PNG never enters the #s= fragment or editable draft. Existing
reference-view work retains its earlier live-source meaning.
The optional saved-work capture bridge binds preparation to the saved task's revision, input fingerprint, owner and access epoch. It does not save a draft implicitly or select a Study. The normal atomic snapshot transaction records the prepared IDs and bytes; an uncertain outcome checks those same identities before retry. A changed task or scope invalidates preparation.
Study recovery keeps these snapshots through the existing version-4 bundle contract, with the usual identity remapping and envelope hashes. No new store, asset record or bundle version is introduced. The 4 MiB backup and 50 MiB recovery limits remain; a local recording is not confirmation of backup. Historical image/export readers verify the exact retained input and PNG, and never fetch current coordinates or execute saved scene JSON. Text-only projection uses the shared strict metadata reader and explicitly marks omitted image bytes as unverified there. The loader selects a historical-only or full exact reader before importing the Protein projection part. Its capability key includes any new-schema/adapter/capture hint, including late inputs and malformed tuples; synchronous and asynchronously loaded projections enforce the same rules. See Protein.
| Layer | Holds | Lifetime | Main code |
|---|---|---|---|
IndexedDB mbio-suite |
Projects and their records, Studies, objects, links, object versions, tasks, input and result snapshots, Study associations, hand-off records, store metadata | Until the person or the browser clears site data | src/components/suite/store/idb.ts |
localStorage (suite:*) |
Theme, settings, device id, each app's last address, layout conveniences, the last chosen Study | Per device | src/components/suite/store/keys.ts |
sessionStorage |
The tab's research context, cached Workspace access, a guided walk's run, the row to highlight on return | Per tab | src/components/suite/objects/research-context.ts, src/lib/workspace/access-client.ts |
URL fragment #s= |
The view an app shows, reproducible from public data | The address | src/components/suite/store/url-state.ts |
history.state |
{ v: 1, app, state, origin } for Back and Forward |
Browser history | url-state.ts (historyStateFor) |
| Files | Exports, project files, Study ZIPs and .drybench-study.json recovery files |
Wherever the person saves them | src/components/suite/data/, src/lib/workspace/bundle.ts |
Nothing in IndexedDB is uploaded automatically. The only path to a server is an explicit account backup of one Study, described in Server, accounts and billing.
The IndexedDB database
idb.ts is an in-house wrapper with no dependency (contracts §4.1, D14 in docs/design/shared-contracts.md). DB_NAME is "mbio-suite", a stable machine id that is never derived from the display name. DB_VERSION is 5. upgradeSchema creates whatever stores and indexes in SCHEMA are missing, so it is idempotent on any version. An open connection closes itself on onversionchange so that another tab can upgrade.
| Store | Key | Indexes | Owner module |
|---|---|---|---|
projects |
<app>:<projectId> |
app, app_kind, app_updated |
store/projects.ts |
records |
<app>:<projectId>:<type>:<id> |
project, project_type, updatedAt |
store/projects.ts |
cache |
<app>:<service>:<cacheKey>; in practice suite:handoff:<id> |
app_lastUsed, app_kind, expiresAt |
objects/store.ts (hand-offs) |
handles |
<app>:<projectId> |
none | store/projects.ts (removed with a project) |
meta |
free-form keys | none | project store, object store, restore ledger, backup outbox, drafts of named tasks |
objects |
the object's ref, e.g. uniprot:P00533, mine:assay/<uuid> |
type, updatedAt, type_updated, aliases (multi-entry) |
objects/store.ts |
links |
deterministic link id <role>|<from>|<to> (linkId) |
from, to, updatedAt |
objects/store.ts |
versions |
<ref>#<versionId> (versionKey) |
ref, ref_at, updatedAt |
objects/store.ts |
workspaceWork |
workspace:<len>:<scopeId>:work:<id> (workspaceKey) |
scope_updated, scope_app |
src/lib/workspace/work.ts |
workspaceInputs |
workspace:<len>:<scopeId>:input:<id> |
scope |
src/lib/workspace/snapshots.ts |
workspaceResults |
workspace:<len>:<scopeId>:result:<id> |
scope_work, scope_recorded |
src/lib/workspace/snapshots.ts |
workspaceAssociations |
workspace:<len>:<scopeId>:association:<len>:<studyId>:<kind>:<id> (associationKey) |
scope_study, scope_subject, scope_updated |
src/lib/workspace/associations.ts |
Version history: version 2 added objects and links (build-spec-objects §3), version 3 added versions, and the change from 3 to 5 added the four workspace* stores in one step. Keys in the app-owned stores start with <app>:, so clearApp and appRange can list or clear one app without touching another.
SuiteDb exposes get, put, del, list, count, batch (several puts and deletes in one transaction) and transaction(stores, run). Inside transaction the callback may await only the transaction's own operations; throwing rolls back every write, and the callback is never replayed. expectRevision throws RevisionConflict when a record's rev is not the one the caller expected, which is how optimistic concurrency is enforced across stores. createMemoryDb implements the same interface over Maps with structured cloning and IndexedDB key ordering; it is the fallback and the backend most tests use. fake-indexeddb.test-util.ts drives the real wrapper in Node tests.
The shared runtime database and fallbacks
sharedDatabase() in store/shared-db.ts gives the page one handle that the object, Study, project and Workspace stores share, so they also share one fallback. If IndexedDB is missing or blocked, or does not answer within OPEN_TIMEOUT_MS (5 s), the open rejects with StorageUnavailable and the shared handle starts as a memory database with fallbackReason: "unavailable". An open that answers after the timeout is not discarded (REL-44; the CTO's ruling of 13 October 2026): openSuiteDbPatient returns the open and, separately, the late connection. On a busy main thread (the 3D viewer starting under software WebGL) the success event waited 5.4 to 10.1 s in measured runs, and the tab used to stay in memory for its life, so what it saved was gone on the next page. Now reads start in memory at the timeout; a write waits for the open up to WRITE_WAIT_MS (30 s from the open's start) before it commits to memory, and a write touching only meta (a task's draft) up to META_WRITE_WAIT_MS (8 s), because a leave awaits the draft chain and the draft is still on screen (the coordinator's ruling of 13 October 2026); a leave through navigateWithDocumentPolicy that waits more than a second on flushPendingWorkspaceWork shows workspaceWorkText.capture.saving ("Saving…") as an info toast, announced; and when the connection answers, the handle adopts it inside its write queue: the rows written to memory meanwhile are read out, then copied in one transaction where IndexedDB lacks their key (on a shared key IndexedDB's row is kept, and the count is reported in adoption.dropped), and only then do reads move to IndexedDB, so no read sees a half-copied database. Subscribers hear the change (the first time a status goes from memory back to IndexedDB): the object store tells the other tabs about the copied objects (adoption.objectRefs), and a project store marks its projects made meanwhile storage.mode: "indexeddb". A late error, or a copy that fails (out of space), leaves the handle in memory. Free app project stores (projects.ts, no open option) open through their own createSharedDatabase instance with the tab's session mirror as the memory seed, so they adopt a late open the same way; turning session-only mode off still uses openSuiteDb, the person's own retry. openSuiteDb alone (the draft worker) closes a late connection as before. If a write fails with a quota error, the handle copies every store into a memory database, sets fallbackReason: "quota" and notifies subscribers; the failed command stays failed and is not replayed. Stores surface this through their status (StoreStatus.reason), which the shell shows as a storage notice.
workspaceDatabase(scopeId) in store/workspace-db.ts wraps the shared handle in an ownership scope (next section) and caches one facade per scope.
Ownership scopes: anonymous, browser and account
currentWorkspaceAccess().scopeId (src/lib/workspace/access-client.ts, capabilities.ts) is one of:
kind |
scopeId |
Meaning |
|---|---|---|
signed-out |
null |
No Workspace access; the legacy anonymous library |
browser |
"browser" |
WORKSPACE_ACCESS_MODE=browser, the default: Workspace without an account |
account |
"account:<accountId>" |
A signed-in account |
development |
"development" |
The loopback-only development override |
scopeDatabase in store/scoped-db.ts applies the scope. For null and "browser" it uses no prefix, so browser mode continues the anonymous library in place. For any other scope it prefixes physical keys in projects, records, cache, handles, meta, objects, links and versions with \u0001workspace:<encoded scopeId>: and hides rows outside that prefix. Scientific refs stay unchanged; only physical keys are namespaced, and a ref that starts with the physical prefix is refused. The four workspace* stores instead carry a scopeId field and a scope inside the logical key; a write whose scopeId or key belongs to another scope throws.
A scoped facade is a fixed lease: it captures the scope when created and throws DatabaseScopeChanged (store/scope-changed.ts) if the account changes during an operation, so a multi-step operation cannot read in one account and write in another. Signing in never adopts or uploads anonymous records. Moving work between scopes is an explicit export and restore of a recovery file (see below). docs/workspace-setup.md notes that this is isolation within the application, not encryption against someone with access to the browser profile.
The project store
createProjectStore in store/projects.ts implements contracts §4. A project is the unit of save, open, version, export and import.
- Envelope.
ProjectEnvelopeholdskey,app,id(a UUID),kind,name,schemaVersion,rev, timestamps, export counters,storage.mode,size,data,recordTypes,versions, an optional provenancesnapshot,appVersionandhistoryHead.RecordEnvelopeholds the per-project records with their ownschemaVersion,revand optionaldeletedAt. - Revisions and conflicts. Every save bumps
rev.updateruns in one transaction and refuses a save against an olderbaseRevwithProjectConflict.saveMergingmerges per independent key and returns conflicts instead of overwriting (contracts §4.6, D21). - Change history. Each save writes a JSON Patch as a
historyrecord. Edits withinHISTORY_COALESCE_MS(five minutes) are combined, andtrimHistoryfolds the oldest entries once there are more thanHISTORY_MAX(300). - Versions and trash.
saveVersionstores an immutable snapshot as aversionrecord.deleteRecordsoft-deletes with undo, andemptyTrashremoves entries older thanTRASH_DAYS(30). - Persistence and eviction. On the first write the store records
<app>:markerand<app>:persistinmeta, sets thelocalStoragemarkermbio-suite:<app>[:workspace:<scope>]:had-projects, and callsnavigator.storage.persist()once (D17). A marker inlocalStoragewith none in IndexedDB on a later visit setsstatus.evicted. - Autosave.
createAutosavedebounces saves (2,000 ms by default), never overlaps them, andflush()saves at once onpagehide. - Export reminder.
exportReminderDueis true after 200 changes since export, or when unexported changes are at least seven days old (D18).
sessionOnly mode and the sessionStorage mirror (…:session-mirror) exist in projects.ts, but a store given a scopeId refuses them ("Workspace storage mode is shared across its records"). getProjectStore (store/use-projects.ts) and studyStore() always pass a scope, so neither is used on those paths. StorageMode is "indexeddb" | "memory"; the file-backed mode in contracts §4.4 is not implemented, and nothing creates entries in handles.
Two callers use the project store. Studies use it directly (next sections). The shell's session integration (store/session-controller.ts: ProjectChooser, SaveStatus, autosave, the conflict dialog) is generic and switches on when an app definition declares both storage and session (src/components/suite/app-definition.ts, StorageSpec). In the current code only the dev-kit demonstration declares them, under the namespace dev-kit-demo. The scientific apps save through objects and Workspace tasks instead.
Objects, links and versions
createObjectStore in objects/store.ts holds the suite's shared objects (build-spec-objects §2 to §3). Types are in objects/types.ts.
ObjectRecordis keyed byref. Public types (target,compound,condition,trial,product,sequence,paper,cell-line) use public identifiers. The person's own types (construct,assay,recipe,run,note,file,compound,vessel,experiment,stock,cell-line) usemine:<type>/<uuid>.mineholds the person's pin, tags andnumbers(Measurement[]);factsholds cached public summaries, at most one block per source;datais the type's body.- Records are shaped for later sync: a stable key,
rev,updatedAt, the writer'sdeviceId(fromlocalStoragesuite:device-id,readDeviceId) and adeletedAttombstone instead of a hard delete. A merged record keeps a tombstone withmergedInto. upsertkeeps the person's own fields. Itsasoption (D-0045) distinguishes an explicit save fromcarry, which stores something another record needs and marks itcarried: trueso the library leaves it out until saved, pinned or added to a Study, and fromrefresh, which only updates a record already held.LinkRecordids are deterministic (linkId), so the same link made twice is one record. Roles are listed inLINK_ROLES.measured-in(E10) runs only from amine:assayto a cell line record; the store refuses any other pair.- The
cell-lineobject (E10, D-0099; the CPO's ruling 7 and the CTO's ruling 4(c), 17 October 2026).cellosaurus:CVCL_…names a Cellosaurus record, owned by no app (it is not yet inASSIGNED_NAMESPACES, so a Send to would carry it by hand-off record; no app receives a cell line yet, and the bytes that list would add to the shared objects code are over Bioreactor's chunk ceiling) (ownerOf("cell-line")is undefined), addressed at/o/cell-line/<accession>and never by a name (the palette does not yet identify a typed accession: the bytes it would add to every workspace's shared objects code were over Bioreactor's chunk ceiling, which this run may not touch). Itsdatais aCellLinePassport(src/lib/cells/passport.ts,v: 1, strict readerreadPassport): the accession, the release it was quoted from (release.version,release.date,release.retrievedAt), the name, synonyms, earlier accessions, category, species, diseases, sex, age, the "Problematic cell line" comments verbatim, the STR profile andquotedAt. The fields are kept by value: an app that later writes a newer release only fills fields the record lacks (the store's ownership rule), so a saved record is never rewritten and only the current release's snapshot stays in the repository.mine:cell-line/<uuid>holds aTypedCellLine({ v: 1, typed, notLinkedTo: "cellosaurus" }), a name kept as typed from the CandidateList. The free-text aliases are the lower-cased synonyms, for the library's search; they never merge records. - A plate's cell line in Assay is a workspace input binding in
AssayData.workspace.bindingswith the rolecell-line:<plate index>(src/apps/assay-analyser/cell-line-binding.ts): the cell line's ref, revision, schema (cell-line.passportorcell-line.typed, version 1), label, the record's body as the payload by value, its content hash and capture time, as every other binding. The assay record gains no top-level key, so its record version is unchanged (a new top-level key would need v8).captureBoundInputscarries the binding into a recorded result's input snapshots. The assay linksmeasured-into each distinct line on attach, on autosave and when results are recorded, and unlinks a line no plate names. VersionRecordrows inversionsare named snapshots of one of the person's own objects, immutable once written (OP-07).- The
SyncAdapterseam is a no-op locally (localSync); its cursor is kept inmetaundersuite:objects-sync.
Physical stock records
inventory/stock.ts is the strict version-1 manual inventory contract. A stock
is an Object with mine:stock/<uuid>, madeBy: "suite" and no aliases. Its
optional historical material association captures a saved canonical compound or
private construct's reference, label and exact revision. It neither confirms
container identity nor follows later material edits. Concentration includes an
explicit stated basis; carrier percentage is percent v/v of its named component.
Missing amount or percentage remains unknown, distinct from zero. Units change
without conversion. Each of 1–100 container/aliquot entries has a permanent UUID
and explicit available, used-up, discarded or entered-in-error status. A saved
entry cannot be deleted by an edit; a status change never changes its amount.
Entered expiry dates retain the supplier-label/user-entered basis without a
suitability decision. No totals, consumption, reservations or calculation autofill
are inferred.
workspace/stocks.ts owns create, expected-revision update and tombstone removal.
It checks scope, capability, latest record, retained IDs and any newly selected
material inside the same transaction. Only changed concentration, carrier or
ID-matched amount assertions get a new enteredAt; unrelated edits preserve the
old timestamp. Receipts return the exact committed record, scope and previous
revision. workspaceStockStore() notifies subscribers after commit. Add to Study
is a separate command; its failure cannot undo a saved stock.
Generic Object upserts/replacements, restore/sync/remote writes, measurements, bulk cleanup, walk carries and named versions cannot replace stock bodies. Pin and tags use narrow transactions over the current row. Stock Pin/Add to Study skip the generic snapshot pre-upsert. The stock overview owns editing and removal; Mine retains notes, tags and attachments, without generic numbers or versions.
Objects always offers the Stocks type and an explicit Record a stock form. A saved molecule/plasmid Object offers the same form with a checked contextual link.
Calc input from a stock (contract 05, 5b, 17 October 2026). Calc can read a
stock's selected Available container into C1V1 or Serial as a Workspace input
binding (drybench.stock-input v1, role source-stock; inventory/stock-input.ts).
The binding pins the stock's revision and payload hash and carries only the stock
and container labels, the concentration with its basis and enteredAt, the
carrier and the material context: never the amount, location, lot, expiry, notes
or other containers. Later stock edits never rewrite it. Reading, applying,
recording and exporting never write the stock; a consuming task's export carries
the pinned payload only. The #s= fragment carries the copied Calc values and the
cru/c1s markers, never the stock's ref or labels. See Calc.
The shared mine handoff validator accepts mine:stock/<uuid> alongside the
other saved Object types, so stock links survive cross-app navigation.
No starter record is seeded. Library search uses only stock name, lot, captured
material label, container labels and locations, through the strict safe projection.
Summaries show a bounded container/location excerpt; the overview shows every row.
Draft strings stay local to the mounted editor until Save. Conflicts retain the
whole draft and original expected revision; reviewing and loading the current
saved version requires an explicit discard. Scope changes hide private draft
values; returning to its original workspace offers explicit resumption with the
old expected revision, so later stored edits still conflict. The creation dialog
lives above scope-keyed library rows. Stock tab changes and same-policy imperative
navigation participate in the explicit-draft barrier; document leaves use the
existing pending-work and browser-unload guards.
Full stock recovery uses the existing format-4 bundle and formats 1–4 reader. It strictly validates stock bodies before writes, remaps only the material reference, and preserves container IDs, captured labels/revisions and assertion timestamps. A private material body is not implicitly exported by its association. Named stock versions are refused. The pipeline compatibility check executes the actual prior reader against stock-object, missing-ref and stock-binding packages and proves refusal before writes; it is an explicit check because shallow CI histories need not contain that old commit.
Study recovery JSON, Study ZIP and existing saved-backup downloads prepare their actual bytes, then review included stock names/count and full private-field contents before Download. Cancel downloads nothing. Scope/epoch changes invalidate the review. The disclosure covers all retained rows, including errors, locations, notes, assertions, timestamps and entered dates. PDF/HTML reports currently omit stock bodies and do not claim to include these full inventory fields.
Studies and Experiments
A Study is a project with app: "suite" and kind: "study" (STUDY_APP, STUDY_KIND in objects/types.ts), so it inherits revisions, history, versions, export and import. studyStore() in objects/runtime.ts creates it per scope with studyStoreOptions (objects/study.ts) and guards every mutating method with requireWorkspaceWrite.
StudyData holds description (the Study's notes: since D-0084 Markdown in the notes allowlist's dialect with ##/### headings, underline stored as _emphasis_ and links to the suite's own pages, written by the Study page's editor and read as plain text by the Bench notepad, exports, the report packet and search; workspace/study-notes-markdown.ts), members (refs with who added them and when), views (saved app states as #s= strings), and optional todos, conclusion (text citing exact result ids), research, archived, archiveRevision and example. STUDY_SCHEMA_VERSION is 5 and STUDY_LIMITS caps sizes (for example 2,000 members, 200 views, 20,000 characters of description). Adding an object to a Study writes the member and an in-study link through changeStudyMembership.
An Experiment is an object of type experiment with ref mine:experiment/<uuid>, a member of its Study and linked to it with in-study. Its data is ExperimentData (src/lib/workspace/experiments.ts): schemaVersion: 1, objective, status and a conclusion. createExperimentStore().create writes the object, the link and the Study membership inside the Study's update transaction.
Experiment schema 2 and the Repeat setup receipt (contract 06, 17 October 2026). ExperimentData is a union: schema 1 is unchanged; schema 2 adds one required repeatSetup receipt (RepeatSetupReceipt, src/lib/workspace/repeat-setup-contract.ts). validateExperimentData, which every workspace loads, checks the exact keys and the receipt's version only; the receipt's strict reader (validateRepeatSetupReceipt, exact keys throughout) runs where a receipt is written (the command), exported or restored (validateStudyBundle). The object store's guard reads it through the one-function repeat-receipt.ts. A reader that knows only schema 1 refuses a schema-2 Experiment, so an older build cannot edit it and drop the receipt, and an older Study bundle reader refuses the whole package before any write; Study bundle format stays 4 and no store or migration was added. The receipt has operation (a UUID, the time and the prepared command's hash), history (the source Study's label and revision; each source task's label, work and state revisions, schema and captured content hash, and the Calc plan's method pins; the material's label, revision and binding hash; the reviewed choices exactly as typed; the cleared and still-needed categories; the initial hashes of the two new payloads) and outputs (the two new tasks as { available: true, workId, ref, label } or { available: false, label }). history holds no identifier a reader could resolve as a local record and travels byte for byte through export and import; source lineage is each new manifest's duplicatedFrom (duplicatedFromHistory when the source is omitted). Initial hashes are historical: later edits and a recovery remap legitimately change the current payloads. ExperimentStore.update now takes only objective, status and conclusion and keeps the stored schema and receipt under its revision guard. The object store refuses any generic write that would replace or drop a receipt (setData, upserts, batch writes), named versions of a repeat Experiment and restoring one (rejectReceiptRewrite in objects/store.ts); pins, tags and tombstones keep it.
The Repeat setup command (src/lib/workspace/repeat-setup.ts). listRepeatCandidates and readRepeatPair read the Study, the selected works, their objects and associations in one read transaction and check the pair with the app adapters (loaded on demand). prepareCalcAssayRepeat reads saved revisions only (it never saves or flushes an open editor), checks the reviewed inputs, builds every output (one mine:experiment, one mine:recipe, one mine:assay, two work manifests at revision 1 with duplicatedFrom, subjectRefs from the new selections, no latestResultId or userAuthored), and seals a serialisable calc-assay-repeat/v1 command with its content hash; it writes nothing. createRepeatedSetup is one transaction over projects, records, objects, links, workspaceWork and workspaceAssociations: first the replay check on the fixed Experiment ref (the same operation and hash returns the original pair, writing nothing, after any later source or output edit; a deleted or moved output refuses with output-missing and is never recreated; another receipt at the ref refuses with conflict); then the Study revision (archived refuses), both source works and objects by revision and canonical body (a same-revision body replacement refuses), their live associations and the material's revision and body; then unused identities, tombstones included; then the planned Experiment, its in-study link and one Study history entry (writeStudyOrganisation), and the two objects, manifests and associations. Every refusal is a RepeatSetupError with a code and means nothing was written; any other error is an unknown outcome, which checkRepeatedSetup resolves read-only on the same ref. No result or input snapshot is written.
Recovery. validateStudyBundle requires each available output link to name an included task of the right app whose state is that ref and whose association belongs to that Experiment. translate remaps only the two output links (identity("work", …) and the object ref) and marks a link unavailable, with its label, when the task, its object or its association to the Experiment is not in the package; the receipt's history and operation are copied unchanged.
Tasks, inputs and recorded results
The Workspace records live in the four workspace* stores. Their types are in src/lib/workspace/contracts.ts; all carry schemaVersion: 1 and a scopeId.
WorkManifest(a named task) points at the one editable scientific record throughstateRef(EditableStateRef: an object, a project or a draft, with itsrevand payload schema). It stores no copy of the editable payload. It carries the preferred destination (context:studyId, optionalexperimentRef),subjectRefs,latestResultIdand optional researcher-authoreduserAuthored.nextStep.createWorkspaceWorkStoreinwork.tsguards writes withassertWorkRevision; deletion is adeletedAttombstone.assertWorkRevisionandWorkRevisionConflictlive in the purework-model.ts(re-exported bywork.ts), sosnapshots.tsrecords a result without loading the work store and its Study writers (the CTO's size ruling of 24 October 2026,docs/board/memos/2026-10-24-cto-workflow-finish-storage-size.md).InputSnapshotcaptures exactly the scientific content a result consumed, with itssource, anEvidenceclassification (origin,derivation,execution) and provenance.ResultSnapshotrecords output, settings, method and data versions,qualifications, areportingstatus,inputIdsand an optionalpriorResultId.WorkspaceAssociationis the canonical membership of a task or result in a Study and optional Experiment.attachWorkspaceSubjectwrites it in the same transaction as the task or result.
Recording is explicit. captureInputSnapshot and captureResultSnapshot run before the transaction and set contentHash, a SHA-256 over canonical JSON (canonicalJson, contentHash, with assertJson and WorkspaceValidationError, in the dependency-free leaf src/lib/workspace/canonical-json.ts, which contracts.ts re-exports, so a reader such as Plasmid's admission worker can validate and compare without the object references contracts.ts imports; REL-23); the code comments note that hashes identify content, not truth. src/lib/workspace/content-hash.ts (canonicalJsonLight, contentHashLight) gives the same strings for valid JSON without contracts.ts's validation and imports, for client panels and task islands that check a result against its inputs (content-hash.test.ts asserts the equality). assertJson refuses anything JSON would lose, such as NaN, -0, sparse arrays or class instances. The snapshot store refuses to overwrite a recorded result or input with different content ("A recorded result is immutable; use a new result ID"); repeating an identical record operation returns the same result. verifyResultSnapshot and verifyInputSnapshot recompute the hash on export. A saved calculation is a plan; Evidence.execution records whether it is planned or recorded.
A recorded Assay dose-response result's curves may carry responseEvidence on a curve the fitter refused as unidentifiable, not converged or numerically flat (13 October 2026, A-4 item 12): an additive value of the existing optional field of StoredCurve, read by the negative result (negative-result.ts); the results CSV's permutation-p columns and the inspector's response-ordering line skip it on a refused curve, so they read as before. No record version changes and the fit engine's version is not raised, on purpose (raising it would withhold every stored result until a refit, out of proportion to an optional field): the same engine version can therefore hold a refused curve with or without the evidence, depending on when it was fitted, and only the later fit states a negative result (REL-50). The projection adds a negativeResult fact to an eligible withheld curve, computed from the recorded curve and the exact input's experiments, never stored (see Assay).
An Assay record (v6, written as v7 since REL-53) may carry blind (A-4 item 9): the key from code to compound name, when the names were hidden, the lock (its time and the canonical state of the settings, exclusions and fixes) and when the names were shown. While an assay is blind every compound name in it is a code; showing the names rewrites them back across the record and reseals the stored results' inputHash over the same readings, after they verify (see Assay). A recorded result captured while blind keeps its codes, as any recorded result keeps its exact inputs.
Bench projects most recorded results through per-app projections. A captured stock input (drybench.stock-input) reads through its own part, stock-input-projection.ts: result-projection.ts joins it synchronously, and result-projection-loader.ts loads it through stock-input-projection-entry.ts only when a record holds one, naming it stock in projectionApps so a view keyed on those names reloads when the inputs arrive; a stock input with the part not loaded throws, never reading as unsupported (the CTO's stock-autofill size memo of 17 October 2026). A result whose schema is listed as a presentation writer is read from the bounded rows (summary: label, value, detail) the app stored beside its output, never by recomputing: Sequence, Plasmid and Bioreactor (recorded-simulation, recorded-growth-review and Contract 11A's recorded-measured-comparison v1, whose settings input is bioreactor-simulator.measured-comparison-settings v1 since 20 October 2026 and bioreactor-simulator.measured-comparison v1 in snapshots captured before; both are read only by closed readers, see Bioreactor), and since Wave 1 Assay's assay-analyser.growth-result, kinetics-result, inhibition-result and thermal-result (ASSAY_PRESENTATION_SCHEMAS in result-projection-core.ts).
workspaceWorkStore() and workspaceSnapshotStore() in src/lib/workspace/runtime.ts bind these stores to the scoped database and to currentWorkspaceAccess.
Drafts of named tasks
src/lib/workspace/work-draft.ts keeps at most one draft per named task in the meta store, under suite:work-draft:<len>:<scopeId>:<workId> (the backup outbox's key pattern). scoped-db.ts prefixes meta keys per account scope, so a draft is as isolated as the records it sits beside. A stored draft is { key, schemaVersion: 1, scopeId, workId, base: { ref, rev }, label, payloadJson, schema, at }: the edited inputs as their canonical JSON text, the name, and the object and revision they were edited from; readWorkDraft returns it as a WorkDraft with payload parsed beside payloadJson.
writeWorkDraft(in the page, or inwork-draft.worker.tsthroughwriteWorkDraftOffThread) replaces the task's draft after validating it (requireId,validateSchema,canonicalJson's validating walk of the payload, done once and reused when the caller passes it, a privatemine:object ref and a positive revision asbase). It refuses a draft aboveWORK_DRAFT_MAX_BYTES, 4 MiB of canonical JSON (theBACKUP_MAX_BYTESprecedent), by throwingWorkDraftTooLarge, so the refusal can be shown rather than swallowed.readWorkDraftreturns the draft, or null for a row of an unknown version or shape, which it leaves in place (rule 6 below).clearWorkDraftdeletes it.listWorkDrafts(scopeId)reads one scope's drafts by key range, for a sweep of drafts whose task is gone.workDraftIsStaleis true once the record's ref or revision differs frombase, or the record is deleted or gone;workDraftMatchesis true when the draft holds exactly the record's inputs and name.
A draft is not saved work. It is never written to a recovery file, a Study ZIP, an account backup or the #s= fragment, and it needed no DB_VERSION change, new store or record shape change (rule 1 below). The tests are in work-draft.test.ts, on createMemoryDb.
Unnamed working drafts (unnamed-draft.ts)
src/lib/workspace/unnamed-draft.ts (bench-workflows Milestone B, 19 October 2026) keeps at most one unnamed working draft per scope and app in the meta store under suite:unnamed-draft:<len>:<scopeId>:<app>, capped at WORK_DRAFT_MAX_BYTES (4 MiB of canonical JSON, single-pass). Like work-draft.ts, it never enters #s=, recovery files or Study ZIP archives, and requires no DB_VERSION change. In Sequence (src/apps/sequence-toolkit/views/workbench.tsx), unnamed working state that exceeds #s= (> 1,800 characters, GenBank annotations, PCR templates, primer sets or source context) is persisted with overflowedLink: true, auto-restored across same-tab refreshes or navigation, offered via UnnamedDraftBanner (src/components/suite/workspace/unnamed-draft-banner.tsx: "Unfinished work kept on this device", Recover, Discard with Undo) when opening an empty workspace, and cleared when promoted to named Bench work (onWorkSaved) or explicitly discarded. Tests: src/lib/workspace/unnamed-draft.test.ts.
The Bench at /o
src/app/(workspace)/o/page.tsx renders ObjectListRoute, which reads everything from this browser's stores; no server holds the library. Its featured Study notepad (components/suite/workspace/bench-note.tsx) edits StudyData.description and saves with baseRev, so a change made elsewhere is reported rather than overwritten. The library's List/Cards choice is kept in localStorage under suite:library:layout (objects/library-view.ts; List is the default since D-0084, so an absent key reads as List and a saved Cards choice stands; the renamed Library key deliberately starts in List rather than importing an earlier Objects preference, as requested by the CEO on 23 October). A Study's materials keep their own Cards/List choice under suite:study:materials-layout in the same module (Cards is the default there; the CEO, 14 October 2026). Object pages are /o/<type>/<id> and /o/mine/<type>/<uuid>, and Study pages are /study/<id> (content/apps/routes.ts).
The dashboard's libraryRelations projection distinguishes owning Study IDs in
WorkManifest.context.studyId from StudyData.members record inclusion. It
retains both relations and never repairs one by writing the other. Study filters
use their union, including unresolved owner IDs; absence requires successful
scoped reads. useSavedWorkState returns all manifests per input reference,
newest Resume mapping, explicit known/loading/failed status and Retry. Success
and failure share request-generation and current-scope guards; account changes
immediately hide the previous scope, and a failed same-scope refresh cannot
support an unassigned label. Retry includes runtime import failures. Existing
useSavedWork callers retain the newest-manifest map interface.
The dashboard of 13 October 2026 (D-0083) adds two optional fields on StudyData, status (the person's own word, chosen on the Study page) and pinned (leads the Bench's Recent studies), and an optional due date on a next step; all are absent until set, so no schema version or migration changes. It adds two reads: useResultCount calls the snapshot store's count(), a key count over the scope_recorded index bounded to the account's scope (nothing is loaded, verified or cloned), for the Recorded results card; and the activity timeline and the counts flatten useSavedWorkState().value.all into every live manifest. The example Study (workspace/example-study.ts) writes through the ordinary verbs under a run's write context, so its records, links, Experiment and Study carry the run's mark and removeExample finds them. The Study page's useStudyWorkspace reads the Study's work, results, Experiments and change history (studyStore().history) read-only. The Quick start's Examples tab reads each app's labelled example from content/apps/app-examples.generated.ts (a generated module of its own, so the light index stays what every workspace loads); opening one carries the example's records as the app does, and lists none here (D-0045).
App sessions and resume
- Last address. While a person works in an app, the shell keeps the workspace address with its
#s=state inlocalStorageundersuite:<slug>:last-address, suffixed:workspace:<scope>for an account or development scope (shell/last-address.ts).keepableAddressrefuses addresses carrying a one-timehandofforwalkkey, and drops asectionkey (Molecule's tab), on write and on read, so an app reopened from its last address opens on its first section with the same subject (the CEO's feedback of 14 October 2026); a copied link keeps its section. Bench lists these inside Recently viewed; Continue working uses WorkManifest entries. - Research context. The working Study, Experiment and task for a tab are navigation context, not durable work (
objects/research-context.ts, D-0046). The tab's value lives insessionStorageundersuite:research-contextas{ schemaVersion: 1, context, seeded? }, so a choice in one tab never changes another. An explicit choice also records the device's last Study inlocalStorageundersuite:research-context:last-study(per owner), and a tab with no value is seeded from it once if that Study exists and is not archived. This module deliberately uses no storage events or BroadcastChannel. An owner change clears the tab's context (access-client.ts,publish). - Resume address.
resumeWorkspaceWork(src/lib/workspace/runtime.ts) gives Sequence, Protein, Molecule, Pipeline and Assay work its record's own address,/<app>/app#s=mine=<ref>(directResumeHref,src/lib/workspace/resume-href.ts, which refuses a record of another type); the other apps resume through Send to, a hand-off record. A hand-off is resolved only when a page starts (start()inuse-url-state.ts); on a same-document change of address the URL store skips thehandoffkey (acceptInbound). Assay moved to the direct address on 24 October 2026 (CW-02, REL-561): Repeat with new readings, started inside Assay, reopened its new run by hand-off, the address then named no assay, and the page stamped the previous run back.resume-href.test.tschecks the address;e2e/assay-analyser/repeat-lands.spec.tsthe journey. - Named tasks. A task reopens from its
WorkManifest.stateRef;work.tsopenedupdateslastOpenedAt. Its object and manifest change revision only by an explicit act: Save work (and "Save to this work" for kept edits), Record result, Save as new work (for the new task only), Restore version, or "Open current inputs" (D-0068 rule 8; the CTO's ruling 1 of 12 October 2026, invariant W1).WorkCaptureControls(src/components/suite/workspace/work-capture-controls.tsx) keeps edits made in between, the name included, as the task's draft (see Drafts of named tasks): its timer writes the draft 1,200 ms after a change, or removes this tab's draft when the screen matches the record again, and never callssaveObjectBackedWork. The status follows the unit-of-work design (docs/design/assay-stage/unit-of-work-and-project-file.md§2.5, §3.2 to §3.4): "Keeping edits on this device…" until the write commits, then "Edits kept on this device." (quiet, not announced), with thedraftring on the trigger, never the check. An explicit act writes the record from the screen and removes the draft it consumed, only when the stored draft is the one this tab wrote or put on screen (a draft another tab wrote since stays for its own offer); Save as new work leaves the original's object and manifest untouched, and its name defaults toduplicateName("{name} (copy)"). Before navigating away, mounted editors flush throughflushPendingWorkspaceWork(src/lib/workspace/pending-work.ts): the barrier writes the draft and lets the navigation go on, andbeforeunloadwarns only while edits are not yet kept. - A Record result key that confirms first (20 October 2026; the CPO's ruling of that day, 3, "Record with reasons").
WorkCaptureControlstakes an optionalrecordConfirm(label,recorded,open): the Record result key then readslabel, its press handsopenthe recording to run, the app draws its own confirmation dialog and runs it on confirm, and the success line readsrecorded. Assay's combination responses use it when no dose pair was calculated under either model (see app-assay.md, "Combination responses, from 20 October 2026"); Save work never records a result. A combination result is recorded, exported and shown only while its storedinputHashmatchescombinationInputof the sub-record it is stored in (verifyCombinationResult); a result stored with an empty hash between 18 and 20 October 2026 reads but asks for a rerun. - Reopening a task with a draft. The app reads the draft on its own open path and sets its data once (
readOpenWorkDraftinsrc/lib/workspace/work-draft-open.ts, validated by the app's strict reader): Calc's bench sheet through its recipe restore, Assay at both of its open sites (state.mineandopenSavedAssay) throughreadAssayData. In the same state update it sets the control'sappliedDraft({ key, at }of the draft it accepted; null when it refused the draft or opened another record), and it passesonDraftResolved. The control reads the same row only to offer it, inside Work details, and suspends the timer until it is resolved (W3).draftOfferState(applied, onScreen)inwork-draft.tsgives the state (the CTO's ruling on the offer states, 12 October 2026): unshown when the app did not apply the draft; otherwise shown when the screen equals the draft and edited when it does not, compared live, never latched. Shown and edited offer Save to this work, Save as new work and Discard; a shown or edited draft whose record moved on (another tab's explicit save) is stale and offers Save as new work and Discard, with Save work and Record result disabled; an unshown draft offers Discard only, stale or not. No read deletes a draft: a payload the app's reader refuses stays in its row, offered as unshown, and Save work then makes it stale. Discard removes the row at once (only if it is still the draft offered), puts the task's saved name back in Work name and the record back on screen throughonDraftResolved("discard"), and shows a toast with Undo; Undo writes the same draft back to its row and, only while the same task is open in the same scope, puts it back on screen (onDraftResolved("undo")). Nothing waits on a clock, so leaving after Discard keeps nothing, and an Undo after a task switch restores the draft to its own task's row without touching the open one (the PR 21 review, findings 1, 5, 6 and 10). Discard is disabled while the app's control is disabled (Assay during a fit, finding 8). An app that puts a saved record on screen bumps the control'ssavedOnScreenonce its data and settings show, and the control takes that screen as the task's baseline, so a record the app changes as it opens (Assay's raise to record version 6) is not counted as an edit; Assay's raise to version 7 happens on write, never on open (REL-53) (finding 7). An explicit act waits for a timer write already in flight before it reads which draft to clear (finding 4), and the timer does not write while an explicit act runs. A confirmation for a screen with no edits is shown before the kept draft's status line, and a failed save's error is shown in Work details beside an open offer (findings 11 and 16). An app that opens one of its saved objects from an address alone (a bookmark, the object page's link, or after a Study was chosen) looks up the live task that points at it (readOpenWorkinwork-draft-open.ts) and opens that task with it; Assay also keeps its autosave of unnamed assays off for an assay a task owns (finding 2). While the offer waits, leaving keeps the draft and what was typed since stays in the tab. - Limits. One draft per task, replaced in place (the CTO's design): two tabs editing the same task keep only the last write, beside the rule that an explicit act removes only the draft its own tab wrote or showed. All eight apps with object-backed tasks read drafts on their open paths: Calc and Assay, and since REL-32 Sequence, Molecule, Protein, Pipeline, Bioreactor and Plasmid (each app's page, "Kept edits"). Each validates the draft with its own strict reader, sets
appliedDraftwith its data, servesonDraftResolved, opens the owning task from an address alone (readOpenWork), and bumpssavedOnScreenonce a saved record has settled on screen, since every one of them rebuilds its payload with values the record does not hold verbatim (retrieval times, late-loading data versions, regenerated bindings, a dated annotation). A new app that takes drafts follows the same five steps. An app whose saved record fills in late can also passsettlingwhile it does (Plasmid during its first analysis): nothing is kept as a draft and leaving is not held for that screen, unlikedisabled, which still keeps edits made before, say, a fit (REL-41 b). What is still open is REL-41's Molecule case and the CSO's point. A draft above 4 MiB of canonical JSON is refused: "These inputs are too large to keep on this device between saves. Save work to keep them." under the cluster and in Work details, warning tone, with ", or download a project file." and the quiet key only where the app registers a project export (onDownloadProject; none today); any other failed write reads "Edits could not be kept on this device. Save work now." An Assay task reaches the cap at about a quarter of a mebibyte of pasted table, since its saved data is about 15 times its table text (12 October 2026: 0.1 MiB of text gives 1.47 MiB, 0.25 MiB gives 3.71 MiB, 0.5 MiB gives 7.44 MiB). The cap stays at 4 MiB (the CTO's ruling 3 of 12 October 2026); one timer write of a 3.94 MiB Assay draft first took 2,686 to 3,062 ms from the timer's callback to the commit at CPU 4x (median 2,993 ms over three runs, CDPEmulation.setCPUThrottlingRate4, desktop Chromium), so the cap is not raised and slimming Assay'ssourceis Stage B (REL-34). The write now walks the payload once: it reuses the canonical JSON the render computed for the fingerprint, stores the payload as that text (payloadJson; parsed on read), counts its UTF-8 bytes without encoding (utf8Length), and leaves the only copy to the store; the longest main-thread task was then a median 113 ms. The control also compares a screen with a draft as the canonical text and the name apart (sameScreen), never as one joined multi-megabyte string, and the write runs inwork-draft.worker.ts(writeWorkDraftOffThread), warmed when a task opens and used once it has opened IndexedDB; until then, with the memory database, or after a failure, the page writes. Measured on 12 October 2026, five runs at the cap, CPU 4x: no main-thread task over 50 ms during the write, and a median 86 ms from the timer to the acknowledged write (REL-35, fixed).e2e/support/draft-write-timing.mjsrepeats the measurement against a production server. Calc's hand-offs (the send and the plate sheet throughopenPlanInAssay, the stock continuation throughcontinueStock, and the response template export) carry the on-screen inputs only when they equal the saved record; with a draft they are refused with "These edits are not saved yet. Save work first, then send them." (workflow.assayUnsaved, announced as a warning toast and shown under the key until a save, an edit or a task switch; the CPO's ruling 4 of 12 October 2026, reworded for the PR 21 review, finding 12, since a draft from the current session has no Discard) and never consume the draft. The ⋯ menu's object actions (Open in, Pin, Add to Study, Keep and Open its page), which would write the on-screen inputs into the saved record, are replaced by the line "Save work first to keep, pin or open these edits elsewhere." (workflow.menuUnsaved) and the Bench input item while the draft lasts (finding 3). - Guided walk. A walk's run is kept in
sessionStorageundersuite:walk:run(walk/run.ts). - Return highlight. The Objects library keeps the reference of the record a person opened under
suite:returnas{ ref, at }(motion/effects.ts), read once when the list shows that row again, removed when used and ignored after 30 minutes. It is a presentation hint only: nothing is selected or changed by it.
Hand-off between apps (Send to)
sendTo in objects/verbs.ts (implementation in verbs-impl.ts) asks sendPlan in objects/inbound.ts how to open an object in another app (build-spec-objects §5, §7.5):
- Mechanism Q, a plain link. For a public object whose parameters are all query-safe,
linksFor(src/lib/handoff/links.ts) builds/<app>/app?…&obj=<ref>with an optionalvia=<sender>. The receiving app reads the query once withacceptInbound, validates it againstHANDOFF_CHECKS(src/lib/handoff/params.ts) and rewrites the address to the fragment form before making any request. - Mechanism P, a hand-off record. For one of the person's own objects, or any parameter in
FRAGMENT_ONLY_KEYS(mine,walk;src/lib/handoff/fragment-only.ts),putHandoffwrites aHandoffRecordto thecachestore undersuite:handoff:<id>with anexpiresAtseven days ahead (HANDOFF_DAYS), and the link carries only#s=handoff%3D<id>.getHandoffreturns nothing for an expired or absent record; expired records are deleted when the next one is written.
The payload codecs for larger hand-offs are in src/lib/handoff/payloads/. A list payload uses the z: codec in url-state.ts (compressParam: deflate-raw, then base64url). The recipe payload (recipe.ts) carries inputs only, and pins its methods as { module, version, name? } (the name added on 12 October 2026 under the same v: 1, an additive optional field read by the one shared reader; see Calc); Wave 3 added two optional blocks: ph (RecipePhBlock v1, a buffer's pH line, on buffer and multi-component recipes) and plateSheet (PlateSheetInput v1 from plate-sheet.ts, on a serial-dilution recipe with a table; readRecipePayload checks only that it is a versioned object, and each consumer reads it with readPlateSheetInput). From 13 October 2026 (A-4 item 2) a sheet may carry an optional analysis block (AnalysisPlan v1, analysis-plan.ts), additive under the same v: 1; a reader from before it refuses a sheet that carries one (its key list is strict): an older Assay build refuses the whole assay record as malformed (its plannedSheet no longer reads), and an older Calc build drops ps when it resumes a saved recipe or hides the sheet's panel from the address. The exposure is stale tabs and mixed builds during a deploy; every build from that date reads both. Assay keeps a received sheet as plannedSheet, an envelope with the recipe, capturedAt, the sender and a SHA-256 contentHash, never edited. Stage H (BR5, B3; the CTO's day-one memo of 24 October 2026, §4) adds an optional feed block on stock and multi-component recipes only: a Bioreactor fed-batch run's planned feed (RecipeFeedBlock v1 in recipe-feed.ts: the law, the substrate as a solution at Sf in g/L, the program keys pumpProgram writes each with its one unit, a scenario witness { model, preset, inputHash } with the preset as id@n, the stop { at, reason } (tEnd, Vmax or runEnd), the total fed in L as a cross-check, the overlapping flag codes, and the feed's and the hand-off's versions). readRecipeBase checks its shape (a versioned object on an allowed kind; a malformed shape refuses the recipe) and carries it, as it does plateSheet; every reader that acts on a feed reads it strictly with readRecipeFeedBlock and refuses the recipe when it does not read, and a serial recipe calls it foreign. The strict read is not in readRecipePayload (the CTO's H27 substrate-identity memo §5 put it there; the coordinator moved it out on 25 October 2026, because it cost about 1.1 KB gzip in every chunk that reads recipes and put material-summary-calc over its ceiling). The programme's keys and units are the leaf src/lib/hosts/pump-program-keys.ts, which feed.ts re-exports. It travels by a hand-off record beside mine:run/…, never in a query (recipe is a live query key, so the receiving app's address refuses it there). No pump key goes into inputs, which Calc's calculator keys share. A component's amount is the concentration of the form its compoundRef names, in that form's own mass or moles: a hydrate's own mass, never the anhydrous equivalent (the CTO's memo §1). readStockRecipePayload (recipe-stock.ts) is readRecipePayload for stock and multi-component recipes without loading the other kinds' readers, as readSerialRecipePayload is for serial dilutions, plus the strict feed read: it agrees with readRecipePayload on every recipe without a strictly invalid block, and refuses one that has one (tested). Bioreactor's run payload (RunData, objects.ts) gains an optional recipes list (H27): each applied recipe's ref, store revision, label, matched component, value, payload hash and identity provenance, read strictly, never in the link.
drybench.batch-observations v1 (batch-observations.ts, the CTO's B2 observations payload memo of 24 October 2026) carries B2's input: biomass (dry-cell weight) and substrate (the model's glucose or methanol) as mass concentrations in g/L or mg/L per channel, one time unit (h or min) and a declared time zero, the data origin, and the source (label, a mine: ref with its revision or neither, the writer's sha256: witness of what it read). Rows carry an id (no :), the source row, a sample id, a time of 0 or more, the value (negative readings admitted unchanged) and an exclusion with its reason. At most 2,000 rows across both channels and 2 MB. readBatchObservations is strict and structural only: the first failure wins, located by its JSON path, in the order size, lossless JSON, unknown-schema (so a growth-review file is refused by name), unknown-version, the row count, exact keys and types (malformed), unit-not-mass and unknown-quantity per channel, then per row negative-time, exclusion-reason and duplicate-row-id; an accepted payload comes back as a clone. The fit's admission rules stay in src/lib/hosts/fit/admit.ts. The payload holds no hash of itself; the receiver hashes it with contentHashLight. It travels as a file or a hand-off record, never in a URL. The lossless-JSON check it shares with growth-review.ts lives in lossless-json.ts (isLosslessJson; growth-review.ts re-exports it as isLosslessGrowthJson, unchanged). Its writer is the Bioreactor observations import (src/apps/bioreactor-simulator/batch-observations-import.ts; see Bioreactor), which reads one two-channel table and passes what it writes back through this reader.
Shared links and the #s= fragment
url-state.ts defines the grammar (contracts §3, D7). State lives under #s=<encoded>, where <encoded> is the encodeURIComponent of a key=value&… string. Any other fragment is a plain anchor; the test for which is which (isStateFragment, with FRAGMENT_PREFIX) lives alone in state-fragment.ts, with no imports, so the document-policy navigation on every page can use it without loading the codec (REL-247), and url-state.ts re-exports it. The fragment is not sent to the server and is not in Referer. The query string is inbound only. An app may check a large JSON key for shape only in its schema and run the full reader later: Calc checks ep, mm, ip and ps as "a JSON object with v: 1 within a length limit" so its task islands load no reader, and its bench sheet then runs deepDropped (src/components/tools/lab-calculators/address-deep.ts) and drops a failing key with the usual notice. A subject change pushes history, and other changes replace it at most once per REPLACE_INTERVAL_MS (2 s). Links longer than LINK_MAX_LENGTH (2,000 characters) are refused in favour of a file. The addressBar: "static" setting keeps state out of the address bar and in history.state only (D57, use-url-state.ts).
useUrlState (src/components/suite/store/use-url-state.ts) exposes two records of what admission dropped. inbound is what arrived with the page (dropped values, via, example=1) and keeps that meaning for the life of the page. admission ({ dropped, token }, 25 October 2026) is what the current navigation's admission dropped: the arrival, then each Back or Forward. A restore from history.state drops nothing; a restore read from the address itself (an entry with no history state, such as a fragment navigation) carries that address's own dropped values. The token changes whenever the dropped list does, and the snapshot is published even when the normalised state is unchanged; onRestore still runs only when the state changes. Protein reads admission so that a malformed sel blocks the shared range on such a Back too (F5; the CTO's approved scope in docs/design/protein-f4-f5-reviewed-brief.md, "Necessary narrow shared ownership consultation"). No other consumer reads it.
A schema key may be marked fragmentOnly (ParamSpec, Wave 4): acceptInbound drops such a key when it arrives in a query string, with the unknown-key reason, and reads it only after the #. Protein's cam is the first. Before refusing an over-long link an app may shed optional keys: Protein's fitLinkState drops cam and afv, then var, then cmp, never id, and says once that the camera is not in the link (see Protein).
Wave 4 keys, by app:
| App | Key | Shape |
|---|---|---|
| Protein | cam |
Ten numbers px,py,pz,tx,ty,tz,ux,uy,uz,r (position and target in Å and radius to 0.1, the normalised up vector to 0.001), checked by checkCamera in src/components/suite/store/camera-param.ts, a pure module url-state.ts does not import; fragment only. Applied once to the viewer structure names when its distance fits the scene |
| Protein | afv |
AlphaFold DB model version, integer 1 to 99, written only with cam |
| Protein | cmp |
A UniProt accession or isoform to align against; recomputed on open |
| Protein | var |
A substitution as typed, at most 24 characters, shape-checked only |
| Pipeline | tl |
Timeline mode all (default) or phase |
| Molecule | section |
Gains series (the Bench's third section). The series itself never goes in a link: the share dialog offers its file instead (notInLink, onShareFile) |
src/lib/sharing/links.ts turns a link into a /share/<segment>/<subject> preview address only for public subjects: a UniProt accession for Protein, or an authored calculator or operation id for Calc and Sequence (sharedSubject, richShareLink). The rest of the original query and fragment is preserved. Private inputs never reach the preview route. See Security and network for headers and the referrer policy.
Export writers and Study recovery
src/components/suite/data/ holds the writers: csv.ts and csv-bundle.ts (with formula escaping and provenance columns), json-export.ts (mbio-suite-export data envelopes and mbio-suite-project project files, both formatVersion: 1), png.ts and svg.ts (provenance embedded in the image), zip.ts (a store-only ZIP writer and reader) and download.ts (exportFilename: {slug}-{subject}-{kind}-{YYYYMMDD}-{HHMM}.{ext}). Project imports (importProjects) are capped at IMPORT_MAX_BYTES (50,000,000 bytes), checked against the format and the app's schema, migrated, and given a new id unless replacing.
The Assay project file (Stage A, data contract only). src/lib/assay/project-file.ts holds the format of the free, portable form of one piece of Assay work (D-0068; the CTO's rulings 2 and 3), not yet written by the Export menu: an mbio-suite-export envelope of kind assay-project, schemaVersion 1, whose data is { project: { id, label, createdAt, updatedAt, notes }, assays: [exactly one { id, label, assay, fitVersion, tableVersion, provenance }], originals?: [{ ref, name, mediaType, lastModified, size, sha256, base64 }], integrity?: { algorithm: "sha256", canonical: "canonicalJson", hash } }. The hash is contentHashLight of data without integrity. readProjectFile(text, readAssay) refuses above PROJECT_FILE_MAX_BYTES (50,000,000), anything that is not this kind or the assay-session kind, a newer schemaVersion (its own code, so the app can say the file is from a newer version), other than one assay, unknown keys in data and any malformed part, naming the part (path); it reads the assay-session file as a one-entry project; it returns each original with verified (its bytes hash to the receipt) and integrity as intact, mismatch (changed outside the app; it still opens) or absent. It knows nothing of Studies. The recent-files list (readRecentFiles, withRecentFile) holds { name, size, sha256, openedAt, labels }, at most 20, never content or a handle, for assay-analyser:recent-files:v1 in meta (not yet stored). Tested in src/lib/assay/project-file.test.ts.
The suite's project file (project@1; written and opened by Assay from A-0 commit 2b). src/lib/project/file.ts implements docs/design/project-file-and-grid-contracts.md §1 to §3 and §6 as the CTO and CPO ruled them on 12 October 2026: kind project, schemaVersion 1, in the mbio-suite-export envelope; data is { project, records, originals?, integrity? }; each record is { app, type, schemaVersion, id, label, data, engines?, provenance?, versions?, omitted? }, its schemaVersion equal to data.v where the data has one. readProjectFile(text, { app, readers, counts, adapters }) reads strictly in three layers: the envelope and every record's keys refuse what they do not know (malformed with a path such as records[1]); a record the opening app owns goes through that app's reader (passed in) and refuses the open when newer than the suite index knows, under an unknown type, with schemaVersion unequal to data.v, or when its reader names a problem (wrapped as { code: "record", index, app, type, problem }); a record another app owns is opaque, returned in carried with its index and whether it is newer, and recordsForSave writes it back unchanged in its place (checked as canonical JSON). The originals go with the work that references them (contracts §2 MUST; the CTO's Sequence ruling, Q6): originalsToCarry keeps, exactly as read and verified or not, every original in the opened file that none of the opening app's records references, and originalsForSave writes them after the app's own (never twice by ref), so a file another app saved keeps its raw files when Assay saves it again (project-1-carried-original.json, open-project.test.ts). The opening app's declared count (record-count, naming the type in full). Counts and readers name each type in full, <app>/<type> (assay-analyser/assay), as the registry holds it; readProjectFile throws a TypeError before reading if one names another app or a bare type, a programming error rather than a problem with the file (the CTO's Sequence ruling, Q1), a newer schemaVersion of the file (schema-newer), an unknown kind (not-a-project), JSON, the 50,000,000-byte cap (too-large, naming bytes and cap) are refused; originals are bounded by the file cap in project@1, verified against their receipts; versions are the record's VersionRecord rows less local fields plus schemaVersion; omitted names what the person left out (reduced). sealProjectData hashes data less integrity and refuses a save above the cap (ProjectTooLarge). Older single-app kinds open through adapters the app passes in (ASSAY_KIND_ADAPTERS in src/lib/assay/project-file.ts). src/lib/project/record-types.ts indexes the record types (assay-analyser/assay 1 to 8 since REL-53, sequence-toolkit/record 1; a project file carries an Assay record's version as its schemaVersion, equal to data.v, so a build that knows only v6 refuses a v7 record as newer) from each app's version module, which imports nothing. src/lib/project/detect.ts is the import layer's stage one: sniffBytes (the first 16 KiB, decoded by decodeText, text only when it reads as text), sniffText for a paste, detectImport (certain, likely, possible; hinted detectors first within a tier; a hint alone gives "possible") and projectDetector (certain on the envelope's format and a project kind, never on the name). Fixtures: pipeline/assay-analyser/fixtures/project-files/project-1-assay-and-sequence.json and project-1-two-assays.json (make-project-v1-fixtures.mts), and project-1-carried-original.json (make-project-carried-original-fixture.mts: a raw read only the sequence record references). Tested in src/lib/project/file.test.ts. The words are in content/apps/project-file.ts.
A Study can leave the browser in two forms:
- Study ZIP (
objects/study-export.ts,studyBundleFiles): the Study's project file, member objects and links, each contributing app's provenance, a manifest with formatmbio-suite-study-bundle, and alsoworkspace-recovery.json(the recovery bundle below) andstudy-report.html.
The Study's Export report (PDF) action downloads an A4 PDF directly in the
browser. It first flushes pending work and Study edits, then exports one
transaction snapshot through the portable Study service. The readable HTML
action and PDF share workspace/study-report-document.ts: every retained result
is listed, and prepareNotebookBlock verifies its hash and exact input set before
using the existing schema readers. Captured result states, withheld quantities,
qualifications, dates, method/data pins and input identities survive unchanged.
Current saved Study notes and material context are labelled separately.
Only version-1 captured provenance blocks and explicit recovery envelopes supply
source names, retrieval/data dates, attribution, licence identifiers/read dates,
share-alike flags and captured method references. Missing fields say Not
recorded. Calc recipe version 1 additionally supplies retained input values and
units, method pins and constant-table identifiers. Constant values and historical
method prose are never filled from the current registry. The optional
StudyReportOptions.methods adapter accepts the exact result/inputs and returns
version-resolved paragraphs, qualifications, omissions, template ID and receipt
facts; unavailable coverage stays explicit. The report renderer has no historical
method registry of its own.
Retained figures use the notebook reader's bounded stored coordinates (supported Assay dose-response and calibration captures). No fit or curve is regenerated. Series retain distinct colours and matching shape/colour keys in the PDF. Unsupported schemas, refused results and absent or oversized figures retain their omission notices. Raw original files remain in the separately downloaded recovery bundle, not the PDF. The action guards scope ID, scope epoch and component lifetime after asynchronous work, and synchronously locks duplicate exports. Study identity and lifetime guards update during the layout phase, before a pending export can resume after navigation. Exports do not write or reanalyse stored scientific records.
- Recovery file (
src/lib/workspace/bundle.ts): JSON with formatdrybench.study.bundle, written atformatVersion: 6and downloaded as….drybench-study.json(components/suite/workspace/study-recovery-controls.tsx). It holds one Study's current work and the dependency closure of its selected recorded results. Global pins, tags and measurements are excluded (validateObjectrejects them), as are unrelated Studies.
STUDY_BUNDLE_LIMITS is 50 MiB, 10,000 records, and 2 MiB per inline file (ORIGINAL_FILE_MAX_BYTES). validateStudyBundle accepts format versions 1 to 6 and checks the whole bundle, including snapshot hashes, before anything is written. restoreStudyBundle then commits in one transaction across projects, objects, links, versions, the workspace* stores and meta. It gives private identities new ids, reuses public identities without overwriting the destination's own fields, and records a ledger entry in meta under suite:workspace-import:<contentHash>, so repeating the same restore returns the existing import unless "another copy" was chosen. createLegacyStudyBundle exports from the anonymous namespace only while signed out ("Recover browser work").
Account backups queue the same bundle in meta under suite:workspace-backup:<len>:<scope>:<id> (backup-queue.ts), limited to BACKUP_MAX_BYTES (4 MiB). The upload and acknowledgement are covered in Server, accounts and billing.
Cross-tab synchronisation
| Channel or event | Name | Message | Sender |
|---|---|---|---|
| BroadcastChannel | mbio-suite:<app>, plus :workspace:<scope> unless the scope is null or "browser" |
{ t: "write" | "delete" | "open" | "here" | "close", projectId, rev?, tabId } |
projects.ts: changes and presence |
| BroadcastChannel | mbio-suite:objects, plus :workspace:<scope> for any non-null scope |
{ t: "objects", refs, tabId } |
objects/store.ts |
| BroadcastChannel | mbio-suite:workspace |
{ scopeId }; receivers act only for their own scope |
lib/workspace/runtime.ts (notifyWorkspaceWork) |
storage event |
suite:theme, suite:settings, suite:library:layout, suite:study:materials-layout, suite:notepad:<scope>, shell layout keys |
the changed key | theme, settings, library view, Study materials view, the notepad, shell/local-value.ts |
storage event |
suite:workspace-session-changed |
a random value | announceWorkspaceSessionChange; other tabs clear and re-check access |
A tab ignores its own messages by tabId. A remote project change emits a change event. The session controller brings another tab's save in through the app's restore() when nothing is waiting to be saved, and its autosave uses saveMerging, which merges independent keys or opens the conflict dialog. Studies use the project store channel for app suite.
Theme and other preferences
The theme is one key for every page: localStorage["suite:theme"] with light, dark or system (theme/preference.ts, D-0029). DEFAULT_PREFERENCE is light (D-0035). On first read, createThemePreferenceStore migrates a valid old suite:<slug>:theme, or for the suite scope drybench:landing-tone, only when no shared choice exists, and writes it to suite:theme. The inline first-paint script in shell/theme-script.tsx applies the same rule before hydration. A blocked store keeps the choice in memory for the tab. clearLocalData(slug) removes only suite:<slug>:* and the legacy mbio-suite:<slug>:* keys, so it leaves the theme alone.
Other suite-wide settings (reduced motion, density, larger text, number precision, keyboard and address-bar mode) are one validated JSON value in suite:settings (theme/settings.ts).
Storage key reference
| Key or database | Area | Owner module |
|---|---|---|
mbio-suite (database, version 5) |
IndexedDB | store/idb.ts |
\u0001workspace:<scope>:… physical key prefix |
IndexedDB | store/scoped-db.ts |
<app>:marker, <app>:persist |
IndexedDB meta |
store/projects.ts |
suite:objects-sync |
IndexedDB meta |
objects/store.ts |
suite:workspace-import:<command> |
IndexedDB meta |
lib/workspace/bundle.ts |
suite:workspace-backup:<len>:<scope>:<id> |
IndexedDB meta |
lib/workspace/backup-queue.ts |
suite:work-draft:<len>:<scope>:<workId> |
IndexedDB meta |
lib/workspace/work-draft.ts |
suite:handoff:<id> |
IndexedDB cache |
objects/store.ts |
suite:theme |
localStorage | theme/preference.ts |
assay-analyser:recent-files:v1 ({ key, files }, at most 20, each row exactly { name, size, sha256, openedAt, labels }) |
IndexedDB meta |
lib/assay/project-file.ts (recentFileStore) |
suite:settings |
localStorage | theme/settings.ts, theme/appearance.ts |
suite:device-id |
localStorage | objects/store.ts (DEVICE_KEY) |
suite:<slug>:last-address[:workspace:<scope>] |
localStorage | shell/last-address.ts |
suite:storage-note-hidden |
localStorage | workspace/entry-banner.tsx: the Bench and Study storage line hidden once (the CEO's feedback of 14 October 2026); the sidebar's "On this device" still says it |
suite:research-context:last-study[:workspace:<scope>] |
localStorage | objects/research-context.ts |
suite:library:layout |
localStorage | objects/library-view.ts |
suite:study:materials-layout |
localStorage | objects/library-view.ts |
suite:notepad:<scope> ({ text, at }, at most 20,000 characters; one draft per account, D-0087) |
localStorage | workspace/notepad-model.ts |
suite:banner:<key> |
localStorage | feedback/banner.tsx |
suite:consent:<host> |
localStorage (never) or sessionStorage (session) |
net/consent-dialog.tsx |
suite:workspace-session-changed |
localStorage (signal only) | lib/workspace/access-client.ts |
suite:<slug>:<key> (rail, inspector, view and similar) |
localStorage | per app, via storageKey |
mbio-suite:<app>[:workspace:<scope>]:had-projects |
localStorage | store/projects.ts |
suite:research-context |
sessionStorage | objects/research-context.ts |
suite:workspace-access:v1, suite:workspace-context-owner:v1 |
sessionStorage | lib/workspace/access-client.ts |
suite:walk:run |
sessionStorage | walk/run.ts |
No storage key was added in Wave 4. Two new record shapes live in existing stores: Molecule's saved series, a person's own note object whose data is { text, moleculeSeries: SeriesRecord } (v: 1, kind: "molecule-series", the table text, the column mapping, up to 200 row results, and the fingerprintPolicy, rdkit and descriptorVersion they were computed under; a mismatch recomputes; see Molecule); and the walk's example series, saved the same way with a source block naming ChEMBL, its release, retrieval date, licence and document.
Module paths without a src/ prefix are under src/components/suite/. New keys should come from storageKey(slug, key) or suiteKey(key) in store/keys.ts, so no app types a prefix by hand.
Changing a stored shape safely
These rules follow from how the code handles versions today (see also contracts §4.7).
- IndexedDB layout. Bump
DB_VERSIONonly to add a store or an index, and add it toSCHEMA;upgradeSchemacreates what is missing. Never bump it for a change of record shape. Decide whether a new store holds scope-tagged rows (add it toSCOPED_ROWSinscoped-db.ts) or prefix-namespaced rows. Add it to the recoverySTORESlist if restore must write it. - Project data (Studies, app sessions). Adding an optional field does not need a new version, but the field must also be added to the kind's schema (
studySchema, or the app'sStorageKind.schema).checkDataruns on everyupdate, and the validator instore/schema.tsdrops unknown keys, so an unlisted field is silently lost at the next save. For an incompatible change, raise the kind'sschemaVersionand add a one-stepmigrate(kind, fromVersion, data).migrateDataapplies the chain in memory on read, and the next save writes the result back; opening never writes. A stored version newer than the code throwsNewerSchema, and an import newer than the code is refused. Study versions 1 to 4 were additive; 4 to 5 moves the research brief's next step intotodos(migrateStudyStep). A command that reads a raw row in a transaction and writes it back must migrate it first (readStudyRow), or the current check drops the older field. - Workspace records.
WorkManifest,InputSnapshot,ResultSnapshot,WorkspaceAssociationandExperimentDataare validated againstschemaVersion: 1, and an unknown version is refused with a message that keeps the original record. Never rewrite a recorded result or input in place: itscontentHashcovers the stored body and is re-verified on export. A new shape needs a new version that the validators and readers accept alongside the old one. App payloads inside snapshots name their ownPayloadSchema({ id, version }), and readers list the versions they accept, asretainedOriginalsinbundle.tsdoes forassay-analyser.assayversions 1 to 5. Adding optional fields inside an existing payload version is backward-compatible only if old readers keep unknown fields or refuse the record: Assay addedenzyme,growth,inhibition,thermalandplannedSheetto its v5 record, which an older v5 reader either refuses (an unknownanalysisMode) or reads without the new field (see Assay). - Objects.
ObjectRecordhas no version field. Add fields as optional, so that a record without them keeps its earlier meaning, ascarrieddoes under D-0045. Keepdataper type backward-readable. - Recovery files. Add a new
formatVersionto the accepted list invalidateStudyBundleand keep reading the older ones. The Study is migrated by itsschemaVersionfirst (currentStudyinbundle.ts), and every check reads the migrated data; it must then survivecheckStudyDataunchanged, so a new Study field must be instudySchemabefore bundles carry it. The file is verified and returned as written, because the recovery screen hands the validated bundle back torestoreStudyBundle, which reads it again and writes the migrated Study. - Browser storage values. Validate on read and fall back to a default, as
theme/settings.tsandlast-address.tsdo. Version the value (schemaVersioninside the JSON, as insuite:research-context) or the key (:v1), and treat an unknown version as absent. - Tests. The store tests run against
fake-indexeddb.test-util.tsandcreateMemoryDb. Add a fixture of the previous shape and a test that it still opens, migrates and saves. See Build, test and CI.
Bench note disclosure continuity (11 October 2026)
Bench keeps each work note editor mounted inside Work details in both recent and library views. Closing the disclosure preserves the draft. Resume, library collapse and filters consult the existing pending-work barrier; a blocked note opens every enclosing disclosure and focuses its field, or its summary while the field is disabled. Async note errors also reveal the disclosure. Dirty or saving rows stay mounted through recency updates, even outside the ordinary three-row window. These are UI continuity changes, with no persisted schema or recorded-input/result changes.
The homepage intro records suite:home-intro-seen in localStorage as
playback begins. Returning visits skip the overlay before first paint;
blocked storage is caught. Guided-walk run persistence stays in the existing
sessionStorage record; collapsing descriptive text does not alter the run.
Calc's optional RecipeComponentPayload.stock retains a recipe row's liquid-source
concentration and unit (16 October candidate). It describes a calculation input,
not physical inventory. The reader accepts positive finite concentrations in known
molar/mass-concentration units, and refuses malformed stock fields. Old payloads
remain readable without a migration. Protocol checkboxes are temporary UI state.
Historical methods text (16 October 2026)
prepareRecordedMethods(result, exactInputs) clones before its first await,
verifies each immutable snapshot and exact membership, then resolves exactly one
primary input by schema and captured object reference/revision. Recovery remaps
those references together; the two-recovery test preserves methods meaning while
local receipt identities change. Work revision and editable source revision are
separate receipt fields. Results are deeply frozen in memory, with no persistence
schema, store write, migration, lookup, refit or current-version substitution.
recordedMethodsText(ready) exports the paragraph, all recorded and additional
qualifications, template ID and complete evidence receipt. Hash verification is
an integrity check, not independent scientific validation. An unsupported schema,
method, variant or missing input context produces a typed unavailable response;
invalid hashes or missing exact snapshot membership throw validation errors.
Historical reporting restrictions and synthetic/edited dataset classification
remain attached. New scientific wording requires a reviewed text-template
revision; old method support is literal and independent of live engine constants.
Study HTML/PDF exports call this same resolver for each result and its exact inputs. Supported paragraphs and all qualifications are included with the full Methods evidence text (both revision labels, identities, hashes, dates, evidence, method layers and data pins). Unsupported coverage adds its reason and receipt without inventing a paragraph or template. Integrity failures stop the export; owner/epoch checks surround the resolver's asynchronous boundaries. PDF generation never reruns a calculation to fill missing historical information.
Historical Methods and notebook preparation import immutable hash/schema checks
from snapshot-verification.ts; snapshots.ts re-exports the same functions for
existing callers and retains persistence. No checks or exception messages change.
Notebook preparation loads only required recorded-app projectors. Descriptive
report context lives in report-context.ts, so Study PDF does not import the
legacy full report renderer. These are loading-boundary changes, not migrations.
Study data validation and read-only membership transforms live in
objects/study-model.ts and study-membership-model.ts; study.ts retains its
existing exports and persistence setup. The unchanged deterministic link helper
is in objects/link-id.ts, re-exported by the store. Transaction readers import
these leaves rather than the object store's UI/measurement setup. Historical
context's strict reader lives in historical-context-model.ts, avoiding a cycle
with transfer provenance. Report summaries similarly share only their small
content leaves and report-context.ts, and use plate geometry without importing
the Assay parser. workspaceExperimentStore() loads its implementation on demand
inside the existing asynchronous factory. No saved schema, values, validators or
exception behavior changes through these import-boundary refactors.
Public reference workflows (16 October 2026)
Reference inputs apply only when explicit arriving URL/history inputs are absent. Empty #s= requests a blank workspace. Reset flushes pending work and unmounts old writers before replacing the address; saved database rows are not deleted. See Public reference workflows for the shared route, state, source and export contracts.
Study content digests and RO-Crate metadata (17 October 2026)
The former Study page's "Content digests and RO-Crate" (removed from the UI at the CEO's request on 23 October; its underlying export utilities remain) used the ⋯ menu and the Provenance
rail to open src/components/suite/workspace/ro-crate-export-dialog.tsx. On
open it saves pending work and reads the same bundle Export Study builds
(exportWorkspaceStudy, or exportLegacyWorkspaceStudy signed out), with the
same account-scope checks, then src/lib/workspace/ro-crate.ts computes
SHA-256 digests with contentHash (contracts.ts, WebCrypto over the suite's
canonical JSON):
- each input snapshot:
{ schema, payload }, its contents, not its id; - each object record the bundle carries:
{ type, data }; - each recorded result:
{ id, app, schema, methods, dataVersions, computedAt, inputs (the digests of the inputs it read), settingsDigest, outputDigest }; - the Study: the ordered
[id, digest]lists of the three (inputs and records by id, results bycomputedAtthen id). The Study name is not included, so a rename is not a content change.
The dialog downloads the digests file (schema study.content-digests,
version 1) and ro-crate-metadata.json, and compares the Study with a digests
file chosen by the person (compareStudyDigests: changed, added and removed
inputs, records and results by name; a file from another Study or of another
shape is refused). Digests identify content only: they are not a signature or
a timestamp and do not record who changed what; anyone who edits the data can
compute new ones. The copy (content/apps/study-digests.ts) says so.
The RO-Crate metadata is flattened JSON-LD with the RO-Crate 1.1 context and
descriptor: a root ./ Dataset (the Study digest as identifier), one Dataset
per input, record and result, a SoftwareApplication per app and per method
version, and a CreateAction per recorded result (instrument, object,
result, endTime) referenced from the root by mentions. It holds digests
and names, not the data; Export Study carries the data. RO-Crate 1.1 requires
a root licence: by default it is the contextual entity #licence-none
("No licence granted"); CC BY 4.0 or CC0 1.0 is written only when the person
chooses it in the dialog. The fixture crate passes rocrate-validator 0.12.2,
profile ro-crate-1.1, at severity "required" (author and publisher remain
recommended and are not invented).
Markdown table export (17 October 2026)
src/lib/export/eln-export.ts writes one format: a GitHub-Flavored Markdown
table (serializeToGfm) that Benchling, Notion and most notebooks accept on
paste, used by Calc's normalization panel. A | in a header or cell is
escaped and a line break becomes a space, so a value cannot add a column or end
the table; the caption is one line with * escaped. The attribution line,
from content/apps/eln-texts.ts, names the app, the suite origin
(SUITE_ORIGIN), the method and the time when given, and makes no claim about
servers.
Saving primary subjects with work (18 October 2026)
WorkCaptureControls prepares explicitly supplied primary objects using the existing identity/allowed-facts path as carried records. This source preparation happens before the work transaction and may cache the source even if saving later fails. It does not announce successful saving or pin the source.
saveObjectBackedWork extends its existing StateCommit to include projects, records and links when studySubjectRefs are supplied. saveWorkSubjects resolves canonical identities in that transaction, validates the destination, promotes newly attached carried subjects, and uses changeStudyMembershipInTransaction plus writeStudyOrganisation to commit material membership, relationships and one Study history revision with the editable input object, work manifest and work association. A failed final write rolls all those changes back. Notifications use the originally captured scoped stores after commit.
New work attaches its primary subjects. Existing work uses its saved owner, regardless of the default selection. A changed primary subject is added without removing earlier materials. Unchanged references in the prior manifest are not reattached, preserving explicit removals; timer drafts never modify membership. Historical Protein work already had subjectRefs before unified saving, so an absent material there remains absent on re-save; Add to Study explicitly restores/adds it. No migration can safely infer whether that absence was deliberate. Save as new begins a new membership decision and keeps the original inputs and recorded-result pointer intact. Without a Study remains valid.
Aliases are canonicalised and duplicate memberships coalesced without losing their notes. The existing future-schema and archived-destination guards remain. No database version, scientific identity rule, engine, fixture or recorded-result shape changes. Tests: save-work-subjects.test.ts and e2e/objects/unified-save.spec.ts.
Contract 07A comparison read model
src/lib/workspace/run-comparison.ts strictly reads complete version-1 selection
objects, groups by current result association and app/task identity, and verifies
retained result/input digests and exact input sets. Missing evidence remains an
explicit refusal. run-comparison.test.ts drives all 23 frozen Python cases
through the reader and compares every field at declared tolerance zero. It adds
no scientific calculation or persisted schema.
run-comparison-read.ts reads the Study, Experiment identities, current
associations, saved task labels, results and exact inputs in one database
transaction. Hashing follows on detached captures. Scope/epoch guards run before
and after awaits. verifyStudyRunPair repeats the read and checks the captured
association revisions, result/input hashes and selected identities, even for
same-task pairs. Captured context is never replaced with current filing. The
final verifier re-reads current Study/Experiment membership and byte-identical
associations/results/inputs after asynchronous hashing, so detachment during
hashing cannot pass.
src/components/suite/workspace/run-comparison.tsx (RUN_COMPARISON_CHUNK_MARKER = "drybench-run-comparison-v1", budgeted as the lazy study-run-comparison
chunk in scripts/size-budgets/study-report.json) mounts on demand from
experiments-panel.tsx (Compare recorded results, available whenever the Study
can be read). Selection state (firstExperiment, secondExperiment,
firstResult, secondResult) is held in component memory only and resets on
scope, epoch or Study identity change without persisting a new schema or URL
fragment. Both Experiment selectors start blank (""), disambiguate duplicate
Experiment labels with their stable references (label · ref), list historical
revisions per Experiment with both Currently filed in and immutable Recorded in provenance, and expose Study-assigned results without a live Experiment in a
Other recorded results in this Study section, always shown when it has rows
(Unassigned and Experiment unavailable groups). When a verified eligible pair is selected,
result-comparison.tsx renders in controlled pair mode (comparedResult,
verifiedInputs, onBeforeCite), hides the duplicate opponent selector, emits
the applied Response scale row beside Normalisation in assayComparisonRows,
and gates Cite both recorded results behind verifyStudyRunPair before
appending to the Study conclusion draft. A pair check that finds a changed
stamp (result-membership: an association, Experiment revision or captured result
changed since the choice) clears both chosen results through
run-comparison-selection.ts and shows pairCleared; the refresh that follows
cannot show the pair again until both results are chosen anew, even when the
result is filed as before (A→B→A; the CTO's ruling of 18 October 2026, audit of
20 October 2026 F8). Other failures keep the choice and show their problem. The
open and close buttons and the download failure are typed once, in
workspace-experiments.ts.
Bench execution reference draft (18 October 2026)
Feature 14 is not implemented. pipeline/workspace/bench_execution_reference.py
is an unpublished independent Python draft following the three 18 October
bench-execution officer memos. It derives 30 plate acceptance/refusal cases
(15 accepted) from the Fraction plate oracle, 13 canonical string/hash vectors,
29 creation-source refusals, ten binding cases, nine hash-domain mutations,
six hash/version cases and 61 synthetic researcher-action transitions.
--out PATH refuses overwrite; --check PATH independently rederives the
entire deterministic UTF-8 file byte for byte. Source and helper hashes are
recorded. Scientific comparisons use relative tolerance 1e-9 (zero exact);
canonical bytes, hashes and state use exact comparison.
Accepted sources are synthetic records conforming to current saved Calc
recipe, Work and association shapes, with separate source-payload,
print-input and frozen-plan hashes. Qualifications retain content-file hashes;
material/stock/version bindings retain consumed receipts. Work and Object
revisions deliberately differ. Negative zero is a serializer-only vector:
the strict workspace reader refuses it. Optional amount notes and correction
chains have explicit acceptance/refusal cases. Timer takeover requires explicit
review; consuming the exact remainder ends the reminder and zero cannot resume. The draft README at
pipeline/workspace/bench-execution-draft/README.md states each boundary.
Command envelopes/replay, complete strict readers, bounds, broader source
variants, runtime interruption, database atomicity and recovery cases remain
required before publication or engine implementation. No public verification
version is reserved. Recovery's bundle.ts and editable-references.ts are
held by Stage C; their ownership must be resolved before the full feature
can ship. No new stored type, write path, session surface or recovery
behaviour is enabled by this draft.
Bench execution component references (20 October 2026)
The CEO released the required shared integration files on 20 October. The
independent references now precede their consumer implementations:
bench-execution-plan-1.json retains 30 independently derived planning cases,
including 15 accepted frozen plans; bench-execution-plan-reader-1.json
provides 96 literal structural/hash/version expectations; and
bench-execution-reducer-1.json supplies 395 action transitions (98 accepted).
These files live in src/lib/workspace/fixtures/, with standalone Python
scripts under pipeline/workspace/ and public copies under Calc 0.10.0.
The fixture manifest pins their bytes and scripts.
The reducer catalogue is synthetic and contains only step/field identities. Its entry labels are not persistence UUIDs. It covers explicit marks, retained notes and amounts, linear correction chains, lifecycle actions, reminder ownership/review and malformed-action refusals. Nonfinite and negative-zero inputs are described explicitly and reconstructed by tests. Timer review requires needs-review; an ended reminder cannot be revived by takeover. These component fixtures do not establish complete command, database, scope, recovery or browser acceptance. No execution type or UI is enabled at this fixture checkpoint. The full Feature 14 acceptance contract remains in force.
Assay repeats carry an explicit reviewed destination (or standalone), separate source/destination Study revisions and the invocation membership revision. The atomic duplicate transaction rechecks these, the live source and exact retained historical result/input envelopes. Scope epochs guard asynchronous work. New object/work identities carry only the reviewed Study, no previous Experiment, readings or result; archived source/destination Studies refuse.
study-result-preview.ts verifies result and complete exact input hashes outside
transactions, then rechecks an authoritative read receipt in a short transaction.
The receipt can be checked again after projector loading. Essential context is a
pure selector over captured inputs and the existing operation projectors; no live
material lookup or recalculation. Supported app readers are loaded on display
intent, malformed required input withholds the preview, absent optional context
says Not recorded, and unsupported summaries retain omissions. Raw mass records
are reduced only to explicit retained form, mass, origin and table declarations.
Prepared Study exports reuse the complete bundle collector's tracked reads. One validated capture plus row/list receipt drives section preparation and generation; checking the receipt rereads authoritative dependencies without exporting again. Hashing, module loading, report layout and PDF rendering stay outside IndexedDB transactions. The UI observes existing work/object/Study change signals and checks actual drift, with final active-session/generation/identity and scope-epoch guards. Backup preparation and each queued send check mounted Study lifetime and epoch.
Assay capture versions 1–8 use assay-captured-input.ts, independently of the
complete editable-envelope reader. Shipped v6 captures may omit editable table
decisions and early captures may omit parser shape/skipped bookkeeping. Absence
stays absent: shared strict source guards validate every finite reading, declared
molar unit and retained plate/layout/settings; mode subrecords use their existing
strict readers on intent. Hash-valid malformed required scientific input refuses
the preview/export before values project. The editable parser is unchanged. The shared context keeps declared dataset lineage,
qPCR method/identities/efficiency convention, status and source, stock carrier and
accepted mismatch, calibration weighting, and retained historical source identity.
The ZIP helper also receives the original Study caller's lifetime check, runs it
before flushing and after every asynchronous boundary, and checks immediately
before download. It cannot finish a download after its caller unmounts while
remaining in the same account. Portable bytes and full recovery selection are
unchanged. Strict notebook block preparation and inert HTML/text/SVG writers have
separate implementation bodies with the existing notebook.ts compatibility API;
its copy has a narrow shared leaf.
The Study-only context presentation module classifies exact dose-response assignment keys against each captured input. It preserves assignments in More captured context and states the distinct recorded roles beside the finding, without claiming control quality passed. Only explicitly absent reference fields join that disclosure. qPCR, unknown/control-availability states, exclusions, normalisation/units, conditions, replication, deviations, read-basis missingness and all qualifications stay adjacent. Complete report facts are untouched. Provided row decisions and exclusions must satisfy their existing strict readers; missing editable bookkeeping in historical captures is still accepted.
Export owners are separate: study-report-export.ts serves the existing HTML-only
recovery control and loads complete document preparation; prepared-study-export.ts
owns active-capture PDF selection/generation and loads the existing overview/PDF
formatters. study-report-historical-methods.ts is their shared historical reader,
with no reverse orchestration import. Complete HTML no longer dispatches to
PDF overview. This removes false dynamic owner edges without changing snapshots,
method receipts, report facts, markers or ceilings.
The two export owners prepare immutable report evidence from the already validated
capture bundle. study-report-evidence.ts verifies exact snapshots in result input
order and calls the existing strict notebook preparation, recorded projector,
scientific-context reader and historical Methods callback. Guards run before work
and after every awaited stage boundary. The returned clone is deeply frozen and
carries bundle hash, Study identity/revision and original result/input identities
beside captured provenance. No store access, capture, persistence or provider call
occurs in this stage. Pure formatters consume its data; overview ordering uses a
copied array. Section changes may repeat preparation from the same capture, without
a cross-interaction cache. Paper validation and all formatting policies stay in
their existing owners.
Retained sample-normalisation plans (24 October 2026)
Calc’s manual/source/mixed normalisation plans use the existing object-backed Save work route, recipe envelope v1 and strict np v1 input. Manual plans require a null source key and no source-backed rows; a malformed ns or present-invalid np is preserved as a visible refusal, never replaced with defaults. The generic Bench picker consumes an allowlisted calculator snapshot (task, norm, optional ns, np) with no recursive bindings, and pins its exact object/revision/version and payload hash. Other recipe binding contracts remain unchanged.
Normalisation Record result writes lab-calculators.recorded-result v2; other calculation results remain v1 and inputs remain lab-calculators.recipe v1. The existing immutable snapshot store supplies revision/scope guards, atomic input/result transactions, replay-safe retry and failure retention. The capture records full raw settings, ordered stable row identities/origins, complete source receipt and qualifications, full-precision output/refusals, source omissions and method/unit pins. These are calculated loading plans, with synthetic status retained where applicable; no physical execution is implied. Blank/non-finite successful outputs refuse recording and retain the editable draft.
Historical projection and methods text consume strict saved fields without a planner or live lookup. The exact primary input must match state ref/revision/schema even when additional recipe bindings exist. Unknown versions and inconsistent records withhold numerical projection. Workspace Study backup/recovery preserves scientific payload/settings/output and original transfer receipts while remapping outer identifiers/scope and computing new envelope hashes. Existing snapshot integrity verification applies. No Calc project-file record type or store migration is added. See Calc.