Skip to content

Open Climate Service Managed Data Guide

This guide describes the current native FastAPI surface for Open Climate Service and how datasets are published as STAC collections.

The current public story is:

  • run and inspect ingestion operations with /ingestions
  • discover the configured extent with /extent
  • discover managed datasets with /datasets
  • discover vector feature collections with /features
  • discover published GeoZarr datasets with /stac/catalog.json
  • access raw Zarr data with /zarr/{dataset_id} (vanilla zarr clients, web maps)
  • access the native Icechunk store with /icechunk/{dataset_id} (Icechunk SDK)
  • preview a dataset with /datasets/{dataset_id}/thumbnail.png (also a STAC collection asset)

Internal artifacts still exist as a storage and provenance model, but they are not part of the public API contract.

Operational note:

  • /ingestions is the execution and admin-facing surface for ingestion runs
  • /datasets is the canonical managed-data surface for consumers

Main Public Endpoints

  • POST /ingestions
  • GET /ingestions
  • GET /ingestions/{ingestion_id}
  • GET /extent
  • GET /datasets
  • GET /datasets/{dataset_id}
  • GET /datasets/{dataset_id}/download
  • GET /datasets/{dataset_id}/thumbnail.png
  • GET /features
  • GET /features/{collection_id}
  • GET /features/{collection_id}/data.parquet
  • GET /stac
  • GET /stac/catalog.json
  • GET /stac/collections/{dataset_id}
  • GET /zarr/{dataset_id}
  • GET /zarr/{dataset_id}/{relative_path}
  • GET /icechunk/{dataset_id}/{path}
  • GET /sync/{dataset_id}/plan
  • POST /sync/{dataset_id}

1. Discover the configured extent

The configured extent is setup-time Open Climate Service configuration. It is read-only at runtime.

Example:

curl -s http://127.0.0.1:9000/extent | jq

Example response:

{
  "name": "Sierra Leone",
  "description": null,
  "bbox": [-13.5, 6.9, -10.1, 10.0]
}

What this means:

  • bbox is the resolved spatial extent exposed publicly
  • provider-specific hints may exist internally in extent config, but they are not part of the public extent response

2. Ingest a dataset

The public ingestion contract takes:

  • dataset_id
  • start
  • optional end
  • overwrite
  • publish

Raw bbox and country_code are not part of the public ingestion payload — the API resolves them from the configured extent. All datasets are stored as Icechunk stores; there is no format selection parameter.

Example: CHIRPS3

curl -s -X POST http://127.0.0.1:9000/ingestions \
  -H "Content-Type: application/json" \
  -d '{
    "dataset_id": "chirps3_precipitation_daily",
    "start": "2024-01-01",
    "end": "2024-01-31",
    "overwrite": false,
    "publish": true
  }' | jq

Example: WorldPop

curl -s -X POST http://127.0.0.1:9000/ingestions \
  -H "Content-Type: application/json" \
  -d '{
    "dataset_id": "worldpop_population_global2_100m",
    "start": "2020",
    "end": "2020",
    "overwrite": false,
    "publish": true
  }' | jq

Example response:

{
  "ingestion_id": "a7e06c93-ba78-4c74-b772-160927fdb463",
  "status": "completed",
  "dataset": {
    "dataset_id": "chirps3_precipitation_daily",
    "source_dataset_id": "chirps3_precipitation_daily",
    "dataset_name": "Total precipitation (CHIRPS3)",
    "short_name": "Total precipitation",
    "description": "CHIRPS v3 daily precipitation in mm.",
    "itemType": "coverage",
    "variable": "precip",
    "period_type": "daily",
    "units": "mm",
    "resolution": "5 km x 5 km",
    "source": "CHIRPS v3",
    "source_url": "https://www.chc.ucsb.edu/data/chirps3",
    "extent": {
      "spatial": {
        "xmin": -13.52499751932919,
        "ymin": 6.92499920912087,
        "xmax": -10.124997468665242,
        "ymax": 10.02499925531447
      },
      "temporal": {
        "start": "2024-01-01",
        "end": "2024-01-31"
      }
    },
    "last_updated": "2026-04-01T09:03:28.691120Z",
    "links": [
      {
        "href": "/datasets/chirps3_precipitation_daily",
        "rel": "self",
        "title": "Dataset detail"
      },
      {
        "href": "/zarr/chirps3_precipitation_daily",
        "rel": "zarr",
        "title": "Zarr store"
      },
      {
        "href": "/stac/collections/chirps3_precipitation_daily",
        "rel": "stac",
        "title": "STAC collection"
      }
    ],
    "publication": {
      "status": "published",
      "published_at": "2026-04-01T09:03:28.692230Z"
    }
  }
}

