R&D/ /7 min read
ForestLayer: a geospatial activity schema for forests
We kept hitting the same wall while making forestry computable across an entire territory. This is what we found, what we built, and the design decisions we'd defend.
The wall we hit
The hard part was never the science or the model. It was that every source arrived with its own format, its own geometry and its own notion of time.
A field inventory, a canopy layer, a carbon project and a national system all describe the same tree in mutually incompatible ways. So every new question became a new integration: fragile glue, written from scratch, thrown away, written again.
We recognized the shape of it. This wasn't a missing feature. It was a missing data contract.
Why we stopped modeling state
Forest data is almost always stored as state: an inventory table per year, a canopy raster per date, a carbon ledger per project. Each with its own key and its own time semantics.
With state, every question about change turns into a bespoke join across sources that share nothing. That's the classic join problem — dimensional modeling bakes one report at a time, and each new question demands a new pipeline.
The deeper issue is that state is lossy. You can rebuild an inventory as of any date from a stream of events. You cannot recover the events from a snapshot. We were storing derivatives and throwing away the source.
Why an activity schema
Activity Schema inverts the relationship: one append-only ledger where each row records who did what, when, and with what context, and relationships get resolved at query time through temporal joins.
Four reasons it fit forests:
- Forest data is already events. A measurement, a satellite pass, a harvest, a fire, a credit issuance. Storing them as state destroys information.
- State derives from events, not the other way around. The ledger reconstructs any date; a snapshot reconstructs nothing.
- MRV is a temporal question by definition. What changed, between which dates, observed by which source. A ledger answers that natively.
- Audit needs provenance per fact, not per table. Every row carries its source, its method and its instant.
The core contract
We kept the 2.0 spec columns and added the dimension a forest can't do without: where. Geometry lives on the activity, not on the entity, because boundaries change — a plot gets subdivided, a stand regrouped — and the event has to preserve the geometry that was current when it happened.
CREATE TABLE forest_stream (
activity_id text NOT NULL, -- unique event id
ts timestamptz NOT NULL, -- when it happened (UTC)
entity_id text NOT NULL, -- tree, plot, stand, project
entity_type text NOT NULL,
activity text NOT NULL, -- verb_noun: measured_dbh, observed_canopy
geom geometry(Geometry, 4326) NOT NULL, -- where, as of ts
observed_at timestamptz, -- observation instant (bitemporal)
feature_json jsonb NOT NULL DEFAULT '{}', -- variable context
source text NOT NULL, -- sensor, protocol, operator
link text, -- originating STAC item / asset
PRIMARY KEY (activity_id, ts)
) PARTITION BY RANGE (ts);
Two decisions are worth defending.
ts vs observed_at. A GEDI pass observes canopy on day 1 and gets processed on day 12. Without both stamps, a later re-verification can't reproduce what the system knew on a given date — which is exactly what an audit asks for. Bitemporality isn't academic here; it's the difference between a defensible record and a plausible one.
link instead of the pixel. Rasters stay out of the ledger. What goes in is the event plus a pointer to the asset, typically a STAC Item. The ledger stays small and provenance stays intact.
Why we run it on PostgreSQL, PostGIS and JSONB
The model demands three things at once: spatial predicates, temporal queries over very large tables, and heterogeneous context per activity type. Almost no engine gives you all three. An analytical warehouse has no first-class geometry. A classic GIS has no event model. We evaluated that split and built on PostgreSQL with PostGIS and JSONB, which is where the three meet. It's the stack we run in production.
PostGIS gives geometry as a native type, with GiST indexes and predicates (ST_Intersects, ST_DWithin) that let you ask "what happened inside this polygon" without leaving SQL. Canonical CRS is EPSG:4326 and projection happens on read, never on write — reprojecting at ingest is an irreversible loss.
timestamptz plus declarative range partitioning makes the volume tractable. A satellite stream over a territory produces hundreds of millions of rows; partitioning by time turns a one-year query into a scan of a few partitions.
JSONB handles context. Each activity type carries different features — measured_dbh has diameter and species, issued_credits has standard and tonnage. Forcing those into columns gives you either a wide table full of nulls or a migration per new protocol. The core stays small and typed; the variable part lives in JSONB and projects to columns on read.
The cost of JSONB is real and worth stating: you lose write-time validation. We compensate by validating feature_json against a per-activity JSON Schema at ingest, so flexibility doesn't decay into dirty data.
Indexing
The strategy falls out of the workload: append-only writes ordered by time, reads filtered by activity, area and time window.
-- append-only and time-ordered: BRIN costs kilobytes where a B-tree costs gigabytes
CREATE INDEX ON forest_stream USING brin (ts) WITH (pages_per_range = 64);
-- spatial predicates
CREATE INDEX ON forest_stream USING gist (geom);
-- the spec's recommended access pattern, plus the entity
CREATE INDEX ON forest_stream (activity, ts DESC);
CREATE INDEX ON forest_stream (entity_id, ts DESC);
-- containment lookups on features: jsonb_path_ops is smaller and faster for @>
CREATE INDEX ON forest_stream USING gin (feature_json jsonb_path_ops);
BRIN is the piece that changes the economics. On a table whose physical order follows time, a block-range index gives near-free temporal pruning and frees the memory budget for the spatial GiST, which actually needs it.
Spatio-temporal joins
This is where the payoff shows up. Questions that used to be integration projects become one query against one ledger.
Last canopy observation before each harvest, on intersecting geometry:
SELECT h.entity_id, h.ts AS harvest, o.ts AS last_observation,
o.feature_json->>'canopy_height_m' AS prior_height
FROM forest_stream h
CROSS JOIN LATERAL (
SELECT o.ts, o.feature_json
FROM forest_stream o
WHERE o.activity = 'observed_canopy'
AND o.ts < h.ts
AND ST_Intersects(o.geom, h.geom)
ORDER BY o.ts DESC
LIMIT 1
) o
WHERE h.activity = 'logged_stand';
State reconstructed as of a date, with no snapshot table:
SELECT DISTINCT ON (entity_id) entity_id, ts,
(feature_json->>'agb_t_ha')::numeric AS biomass
FROM forest_stream
WHERE activity = 'estimated_biomass'
AND ts <= '2025-12-31'
ORDER BY entity_id, ts DESC;
Plot-level due diligence (the EUDR pattern): intersecting a declared polygon with every loss event after a cutoff date is an ST_Intersects plus a range filter — the same query that feeds a carbon report, over the same rows.
What it changed
- A new activity is rows, not a migration. Onboarding a sensor or a protocol stops being schema work.
- Provenance per row. Every fact knows where it came from and when it was known. Verification queries instead of reconstructing.
- One ledger, many consumers. MRV, compliance, dashboards and models read the same layer instead of each keeping a diverging copy.
Where the activity schema solved business modeling inside the warehouse, ForestLayer solves it for territory: the forest as a stream of geolocated, verifiable, composable activities.
Fewer moving parts is the whole point
The argument we'd make to another data team isn't that the ledger is more powerful. It's that it's smaller, and that's precisely why it handles more.
In a state-modeled stack, cost scales with pairs. N sources × M questions means roughly N×M pipelines, because each question needs its own join across its own subset of sources, and every source change ripples through every model downstream of it.
On a ledger it scales with sums. N ingest paths plus M queries. Sources and questions stop being coupled: adding a source doesn't touch any query, and adding a question doesn't touch any source. That's the difference between a dependency graph you maintain and a contract you write once.
The practical consequences, side by side:
| Task | State-modeled stack | Activity ledger |
|---|---|---|
| Add a data source | New table, new join logic per consumer | Map it to activities; write rows |
| Answer a new question | New pipeline, new model, new deploy | New query against the same table |
| Support a new protocol or standard | Schema migration, backfill | New activity names and features |
| Reconstruct a past state | Hope a snapshot exists for that date | DISTINCT ON with a ts bound |
| Trace a fact to its origin | Reverse-engineer the pipeline | Read the row: source, method, instant |
| Change a plot boundary | Break historical comparability | Geometry is per-event; history holds |
Speed of delivery follows from the shape, not from tooling. Most of the time we used to spend was schema work and glue — and on a ledger that work mostly doesn't exist. What's left is mapping a source to activity names and writing SQL.
The simplicity is also what makes it auditable. A verifier can read one table definition and understand the entire data model. That is not true of any pipeline graph we've ever inherited.
An activity schema for physical assets
The part that generalizes: a tree is just one kind of physical asset.
The original activity schema models a customer doing things over time inside a warehouse. What we needed was an entity that exists somewhere, whose location and shape can change, observed by instruments that disagree, and whose history has to survive an audit. A forest stand fits that description. So does a power line, a parcel of farmland, a water catchment, a stretch of infrastructure.
The physical economy has the same modeling failure everywhere: assets stored as state, snapshots overwriting each other, geometry treated as a property of the record rather than of the moment. Whenever the question is "what changed here, when, and who says so", the shape of the answer is the same — an append-only ledger of geolocated, timestamped, bitemporal events, with a small typed core and a flexible feature payload.
ForestLayer is that contract applied to forests. The contract itself isn't forest-specific, and we don't think it should be.
Where this is going
ForestLayer is live, with limited early access. The open questions are the ones any young protocol has: what belongs in the core versus an extension, how to version without breaking compatibility, and how to govern the schema openly as adoption grows.
One item on the roadmap follows directly from the data contract: automatic settlement via smart contracts. When every event is verifiable, geolocated and timestamped, payment for results can fire against the event itself rather than against a report written afterward. We treat it as future work until it runs in the field.
If you build forest monitoring, MRV or climate finance, the path in is to integrate and help define the standard from the inside.
References
- Hansen, M. C., et al. (2013). High-Resolution Global Maps of 21st-Century Forest Cover Change. Science, 342(6160), 850–853. doi.org/10.1126/science.1244693
- Dubayah, R., et al. (2020). The Global Ecosystem Dynamics Investigation (GEDI). Science of Remote Sensing, 1, 100002. doi.org/10.1016/j.srs.2020.100002
- Butler, H., et al. (2016). The GeoJSON Format. RFC 7946, IETF. rfc-editor.org/rfc/rfc7946
- Radiant Earth Foundation. SpatioTemporal Asset Catalog (STAC) Specification. github.com/radiantearth/stac-spec
- Elsamadisi, A. Activity Schema 2.0 (Apache-2.0). activityschema.com
- FAO (2025). Global Forest Resources Assessment 2025. fao.org
- FAO. Open Foris. fao.org/in-action/openforis



