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-trail with an explicit plot filter.
  • Upload fast path โ€” create_audit_trail: true on POST /ingestions/assets, which auto-creates the record once the upload finishes deforestation.
  • Reprocess fast path (split flow) โ€” the same create_audit_trail: true opt-in on PUT /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 via PATCH).

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

MethodPathDescription
GET/regulations/eudr/audit-trail/:recordId/eventsFull audit log for the record, across every version, newest-first
GET/regulations/eudr/audit-trail/:recordId/versions/:versionId/eventsCumulative history up to and including the named version
POST/regulations/eudr/audit-trail/:recordId/eventsAppend 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 action
  • filter[action][in] โ€” comma-separated list of actions
  • filter[target_plot_id][eq] โ€” events scoped to one plot
  • filter[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": [ ]
  }
}
  • action is one of the closed vocabulary catalogued below.
  • target_plot_id is set only for plot-scoped actions (see Plot-scoped actions), and null otherwise.
  • payload is 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_id is null for 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

ActionMeaning
record.createRecord created
record.updateRecord 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

ActionMeaning
version.createA new version was cut
version.task_createVersion-cut task started (async)
version.task_completedVersion-cut task succeeded
version.task_failedVersion-cut task failed

Configuration

ActionMeaning
config.replaceFull replacement of exclusions + justifications
config.patchIncremental update of exclusions + justifications
config.finding_setA finding was written or replaced on any axis (payload.axis says which)
config.finding_removedA finding was removed
config.finding_batch_setMany findings were written on one axis in one batch call (payload.subject_ids names them)
config.exclusion_addedA plot was excluded from the record โ€” system-only since EPO-1983
config.exclusion_removedA plot was re-included โ€” system-only since EPO-1983
config.justification_setA justification was set on a plot
config.justification_removedA justification was removed from a plot

Which action records an exclusion. An operator excluding a plot writes config.finding_set with payload.axis: "exclusion", via PUT /verdict-overrides. The config.exclusion_added / config.exclusion_removed pair is now emitted only by the submit-time auto-exclude and its re-include, with payload.source: "system" and systemAuthored: true.

That split is deliberate, not cosmetic: config.finding_set invalidates the authorisation grant a submit files under, so emitting one during the submit itself would 409 every record holding a non-compliant plot. Filter on payload.source to separate operator decisions from system ones.

Plot

ActionMeaning
plot.attachedA plot was attached to a version
plot.metric_updatedA plot's deforestation metrics changed between versions (carries a { previous, next } diff of only the changed attributes)
plot.assessment_updatedOne or more of a plot's validation assessments (core / art9 / art10 / hazard / infrastructure) changed between versions

Submission & authorization

ActionMeaning
version.submit_startedSubmission task initiated
version.submittedFiled with EU TRACES by Epoch on the operator's behalf
version.submit_failedSubmission failed
version.marked_submittedOperator filed externally via the EU portal and recorded the result manually
version.authorization_grantedOperator acknowledged the ยง9 step-3 "authorize Epoch to submit" consent text

Assessment refresh

ActionMeaning
version.assessments_refreshedThe reprocess / version-cut flow ran a fresh sco2api pass for at least one staged location
underlying_assessment_refreshedAn out-of-band /refresh of the /assessments/* write surface overwrote validation data that an open version pins (see below)

Supplier

ActionMeaning
supplier.overriddenA per-record supplier snapshot override was written
supplier.propagatedA supplier edit propagated into a pending version via live-link
supplier.forkedA multi-org supplier edit repointed this org's pivot to a new global row
supplier.mergedA supplier was repointed onto a colliding row (fingerprint dedup)
supplier.frozen_on_submitSupplier snapshots frozen before building the TRACES envelope
supplier.frozen_on_version_cutPrior version's supplier snapshots frozen during a version cut

Operator

ActionMeaning
operator.defaults_changedThe org's operator defaults changed while this version was still open
operator.frozen_on_submitThe 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.patch
  • config.exclusion_added
  • config.exclusion_removed
  • config.justification_set
  • config.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

This 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

FieldTypeNotes
assessment_slugstringHuman slug for the refreshed assessment
assessment_typestringAssessment type enum value
staged_location_iduuidThe staged location whose validation was overwritten
previous_sco2_run_atISO-8601 string | nullThe sco2_run_at the version pinned before the refresh; null when the overwrite created the row where none existed
new_sco2_run_atISO-8601 stringThe sco2_run_at after the refresh
assessment_task_iduuidThe 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_id in the group
  • the old โ†’ new window = the earliest previous_sco2_run_at โ†’ the new_sco2_run_at in the group (they share one refresh, so new_sco2_run_at is 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

MethodPathDescription
POST/regulations/eudr/audit-trail/:recordId/attachmentsMint a signed upload URL and a pending attachment row
GET/regulations/eudr/audit-trail/:recordId/attachmentsKeyset-paginated list of the record's attachments
GET/regulations/eudr/audit-trail/:recordId/attachments/:idFetch one attachment with a signed download URL
DELETE/regulations/eudr/audit-trail/:recordId/attachments/:idSoft-delete (idempotent); drops from listings, bytes stay resolvable
PUT/regulations/eudr/audit-trail/:recordId/attachments/:id/plots/:plotIdLink an existing attachment to one of the record's plots (idempotent)
DELETE/regulations/eudr/audit-trail/:recordId/attachments/:id/plots/:plotIdUnlink 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:

  1. Mint. POST โ€ฆ/attachments with the file's metadata. The response is a 201 carrying an upload_url (a short-lived signed URL with application/octet-stream and a maxBytes ceiling pinned into the signature) and expires_at. The attachment row is created in status: "pending". content_type and size_bytes are 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>"]
        }
      }
    }
    
  2. Upload. PUT the raw bytes to upload_url with Content-Type: application/octet-stream (the pinned type). Exceeding the pinned size is rejected by storage.

  3. Auto-confirm. When the object finalizes, GCS fires an OBJECT_FINALIZE event to an internal webhook (POST /_internal/attachment-finalize), which flips the row to status: "stored" and stamps confirmed_at. There is no client confirm call โ€” poll the list/single GET until status is stored.

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 of supporting_document, certificate, due_diligence_statement, legality_evidence, geolocation_evidence, correspondence, map, other.
  • source โ€” one of operator, supplier, third_party, or verifier (the last for accredited third-party verifier reports).
  • status โ€” pending until the bytes finalize, then stored.
  • verification_status โ€” approved (default), awaiting_verification, or rejected. An attachment left awaiting_verification blocks submission of the DDS version.
  • plot_ids โ€” the record plots this document justifies (may be empty; one attachment can cover many plots via the attachment_plot pivot).
  • locked โ€” true once the DDS version is submitted (see below).
  • The mint response additionally carries upload_url + expires_at; the single-GET carries download_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 | stored
  • filter[verification_status][eq|in] โ€” approved | awaiting_verification | rejected
  • filter[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). A DELETE against a locked attachment returns 409 Conflict, so the evidence backing a filing cannot be altered after the fact.
  • Soft-delete. Before submission, DELETE โ€ฆ/attachments/:id tombstones the row (archived_at set) โ€” 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.