feat(db): auto-provision workspace_members on user invite#60
Conversation
- add SECURITY DEFINER trigger provision_workspace_membership() resolving company_id -> halcrm_workspaces.workspace_slug, computed is_default guards the one-default-per-user partial unique index, ON CONFLICT DO NOTHING - fire AFTER INSERT OR UPDATE OF raw_app_meta_data, WHEN-gated on a non-null and changing company_id (auth-gateway sets it after insert) - one-shot idempotent backfill for Yani (ic-ingenieurs-conseils) - committed only; applied manually to prod post-merge (no db push here) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔍 Comprehensive PR ReviewPR: #60 — feat(db): auto-provision workspace_members on user invite SummaryPure SQL migration — a hal-owned Verdict:
(10 raw findings → 8 after dedup: code-review F1 ≡ test-cov F2 (unbounded join); error-handling F2 ≡ code-review F3 (unguarded 🟡 Medium Issues (Needs Decision)M1 ·
|
| # | Issue | Location | Suggestion |
|---|---|---|---|
| L1 | CREATE TRIGGER not idempotent (asymmetric with CREATE OR REPLACE FUNCTION) |
:47 |
Prepend DROP TRIGGER IF EXISTS trg_provision_workspace_membership ON auth.users; — replay-safe on manual apply |
| L2 | Backfill hard-codes is_default=true while the fn computes it |
:64-66 |
Compute is_default via the same NOT EXISTS subquery — drift-proof, removes the asymmetry |
| L3 | hal/CLAUDE.md still says provisioning is manual-only |
hal/CLAUDE.md:30-34, 67 |
Add a bullet: auto-provisioned by the trigger on invite; reframe backfill as the historical one-off |
✅ What's Good
- Computed
is_defaultcorrectly guards the partial unique index (avoids a violation and a second UPDATE). WHEN … IS DISTINCT FROM—OLD IS NULLon INSERT fires;last_sign_in_atupdates skip. Exactly the two-step auth-gateway flow.ON CONFLICT (workspace_slug, user_id)targets the real PK (verified) → safe idempotency.SECURITY DEFINER+SET search_path = publiccorrectly scoped and justified in-comment.- Excellent inline docs, no history comments; all comment claims verified vs. live prod schema.
- Tight scope:
index.tsuntouched, edifice untouched, cross-repo-log accurate. pytest (157) green.
📋 Suggested Follow-up Issues
| Title | Priority | Finding |
|---|---|---|
| Enforce 1:1 company_id→workspace (partial UNIQUE) | P2 | M1 |
| RAISE WARNING when provisioning inserts zero rows | P2 | M3 |
| Scripted verification checklist for the provisioning trigger | P3 | M5 |
| Update hal/CLAUDE.md access-model for auto-provisioning | P3 | L3 |
Next Steps
- ⚡ Auto-fix addresses 0 issues (no CRITICAL/HIGH). The comment fix (M4) + two one-line SQL hardenings (L1, L2) are low-risk and could ride this PR.
- 📝 Decide the MEDIUM cluster: how defensive to be against a trigger exception aborting the auth write (M1/M2/M3) vs. accept-per-POC. Fix M4.
- 🎯 Adopt the M5 checklist before/at prod apply — cheap, closes the real risk.
Reviewed by Archon comprehensive-pr-review workflow
Artifacts: artifacts/runs/3cf553cda682155d223093cb7d4a9dfe/review/
No CRITICAL/HIGH issues found by the review (0/0). Applied the three zero-tradeoff fixes the review flagged as "could ride this PR": - M4: header comment contradicted CLAUDE.md — rewrote to name apply_migration (Supabase MCP)/dashboard as the sanctioned tool and keep the real `supabase db push` footgun warning. - L1: prepend `DROP TRIGGER IF EXISTS` so a manual post-merge re-apply is idempotent (matches the CREATE OR REPLACE FUNCTION already above it). - L2: backfill computes is_default via the same NOT EXISTS guard as the function instead of hard-coding true — drift-proof on re-run. Left for user decision (design tradeoffs, per review "Decide the MEDIUM cluster"): M1 (unbounded company_id→workspace join), M2 (unguarded ::uuid cast), M3 (silent zero-row no-op), M5 (verification checklist), L3 (hal/CLAUDE.md access-model doc). Review artifacts: /Users/renaud/.archon/workspaces/BluegReeno/hal/artifacts/runs/3cf553cda682155d223093cb7d4a9dfe/review/ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
⚡ Auto-Fix ReportStatus: COMPLETE The review found 0 CRITICAL / 0 HIGH — nothing mandatory to auto-fix. Applied the three zero-tradeoff hardenings the review flagged as "could ride this PR". Left the MEDIUM design cluster (M1–M3, M5) for your decision, per the review's own "Decide the MEDIUM cluster". Fixes Applied
What Was Fixed
Tests AddedNone — SQL-only migration; no pgTAP harness in the repo (matches all 19 prior migrations). pytest (157) is application-layer and unaffected. 🟡 MEDIUM Issues (Your Decision — design tradeoffs, not defects; none block the PR)
🟢 L3 — deferred
📋 Suggested Follow-up Issues
ValidationSQL-only PR — Auto-fixed by Archon comprehensive-pr-review workflow |
🎯 Workflow SummaryPlan: Implementation vs Plan
No functional deviations. Implementation matches the plan's Migration SQL verbatim; only inline explanatory comments were added (consistent with neighbouring migrations). Offline pytest suite: 157 passed, 0 failed. Review Summary5 review agents ran (code-review, error-handling, test-coverage, comment-quality, docs-impact). Verdict: NEEDS_DISCUSSION — no defects, but a MEDIUM design cluster to decide.
Fixed in
⚖️ Decision Cluster — MEDIUM findings left open (design tradeoffs, not bugs)The recurring question: how defensive to be against a trigger exception aborting the
📝 Documentation Gap (deferred — outside this PR's changed files)
📋 Suggested Follow-Up Issues
⏭️ Reminder before merge / apply
ℹ️ Deferred by design (NOT Building — intentionally excluded)
Artifacts: |
The "no matching halcrm_workspaces for company_id" branch was a fully silent no-op — the exact original bug (user provisioned into nothing) could reappear undetected. Add a loud-but-non-fatal RAISE WARNING for that specific case only, keeping the "never fail the signup" invariant (no RAISE EXCEPTION). The idempotent "membership already exists" case still matched a workspace, so it stays silent by design. Refactor the company_id cast into a v_company_id local to avoid computing NULLIF twice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix: scripted verification checklist for auth.users provisioning trigger (#63) provision_workspace_membership() (the SECURITY DEFINER trigger auto-provisioning workspace_members on invite) had only ever been checked by ad hoc, undocumented rolled-back transactions (PR #60 review finding M5). Add one reusable, self-asserting asset. Changes: - Add scripts/verify_provisioning_trigger.sql: 6 numbered, independent BEGIN...ROLLBACK scenarios (happy path, UPDATE-after-INSERT, is_default guard, orphan company_id, idempotent re-run, routine last_sign_in_at no-op), each self-reporting PASS/FAIL via RAISE NOTICE/EXCEPTION on synthetic fixtures — zero residue on prod - Link the script from docs/operations.md §1 with guidance on when to run it Task 3 (live 6/6 PASS run against prod + cross-repo-log entry) is a post-merge step requiring Supabase MCP access, matching PR #60's precedent. Fixes #63 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: correct overclaimed coverage in verification header comment Fixed: - scripts/verify_provisioning_trigger.sql header overclaimed coverage of the #62 malformed-company_id cast guard (no scenario tests it) and mischaracterized the is_default collision as a reproduced prod incident (issue #61 records it as latent, never reproduced). Skipped: - README.md scripts/ tree omission of the new script — docs-impact agent explicitly recommends no action (tree already omits 3 pre-existing scripts; piecemeal fix would just extend the gap). --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The job as first written could not have worked. Each fault below was found by running it locally against a real prod dump, not by review: - postgres:17 has neither the cluster roles the snapshot assigns ownership to (supabase_admin, supabase_auth_admin, anon, authenticated, service_role, dashboard_user) nor the extensions its types come from. It died on line 18 with `role "supabase_admin" does not exist`, and later on `type "public.geometry" does not exist`. Use public.ecr.aws/supabase/postgres at prod's exact version (17.4.1.052) — the same image `supabase db dump` runs — where postgis, vector and pg_net all resolve to prod's exact versions. - Connect as supabase_admin. In that image `postgres` is restricted and the snapshot's first statements fail with `permission denied for schema auth`. - Drop the image's own auth schema first. It ships a GoTrue-initialised `auth` older than prod's, and the snapshot uses CREATE TABLE IF NOT EXISTS — so it silently skipped auth.users, then failed on `column "is_sso_user" ... does not exist`. - Install prod's public-schema extensions before loading (tests/fixtures/ ci_extensions.sql). The dump carries the contents of auth/public but not the CREATE EXTENSION statements behind their types. - Apply what the branch ADDS, not "the newest migration". The snapshot is a dump of prod, so it already contains every migration prod has applied — replaying the newest one fails `relation ... already exists` on every single run. Diff against the base branch instead, which is why checkout needs fetch-depth: 0. - Dump auth,public,private, not auth,public: objects in public reference prod's private schema, and the load aborts on `schema "private" does not exist`. Verified locally on 2026-07-24 against a real dump: extensions + snapshot load clean (62 public tables, 23 auth tables, 8 halcrm tables, 23 triggers); a branch adding no migration passes; and a trigger re-introducing the pre-fix `WHEN (OLD...)` form fails with `INSERT trigger's WHEN condition cannot reference OLD values` — the exact error that reached prod through PR #60. The snapshot itself is still not committed, now for a new reason: a prod dump of `public` carries a hardcoded service_role key out of edifice's edifice_trigger_map_generation. Tracked separately in edifice; #65 stays open. Refs #65 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix: CI migration smoke-test against ephemeral prod-schema Postgres (#65) The provision_workspace_membership migration (PR #60) passed CI + 5-agent review yet failed at prod apply with 42P17 (INSERT trigger WHEN cannot reference OLD). DDL-only errors are invisible to Deno type-check + offline pytest; only a real Postgres catches them. Add a CI net before prod. Changes: - .github/workflows/ci.yml: new migration-smoke job with a postgres:17 service container (major pinned to prod's verified 17.4). Fails loud with the exact `supabase db dump` command when supabase/schema.snapshot.sql is absent, loads the snapshot, then applies the newest migration under supabase/migrations/ with psql -v ON_ERROR_STOP=1; non-zero exit fails the job. - docs/operations.md §1: snapshot refresh procedure, auth prerequisite, the GoTrue limitation, the version-pin rationale, and the newest-only scope decision. - README.md: repo-layout pointer (marked NOT YET COMMITTED) + Develop & deploy note. schema.snapshot.sql is intentionally NOT created — it needs an interactive `supabase db dump` as renaud@bluegreen.ai against shared prod. The job is expected RED until the human commits it; a silent pass is the failure mode this issue removes. Fixes #65 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ci): make migration-smoke actually runnable against prod's schema The job as first written could not have worked. Each fault below was found by running it locally against a real prod dump, not by review: - postgres:17 has neither the cluster roles the snapshot assigns ownership to (supabase_admin, supabase_auth_admin, anon, authenticated, service_role, dashboard_user) nor the extensions its types come from. It died on line 18 with `role "supabase_admin" does not exist`, and later on `type "public.geometry" does not exist`. Use public.ecr.aws/supabase/postgres at prod's exact version (17.4.1.052) — the same image `supabase db dump` runs — where postgis, vector and pg_net all resolve to prod's exact versions. - Connect as supabase_admin. In that image `postgres` is restricted and the snapshot's first statements fail with `permission denied for schema auth`. - Drop the image's own auth schema first. It ships a GoTrue-initialised `auth` older than prod's, and the snapshot uses CREATE TABLE IF NOT EXISTS — so it silently skipped auth.users, then failed on `column "is_sso_user" ... does not exist`. - Install prod's public-schema extensions before loading (tests/fixtures/ ci_extensions.sql). The dump carries the contents of auth/public but not the CREATE EXTENSION statements behind their types. - Apply what the branch ADDS, not "the newest migration". The snapshot is a dump of prod, so it already contains every migration prod has applied — replaying the newest one fails `relation ... already exists` on every single run. Diff against the base branch instead, which is why checkout needs fetch-depth: 0. - Dump auth,public,private, not auth,public: objects in public reference prod's private schema, and the load aborts on `schema "private" does not exist`. Verified locally on 2026-07-24 against a real dump: extensions + snapshot load clean (62 public tables, 23 auth tables, 8 halcrm tables, 23 triggers); a branch adding no migration passes; and a trigger re-introducing the pre-fix `WHEN (OLD...)` form fails with `INSERT trigger's WHEN condition cannot reference OLD values` — the exact error that reached prod through PR #60. The snapshot itself is still not committed, now for a new reason: a prod dump of `public` carries a hardcoded service_role key out of edifice's edifice_trigger_map_generation. Tracked separately in edifice; #65 stays open. Refs #65 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ci): commit the prod schema snapshot — migration-smoke can now run Unblocks the last acceptance criterion of #65. The snapshot could not be committed until today: a dump of `public` carried a hardcoded service_role key out of edifice's edifice_trigger_map_generation(). That is now fixed on the edifice side (key moved to Vault, then replaced by a dedicated narrow-purpose secret, and both compromised keys rotated out), so a plain dump is finally clean. `supabase db dump --schema auth,public,private` against zgkvbjqlvebttbnkklpo, 2026-07-24. Audited before committing, not assumed: - no sb_secret_/sb_publishable_/eyJhbGci/SCRAM-SHA/PASSWORD literal, no private key block; the only long quoted strings are identifiers (`edifice_map_trigger_secret` is the NAME of a Vault entry, not its value); - edifice_trigger_map_generation() reads `vault.decrypted_secrets` — no credential in the body; - 0 COPY/INSERT statements: schema only, no data. Load verified against public.ecr.aws/supabase/postgres:17.4.1.052 — extensions + snapshot apply clean (62 public tables, 23 auth, 8 halcrm, 23 user triggers). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
hal-mcp resolves the caller's workspace strictly from
workspace_members, but auth-gateway invites a user (setsraw_app_meta_data.company_id) without ever creating that membership row — so every invited user is broken until manual SQL. This adds a hal-owned SECURITY DEFINER trigger onauth.usersthat auto-provisions the membership by resolvingcompany_id → halcrm_workspaces, plus a one-shot backfill for Yani.Pure DB change — no hal-mcp code touched.
index.tsis untouched (it already readsworkspace_members); no Edge Function redeploy.Implements brief
docs/features/workspace-members-provisioning-trigger.md.Changes
supabase/migrations/20260719000000_provision_workspace_membership.sqlprovision_workspace_membership()fn +trg_provision_workspace_membershiptrigger + Yani backfill (+74)docs/cross-repo-log.mdHow it works
public.provision_workspace_membership()—SECURITY DEFINER,SET search_path = public. Resolvesworkspace_slugby joininghalcrm_workspacesoncompany_id = NULLIF(NEW.raw_app_meta_data->>'company_id','')::uuid, insertsrole='member'.is_defaultis computed (NOT EXISTS (… WHERE m.user_id = NEW.id AND m.is_default)) so it never violates theworkspace_members_one_default_per_userpartial unique index — hard-codingtruewould raise a unique violation and fail signup for a user who already has a default.ON CONFLICT (workspace_slug, user_id) DO NOTHING→ idempotent.trg_provision_workspace_membership—AFTER INSERT OR UPDATE OF raw_app_meta_data ON auth.users FOR EACH ROW. WHEN-gated on a non-null AND changingcompany_id:OLDis NULL on INSERT (IS DISTINCT FROMa real value → fires); on UPDATE it fires only whencompany_idactually changes, so routinelast_sign_in_atupdates don't re-run it. auth-gateway setscompany_idafter the initial insert, henceUPDATE OF raw_app_meta_datais required.ic-ingenieurs-conseils,aa7b0527-ca6b-4134-b31b-247ef48fc01e,member,true), who was invited before this trigger existed (verified 2026-07-19: zeroworkspace_membersrows).Tests
None added. Pure SQL migration — migrations are not unit-tested in this repo; verified manually against prod post-merge (fresh test invite →
whoami). Primary validation is SQL review by reading.Validation
is_defaultguard,ON CONFLICT DO NOTHING, trigger WHEN-clause, backfill)edifice_*tables NOT touched20260719000000sorts after latest20260630165203; naming convention respecteddeno checkruns in CI regardless.Scope limits (do NOT flag as missing)
edifice_profiles.organization_idrepair (Steeve) — edifice-owned, out of scope (brief §Open-questions Q1).member; no multi-workspace auto-provisioning.Post-merge action (Renaud)
Apply via
apply_migrationon prodzgkvbjqlvebttbnkklpo(neverdb pushfrom this repo), then verify with a fresh test invite →whoamireturns the workspace. Yani's backfill row appears immediately after apply.Plan:
docs/features/workspace-members-provisioning-trigger.mdWorkflow ID:
3cf553cda682155d223093cb7d4a9dfe