auditnodedough · v7claude-opus-5-cowork2026-08-12served from databaseAll documents

NodeDough — Remediation Record (Aug 2026)

NodeDough — Remediation Record

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.


BLUF

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:


Correction to the review

The review said node sharing and managed credit cards were "broken for every user." Reading the client more closely, that was half wrong:

The fix now serves both properly, and the credit-card path became one transaction instead of four round trips.


What is now live in production

Five migrations, applied in order, each validated on staging first.

1. tenant_scoped_composite_fks — closes the P0

Added 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.

2. rls_rewrite_hoistable_and_split — closes the scaling ceiling

New 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.

3. function_grant_and_search_path_hardening

Blanket-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).

4. indexes_cycle_guard_and_retention

23 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.

5. managed_credit_cards_and_node_sharing — the six missing RPCs

New 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.


Verification

The original exploit, re-run against production

TestBeforeAfter
Cross-tenant node_edges insertACCEPTEDBLOCKED
Cross-tenant recurring_items insertACCEPTEDBLOCKED
Cross-tenant account_adjustments insertACCEPTEDBLOCKED
Cross-tenant account_balance_checkpoints insertnot testedBLOCKED
Self-grant is_platform_adminblockedblocked

No regression — all six real users

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.

Performance, measured on identical data

Staging, 10,003 nodes, same query, old policy shape vs new:

BeforeAfter
PlanSeq ScanIndex Scan
Execution time115.6 ms3.1 ms
Buffers10,903368
Rows returned5,0025,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.

Advisor categories

CheckBeforeAfter
Functions callable by anon1523 (intentional pre-auth)
SECURITY DEFINER callable by anon223
Mutable search_path30
Policies addressed to public role290
FOR ALL policies90
Bare auth.uid() in policies50
Tables with overlapping SELECT policies90
Tenant-scoped composite FKs012

Live surface

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.


Staging is real now

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.


Still open

Needs you (I have no GitHub access here):

  1. Client-side work — no pagination anywhere (119 queries, zero .limit()/.range()), 7 .select(""), 49 innerHTML sinks, and money entry using Math.round(parseFloat(x) 100). All repo changes.
  2. Merge and deploy — you asked me to merge all branches and deploy. I couldn't. Authorize the GitHub connector (claude.ai → Connectors) or run it from an interactive session and I'll do it.

Decisions, not blockers:

  1. Retentionpurge_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.
  2. Schema split — moving the admin console, ops logging, CMS, and support desk into unexposed 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.
  3. CI drift check — with a real staging database this is now a cheap CI job rather than a research project.
  4. Sharing semantics — I scoped 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.