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.
Layers
No source writes the record. Each source writes only its own layer.- 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.
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:
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.
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 onGET /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:
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 thegetdialed__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.
An uploaded spreadsheet
Upload the file as a record set and trigger the same kind of flow with its id. Any uploaded spreadsheet writes thecsv_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.
{"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.
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.