Full context — the reasoning, the constraints and what has already been ruled out. This is the part a coding agent should read.
Bluehex owns the vocabulary the directory filters on, and nothing lets Bluehex edit it. This is that surface.
Binding design: docs/spec/profile-and-credentials.md § "Superseded: the closed set is stable keys, and a practitioner may add their own", and CONTEXT.md § "What a practitioner offers". Decided in #76.
What this is not
It is not a moderation queue, and building it as one would undo the model it serves. A practitioner writing a custom service is not proposing an addition to the catalogue — they are describing their own business on their own profile, and it renders immediately. Nothing is submitted, so nothing is pending, nothing is approved and nothing is ever rejected. CONTEXT.md puts it directly: do not call a custom service a "proposal", because treating it as a request implies a queue that does not exist.
Promotion is Bluehex reading evidence, not Bluehex answering requests. Same outcome for the practitioner, none of the machinery, and no implied promise about response time. If this ticket grows states, notifications or a rejection path, that is the signal it has drifted.
Scope
Catalogue CRUD over service_catalogue — add an entry, correct a label, retire one with active = false, set sort_order. Retire rather than delete: the FK from practitioner_services is on delete restrict precisely so a catalogue row cannot be removed out from under somebody's profile. A retired entry still renders on profiles that hold it and drops out of the picker.
A promotion report. Custom services are the rows in practitioner_services where catalogue_id is null, ranked by how many distinct practitioners wrote something similar — distinct practitioners rather than row count, because the signal that justifies a filter chip is "several people sell this", not "one person listed it three times". This is the screen an admin opens when curious, and it is the whole reason the catalogue can learn what the market sells rather than what Bluehex guessed.
How similar is "similar" is the one open design question here. Exact match is cheap and will under-report; normalising case and collapsing whitespace is nearly as cheap and catches most of it; anything fuzzier is a rabbit hole and should be argued for before it is written. Start at normalised exact match.
A promote action, admin-only and atomic.
The trap: promotion has two modes and only one of them can re-point
Promoting to a new catalogue entry is the easy case: insert the service_catalogue row, then update public.practitioner_services set catalogue_id = <new>, label = null for the matching rows.
Merging a custom label into an existing catalogue entry is the case that breaks. If a practitioner already holds the target catalogue row and a custom label that means the same thing — which the spec explicitly permits and calls "an admin sees it during review, promotion fixes it permanently" — then re-pointing violates unique (practitioner_id, catalogue_id). The merge path has to delete the custom row rather than re-point it, for exactly those practitioners and only those.
So the promote action is: for each matching custom row, re-point it if the practitioner does not already hold the target entry, delete it if they do. Both modes in one transaction — a half-applied promotion leaves the directory with duplicate chips and no record of what went wrong.
Two smaller things that follow:
- It runs as
bluehex_admin, never the service role. See docs/adr/0001-admins-are-a-postgres-role.md. The service role bypasses RLS outright and would make every policy on these tables decorative.
updated_at moves on every re-pointed row, via practitioner_services_set_updated_at. That is correct and expected — the row now means something different — but it will look like practitioner activity in any query that reads updated_at as "when did they last edit this", so do not build one that does.
Out of scope
Depends on
service_catalogue and practitioner_services existing, with their grants, policies, the practitioner_services_cap trigger and set_updated_at. There is no issue for that yet — the services schema landed in the spec via #76 and has not been cut as a ticket. This is blocked until it is.
TL;DR
service_catalogue(retire withactive = false, never delete — the FK ison delete restrict), a report ranking custom labels by distinct practitioners, and one atomic promote action. Unblocked since the services schema landed in practitioner_services: what a practitioner offers, capped at three #90.Full context — the reasoning, the constraints and what has already been ruled out. This is the part a coding agent should read.
Bluehex owns the vocabulary the directory filters on, and nothing lets Bluehex edit it. This is that surface.
Binding design:
docs/spec/profile-and-credentials.md§ "Superseded: the closed set is stable keys, and a practitioner may add their own", andCONTEXT.md§ "What a practitioner offers". Decided in #76.What this is not
It is not a moderation queue, and building it as one would undo the model it serves. A practitioner writing a custom service is not proposing an addition to the catalogue — they are describing their own business on their own profile, and it renders immediately. Nothing is submitted, so nothing is pending, nothing is approved and nothing is ever rejected.
CONTEXT.mdputs it directly: do not call a custom service a "proposal", because treating it as a request implies a queue that does not exist.Promotion is Bluehex reading evidence, not Bluehex answering requests. Same outcome for the practitioner, none of the machinery, and no implied promise about response time. If this ticket grows states, notifications or a rejection path, that is the signal it has drifted.
Scope
Catalogue CRUD over
service_catalogue— add an entry, correct alabel, retire one withactive = false, setsort_order. Retire rather than delete: the FK frompractitioner_servicesison delete restrictprecisely so a catalogue row cannot be removed out from under somebody's profile. A retired entry still renders on profiles that hold it and drops out of the picker.A promotion report. Custom services are the rows in
practitioner_serviceswherecatalogue_id is null, ranked by how many distinct practitioners wrote something similar — distinct practitioners rather than row count, because the signal that justifies a filter chip is "several people sell this", not "one person listed it three times". This is the screen an admin opens when curious, and it is the whole reason the catalogue can learn what the market sells rather than what Bluehex guessed.How similar is "similar" is the one open design question here. Exact match is cheap and will under-report; normalising case and collapsing whitespace is nearly as cheap and catches most of it; anything fuzzier is a rabbit hole and should be argued for before it is written. Start at normalised exact match.
A promote action, admin-only and atomic.
The trap: promotion has two modes and only one of them can re-point
Promoting to a new catalogue entry is the easy case: insert the
service_cataloguerow, thenupdate public.practitioner_services set catalogue_id = <new>, label = nullfor the matching rows.Merging a custom label into an existing catalogue entry is the case that breaks. If a practitioner already holds the target catalogue row and a custom label that means the same thing — which the spec explicitly permits and calls "an admin sees it during review, promotion fixes it permanently" — then re-pointing violates
unique (practitioner_id, catalogue_id). The merge path has to delete the custom row rather than re-point it, for exactly those practitioners and only those.So the promote action is: for each matching custom row, re-point it if the practitioner does not already hold the target entry, delete it if they do. Both modes in one transaction — a half-applied promotion leaves the directory with duplicate chips and no record of what went wrong.
Two smaller things that follow:
bluehex_admin, never the service role. Seedocs/adr/0001-admins-are-a-postgres-role.md. The service role bypasses RLS outright and would make every policy on these tables decorative.updated_atmoves on every re-pointed row, viapractitioner_services_set_updated_at. That is correct and expected — the row now means something different — but it will look like practitioner activity in any query that readsupdated_atas "when did they last edit this", so do not build one that does.Out of scope
Depends on
service_catalogueandpractitioner_servicesexisting, with their grants, policies, thepractitioner_services_captrigger andset_updated_at. There is no issue for that yet — the services schema landed in the spec via #76 and has not been cut as a ticket. This is blocked until it is.