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.tsfinance READS have 0 explicitworkspace_idfilters, they rely purely on RLS viaasUser(identity)(lib/rls.ts). Extending finance RLS to admit a shared user makes her reads of #67 return automatically.- Finance WRITES set
workspace_idexplicitly (e.g.learnRuleinserts intofinance.category_rules, new-txn/budget inserts). These need the resolved owner workspace id (67), not Krystal's own workspace. financeapp isownerOnly: 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 viacurrentUser()(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 notesheading. - What: Grep the finance-tables migration(s) in
db/migrations/for thecreate policy ... on finance.*statements (SELECT/INSERT/UPDATE/DELETE) and record their exact names +using/with checkclauses. Enumerate every finance WRITE site that setsworkspace_id(greplib/finance*.ts,app/api/finance/**,app/finances/**forworkspace_idin inserts andlearnRule(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 | headreturns 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 policyworkspace_isolation USING (workspace_id IN (SELECT m.workspace_id FROM core.memberships m WHERE m.user_id = core.current_user_id())), noWITH 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);asUsersetsapp.user_id/app.is_owner;core.current_user_id()readsapp.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 globalisPersonalAppsOwneris shared by many apps (business, social, calendar, fitness, campaigns, sales, rss, agents, admin), so it must NOT change; add a finance-onlyhasFinanceAccess. - 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) + inlineselect m.workspace_id ... order by workspace_id limit 1inapp/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 throughresolveFinanceWorkspace(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(viapython3 ~/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 helpercore.finance_shared_workspaces(user_id bigint) returns setof bigint(owner workspace ids shared TO this user). Extend eachfinance.*RLS policy'susing/with checkso a row whoseworkspace_idis incore.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.pyapplies clean;psql ... -c "\d+ finance.shares"shows the table andselect 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 newlib/finance-share.ts), exportresolveFinanceWorkspace(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>))})"prints67for 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(learnRulecallers 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 withawait 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 haveworkspace_id=67and 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_invitessibling or a newcore.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_inviteswith akind='finance_share'+owner_workspace_id, or a smallfinance.share_invites(email, owner_workspace_id, role)). On signup/login, for the authenticated email, convert any pending finance-share into afinance.sharesrow 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 afinance.sharesrow 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;
curlto 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-krystalto 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), newforge/memory/general/reference_creatortrack_finance_sharing.md(with theURL:line), runbash /home/justinwieb/forge/scripts/forge_eval_run.sh. - What: Document the
finance.sharesmodel, 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.shpasses;grep -q finance_sharing MEMORY.md. - Commit:
docs(finance-share): reference topic + MEMORY index