Second pass, 12 August 2026. The first pass (v1 of this document) reported control design. This pass executed the tests against the live Railway and Supabase estate. Several items that were previously "unverified" are now confirmed findings; several others turned out to be clean and are recorded as passes, including one earlier concern that was wrong and is withdrawn.
Read-only throughout: introspection and SELECT only, every probe rolled back, nothing changed.
Tested live against production, 12 August 2026. Everything below was executed, not inferred. Read-only: introspection and SELECT only; every probe ran inside a transaction that was rolled back. No grant, policy, function, row, or configuration was changed.
This file supersedes the "unverified / not assessed" status on the items it covers.
Directive v36 names nodedough, fedm8, and cipherdeck as the gated lane. The Railway configuration says otherwise.
| Railway project | Service | Repo | Production branch | Environments | Serves |
|---|---|---|---|---|---|
nodedough | web | jbnx/nodedough | main | production only | nodedough.com |
project-cipherdeck | web | jbnx/cipherdeck-apps | main | production only | app.cipherdeck.com |
fedm8 | scan-worker | jbnx/fedm8-scan | main | test + production | scan worker |
All three gated projects build their production environment from main. nodedough and project-cipherdeck have no second environment at all — the production branches created earlier are wired to nothing. A push to main on any of these is a live production deploy.
Combined with the PROD-IT lane (jbnx-bill, jbnx.io/projects-portal, mkt, landing pages), no repository in the estate has an enforced change gate. CC8.1 fails estate-wide.
Fix: repoint each gated service's production environment to the production branch. Dashboard only, 2FA required — a CEO action. Acceptance test: re-read get-service-config for each; source.branch reads production.
ops_is_admin() fails open — FedM8 and CipherDeck Appsv_claims := current_setting('request.jwt.claims', true);
if v_claims is null or v_claims = '' then return true; end if; -- ← fail-open
The guard returns true when no JWT claims are present, rather than false. Present in identical form on both oeohvlajtexuxjfrtcdb (FedM8) and xrlpdsovirwluwhemuwf (CipherDeck Apps).
What it guards on CipherDeck Apps — each of these begins if not ops_is_admin() then raise exception 'forbidden':
admin_search_users(p_q, p_limit) → returns user_id, email, display name, created_at, last_sign_in_at, plan and subscription status
admin_404_recent(...), admin_404_top(...)Reachability, tested both ways:
| Caller context | request.jwt.claims | ops_is_admin() | admin_search_users('a',5) |
|---|---|---|---|
| Real PostgREST anonymous request | {"role":"anon"} | false | forbidden |
| Direct DB connection (psql, MCP, worker, cron) | unset | true | returned 5 user rows incl. emails |
Severity: High, not Critical. PostgREST always sets request.jwt.claims, so this is not exploitable over the public API — I verified that explicitly rather than assuming it. It is exploitable from any direct database connection, and anyone holding a connection string already has broader access, so the immediate blast radius is limited.
It matters because it is a fail-open default in a reusable authorisation primitive. The moment ops_is_admin() is referenced by an RLS policy, a new RPC, or an edge function that does not set claims, it silently grants admin. That is how this class of bug becomes critical later.
Fix: invert the default — return false when claims are absent, and grant the trusted-server case explicitly by checking for service_role. Acceptance test: with claims unset, ops_is_admin() returns false; with {"role":"service_role"}, returns true; anonymous PostgREST call to admin_search_users still returns forbidden.
anon and/or authenticated hold INSERT/UPDATE/DELETE with no policy backing them. RLS is enabled on every one, so nothing is currently reachable — but each becomes live the moment someone adds a permissive policy.
| Table | anon | authenticated |
|---|---|---|
companies, company_ai_wallets, company_capabilities, company_invites, company_join_requests, company_members | INS/UPD/DEL | INS/UPD/DEL |
crm_accounts, crm_activities, crm_contacts, notification_prefs | INS/UPD/DEL | — |
Fix: revoke the unbacked privileges. Check relrowsecurity first on anything you touch. Acceptance test: the dead-grants query returns zero rows.
ops_submit_ticket(p_email, p_subject, p_body, p_name, p_source, p_ip, p_meta) — anon-executable write with fully attacker-controlled fields including p_ip and arbitrary p_meta JSON.
ops_log_404(p_path, p_referrer, p_user_agent, p_ip, p_user_id uuid, p_meta) — anon canwrite log rows attributed to any user id. Spoofable attribution in a security-relevant log.
Fix: derive p_ip and p_user_id server-side rather than accepting them; rate-limit ticket submission.
ops_touch_ticket() and vet_profiles_guard_stable_ids() are trigger functions and are directly callable by anon. Trigger functions should never be. vet_profiles_guard_stable_ids is a guard on veteran profile identity. This is the default-grant issue the runbook documents, unremediated.
Fix: revoke execute ... from anon, authenticated on both.
| Measure | Value |
|---|---|
billing.work_sessions rows | 269 |
with project_id NULL | 76 (28%) |
| Distinct slugs used in sessions | 14 |
Slugs missing from billing.projects | 0 ✅ |
billing.projects rows | 17 |
billing.customer_projects assignments | 5 |
full-stack present in billing.projects | yes (205851e0…) |
full-stack assigned to a customer | 0 |
The directive defines this exactly: "A run that bills a slug missing from billing.projects / customer assignment is a defect — same class (invisible hours)." Twelve of seventeen billed projects have no customer assignment, and my own session on full-stack is one of them.
Fix: backfill project_id on the 76 orphaned sessions; assign the 12 unassigned projects to their paying customer; add a NOT NULL constraint or a nightly orphan check so it cannot recur. Acceptance test: orphan count is 0 and every slug appearing in work_sessions resolves to an assigned customer.
GITHUB_TOKEN in a production workerfedm8 scan-worker production carries GITHUB_TOKEN alongside SUPABASE_SECRET_KEY, STRIPE_SECRET_KEY, LITELLM_API_KEY, RESEND_API_KEY, SAM_API_KEY, AGENT_GATE_TOKEN, and OUTBOUND_WEBHOOK_INGEST_TOKEN. A GitHub token in a scanning worker is a lateral-movement path from a data-processing service into source control.
Fix: confirm the token's scopes; if it is a classic PAT, replace with a fine-grained token limited to the one repository and permission required, or remove it if unused.
Recorded because a review that only reports problems is not an assessment, and because several of these overturn assumptions in the first draft of this framework.
| Check | Result |
|---|---|
Tables with RLS disabled in public | None on FedM8, NodeDough, or CipherDeck Apps |
TRUNCATE granted to anon/authenticated | None on FedM8 or NodeDough |
| Write privileges on views | None on FedM8, NodeDough, or CipherDeck Apps — the 2026-07-31 NodeDough fix has held |
| Dead grants on CipherDeck Apps | None |
| FedM8 entitlement functions as anon | my_plan() → free, plan_at_least('pro') → false, ops_is_admin() → false. Fails closed correctly under a real anon request |
NodeDough validate_invite_code | Exists in production. The runbook records it as missing; that is now stale |
| CipherDeck Apps key format | Migrated to SUPABASE_PUBLISHABLE_KEY / SUPABASE_SECRET_KEY. NodeDough still on legacy SUPABASE_ANON_KEY — migration is partial, not absent |
| Billing credits arithmetic | Correct. hoursUsed() returns full precision with a comment explaining why, and creditsUsed = Math.round(hoursUsedVal * 6000) rounds once at the credit boundary. 325.867s → 543 credits → $5.43. The hours: 0.09 in the claim/release API response is display rounding only; the stored row holds exact timestamps. An earlier concern in this engagement about a 3-credit variance was wrong and is withdrawn. |
ops_is_admin() default on both databases — one-line fix, removes a fail-openprimitive before it gets reused.
project_id and the 12 missing customer assignments — currently invisiblehours that a customer cannot see and you cannot bill for.
ops_submit_ticket / ops_log_404 — stop accepting p_ip and p_user_id from callers.GITHUB_TOKEN on the FedM8 scan worker.Items 2, 3, and 5 are database changes. Per the standing instruction not to modify code, they are written here as findings with acceptance tests rather than applied. Say the word and they are a short, reviewable migration.