> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getdialed.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Data stores

> Canonical business records built from every source that describes them — source layers, a resolved view decided by precedence you control, audit history, and change events

A **data store** holds one kind of business record — the phone numbers your company owns, for instance — and keeps every source that describes it side by side rather than letting the newest writer overwrite the rest. Each source writes its own **layer**; the store computes a single **resolved** view from those layers using a precedence order you configure.

That is the difference between a store and a sync. A sync gives you one system's copy of the truth, and the last import wins. A store lets a nightly platform report, a spreadsheet you uploaded last month and a hand edit all be right about *different fields* of the same number, and lets you ask which one supplied any value you are looking at.

```json theme={null}
{
  "key": "+15551234567",
  "resolved": {
    "e164": "+15551234567",
    "status": "active",
    "campaign": "Inbound Main",
    "friendly_name": "West Region Main",
    "owner_team": "Support",
    "state": "CA"
  },
  "resolved_config_version": 2,
  "created_at": "2026-08-01T09:00:00Z",
  "updated_at": "2026-08-20T12:00:00Z"
}
```

## What the platform declares, and what you configure

A store's shape is a platform declaration: its fields and their types, which field is the record key, the complete set of sources that may ever write to it, how long records and history are kept, and the events it emits. `GET /data/stores/{store}` returns all of it, so a table or a form can be rendered from that one response.

**Precedence is the part you configure.** It can reorder or restrict the sources the store declares — it can never introduce a source or a field the store does not declare.

|                                    | Declared by the platform | Configured by you |
| ---------------------------------- | ------------------------ | ----------------- |
| Fields and their types             | ✓                        |                   |
| The record key and its format      | ✓                        |                   |
| Which sources may ever write       | ✓                        |                   |
| Retention, event names             | ✓                        |                   |
| The order sources resolve in       |                          | ✓                 |
| Per-field exceptions to that order |                          | ✓                 |

One store ships today: **company phone numbers**, addressed as `company-phone-numbers` in every store URL.

## The record key

The key is a **natural key**, normalized by the store rather than by each writer. For phone numbers that is E.164 — `+15551234567`.

Normalization happens on write, at the store boundary, and it is the reason one number never becomes two records. A spreadsheet uploads `5551234567`, a platform report returns `+15551234567`, a hand edit types `(555) 123-4567`; all three land on the same record. Reads normalize too, so you can fetch a record by whichever form you have.

<Warning>
  **A value that is not a valid phone number is rejected, never stored as written.** On the API a bad key returns `422`. On a bulk write the row is reported as skipped by its row number in the set and the rest of the set is still written — a store with a forked record is worse than a store with a reported reject, because the fork is silent and permanent.
</Warning>

## Layers

No source writes the record. Each source writes **only its own layer**.

```json theme={null}
{
  "key": "+15551234567",
  "layers": {
    "five9_report": {
      "data": { "campaign": "Inbound Main" },
      "written_at": "2026-08-20T09:15:04Z",
      "written_by": { "kind": "flow", "report_name": "DNIS by Campaign" }
    },
    "csv_file": {
      "data": { "state": "CA", "provider": "Acme Telecom" },
      "written_at": "2026-07-02T16:40:11Z",
      "written_by": { "kind": "csv_upload", "filename": "company-numbers.csv" }
    },
    "manual": {
      "data": { "friendly_name": "West Region Main", "owner_team": "Support" },
      "written_at": "2026-08-20T12:00:00Z",
      "written_by": { "kind": "user" }
    }
  }
}
```

Three properties follow from that, and each one is a capability rather than a detail:

* **Sources contribute different, partial field sets.** The union across layers is richer than any single source, which is what makes this a master record rather than a mirror.
* **A write replaces that source's own layer wholesale.** A source that stops asserting a value has that assertion *disappear*, and the resolved view falls through to the next source. Retraction is expressible without a delete.
* **Every other layer is untouched.** A new integration joins by writing a new layer; it changes no existing writer and no existing values.