What this means:

  • ingestion_id is the handle for the ingestion event lookup route
  • status = "completed" means this branch still treats ingestion synchronously
  • /ingestions is an operational/admin surface, not the main managed-data catalog
  • dataset is a public managed dataset summary, not an internal artifact record
  • extent is realized data coverage, not just the configured bbox
  • links point to the native dataset metadata, native Zarr access, and STAC collection metadata

3. List ingestion runs

GET /ingestions returns ingestion run records for operational and admin use.

Example:

curl -s http://127.0.0.1:9000/ingestions | jq

What this means:

  • this route is for execution lookup and operational visibility
  • it is not intended to replace /datasets as the primary data discovery surface
  • items are ordered from most recent ingestion to oldest

4. Ingestion failure behavior

Ingestion should fail gracefully with a structured API error, not a raw 500 stack trace.

Current behavior:

  • invalid or missing spatial/config inputs return 400
  • dataset/provider execution failures return 502

Example cases:

  • a provider requires a country code and the resolved extent config does not provide one
  • a dataset requires a bbox and no bbox can be resolved
  • the upstream provider fails at download time

Example error response:

{
  "detail": "Upstream dataset download failed: provider timeout"
}

5. Discover managed datasets

GET /datasets is the native managed-data catalog and the main consumer-facing data surface.

Example:

curl -s http://127.0.0.1:9000/datasets | jq

Example response:

{
  "kind": "DatasetList",
  "items": [
    {
      "dataset_id": "chirps3_precipitation_daily",
      "source_dataset_id": "chirps3_precipitation_daily",
      "dataset_name": "Total precipitation (CHIRPS3)",
      "short_name": "Total precipitation",
      "description": "CHIRPS v3 daily precipitation in mm.",
      "itemType": "coverage",
      "variable": "precip",
      "period_type": "daily",
      "units": "mm",
      "resolution": "5 km x 5 km",
      "source": "CHIRPS v3",
      "source_url": "https://www.chc.ucsb.edu/data/chirps3",
      "extent": {
        "spatial": {
          "xmin": -13.52499751932919,
          "ymin": 6.92499920912087,
          "xmax": -10.124997468665242,
          "ymax": 10.02499925531447
        },
        "temporal": {
          "start": "2024-01-01",
          "end": "2024-01-31"
        }
      },
      "last_updated": "2026-04-01T09:03:28.691120Z",
      "links": [
        {
          "href": "/datasets/chirps3_precipitation_daily",
          "rel": "self",
          "title": "Dataset detail"
        },
        {
          "href": "/zarr/chirps3_precipitation_daily",
          "rel": "zarr",
          "title": "Zarr store"
        }
      ],
      "publication": {
        "status": "published",
        "published_at": "2026-04-01T09:03:28.692230Z"
      }
    }
  ]
}

What this means:

  • /datasets is the public native catalog of managed datasets
  • itemType says what the dataset holds: coverage for a raster, feature for a feature collection such as a boundary set. It is the field to filter a listing on, because format sits on the nested version record and only GET /datasets/{dataset_id} returns those. The name and the feature value come from OGC API - Features Part 1, which defines itemType on the collection object; coverage is convention rather than conformance, since OGC API - Coverages is a candidate draft and silent on the field. /datasets is Open Climate Service's own API, so do not infer the rest of an OGC collection object from the borrowed name.
  • variable and period_type are both nullable, and are null together for a feature collection: a boundary set measures nothing and has no temporal axis. Read itemType rather than testing these two for absence.
  • license is an SPDX identifier, or other for a licence that has none — the Copernicus licence, for instance. It is never absent: a dataset whose template declares no licence reports other rather than something that reads as permissive. license_url points at the licence text where one is known, and the STAC collection carries the same information as a rel: license link plus providers for attribution.

  • description carries the dataset template's own prose, and is null when the template declares none. It is where a dataset states what its values actually mean, so it is worth reading before using one: chirps3_precipitation_monthly is a mean daily rate rather than a monthly total. The same text is published as the STAC collection description.

  • items is wrapped in a kind envelope for consistency and self-description
  • dataset items contain public metadata and access links only
  • internal artifact ids, filesystem paths, and downloader implementation details are intentionally omitted

