Skip to main content
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.

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. 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.
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.

Layers

No source writes the record. Each source writes only its own layer.
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:
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}/precedenceGET 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.
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.
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. 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.
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.

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: Both names are on GET /data/stores/{store} as changed_event_type and summary_event_type — subscribe to them with an event subscription 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:
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 carries the rows and a flow step writes them into one named layer with the getdialed__data__write_store_layer action:
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.
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.

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.
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 you can inspect, edit and trigger like any other. 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.
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.
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

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.
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.

Next steps

Bulk ingestion

Upload the rows a store write works from, as a record set.

Event subscriptions

Trigger a flow from a store’s change or completion event.

Catalog

The layer-write action’s full parameter and output reference.

Flow definitions

Edit a seeded writer flow, or author your own.