Each layer carries its own `written_at` and `written_by`, whether or not the values moved. That is what makes "the platform report layer is older than last night's sync" a question you can answer, and it is why a re-run that changes nothing still updates the timestamp.

## The resolved view and precedence

`resolved` is one value per field, computed from the layers and stored on the record, so a reader that needs the current answer does a single read rather than a computation.

Precedence is a store-wide **default order** with optional **per-field overrides**, because a single order cannot express "the platform report is authoritative for the campaign but should never touch the owning team" — and that is a real configuration, not a hypothetical.

> For each field, take its precedence list — the per-field override if there is one, otherwise the default order. Walk it in order. **The first layer that asserts the field wins.** A key present in a layer is an assertion, **including one whose value is `null`**. A key absent is not an assertion.

That last clause is load-bearing. A hand edit setting `owner_team` to `null` is saying "I assert there is no owning team", and it beats a lower source that names one. Saying nothing is different from saying nothing-is-there, and the store keeps them different.

**A field's precedence list is also its writer allowlist.** A source absent from a field's list could never win it, so an assertion it made would be inert — the write is refused rather than stored somewhere it can never be read. One list answers both "who wins this field" and "who may say anything about it at all".

The shipped store's defaults:

```json theme={null}
{
  "default_order": ["manual", "crm", "five9_report", "csv_file"],
  "field_overrides": {
    "owner_team": ["manual", "crm"]
  }
}
```

Read that override as the exclusion it is: `owner_team` resolves from a hand edit or your CRM, and neither the platform report nor an uploaded spreadsheet may write it at all.

## Changing precedence

Precedence lives at `/data/stores/{store}/precedence` — `GET` to read it, `PUT` to replace it, `DELETE` to revert to the store's shipped defaults. **All three require an admin-role caller** (an `X-API-Key` is admin-equivalent), because this is the setting that decides which source wins every field for the whole account.

Reordering sources changes which value resolves **without rewriting any source's data**. Nothing is lost and nothing is migrated: the layers are already there, and the resolved view is recomputed from them.

Every change bumps `config_version` — including a revert to the defaults — and every record records the version its resolved view was computed with. Recompute then runs in the background.

<Warning>
  **While a recompute is running, bulk queries legitimately mix precedences.** Some records still carry a view computed under the old settings and some carry the new one. `recompute.pending` is the gate: it counts the records still behind, and **zero** is how you know the window has closed. Read it on `GET /data/stores/{store}` or on any precedence response, and gate a bulk read on it rather than on elapsed time.
</Warning>

Every record carries `resolved_config_version`, the precedence version that produced its view, so a record that has not been recomputed yet says so on its face rather than looking current. That is also what makes a resolved value explainable after the fact: you know which precedence produced it, and the layers it was computed from are still there.

## History

Every store keeps an audit trail: **one row per layer version**, append-only, oldest first on `GET /data/stores/{store}/records/{key}/history`.

| Field                     | What it is                                                                                                           |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `source`                  | Which source wrote this version. Each source has its own independent version sequence.                               |
| `version`                 | The version number within this record and source.                                                                    |
| `layer_data`              | What that source asserted — the layer, never the resolved view, so a version can be replayed against any precedence. |
| `changed_fields`          | Which resolved fields moved when this version was written.                                                           |
| `valid_from` / `valid_to` | The period this version was in force. A null `valid_to` marks the version in force now.                              |
| `written_by`              | The flow, upload or user behind the write.                                                                           |

**A version is appended when a source's values actually change, not on every write.** A nightly sync that re-asserts identical values adds nothing at all — which is the only reason an audit trail over a store synced every night stays a readable size instead of growing by its whole record count every day.

<Note>
  A record with no history returns an empty page rather than a `404`: a key that exists and has never changed is a real answer. History is cursor-paged and reports no total, because an audit trail grows without bound and counting it is the one thing this surface should not offer.
</Note>

## Change events