6. Get dataset detail

GET /datasets/{dataset_id} returns the full managed dataset detail view.

Example:

curl -s http://127.0.0.1:9000/datasets/chirps3_precipitation_daily | jq

What this adds beyond the list response:

  • full dataset metadata
  • publication summary
  • slim versions history derived from internal records

The detailed dataset response is where version history belongs. The ingestion response stays as a summary.

7. Access raw Zarr data

/zarr/{dataset_id} bridges the Icechunk store into standard zarr HTTP semantics. It works with any vanilla zarr client including xarray.

Examples:

curl -s http://127.0.0.1:9000/zarr/chirps3_precipitation_daily/zarr.json | jq

/zarr/{dataset_id} is the store prefix; the root metadata is at /zarr/{dataset_id}/zarr.json.

8. Access the Icechunk store natively

/icechunk/{dataset_id}/{path} serves raw Icechunk store files for native SDK access. Use this when you need versioning or want to avoid the zarr proxy layer.

import icechunk, xarray as xr

repo = icechunk.Repository.open(icechunk.http_storage("http://127.0.0.1:9000/icechunk/chirps3_precipitation_daily"))
ds = xr.open_zarr(repo.readonly_session("main").store, zarr_format=3, consolidated=False)

Both endpoints are advertised as assets in the STAC collection:

"assets": {
  "zarr": {
    "href": "https://host/zarr/chirps3_precipitation_daily",
    "type": "application/vnd.zarr; version=3",
    "xarray:open_kwargs": { "consolidated": true }
  },
  "icechunk": {
    "href": "https://host/icechunk/chirps3_precipitation_daily",
    "type": "application/octet-stream",
    "xarray:open_kwargs": { "zarr_format": 3, "consolidated": false }
  },
  "thumbnail": {
    "href": "https://host/datasets/chirps3_precipitation_daily/thumbnail.png",
    "type": "image/png",
    "title": "Thumbnail",
    "roles": ["thumbnail"]
  }
}

The thumbnail asset is present only when the image exists — see section 9.

A pyramided store advertises one extra media type parameter:

"type": "application/vnd.zarr; version=3; profile=multiscales"

This is how a client learns the store has resolution levels — the plain Zarr media type cannot express it, and clients that only render pyramided stores (STAC Browser, via ol/source/GeoZarr) will not offer to display a store without it. The parameter is only added when the store genuinely has a non-empty multiscales.layout; claiming it for a flat store would send a renderer looking for levels that do not exist.

It is matched as a literal, not parsed, so the string is byte-for-byte fixed: same parameter order, one space after each ;. Reformatting it silently disables rendering.

9. Fetch a dataset thumbnail

GET /datasets/{dataset_id}/thumbnail.png serves a small PNG preview of the dataset: one representative 2-D slice, styled with the template's display.colormap, longest side 512 px, missing values transparent.

curl -s -o thumb.png "http://127.0.0.1:9000/datasets/chirps3_precipitation_daily/thumbnail.png"

A thumbnail is written at the end of each ingest and sync run, so a published dataset normally has one. The endpoint 404s only when none has ever been produced — a dataset not yet ingested, or a first render that failed or found nothing to draw. A later run that fails, or whose chosen slice is entirely missing, leaves the previous image in place rather than deleting it, so a served thumbnail can be a run or more stale. The STAC collection advertises the thumbnail asset only when the image exists.

10. Access published STAC collections

