Record Supersession

Upload the same facility three times and the API holds three records of it. All three are real: each one is the historical fact of what an upload contained. Only one of them describes the location now.

This page explains which one, why, and how to audit the rest.


Why one location appears many times

A row on GET /ingestions/assets is an uploaded record, not a location. One row of one file, preserved as it arrived.

Re-uploading a supplier list does not edit the earlier records. It adds new ones. So a location that has been in six monthly files has six records, and a caller listing assets sees it six times.

That is correct for an audit trail and wrong for a supplier count. Both questions are legitimate, so the API answers them at two different grains:

You wantUseGrain
Every upload of every location, including replaced onesGET /ingestions/assetsOne row per record
The current state of each location, onceGET /ingestions/assets/latestOne row per location

Neither is a filtered view of the other's numbers. meta.total on the deduped read counts locations; on the full read it counts records.


What makes one record current

Records are grouped by organization and location — the pair (organization_id, location_id). Within a group, exactly one record is current and the rest are superseded. The grouping is per organization on purpose: if two of your child orgs both uploaded the same physical site, each holds its own group, its own current record, and its own history.

A record that never resolved to a location has no group. It is never superseded and always appears, with location_id and location_type null.

The ordering is the DDS pin's ordering

Currency is not "the newest upload". It is the same six-key precedence the DDS filing uses to pin its plots, applied in this order:

#KeyWhat it prefers
1has_metricA record whose location has metric results over one that has none
2monitoring_endThe later monitoring window
3is_assessedAn assessed location over an unassessed one
4used_ccdcA CCDC-backed result
5ingestion_dateThe more recently ingested metric run
6link_created_atThe more recently linked pivot row

This is the point of the whole mechanism. The deduped read, the DDS plot pin, filter[compliance] and the dashboard roll-up all resolve currency this way, so they name the same record. If you reconcile two Epoch numbers and they disagree, that disagreement is a defect rather than a difference of definition.

The practical consequence: a re-upload carrying a later end_date takes currency, because it mints a newer monitoring_end at key 2. A re-upload with the same window does not automatically win — keys 1 and 3 can keep the earlier record current if the new one has no metric results yet.


Every deduped row says what it replaced

Deduping hides a location's history, so each row carries a summary of it.

Per-record rows — facility and shed

"attributes": {
  "superseded_count": 1,
  "asset_task_ids": ["<this record's task>", "<the replaced record's task>"],
  "row_id": "<this record>"
},
"links": {
  "superseded": "/ingestions/assets?filter[location_id]=eq:<location_id>&filter[organization_id]=eq:<org_id>",
  "history": "/ingestions/assets/latest/<row_id>/history"
}

superseded_count is always present on a per-record row. 0 means the record replaced nothing — it does not mean "not applicable".

The count deliberately ignores every other filter on your request. It counts the whole group. That is what keeps it in agreement with links.superseded, which also returns the whole group: superseded_count + 1 rows, the current record plus the ones it replaced.

row_id is the record's own handle. It is not the resource id — that is the shared asset_entity id, which repeats across organizations that uploaded identical attributes, and so cannot address one record.

Folded plot rows

A plot upload folds to one row for the whole task, so it spans many locations and has no single predecessor set. It carries the split instead, and omits superseded_count, row_id and links.history entirely:

"attributes": {
  "live_plot_count": 128,
  "superseded_plot_count": 12
}

Both count plots, not records. live_plot_count is the plots a current record still owns — directly, or through the plot's shed, or through the facility above that shed, one surviving owner being enough. superseded_plot_count is its complement over the plots the task touches: those no live record reaches. A plot lands there whenever nothing live owns it, which includes one no record ever owned — not only one a later upload replaced. A pivot row that never resolved to a plot belongs to neither.

live_plot_count is the deforestation block's total_plots — the same number, read from the same cached summary, not a second count of the same thing. Note that in_scope_plots is lower by the Art 2(3)-exempt count: the compliance ratio's denominator takes exempt plots out, and that subtraction is the reason a row can read "6 of 225" beside "226 live" (EPO-2667).

Both are absent when the task has no cached compliance summary, or when the one it has was stamped for a different organisation. They describe currency over the plots the compliance figures cover, so with no such figures there is nothing to describe — and reporting the raw pivot total as "live" would assert a currency nothing had established.


Three ways to audit what a record replaced

They answer different questions, and the differences matter.

RouteReturnsOrdered byTotal
?include=supersededThe replaced records, inline, capped at 10Most recently replaced firstExact past the cap, with truncated
links.supersededThe whole group as a filtered listWhatever you sorted byThe group
links.historyThe whole group, uncappedCurrency: the live record leadsExact

Prefer links.history for the history itself

links.superseded is a filter query. It returns the group, but nothing in the response says which row is current — you would have to re-derive it.

GET /ingestions/assets/latest/{row_id}/history returns the same records ordered by supersession, each flagged is_live and carrying the monitoring_end that decided the outcome. The order comes from the stored currency stamp, so position and is_live can never disagree.

A superseded row_id is accepted and returns the same history, so a stale id from an old export still answers.

One honest inconsistency between them

?include=superseded and superseded_count are computed from the same stored pointer, which is why they always agree — and agreement is what a caller comparing data.length to the count needs.

links.history resolves the group by (organization, location) instead. It is therefore the authoritative complete set, and it can return more records than superseded_count + 1 if a pointer was left stale. If the two disagree, trust /history.


What supersession does not fix

Two known limitations. Neither is a defect in the mechanism described above; both sit at a different grain, and a reader who assumes the dedupe is total will misread their data.

A shed whose geometry shifts between runs is not superseded. Record currency tracks uploaded records against a location. A re-run that re-derives a facility's catchment can mint a new location rather than a new record of the old one, and two locations are two groups. Both stay live. Tracked as EPO-1277 Gap B.

This is not a rare edge. On our production data 42% of facilities that have sheds carry more than one shed row, and most of those are one catchment recorded more than once rather than genuinely separate catchments — so counting shed rows overstates a facility's sourcing base. GET /ingestions/assets/latest cannot fold them, because each shed is its own location. Compare geo and commodity to tell a real sibling catchment from a repeat.

The deforestation roll-up still double counts across entity generations. One current record's location can own two generations of plots, and both are counted. Record currency cannot see entity generations — a record can be current while the footprint beneath it has gained a generation. Tracked as EPO-1899, and described in Deforestation Methodology.


Task-scoped reads are exempt

filter[assets.task_id]=eq:<uuid> (or filter[asset_task_id]) returns that task's rows whether or not a later upload replaced them, on both endpoints.

This is deliberate, not an oversight. A task is the historical record of what one upload ingested, and filtering it by present-day currency would make it unauditable.


See also

  • Query Parameters — filters, sorting, cursor pagination, and the meta[count] / total_is_approx counting contract.
  • Deforestation Methodology — the two things called "deforestation" on an asset row, and the roll-up's count caveats.
  • EUDR Audit Trail — how a filing pins its plots, and why include=audit_trails reports whether the pinned record is still current.
  • Asset Upload — what an upload creates.