A store emits its change event **when a resolved value changes** — not when a layer is written.

That is deliberate and it is what makes push-back flows safe. The pattern the store is built for is: a resolved value changes, an event fires, a flow pushes the new value out to the platform it came from, and the next sync reads it back and writes its layer. Firing on layer writes would make that loop run forever. Firing on resolved *change* terminates it structurally, because the sync is re-asserting a value that already resolved, so nothing changed and no event is emitted.

Two event types, and they answer different questions:

| Event                                | Fires                                                                   | Subscribe when you want                       |
| ------------------------------------ | ----------------------------------------------------------------------- | --------------------------------------------- |
| `core.company_number.changed`        | Once per write call in which at least one record's resolved value moved | To react to the numbers that actually changed |
| `core.company_number.sync_completed` | Once per completed sync, whether or not anything changed                | To react to "the sync ran", with its totals   |

Both names are on `GET /data/stores/{store}` as `changed_event_type` and `summary_event_type` — subscribe to them with an [event subscription](/concepts/event-subscriptions) exactly like any other event.

Change events are **coalesced per write call** — one event for the whole call, not one per record — and the payload is a **reproducible reference rather than a snapshot**:

```json theme={null}
{
  "event_type": "core.company_number.changed",
  "store": "company_phone_numbers",
  "layer": "five9_report",
  "config_version": 2,
  "written_at_from": "2026-08-20T09:15:00Z",
  "written_at_to": "2026-08-20T09:17:42Z",
  "written_by": { "kind": "flow", "report_name": "DNIS by Campaign" },
  "changed_count": 14,
  "keys": ["+15551234567"]
}
```

A consumer re-derives the changed set by querying that window, so consumption that is late, retried or replayed never dangles on a value that has since moved on. `keys` is inlined only for small changes — past a couple of dozen records the event carries the count and the window and you query for the rest.

The completion event carries that same reference plus the run's totals: `records_seen`, `resolved_changed`, `layer_only`, `unchanged` and `rejected`. `layer_only` is worth knowing about — it counts records where this source's values changed but the resolved view did not, because a higher-precedence source still wins those fields.

## Writing into a store

Three doors, and they all go through the same engine — the same layer semantics, the same precedence, the same history, the same events.

### A flow, over a record set

The general path. A [record set](/guides/bulk-ingestion) carries the rows and a flow step writes them into one named layer with the `getdialed__data__write_store_layer` action:

```json theme={null}
{
  "task_id": "task_write_layer",
  "action_id": "getdialed__data__write_store_layer",
  "parameters": {
    "store": "company_phone_numbers",
    "layer": "five9_report",
    "record_set_id": "{{ step_fetch_report.output.record_set_id }}",
    "key_field": "DNIS",
    "field_mapping": { "campaign": "CAMPAIGN" },
    "provenance": {
      "report_folder": "Campaign Reports",
      "report_name": "DNIS by Campaign"
    }
  }
}
```

`field_mapping` reads **store field to your column**, so a column header of yours can never become a store field. The key column is named separately by `key_field` and is the one value the store normalizes. A store field you leave out of the mapping is not written, and a column your rows do not carry contributes nothing rather than blanking the field.

The records are read from the set as the step runs, so a set of any size is written without its contents travelling inside the flow. The step returns how many records it saw, how many resolved-changed, how many were layer-only or unchanged, and the row numbers of any it skipped.

<Warning>
  **The write is set-only.** It touches only the records it was given. A record missing from the set is left exactly as it was and is never treated as deleted — so "this number is gone" is not something a write can express by omission.
</Warning>

### An uploaded spreadsheet

Upload the file as a record set and trigger the same kind of flow with its id. Any uploaded spreadsheet writes the `csv_file` layer; the filename is recorded in that layer's provenance, so two uploads are told apart there rather than by layer name. The later upload replaces the earlier one's assertions, which is the wholesale-replacement rule doing exactly what it says.

### A hand edit

