Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,11 +342,11 @@ Keep the tree this thin until something needs otherwise.

What exists: the local Supabase stack, the three clients in `src/lib/supabase/`, generated types, and the environment wiring. An earlier SQLite (`better-sqlite3`) setup was removed, and a Drizzle/Postgres one was scaffolded and stripped back out, before this.

**There are seven migrations.** The first creates the `bluehex_admin` role, the `public.admins` list and the `custom_access_token_hook` that stamps the role onto an access token — the thing every later policy and grant refers to, and it holds no product data. The second is the profile core: `practitioner_contacts`, `practitioners` and `practitioner_review_notes`, their column-scoped grants, their policies, `practitioners_guard`, and the `approve_practitioner()` / `reject_practitioner()` RPCs. The third is the two catalogues (#89): `credential_catalogue` and `service_catalogue`, both Bluehex-owned reference data, whose read grants go to `anon` and `authenticated` by named column and whose write grants go to `bluehex_admin` alone — that omission is the whole of "a practitioner cannot invent a credential" and of "a custom service never becomes a filter chip". `service_catalogue` seeds its six labels from `src/lib/practitioners.ts`; `credential_catalogue` ships empty, because a wrong credential label is permanent in migration history while an empty catalogue is fixed by an `insert`, which is that table's sanctioned correction path anyway. The real list now exists and is not in that migration: the 24 confirmed Claude credentials are the canonical record in `supabase/seed/credential-catalogue.json` and load through `supabase/seed.sql`, which runs on any stack somebody boots — `pnpm db:reset` locally, `supabase start` in the `Schema` workflow — and never against the hosted project. Where they are permanently housed is still open, which is the whole reason they are somewhere reversible rather than in migration history; `tests/db/credential-catalogue-seed.test.ts` stops the JSON and the loader drifting apart. The fourth splits that table's `source` into `kind` and `platform` and adds `course_url` (#103) — two axes that vary independently, which is why the replacement constraint is `unique (kind, platform, label)` on all three. The fifth is `practitioner_credentials` (#50): the table the badge attests to, its column-scoped grants, its three policies, `credentials_guard`, `catalogue_guard` with `correct_catalogue_entry()`, `clear_profile_verification()` and its three triggers, and `set_credential_verified()`. The sixth is `practitioner_services` (#90): what a practitioner offers, one row per service, with its column-scoped grants, its three policies, `practitioner_services_cap` and `set_updated_at`. It is the one child table with **no guard**, because it carries no attested column — nothing there is Bluehex's assertion, so there is no `OLD` to pin and no badge to clear, and editing what you offer never touches verification. The seventh is `practitioners.handle` (#119): the public identifier as a `not null unique` column, generated by `new_profile_handle()` as a column default — eight characters of Crockford base32, packed by hand because Postgres `encode()` has no `base32` — with a format check, a `select` grant to `anon` and `authenticated`, and `practitioners_guard` replaced to pin it. It replaced `id.slice(0, 6)`, which was computed in TypeScript, enforced by nothing, and resolved with `.find()`, so a collision served the wrong practitioner's profile with their badge on it rather than erroring. `/p/<handle>` is the whole URL now; the slug and its canonical redirect are gone.
**Every migration gets an entry here, including one that only adds a function.** The first creates the `bluehex_admin` role, the `public.admins` list and the `custom_access_token_hook` that stamps the role onto an access token — the thing every later policy and grant refers to, and it holds no product data. The second is the profile core: `practitioner_contacts`, `practitioners` and `practitioner_review_notes`, their column-scoped grants, their policies, `practitioners_guard`, and the `approve_practitioner()` / `reject_practitioner()` RPCs. The third is the two catalogues (#89): `credential_catalogue` and `service_catalogue`, both Bluehex-owned reference data, whose read grants go to `anon` and `authenticated` by named column and whose write grants go to `bluehex_admin` alone — that omission is the whole of "a practitioner cannot invent a credential" and of "a custom service never becomes a filter chip". `service_catalogue` seeds its six labels from `src/lib/practitioners.ts`; `credential_catalogue` ships empty, because a wrong credential label is permanent in migration history while an empty catalogue is fixed by an `insert`, which is that table's sanctioned correction path anyway. The real list now exists and is not in that migration: the 24 confirmed Claude credentials are the canonical record in `supabase/seed/credential-catalogue.json` and load through `supabase/seed.sql`, which runs on any stack somebody boots — `pnpm db:reset` locally, `supabase start` in the `Schema` workflow — and never against the hosted project. Where they are permanently housed is still open, which is the whole reason they are somewhere reversible rather than in migration history; `tests/db/credential-catalogue-seed.test.ts` stops the JSON and the loader drifting apart. The fourth splits that table's `source` into `kind` and `platform` and adds `course_url` (#103) — two axes that vary independently, which is why the replacement constraint is `unique (kind, platform, label)` on all three. The fifth is `practitioner_credentials` (#50): the table the badge attests to, its column-scoped grants, its three policies, `credentials_guard`, `catalogue_guard` with `correct_catalogue_entry()`, `clear_profile_verification()` and its three triggers, and `set_credential_verified()`. The sixth is `practitioner_services` (#90): what a practitioner offers, one row per service, with its column-scoped grants, its three policies, `practitioner_services_cap` and `set_updated_at`. It is the one child table with **no guard**, because it carries no attested column — nothing there is Bluehex's assertion, so there is no `OLD` to pin and no badge to clear, and editing what you offer never touches verification. The seventh is `practitioners.handle` (#119): the public identifier as a `not null unique` column, generated by `new_profile_handle()` as a column default — eight characters of Crockford base32, packed by hand because Postgres `encode()` has no `base32` — with a format check, a `select` grant to `anon` and `authenticated`, and `practitioners_guard` replaced to pin it. It replaced `id.slice(0, 6)`, which was computed in TypeScript, enforced by nothing, and resolved with `.find()`, so a collision served the wrong practitioner's profile with their badge on it rather than erroring. `/p/<handle>` is the whole URL now; the slug and its canonical redirect are gone. The eighth is the owner's own reads (#14): `my_profile()` and `my_credentials()`, both `security definer`, both returning only the caller's rows, and neither adding a column grant — the arguments that withheld those columns are right and are not reopened by a form that needs them. `practitioners` needs a function because `practitioners_read_own` sits in a disjunction with `practitioners_read_approved`, so a bare select returns every approved profile plus your own, and narrowing that from the client means filtering on `user_id`, which Postgres checks as a column privilege before any policy is consulted. `practitioner_credentials` needs one because the raw `evidence_url` is withheld from `authenticated` and the owner still has to read theirs back to populate an edit form. Neither takes an argument: the answer is a function of who is asking, and an argument would be the obvious way to turn either into a disclosure. The ninth is `account_emails(uuid[])` (#123): the admin-only lookup that resolves an account id to the address behind it, for the two places on the review queue that hold a uuid and cannot say whose it is. `security definer` like the reads above, and it departs from them in the one way that decides its shape — it takes an argument, because an admin is asking about somebody other than themselves, so the predicate cannot be the control and the execute grant is, going to `bluehex_admin` alone and never to `authenticated`, which is one role holding every signed-in practitioner. It returns `(id, email)` rather than a column of addresses, so a caller reads the answer by id rather than by position, and an account with no address returns no row rather than a third case for every caller to hold open. The tenth is `apply_profile_children()` (#128): the first RPC a practitioner writes through rather than an admin, applying a save's whole effect on `practitioner_credentials` and `practitioner_services` in one transaction, because separate PostgREST requests are separate transactions and a save that failed part way through had already committed the deletes that ran before it. It takes the rows to remove, update and insert rather than the state the practitioner wants, since "absent from the payload" cannot separate a row the practitioner removed from one the editor was unable to draw, and that ambiguity is made on the client where the function cannot see it. It is `security invoker`, which is load-bearing rather than a default left alone: `credentials_guard` reads `current_user` against an allow-list holding `postgres`, and a definer function a migration created is owned by `postgres`, so the pin holding `verified` to `OLD` would stop applying inside the one door a practitioner's own save writes through. And it takes no practitioner id, reading the profile from `my_profile()` for the reason the eighth gives.

**The first query landed in #53**, and it is the public one: the home page reads approved profiles with their credentials and services, `/p/<handle>` reads one of them against the whole credential catalogue, and `/contact?about=` resolves a name. All of it goes through `src/lib/directory.ts` and the anonymous client, so every row that comes back is a row row level security decided a visitor may see — nothing filters on `status`, because `anon` cannot read it and the policy is the filter. Before that a health-check table existed briefly to prove the connection and was taken back out before it was ever committed, because it would have sat in the migration history permanently, describing a table dropped a fortnight later, to prove something the first real query proves for free.

What does not exist yet: `withdraw_profile()` and the erasure path (#52). `credential_catalogue.updated_at` **is** maintained on update as of #50 — `catalogue_guard` is what bumps it, its body reads `practitioner_credentials`, and a plpgsql body resolves its names at call time, so it could not be written before that table existed. **Auth landed with #83**: magic link only, so there is no password and no password reset; `@supabase/ssr` carries the session in cookies; `src/proxy.ts` refreshes it; and the `bluehex_admin` claim is read by the application. What that unblocks is that every policy in the spec is written against `auth.uid()`, and `auth.uid()` now resolves for a request the app makes on a signed-in person's behalf. Self-service writes through one as of #14, in two halves: the review queue at `/admin` (#122) and the profile editor at `/profile`, which reads the practitioner's own rows through `my_profile()` and `my_credentials()` and saves them with a Server Action. What is left of that ticket is the claim path for a curated profile, which is still an admin pasting an account id. The rest of this section is the contract for building those — treat it as binding, not as a description of current state.
What does not exist yet: `withdraw_profile()` and the erasure path (#52). `credential_catalogue.updated_at` **is** maintained on update as of #50 — `catalogue_guard` is what bumps it, its body reads `practitioner_credentials`, and a plpgsql body resolves its names at call time, so it could not be written before that table existed. **Auth landed with #83**: magic link only, so there is no password and no password reset; `@supabase/ssr` carries the session in cookies; `src/proxy.ts` refreshes it; and the `bluehex_admin` claim is read by the application. What that unblocks is that every policy in the spec is written against `auth.uid()`, and `auth.uid()` now resolves for a request the app makes on a signed-in person's behalf. Self-service writes through one as of #14, in two halves: the review queue at `/admin` (#122) and the profile editor at `/profile`, which reads the practitioner's own rows through `my_profile()` and `my_credentials()` and saves them with a Server Action, whose whole effect on the two child tables travels through `apply_profile_children()` in one transaction (#128). What is left of that ticket is the claim path for a curated profile, which is still an admin pasting an account id. The rest of this section is the contract for building those — treat it as binding, not as a description of current state.

- **Target is [Supabase](https://supabase.com)** — Postgres, plus the auth that comes
with it. Local development runs the Supabase CLI stack; deployed is a hosted Supabase
Expand Down