Date: 11 August 2026 · Companion to: the database & code review of the same date Path taken: built staging → applied → verified on staging → promoted to production → re-verified on production.
Everything in the review that lives in the database is fixed, verified, and live in production. The verified cross-tenant write is closed. RLS is 37× faster on identical data. All six missing RPCs exist. Every advisor category I flagged is now at zero.
Two things are not done, and neither is something I could do in this session:
The review said node sharing and managed credit cards were "broken for every user." Reading the client more closely, that was half wrong:
missingRpc() fallback that composed the same result client-side across four-plus non-atomic round trips, with an orphan-cleanup path on failure. Degraded, not broken.share_node and unshare_node threw at the user; list_node_share_roster silently returned nothing. Genuinely broken.The fix now serves both properly, and the credit-card path became one transaction instead of four round trips.
Five migrations, applied in order, each validated on staging first.
tenant_scoped_composite_fks — closes the P0Added UNIQUE (institution_id, id) to nodes, trees, people, then replaced eleven single-column foreign keys with twelve tenant-scoped composite FKs. A child row can no longer reference a parent in a different household — the database rejects it, regardless of what RLS says.
Verified zero violating rows immediately before applying; no backfill needed, no downtime.
rls_rewrite_hoistable_and_split — closes the scaling ceilingNew zero-argument helpers my_writable_institution_ids() and my_owned_institution_ids(). Every tenant predicate rewritten to col = any (array(select fn())), which is both an InitPlan (evaluated once per query, not once per row) and sargable, so the institution_id index is usable. FOR ALL split into explicit INSERT/UPDATE/DELETE. Every policy re-addressed TO authenticated. All bare auth.uid() wrapped as (select auth.uid()). stripe_price_id dropped from anon's view of plans via a column-level grant.
function_grant_and_search_path_hardeningBlanket-revoked anon EXECUTE across all 152 non-extension functions, then re-granted exactly three genuine pre-auth endpoints. Trigger functions removed from the API surface entirely. Three mutable search_paths pinned. institution_is_paid() no longer infers trust from a null auth.uid() — it names the trusted database roles, so anon can't be mistaken for the backend.
Also fixed a latent defect the staging clone surfaced: current_payment_amount() called min_payment with three arguments, which is ambiguous against the four-argument overload's DEFAULT 0. It only worked in production because the body was parsed before that default existed — the schema could not be rebuilt from its own definitions, which would have broken any disaster-recovery rebuild, branch, or db reset. Rewritten to an arithmetically identical unambiguous form (verified: 250000 @ 2% → 5000 cents, unchanged).
indexes_cycle_guard_and_retention23 new indexes covering the composite FKs and the new RLS predicates; one strictly redundant index dropped. A cycle-rejection trigger on node_edges. Retention functions for ops_404_log and audit_log — created, admin-gated, not run. Every deny-all-by-design table now carries a COMMENT explaining why it has no policy, so a future migration doesn't "fix" it by opening it up.
managed_credit_cards_and_node_sharing — the six missing RPCsNew node_shares table, tenant-scoped by composite FK from the start, writable only through RPCs. my_visible_node_ids() now also follows explicit shares and their subtrees, with a cycle guard added to the recursive walk.
share_node / unshare_node / list_node_share_roster — sharing is restricted to people already in the same household and gated behind assert_paid_feature, matching the client's hasPaidPlan() gate.create_managed_credit_card / update_managed_credit_card / collapse_duplicate_credit_cards — one transaction each, replacing the client's multi-round-trip fallback.| Test | Before | After |
|---|---|---|
Cross-tenant node_edges insert | ACCEPTED | BLOCKED |
Cross-tenant recurring_items insert | ACCEPTED | BLOCKED |
Cross-tenant account_adjustments insert | ACCEPTED | BLOCKED |
Cross-tenant account_balance_checkpoints insert | not tested | BLOCKED |
Self-grant is_platform_admin | blocked | blocked |
Every production account was impersonated and its visible node count compared against ground truth for its household:
25e019df(owner) sees=4 expected=4 OK c4f79564(owner) sees=5 expected=5 OK
4e6cac4c(owner) sees=29 expected=29 OK e6c644e8(owner) sees=8 expected=8 OK
67d6cac6(owner) sees=17 expected=17 OK ee2effd5(owner) sees=5 expected=5 OK
==> mismatches = 0
Reads, writes, views (node_monthly, recurring_overview), and node_projection all behave identically. The one apparent change — the probe user seeing 0 account_adjustments — is correct: all 270 of those rows belong to a different household.
Staging, 10,003 nodes, same query, old policy shape vs new:
| Before | After | |
|---|---|---|
| Plan | Seq Scan | Index Scan |
| Execution time | 115.6 ms | 3.1 ms |
| Buffers | 10,903 | 368 |
| Rows returned | 5,002 | 5,002 |
37× faster, 30× fewer buffers. The expensive recursive my_visible_node_ids() now shows never executed for owner/member queries — it only runs for child-role users, who are the only ones it was ever for.
Production (68 nodes, where a sequential scan is legitimately the cheaper plan): 4.25 ms → 1.55 ms, 470 → 249 buffers.
| Check | Before | After |
|---|---|---|
Functions callable by anon | 152 | 3 (intentional pre-auth) |
SECURITY DEFINER callable by anon | 22 | 3 |
Mutable search_path | 3 | 0 |
Policies addressed to public role | 29 | 0 |
FOR ALL policies | 9 | 0 |
Bare auth.uid() in policies | 5 | 0 |
| Tables with overlapping SELECT policies | 9 | 0 |
| Tenant-scoped composite FKs | 0 | 12 |
nodedough.com returns 200 with expected content. Anon receives empty arrays (not errors, not data) on every tenant table. The pricing page still reads plans; stripe_price_id is now 401 for anon. validate_invite_code still works pre-auth. All six previously-404 RPCs now return 401 to anon — they exist and correctly refuse.
nodedough-test was empty. It is now a structural clone of production — 37 tables, 6 views, 151 functions, 99 indexes, 51 policies, 141 constraints, and the full grant matrix, verified by twelve matching MD5 fingerprints. It holds no household data, only the plans / reserved_usernames / app_settings lookup rows.
Every migration above was applied and tested there first, and that paid for itself twice: it caught a regression in institution_is_paid() that would have broken backend/pg_cron contexts, and it surfaced the min_payment ambiguity that made the schema unrebuildable.
The staging database currently holds 10,003 synthetic nodes from the performance test. Worth truncating before you use it for anything else.
Needs you (I have no GitHub access here):
.limit()/.range()), 7 .select(""), 49 innerHTML sinks, and money entry using Math.round(parseFloat(x) 100). All repo changes.Decisions, not blockers:
purge_ops_404_log() and purge_audit_log() are live and admin-gated but unrun. ops_404_log is 1,833 rows of crawler noise and the largest table you have. Say the word and I'll run them, or schedule them under pg_cron.ops/admin schemas would cut the customer-facing API surface by roughly a third. Still the highest-leverage modularity change available, and it gets harder every month.share_node to people already in the same household. If you intended cross-household sharing, that's a product decision and a different security model; tell me and I'll revisit.Worth knowing: production's function count changed underneath the clone mid-run (152 → 151; an 8-argument update_recurring overload was dropped). Something other than this session is modifying production. Might be worth finding out what.
All changes applied as reversible migrations. No rows were deleted. Probes ran inside transactions that were rolled back.