The stakeholder.* family (8 verbs). Every verb enters through the one front door: it is capability-checked, permission-checked, scope-checked and audited before it runs (see Security & permissions). Each entry below lists its capability, risk class (which fixes the minimum autonomy level), the minimum caller permission, its availability tier, whether it is idempotent, and any outbound egress.
Upsertstakeholder.companyLink.upsert
| Property | Value |
|---|---|
| Capability | stakeholder.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | On demand (MCP tier 2) |
| Idempotent | Yes |
| Egress | None - in-box (pure Postgres, no outbound call) |
{ stakeholderId, companyTableId, companyRowId, companyName, role, status? }. Idempotency key (stakeholder_id, company_table_id, company_row_id):
ON CONFLICT DO UPDATE role + status + company_name. The stakeholder is fenced within the workspace.
role/status validated against their enums. NB: stakeholder_company_links has NO updated_at column.
Call POST /v/stakeholder.companyLink.upsert with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool stakeholder.companyLink.upsert.
Setstakeholder.favourite.set
| Property | Value |
|---|---|
| Capability | stakeholder.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - in-box (pure Postgres, no outbound call) |
Toggle the acting user’s favourite of a CSP entity/individual.
{ entityType, entityId }. Returns { entityType, entityId, favorited }.
Call POST /v/stakeholder.favourite.set with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool stakeholder.favourite.set.
Setstakeholder.follow.set
| Property | Value |
|---|---|
| Capability | stakeholder.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - in-box (pure Postgres, no outbound call) |
Toggle the acting user’s follow of a CSP entity/individual.
{ entityType, entityId }. Returns { entityType, entityId, following }.
Call POST /v/stakeholder.follow.set with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool stakeholder.follow.set.
Createstakeholder.source.create
| Property | Value |
|---|---|
| Capability | stakeholder.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - in-box (pure Postgres, no outbound call) |
{ tableId, emailColumnId, nameColumnId? }. The table +
columns are fenced within the workspace; the (workspace_id, table_id) UNIQUE maps 23505 -> Conflict
(the route’s 409). enabled defaults true.
Call POST /v/stakeholder.source.create with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool stakeholder.source.create.
Deletestakeholder.source.delete
| Property | Value |
|---|---|
| Capability | stakeholder.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - in-box (pure Postgres, no outbound call) |
{ id }. DELETE by id + workspace fence; rows_affected==0
-> BadInput (the route’s 404).
Call POST /v/stakeholder.source.delete with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool stakeholder.source.delete.
Disable Allstakeholder.source.disableAll
| Property | Value |
|---|---|
| Capability | stakeholder.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Admin |
| Availability | Admin only (CLI/HTTP; never advertised over MCP) |
| Idempotent | Yes |
| Egress | None - in-box (pure Postgres, no outbound call) |
(system). No input beyond ctx. Disable every enabled source in the workspace (the CSP-uninstall cleanup). Idempotent: rows_affected==0 -> changed
.Call POST /v/stakeholder.source.disableAll with a JSON body; returns JSON (or { error, code }). Not advertised over MCP (admin-only); reachable via the CLI and HTTP doors.
Updatestakeholder.source.update
| Property | Value |
|---|---|
| Capability | stakeholder.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - in-box (pure Postgres, no outbound call) |
{ id, emailColumnId?, nameColumnId?|null, enabled?, lastResolvedAt? }. A partial PATCH by id + workspace fence; rows_affected==0 -> BadInput not-found (the
route’s 404). nameColumnId discriminates absent (leave) vs null (un-set) vs string (set). lastResolvedAt
is the background-resolver touch leg (ISO timestamp).
Call POST /v/stakeholder.source.update with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool stakeholder.source.update.
Upsertstakeholder.upsert
| Property | Value |
|---|---|
| Capability | stakeholder.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | On demand (MCP tier 2) |
| Idempotent | Yes |
| Egress | None - in-box (pure Postgres, no outbound call) |
The find-then-create/update + P2002 race dance collapsed into one
INSERT … ON CONFLICT (workspace_id, email) DO UPDATE. Email is normalised lower+trim. The CONDITIONAL
MERGE preserves the invariants of the old code: only overwrite the enrichment fields (formatted_address /
recipient_notice*) when the NEW value is non-empty (never clobber with a blank), and set company_row_id
ONLY when it is currently NULL (never flip a non-null primary-company pointer); identity fields
(first/last name, dob, nationality) are set on INSERT only, never updated. created_by_id is bound from
the input (a service-context actor, e.g. matter.createdById), NOT the acting user.
{ email, firstName, lastName, dateOfBirth?, nationality?, formattedAddress?, recipientNoticeName?, recipientNoticeEmail?, formattedRecipientNoticeAddress?, companyRowId?, createdById }.
Call POST /v/stakeholder.upsert with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool stakeholder.upsert.