ResearchOS/Wiki

Sharing and permissions

Every shareable record in ResearchOS (a method, a note, a task, a project, a high-level goal) carries one sharing field, shared_with, an array of small objects pairing a username with a permission level. That field plus a single sentinel value for 'the whole lab' is the entire permission story. Two primitives, canRead and canWrite, build on top of it. PIs get an implicit view-all on the read side and write-all on the write side, gated by a once-per-session UI confirm in record popups. This page is the canonical reference for how the model works.

The In your lab tab of the share dialog. Each recipient has its own read or edit toggle, and a Share with the whole lab toggle covers present and future members at read-only by default.

The shared_with array

Every shareable record carries a field. Here is its TypeScript signature.

type SharedUser = {
  username: string;
  level: "read" | "edit";
};

shared_with: SharedUser[];

Each entry pairs a recipient with the level of access they get on this record. An empty array means "private to me", since the owner is implicit and never appears in their own list. Adding an entry grants that user the chosen level. Removing the entry revokes access entirely. The shape lives in frontend/src/lib/sharing/unified.ts, which is also where the read and write helpers documented below are defined.

Inspecting your JSON folder, you will see the objects on disk. Here is a method shared with two members at different levels.

"shared_with": [
  { "username": "alex",   "level": "read" },
  { "username": "morgan", "level": "edit" }
]

The WHOLE_LAB_SENTINEL

A reserved username, the constant WHOLE_LAB_SENTINEL with the value "*", can appear in any entry where a real username would. It means "every user in this lab folder, present and future." When you flip the Share with the whole lab toggle in the In your lab tab of any share dialog, the affordance writes one entry, { username: "*", level: "read" }, rather than enumerating every current member (which would silently exclude anyone who joins later). Whole-lab shares default to read-only. Bump that one entry to level: "edit" the same way you would any other recipient row if you want the whole lab to be able to edit.

The sentinel is comparable to a Unix everyone group: one entry that expands to the current member set at read time. The backing list is not stored, so adding or archiving a lab member never requires a sweep across every record to keep things in sync.

Inventory items are a special case. New inventory records are created with { username: "*", level: "edit" } by default, so the whole lab can add, edit, and decrement stock out of the box. You can tighten this per-item by editing its sharing in the inventory popup.

Account types

The account_type field on a viewer drives the PI implicit view-all and write-all rules. The three values in the system are.

  • "solo". A user with no lab affiliation. No special privileges beyond owning their own records.
  • "lab". A lab member (not the head). Can read and write their own records plus anything explicitly shared with them.
  • "lab_head". The PI. Gets implicit view-all on reads and write-all on writes (see below for how the write-all is gated in the UI).

The two primitives: canRead and canWrite

Every permission decision in the app routes through one of two pure, synchronous functions in lib/sharing/unified.ts.

  • canRead(record, viewer): true if the viewer is the owner, OR the viewer's account_type is "lab_head" (implicit view-all), OR record.shared_with has an entry whose username matches the viewer OR is WHOLE_LAB_SENTINEL.
  • canWrite(record, viewer): true if the viewer is the owner, OR the viewer's account_type is "lab_head" (role-based write-all, see note below), OR record.shared_with has an entry whose username matches (or is "*") AND the entry's level is "edit".

Both functions take exactly two arguments and are pure, so the same input always gives the same output with no I/O. They are synchronous, so every render and every save can call them without async ceremony.

canWriteIgnoringPiRole and the once-per-session UI confirm

A companion helper, canWriteIgnoringPiRole(record, viewer), returns true only when the viewer is the owner or has an explicit edit-share entry. Callers use it to tell whether the PI's write right comes purely from their role or from a normal owner/edit-share basis.

When a PI tries to edit a record that passes canWrite only because of the PI role (not because they own it or have an explicit edit-share), the record popup shows a once-per-session confirmation before opening the editor. That confirm step is a UI guard in the popup, not a condition in canWrite itself. canWrite is a pure predicate with no session state.

expandSharedWith: resolving the sentinel

expandSharedWith(shared_with, allLabUsernames, owner) in lib/sharing/unified.ts replaces the "*" sentinel with the concrete set of current lab members. The owner is excluded (they already have access). When a user appears both as an explicit entry and via the sentinel, the highest level wins (edit beats read). This is the function share dialogs use to render the per-member recipient list.

The PI implicit view-all

