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 +/sharealso 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_saveRPC, 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
| Capability | What it does | Entry points | Modes |
|---|---|---|---|
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 saas | app/(saas)/layout.tsx, middleware.ts, env.ts | both |
| Magic-link sign-in | signInWithOtp 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-in | signInWithOAuth, providers hard-coded to Google + GitHub (configured in the Supabase dashboard, not code) | /login, /signup (components/auth/oauth-buttons.tsx) | saas |
| Auth callback + routing | Exchanges ?code, honours open-redirect-safe relative ?next, else routes to /onboarding/new-workspace (zero memberships) or /w/<slug> preferring the eli_ws cookie | GET /api/auth/callback (app/api/auth/callback/route.ts) | saas |
| Session refresh + auth wall | Middleware calls getUser() on every non-static request to rotate session cookies; anonymous /w/*, /settings*, /onboarding* redirect to /login?next=; /s/[token] deliberately NOT walled | middleware.ts | saas |
| Workspace creation + onboarding | Name/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 cookie | httpOnly/lax/30-day cookie persists the active workspace; membership re-verified via RLS on every select | WorkspaceSwitcher, selectWorkspaceAction | saas |
| SaaS app shell + navigation | Top bar (switcher, email, Notes/Projects/Search/Chat/Digest/Trash), mobile hamburger nav, bare shell for zero-membership users, skip-link | app/(saas)/(app)/layout.tsx, components/saas/mobile-nav.tsx | saas |
| Invite lifecycle | Admin+ invites email+role (owner not invitable); 32-byte token, sha256 hash stored, 7-day TTL; Resend email; service-role acceptance inserts membership idempotently | InviteDialog on /w/[ws]/settings/members, /w/[ws]/invite/[token] (app/_actions/invites.ts) | saas |
| Member management | Role 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 settings | Rename (admin+), slug change (owner-only, rotates eli_ws cookie) | /w/[ws]/settings (app/_actions/workspaces-settings.ts) | saas |
| Profile: display name + avatar | Display 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 comments | Threads 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-attach | CommentsThread 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 notification | comment + reply inputs (components/comments/mention-input.tsx) | saas (degrades to plain textarea local) |
| Realtime note notification | Per-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 buffer | RealtimeNoteWatcher inside /w/[ws]/n/[...path] (components/realtime/note-watcher.tsx) | saas |
| Share links: mint/list/revoke | Editor+ mints view/comment/edit links with optional 1-3650-day expiry; 32-byte CSPRNG token, sha256 stored, plaintext returned exactly once; revoke = hard DELETE | Share 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-in | app/(saas)/s/[token]/page.tsx | saas |
| Share-token-gated writes | Dual 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 log | member.* 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 email | Resend wrapper with console-log dev fallback (kind: 'logged'); exactly one template — the workspace invite; magic-link mail is sent by Supabase Auth itself | createInviteAction (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 dialog | app/(saas)/(app)/w/[workspace]/page.tsx, .../n/[...path]/page.tsx | saas |
| PWA share-target capture | OS 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 suite | 21 asserts over notes select/write policies and the share_note_id JWT-claim bypass; runs in CI against pgvector:pg17 | pg_prove tests/rls/*.sql, .github/workflows/pgtap.yml | saas (schema) |
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
| Component | Role | Files |
|---|---|---|
| Auth clients & session plumbing | Three 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 wall | lib/auth/supabase.ts, lib/auth/supabase.client.ts, middleware.ts |
| Auth UI + callback | Login/signup (magic link + Google/GitHub), centered auth layout, code-exchange callback owning post-auth routing | app/(saas)/(auth)/*, components/auth/email-magic-link.tsx, components/auth/oauth-buttons.tsx, app/api/auth/callback/route.ts |
| Workspace model & shell | Workspace CRUD actions, reserved-slug validation, eli_ws cookie, onboarding form, app shell, switcher | app/_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 management | Invite create/email/cancel/accept (sha256 token, 7-day TTL, service-role accept), role change/remove/leave with sole-owner guards, members UI | app/_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 & triggers | All SaaS-table policies, profile auto-create trigger, workspace-bootstrap trigger, invites table, audit_log + trigger, bucket policies | lib/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 stack | Adapter CRUD + orphan scan/reattach, server actions via getStorage(), threaded optimistic UI, mention autocomplete, profile lookups | lib/storage/supabase/comments.ts, app/_actions/comments.ts, components/comments/comments-thread.tsx, components/comments/mention-input.tsx, lib/storage/supabase/profiles.ts |
| Realtime watcher | Per-open-note Supabase Realtime subscription; toast-with-refresh on foreign updates; self-edit suppression | components/realtime/note-watcher.tsx |
| Share-link stack | Token primitives, adapter create/resolve/revoke/list, member-side and token-gated actions, mint dialog, public renderer + clients | lib/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 & profile | Settings layout/side-nav, general, profile (name + avatar), audit viewer, about/PWA-install page | app/(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 |
| Resend wrapper with console-log fallback; single invite template (HTML+text, escaped) | lib/email/resend.ts, lib/email/templates/invite.ts | |
| AI audit bridge | Best-effort service-role ai.tool.* inserts when chat tools write notes; no-op in local mode | lib/ai/audit.ts |
| SaaS workspace pages | Dashboard (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-target | Mode-aware /share confirm-and-save page feeding handleClip(); saas branch resolves workspace from eli_ws | app/share/page.tsx |
| RLS test suite | pgTAP conformance for notes select/write and the share_note_id JWT-claim bypass, with auth.uid() stubs | tests/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.
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
(b) Concurrent edit → conflict snapshot → realtime toast
(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 toSupabaseAdapterin saas mode, so the notes/editor pages andsaveNoteActionrun unchanged against Postgres+RLS; the SaaS note page reusesNoteEditorRoot/PropertiesPanelverbatim (app/(saas)/(app)/w/[workspace]/n/[...path]/page.tsx). Share-link edits reuse the samenotes_saveRPC (0006_notes_save.sql) with message'edit via share link'(app/_actions/shares-comment-edit.ts:216-230). Comments consume the section-anchor vocabulary fromlib/storage/helpers/section-diff.ts(splitIntoSections). - 02-authoring-ux — the editor toolbar hosts
ShareLinkDialog; the note page mountsRealtimeNoteWatcherandCommentsThread; the editor's save loop suppliesexpectedVersionIdand consumesSaveState.snapshotVersionIdfor the ConflictBanner. - 06-versioning-history — conflict snapshots are ordinary
versionsrows (message: 'auto-conflict-snapshot',author_kind: 'system'); comments pin the noteversionIdat 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) soai.tool.create_note/ai.tool.update_noterows surface in this cluster's/settings/auditviewer 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/developershosts the PAT UI;/api/clipauthenticates workspace PATs. - 08-capture-templates — the
/sharePWA share-target resolves the target workspace from the session +eli_wscookie and feedshandleClip()(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.tsowns theELI_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.ymlruns the RLS suite in CI.
Load-bearing details
- Exactly 33
create policystatements across 9 RLS-enabled tables inlib/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 joinsmembershipsonworkspace_idand checks the caller's role against the ladder owner > admin > editor > commenter > viewer; writes additionally pin= auth.uid()columns (e.g.comments_insert_commenterrequires commenter+ ANDauthor_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), plusresolveShareLinkas 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 PATlast_used_atbumps (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 forsignOutfinds only a doc comment inlib/auth/supabase.client.ts:8(verified). - The auth callback rejects protocol-relative
?nextvalues (open-redirect guard,app/api/auth/callback/route.ts:54-61) and the profile row is created by theon_auth_user_createdDB trigger (0002_profiles_trigger.sql), not by the callback; the trigger seedsdisplay_namefromraw_user_meta_data->>'display_name', falling back to the email local-part (split_part(email, '@', 1),0002:30-35). middleware.tsis the only session-refresh point — it mirrors rotated cookies onto both request and response (middleware.ts:36-54), and returnsNextResponse.next()immediately whenELI_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 (verifyHashwithtimingSafeEqual,lib/auth/pat.ts:88-95, called fromlib/mcp/auth.ts); invite and share resolution are plain SQL equality lookups on the uniquetoken_hashindex (app/_actions/invites.ts:120-129,lib/storage/supabase/shares.ts:121-125), andverifyShareToken— 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 stampedaccepted_at, not deleted; cancel is a hard delete. The accept URL base isenv.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 viainvites_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);MembersTablemirrors the guards by disabling options. The slug-change owner-only rule is likewise app-code: the governing RLS policy isworkspaces_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_wscookie (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:
createWorkspaceActionvalidates the reserved-slug list app-side, and the SECURITY DEFINER triggerhandle_new_workspacere-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 inworkspaces-settings.ts:20-24. audit_loghas 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-levelcomment.reattachedinsert uses the user-scoped client and swallows failure withconsole.warn(lib/storage/supabase/comments.ts:166-168).member.leftvsmember.removedis 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 thenotes_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. resolveShareLinkreturns 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'sauth.uid().- The
notes_select_shareRLS policy (anonymous SELECT of exactly one note via ashare_note_idJWT 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:
attachmentsis private with membership-join RLS on path segment 1 = workspace uuid;avatarsis 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 inlinessb.storagein the action (bypassing thelib/storage/supabasehelpers) withupsert: 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).clearAvatarActionbest-effort deletes all three extensions and treats theprofilesUPDATE 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_KEYdowngrades delivery to'logged'without failing the invite (lib/email/resend.ts:39-68). Thefromaddress defaults toRESEND_FROMor'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-offRealtimeNoteWatchercomponent; 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 thenotestable.- SaaS clipping is broken end-to-end:
/api/clipauthenticates a workspace PAT (app/api/clip/route.ts:163-197) but then resolves storage viagetStorage()→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/auditpage is absent from the nav and reachable only by direct URL (app/(saas)/(app)/w/[workspace]/settings/layout.tsx:4-13). ShareEditClientnever updates itsversionIdafter a successful save (components/share/share-edit-client.tsx:40-43), so a share recipient's second save sends a staleexpectedVersionIdand 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
versionstable'sauthor_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.addedrows from invite acceptance haveactor_user_idnull becauserequest.jwt.claims.subis empty under the service-role client (0005_audit_log.sql:53,app/_actions/invites.ts:142-146). - The
notes_select_shareJWT-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). isShareLinkActiveaccepts arevokedAtfield no column backs: theshare_linksschema has norevoked_at(lib/db/schema.pg.ts:268-289; the one at:344belongs 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
sendEmailis the invite action; the cron writes a digest note instead (lib/email/resend.ts:5-9; see 04-ai-platform/09-platform-ops). comment.reattachedaudit rows can silently fail: they are inserted with the user-scoped client butaudit_loghas no INSERT policy, so the insert is rejected and swallowed withconsole.warn(lib/storage/supabase/comments.ts:166-168).SupabaseAdapterthrowsNotImplementedErrorforsemanticSearch("Phase 5"),graph("Phase 7"), andapplyTemplate("Phase 6") (lib/storage/supabase.ts:133-136,195-199) — SaaS semantic search routes around it via the hybrid search endpoint instead.