Skip to content

Plan: Share CreatorTrack Finances with Krystal (shared household finance dataset)

URL: https://mkdocs.justinsforge.com/memory/plans/finances-shared-with-krystal-2026-07-07/

Date: 2026-07-07 Approved spec: Krystal signs up with any of 3 candidate emails ([email protected], [email protected], [email protected]) and gets her own normal workspace whose Finances tab renders Justin's shared finance dataset (workspace #67) as a full co-owner (view + edit: categorize, confirm recurring, edit transactions, budgets). She never becomes a member of #67, so every non-finance app's membership-based RLS already denies her. Mechanism: a finance.shares table + extended RLS on the finance.* tables so a shared user can read/write the owner's rows, a finance-source resolver so writes target the owner workspace, and the existing signup/login claim flow materializes the share for whichever email she uses. Out of scope: Krystal connecting her own separate bank/FIN Bridge accounts (nothing more to add, she mirrors the existing setup); the public multi-tenant SimpleFIN release (its own plan, finances-public-release-2026-07-07); merchant_norm collapsing; any cross-workspace finance UI beyond her Finances tab; a general per-app API guard (unnecessary under this model).

Grounding facts (verified 2026-07-07)

  • lib/finance.ts finance READS have 0 explicit workspace_id filters, they rely purely on RLS via asUser(identity) (lib/rls.ts). Extending finance RLS to admit a shared user makes her reads of #67 return automatically.
  • Finance WRITES set workspace_id explicitly (e.g. learnRule inserts into finance.category_rules, new-txn/budget inserts). These need the resolved owner workspace id (67), not Krystal's own workspace.
  • finance app is ownerOnly: true (lib/apps.ts). Krystal is the OWNER of her own workspace, so the Finances tile shows for her with no allowlist work.
  • Finance data lives in workspace #67 "Personal", owner [email protected] (sole member). Invite/claim logic: lib/invites.ts + core.claim_workspace_invites. Finance app identity via currentUser() (components/apps/finance/FinanceApp.tsx).
  • Work happens in an isolated slot: bash /home/justinwieb/forge/scripts/forge_creatortrack_agent_bootstrap.sh feat/finances-shared-krystal 3065. Prod DB = lifeos; dev clone = ct_feat_finances_shared_krystal.

Task list