A user whose account_type === "lab_head" gets an extra rule on the read side: canRead returns true for every record in the lab regardless of shared_with. The Lab Overview cross-lab dashboards depend on this rule. The member workload widget has to be able to read every member's active tasks even when those tasks are private to the member.

Methods auto-grant via task-share

One non-obvious rule sits next to canRead: when a user shares a task with you, you also get transient read access on any method that task references. The pure helper canReadMethodViaTask in lib/sharing/unified.ts performs the depth-1 check, and the read path emits a method-transient-read entry into the method owner's audit log so they can see who has been reading their protocols via task-share. So sharing a task that uses one of your own private methods does not silently leak the method without a paper trail, and a method owner can spot a viewer who only ever reaches the protocol through someone else's task. The grant is scoped to direct task-to-method references; compound-method children do not transitively unlock. See Methods Library for where this surfaces in the protocol-reading UX.

Granularity, what is shareable

Sharing is per-record.

  • Methods can be shared with the whole lab or with specific users. Public-sharing a method makes it readable across libraries; the structured-method editor still gates writes oncanWrite, which usually keeps edits to the original creator. See Methods Library.
  • Projects share the project itself plus every task under it. The dialog offers view-only or edit permission. The owner's Home page is the source of truth; the receiver sees a live read of the owner's data.
  • Tasks can be shared one at a time when you want a single experiment visible without sharing the whole project.
  • Notes can be shared the same way.
  • Inventory items default to whole-lab edit (the "*" sentinel at level "edit") so every member can add, use, and update stock. You can restrict a particular item per-record.

When someone shares with you

Sharing is not silent on the receiving end. When a labmate adds you to a record, it shows up in your Inbox under the Shared with me segment, the running list of everything people in your folder have shared to you, newest first. The Inbox badge carries a count so you can tell at a glance that something new is waiting, and the bell-style notification surfaces the share without you having to go looking for it. Opening the item from the Inbox takes you straight to the live record in the owner's data, so what you read is always their current copy, edits and all. See Notifications for how the Inbox and its badge behave.

Sharing outside your lab

Everything above is sharing inside one folder, where labmates already see the same files. Sharing with a researcher who is not in your folder works differently, because they have nothing of yours locally. The share dialog handles this on its Outside your lab tab, and there are two ways to do it.

A one-time encrypted send

Send a note, method, project, or file to someone as a frozen copy. You pick the recipient by the email tied to their ResearchOS profile, and ResearchOS encrypts the payload end to end before it leaves your machine. The relay only ever holds ciphertext, never the readable content, and the copy is transient. It is deleted the moment the recipient imports it, or after thirty days if they never do. This is the right choice for handing someone a snapshot you do not need to keep editing together.

Live collaboration with an outside researcher

Grant a researcher outside your folder live, editable access to a note, the same real-time collaboration labmates get. The document stays live until you revoke it. Because the outside person holds nothing of yours locally yet, accepting the invite writes a real copy into their own folder, so they keep a local-first, exportable copy rather than a cloud-only document. From then on both sides edit the same live document.

How the recipient finds and accepts it

An outside recipient sees the invite in their own Shared with me list, the same place in-lab shares appear. Before anything materializes, ResearchOS checks that the sender's identity matches their published directory key, so a spoofed email cannot push a document into someone's folder. The recipient accepts on purpose, the copy is written locally, and they can decline or block a sender they do not want. Revoking later stops the live updates but leaves the recipient their last copy, the same way an export does.

Hosting a labmate's task in your project

Sharing a task one way is read or edit access on that task. Hosting goes a step further. A labmate can let one of their tasks also appear inside your project, so the experiment lives in your project board alongside your own tasks without ever leaving its original owner's folder. The task file stays where it started (so the owner keeps editability), and your project keeps a small sidecar manifest listing every foreign task hosted into it.

The link is bidirectional: the task points at your project and your project points back at the task, and both sides have to agree for the host to count. If the two ever drift apart (a task gets deleted, or the reference goes stale), ResearchOS self-heals the mismatch on read and through a background sweep, dropping orphaned entries so the board never shows a phantom task. The repair is automatic, so hosting stays clean from both ends.

Cross-link map

Pages elsewhere in the wiki that touch sharing decisions.

  • Methods Library covers method-level sharing UI.
  • Home and Projects covers project-level sharing and the receiver's view.
  • The Workbench covers task-level sharing.
  • PI covers the implicit view-all rule.
  • Notifications covers the Inbox, the Shared with me segment, and the badge that flags a new share.
  • Security covers what the gate actually protects against (and what it does not).