# Data models, IDs and safe updates

Use the [API reference](https://api.quake.dev/documentation) for each operation's request schema, and [OAuth and delegated sessions](https://api.quake.dev/documentation/guides/delegated-openapi-sessions) to choose an identity and scopes. All examples below use the production Core API at `https://api.quake.dev`.

## IDs and dataset boundaries

| Identifier | Meaning | How to use it |
| --- | --- | --- |
| Resource `id` | Quake's local record ID | Use in resource paths and relationships unless the schema explicitly provides a remote-ID mode |
| `dataset_id` | Tenant containing the record | Tokens resolve the dataset; a record in another dataset is not interchangeable |
| Recruitment `remote_id` | Identifier from a source system | Use supported exact-match filters, remote reference fields or the resource's upsert operation; do not put it in an `{id}` path |
| Note `external_id` | External identifier used by note import/upsert contracts | Keep a stable mapping; an external ID is not a global Quake ID or a general retry guarantee |
| Imported email-log `remote_id` | External identifier accepted by the public email-log import/upsert contract | Send `remote_id` in import rows; do not substitute note-style `external_id` |
| Dataset user `id` | Membership ID from `/core/users` (`user_datasets.id`) | Use for user owners, secondary owners, access-group assignments and fields named `user_dataset_id` |
| Dataset user `user_id` | Global account ID (`users.id`) | Account identity; not an owner-assignment ID |
| Note link `{ id, type, is_remote: true }` | Source-system record reference | Here `id` means the target's `remote_id`; the service attempts resolution before storage |

IDs have meaning in their resource and dataset. Store both the external identity and returned Quake ID in your integration; do not join unrelated tables merely because identifiers look alike. `external_id` on a profile URL is a profile identity, not the parent candidate's `remote_id`.

For example, `/core/users` returns both `id` and `user_id`. In a delegated session, `session.user.id` is the global account ID and `session.user.user_dataset_id` is the membership ID. One account can belong to several datasets with different membership IDs and permissions.

## Recruitment relationships

| Resource | Main relationships |
| --- | --- |
| Clients | Recruitment companies; linked to contacts through client/contact associations |
| Contacts | People at client companies; `clients` contains Quake client IDs |
| Jobs | Hiring roles; `client_id` references a client, and `contacts` links contacts |
| Candidates | People considered for jobs; applications connect them to jobs |
| Applications | `candidate_id` and `job_id` identify the candidate/job pair |
| Notes | One note can link to multiple records through `records`, without duplicating the note |
| Tags | `tag_assignments` links a record to tag definitions and values; it is separate from `meta.attributes` |

The job write schemas also accept `client_remote_id`, which resolves the client's `remote_id` and takes precedence over `client_id`. Use remote references only where the operation documents them. For note links, use the exact supported `type` from the note schema; synchronous and bulk request schemas have different shapes and limits.

Create or resolve referenced records before writing links. For delegated owner-backed record writes, submitted candidate/job/client/contact references must also be visible to the user. A visible parent does not automatically make its related records visible.

## Ownership and access groups

OAuth scopes allow operations; ownership can further narrow record access for delegated callers. App tokens have dataset-wide record visibility within their scopes. Delegated owner-backed lists/counts/search apply the user's ownership and access groups; an `owners` filter narrows that set and cannot grant additional visibility.

- User owners use `user_datasets.id`. Stored user owners have `type: "user"`; `user_dataset` describes the ID source, not the stored owner type.
- Candidates expose `primary_owner` and `secondary_owners`. Contact owners use an `owner` object with a primary ID and optional secondary-owner objects.
- Direct-owner records with no owner or a supported non-user owner are dataset-visible. Contacts require a dataset-user owner on creation.
- A user can see records they own, records on which they are a secondary owner, and records whose primary owner delegates access through that owner's **primary** access group. The viewer may match through their own primary or secondary group. An owner's secondary groups do not delegate visibility.
- Admins and the applicable `access_groups:bypass` permission can bypass ownership visibility. An unresolved user owner does not become dataset-visible.
- Client `owners[]` is derived and read-only: the distinct union of client creator and linked contact primary/secondary owners. Do not write the legacy client `owner` field or try to assign `owners[]` manually. Historical owners can remain in responses after membership removal; their removed memberships do not continue to delegate group visibility.

Hidden singular reads of owner-backed resources return `404`; hidden mutations or newly submitted hidden links return `403`. Nested relationships may be filtered even when the parent is visible. These rules describe owner-backed record paths; do not assume every REST resource uses the agent tools' visibility model. In particular, REST `GET /core/notes` checks `notes:read` and does not use `search_notes`'s linked-type permission and record-visibility filtering.

## Discover custom fields before writing

Read the resource's `/core/candidates/settings`, `/core/contacts/settings`, `/core/clients/settings`, `/core/jobs/settings` or `/core/placements/settings` endpoint. The response supplies dataset-specific definitions, required boundaries, option values, record targets, and `attribute_values` with read/write paths and clear semantics. Definitions are not portable across datasets.

For candidate/client/contact writes, values live in `meta.attributes`, keyed by the definition's ID. A `Record` value is a Quake record ID string. A `Multi Record` value is an array of `{ "id": "...", "entity": "..." }`; use the canonical entity permitted by the definition, such as `candidate`, not a guessed table name. Other field types must match the definition's write schema. Placement writes instead use top-level `attributes`; responses use `meta.attributes`. Custom-entity record endpoints have their own payload contract.

For example, after discovering a Text definition whose ID is `source_label`:

```json
{
  "meta": {
    "attributes": {
      "source_label": "Partner import"
    }
  }
}
```

For candidate/client/contact PATCH paths, attribute keys are merged with existing attributes. Omitted keys preserve their values; `null` clears a supplied key if the field's required validation allows it. Sending `meta.attributes: {}` does not clear all attributes. This merge is by key, not a recursive merge of arbitrary nested objects: a supplied array or nested object value replaces that value. Delegated custom-entity `Record` / `Multi Record` references also validate the target dataset, model and visibility.

## Field-specific update behavior

JSON omission, `null`, `[]` and `{}` have different meanings. A nullable **response** field does not mean the corresponding request accepts null. These are verified examples, not a universal PATCH policy:

| Operation / field | Omitted | Supplied value |
| --- | --- | --- |
| Candidate/client/contact PATCH `meta.attributes` key | Preserve the existing key | Merge keys; `null` clears an optional value; array/object values replace the key's value |
| Supported record writes `tag_assignments` | Preserve current assignments | Replace the complete assignment set; `[]` clears it; `null` is rejected |
| Synchronous note PATCH or `/notes/upsert` `records` | Preserve existing links on update | Replace the full linked-record set; `[]` clears it; `null` is rejected |
| Candidate PATCH `primary_owner` | Preserve primary owner | Dataset-user ID/email assigns; `null` clears primary and secondary ownership |
| Candidate PATCH `secondary_owners` | Preserve existing secondary owners unless primary ownership is cleared | Replace secondary owners; `[]` or `null` clears them; requires a primary owner unless the same request clears `primary_owner` |
| Contact PATCH `owner` | Preserve owner | Replace supplied owner object, including its secondary-owner set; `null` is rejected |
| Contact PATCH `occupation`, `last_email_sent`, `last_email_received` | Preserve value | `null` clears it; date values must include a timezone |
| Contact PATCH `emails`, `phones`, `addresses`, `profile_urls` | Preserve array | Replace whole array; `[]` clears it; `null` is rejected |
| Contact PATCH `clients`; client PATCH `contacts` | Preserve links | Replace links for app tokens; for delegated callers replace only the visible subset and preserve hidden existing links; `[]` clears that replaceable subset |
| Dataset user PATCH `primary_access_group_id` | Preserve assignment | A dataset group ID assigns; `null` clears |
| Dataset user PATCH `secondary_access_group_ids` | Preserve assignments | Replace all secondary assignments; `[]` clears; `null` is rejected |

Candidate scalar fields such as `email`, `remote_id`, `phone`, `description` and `location` do not accept null in the current PATCH schema. For example, do not send `email: null` just because candidate responses allow a nullable email. Do not use an empty string as a general clearing mechanism either: validation differs by field.

Notes' synchronous `records` behavior does not imply identical bulk import behavior. Bulk note upsert currently supplies an empty link set when a row omits `records`; include the intended full links when importing updates. Imported email-log upsert also maps a complete row rather than following every partial-update rule above. Review the import operation before reusing a PATCH payload in a batch.

Example: preserve a candidate's ownership, custom fields and tags while changing only their phone:

```bash
curl --fail-with-body -X PATCH \
  "https://api.quake.dev/core/candidates/$QUAKE_CANDIDATE_ID" \
  -H "Authorization: Bearer $QUAKE_ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"phone":"+441234567890"}'
```

Example: replace a note's links with exactly the candidate and job shown:

```json
{
  "records": [
    { "id": "22222222-2222-4222-8222-222222222222", "type": "candidate" },
    { "id": "33333333-3333-4333-8333-333333333333", "type": "job" }
  ]
}
```

To add one tag while preserving others, read current `tag_assignments`, build the desired complete set, and submit only writable fields (`tag_definition_id`, `tag_value_id`, optional `source`). Do not send read-only assignment IDs or actor metadata from the response. A concurrent writer can still replace that set: read/modify/write is not a promise of conflict detection or an atomic append.

## Safe integration sequence

1. Choose app or delegated authentication and request scopes for reads as well as writes you need.
2. Resolve Quake IDs in the token's dataset. Fetch settings and validate custom fields locally against the returned shapes.
3. Send only fields you intend to change. For replacement arrays, submit the complete desired set using the rules above.
4. Inspect the response and read the resource back when confirming important relationships or assignments.
5. If a network failure leaves a mutation outcome uncertain, verify state before repeating it. An upsert's matching contract is resource-specific; a field named `remote_id` is not itself an idempotency guarantee.

Use the [Call logs guide](https://api.quake.dev/documentation/guides/call-logs.md) for its separate nullable fields and nonempty replacement-link contract. Consult the current operation schema for unsupported fields, limits, error shapes and updates not listed here. No global API compatibility or retry policy is implied by this guide.

## Compatibility when upgrading an integration

`info.version` in `/openapi.json` identifies the current API document version. It does not establish a semantic-versioning, deprecation-window or SLA policy.

Recommended client practice: retain the spec used to build your integration, compare it with the newly delivered contract, and validate your requests and expected responses against that contract before upgrading. Tolerate additional response properties while validating the fields you consume. Recheck endpoint-specific request fields, null/replacement semantics, scopes and response envelopes; similar resource names do not imply identical contracts. These are integration recommendations, not guarantees that future changes are backward compatible.
