Skip to documentation

Capability clusters

C7 · SaaS collaboration

The multi-user half of eli: Supabase auth, slug-addressed workspaces, role-based memberships enforced by Postgres row-level security, invites, comments, realtime notifications, public share links, and a trigger-driven audit log.

This cluster is the multi-user half of eli: Supabase-backed authentication (magic link + Google/GitHub OAuth), workspaces with slug URLs and an eli_ws active-workspace cookie, role-based memberships (owner > admin > editor > commenter > viewer) enforced end-to-end by Postgres row-level security (33 policies across 9 tables in lib/db/migrations/pg/0001_rls.sql alone), an email invite lifecycle via Resend, section-anchored threaded comments with orphan detection and @-mentions, Supabase Realtime change-notification toasts, public tokenized share links (/s/[token]) with view/comment/edit modes, a trigger-driven workspace audit log, and per-user profile management. The entire route group is gated behind ELI_MODE=saas (app/(saas)/layout.tsx:6-8); in local mode every (saas) route 404s and most of this subsystem is inert — the notable exceptions are comments (which run in both modes through the storage abstraction) and the /share PWA share-target.

  • Modes: saas (whole cluster gated by ELI_MODE; comments + /share also run local)
  • Key dirs: app/(saas)/, app/_actions/, components/{auth,members,comments,share,realtime,saas}/, lib/{auth,share,email}/, lib/storage/supabase/, lib/db/migrations/pg/, tests/rls/
  • Depends on: 01-core-data-storage (getStorage(), notes_save RPC, section anchors), Supabase (Auth/Postgres/Realtime/Storage), Resend
  • Feeds: 02-authoring-ux (comments/realtime/share mounted in the note page), 04-ai-platform (audit viewer for AI writes), 06-versioning-history (conflict snapshots), 10-end-to-end-journeys

Capabilities