Task 1: Locate and document the exact finance RLS policies + write-path workspace_id sites

  • Files: none edited. Produce notes appended to this plan file under a ## Implementation notes heading.
  • What: Grep the finance-tables migration(s) in db/migrations/ for the create policy ... on finance.* statements (SELECT/INSERT/UPDATE/DELETE) and record their exact names + using/with check clauses. Enumerate every finance WRITE site that sets workspace_id (grep lib/finance*.ts, app/api/finance/**, app/finances/** for workspace_id in inserts and learnRule( callers). This de-risks Tasks 2-4.
  • Verification: cd ~/forge-suite-wt/feat/finances-shared-krystal && grep -rnE "on finance\.(transactions|accounts|budgets|category_rules|recurring)" db/migrations | head returns the policy definitions; the plan's Implementation notes list every write site.
  • Commit: docs(finance-share): audit finance RLS policies + write-path workspace_id sites

Implementation notes (Task 1 audit, 2026-07-07)

  • RLS pattern: all 19 finance.* tables carry one identical policy workspace_isolation USING (workspace_id IN (SELECT m.workspace_id FROM core.memberships m WHERE m.user_id = core.current_user_id())), no WITH CHECK (so USING doubles as the insert/update check). Tables: accounts, alert_feed, alert_state, balances, budgets, category_rules, etf_constituents, holdings, job_runs, manual_values, merchant_icons, nw_history_grid, receipts, recurring, recurring_dismissed, recurring_excludes, transactions, user_rules, weekly_snapshots. No gaps. Identity = {userId, isOwner, email} (no workspace); asUser sets app.user_id/app.is_owner; core.current_user_id() reads app.user_id.
  • The real gate is email, not RLS: Finances (+ Fitness, Calendar) are "personal-data apps" gated by isPersonalAppsOwner(id) = id.email === PERSONAL_APPS_OWNER_EMAIL ([email protected]) at every door (lib/personal-apps.ts). So a co-owner needs BOTH (a) a finance-specific gate that admits her, and (b) the RLS share so her reads/writes reach #67. The global isPersonalAppsOwner is shared by many apps (business, social, calendar, fitness, campaigns, sales, rss, agents, admin), so it must NOT change; add a finance-only hasFinanceAccess.
  • Finance doors: READ gate components/apps/finance/FinanceApp.tsx:47. Co-owner EDIT doors: app/api/finance/transactions, app/api/finance/recurring, app/api/finance/manual-values, app/finances/categorize/apply, app/finances/icons/upload, app/finances/receipts/upload. Stay OWNER-ONLY (shared SimpleFIN credential): app/finances/connect, app/finances/sync.
  • Write workspace_id sources: target-row-derived (transactions update returns workspace_id; manual_values via account's ws) are already correct for a co-owner (#67 rows). Membership-lookup inserts need the resolver: ownerWorkspaceId (lib/finance-jobs.ts:78) + inline select m.workspace_id ... order by workspace_id limit 1 in app/api/finance/budgets (:42), lib/finance-recurring.ts (recurring_excludes/dismissed), lib/finance-icons.ts, app/finances/receipts/upload.
  • Revised Task 4 scope: (i) add hasFinanceAccess(id) and swap it into the finance edit/read doors above; (ii) route membership-lookup inserts through resolveFinanceWorkspace(id).

Task 2: Migration, finance.shares table + extend finance RLS to admit shared users

  • Files: new db/migrations/<ts>_feat_finance_share_finance_shares_and_rls.sql (via python3 ~/forge/scripts/forge_workspace_new_migration.py "finance share finance_shares and rls").
  • What: Create finance.shares (id, owner_workspace_id bigint, shared_user_id bigint, role text default 'coowner', created_at, unique(owner_workspace_id, shared_user_id)). Add a SECURITY DEFINER helper core.finance_shared_workspaces(user_id bigint) returns setof bigint (owner workspace ids shared TO this user). Extend each finance.* RLS policy's using/with check so a row whose workspace_id is in core.finance_shared_workspaces(current_user_id()) is also permitted (in addition to existing membership check). Fail closed: no share row => no access.
  • Verification: cd ~/forge-suite-wt/feat/finances-shared-krystal && PGDATABASE=ct_feat_finances_shared_krystal WORKSPACE_MIGRATIONS_DIR=db/migrations python3 ~/forge/scripts/forge_workspace_migrate.py applies clean; psql ... -c "\d+ finance.shares" shows the table and select polname from pg_policies where schemaname='finance' shows updated policies.
  • Commit: feat(finance-share): finance.shares table + shared-user RLS on finance.*

Task 3: Finance-source resolver for write paths

  • Files: lib/finance.ts (or new lib/finance-share.ts), export resolveFinanceWorkspace(identity): Promise<number>.
  • What: Return the workspace id whose finance dataset the identity should WRITE to: the identity's own workspace if it owns/holds finance data, else the single shared owner workspace from finance.shares (via a SECURITY DEFINER read). Krystal (own workspace, no finance data, one share to #67) resolves to 67. Justin resolves to 67 (his own). Deterministic; if a user somehow has >1 share, prefer explicit own-workspace, else lowest owner id (documented).
  • Verification: env -u PGPASSWORD PGDATABASE=ct_feat_finances_shared_krystal npx tsx -e "import('@/lib/finance-share').then(async m=>{console.log(await m.resolveFinanceWorkspace(<krystalIdentity>))})" prints 67 for a seeded test share.
  • Commit: feat(finance-share): resolveFinanceWorkspace() source resolver

Task 4: Route finance WRITES through the resolver

  • Files: the write sites enumerated in Task 1, e.g. lib/finance-categorize.ts (learnRule callers pass resolved ws), app/api/finance/transactions/route.ts, app/api/finance/budgets/route.ts, app/api/finance/manual-values/route.ts, app/finances/recurring/action + app/finances/icons/* handlers.
  • What: Replace identity.workspaceId (and any hardcoded current-workspace use) in finance INSERT/UPDATE-by-workspace paths with await resolveFinanceWorkspace(identity), so a co-owner's edits land on #67. RLS from Task 2 authorizes; the resolver picks the correct target. Reads are untouched (pure RLS already covers them).
  • Verification: env -u PGPASSWORD PGDATABASE=ct_feat_finances_shared_krystal npx tsx scripts/dev-verify-finance-share.mts (new tiny script): as a seeded Krystal identity, categorize a #67 txn + create a budget, then assert both rows have workspace_id=67 and are visible to Justin's identity.
  • Commit: feat(finance-share): co-owner writes target the shared owner workspace

Task 5: Materialize the finance share on signup/login claim

  • Files: lib/invites.ts, the claim path (core.claim_workspace_invites sibling or a new core.claim_finance_shares), and the signup/login handlers that already call the claim (per the workspace-sharing feature).
  • What: Add a pending-finance-share concept keyed by email (reuse core.workspace_invites with a kind='finance_share' + owner_workspace_id, or a small finance.share_invites(email, owner_workspace_id, role)). On signup/login, for the authenticated email, convert any pending finance-share into a finance.shares row for that user id (idempotent upsert on unique(owner_workspace_id, shared_user_id)). Drop a bell notification ("Justin shared Finances with you").
  • Verification: env -u PGPASSWORD PGDATABASE=ct_feat_finances_shared_krystal npx tsx scripts/dev-verify-finance-share.mts --claim [email protected] seeds a pending share, simulates the claim for a test user with that email, and asserts a finance.shares row to #67 now exists.
  • Commit: feat(finance-share): claim pending finance shares on signup/login

Task 6: End-to-end verification on the dev slot

  • Files: none (or extend scripts/dev-verify-finance-share.mts).
  • What: On the running slot (:3065) with a seeded test Krystal in her own workspace + a claimed share to #67: confirm (a) her Finances surfaces render #67 data (dashboard totals, transactions, recurring), (b) she can categorize a txn and it persists to #67 and is visible to Justin, (c) her tasks/notes/calendar APIs return empty/403 (she is not a member of #67). Capture a screenshot of her Finances tab.
  • Verification: documented screenshot + the curl/tsx checks in the script all pass; curl to a #67 tasks/node API as Krystal returns 403/empty.
  • Commit: test(finance-share): end-to-end co-owner finances + non-finance isolation

Task 7: Seed the 3 pending finance shares + deploy (prod, gated on Justin's go)

  • Files: none (data op) + merge/deploy.
  • What: Merge feat/finances-shared-krystal to main, deploy (bash /home/justinwieb/forge/scripts/forge_creatortrack_deploy.sh, runs the migration), then seed pending finance shares to #67 for all 3 emails so whichever Krystal uses auto-claims. Do NOT run until Justin approves the deploy.
  • Verification: psql lifeos -c "select email, owner_workspace_id from finance.share_invites where owner_workspace_id=67" shows all 3; a manual signup test (or Justin confirms) shows Krystal's Finances tab live.
  • Commit: chore(finance-share): seed pending finance shares for the 3 Krystal emails

Task 8: Register and document

  • Files: /home/justinwieb/.claude/projects/-home-justinwieb-forge/memory/MEMORY.md (index line), new forge/memory/general/reference_creatortrack_finance_sharing.md (with the URL: line), run bash /home/justinwieb/forge/scripts/forge_eval_run.sh.
  • What: Document the finance.shares model, the resolver, the claim flow, the RLS extension, and the "co-owner writes target owner workspace" rule. Add the MEMORY.md index entry.
  • Verification: bash /home/justinwieb/forge/scripts/forge_eval_run.sh passes; grep -q finance_sharing MEMORY.md.
  • Commit: docs(finance-share): reference topic + MEMORY index