Skip to content

Save a profile's credentials and services in one transaction - #147

Merged
davidtaing merged 2 commits into
mainfrom
fix/atomic-profile-children
Sep 1, 2026
Merged

Save a profile's credentials and services in one transaction#147
davidtaing merged 2 commits into
mainfrom
fix/atomic-profile-children

Conversation

@toruiwasa

@toruiwasa toruiwasa commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds apply_profile_children() and points saveChildren at it, so a save's whole effect on practitioner_credentials and practitioner_services travels as one request and therefore one transaction.

apply_profile_children(
  credential_removals uuid[],
  credential_updates jsonb,
  credential_inserts jsonb,
  service_removals uuid[],
  service_inserts uuid[]
) returns void

Why it has that shape is in the migration's own comments, since it is the shape a later change could quietly break. What follows is what the diff does not say.

Related issue

Closes #128.

What I decided without asking

  • Every statement is bounded by profile_id, not only the inserts. A practitioner would never notice: row level security has already narrowed their removals and updates to rows they own. An admin would, because credentials_admin_all and services_admin_all are using (true), so without the condition an admin's removals and updates find another profile's rows. It closes no privilege and is not meant to: bluehex_admin holds update and delete on both tables outright and reaches the same rows with one PATCH. What it buys is that the practitioner's own save is not a door a mistyped id can travel through, and that the bound survives a later migration widening a policy by accident. I read the condition as authorization at first, which is the thing this repository keeps out of functions; it is scope, and the inserts were already written that way.
  • A plan is not idempotent. Pressing Save twice in sequence is safe, because the second press plans against the rows the first one wrote. One plan sent from two tabs at once is not: the loser is refused 23505 and told to remove a duplicate that is not in their form. The rows are right and only the sentence is wrong, because what happened is a stale read, and detecting that is A stale tab should not silently delete what another one saved #129's token rather than something this function should guess at.
  • The profile and its contact row stay outside the transaction. Folding them in is possible now rather than impossible, but it would move the profile's own update inside a function, which is where A stale tab should not silently delete what another one saved #129's token would then have to live.
  • docs/spec/profile-and-credentials.md gains one paragraph, on the sentence enumerating the RPCs that remain. This is the first write RPC on the practitioner's side of that line and the sentence counts them.
  • refusal() named a constraint that does not exist. Its example read practitioner_credentials_practitioner_id_catalogue_id_key; the constraint is practitioner_credentials_one_claim_each. Corrected in place, since the new tests assert against that message.

The two departures from the brief are the ones I raised on the issue before starting and you approved there: it takes the plan rather than the desired state, and it is security invoker. Neither moved between that comment and this branch, and the arguments for both are in the migration header.

Verification

check result
pnpm lint 0
pnpm test 0, 305 tests
pnpm build 0
pnpm db:reset migrations and seed applied
pnpm test:db 0, 251 tests
pnpm test:e2e 0, 24 tests, with .env.local moved aside

tests/db/profile-children.test.ts is twenty assertions. The one the file exists for removes a verified credential and adds two services to a profile already holding two, then asserts the credential and its badge are still standing after the cap refuses. Every statement is asked from a second practitioner as well as from the owner, and from an admin too. The admin cases are the ones that earn their place: row level security narrows nothing for bluehex_admin, so the three naming another profile's rows are the only assertions here that fail if the profile_id bound is dropped, one per bounded statement.

pnpm test:e2e needs .env.local moved aside, and I do not think that belongs to this branch. With the file in place, an empty directory renders the invitation rather than nothing fails on both projects: the test's own docstring says it assumes a build with no Supabase configured, and .env.local gives the build the local stack, whose seed holds eight practitioners. Measured both ways here, 22 of 24 against 24 of 24, the same two tests either way. Worth its own issue, which I have not opened in case it is already known.

One thing I have left alone

AGENTS.md introduces its migration list with There are seven migrations. It was two behind before this branch and is three behind after it. That paragraph is about 1,200 words on a single line, so any edit to it arrives as one enormous changed line and would be unreadable beside a migration. I would rather send it on its own than bury it here.

Checklist

  • Opened as a draft pull request
  • pnpm lint passed
  • pnpm test:e2e passed
  • Commit messages have no Co-Authored-By, Generated with, or tool-attribution trailers
  • Linked the closing issue

