List personas (v2)
The shared list envelope — data, total, total_unfiltered, keys — with real filtering, sorting and pagination, which /v1/personas has none of. Filterable fields are id, created_at, created_by, modified_at, modified_by, active, metadata, first_name, last_name, date_of_birth, date_of_death, nationality and hash_id. Any other search.<field> is dropped by the shared parser rather than rejected, so the response comes back UNFILTERED instead of empty. search.linked_to.record_id and search.linked_to.record_type are the exception: they are not columns and would be dropped like any unknown key, so the handler lifts them out of the raw query string and puts them back into the search map by hand before the service runs. The .eq suffix form is accepted for both.
Authorization
bearerAuth In: header
Query Parameters
Default 10, over 100 clamps to 100, non-positive becomes 10. -1 is the platform's "totals only" sentinel and survives the query parser, but this route ignores it: the data query runs GetLimit, which maps every non-positive value to 10, so limit=-1 returns a normal page of 10.
Default 0. A negative offset is normalised to 0.
Comma-separated sort fields. Prefix with '-' for descending, e.g. -created_at
Optional stack field, e.g. active or created_at:month
JSON-encoded search object
Filter by persona ID
Filter by created_at (date-time)
Filter by created_by
Filter by modified_at (date-time)
Filter by modified_by
Filter by active flag
Search in metadata JSON as text
Filter by first name
Filter by last name
Filter by date of birth
Filter by date of death
Filter by nationality
Filter by hash_id
Filter personas linked to a specific record ID
Filter personas linked to a specific record type (e.g. users, customers)
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/v2/personas"{
"data": [
{
"first_name": "string",
"last_name": "string",
"date_of_birth": "2019-08-24",
"date_of_death": "2019-08-24",
"nationality": "string",
"hash_id": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"active": true,
"metadata": {},
"created_at": "2019-08-24T14:15:22Z",
"created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
"modified_at": "2019-08-24T14:15:22Z",
"modified_by": "e8d4374d-93a1-4e98-a6c6-fdcf00c5059f",
"linked_to": [
{
"record_type": "string",
"record_id": "8bf519b6-a3e0-49d2-8e42-039542d9a489"
}
]
}
],
"total": 0,
"total_unfiltered": 0,
"keys": [
"string"
],
"has_more": true
}{
"status": 400,
"message": "Invalid input",
"code": "common.invalid_input",
"class": "validation"
}{
"status": 401,
"message": "Unauthorized",
"code": "common.unauthorized",
"class": "business"
}{
"status": 403,
"message": "No access to the record",
"code": "common.rbac_no_rec_access",
"class": "business"
}{
"status": 429,
"message": "Rate limit exceeded",
"code": "rate_limits_m.exceeded",
"class": "temporary",
"retryable": true
}{
"status": 500,
"message": "Database error",
"code": "common.database_error",
"class": "business"
}{
"overall_status": "unhealthy",
"message": "Service is shutting down",
"timestamp": "2026-08-27T15:04:05Z"
}Updates the persona linked to a user. Carries every caveat of PUT /v1/personas/{persona_id} — no validation, and Updates(struct) skips zero values so active: false and empty strings do nothing — plus two of its own. THE LINK IS RESOLVED FROM THE CALLER'S OWN RECORD GRANTS, NOT FROM THE PATH USER'S LINKS. The handler calls links.FetchLinksByType with the AUTHENTICATED CALLER's id and canReadAll hardcoded to false, so it sees only links touching a user record the caller — or one of the caller's roles — holds a row for in rbac.record_permissions. rbac.RecPermission, which guards the route, returns true for an Administrator without consulting that table at all, so an Administrator passes the permission check and then finds no links: the caller must hold an explicit record grant on the user in the path for this route to resolve anything. IT ONLY WORKS WHEN EXACTLY ONE LINK TOUCHES THAT USER. The ids collected from those links are bound to "id = ?", which GORM expands as a parenthesised list: one id gives id = ($1) and updates that row; none gives id = (NULL), which matches nothing; two or more gives id = ($1,$2), which PostgreSQL rejects as a comparison against a row constructor. Every failure lands in the same branch and answers 404 having written nothing. The collected ids are not filtered by record type either — the customer-to-user and signatory-to-user links other modules create count toward the same total, and a single one of them can be handed to First() in place of the persona, which then finds no persona row and also answers 404.
Same persona payload as v1 plus linked_to, the list of records this persona is linked to as {record_type, record_id} pairs. Unlike the v1 equivalents, the refusals here come from the v2 service and use the shared codes: common.forbidden for 403 and common.record_not_found for 404, rather than v1's common.rbac_no_rec_access and bare NotFound404.