Reconciling an export against its source
Every analysis in this catalog assumes its input is complete. Almost none of them can tell
when it is not — a metric computed on 80% of conversations looks exactly like a metric
computed on all of them, and it is wrong by an amount nobody can estimate afterwards.
Support APIs lose data quietly. A default status filter, a pagination cap, a permission scope,
a rate limit that ended a run early, an integration that was offline for a week. All of these
produce a healthy-looking file.
Reconcile before you analyse, and report the reconciliation alongside every conclusion.
The source's own count is the only real anchor
Every inferred check below can be fooled. A count from the platform cannot, so get one:
- The platform's own reporting or analytics view for the same window.
- A count endpoint or a
total_count in a list response.
- The UI's own result count for an equivalent filter.
Then compare, and compare per segment as well as in total. A total that matches while one
channel is short and another is long is two errors cancelling, and it happens more often than
it should.
Where the platform's count uses a different definition from your export — a different date
field, a different status set — reconcile the definitions first. The gap is frequently the
definition rather than missing data, and finding that out is worth more than the count.
If no source count is obtainable, say so explicitly. The rest of the checks then constitute
internal consistency, not completeness, and the difference matters.
Run it
node scripts/reconcile-export.mjs --dir ./out --expected expected.json
expected.json carries the source's counts and the window you asked for. The script checks
the total and per-segment counts against it, finds duplicate ids, records outside the window,
days with no data at all, orphaned messages, and the field-population rates that indicate a
partial extract. See references/expected-format.md.
Daily coverage is the highest-yield inferred check
A gap in a date-partitioned count is the clearest signal of a failed run, and it is visible
without any source count.
- List every day in the window with zero conversations. On most operations that is
suspicious immediately, and it points at the exact day a run failed.
- Check volume per day against a seasonal baseline — same weekday, over several weeks.
A day at 30% of normal is a partial run, and a total-only check will never see it.
- Distinguish a real quiet day — a public holiday, a weekend, a market's closure — from a
gap. This needs the per-market holiday calendar, and without it every holiday looks like an
outage.
What each check actually catches
- Total short of source — a status default, a pagination cap, a permission scope, or a run
that ended early.
- Total over source — duplicate ingest, or a different definition on one side.
- Duplicate
source_ids — a re-run without an upsert key, or overlapping windows appended
rather than merged.
- Records outside the requested window — a filter that did not apply, which usually means
the whole window filter was ignored and you have an unbounded extract.
- A day with zero records — a failed run.
- One segment much shorter than its source count — a permission scope excluding an inbox,
queue or market. The most common partial-export cause, and it never errors.
- Orphaned messages — messages whose conversation is absent, meaning the conversation pass
and the message pass disagree.
- Conversations with zero messages — expected for voice-only sources, a red flag for text
ones, so it must be interpreted per source.
- A field's population rate far below expectation — an extract that skipped a hydration
step, which is the failure that corrupts metrics rather than stopping them.
- A high
unknown author-type share — a broken author mapping, which invalidates every
response-time and turn-count metric downstream.
Reconciling a migration or a backfill
Same checks, plus:
- Compare id sets, not counts. For a migration, the question is which specific records are
missing, and a matching count with different membership is entirely possible.
- Check the tails. The oldest and newest records on each side. A migration that silently
started at a date boundary shows up here first.
- Check the things that are easy to lose in a migration: attachments, internal notes,
status history, custom fields, and the mapping of agents to their records.
- Reconcile after the source stops changing, or accept that the delta includes real new
activity. Where you cannot freeze the source, take both snapshots at the same instant and
say you did.
Report it as a gate, not as a note
The output should be usable as a sign-off:
- A verdict: reconciled, reconciled with known gaps, or not reconciled.
- The residual gap, as a count and a percentage, with the segments it sits in.
- The specific missing ids or days, so it can be re-run rather than re-argued.
- What was not checkable, and therefore what completeness claim is unsupported.
Then carry the verdict forward. An analysis built on an export reconciled to 94% should say so
in its own limitations, and the number should travel with the dataset rather than living in a
ticket.
Guardrails
- Do not analyse an unreconciled export and add a caveat later. The caveat does not
propagate; the number does.
- Do not treat internal consistency as completeness. No duplicates and no gaps is
compatible with missing a third of the account.
- Do not fill a gap by re-running with a wider window and appending. That produces
duplicates. Re-run into a clean directory, or upsert on
source_id.
- Do not silently drop records that fail a check. Report them; they are the finding.
- Report counts and ids only. Reconciliation output does not need conversation content,
and exports are production PII that must not be committed.
Present results to the user
- The verdict, first — reconciled, reconciled with known gaps, or not reconciled.
- Source counts versus export counts, in total and per segment, with the definition
reconciliation where the two sides count differently.
- Daily coverage, with zero-days and low-days listed, and holidays distinguished from
gaps.
- Structural checks — duplicates, out-of-window records, orphaned messages, empty
conversations interpreted for this source type.
- Field population rates, including the
unknown author-type share.
- The residual gap, quantified, with the specific ids or days to re-run.
- What could not be checked — no source count available, no holiday calendar, no id list
for a migration — and the completeness claim that is therefore unsupported.
1---2name: cx-export-reconciliation3description: Use to prove a conversation export is complete against the source system's own counts, before anything is built on it. Trigger for "is this export complete", "reconcile the export against Zendesk", "our export is missing tickets", "the numbers don't match the helpdesk", verifying a migration or a backfill, or signing off a dataset for analysis.4---56# Reconciling an export against its source78Every analysis in this catalog assumes its input is complete. Almost none of them can tell9when it is not — a metric computed on 80% of conversations looks exactly like a metric10computed on all of them, and it is wrong by an amount nobody can estimate afterwards.1112Support APIs lose data quietly. A default status filter, a pagination cap, a permission scope,13a rate limit that ended a run early, an integration that was offline for a week. All of these14produce a healthy-looking file.1516**Reconcile before you analyse, and report the reconciliation alongside every conclusion.**1718## The source's own count is the only real anchor1920Every inferred check below can be fooled. A count from the platform cannot, so get one:2122- The platform's own reporting or analytics view for the same window.23- A count endpoint or a `total_count` in a list response.24- The UI's own result count for an equivalent filter.2526Then compare, and **compare per segment as well as in total.** A total that matches while one27channel is short and another is long is two errors cancelling, and it happens more often than28it should.2930Where the platform's count uses a different definition from your export — a different date31field, a different status set — reconcile the definitions first. The gap is frequently the32definition rather than missing data, and finding that out is worth more than the count.3334If no source count is obtainable, say so explicitly. The rest of the checks then constitute35*internal consistency*, not completeness, and the difference matters.3637## Run it3839```bash40node scripts/reconcile-export.mjs --dir ./out --expected expected.json41```4243`expected.json` carries the source's counts and the window you asked for. The script checks44the total and per-segment counts against it, finds duplicate ids, records outside the window,45days with no data at all, orphaned messages, and the field-population rates that indicate a46partial extract. See [references/expected-format.md](references/expected-format.md).4748## Daily coverage is the highest-yield inferred check4950A gap in a date-partitioned count is the clearest signal of a failed run, and it is visible51without any source count.5253- **List every day in the window with zero conversations.** On most operations that is54 suspicious immediately, and it points at the exact day a run failed.55- **Check volume per day against a seasonal baseline** — same weekday, over several weeks.56 A day at 30% of normal is a partial run, and a total-only check will never see it.57- **Distinguish a real quiet day** — a public holiday, a weekend, a market's closure — from a58 gap. This needs the per-market holiday calendar, and without it every holiday looks like an59 outage.6061## What each check actually catches6263- **Total short of source** — a status default, a pagination cap, a permission scope, or a run64 that ended early.65- **Total over source** — duplicate ingest, or a different definition on one side.66- **Duplicate `source_id`s** — a re-run without an upsert key, or overlapping windows appended67 rather than merged.68- **Records outside the requested window** — a filter that did not apply, which usually means69 the whole window filter was ignored and you have an unbounded extract.70- **A day with zero records** — a failed run.71- **One segment much shorter than its source count** — a permission scope excluding an inbox,72 queue or market. **The most common partial-export cause, and it never errors.**73- **Orphaned messages** — messages whose conversation is absent, meaning the conversation pass74 and the message pass disagree.75- **Conversations with zero messages** — expected for voice-only sources, a red flag for text76 ones, so it must be interpreted per source.77- **A field's population rate far below expectation** — an extract that skipped a hydration78 step, which is the failure that corrupts metrics rather than stopping them.79- **A high `unknown` author-type share** — a broken author mapping, which invalidates every80 response-time and turn-count metric downstream.8182## Reconciling a migration or a backfill8384Same checks, plus:8586- **Compare id sets, not counts.** For a migration, the question is which specific records are87 missing, and a matching count with different membership is entirely possible.88- **Check the tails.** The oldest and newest records on each side. A migration that silently89 started at a date boundary shows up here first.90- **Check the things that are easy to lose in a migration**: attachments, internal notes,91 status history, custom fields, and the mapping of agents to their records.92- **Reconcile after the source stops changing**, or accept that the delta includes real new93 activity. Where you cannot freeze the source, take both snapshots at the same instant and94 say you did.9596## Report it as a gate, not as a note9798The output should be usable as a sign-off:99100- **A verdict**: reconciled, reconciled with known gaps, or not reconciled.101- **The residual gap**, as a count and a percentage, with the segments it sits in.102- **The specific missing ids or days**, so it can be re-run rather than re-argued.103- **What was not checkable**, and therefore what completeness claim is unsupported.104105Then carry the verdict forward. An analysis built on an export reconciled to 94% should say so106in its own limitations, and the number should travel with the dataset rather than living in a107ticket.108109## Guardrails110111- **Do not analyse an unreconciled export and add a caveat later.** The caveat does not112 propagate; the number does.113- **Do not treat internal consistency as completeness.** No duplicates and no gaps is114 compatible with missing a third of the account.115- **Do not fill a gap by re-running with a wider window and appending.** That produces116 duplicates. Re-run into a clean directory, or upsert on `source_id`.117- **Do not silently drop records that fail a check.** Report them; they are the finding.118- **Report counts and ids only.** Reconciliation output does not need conversation content,119 and exports are production PII that must not be committed.120121## Present results to the user1221231. **The verdict**, first — reconciled, reconciled with known gaps, or not reconciled.1242. **Source counts versus export counts**, in total and per segment, with the definition125 reconciliation where the two sides count differently.1263. **Daily coverage**, with zero-days and low-days listed, and holidays distinguished from127 gaps.1284. **Structural checks** — duplicates, out-of-window records, orphaned messages, empty129 conversations interpreted for this source type.1305. **Field population rates**, including the `unknown` author-type share.1316. **The residual gap**, quantified, with the specific ids or days to re-run.1327. **What could not be checked** — no source count available, no holiday calendar, no id list133 for a migration — and the completeness claim that is therefore unsupported.