Published Zarr-backed datasets are exposed through /stac for discovery.

STAC examples:

curl -s "http://127.0.0.1:9000/stac/catalog.json" | jq
curl -s "http://127.0.0.1:9000/stac/collections/chirps3_precipitation_daily" | jq

What this means:

  • /stac is the public STAC discovery surface for published datasets, raster and vector alike
  • native FastAPI no longer exposes /collections
  • dataset responses include /stac/collections/{dataset_id}

Raster and feature collections are described differently

One catalogue, two representations. STAC has no itemType — a Collection's type is always "Collection" — so a client tells them apart by the extensions and asset media types they declare:

Raster Feature collection
Extensions datacube, zarr, projection table, projection
Describes the data with cube:dimensions, cube:variables table:row_count, table:primary_geometry, table:columns
Data asset zarr (plus icechunk) data, application/x-parquet

A feature collection emits no cube: fields and no Zarr asset, and a raster emits no table: fields. Both carry the same envelope: licence, rel: license link when the licence is a URL rather than an SPDX identifier, providers for attribution, and the self/root/parent links.

Two things worth knowing about a feature collection's document:

  • table:primary_geometry names a column, not a geometry type. One column can hold points and polygons together, which is what an org unit hierarchy looks like: polygons at the upper levels, facility points at the lower ones.
  • It declares no temporal extent ([[null, null]]). Static geometry has no time axis, and a release identifier such as 2026-08-19.0 is not an instant — turning one into a temporal extent would publish a range no feature was observed in.

11. Discover feature collections

GET /features is the inventory of the vector collections this instance holds — org unit boundaries and facility points, stored as GeoParquet. They also appear under /datasets with itemType: "feature"; /features is where the vector-specific facts live.

curl -s http://127.0.0.1:9000/features | jq
curl -s http://127.0.0.1:9000/features/districts | jq

Example response:

{
  "kind": "FeatureCollectionList",
  "items": [
    {
      "id": "districts",
      "name": "District boundaries",
      "description": "District boundaries from the national hierarchy.",
      "license": "CC-BY-4.0",
      "license_url": "https://creativecommons.org/licenses/by/4.0/",
      "attribution": "Ministry of Health",
      "id_property": "orgUnitCode",
      "feature_count": 202,
      "geometry_types": ["Polygon"],
      "primary_geometry": "geometry",
      "crs": "EPSG:4326",
      "version": null,
      "extent": {
        "spatial": {
          "xmin": -13.5,
          "ymin": 6.9,
          "xmax": -10.1,
          "ymax": 10.0
        },
        "temporal": { "start": null, "end": null }
      },
      "last_updated": "2026-09-21T10:14:02.118330Z"
    }
  ]
}

What this means:

  • A record is what makes a collection exist. The listing reads records, never the filesystem, so a GeoParquet file placed in the store directory by hand does not appear. The store directory is not an inbox, and there is no reconciliation step in which disk and index can disagree.
  • id_property names the property each feature is identified by. That value becomes the location column of a DHIS2 or CHAP export, so it must identify exactly one feature — a duplicate is not a dropped feature, it is two features pushing values against one org unit.
  • crs is the CRS the geometry is actually stored in, and is never assumed. A collection in a projected CRS reports its own extent under extent.spatial and the WGS 84 one under extent.spatial_wgs84, the same convention a raster in a projected CRS uses.
  • geometry_types is read from the stored file's own metadata. An empty list means the file declares none, which is "not stated" rather than "no geometry".
  • description, license and attribution come from the collection's template. Until feature templates land (CLIM-926) they read as null and other — license is never absent, and an undeclared licence reports other rather than something that reads as permissive.
  • Unpublished collections are listed. /features reports what this instance holds; publication decides what the catalogues advertise, which is a different question.

Reads of the geometry itself are windowed by a bounding box, and an unwindowed read of a large collection is refused rather than served by accident — a national hierarchy runs to the thousands of features, and pulling all of it should be deliberate.

GET /features/{collection_id}/data.parquet serves the stored GeoParquet for a published collection, as application/x-parquet. It is the href the collection's STAC data asset advertises, and it resolves through the registered record — an unregistered file in the store directory is not reachable through it.

