Skip to content

feat(db): auto-provision workspace_members on user invite#60

Merged
BluegReeno merged 3 commits into
mainfrom
archon/task-feat-workspace-members-provisioning-trigger
Jul 19, 2026
Merged

feat(db): auto-provision workspace_members on user invite#60
BluegReeno merged 3 commits into
mainfrom
archon/task-feat-workspace-members-provisioning-trigger

Conversation

@BluegReeno

Copy link
Copy Markdown
Owner

Summary

hal-mcp resolves the caller's workspace strictly from workspace_members, but auth-gateway invites a user (sets raw_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 on auth.users that auto-provisions the membership by resolving company_id → halcrm_workspaces, plus a one-shot backfill for Yani.

Pure DB change — no hal-mcp code touched. index.ts is untouched (it already reads workspace_members); no Edge Function redeploy.

Implements brief docs/features/workspace-members-provisioning-trigger.md.

Changes

File Action Description
supabase/migrations/20260719000000_provision_workspace_membership.sql CREATE provision_workspace_membership() fn + trg_provision_workspace_membership trigger + Yani backfill (+74)
docs/cross-repo-log.md UPDATE Implementation entry appended (+9)

How it works

  • Function public.provision_workspace_membership()SECURITY DEFINER, SET search_path = public. Resolves workspace_slug by joining halcrm_workspaces on company_id = NULLIF(NEW.raw_app_meta_data->>'company_id','')::uuid, inserts role='member'. is_default is computed (NOT EXISTS (… WHERE m.user_id = NEW.id AND m.is_default)) so it never violates the workspace_members_one_default_per_user partial unique index — hard-coding true would raise a unique violation and fail signup for a user who already has a default. ON CONFLICT (workspace_slug, user_id) DO NOTHING → idempotent.
  • Trigger trg_provision_workspace_membershipAFTER INSERT OR UPDATE OF raw_app_meta_data ON auth.users FOR EACH ROW. WHEN-gated on a non-null AND changing company_id: OLD is NULL on INSERT (IS DISTINCT FROM a real value → fires); on UPDATE it fires only when company_id actually changes, so routine last_sign_in_at updates don't re-run it. auth-gateway sets company_id after the initial insert, hence UPDATE OF raw_app_meta_data is required.
  • Backfill — one-shot idempotent insert for Yani (ic-ingenieurs-conseils, aa7b0527-ca6b-4134-b31b-247ef48fc01e, member, true), who was invited before this trigger existed (verified 2026-07-19: zero workspace_members rows).

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

  • Offline pytest suite: 157 passed, 0 failed, 17 deselected (live) — confirms no regression
  • SQL review (by reading): all acceptance criteria met (SECURITY DEFINER, workspace resolution, computed is_default guard, ON CONFLICT DO NOTHING, trigger WHEN-clause, backfill)
  • edifice_* tables NOT touched
  • Timestamp 20260719000000 sorts after latest 20260630165203; naming convention respected
  • No migration applied to any live DB (file committed only)
  • ⏭️ Type check / lint / format / build — N/A (no JS/TS/Python source changed). deno check runs in CI regardless.

Scope limits (do NOT flag as missing)

  • No hal-mcp code change; no Edge Function redeploy.
  • No edifice_profiles.organization_id repair (Steeve) — edifice-owned, out of scope (brief §Open-questions Q1).
  • No Steeve backfill (already has a membership); only Yani was missing.
  • No role mapping beyond member; no multi-workspace auto-provisioning.

Post-merge action (Renaud)

Apply via apply_migration on prod zgkvbjqlvebttbnkklpo (never db push from this repo), then verify with a fresh test invite → whoami returns the workspace. Yani's backfill row appears immediately after apply.


Plan: docs/features/workspace-members-provisioning-trigger.md
Workflow ID: 3cf553cda682155d223093cb7d4a9dfe

- 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>
@BluegReeno

Copy link
Copy Markdown
Owner Author

🔍 Comprehensive PR Review

PR: #60 — feat(db): auto-provision workspace_members on user invite
Reviewed by: 5 specialized agents (code-review · error-handling · test-coverage · comment-quality · docs-impact)
Date: 2026-07-19


