Audit Trail
Every audit-trail record carries an append-only audit log that records who changed what, and when, across the record's whole lifecycle โ from creation through configuration, version cuts, assessment refreshes, and submission to EU TRACES. The log is the system of record an operator (or a competent-authority inspector) reads to reconstruct the record's full history, from its first edit through to filing with EU TRACES.
The log is strictly append-only: rows are never updated or deleted, and each row is written in the same database transaction as the state change it describes, so the trail can never drift from the underlying data.
โถWhich records the list returns
GET /regulations/eudr/audit-trail returns the latest assessment of each
location, not every record ever created. Two records collapse only when they
state the same claim: the same physical location, the same supplier, and the
same set of parts. The one that carries a product declaration wins, and the
most recent wins after that.
Records that state different claims are always returned separately. Two suppliers who declare the same producer stay two records, because each is a supply lane a buyer must account for on its own.
Two records are never hidden:
- one whose product declaration disagrees with another record in its claim โ the whole claim is returned, because choosing between two declarations is yours to make;
- one that has entered the submit lifecycle.
Pass filter[generations]=eq:all to return every generation, including the
superseded ones. Use it to reconstruct the full history of a location.
โถCreating a record
There are three ways to create an audit-trail record:
- Directly โ
POST /regulations/eudr/audit-trailwith an explicit plot filter. - Upload fast path โ
create_audit_trail: trueonPOST /ingestions/assets, which auto-creates the record once the upload finishes deforestation. - Reprocess fast path (split flow) โ the same
create_audit_trail: trueopt-in onPUT /ingestions/assets, for callers who upload first, review, then kick off deforestation via the reprocess. The record is scoped to the task the PUT acts on and fires once (a record the upload already created is not duplicated โ edit it viaPATCH).
Both fast paths require metrics to include deforestation. See the
Ingest Assets and
Process Ingestion Assets
endpoint docs for the full field reference.
โถReading the audit trail
| Method | Path | Description |
|---|---|---|
| GET | /regulations/eudr/audit-trail/:recordId/events | Full audit log for the record, across every version, newest-first |
| GET | /regulations/eudr/audit-trail/:recordId/versions/:versionId/events | Cumulative history up to and including the named version |
| POST | /regulations/eudr/audit-trail/:recordId/events | Append a user-authored entry (restricted set โ see below) |
The list endpoints page like every other list endpoint (page[number],
page[size]) and support these filters:
filter[action][eq]โ single actionfilter[action][in]โ comma-separated list of actionsfilter[target_plot_id][eq]โ events scoped to one plotfilter[occurred_at][gte]/filter[occurred_at][lte]โ time range
โถEvent shape
Each event is returned as a dds_mitigation_event resource:
{
"type": "dds_mitigation_event",
"id": "<uuid>",
"attributes": {
"dds_id": "<uuid>",
"dds_version_id": "<uuid>",
"occurred_at": "2026-05-09T12:05:00Z",
"actor_user_id": "<uuid|null>",
"actor_organization_id": "<uuid>",
"action": "<action>",
"target_plot_id": "<uuid|null>",
"payload": { },
"attachments": [ ]
}
}
actionis one of the closed vocabulary catalogued below.target_plot_idis set only for plot-scoped actions (see Plot-scoped actions), andnullotherwise.payloadis an action-specific JSON object. The per-action shapes are documented below; consumers should read only the fields documented for the action they are rendering, as payloads may carry additional keys.actor_user_idisnullfor system-emitted events (worker jobs, async flows) that have no interactive user.
โถAction vocabulary
The action field is drawn from a fixed set. New actions are added to the
DDS_MITIGATION_ACTION vocabulary in fm-api before any code path can emit
them, so a renderer that maps these to display text can treat the set as
closed for a given API version.
โถRecord
| Action | Meaning |
|---|---|
record.create | Record created |
record.update | Record operator / commodity attributes updated |
A PATCH of commodity.hs_code writes record.update. For the codes TRACES
accepts, the blockers a code can raise and how to clear them, see
HS codes TRACES accepts.
โถVersion lifecycle
| Action | Meaning |
|---|---|
version.create | A new version was cut |
version.task_create | Version-cut task started (async) |
version.task_completed | Version-cut task succeeded |
version.task_failed | Version-cut task failed |
โถConfiguration
| Action | Meaning |
|---|---|
config.replace | Full replacement of exclusions + justifications |
config.patch | Incremental update of exclusions + justifications |
config.finding_set | A finding was written or replaced on any axis (payload.axis says which) |
config.finding_removed | A finding was removed |
config.finding_batch_set | Many findings were written on one axis in one batch call (payload.subject_ids names them) |
config.exclusion_added | A plot was excluded from the record โ system-only since EPO-1983 |
config.exclusion_removed | A plot was re-included โ system-only since EPO-1983 |
config.justification_set | A justification was set on a plot |
config.justification_removed | A justification was removed from a plot |
Which action records an exclusion. An operator excluding a plot writes
config.finding_setwithpayload.axis: "exclusion", viaPUT /verdict-overrides. Theconfig.exclusion_added/config.exclusion_removedpair is now emitted only by the submit-time auto-exclude and its re-include, withpayload.source: "system"andsystemAuthored: true.That split is deliberate, not cosmetic:
config.finding_setinvalidates the authorisation grant a submit files under, so emitting one during the submit itself would 409 every record holding a non-compliant plot. Filter onpayload.sourceto separate operator decisions from system ones.
โถPlot
| Action | Meaning |
|---|---|
plot.attached | A plot was attached to a version |
plot.metric_updated | A plot's deforestation metrics changed between versions (carries a { previous, next } diff of only the changed attributes) |
plot.assessment_updated | One or more of a plot's validation assessments (core / art9 / art10 / hazard / infrastructure) changed between versions |
โถSubmission & authorization
| Action | Meaning |
|---|---|
version.submit_started | Submission task initiated |
version.submitted | Filed with EU TRACES by Epoch on the operator's behalf |
version.submit_failed | Submission failed |
version.marked_submitted | Operator filed externally via the EU portal and recorded the result manually |
version.authorization_granted | Operator acknowledged the ยง9 step-3 "authorize Epoch to submit" consent text |
โถAssessment refresh
| Action | Meaning |
|---|---|
version.assessments_refreshed | The reprocess / version-cut flow ran a fresh sco2api pass for at least one staged location |
underlying_assessment_refreshed | An out-of-band /refresh of the /assessments/* write surface overwrote validation data that an open version pins (see below) |
โถSupplier
| Action | Meaning |
|---|---|
supplier.overridden | A per-record supplier snapshot override was written |
supplier.propagated | A supplier edit propagated into a pending version via live-link |
supplier.forked | A multi-org supplier edit repointed this org's pivot to a new global row |
supplier.merged | A supplier was repointed onto a colliding row (fingerprint dedup) |
supplier.frozen_on_submit | Supplier snapshots frozen before building the TRACES envelope |
supplier.frozen_on_version_cut | Prior version's supplier snapshots frozen during a version cut |
โถOperator
| Action | Meaning |
|---|---|
operator.defaults_changed | The org's operator defaults changed while this version was still open |
operator.frozen_on_submit | The version's operator block was snapshotted from org defaults at submit time |
โถUser-authored vs. system-emitted
Only this subset may be appended by callers via
POST /regulations/eudr/audit-trail/:recordId/events; everything else is emitted
exclusively by the fm-api code paths that own the underlying state
transition, so the trail cannot be forged:
config.patchconfig.exclusion_addedconfig.exclusion_removedconfig.justification_setconfig.justification_removed
Amendment: config.finding_set and config.finding_removed are not
caller-appendable; they are written by fm-api code that owns the finding.
config.finding_set is written by PUT /regulations/eudr/audit-trail/:recordId/verdict-overrides.
config.finding_removed is written by DELETE /regulations/eudr/audit-trail/:recordId/verdict-overrides.
โถPlot-scoped actions
These actions always carry a non-null target_plot_id; all others carry
null:
config.exclusion_added, config.exclusion_removed,
config.justification_set, config.justification_removed,
plot.attached, plot.metric_updated, plot.assessment_updated,
underlying_assessment_refreshed.
config.finding_set and config.finding_removed are the exception: they carry
target_plot_id when the finding's subject_type is plot, and null on a
record-grain finding such as a legality clearance. Read payload.subject_type
and payload.subject_id to address the subject on any axis.
config.finding_batch_set is the exception among finding actions: it covers many
plots, so it carries a null target_plot_id and names the plots in
payload.subject_ids instead.
โถunderlying_assessment_refreshed
underlying_assessment_refreshedThis event records an out-of-band refresh of the data underneath an open
draft. When a caller /refreshes the /assessments/* write surface and
the overwrite lands on a staged_location_validation row that an open
(unsigned) version pins, the trail records that the draft's assessment
moved after the version was cut โ so an inspector knows the draft no
longer reflects the data it was originally built from.
It is distinct from version.assessments_refreshed, which records the
reprocess / version-cut flow refreshing assessments as part of cutting a
version. underlying_assessment_refreshed is the out-of-band case.
The event is suppressed (no row written) for sealed/signed versions, freshness-skipped pairs, failed refreshes, and locations not referenced by any open version.
โถPayload
| Field | Type | Notes |
|---|---|---|
assessment_slug | string | Human slug for the refreshed assessment |
assessment_type | string | Assessment type enum value |
staged_location_id | uuid | The staged location whose validation was overwritten |
previous_sco2_run_at | ISO-8601 string | null | The sco2_run_at the version pinned before the refresh; null when the overwrite created the row where none existed |
new_sco2_run_at | ISO-8601 string | The sco2_run_at after the refresh |
assessment_task_id | uuid | The originating refresh task โ shared by every row from the same refresh |
selection_kind | "single" | "list" | "filter" | How the refresh selected its locations |
trigger | "refresh" | Constant โ distinguishes this from version-cut refreshes |
target_plot_id on the event is the affected plot.
โถRow granularity & rolling up for display
A single refresh can touch many plots across many open versions. The
emitter writes one event per (open version, plot) affected โ not
one per plot and not one per refresh โ and every row produced by the same
refresh shares one assessment_task_id.
A renderer that wants a single human line per refresh โ e.g.
Underlying {assessment_slug} assessment refreshed on N plot(s) โ {previous_sco2_run_at} โ {new_sco2_run_at}
should roll up at render time, grouping the rows within a version by
assessment_task_id:
- N = count of distinct
target_plot_idin the group - the old โ new window = the earliest
previous_sco2_run_atโ thenew_sco2_run_atin the group (they share one refresh, sonew_sco2_run_atis uniform)
The API intentionally does not pre-aggregate: it preserves per-plot granularity so a consumer can render either the rolled-up summary or the individual plot rows. The rollup is a presentation concern.
โถWhich plots are listed as non-compliant
A plot appears as non-compliant when its deforestation alert resolves to
critical, and only then. critical means more than 0.5 ha of
non-compliance area AND overlap with natural forest or a WDPA-mandated area.
Detected loss that clears neither bar resolves to non-critical or no deforestation, and the plot is listed as compliant.
A plot whose alert cannot be resolved at all carries no verdict rather than a compliant one, so an unassessed plot is never filed as clean.
This is the same set the deforestation thumbnails cover, which is the point
of stating it here. The two used to disagree: plot_for.deforestation_alert is
NULL on every row, and the read path fell back to "any non-compliance area at
all", with no size threshold and no overlap requirement. That listed 485,221
plots where the ladder lists 36,272, so roughly 46% of listed plots had no
image behind them. Both sides now derive through one ladder, and a
cross-surface test pins them together.
The practical consequence for a reader: every plot the appendix names as non-compliant has evidence you can look at.
โถWithheld is not the same as non-compliant
Two different reasons keep a plot out of a filing, and the audit trail states which one applies.
Judged non-compliant. The effective verdict is non_compliant โ either
alert-derived, or an operator override to non_compliant. Auto-excluded at
submit, narrated as "non-compliant plot (not filed to TRACES)".
Awaiting human review. The plot carries plot_resolution: pending_review: the automated adjudicator proposed a verdict and nobody has
confirmed it yet. Also auto-excluded at submit, but narrated as "plot awaiting
human review (not filed to TRACES)" โ it is not an assertion that the plot
is non-compliant, and an inspector reading the trail can tell the two apart.
The distinction matters because the review state is independent of the alert. A plot can be awaiting review while its alert reads sub-threshold, so withholding is decided on the review state itself rather than inferred from the verdict. Without that, an unadjudicated plot whose alert happened to clear the threshold would file as compliant.
An operator override on the plot_compliance axis retires the pending state
and returns the plot to the filing if it resolves compliant. Adding
exemption_driver to a compliant override records a non-agricultural cause,
and the plot then reads exempt with that cause. List the
outstanding queue with
GET /regulations/eudr/audit-trail/:recordId/plots?filter[plot_resolution]=eq:pending_review.
Two caveats on that queue. An active override removes a plot from the
filter whatever value you ask for โ the filter answers "which plots still need
a decision", and a decided plot is out of scope. And the endpoint lists only
plots still in the filing: a submit attempt withholds every pending-review
plot, so after a failed submit they appear on /plots/excluded instead and the
queue answers zero. Read both surfaces for the full outstanding set.
For the wider worklist, every plot an operator may still act on rather than
only those awaiting review, send filter[needs_resolution]=true. It drops
plots that are compliant with no justification and keeps everything else,
including plots already overridden, so a progress count does not shrink as
plots are resolved. It has no effect on /plots/excluded.
To read one plot's row on either list, send filter[plot_id]=eq:<uuid>.
โถAttachments
An audit-trail record can carry operator-supplied supporting documents โ land titles, certificates, third-party audit reports, correspondence, maps โ alongside the machine-generated evidence. Attachments are the operator's retained backing for a DDS; they are never submitted to EU TRACES, and they are stored in an isolated bucket, served only through short-lived signed URLs that force an attachment disposition (never rendered inline).
โถEndpoints
| Method | Path | Description |
|---|---|---|
| POST | /regulations/eudr/audit-trail/:recordId/attachments | Mint a signed upload URL and a pending attachment row |
| GET | /regulations/eudr/audit-trail/:recordId/attachments | Keyset-paginated list of the record's attachments |
| GET | /regulations/eudr/audit-trail/:recordId/attachments/:id | Fetch one attachment with a signed download URL |
| DELETE | /regulations/eudr/audit-trail/:recordId/attachments/:id | Soft-delete (idempotent); drops from listings, bytes stay resolvable |
| PUT | /regulations/eudr/audit-trail/:recordId/attachments/:id/plots/:plotId | Link an existing attachment to one of the record's plots (idempotent) |
| DELETE | /regulations/eudr/audit-trail/:recordId/attachments/:id/plots/:plotId | Unlink a plot (idempotent) |
Reads require the eudr:read scope; mutations require eudr:write. Every
route is org-scoped โ an attachment on a record your key cannot see returns
404.
โถUpload flow (two-step, signed URL)
The bytes never transit the API. Uploading is a mint โ PUT โ auto-confirm sequence:
-
Mint.
POST โฆ/attachmentswith the file's metadata. The response is a201carrying anupload_url(a short-lived signed URL withapplication/octet-streamand amaxBytesceiling pinned into the signature) andexpires_at. The attachment row is created instatus: "pending".content_typeandsize_bytesare not accepted as inputs โ the same as Ingest Assets, the finalize step resolves them from the stored object (size) and the filename extension (content type).{ "data": { "type": "attachment", "attributes": { "category": "certificate", "source": "operator", "filename": "harvest-permit.pdf", "plot_ids": ["<plot-uuid>"] } } } -
Upload.
PUTthe raw bytes toupload_urlwithContent-Type: application/octet-stream(the pinned type). Exceeding the pinned size is rejected by storage. -
Auto-confirm. When the object finalizes, GCS fires an
OBJECT_FINALIZEevent to an internal webhook (POST /_internal/attachment-finalize), which flips the row tostatus: "stored"and stampsconfirmed_at. There is no client confirm call โ poll the list/single GET untilstatusisstored.
โถAttachment resource
{
"type": "attachment",
"id": "<uuid>",
"attributes": {
"subject_type": "dds_record",
"subject_id": "<record-uuid>",
"category": "certificate",
"source": "operator",
"filename": "harvest-permit.pdf",
"content_type": "application/pdf",
"size_bytes": 48213,
"status": "stored",
"verification_status": "approved",
"plot_ids": ["<plot-uuid>"],
"checksum_sha256": "<hex|null>",
"locked": false,
"metadata": null,
"created_at": "2026-07-16T09:00:00Z",
"confirmed_at": "2026-07-16T09:00:04Z",
"archived_at": null
},
"links": { "self": "/regulations/eudr/audit-trail/<record>/attachments/<id>" }
}
categoryโ one ofsupporting_document,certificate,due_diligence_statement,legality_evidence,geolocation_evidence,correspondence,map,other.sourceโ one ofoperator,supplier,third_party, orverifier(the last for accredited third-party verifier reports).statusโpendinguntil the bytes finalize, thenstored.verification_statusโapproved(default),awaiting_verification, orrejected. An attachment leftawaiting_verificationblocks submission of the DDS version.plot_idsโ the record plots this document justifies (may be empty; one attachment can cover many plots via theattachment_plotpivot).lockedโtrueonce the DDS version is submitted (see below).- The mint response additionally carries
upload_url+expires_at; the single-GET carriesdownload_url+expires_at(pass?ttl=<seconds>to shorten the download URL lifetime).
โถListing: filter, sort, paginate
The list endpoint pages via keyset cursor (page[size], default 50, max 200)
and supports the standard four Accept formats. Filters:
filter[category][eq|in]filter[source][eq|in]filter[status][eq|in]โpending|storedfilter[verification_status][eq|in]โapproved|awaiting_verification|rejectedfilter[filename][eq|in|contains|startsWith]filter[plot_id][eq]โ attachments covering a given plot (via the pivot)
Sort with ?sort= (prefix - for descending) on created_at (default
-created_at), confirmed_at, size_bytes, filename, category,
source, status, or verification_status.
โถImmutability, soft-delete & version carry-forward
- Lock-on-submit. When a DDS version is submitted, its attachments
become immutable (
locked: true). ADELETEagainst a locked attachment returns409 Conflict, so the evidence backing a filing cannot be altered after the fact. - Soft-delete. Before submission,
DELETE โฆ/attachments/:idtombstones the row (archived_atset) โ it drops out of listings, but its bytes remain resolvable so any already-filed reference still downloads. The call is idempotent. - Plot links. A plot can only be linked if it belongs to the record's
current-version plot set (otherwise
422). - Carry-forward. Cutting a new DDS version carries the previous version's live (unlocked) attachments forward, re-keyed onto the new version's plots, so the current version always shows the operator's active evidence. This is addition-only โ it never removes existing plot links, and already-frozen version rows are left untouched.
The upload / lock / archive lifecycle is recorded on the audit trail via the
attachment.uploaded, attachment.locked_on_submit, and attachment.archived
events (see the event vocabulary above).
See EUDR Compliance for the end-to-end compliance workflow.