CapabilityWhat it doesEntry pointsModes
Mode gate (ELI_MODE)(saas) route group notFound()s unless env.ELI_MODE==='saas'; middleware no-ops in local; env.ts:104-116 requires Supabase URL/anon key when saasapp/(saas)/layout.tsx, middleware.ts, env.tsboth
Magic-link sign-insignInWithOtp with emailRedirectTo=/api/auth/callback?next=...; no passwords anywhere; signup is the same flow with different copy/login, /signup (components/auth/email-magic-link.tsx)saas
OAuth sign-insignInWithOAuth, providers hard-coded to Google + GitHub (configured in the Supabase dashboard, not code)/login, /signup (components/auth/oauth-buttons.tsx)saas
Auth callback + routingExchanges ?code, honours open-redirect-safe relative ?next, else routes to /onboarding/new-workspace (zero memberships) or /w/<slug> preferring the eli_ws cookieGET /api/auth/callback (app/api/auth/callback/route.ts)saas
Session refresh + auth wallMiddleware calls getUser() on every non-static request to rotate session cookies; anonymous /w/*, /settings*, /onboarding* redirect to /login?next=; /s/[token] deliberately NOT walledmiddleware.tssaas
Workspace creation + onboardingName/slug validation (regex + 20 reserved slugs), RLS-gated insert, DB trigger bootstraps the owner membership, eli_ws cookie + redirect/onboarding/new-workspace, createWorkspaceAction (app/_actions/workspaces.ts)saas
Workspace switcher + eli_ws cookiehttpOnly/lax/30-day cookie persists the active workspace; membership re-verified via RLS on every selectWorkspaceSwitcher, selectWorkspaceActionsaas
SaaS app shell + navigationTop bar (switcher, email, Notes/Projects/Search/Chat/Digest/Trash), mobile hamburger nav, bare shell for zero-membership users, skip-linkapp/(saas)/(app)/layout.tsx, components/saas/mobile-nav.tsxsaas
Invite lifecycleAdmin+ invites email+role (owner not invitable); 32-byte token, sha256 hash stored, 7-day TTL; Resend email; service-role acceptance inserts membership idempotentlyInviteDialog on /w/[ws]/settings/members, /w/[ws]/invite/[token] (app/_actions/invites.ts)saas
Member managementRole change / remove / leave with app-layer sole-owner guards; read-only badges for non-admins/w/[ws]/settings/members (app/_actions/memberships.ts)saas
Workspace settingsRename (admin+), slug change (owner-only, rotates eli_ws cookie)/w/[ws]/settings (app/_actions/workspaces-settings.ts)saas
Profile: display name + avatarDisplay name (1-80 chars); avatar PNG/JPEG/WebP ≤2MB to public avatars bucket at <user_id>/avatar.<ext>, cache-busted URL/w/[ws]/settings/profile (app/_actions/profile.ts)saas
Section-anchored commentsThreads keyed to heading-derived section_anchor (+ block: inline anchors), replies via parentId (indent cap 3), resolve/delete, optimistic UI with RLS-denial rollback, orphan detect + re-attachCommentsThread on note views (app/_actions/comments.ts, lib/storage/supabase/comments.ts)both (local uses authorId: 'local' sentinel)
@-mention autocomplete@ opens a popup of up to 8 workspace members; commits slugified handle; renderer highlights mentions — purely presentational, no notificationcomment + reply inputs (components/comments/mention-input.tsx)saas (degrades to plain textarea local)
Realtime note notificationPer-note postgres_changes subscription; foreign version_id fires a Sonner toast with a Refresh action; self-edits suppressed via a 32-entry local-version registry; never auto-replaces the bufferRealtimeNoteWatcher inside /w/[ws]/n/[...path] (components/realtime/note-watcher.tsx)saas
Share links: mint/list/revokeEditor+ mints view/comment/edit links with optional 1-3650-day expiry; 32-byte CSPRNG token, sha256 stored, plaintext returned exactly once; revoke = hard DELETEShare button in the note toolbar (app/_actions/shares.ts, lib/share/tokens.ts)saas
Public share renderer/s/[token] resolves via service-role with uniform null→404 (anti-enumeration); view = anonymous read-only; comment/edit require sign-inapp/(saas)/s/[token]/page.tsxsaas
Share-token-gated writesDual gate: valid token with required mode (edit implies comment) AND a Supabase session; then service-role comment insert / notes_save RPC with OCC/s/[token] clients (app/_actions/shares-comment-edit.ts)saas
Workspace audit logmember.* via SECURITY DEFINER trigger on memberships; comment.reattached via app insert; ai.tool.* via service-role bridge; admin-only viewer of last 200 rows/w/[ws]/settings/audit (deep-link only) (lib/db/migrations/pg/0005_audit_log.sql, lib/ai/audit.ts)saas
Transactional emailResend wrapper with console-log dev fallback (kind: 'logged'); exactly one template — the workspace invite; magic-link mail is sent by Supabase Auth itselfcreateInviteAction (lib/email/resend.ts, lib/email/templates/invite.ts)saas
SaaS dashboard + note view/w/[ws] shows pinned + 20 recent notes; the note page reuses the local editor stack via getStorage() and adds comments, realtime, share dialogapp/(saas)/(app)/w/[workspace]/page.tsx, .../n/[...path]/page.tsxsaas
PWA share-target captureOS share sheet GETs /share?title=&text=&url=; saas branch resolves workspace from session + eli_ws cookie and feeds handleClip()/share (app/share/page.tsx)both
pgTAP RLS conformance suite21 asserts over notes select/write policies and the share_note_id JWT-claim bypass; runs in CI against pgvector:pg17pg_prove tests/rls/*.sql, .github/workflows/pgtap.ymlsaas (schema)

System view — simplified

System view — simplified

This cluster is the hosted multi-tenant product shell. Standing alone — with search, AI, versioning polish, and capture all switched off — it delivers a complete team workspace: sign in without a password, create a slug-addressed workspace, invite teammates with scoped roles, read and comment on notes, mint and revoke public share links, and audit membership changes. Its one hard security boundary is Postgres RLS: every user-scoped query flows through Supabase clients that carry auth.uid(), and the small set of RLS bypasses is a documented, grep-auditable service-role allow-list (0001_rls.sql:403+). Everything degrades gracefully in dev: no Resend key means invites are console-logged, no Realtime replication means the watcher is silently inert.

System view — detailed

System view — detailed
ComponentRoleFiles
Auth clients & session plumbingThree Supabase client factories (server cookie-read-only, middleware cookie-adapter — the only refresh point, service-role with documented allow-list) plus the memoised browser client; middleware enforces the auth walllib/auth/supabase.ts, lib/auth/supabase.client.ts, middleware.ts
Auth UI + callbackLogin/signup (magic link + Google/GitHub), centered auth layout, code-exchange callback owning post-auth routingapp/(saas)/(auth)/*, components/auth/email-magic-link.tsx, components/auth/oauth-buttons.tsx, app/api/auth/callback/route.ts
Workspace model & shellWorkspace CRUD actions, reserved-slug validation, eli_ws cookie, onboarding form, app shell, switcherapp/_actions/workspaces.ts, app/_actions/workspaces-settings.ts, app/(saas)/layout.tsx, app/(saas)/(app)/layout.tsx, app/(saas)/(app)/onboarding/new-workspace/page.tsx, components/saas/new-workspace-form.tsx, components/saas/workspace-general-form.tsx, components/saas/mobile-nav.tsx, components/workspace-switcher.tsx
Invites & membership managementInvite create/email/cancel/accept (sha256 token, 7-day TTL, service-role accept), role change/remove/leave with sole-owner guards, members UIapp/_actions/invites.ts, app/_actions/memberships.ts, app/(saas)/(app)/w/[workspace]/invite/[token]/page.tsx, app/(saas)/(app)/w/[workspace]/settings/members/page.tsx, components/members/invite-dialog.tsx, components/members/members-table.tsx
RLS schema & triggersAll SaaS-table policies, profile auto-create trigger, workspace-bootstrap trigger, invites table, audit_log + trigger, bucket policieslib/db/migrations/pg/0001_rls.sql, 0002_profiles_trigger.sql, 0003_workspace_bootstrap.sql, 0004_invites.sql, 0005_audit_log.sql, 0007_buckets.sql
Comments stackAdapter CRUD + orphan scan/reattach, server actions via getStorage(), threaded optimistic UI, mention autocomplete, profile lookupslib/storage/supabase/comments.ts, app/_actions/comments.ts, components/comments/comments-thread.tsx, components/comments/mention-input.tsx, lib/storage/supabase/profiles.ts
Realtime watcherPer-open-note Supabase Realtime subscription; toast-with-refresh on foreign updates; self-edit suppressioncomponents/realtime/note-watcher.tsx
Share-link stackToken primitives, adapter create/resolve/revoke/list, member-side and token-gated actions, mint dialog, public renderer + clientslib/share/tokens.ts, lib/storage/supabase/shares.ts, app/_actions/shares.ts, app/_actions/shares-comment-edit.ts, components/share/share-link-dialog.tsx, components/share/share-comments-client.tsx, components/share/share-edit-client.tsx, app/(saas)/s/[token]/page.tsx
Settings & profileSettings layout/side-nav, general, profile (name + avatar), audit viewer, about/PWA-install pageapp/(saas)/(app)/w/[workspace]/settings/*, app/_actions/profile.ts, components/saas/profile-avatar-form.tsx, components/saas/profile-display-name-form.tsx, lib/storage/supabase/audit.ts
EmailResend wrapper with console-log fallback; single invite template (HTML+text, escaped)lib/email/resend.ts, lib/email/templates/invite.ts
AI audit bridgeBest-effort service-role ai.tool.* inserts when chat tools write notes; no-op in local modelib/ai/audit.ts
SaaS workspace pagesDashboard (pinned + recent) and the note view composing editor, properties, comments, realtime, share via getStorage()app/(saas)/(app)/w/[workspace]/page.tsx, app/(saas)/(app)/w/[workspace]/n/[...path]/page.tsx
PWA share-targetMode-aware /share confirm-and-save page feeding handleClip(); saas branch resolves workspace from eli_wsapp/share/page.tsx
RLS test suitepgTAP conformance for notes select/write and the share_note_id JWT-claim bypass, with auth.uid() stubstests/rls/_setup.sql, tests/rls/notes_select.sql, tests/rls/notes_write.sql, tests/rls/share_links.sql

Data flow — simplified

The single most important runtime flow is the invite lifecycle — it is how a workspace becomes multi-user, and it exercises auth, email, RLS, service-role, and the audit trigger in one pass.

Data flow — simplified: the invite lifecycle

Only the sha256 hash of the invite token ever touches the database; the plaintext exists in the email link alone. Acceptance must run service-role because the invitee is not yet a member and RLS would hide both the invite row and the membership insert — the unguessable token is the security boundary, which is also why an email mismatch is deliberately not blocked (forwarded invites work). The membership trigger writes the audit row no matter which code path performed the insert.

Data flow — detailed

(a) Invite → email → accept → membership + audit

Detailed flow (a) — invite → email → accept → membership + audit

(b) Concurrent edit → conflict snapshot → realtime toast

Detailed flow (b) — concurrent edit → conflict snapshot → realtime toast

(c) Share link mint → anonymous view → revoke

Detailed flow (c) — share link mint → anonymous view → revoke

Works separately / works together

Standalone

With every other cluster turned off, C7 plus its Postgres schema is a functioning multi-tenant collaboration product: passwordless sign-in, workspace creation with slug URLs, role-scoped team membership via emailed invites, threaded section comments, public share links with three permission modes, and an admin audit trail. Its dev-mode degradations are deliberate: no RESEND_API_KEY → invites console-logged but the flow succeeds; Realtime replication off → the watcher is silently inert; no Supabase at all (ELI_MODE=local) → the (saas) group 404s but comments still work against the local adapter with the 'local' author sentinel (app/_actions/comments.ts:11). The pgTAP suite (tests/rls/) proves the RLS layer independently of the app, against a plain pgvector container.

Composed

  • 01-core-data-storage getStorage() resolves to SupabaseAdapter in saas mode, so the notes/editor pages and saveNoteAction run unchanged against Postgres+RLS; the SaaS note page reuses NoteEditorRoot/PropertiesPanel verbatim (app/(saas)/(app)/w/[workspace]/n/[...path]/page.tsx). Share-link edits reuse the same notes_save RPC (0006_notes_save.sql) with message 'edit via share link' (app/_actions/shares-comment-edit.ts:216-230). Comments consume the section-anchor vocabulary from lib/storage/helpers/section-diff.ts (splitIntoSections).
  • 02-authoring-ux — the editor toolbar hosts ShareLinkDialog; the note page mounts RealtimeNoteWatcher and CommentsThread; the editor's save loop supplies expectedVersionId and consumes SaveState.snapshotVersionId for the ConflictBanner.
  • 06-versioning-history — conflict snapshots are ordinary versions rows (message: 'auto-conflict-snapshot', author_kind: 'system'); comments pin the note versionId at write time; the versions table is append-only by RLS design (no UPDATE/DELETE policies).
  • 04-ai-platform — AI chat write tools call recordAiAudit (lib/ai/audit.ts) so ai.tool.create_note/ai.tool.update_noterows surface in this cluster's /settings/audit viewer with a blue badge; settings/ai(BYOK keys, usage) lives under this cluster's settings layout.
  • 05-agent-surface — PATs share the 32-byte CSPRNG + sha256 + constant-time-compare convention with invite and share tokens (lib/auth/pat.ts, lib/share/tokens.ts); settings/developers hosts the PAT UI; /api/clip authenticates workspace PATs.
  • 08-capture-templates — the /share PWA share-target resolves the target workspace from the session + eli_ws cookie and feeds handleClip() (app/share/page.tsx:112-168).
  • 03-semantic-search — every successful saas save fire-and-forgets scheduleEmbed (30s debounce) to re-embed the note.
  • 09-platform-ops env.ts owns the ELI_MODEgate and saas env validation; the digest/trash-prune crons fan out per workspace via service-role using this cluster's workspace model; .github/workflows/pgtap.yml runs the RLS suite in CI.

Load-bearing details

  • Exactly 33 create policy statements across 9 RLS-enabled tables in lib/db/migrations/pg/0001_rls.sql (workspaces, memberships, profiles, notes, versions, note_links, comments, attachments, share_links; verified by grep). Later migrations add per-user/admin RLS for pats, favorites, ai_keys, chat, ai_usage — 63 policies repo-wide, but those tables belong to other clusters. Enforcement pattern: every policy joins memberships on workspace_idand checks the caller's role against the ladder owner > admin > editor > commenter > viewer; writes additionally pin = auth.uid() columns (e.g. comments_insert_commenter requires commenter+ AND author_id = auth.uid(), 0001_rls.sql:287-296).
  • Service-role (RLS bypass) usage is a documented 5-item allow-list: profiles trigger, share-token validation, migrations/seed, invite acceptance (0001_rls.sql:403-427), plus resolveShareLinkas the self-described "FIFTH canonical entry" (lib/storage/supabase/shares.ts:30-34). Every caller carries a // SERVICE-ROLE: comment for cheap grep audits — though the live caller set has outgrown the written list: share comment/edit writes (app/_actions/shares-comment-edit.ts), the AI audit bridge (lib/ai/audit.ts), and PAT last_used_at bumps (0008_pats.sql) also run service-role with the same greppable convention.
  • No passwords and no sign-out UI exist: auth is signInWithOtp + Google/GitHub OAuth only (components/auth/email-magic-link.tsx:46, components/auth/oauth-buttons.tsx:18-21); grep for signOut finds only a doc comment in lib/auth/supabase.client.ts:8 (verified).
  • The auth callback rejects protocol-relative ?next values (open-redirect guard, app/api/auth/callback/route.ts:54-61) and the profile row is created by the on_auth_user_created DB trigger (0002_profiles_trigger.sql), not by the callback; the trigger seeds display_name from raw_user_meta_data->>'display_name', falling back to the email local-part (split_part(email, '@', 1), 0002:30-35).
  • middleware.ts is the only session-refresh point — it mirrors rotated cookies onto both request and response (middleware.ts:36-54), and returns NextResponse.next() immediately when ELI_MODE !== 'saas' (middleware.ts:19, verified). /s/[token] is intentionally outside the auth wall.
  • All three secret types (invite tokens, share tokens, PATs) use 32-byte CSPRNG + sha256 storage — deliberately not argon2, because high-entropy secrets do not need slow hashing (lib/share/tokens.ts:9-21, lib/auth/pat.ts:17-29). Only PAT auth actually runs an in-process constant-time compare (verifyHash with timingSafeEqual, lib/auth/pat.ts:88-95, called from lib/mcp/auth.ts); invite and share resolution are plain SQL equality lookups on the unique token_hash index (app/_actions/invites.ts:120-129, lib/storage/supabase/shares.ts:121-125), and verifyShareToken — the share-token constant-time helper — has no production caller (tests only).
  • Invite acceptance deliberately ignores email mismatch — any signed-in holder of the token joins; the unguessable token is the boundary (app/_actions/invites.ts:137-141). Accepted invites are stamped accepted_at, not deleted; cancel is a hard delete. The accept URL base is env.ELI_PUBLIC_URL ?? 'http://127.0.0.1:3737' (invites.ts:84); (workspace_id, email) is deliberately not unique so re-invite after expiry works (0004_invites.sql:2-6), and the members page lists open invites (accepted_at is null, admin-only via invites_select_admin).
  • Sole-owner protection is app-layer only (RLS would permit self-delete): sole owner cannot leave, be demoted, or self-demote below admin (app/_actions/memberships.ts:62-90,111-126,146-161); MembersTable mirrors the guards by disabling options. The slug-change owner-only rule is likewise app-code: the governing RLS policy is workspaces_update_admin, so an admin could update the slug directly — the action adds an explicit owner role check for a friendly error (app/_actions/workspaces-settings.ts:59-69).
  • The eli_ws cookie (httpOnly, lax, 30 days, slug value) is set on create/select/accept-invite/slug-rename and re-verified through an RLS-filtered select before trust; "not found" and "not a member" are deliberately indistinguishable (app/_actions/workspaces.ts:63-71,121-133).
  • Workspace bootstrap is double-enforced: createWorkspaceAction validates the reserved-slug list app-side, and the SECURITY DEFINER trigger handle_new_workspace re-checks it DB-side (errcode 23514) while inserting the owner membership (0003_workspace_bootstrap.sql:17-51). Exact constants: name 1-120 chars, slug 2-50 matching /^[a-z][a-z0-9-]*[a-z0-9]$/, 20 reserved slugs (app/_actions/workspaces.ts:26-61) — a list duplicated verbatim in workspaces-settings.ts:20-24.
  • audit_log has no INSERT policy at all — only the SECURITY DEFINER membership trigger writes it by definer rights (0005_audit_log.sql:40-42); ai.tool.* rows go in via service-role (lib/ai/audit.ts:35-42); the app-level comment.reattached insert uses the user-scoped client and swallows failure with console.warn (lib/storage/supabase/comments.ts:166-168). member.left vs member.removed is distinguished by actor==target inside the trigger (0005:82).
  • Realtime is notification-only: one channel per open note filtered id=eq.<noteId>; concurrent-write safety comes from the notes_saveRPC's OCC (error 40001), not from Realtime (components/realtime/note-watcher.tsx:82-110). Self-edits are suppressed via a window-pinned set of the last 32 locally saved version ids.
  • resolveShareLink returns uniform null for unknown/expired/trashed so enumeration is uninformative; the page 404s (lib/storage/supabase/shares.ts:104-146). Share modes form a monotonic ladder — edit implies comment, comment does not imply edit (app/_actions/shares-comment-edit.ts:82-89) — and share writes require a real session and are attributed to the visitor's auth.uid().
  • The notes_select_share RLS policy (anonymous SELECT of exactly one note via a share_note_id JWT claim, 0001_rls.sql:184-188, verified) is dormant in production code — the implemented share path is pure service-role; only pgTAP exercises the policy (tests/rls/share_links.sql).
  • Storage buckets: attachments is private with membership-join RLS on path segment 1 = workspace uuid; avatars is public-read with owner-write keyed on path segment 1 = auth.uid() (0007_buckets.sql). Avatars: PNG/JPEG/WebP ≤2MB at fixed path <user_id>/avatar.<ext>, cache-busted public URL (app/_actions/profile.ts:49-93). The upload inlines sb.storage in the action (bypassing the lib/storage/supabase helpers) with upsert: true + cacheControl: '300' so the pinned path overwrites in place and the public URL stays stable — freshness comes from the appended ?v=Date.now(); dimensions are never validated and no server-side resize exists (the stored object is the uploaded bytes verbatim; sharp resize is a stated follow-up, profile.ts:44-47). clearAvatarAction best-effort deletes all three extensions and treats the profiles UPDATE as the source of truth (profile.ts:106-112). Profiles themselves are readable by any authenticated user, not just co-members (profiles_select_authenticated, 0001_rls.sql:122) — display names and avatars are treated as non-sensitive.
  • Exactly one Resend template exists (the workspace invite); magic-link email is sent by Supabase Auth itself, and a missing RESEND_API_KEY downgrades delivery to 'logged' without failing the invite (lib/email/resend.ts:39-68). The from address defaults to RESEND_FROM or 'eli <noreply@eli.local>' (resend.ts:37-41).

Gaps and partially wired

  • SupabaseAdapter.subscribe() is a no-op stub("until Phase 4 wires Supabase Realtime", lib/storage/supabase.ts:295-299). All realtime behaviour lives in the one-off RealtimeNoteWatcher component; there is no presence (who is viewing/editing) anywhere, no auto-merge, and the watcher is silently inert if Realtime replication is not enabled on the notes table.
  • SaaS clipping is broken end-to-end: /api/clip authenticates a workspace PAT (app/api/clip/route.ts:163-197) but then resolves storage via getStorage() SupabaseAdapter.forActiveWorkspace(), which needs session + eli_wscookies that a cross-origin bookmarklet fetch never sends — storage init throws and the route 500s; the PAT's workspace only feeds the idempotency key and viewUrl. The bookmarklet installer page is local-mode-only (app/(local)/bookmarklet/page.tsx:21-25). (Shared gap with 05-agent-surface/08-capture-templates.)
  • No sign-out UI exists anywhere in the codebase (verified by grep; only a doc-comment mention in lib/auth/supabase.client.ts:8).
  • Settings nav dead link + hidden page: the side-nav lists Billing but no billing page exists (404), while the live /settings/audit page is absent from the nav and reachable only by direct URL (app/(saas)/(app)/w/[workspace]/settings/layout.tsx:4-13).
  • ShareEditClient never updates its versionId after a successful save (components/share/share-edit-client.tsx:40-43), so a share recipient's second save sends a stale expectedVersionId and hits OCC 40001 against their own previous save; they must reload between edits — and since the client autosaves (1.5s debounce) alongside manual save and revert, the stale id bites on the very next pause. Share recipients can add and list comments but never resolve, delete, or re-attach — those stay member-only.
  • Audit coverage is narrow: only membership changes (trigger) and AI write tools produce rows. Note edits, comments (except reattach), share-link create/revoke, PAT create/revoke, invite creation, and clips generate no audit entries — note edits are traceable only via the versions table's author_kind/message. The viewer's empty state is also ambiguous by design: RLS returns zero rows to non-admins, so "no events" and "no permission" render identically (settings/audit/page.tsx:52-54).
  • Service-role membership writes lose the actor: member.added rows from invite acceptance have actor_user_id null because request.jwt.claims.sub is empty under the service-role client (0005_audit_log.sql:53, app/_actions/invites.ts:142-146).
  • The notes_select_share JWT-claim policy is dormant — the share path never mints the short-lived JWT the policy (and the service-role allow-list item 2) describe; reads go pure service-role (lib/storage/supabase/shares.ts:131-138).
  • isShareLinkActive accepts a revokedAt field no column backs: the share_links schema has no revoked_at (lib/db/schema.pg.ts:268-289; the one at :344 belongs to PATs) — vestigial soft-revoke support; actual revocation is hard DELETE only (lib/share/tokens.ts:87-89).
  • @-mentions are presentational only — no notification, email, or inbox entry is produced (components/comments/comments-thread.tsx:658-679); share-page comment threads show truncated user ids instead of the anonymised labels the spec calls for (app/(saas)/s/[token]/page.tsx:32-35).
  • SaaS note view parity gaps: single-pane (no sidebar/filter pane; "lights up in a follow-up"), and the favourites star is hardcoded off (app/(saas)/(app)/w/[workspace]/n/[...path]/page.tsx:31-33,87-89).
  • No digest email exists despite the digest cron — the only call site of sendEmail is the invite action; the cron writes a digest note instead (lib/email/resend.ts:5-9; see 04-ai-platform/09-platform-ops).
  • comment.reattached audit rows can silently fail: they are inserted with the user-scoped client but audit_log has no INSERT policy, so the insert is rejected and swallowed with console.warn (lib/storage/supabase/comments.ts:166-168).
  • SupabaseAdapter throws NotImplementedError for semanticSearch ("Phase 5"), graph("Phase 7"), and applyTemplate("Phase 6") (lib/storage/supabase.ts:133-136,195-199) — SaaS semantic search routes around it via the hybrid search endpoint instead.