`PATCH /data/stores/{store}/records/{key}` writes the `manual` layer for one record. It needs an authenticated account — **not** an admin role, unlike precedence.

```bash theme={null}
curl -X PATCH "$BASE_URL/data/stores/company-phone-numbers/records/+15551234567" \
  -H "X-API-Key: $GETDIALED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "layer_data": {
      "friendly_name": "West Region Main",
      "owner_team": "Support"
    }
  }'
```

The body replaces your manual assertions wholesale — a field you omit stops being asserted manually and resolution falls through to the next source, and `{"layer_data": {}}` withdraws every manual assertion on the record. A field your precedence does not allow `manual` to write is refused with `422`. An edit cannot create a record: a key with no record returns `404`.

## The two shipped writers

Company phone numbers ship with two writer flows, seeded into your account as ordinary [definitions](/concepts/flow-definitions) you can inspect, edit and trigger like any other.

|                   | Five9 DNIS Census Sync                                          | Company Numbers CSV Import                                       |
| ----------------- | --------------------------------------------------------------- | ---------------------------------------------------------------- |
| Layer             | `five9_report`                                                  | `csv_file`                                                       |
| Steps             | Run report → collect its rows as a record set → write the layer | Write the layer over an uploaded record set                      |
| Fields it asserts | `campaign`                                                      | `city`, `state`, `country`, `provider`, `attestation`, `used_by` |
| Runs              | On a schedule you commit to, or on demand                       | When you upload an export                                        |

**Their field sets do not overlap, and that is the point.** Two writers describing the same numbers with disjoint, equally valid facts is the case a single-sync model cannot represent: neither loses, the resolved record is richer than either source, and `include=layers` says which one supplied any value.

<Note>
  The census sync reads an **ownership census** — every number the account holds appears in it — which is why it carries an explicit, deliberately wide date range as definition variables. That report will not default a time range, so the range is part of the flow rather than an assumption. Widen it by editing the variable if your history goes back further.
</Note>

The census sync is seeded **without a schedule**. Trigger it manually while you are getting to know it, and commit a schedule once you have picked a window — off-hours, and not overlapping a heavy load you already run.

## Reading a store back

| You want                                                  | Ask for                                                 |
| --------------------------------------------------------- | ------------------------------------------------------- |
| Which stores you have                                     | `GET /data/stores`                                      |
| A store's fields, sources, precedence and recompute state | `GET /data/stores/{store}`                              |
| A page of records, filtered                               | `GET /data/stores/{store}/records?filter=status=active` |
| One record's resolved view                                | `GET /data/stores/{store}/records/{key}`                |
| Why a value is what it is                                 | `GET /data/stores/{store}/records/{key}?include=layers` |
| What changed and when                                     | `GET /data/stores/{store}/records/{key}/history`        |
| The precedence in effect (admin)                          | `GET /data/stores/{store}/precedence`                   |

The records list filters on any field the store declares — repeat `filter=field=value` for more than one — and its `total` always describes the same filtered population as the page beside it. Paging is cursor-based, because a store is browsed while syncs write to it and an offset page silently repeats or drops rows under concurrent writes.

<Note>
  A record that does not exist and a record belonging to another account return the **same** `404`, never a `403`. On this store that is not a formality: the record key *is* a phone number, so a `403` would answer "does this account hold this number", which is itself the disclosure.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Bulk ingestion" icon="upload" href="/guides/bulk-ingestion">
    Upload the rows a store write works from, as a record set.
  </Card>

  <Card title="Event subscriptions" icon="bell" href="/concepts/event-subscriptions">
    Trigger a flow from a store's change or completion event.
  </Card>

  <Card title="Catalog" icon="book" href="/concepts/catalog">
    The layer-write action's full parameter and output reference.
  </Card>

  <Card title="Flow definitions" icon="diagram-project" href="/concepts/flow-definitions">
    Edit a seeded writer flow, or author your own.
  </Card>
</CardGroup>