Summary

Pure SQL migration — a hal-owned SECURITY DEFINER trigger on auth.users that auto-provisions a workspace_members row when auth-gateway sets raw_app_meta_data.company_id, plus a one-shot Yani backfill. No hal-mcp code touched; scope discipline clean; cross-repo-log entry ships in the same commit. All five agents agree the happy-path logic is carefully written and well-documented (computed is_default guard, ON CONFLICT DO NOTHING, IS DISTINCT FROM WHEN clause). No CRITICAL or HIGH issues. The MEDIUMs cluster around one theme — edge cases where a trigger exception could abort the auth.users write and break the signup the PR protects — plus one born-contradictory comment.

Verdict: NEEDS_DISCUSSION

Severity Count
🔴 CRITICAL 0
🟠 HIGH 0
🟡 MEDIUM 5
🟢 LOW 3

(10 raw findings → 8 after dedup: code-review F1 ≡ test-cov F2 (unbounded join); error-handling F2 ≡ code-review F3 (unguarded ::uuid cast).)


🟡 Medium Issues (Needs Decision)

M1 · company_id → workspace join is unbounded — >1 workspace/company would abort the auth write

📍 supabase/migrations/20260719000000_provision_workspace_membership.sql:20-38 · code-review + test-coverage

halcrm_workspaces.company_id is a plain FK, not UNIQUE. Two workspaces for one company → two rows both computing is_default=true → violates workspace_members_one_default_per_user (not covered by the ON CONFLICT) → aborts the auth write. Latent today (de-facto 1:1), unenforced by schema.

Options
  • Document (recommended, YAGNI): comment the 1:1 assumption at the join + checklist assertion.
  • Compute-safe: row_number() OVER (…) = 1 AND NOT EXISTS(…).
  • Enforce: partial UNIQUE (company_id) WHERE company_id IS NOT NULL — principled follow-up.

Do not add LIMIT 1/DISTINCT ON — that masks a real data-model violation.

M2 · Unguarded ::uuid cast can raise and abort the signup it must not fail

📍 …provision_workspace_membership.sql:37 · error-handling + code-review

NULLIF(…->>'company_id','')::uuid throws 22P02 on non-empty non-UUID input; the WHEN clause checks non-empty only. Inside an AFTER trigger this aborts the invite.

Options — split recommendation
  • Accept (code-review): trust auth-gateway; "fail fast, loudly"; a guard is YAGNI.
  • Regex WHEN guard (error-handling): non-UUID never fires the trigger → hal-local no-op.
  • Narrow EXCEPTION: catch only invalid_text_representation, RAISE WARNING (never WHEN OTHERS).

Crux = how much you trust auth-gateway's write. If hardening, prefer the regex WHEN guard.

M3 · "No matching workspace" is a fully silent no-op — the original bug can reappear undetected

📍 …provision_workspace_membership.sql:20-35 · error-handling

Zero-row insert + RETURN NEW is indistinguishable from success — the exact bug the PR fixes, now silent (company has no workspace yet / stale company_id). Intentional by scope but in tension with CLAUDE.md "fail loudly".

Options
  • RAISE WARNING (recommended): GET DIAGNOSTICS n = ROW_COUNT; warn when n=0 and company_id non-null. Loud but non-fatal → honours both "never fail signup" and "fail loudly".
  • Accept the silent no-op / external monitor.

M4 · Header comment forbids apply_migration — contradicts CLAUDE.md and this PR's own log

📍 …provision_workspace_membership.sql:6-7 · comment-quality

CLAUDE.md: apply_migration is the recommended path; only supabase db push is forbidden. The cross-repo-log entry (same diff) says Renaud applies via apply_migration. Born-contradictory comment.

Fix
-- Applied to zgkvbjqlvebttbnkklpo AFTER merge via `apply_migration` (Supabase MCP)
-- or the dashboard SQL editor — NEVER `supabase db push` from this repo (partial,
-- apply-time-stamped ledger: db push would replay the full history and fail).

One-line comment fix — the only change touching the SQL text.

M5 · Coverage rests on an unautomated, undefined, single-path manual check

📍 whole migration · test-coverage

