Who can see it, who can change it, who signed it.
Roles, per-project permissions, sharing, sign-off authority and the audit trail, explained with what is actually enforced and where.
What do you want to do?
Jump straight to the answer
Overview
Global tier
One of owner / admin / power_user / viewer, resolved from your Supabase Auth login. Controls admin-console access and whether you can create/rename/duplicate any project at all — deleting one is narrower still, restricted to the literal owner tier only (see "Deleting a project" below).
Per-project permissions
A per-(project, member) record — the same shape the live app uses — controlling exactly which stages a member can fill, sign, or raise/close issues for within one specific project.
SupabaseProjectRepository against the same live Diracon Supabase project the original app uses — a project you create in Massive Dynamic is a real row in that project's projects table (tagged metadata.app = 'Massive Dynamic' so it never touches the original app's rows), and adding a member here immediately grants a real, different Supabase account real access, enforced server-side by RLS (not just client-side UI checks). Adding a member resolves their real account by email via get_user_id_by_email — they must already have signed up. This replaces the previous local-first-only limitation. Caveat: RLS's write-blocking check (get_my_role() <> 'viewer') reads the member's GLOBAL account tier, not their per-project role — see "Global tier vs. per-project role" below for exactly when a per-project Viewer share is, and isn't, actually write-blocked at the database level.Opening permissions
Finding a project in a long list
The Projects screen has a search box (name, description, mode, owner e-mail, group name) and, when your list contains projects that are not your own, scope chips: All, Mine, Shared with me (you are a named member) and Other users' (everything else an account owner is allowed to see). Cards that are not yours show who owns or shared them. An account owner's all-projects view never includes other users' nightly-reset template copies — those are identical to the owner's own templates and would only add noise.
Template projects that come with every account
Every approved account sees the platform's template projects — a 2N data center and a GMP facility — in its project list, marked 🧪 Template. Open one and it behaves like your own project: change anything, run the scheduler, sign stages off, walk it in VR. Nothing you do in it is saved — close it and it is the original again; there is one copy for everyone, nothing is multiplied per account. To keep your work, press Duplicate on the template's card: that makes a real project of your own. An account owner publishes a template with 🧪 Make template on one of their own projects (and withdraws it with the same button); only the owner edits the template itself.
Live demo projects
Next to the templates you may also see projects marked 🟢 Live demo. These are the opposite of a template: one shared project that everyone who signs in can open and change for real. Whatever you do — move equipment, complete checklists, sign a stage off, raise an issue with a photo — is saved and visible to everyone else in the demo, and you will see their changes too (the usual "also active" banner shows who is in). Nobody is shown by name or e-mail: every account appears as a fixed callsign such as Papa-69, in the banner, on issues and in the audit trail, and the demo never sends e-mail notifications. Every night the demo is restored to its original and its history is cleared, so feel free to experiment. You cannot rename, delete, share or re-flag a demo — only its owner can — and Duplicate gives you your own real copy at any time.
Adding a project member
Members are added from a dropdown of already-approved Supabase users — not typed as free-text email. The list is scoped by RLS to the accounts you can actually see (the full approved-user table for a global owner-tier account, or accounts delegated to you via managed_by), and anyone already on the project is filtered out of the dropdown. Pick a user, choose a role preset — Viewer, Power User, or Admin — and optionally check one or more parties (SPC / CX / PMC / Owner-type groups) they belong to, then add them. Changing a member's role re-applies that preset's full permission set; removing a member deletes their record entirely.
| Preset | isProjectAdmin | canManageMembers | Checklist fill / Issue create / Issue close stages | Can edit checklists |
|---|---|---|---|---|
| Admin | Yes | Yes | All 4 stages | Yes |
| Power User | No | No | All 4 stages | No |
| Viewer | No | No | None | No |
What's stored per member
| Field | Controls |
|---|---|
isProjectAdmin | Bypasses every other check below and can edit every other member's permissions. Distinct from being the project owner. |
canManageMembers | Can add/remove other members — a separate axis from being a full admin. |
checklistFillStages | Which stage slots (0-3) this member may fill checklist items for. |
canEditChecklists | Create/edit/delete checklist requirement templates. |
issueCreateStages | Which stages this member may raise a new issue against. |
issueCloseStages | Which stages this member may close/resolve an issue for. |
parties | Which party/parties (SPC/CX/PMC/Owner-type) this member belongs to, for the stage-visibility gate above. Empty array = unrestricted, sees every stage. |
How enforcement actually works
Precedence exactly mirrors the live app's checkPermission(): the project owner always passes every check; failing that, a member with isProjectAdmin passes every check; failing that, the specific stage must appear in the relevant array field. This is wired into the Commissioning Tracker today for raising an issue, closing/resolving an issue, filling a checklist item, and ticking Mains On/Control On — the last of these checks the same permission that gates filling the asset's final stage's checklist (L4, or L3 when Final Stage Not Required is set), matching the real app's own checklist_fill_stages['mainsOn'/'controlOn'] check real-source-verified in permission-enforcer.js/ui-Massive Dynamic-shell.js.
canEditProjectContent) that checks this. This closes the case where that choke point only checked the global account tier and ignored a member's own per-project viewer preset — a brand-new account (global tier defaults to neither viewer nor owner) could edit a project it had only been given per-project Viewer access to. Also, the ⇧ Import .json button on the Project Manager screen is hidden entirely (not just disabled) for a global viewer-tier account — same "don't show a control you can never use" convention applied later to the Delete button below. This block has one deliberate exception — a global admin or owner-tier account always passes canEditProjectContent first, before the per-project preset is even checked, so it can still edit a project it's been assigned per-project Viewer on. See the next section for exactly when this applies.Global tier vs. per-project role: which wins?
These are genuinely two separate axes, and each has its own answer to "who wins" — they don't share one rule, which is the most common source of confusion on this page:
| Axis | What bypasses the per-project setting | Enforced by |
|---|---|---|
| Editing — client-side (whether the UI even lets you try) | Global tier admin or owner — canEditProjectContent's if (isGlobalAdmin) return true lets either one through regardless of a per-project Viewer assignment. | Client only. This is wider than what the database actually allows — see the next row and the correction below. |
| Editing — database (whether the write actually lands) | Global tier owner bypasses everything. Global tier admin does NOT get a blanket bypass — an admin can only write to a project it owns, or has a per-project share whose permission_level is write/admin (i.e. was actually assigned Power User or Admin, not Viewer, on that specific project). | projects_update_shared/update_own_projects (ownership/share-based, permissive) AND projects_no_viewer_update (RESTRICTIVE — it can only ever narrow access by blocking the literal global viewer tier, it can never grant access by itself), plus the new owner_tier_can_update_any_project/owner_tier_can_delete_any_project policies (permissive, get_my_role() = 'owner'). |
| Seeing a project in the first place (whether it shows up at all) | Global tier owner ONLY — admin does NOT bypass. An admin-tier account sees only projects it owns, is directly shared on, or belongs to a group for — exactly the same as a power_user or a brand-new account. | Database only: users_view_own_shared_or_as_owner RLS policy's get_my_role() = 'owner' clause plus user_has_project_share(). |
owner-tier account hit a real save failure — 406 Cannot coerce the result to a single JSON object — on a project they'd never been added to (no project_member_permissions row, no project_direct_shares row, not the row's owner). The earlier text here claimed the database's get_my_role() <> 'viewer' check meant "any non-viewer tier passes" — that's true of a RESTRICTIVE policy only in the sense that it can't be satisfied by a viewer, but a restrictive policy never grants access by itself; you still need one of the permissive ownership/share policies to also pass, and none of those checked global tier at all. So a global owner who wasn't the row's owner and had no share genuinely could not write to it, despite the client's canEditProjectContent letting them try (and despite the SELECT policy's real owner-tier bypass making the project visible in the first place). Fixed with two new permissive policies, owner_tier_can_update_any_project and owner_tier_can_delete_any_project (get_my_role() = 'owner'), applied to both Massive Dynamic and v3 — a literal global owner is now a true superuser at the database level, matching the visibility bypass it already had. Global admin tier is unaffected by this fix and still has no blanket database write bypass — see the row above.admin-tier account to only the specific projects you've directly shared with them or grouped them into — a real, useful way to scope an admin's reach without touching their global tier. The catch is the two axes still don't fully mirror each other: an admin-tier account, once it CAN see a project (because you shared it with write/admin-level access), can also EDIT it even if a later per-project Viewer preset would suggest otherwise — the client-side bypass doesn't check per-project role at all for a non-viewer global tier, though the database WILL correctly reject the write if that admin's actual per-project share is Viewer-level (permission_level='read') or nonexistent. That's still a real client/database mismatch for admin tier specifically: the UI won't show a "read-only" indicator for an admin the way it does for a genuine viewer, so a blocked admin write currently surfaces as the same raw save-failure error rather than a graceful message. Not fixed this pass — flag it if you want the client-side check narrowed to match the database exactly.Signing off a commissioning stage
Signing off a stage in the Commissioning Tracker is gated by a real, three-part qualification check — this is a separate gate from the per-project member permissions above, and is never bypassed by them:
- Party membership — your account's
cx_rolemust map to the party you're signing off as (SPC/CX/PMC/Facility Owner); a role of "general" can't sign off as any party. - Password re-authentication — you must re-enter your password immediately before the sign-off commits, verified live against Supabase Auth (the same call the login screen uses). A wrong password leaves the stage untouched.
- A current training certificate — a non-expired
user_training_recordsrow for the party you're signing off as. An admin grants these via the 🎓 Certificates button on the Project Manager screen (visible only to accounts withapproval.isAdmin).
Four different meanings of "owner" / "admin"
This is a real source of confusion because the same words mean different things in different parts of the app — four separate vocabularies, none implying any of the others:
| Term | What it actually means | Where it shows up |
|---|---|---|
| Project owner | Project.ownerId — whoever created the specific project. Always has full access; can't be revoked or restricted from within the app. | Fixed, not shown as an editable row today (same as the live app — see the FAQ below) |
| Project administrator | The per-member isProjectAdmin flag. Bypasses every permission check and can edit everyone else's permissions. Distinct from project ownership. | Admin role preset in the project's member list |
| "Owner"/Facility Owner party | A commissioning-side responsible-party label (alongside CX/PMC/SPC) — a cosmetic stakeholder tag on an asset or stage, unrelated to any permission. | Parties screen, Responsible Party fields, Management View badges |
| Global account tier "owner" | The top global Supabase Auth tier (users_approval.role), alongside admin/power_user/viewer. Controls admin-console and AI-Tiers-tab access, and whether you can create/rename/duplicate any project — and is the ONLY tier that can delete one (see below; a global admin-tier account does not qualify, deliberately). | AI Tiers tab, Project Manager screen (viewer tier is read-only there) |
viewer-tier account can still open and look at any project, but the Project Manager's create/rename/duplicate controls are disabled for them (Import .json and Delete are hidden entirely rather than disabled) — a "🔒 Read-only (viewer)" chip makes this visible. This is a separate restriction from anything in the per-project member permissions above.Deleting a project
isAccountOwner = approval?.role === 'owner' — the literal, global Supabase account tier, checked directly, NOT the broader canEditProject/global-admin-tier bypass that every other per-card action (Rename, Duplicate, Restore) uses. A global admin-tier account, or a member with the per-project isProjectAdmin flag, can fully edit a project's content but cannot delete the project itself. This deliberately narrows a prior state where any global admin-tier account could delete any project — same precedent as the AI Tiers tab's owner-only fix.The Delete button is hidden entirely for every non-owner account (not shown-and-disabled) — the same "don't show a control you can never use" convention already applied to Import .json for viewers. A separate, unrelated bug fixed the same day: the card's action-button row had grown to 7 buttons (Open / Rename / Duplicate / 🔒 Permissions / 🕐 History / ⇩ .json / Delete) and was silently clipping the last one or two off-screen due to overflow: hidden on the card combined with non-wrapping flex buttons — Delete was invisible to owners too until flex-wrap was added so the row spills onto a second line instead of being clipped.
Autosave: when it actually writes to Supabase
Every real edit (adding/moving/connecting assets, ticking a checklist item, changing stage/party assignments, editing the schedule, and so on) is saved automatically — there is no "Save" button and nothing to remember to do before closing a project. The exact timing is:
- Fires about 30 seconds after your last real change — an idle debounce, not a fixed interval. If you keep editing continuously, the write keeps deferring until you actually pause; it does not fire every 30 seconds regardless of activity.
- Panning and zooming the canvas never trigger a save and never reset that 30-second clock — viewport position is a personal view preference, the same way it's already excluded from Undo/Redo, not project content.
- Always saves first, regardless of the 30-second timer, when you leave — navigating back to Project Manager, closing/refreshing the tab, signing out manually, or being signed out by the 30-minute inactivity timer. Sign Out waits for that save to finish (up to a few seconds) before your session is actually ended, so a change made just before clicking it is not lost.
- The pill in the canvas header always shows where you stand — Saved, Unsaved · saving in Ns (a live countdown to the next automatic save), Saving…, or Save failed — Retry. If it reads anything other than Saved, your latest change is not on the server yet.
- The browser warns you if a save is still pending when you close or refresh the tab — its own "Leave site? Changes you made may not be saved" prompt. Choosing to stay and waiting for the pill to read Saved is the safe option; leaving anyway still sends the save on the way out, but on a real tab close that is best-effort.
Two people in the same project at the same time
A project no longer belongs to whoever saves last. When your automatic save finds that someone else saved the same project after you opened it, the app does not throw your work away and does not ask you to reload. It fetches the newer version, replays your changes since your last save on top of it, shows you the merged result and saves that. In practice:
- Different things, no questions asked. A planner moving assets on the canvas in the office and an engineer ticking checklist items in the field never touch the same value, so both sets of changes simply land. The unit of "same thing" is small: one asset's position, one checklist line's result, one stage of one asset, one platform's height — not the whole project and not the whole asset.
- The same value changed by both: the one who is saving now keeps what they are looking at, and a blue bar at the top says so — Merged with a newer version: n changes applied, 1 value also changed elsewhere (yours kept) — with a Download my version button that gives you an untouched copy of what you had, as a .json the Project Manager can import. The audit trail still records both edits.
- Something you edited was deleted by someone else: it comes back with your edit (a change is never silently thrown away). Something you deleted that someone else edited in the meantime stays, with their edit — delete it again once you have seen the newer state.
- A connection to an asset someone else deleted is dropped, exactly as if you had deleted the asset yourself, and the bar says how many.
- Undo is cleared after a merge. Your Undo history was recorded before the other person's changes arrived; undoing through it would silently revert their work, so the stacks start fresh from the merged state.
- Renaming a project, assigning it to a group or clicking Save Version does not disturb anyone who has it open — only real content changes do.
- You also see their work arrive. An open project follows other people's saves: a few seconds after a colleague saves (instantly between desktop tabs, within about ten seconds from a phone), their changes appear on your canvas, in the tracker, in the 3D viewer and in VR, merged with whatever you have not saved yet, and a short blue notice says how many changes came in and from whom. Undo history is cleared when that happens, for the same reason as above.
Per-user audit trail: what's recorded
A real, immutable audit_log table (21 CFR 11 §11.10(e)) records a fixed set of tracked actions — not every click, only these: stage sign-off (including forced sign-off and undo), a Documentation Requirement checked/unchecked, a checklist line set to Pass/Fail/N/A, an issue created/updated/resolved/deleted, and a validation document e-signature. Each row carries who (user id + email), what project/asset/stage/field, old and new value, an optional reason, a session id, and a timestamp — nothing can be edited or deleted once written (no UPDATE/DELETE policy exists on the table at all, immutable by omission).
logAuditEvent into CommissioningPanel.tsx's handleToggleDoc (both the main and Additional Requirements lists) and ChecklistModal.tsx's onSetResult handlers (both the main checklist and every Additional Checklist, disambiguated by name). No Supabase schema change was needed — the audit_log table and its AuditAction type already supported document_checked/document_unchecked/checklist_result_set; this was purely an application-code gap on the desktop UI's two call sites. Applied identically to both the Massive Dynamic/production Supabase project and the v3 template project, per the standing rule that the template must mirror every schema-relevant fix.Viewing recorded entries follows the same visibility rule as everything else in the Access Control tab: a user sees their own actions, an owner-tier account sees everything, and anyone with an owner/direct-share/group relationship to a project sees that project's entries.
Version history & restore
Each project card has a 🕐 History button (showing a count once any versions exist) that lists up to the last 10 saved versions of that project, each with its save time and block count. Restoring an old version is gated by the same canEditProject check as Rename/Duplicate (read-only accounts and blocked viewers can't restore).
Terms & Conditions on signup
Signing up now requires checking an "I have read and agree to the Terms of Service" box, linking to a real /terms.html. It's enforced client-side — signup is blocked with an inline error if the box isn't checked — and the accepted version string is recorded in the real users_approval.tos_version column, matching the version the user actually saw. This is a signup-time gate, separate from every per-project permission on this page.
FAQ
If I add a member to a project, can they actually log in and use it?
Yes — project storage runs on a real SupabaseProjectRepository, so adding a member (picked from the approved-users dropdown) grants that real Supabase account real, RLS-enforced access to the same project row; see the Overview above. Real multi-account project SHARING across different browsers is what this enables — a per-user feature-flag admin console and a dedicated project_direct_shares concept are still separate, not-yet-started tracks (see USER_TRACKING_CERTIFICATES_PLAN.html).
Can I restrict what the project owner can do?
No — same as the live app, this isn't configurable.
Does the global "viewer" tier affect what I can do inside a project I already have open?
It affects the Project Manager screen (no create/rename/duplicate/delete, Import .json hidden) rather than in-canvas actions — those are governed by the per-project member permissions instead, including the same-day-fixed guarantee that a per-project Viewer genuinely cannot edit or delete canvas content — unless their global tier is admin or owner, either of which always bypasses the per-project setting for editing. See "Global tier vs. per-project role" above.
I gave someone per-project Viewer, but their account is global-tier admin or owner — can they still edit the project?
The UI will let them try either way (canEditProjectContent bypasses for both tiers) — but what the database actually allows differs. A global owner can genuinely write to the project regardless of your Viewer assignment (a true superuser bypass). A global admin cannot — the database still enforces your per-project Viewer assignment for them, so their save attempt fails; it just fails with the same raw error a genuine viewer would get, without the graceful "read-only" indicator the UI shows a real per-project viewer. See "Global tier vs. per-project role" above for the full mechanism.
Why can't my admin-tier account see a project I haven't personally shared with it?
By design — only the literal global owner tier bypasses project visibility; admin does not. An admin sees only what it owns, is directly shared on, or belongs to a group for, same as any other non-owner account. This is deliberately useful, not a bug: it's how you restrict an admin's reach to specific projects while still giving them the admin console and other global-tier privileges.
I shared a project with someone and they still can't see it after logging in — what's wrong?
Most likely a database-state gap rather than a UI bug: sharing writes two separate tables (project_member_permissions for the detail, project_direct_shares for the actual grant RLS checks), and if the second write silently didn't land — e.g. a stale deployed bundle predating a fix to this pairing — the member has a permissions row but no real access. Confirm by checking whether a matching project_direct_shares row exists for that project/user; if not, the fix is a one-off backfill insert, not a re-share (re-sharing from a still-stale bundle would fail the same way again). Two live instances of this have been found and repaired directly in the database.
I'm a global admin-tier account — why can't I delete this project?
By design: only the account owner tier can delete a project, not even an admin. Admin tier can still fully edit a project's content and use every other Project Manager action; only the literal global owner tier can delete a project, and the button is hidden (not disabled) for everyone else.
Two of us had the same project open and both made changes — who wins?
Both, in almost every case: the second save merges its changes onto the first instead of replacing it, one value at a time. The only time a choice is made is when you both changed the exact same value; then the person saving keeps theirs and a blue bar says so, with a download of your untouched version. See "Two people in the same project at the same time" above.
If I restore an old version, do I lose my current work?
No — the current content is saved as its own new version before the restore is applied, so restoring is reversible; see "Version history & restore" above.