12. /sync

/sync advances an existing managed dataset from its latest local coverage toward a requested upstream period.

Available operations:

  • GET /sync/{dataset_id}/plan?end={period} returns the planned sync action without downloading or writing data
  • POST /sync/{dataset_id} executes the plan when a new version is needed

Implemented behavior:

  • temporal datasets compare the next missing period with the requested or metadata-clamped latest period
  • release datasets compare the current materialized release with the requested or metadata-clamped latest release
  • static datasets return not_syncable
  • preserve stable managed dataset identity
  • use template-level sync_execution
  • append execution reuses the sync planner's source-available delta and writes only periods missing from the committed store
  • rematerialize execution downloads the complete planned contiguous union into a sibling store and publishes it only after validation
  • return the updated dataset view plus structured sync_detail
  • validate realized temporal coverage against the planned contiguous artifact scope; retain the caller's request scope separately as provenance

Current sync constraints:

  • append execution is a delta-download plus canonical rebuild, not in-place Zarr mutation
  • upstream availability is determined by each plugin's periods() method

Release identity

A release dataset's template may declare a release identity, independent of period_type and of temporal coverage. It has two halves:

sync:
  kind: release
  version:
    value: R2025A # the identifier, verbatim as the source publishes it
    authority: worldpop # whose versioning scheme gives that identifier meaning

The pair is the identity — a bare R2025A or 1.0 says nothing on its own, so both halves are required and neither is inferred. value is opaque: OCS never parses, orders or normalises it, because its syntax belongs to the authority. Because it is stored exactly as declared, a blank or whitespace-padded value is rejected rather than trimmed — otherwise " R2025A " would be a different release from R2025A. authority is a stable machine identifier (worldpop, overture, ocs), never a display label — display names live on source and providers — and it is compared exactly, so changing it renames every release under it. Both are capped at 64 characters, and the same rules apply wherever an identity is built: a template at registration, an artifact at materialization, a record on load.

That identity is stored on the materialized artifact and reported on every release plan:

{
  "current_version": { "value": "R2025A", "authority": "worldpop" },
  "target_version": { "value": "R2025B", "authority": "worldpop" }
}
  • sync_detail.current_version — the release the local artifact holds
  • sync_detail.target_version — the release the template currently declares

It exists because a source can republish the same periods under a new revision, which no period comparison can detect. When the two differ, sync rematerializes even though temporal coverage is unchanged — and they are compared as a whole, so the same value under a different authority is a different release. An artifact materialized before its template declared a version has current_version: null; its release is unknown rather than known-equal, so it rematerializes once to establish identity and then settles.

A version is a logical release, distinct from the other two identities on a record: artifact_id is the exact materialization, and provenance is how it was produced. A derived dataset does not inherit a version from its inputs — an openEO result carries no version unless OCS deliberately releases it, as {"value": "1.0", "authority": "ocs"}, with its inputs recorded as provenance.

A template that declares no version keeps the period-based behaviour described above, and a temporal dataset never carries a release identity however many periods it appends.

Rematerializing for a new release never shortens a managed dataset. If the declared release cannot cover what is already held — the source reports nothing, stops short of the current end, or no longer reaches back to the start — the plan reports:

  • action is no_op
  • reason is release_version_unavailable
  • message names which end of the span the source falls short of

Executing that sync returns top-level status: waiting_for_source (not up_to_date, which would claim the dataset is current when a newer release is declared).

Clients should treat waiting_for_source as "retry later": nothing was written, the existing artifact and its version are untouched, and the same request succeeds once the source publishes the declared release. Where the source can preserve existing coverage but reaches only partway to the requested end, sync proceeds and clamps target_end to what is available, reporting target_end_source as plugin_availability.

This guard is only as good as each plugin's periods() reporting. A plugin that enumerates periods without regard to the revision it was configured with — WorldPop's returns a fixed 2015–2030 year list — reports every period as available even for a revision the upstream hub has not published, so advancing such a template to an unpublished revision fails at fetch time rather than returning waiting_for_source.

Configured availability policies:

  • CHIRPS3 daily uses open_climate_service.providers.availability.chirps3_daily_latest_available; this clamps sync targets to the latest complete released source month
  • ERA5-Land hourly uses open_climate_service.providers.availability.lagged_latest_available with a YAML-declared lag_hours
  • WorldPop yearly uses open_climate_service.providers.availability.worldpop_release_latest_available; this can allow configured future projection years

Example dry-run plan:

curl -s "http://127.0.0.1:9000/sync/chirps3_precipitation_daily/plan?end=2024-02-10" | jq

Example execution:

curl -s -X POST "http://127.0.0.1:9000/sync/chirps3_precipitation_daily" \
  -H "Content-Type: application/json" \
  -d '{"end":"2024-02-10","publish":true}' | jq

Manual Test Sequence

These commands assume the API is running on http://127.0.0.1:9000 and that jq is available.

1. Confirm configured extent

curl -s "http://127.0.0.1:9000/extent" | jq

2. Create an initial CHIRPS3 managed dataset

curl -s -X POST "http://127.0.0.1:9000/ingestions" \
  -H "Content-Type: application/json" \
  -d '{
    "dataset_id": "chirps3_precipitation_daily",
    "start": "2024-01-01",
    "end": "2024-01-31",
    "publish": true
  }' | jq

Expected:

  • status is completed
  • dataset.dataset_id is chirps3_precipitation_daily
  • dataset.extent.temporal.end is 2024-01-31

3. Inspect the managed dataset and publication

curl -s "http://127.0.0.1:9000/datasets/chirps3_precipitation_daily" | jq
curl -s "http://127.0.0.1:9000/stac/collections/chirps3_precipitation_daily" | jq
curl -s "http://127.0.0.1:9000/zarr/chirps3_precipitation_daily" | jq

4. Dry-run a CHIRPS3 append sync

curl -s "http://127.0.0.1:9000/sync/chirps3_precipitation_daily/plan?end=2024-02-10" | jq

Expected planning response:

  • sync_kind is temporal
  • action is append
  • reason is new_periods_available_for_append
  • message explains that existing data is present and which missing period range will be downloaded
  • current_start is 2024-01-01
  • current_end is 2024-01-31
  • target_end is 2024-02-10
  • target_end_source is request
  • delta_start is 2024-02-01
  • delta_end is 2024-02-10

append here means Open Climate Service reuses the planner's source-available delta and writes only missing periods to the existing Icechunk store. A rollback snapshot protects the previously committed store until normalization and artifact registration succeed. Progress counts the new periods in this append, excluding already committed history.

If a complete store has lost its artifact record, repeating /ingest reconstructs the record from the store without querying or fetching historical source data. Recovery validates and normalizes the store and honors the request's publication setting.

Interrupted directory rollback can leave a rejected replacement at <store>.failed. The next ingest restores a missing target from <store>.retired and removes the rejected copy once the target exists. If rollback itself fails, the job reports that failure and preserves the recovery branch and snapshot; inspect the reported store paths before retrying. Snapshot reset is skipped if the original repository could not be restored.

Where these timestamps come from:

  • current_start and current_end come from the latest stored artifact coverage
  • target_end comes from the explicit end query parameter, or defaults to today in the dataset-native period format when omitted
  • target_end_source tells you whether target_end came from request, default_today, current_coverage, or was clamped by source availability
  • delta_start is the first period after current_end
  • delta_end is the resolved target period after any availability clamping

If end is omitted, the planner defaults to the current date. For example, calling /sync/chirps3_precipitation_daily/plan on 2026-04-20 after ingesting through 2024-01-31 first resolves the target from today's date, then applies CHIRPS3 availability. Because CHIRPS3 daily sync is configured to use complete released source months, the target may be clamped below today's date and target_end_source will be default_today_clamped_by_availability.

For controlled tests, always pass an explicit end. If the explicit end extends beyond the configured provider availability, target_end_source will be request_clamped_by_availability.

Availability clamping example

If CHIRPS3 currently has complete released data through 2026-03-31 and you ask for:

curl -s "http://127.0.0.1:9000/sync/chirps3_precipitation_daily/plan?end=2026-04-21" | jq

Expected:

  • target_end is 2026-03-31
  • target_end_source is request_clamped_by_availability
  • the sync does not ask the upstream downloader for unavailable April daily data

5. Execute the CHIRPS3 sync

curl -s -X POST "http://127.0.0.1:9000/sync/chirps3_precipitation_daily" \
  -H "Content-Type: application/json" \
  -d '{
    "end": "2024-02-10",
    "publish": true
  }' | jq

Expected:

  • status is completed
  • sync_detail.action is append
  • sync_detail.current_end was 2024-01-31
  • sync_detail.delta_start is 2024-02-01
  • sync_detail.delta_end is 2024-02-10
  • sync_detail.target_end is 2024-02-10
  • the returned dataset.dataset_id is still chirps3_precipitation_daily
  • the returned dataset has a newer version in versions
  • the returned dataset coverage ends at 2024-02-10, even if the upstream downloader cached the full February source month

You can then extend the same managed dataset again:

curl -s -X POST "http://127.0.0.1:9000/sync/chirps3_precipitation_daily" \
  -H "Content-Type: application/json" \
  -d '{
    "end": "2024-02-20",
    "publish": true
  }' | jq

Expected:

  • sync_detail.current_end was 2024-02-10
  • sync_detail.delta_start is 2024-02-11
  • sync_detail.delta_end is 2024-02-20
  • returned dataset coverage ends at 2024-02-20
  • execution may be fast when the provider cache already contains the needed source files

6. Confirm no-op behavior

Run the same plan again with the current end:

curl -s "http://127.0.0.1:9000/sync/chirps3_precipitation_daily/plan?end=2024-02-20" | jq

Expected:

  • action is no_op
  • reason is no_new_period
  • message explains that the requested target is already covered locally

7. Test release-style sync with WorldPop

Create an initial WorldPop managed dataset:

curl -s -X POST "http://127.0.0.1:9000/ingestions" \
  -H "Content-Type: application/json" \
  -d '{
    "dataset_id": "worldpop_population_global2_100m",
    "start": "2020",
    "end": "2020",
    "publish": true
  }' | jq

Plan a later release:

curl -s "http://127.0.0.1:9000/sync/worldpop_population_global2_100m/plan?end=2021" | jq

Expected:

  • sync_kind is release
  • action is rematerialize
  • reason is new_release_available
  • target_end is 2021

Execute the release sync:

curl -s -X POST "http://127.0.0.1:9000/sync/worldpop_population_global2_100m" \
  -H "Content-Type: application/json" \
  -d '{
    "end": "2021",
    "publish": true
  }' | jq

Expected:

  • status is completed
  • sync_detail.action is rematerialize
  • dataset.dataset_id is worldpop_population_global2_100m

8. Observe release identity

Plan again with the end you just materialized:

curl -s "http://127.0.0.1:9000/sync/worldpop_population_global2_100m/plan?end=2021" | jq \
  '{action: .action, reason: .reason, current_version, target_version}'

Expected:

  • current_version and target_version are both {"value": "R2025A", "authority": "worldpop"}
  • action is no_op and reason is no_new_release — matching releases fall through to the period comparison

To see a release change drive a sync, edit the template's sync.version.value (and the plugin's matching ingestion.params.revision) to a published revision and plan again. Expected:

  • action is rematerialize
  • reason is release_version_changed
  • target_end is unchanged from the current coverage end — a version change rewrites the existing span rather than truncating it to today

If the declared revision is not yet published by the source, the same plan returns no_op with reason: release_version_unavailable; executing the sync returns status: waiting_for_source, provided the plugin's periods() reports availability for that revision — see Release identity above.

Summary

The current branch is no longer an artifact-first API.

The public contract is now:

  • ingest with /ingestions
  • discover the configured extent with /extent
  • discover managed datasets with /datasets
  • discover published Zarr-backed datasets with /stac/catalog.json
  • access raw native data with /zarr/{dataset_id}

Artifacts remain internal because Open Climate Service still needs storage and provenance records behind ingestion and publication, but those internals are no longer exposed as first-class public resources.