No SQL harness (matches repo convention — fine). But the manual "invite → whoami" check has no written procedure and a happy-path click-through misses the two branches that matter: the UPDATE-after-INSERT firing path (the real auth-gateway path) and the is_default guard.

Recommended — written checklist (LOW effort)

Scenarios: (1) UPDATE-after-INSERT fires; (2) user with prior default → new row is_default=false, no violation; (3) idempotency on re-fire; (4) no matching workspace → zero rows, no error; (5) last_sign_in_at UPDATE does not re-fire; (6) assert company_id→workspace is 1:1 (ties to M1). pgTAP-in-CI = over-engineering for a POC.


🟢 Low Issues

View 3 low-priority suggestions
# 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_default correctly guards the partial unique index (avoids a violation and a second UPDATE).
  • WHEN … IS DISTINCT FROMOLD IS NULL on INSERT fires; last_sign_in_at updates 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 = public correctly scoped and justified in-comment.
  • Excellent inline docs, no history comments; all comment claims verified vs. live prod schema.
  • Tight scope: index.ts untouched, 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

  1. ⚡ 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.
  2. 📝 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.
  3. 🎯 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>
@BluegReeno

Copy link
Copy Markdown
Owner Author

⚡ Auto-Fix Report

Status: COMPLETE
Pushed: ✅ Changes pushed to PR (14f4bfc)

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

Severity Fixed Skipped
🔴 CRITICAL 0 0
🟠 HIGH 0 0
🟡/🟢 Low-risk (M4, L1, L2) 3

What Was Fixed

  • M4 (…provision_workspace_membership.sql:6-8) — header comment forbade apply_migration, contradicting CLAUDE.md. Rewrote to name apply_migration (Supabase MCP)/dashboard as the sanctioned tool; kept the real supabase db push footgun warning.
  • L1 (:51) — prepended DROP TRIGGER IF EXISTS … so a manual post-merge re-apply is idempotent (matches the CREATE OR REPLACE FUNCTION above it).
  • L2 (:69-77) — backfill now computes is_default via the same NOT EXISTS guard as the function instead of hard-coding true — drift-proof on re-run.

Tests Added

None — 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)

Issue Options
M1 — unbounded company_id→workspace join (>1 workspace/company aborts auth write; latent-only today) Document 1:1 now / partial UNIQUE follow-up
M2 — unguarded ::uuid cast can raise inside the trigger Accept-per-POC (fail-loud) / regex WHEN guard
M3 — "no matching workspace" is a silent no-op Accept / RAISE WARNING on zero rows
M5 — verification is an undefined single-path manual check Adopt the 6-scenario checklist before prod apply

🟢 L3 — deferred

hal/CLAUDE.md still presents provisioning as manual-only — outside this PR's changed files → P3 follow-up.


📋 Suggested Follow-up Issues

  1. Enforce 1:1 company_id→workspace (partial UNIQUE on halcrm_workspaces) (P2) — M1
  2. Add RAISE WARNING when provisioning trigger inserts zero rows (P2) — M3
  3. Write a scripted verification checklist for the provisioning trigger (P3) — M5
  4. Update hal/CLAUDE.md access-model to document auto-provisioning (P3) — L3

Validation

SQL-only PR — type-check / lint / build N/A (no TypeScript, index.ts untouched, no edge redeploy). ✅ SQL coherence reviewed · ✅ pytest 157 unaffected.


Auto-fixed by Archon comprehensive-pr-review workflow
Fixes pushed to branch archon/task-feat-workspace-members-provisioning-trigger

@BluegReeno

Copy link
Copy Markdown
Owner Author

🎯 Workflow Summary

Plan: docs/features/workspace-members-provisioning-trigger.md — hal-owned SECURITY DEFINER trigger that auto-provisions workspace_members on user invite + one-shot Yani backfill.
Status: ✅ Implementation complete, PR ready for review. Pure SQL migration — no hal-mcp/TypeScript touched.


Implementation vs Plan

Metric Planned Actual
Files created 1 1 (…20260719000000_provision_workspace_membership.sql)
Files updated 1 1 (docs/cross-repo-log.md)
Tests added 0 (SQL migration, verified manually post-apply) 0
Functional deviations 0

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 Summary

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