@toruiwasa
toruiwasa force-pushed the fix/atomic-profile-children branch from e9465fd to b277aa2 Compare August 31, 2026 05:53
@toruiwasa
toruiwasa marked this pull request as ready for review August 31, 2026 05:56
`saveChildren` reconciled `practitioner_credentials` and `practitioner_services` over separate PostgREST requests, and separate requests are separate transactions, so a save that failed part way through had already committed everything before the failure. The reachable case needs no crafted payload. Deletes have to run before inserts, because `practitioner_services_cap` counts rows and `practitioner_credentials_one_claim_each` refuses a credential moved from one row to another — so swapping a service on a profile that already holds three means the credential deletes have committed by the time the cap raises. The practitioner presses Save and comes back holding fewer credentials than they started with, on the table the Verified badge attests to. #125 made the message honest about that; this makes the message unnecessary, and `ChildOutcome.applied` goes with it.

`apply_profile_children()` takes the plan and applies it, and PostgREST runs one request in one transaction, so the child rows either all change or none do.

**It takes the rows to remove, update and insert rather than the state the practitioner wants**, which departs from the issue and is deliberate. "Absent from the payload" has two meanings the function cannot tell apart: the practitioner removed the row, or the editor could not draw it. `planServices` only ever deletes a row this form could have shown, because a catalogue label an admin has renamed out from under the closed vocabulary in `src/lib/practitioners.ts` is dropped from the draft on the way in, and reading that absence as a removal would delete it from every profile offering it, one save at a time. The ambiguity is made on the client, so re-reading the stored rows inside the function cannot resolve it.

**It is `security invoker`**, which is the second departure and is load-bearing rather than a default left alone. `credentials_guard` decides whether a caller may move `verified` by reading `current_user` against an allow-list that includes `postgres`, and a function a migration creates is owned by `postgres` — so inside a `security definer` one, and inside every trigger its DML fires, the caller counts as privileged and the pin holding `verified`, `verified_at` and `verified_by` to `OLD` stops applying. The column grants are that invariant's other half and a definer function bypasses those too. Nothing here needs the escalation: every column it writes is already in the `authenticated` grants, and it reads none of the withheld ones. The function the issue names as the shape to follow, `set_credential_verified()`, is `security invoker` for the same kind of reason.

Both concerns the issue raises are answered rather than dropped. `verified` round-trips exactly as `credentials_guard` decides, because the rule is not restated anywhere. And the predicate is not the only control, because there is no predicate: the column grants, `credentials_rw_own` and `services_rw_own`, and both triggers all go on deciding these writes.

**Every statement is bounded by `profile_id` as well, and that is scope rather than authorization.** The inserts carry it because a new row has to belong to somebody; the removals and the updates test it because this function applies a plan to one profile, and without the condition they would apply it to whatever rows the caller happens to be able to reach. A practitioner would never notice, since row level security has already narrowed the statement to rows they own. An admin would: `credentials_admin_all` and `services_admin_all` are `using (true)`, so an admin's removals and updates would otherwise find another profile's rows, and the update is the one worth naming because it leaves the row where it was. It rewrites the claim, `credentials_guard` clears the check on it for every caller including an admin, and `evidence_public` moves without being on that trigger's clearing list at all, so a mistyped id would cost somebody their badge and could publish the certificate link carrying their full legal name.

It closes no privilege and is not pretending to. `bluehex_admin` holds `update` and `delete` on both tables outright and reaches the same rows with one request that never comes near here. What the condition buys is that the practitioner's own save cannot be the door a mistyped id travels through, and that the bound still holds if a later migration widens a policy by accident, which is the argument `credentials_guard` rests on one table over.

The function takes no practitioner id either, reading the profile from `my_profile()` for the reason `20260822050002_profile_own_reads.sql` gives for its own two functions: there is nothing to pass, and an argument would be the obvious way to turn a write over your own rows into a write over somebody else's.

`jsonb` for the two piles that carry columns, rather than an array of a composite type. Nothing in this repository passes a composite through PostgREST, and `jsonb_to_recordset` names the columns and their types at the point of use. The record definition expands `evidence_url` as `public.https_url`, so a link that is not `https://` is refused by the domain as the record is expanded, exactly as the column would have refused it.

The service insert is last, and that ordering is worth keeping. `practitioner_services_cap` takes `pg_advisory_xact_lock` keyed on the profile, and an `xact` lock is held until commit rather than released when the statement ends, so the only statement that fires it being the final one is the shortest hold this function can have. Moving it above the credential work would make every concurrent save of one profile wait on the whole of the transaction ahead of it instead of on one insert, and that arrives as slowness with nothing anywhere to report it.

A removal that matches no row raises nothing, and a plan is not idempotent. Both come back to the same place. Pressing Save twice in sequence is safe, because the piles are worked out against a read taken inside `saveChildren`, so the second press plans against the rows the first one wrote and asks for nothing. What is not safe is one plan sent from two tabs at once, where the loser is refused `23505` and told to remove a duplicate that is not in their form. The rows are right and the sentence is wrong, because what actually happened is a stale read, and detecting that is #129's token rather than this function's to guess at.

`tests/db/profile-children.test.ts` is twenty assertions. The one the file exists for removes a verified credential and adds two services to a profile that already holds two, then asserts the credential and its badge are still standing after the cap refuses; the unique violation test asserts the same rollback on a second sqlstate, because a test that names one constraint holds only until the next constraint arrives. Four are about the badge rather than the rows, because that is what a definer version would have broken silently: the pin holds on a credential the plan does not name, `updated_at` on that row included; the clearing rule still fires when the plan edits the claim; an unchanged row rewritten with the same values keeps its check; and an attestation put in the payload by hand is dropped before Postgres sees it. Every statement is written from the second practitioner as well as from the owner, and from an admin too. The admin cases are the ones that earn their place: row level security narrows nothing for `bluehex_admin`, so the three that name another profile's rows are the only assertions in the file that fail if the `profile_id` bound is dropped, one per bounded statement, and a file that only ever asked the practitioner would state the practitioner's answer as the function's.

`refusal()` named a constraint that does not exist. Its example read `practitioner_credentials_practitioner_id_catalogue_id_key`, and the constraint is `practitioner_credentials_one_claim_each`. Corrected in place, since the new tests assert against that message.

`docs/spec/profile-and-credentials.md` gains a short amendment on the paragraph enumerating the RPCs that remain, which counts them and which this is the first of on the practitioner's side of the line.
@toruiwasa
toruiwasa force-pushed the fix/atomic-profile-children branch from b277aa2 to 4e9b0a0 Compare August 31, 2026 06:12

@davidtaing davidtaing left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review — review pass by Codex (GPT-5.4), verified by Claude Code (Opus 5). Findings were checked against the source before posting.

The change holds. Atomicity is real, security invoker is the right call and load-bearing — credentials_guard has postgres on its allow-list, so a definer version would unpin verified — and the plan-rather-than-state signature is what keeps a row the editor never drew out of reach. The profile_id bound reads as scope rather than authorization, and the three admin cases are what earn it. Nothing here opens a path to an attestation, and the refusals a save has to explain still carry the words refusal() branches on.

1 minor, on the tests rather than on the code.

Next: add the one-line message assertion to refuses anon, so the migration's own revoke has something standing behind it. Nothing else is blocking.

Comment thread tests/db/profile-children.test.ts Outdated
One finding from the review on #147, verified before being acted on.

`refuses anon` passed whether or not this migration's own `revoke` was there. `apply_profile_children()` is `security invoker` and its first statement calls `my_profile()`, whose `execute` was revoked from `public, anon` two migrations earlier. So a version of this file with the revoke at its foot deleted, or a later `create or replace` handing `EXECUTE` back to `PUBLIC` the way every new function does by default, is refused one level further in with the same `42501` and therefore the same 401. `expectPermissionDenied` compares the code and the status, so it cannot tell the two apart.

Confirmed against the local stack by granting `execute` back to `anon`: `expectPermissionDenied` still passed and only the new line failed, on `permission denied for function my_profile`. The function named in the message is the only thing that separates the two.

This is the one grant in the migration that nothing else in the file stands behind. Every other caller here holds `authenticated`, so the revoke has no second assertion covering it, and the same nesting would hide a `PUBLIC` grant from `service_role`, which is the role this repository has gone out of its way never to depend on.
@davidtaing
davidtaing merged commit c22d329 into main Sep 1, 2026
2 checks passed
@davidtaing
davidtaing deleted the fix/atomic-profile-children branch September 1, 2026 21:51
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.

Reconcile a profile's children in one transaction

2 participants