Assessments API

The assessments surface (/assessments/...) is the read + refresh API over the assessment rows every staged location accumulates. Each assessment answers a different question about a location (confidence, hazard, legality, country risk, …) and is exposed under a stable, regulation-neutral URL slug.

This page documents the API contract: the URL surface, the slug map, the filter matrix, refresh semantics, the DDS audit-log interaction, and the cross-org dedup caveat.


URL surface

location_type is one of facilities, sheds, or plots. slug is one of the regulation-neutral slugs in the slug map. Reads require the assessments:read scope; refresh requires assessments:write.

Reads

Method & pathPurpose
GET /assessments/{location_type}/{slug}Latest assessment per location (paginated list).
GET /assessments/{location_type}/{id}/{slug}Single location's latest assessment (404 when none).

Every list response uses the standard meta / links / data envelope; each item is { "type": "assessment", "id", "attributes": { "location_id", "location_type", …slug fields }, "links": { "self" } }. All four list formats (application/json, application/geo+json, application/x-ndjson, application/x-ndgeo+json) are available via the Accept header — see Response Formats.

Flatteners (legality only)

Legality additionally exposes row-level sub-resources that flatten the nested applicable_laws[] / adverse_media_concerns[] arrays into one row each, paginated and filtered at the row level:

Method & pathPurpose
GET /assessments/{location_type}/legality/applicable-lawsOne row per applicable law.
GET /assessments/{location_type}/{id}/legality/applicable-lawsThe applicable-law rows for a single location.
GET /assessments/{location_type}/legality/adverse-mediaOne row per adverse-media concern.
GET /assessments/{location_type}/{id}/legality/adverse-mediaThe adverse-media rows for a single location.

Each row carries a 1-based ordinal (law_ordinal for concerns) that correlates it back to its parent law on the per-location /legality surface.

Refresh (the write surface)

refresh is a trailing path segment (not a colon-verb), so it renders as a literal route in the OpenAPI spec. One handler backs every call shape and disambiguates from the path + body:

Method & pathShapeselection_kind
POST /assessments/{location_type}/refreshBatch, multi-slug (slugs in body)filter (or list)
POST /assessments/{location_type}/{idOrSlug}/refreshBatch single-slug ({slug}) or single multi-slug ({id})filter / single
POST /assessments/{location_type}/{id}/{slug}/refreshSingle-location, single-slugsingle

Refresh is asynchronous: it returns 202 Accepted with an assessment_task resource and a meta.poll hint. See Refresh semantics.

Tasks

Method & pathPurpose
GET /assessments/tasksList the caller's recent refresh tasks (paginated).
GET /assessments/tasks/{id}Poll a single refresh task to terminal state.

Slug mapping

Public URLs are regulation-neutral so future laws can land as new internal assessment types without breaking the URL contract. The slug → internal type map is the stable part; the internal enum names are an implementation detail.

SlugInternal typeWhat the read projects
legalityart9Applicable laws, adverse-media concerns, legality_risk_level / _score, legality_sources.
country-riskart10Article-10 procedural risk: article10_risk_score / _level / _deterministic_tier, corruption_perception_index.
eudr-profileeudr_profileEUDR country benchmark tier, facility description/profile, Article-29 citation + effective date.
deforestation-screendeforestation_screenPer-plot deforestation risk band, deforestation_present, deforestation_area_ha.
hazardhazardNatural-hazard risk: flood, extreme heat, water stress, precipitation trend; nearest power-grid distance.
biodiversitybiodiversityLandscape biodiversity: landscape_integrity_score / _level, natural cover fraction, patch count, Rao's Q, WDPA / IFL proximity.
waterwaterClimatic water stress: water_budget_score / _level, seasonal climatic water deficit.
luc-emissionsluc_emissionsLand-cover change (converted area, transitions) and its emissions: luc_tco2eyear, intensity, luc_risk_level.
infrastructureinfrastructureThree named sub-objects — distances (water/port/road/grid), shed (art9 sibling), climate (hazard sibling).
corecoreConfidence score/level + per-check geometric counts + plausibility summaries.

Future-regulation roadmap

Because the slug is the contract, additional regulations (e.g. UFLPA, the EU Forced Labour Regulation, the German LkSG) land as new slugs backed by new internal assessment types — existing slugs and their fields keep working unchanged. A new internal enum value can also exist without a public slug until one is added, so internal experiments never leak into the URL surface.


Filter matrix

All read endpoints accept filter[...] query parameters (see Query Parameters for operator syntax). Each filter constrains the operators it honours, so filter[risk_level]=high (bare = eq) and filter[risk_level]=in:low,medium work, but unsupported operators 400. Filters fall into three tiers:

1. Location-level (shared by every slug)

organization_id, location_id, commodity, country_code, name, tags, created_at. The legality surfaces additionally support the location-parity filters country, admin_region, city, facility_id, shed_id, plot_id.

2. Ingestion-trace (shared by every slug)

Scope a read back to the upload it came from:

FilterExample
assets.task_idfilter[assets.task_id]=eq:<ingestion-task-uuid>
assets.filenamefilter[assets.filename]=plots.geojson
assets.data.*filter[assets.data.CITY]=London (user-uploaded source columns)

3. Per-slug

Each slug adds filters over its own row data. Representative examples:

SlugPer-slug filters
legality (+ flatteners)risk_level, risk_score_min, risk_score_max, category, has_adverse_media, media_relevance, since
country-riskArticle-10 risk band / score + since
hazardNatural-hazard band / score + since
coreconfidence_level + since
luc-emissionsluc_risk_level (eq / in; a null band never matches) + since

The authoritative, current per-slug filter list lives in the generated OpenAPI spec (the Request/Query tabs on each endpoint page).


Refresh semantics

Request shape

  • Selection is exactly one of: a path :id (single), a location_ids array in the body (list, ≤ 10,000 ids), or a filter[...] querystring (filter). A by-filter selection resolving to > 10,000 locations is rejected (400) rather than silently truncated.
  • Slugs come from either the path slug or a body slugs array — never both (supplying both is a 400). slugs omitted or "all" expands to every slug. Example multi-slug body: { "slugs": ["legality", "country-risk"] }.

Async + polling

A 202 carries the assessment_task plus a meta.poll block:

{
  "meta": {
    "poll": {
      "href": "/assessments/tasks/<id>",
      "attribute": "data.attributes.status",
      "terminal_states": ["completed", "partial", "failed"],
      "interval_ms": 2000
    }
  }
}

Poll GET /assessments/tasks/:id until data.attributes.status is terminal. External status vocabulary: queued · running · completed · partial (some units failed) · failed.

Counts & per-slug breakdown

data.attributes.counts always satisfies:

total = completed + failed + skipped_cached + in_progress + queued

data.attributes.per_slug.<slug> repeats the same count keys plus that slug's own status, so a multi-slug refresh reports progress per partition.

Dedup & freshness

  • Freshness short-circuit — a (location, slug) pair whose existing validation row has an sco2_run_at within the freshness window (24h default) is counted under skipped_cached and excluded from the work set. A refresh where every pair is fresh completes immediately with no work dispatched. ?force=true bypasses the window and re-runs everything.
  • Dedup / claims — concurrent refreshes that target the same pairs attach to the already-in-flight task rather than double-running; the attached request's task finalises against the owning run.
  • Concurrency cap — a soft per-organization cap on in-flight refresh tasks; exceeding it returns 429.
  • Retry — ?retry=failed_only&from=<prior-task-id> re-runs only the failed subset of a prior task (skipping the freshness check), using the prior task's original selection snapshot.

Audit-log interaction with open DDS records

A refresh that overwrites a validation row pinned by an open (unsigned) DDS version writes an underlying_assessment_refreshed audit event on that DDS record's audit feed, in the same transaction as the overwrite. Read it via:

GET /regulations/eudr/audit-trail/{recordId}/events?filter[action][eq]=underlying_assessment_refreshed

Each event's payload carries:

FieldMeaning
assessment_slug / assessment_typeWhich assessment was refreshed.
staged_location_idThe location whose pinned row was overwritten.
previous_sco2_run_at / new_sco2_run_atBefore → after (previous is null if the overwrite filled a prior absence).
assessment_task_idThe refresh task that caused the overwrite.
selection_kindsingle / list / filter.
triggerAlways "refresh" for this action.

Suppression falls out of the walk: sealed versions (signed_at set) and validation rows not pinned by any open version yield zero events; so do freshness-skipped and failed pairs (they never reach the overwrite).

On the task side, GET /assessments/tasks/:id reports affected_open_dds_versions (terminal tasks only) — an array of { dds_id, dds_version_id, plot_count } summarising which open versions this refresh touched and how many of their pinned plots. See EUDR Audit Trail for the broader event feed.


Cross-org dedup caveat

Assessment rows live on the shared underlying location, not per-org — a parent org and its children link to the same staged_location via pivot tables. The refresh dedup + freshness probe are org-agnostic by design: they match on the shared validation row's sco2_run_at regardless of which org last refreshed it. Two consequences:

  • A refresh triggered by one linked org makes the row "fresh" for every org that links to that location — a second org's non-force refresh of the same location will short-circuit (skipped_cached) even though that org never ran it. Pass ?force=true to re-run regardless.
  • Access control is still strict: a single-location or by-list refresh of an :id the caller's orgs cannot see returns 404 (never 403 — no cross-org existence leak), and a by-filter refresh simply resolves to an empty selection. Cross-org callers can neither observe nor attach to another org's in-flight refresh; only the shared assessment data is common.