Severity Found Fixed Remaining
CRITICAL 0 0 0
HIGH 0 0 0
MEDIUM 5 1 (M4) 4 (M1, M2, M3, M5)
LOW 3 2 (L1, L2) 1 (L3)

Fixed in 14f4bfc (zero-tradeoff hardening that rode this PR):

  • M4 — header comment now names apply_migration as the sanctioned tool (was contradicting CLAUDE.md), keeps the real db push footgun warning.
  • L1 — prepended DROP TRIGGER IF EXISTS … → replay-safe on manual post-merge apply.
  • L2 — backfill now computes is_default via the same NOT EXISTS guard → drift-proof.

⚖️ Decision Cluster — MEDIUM findings left open (design tradeoffs, not bugs)

The recurring question: how defensive to be against a trigger exception aborting the auth.users write (the "Never fail the signup" invariant) vs. accept-per-POC.

# Finding Recommendation Effort
M1 company_id → workspace join is unbounded — >1 workspace/company would abort the auth write (latent; de-facto 1:1 today) Document the 1:1 assumption now; enforce partial UNIQUE (company_id) as P2 follow-up LOW / MED
M2 Unguarded ::uuid cast can raise 22P02 inside the trigger and abort signup Split verdict: Accept (fail-loud, controlled service) or add UUID regex to WHEN clause NONE / LOW
M3 "No matching workspace" is a fully silent no-op — the original bug can reappear undetected Add GET DIAGNOSTICS … + RAISE WARNING on zero rows (loud-but-non-fatal) LOW
M5 Coverage rests on an undefined, single-path manual check Adopt the 6-scenario verification checklist before/at prod apply LOW

M2/M3 both trade against the "no silent workarounds / fail loudly" rule — worth an explicit call before applying to prod.


📝 Documentation Gap (deferred — outside this PR's changed files)

File Update
hal/CLAUDE.md:30-34,67 Still presents workspace_members provisioning as manual-only. Add: membership auto-provisioned by the trigger on invite; reframe the backfill as the historical one-off. (L3, P3)

📋 Suggested Follow-Up Issues

Title Priority
Enforce 1:1 company_id→workspace (partial UNIQUE on halcrm_workspaces) P2 (M1)
Add RAISE WARNING when provisioning trigger inserts zero rows P2 (M3)
Write a scripted verification checklist for the auth.users provisioning trigger P3 (M5)
Update hal/CLAUDE.md access-model to document the auto-provisioning trigger P3 (L3)

⏭️ Reminder before merge / apply

  • Do NOT supabase db push from this repo (partial, apply-time-stamped ledger). Apply via apply_migration (Supabase MCP) or dashboard as renaud@bluegreen.ai after merge.
  • Run the M5 verification checklist at apply time — the two branches most likely to be missed by a happy-path click-through are UPDATE-after-INSERT firing (the real auth-gateway path) and the is_default guard.
ℹ️ Deferred by design (NOT Building — intentionally excluded)
  • No hal-mcp code change (index.ts already reads workspace_members; no redeploy).
  • No edifice_profiles.organization_id repair (edifice-owned, separate migration).
  • No Steeve backfill (already has a membership row).
  • No role mapping beyond member (owner/subcontractor stay manual).
  • No multi-workspace auto-provisioning (only the first membership is auto-created).

Artifacts: artifacts/runs/3cf553cda682155d223093cb7d4a9dfe/ · Workflow 3cf553cda682155d223093cb7d4a9dfe

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>
@BluegReeno
BluegReeno merged commit d1a640e into main Jul 19, 2026
2 checks passed
@BluegReeno
BluegReeno deleted the archon/task-feat-workspace-members-provisioning-trigger branch July 19, 2026 10:02
BluegReeno added a commit that referenced this pull request Jul 19, 2026
)

Migration committed, prod apply pending (backlog). M3 folded pre-merge;
M1/M2/M5/L3 → follow-ups #61-#64.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BluegReeno added a commit that referenced this pull request Jul 24, 2026
* 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>
BluegReeno added a commit that referenced this pull request Jul 24, 2026
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>
BluegReeno added a commit that referenced this pull request Jul 24, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant