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

# The unified model

> What normalization guarantees, and what it deliberately does not.

The unified model is what makes [the waterfall](/docs/concepts/waterfall) possible. A cascade can
only work if its steps are interchangeable, and steps are only interchangeable if every source's
output lands in the same shape.

So this is not a convenience layer. It is the contract that lets a different source answer
tomorrow without your code noticing: a field means the same thing everywhere, and it keeps
meaning that as sources are re-tuned and channels are added.

## Four guarantees

<CardGroup cols={2}>
  <Card title="Stable identity" icon="fingerprint">
    An entity keeps its Unif ID across renames, handle changes and re-listings. Store the ID,
    not the URL.
  </Card>

  <Card title="One name per concept" icon="tag">
    `revenue` is `revenue` on every entity and every channel. There is no `gmv` on one and
    `sales_amount` on another.
  </Card>

  <Card title="One unit per field" icon="scale-balanced">
    Money is in the currency you requested. Rates are ratios, never percentages. Timestamps are
    RFC 3339 in UTC.
  </Card>

  <Card title="Declared coverage" icon="eye">
    When a field is not available for a channel and market, it is `null` and named in
    `meta.missing_fields`. It is never quietly zero.
  </Card>
</CardGroup>

## The shape of every record

Each entity response has the same four layers, so code you write against one entity transfers to
the others.

```json theme={null}
{
  "id": "shp_01k3m9x7v2q8r4t6y0b1n5d7fa",
  "object": "shop",
  "channel": "tiktok_shop",
  "market": "US",

  "name": "GlowLab",
  "categories": [{ "id": "cat_beauty_personal_care", "name": "Beauty & Personal Care" }],

  "metrics": { "revenue": 1284300.25, "units_sold": 48210 },

  "period": { "start": "2026-08-16", "end": "2026-09-14", "granularity": "day", "adjusted": false },
  "currency": "USD",
  "meta": { "refreshed_at": "2026-09-15T04:12:00Z", "completeness": 1, "missing_fields": [] }
}
```

<Steps>
  <Step title="Identity">
    `id`, `object`, `channel`, `market`. Stable, and safe to store.
  </Step>

  <Step title="Attributes">
    Facts about the entity that do not depend on a period — name, category, price, follower count.
  </Step>

  <Step title="Metrics">
    Everything measured over a window, always under `metrics`. If a number moves with the period
    you asked for, it lives here.
  </Step>

  <Step title="Context">
    `period`, `currency` and `meta`. The record tells you what it is, rather than making you
    remember what you asked for.
  </Step>
</Steps>

<Tip>
  The attributes-versus-metrics split is the useful line to hold in your own schema too.
  Attributes belong on a dimension table; metrics belong on a fact table keyed by entity and date.
</Tip>

## What normalization does not do

Being explicit about the limits is what keeps the guarantees credible.

<AccordionGroup>
  <Accordion title="It does not invent data a channel never reports" icon="ban">
    If a channel does not publish a metric for a market, Unif returns `null` and lists the field
    in `meta.missing_fields`. Modelling a plausible value would make the number look like the
    ones next to it, which is worse than an absent value.
  </Accordion>

  <Accordion title="It does not make every channel comparable on every metric" icon="not-equal">
    Attribution windows and return handling genuinely differ between channels. Unif documents each
    definition in the [metrics reference](/docs/concepts/metrics) and reports the channel on every
    record. Cross-channel totals are your call to make, with that context in hand.
  </Accordion>

  <Accordion title="It does not smooth over period granularity" icon="calendar">
    If a channel only reports weekly buckets for a market, a request for three arbitrary days
    comes back with `period.adjusted: true` and the window that was actually measured. Silent
    interpolation would be a lie with a plausible shape.
  </Accordion>

  <Accordion title="It does not hide latency" icon="clock">
    `meta.refreshed_at` is when Unif last refreshed the record, not when you asked. Combine it
    with `typical_lag_hours` from [`GET /coverage`](/docs/concepts/coverage) to know how current a
    number really is.
  </Accordion>
</AccordionGroup>

## Adding a channel changes nothing you wrote

This is the property the model exists to deliver. When a channel goes live:

* Your stored Unif IDs stay valid, because IDs are namespaced per entity and never recycled.
* Your filters keep working, because filter names are model-level, not channel-level.
* Your queries do not automatically widen. A search without `channel` spans the channels your
  workspace has **enabled**, so turning one on is a deliberate act.
* New channel-specific nuance shows up in [coverage](/docs/concepts/coverage), not in new field
  names.

<Note>
  The one thing you should re-read after enabling a channel is the coverage report. A filter that
  is fully supported on one channel may be `partial` on another, which changes what your result
  set means.
</Note>
