Cartos — Comprehensive Guide
Sovereign geospatial vertical-AI platform. Last updated
2026-08-01 — two updates, most recent first. Map legibility +
the GFM arc's close. (1) Catalog layers now render risk
as risk — any layer whose features carry
risk_score + risk_tier is automatically drawn
as severity (tier colour, tier-graduated size, pulsing target rings,
optional name labels), and every feature popup is rebuilt from a 20-row
key: value dump into a shaped card with a risk hero band,
score-driver bars and grouped, unit-formatted attributes. Both are
auto-detected from the data, not configured per layer,
so Guardian's 50 strike targets and its 2 500 collateral-risk POIs are
one code path and any future layer adopting the same field names
inherits it. ⚠ The work uncovered a silent renderer bug — a label layer
without an explicit text-font falls back to a font
OpenFreeMap does not serve, drawing nothing at all with
no error — found only by executing the real layers through a headless
MapLibre harness after tsc and vite build were
both already clean. Live on prod (d4805bc); see §9.12. (2)
The GFM arc completed 2026-07-30 → 08-01 across nine
PRs (#136–#148) for under $2 000 of model spend: a
frozen open encoder + embedding index + verified operators free
multispectral fusion measured at +0.067 accuracy and
stored beside the vector rather than concatenated into it,
dataset supersession so a re-embed hides its
predecessor instead of double-serving it, and a cost
gate that is live but deliberately inert (§9.11).
⚠ Stage 1 was measured and declined — the $15–35k
continued-pretraining step had a documented falsifier that nobody had
ever tested, so the frozen baseline was measured on CPU for nothing;
it fired, and the money was not spent. Those sections
were written at the time; this changelog entry is the header catching up
to them.
Earlier: 2026-07-29 — five updates, most recent first.
Security + operability pass, all shipped to main
and deployed to prod (verified). (1) Migration
0087 closes an RLS gap on the three Cartos Harvest
tables — crop_calendars,
field_parcels and farmer_registry each
described themselves as tenant-scoped but carried no row-level-security
policy, leaving only the app-layer WHERE filter as the
boundary; farmer_registry holds PII (name
/ national_id / phone). The fix pairs ENABLE + FORCE +
tenant_team_isolation with a
cartos-data/src/harvest.rs change so all nine
set_tenant_session call sites forward the caller's real
groups instead of &[] — ⚠ the two halves must
ship together (see §7). Verified on prod:
relrowsecurity and relforcerowsecurity true on
all three, zero RLS-violation lines in the API log. (2) The
Africa cadastre ingest is now a saved, credentialed, re-syncable
connector in Admin → Integrations (migration 0088,
connectors/parcel_fabric.rs on the existing
RealConnector seam) — credentials stay server-side and the
idempotent-upsert + human-review-preservation guarantees hold across
re-syncs, turning a one-shot demo call into an operational feed (see §8.4). (3)
Admin console usability — module/feature filters on the
Modules and Features tabs, and a Test button with a coloured status
badge on Catalog and Integrations (see §7.4). (4)
Build-pipeline hygiene — the CI gate no longer
duplicates itself on every push to main, no longer rebuilds
on documentation- or website-only commits, and the mobile iOS build is
now run on demand rather than on every mobile push. Earlier today:
Cartos Africa's africa-parcel module is wired to a
real cadastral store — merged to main and
deployed + verified on prod (migration
0086 applied; live-ingest tested end-to-end).
africa-parcel went from a surface-only stub to a
source-agnostic, LADM-aligned (ISO 19152) parcel + party store
(africa_parcels + africa_parcel_parties) that
two ArcGIS ingest adapters map INTO, so the same review/scope/export
path serves either source: parcel_fabric maps Esri's
documented Parcel Fabric information model
(Name/StatedArea/ CreatedByRecord/RetiredByRecord/IsSeed/GlobalID —
ready to point at a real ArcGIS Enterprise fabric, though no public one
exists to demo against), and featureserver is a
config-driven field map for any generic ArcGIS parcel/ cadastre service
— this is the adapter behind the live demo, verified
end-to-end on prod against a real public Esri Tax_Parcels service (8
parcels ingested → summary → LADM export, then cleaned up). New
ParcelFabricPanel (admin ingest form, load-in-view, colour
by tenure/lifecycle, click-to-review, LADM GeoJSON export) and five
routes under /api/africa/parcels/*. This is the
ingest half of the module's parcel fabric — an existing
authoritative cadastre flowing into the LADM store; the SAM2
segmentation pipeline that generates parcel candidates from
imagery
(prompts/map-africa/ 07-parcel-fabric-development.md) is
still the other, not-yet-built half. See the new subsection in §8.4. Earlier:
2026-07-28 — three updates, most recent first. Cartos Harvest is
merged to main (tag
cartos-harvest-v1) — the ninth edition is no
longer branch-only; every reference to "branch harvest, not
yet merged" below is now historical (and as of 2026-07-29 Harvest is
deployed and live at https://harvest.cartosatlas.com,
with its own Caddy vhost and Keycloak redirect origin like every other
edition — see §8.7). The same
merge ships persisted derived products (M03) for
Harvest: any Crop Type / Health / Yield / Water / Risk panel run can
tick "Save to products" to persist its result as a lineage-tracked
derived_dataset row (reuses migration 0013 —
no new migration), browsable in a new Saved Products
panel (SavedProductsPanel.tsx) and queryable by a new
harvest_products agent tool
(GET /api/harvest/products + /products/:id);
and the farmer registry (migration 0085)
landed, completing the harv-boundaries module with a
FarmerRegistryPanel.tsx + two more agent tools
(parcel_fabric_summary, farmer_lookup). A
ride-along platform fix in the same merge: the map agent's Anthropic
response parser now round-trips Opus 5
thinking/redacted_thinking content blocks
verbatim (llm.rs's ContentBlock enum), fixing
intermittent 502s on reasoning turns — engine-wide, not
Harvest-specific. Earlier the same day: two more updates. First, the
ownership/licensing framing was reworked per the
Owner's direction: Cartos — the engine and every edition — is
the sole and exclusive property of Steven P. Santovasi (the
Owner); Solution Strategy Partners (SSP) holds
a single unrestricted, perpetual, irrevocable, worldwide, royalty-free,
sublicensable master license to all of Cartos;
downstream edition instances (Cartos Atlas, Cartos Grid, …) are
non-exclusive sublicenses under SSP's master license,
not direct grants from the Owner. See the reworked table in §1 and EarthWatch3D-PRD/PRD.md
§20 for the full licensing language. Second, §8.7 was added
documenting Cartos Harvest — STAC-native crop
intelligence (crop type, health, yield, water/drought, field
boundaries, agri-risk) for ministries of agriculture,
agri-insurers/lenders, and extension services, anchored on Kenya +
Nigeria and reusing Cartos Africa's country-scope and foundation-layer
fabric. Earlier: 2026-07-26 — Cartos Sentry, the reactive
crisis-monitoring edition, is merged to main and LIVE at https://sentry.cartosatlas.com
— the eighth live Cartos edition (alongside the newly-documented
seventh, Cartos Guardian, live at https://guardian.cartosatlas.com
since 2026-07-22). A new §8.6 covers
Sentry in full, and a new §8.5
closes the gap and documents Guardian for the first time. Cartos
Guardian is the proactive counterpart — UAE
critical-infrastructure protection, Strait-of-Hormuz-focused, with a
transparent per-site strike-risk model (0-100 score from criticality +
Hormuz proximity + category weight) ranking 103 curated sites, a
POI-collateral layer, grid/strategic-vector overlays, and live AIS/ADS-B
traffic — 5 modules
(guard-command/guard-energy/
guard-utilities/guard-emergency/guard-defense),
theme Red. Cartos Sentry is the
reactive counterpart — optical + SAR + thermal
satellite change detection for crisis monitoring: watch Guardian's
highest-risk assets → autonomously detect an event → open a scored
incident with cross-modality confidence + before/after evidence → report
+ alert — 3 modules
(sentry-command/sentry-detect/sentry-incidents),
theme Camo. The Guardian → Sentry handshake is
the moat: strike-risk score seeds Sentry's monitoring-watchlist
priority (P1/P2/P3), which sizes the commercial-tasking spend a P1
incident can defensibly justify. Sentry ships the platform's
first background ingestion loop
(sentry_monitor.rs — an opt-in tokio interval daemon that
autonomously screens the watchlist, resolves before/after epochs via
STAC, runs real model-free detectors, fuses cross-modality confidence,
and raises + dispatches incident alerts with dedup, all with no
fabricated certainty — a hard "mock guard" means a degraded
model sidecar never raises a fake incident), the platform's
first incident state machine + outbound alert dispatch,
and the platform's first commercial-data-tasking
connectors (cost-visible Umbra/Capella/ICEYE/Maxar/Planet
vendor cards + a live Umbra open-archive search, for the crisis-hour
reactivity open imagery can't deliver). Both editions are
author-owned reference instances, not yet licensed
externally, live on their own sovereign subdomains exactly like every
other edition (hostname-detected, dedicated Caddy vhost + Keycloak
redirect origin). Earlier: 2026-07-18 — Cartos Africa (the One
Africa edition) is merged to main and LIVE at https://africa.cartosatlas.com
— the sixth live Cartos edition, standing alongside
Atlas/Grid/Tower/Milepost/Blueprint on its own sovereign subdomain
(dedicated Caddy vhost + Let's Encrypt cert + Keycloak redirect origin;
hostname → edition detection selects it automatically, same as every
other edition — reachable directly, not only via
?edition=africa). A new §8.4 covers it in
full: a continental-scale Government edition for
African governments, national mapping agencies (NMAs), and ministries —
an operational layer over a free/open national-basemap
substrate — 9 modules (africa-command + eight use-case
departments: Food Sustainability & Agriculture, Health &
Immunization, Border Management, Port Management, Land Use & Land
Cover, Parcel Fabric Development, Flood & Climate Risk, Settlement
Management) on the shared IndustryModule surface
(cartos-web/src/modules/africa/, edition preset
africa, default theme Desert); a
country-scope system
(lib/country-scope.tsx → CountryScopeContext,
an iso3/adm1 filter that reaches map layers,
KPIs, dashboards, and agent answers; CountryScopePanel HUD
chip with AU-region quick-groups — North/West/Central/East/Southern —
plus a sub-national ADM1 drill-down once exactly one
country is selected; geoBoundaries ADM0 — all 54 AU member states — and
a priority-country ADM1 set, 147 units across
Kenya/Nigeria/Egypt/Ghana/Ethiopia/South Africa); a
foundation-layer fabric
(FoundationLayersPanel + a typed
lib/africa-data-catalog.ts registry of continental
imagery/terrain/land-cover/ boundary sources grouped by category —
keyless/open entries render live, premium/derived entries list as
license-badged reference rows; normative license posture: geoBoundaries
not GADM, GLO-30 DSM not FABDEM, Esri imagery view-only, commercial
sources never client-keyed); the Continental Command
dashboard (features/africa/AfricaCommandDashboard
— initiative KPIs, a country- onboarding board, cross-module roll-up
charts, honours the current scope); real data loaded into all 8 use-case
modules via OpenStreetMap, Natural Earth (public domain), and curated
anchors, with truthful caveats — africa-parcel is a
built-up-extent reference, not legal parcels, and the
flood module's raster/ COG/SAR layers are license-flagged references
pending a COG render path (real counts + full caveats in §8.4); and the
new africa_scope agent tool (resolve
country names / ISO3 codes / an AU region / whole-continent → the
canonical scope_iso3 list) plus a countries
filter added to query_catalog_layer (§9.6)
so "ask the map" answers stay inside the selected scope. Real data
loaded in prod across all 9 modules — 7,379 health facilities · 3,011
food/market points · 1,928 built-up-extent parcel-reference polygons ·
1,285 settlements · 435 ports · 406 land-cover polygons · 124
flood/hydrology features · 14 border crossings · 201 command boundaries
(54 ADM0 + 147 ADM1) — with symbology applied (see the real-counts table
in §8.4).
Engine-level ride-alongs shipped the same day (apply
platform-wide, not just to Africa): the "Satellite · Esri"
basemap is now framed as a global high-res imagery
option in every module's basemap pulldown, relabelled from a
UAE-specific name (§9.4); the
agent panel and the External Data Sources
drawer now scope to the actual current module
(agentFocusFromPath / moduleIdFromPath in
shell/AppShell.tsx) instead of always resolving to Smart
City on Grid/industry/Africa routes (§7); and every
floating popup panel now follows the active theme accent — a
27-file CSS sweep plus two new
--accent-rgb/--bg-rgb custom properties so
panel borders, glows, resize chips, and glass backgrounds recolour with
the platform theme instead of hardcoding holographic cyan (§9.4). Also shipped
2026-07-18, after the merge (engine-wide, every edition): the
header edition switcher is now a pulldown
(shell/EditionSwitcher.tsx — a single accent-dot + name +
caret trigger opening a menu, click-outside/Escape to close) instead of
a horizontal pill row, freeing header width so the module-nav tabs fit
on one row; Cartos Africa is in the list. Catalog points now render a
holographic 3-layer symbology stack — a blurred glow
halo in the feature's data-driven color, a bright core "orb" with a
rim-light stroke, and an animated sonar-pulse selection ring — instead
of a flat circle (lib/catalog-layers.ts, opt out per layer
via style.point.halo/pulse), plus
feature-type icons drawn on top from zoom 9 — medical
cross, anchor, cart, tree, gate, droplet, bolt, graduation cap —
auto-classified from each feature's own attributes
(amenity/harbour/
natural/boundary/power/…) with
zero per-layer config (lib/catalog-icons.ts; migration
0061 gives Africa's border crossings an explicit gate
icon). And session vectors now always draw above
rasters: a new raiseVectorsToTop re-raises every
cat-* vector layer above whatever raster was just added —
foundation, satellite, GIBS, STAC, ArcGIS, or a basemap swap — via a
self-terminating styledata listener, and the Compare tool's
non-"current" panes now copy the session's vectors on top of the
compared raster too, matching the main map (§9.6).
Earlier: 2026-07-13 (a new §9.10 covering
Mapillary crowd-sourced street-level imagery, shipped
to main today: a new portable capability feature
mapillary (crates/cartos-core/src/catalog.rs
FEATURES, seventeenth entry) attachable to any module like
Overture. The web overlay
(features/mapillary/MapillaryOverlay.tsx,
lib/mapillary.ts) pulls Mapillary's public
mly1_public MVT vector tileset straight into a MapLibre
vector source — no server of ours — as two layers:
drive-path mapillary-sequences and
mapillary-images photo points (zoom ≥ 14); clicking a photo
point resolves it through the Mapillary Graph API
(graph.mapillary.com/{id}) and shows the dated street-level
image + compass heading in a floating popup, with attribution and an
"Open in Mapillary" link. Panel controls: coverage on/off, opacity, a
"captured since" year slider, and a 360°-panoramas-only toggle, all
persisted per panel; a new camera-icon mapillary kind in
shell/PanelDock.tsx. Default-on in the native trio (Smart
City, Emergency Services, Maritime) and every industry-edition
department module (Cartos Tower/Milepost/Blueprint via
IndustryModule) — not Cartos Grid, where it's an admin
add-on like any cross-pollinated feature. A new cross-module agent tool
mapillary_street_imagery
(crates/cartos-agent/src/builtin/mapillary_street_imagery.rs,
register_mapillary) searches the Graph API by bbox or
point+radius for "is there street imagery of this intersection?"-style
questions, with
pano_only/since_year/limit
filters, returning configured: false gracefully when no
server-side token is set. External, community-contributed data —
© Mapillary contributors, CC-BY-SA — a reference overlay, not sovereign
ground truth (that's Cartos Field); labelled as such in the UI.
Needs a free client token (VITE_MAPILLARY_TOKEN, web) and a
server token (MAPILLARY_TOKEN, agent tool) — the panel
shows a setup notice without one. New backfill migration
0044_enable_mapillary.sql (deploy needs a
cartos-api rebuild). Cross-referenced from the
feature-registry table in §7.4, the agent tool-registry
list in §7, the
data-sources table in §10, and the roadmap in
§13.7. Earlier:
2026-07-11 — a new §9.9
covering the map-tools UI + navigation suite shipped to
main, live across every native/Grid/industry-edition
module: a shared "widget kit" holographic HUD design
language (features/widgets/widget-kit.css,
wk-* classes, theme-token-driven via
color-mix() so a platform theme recolours every map-tool
panel) restyling Measure/Time of Day and styling the two new tools; a
new Draw & Annotate capability feature
(draw, features/draw/DrawOverlay.tsx,
migration 0043) — Select/Point/Line/Polygon/Rectangle with
per-feature stroke/fill/width + optional label, a feature list, Clear
all, Copy/Download GeoJSON, localStorage persistence,
default-on in Smart City/Maritime/Emergency Services; and a new
always-on Navigate compass rail
(features/navigate/NavWidget.tsx, replaces MapLibre's
default zoom/compass/geolocate/fullscreen controls in every
native/Grid/industry module) — a collapsible, drag + resizable HUD with
a live compass rose bound to bearing/pitch/zoom (click-to-reset-north),
a pitch slider, geolocate, fullscreen, a globe/mercator toggle, a
multi-CRS coordinate readout
(lib/coords.ts, new proj4+mgrs
deps: WGS84 dec/DMS, Web Mercator, UTM, MGRS, 21-zone CONUS State Plane
— the map itself still only renders Mercator/Globe, MapLibre's limit, so
CRS drives the readout not the projection) for both map-centre and
cursor, and a toggleable UTM/MGRS grid overlay
(lib/grid-overlay.ts, Off·UTM·MGRS) with zone/GZD
boundaries, 100 km squares, zoom-adaptive spacing, vertical easting
labels, and colour/line-style/thickness controls;
lib/nav-controls.ts now only adds the metric+imperial scale
bars. Every floating map surface is now uniformly drag +
resizable — DockablePanel (already was), the new
Navigate rail, CatalogLegend (now closable), and every
showFloatingPopup window including the ArcGIS identify
popup (the last native maplibregl.Popup converted).
Cross-referenced from the feature-registry table in §7.4 (now sixteen features) and
the roadmap in §13.7. ⚠ Ops gotcha
(already fixed, noted for the next one): a new module
FeatureDef needs a tenant_features backfill
migration (mig 0042 for the map-tools set,
0043 for draw) + an API restart before it
surfaces for tenants seeded before the feature existed —
load_entitlements reads the DB enable-state rows, not the
compile-time catalog defaults. Earlier: 2026-07-10 — Time of
Day (features/time-of-day/TimeOfDayOverlay.tsx,
lib/time-of-day.ts), a dependency-free NOAA/SunCalc-style
solar-position engine that drives setLight 3D building
shading, hillshade illumination direction/colour, and
setSky day/twilight/night gradients from a solar-time +
date slider (sunrise/golden/noon/sunset presets, Play + speed, a
hillshade-contrast Shadows approximation — real deck.gl cast shadows are
a noted TODO); and Measure
(features/measure/MeasureOverlay.tsx, extended
lib/measure.ts), the measure tool upgraded to a 3D-aware
Distance/Area/Height panel with
map.queryTerrainElevation-derived slope length, a
per-segment 3D/horizontal/Δh/bearing table, and an m/ft toggle,
degrading gracefully to 2D with no terrain — both are
PanelDock rail items, default-on in Smart
City/Maritime/Emergency Services via
crates/cartos-core/src/catalog.rs (no new migration
needed), and cross-referenced from the feature-registry table in §7.4 and the roadmap in §13.7. Earlier: a
dedicated §8.3
write-up of the three Cartos industry editions —
Cartos Tower (Airport), Cartos
Milepost (Transportation/DOT), and Cartos
Blueprint (Infrastructure PM/AEC), built 2026-07-07 and now
live on their own subdomains
(tower./milepost./blueprint.cartosatlas.com,
verified against deploy/Caddyfile.prod) — closing the gap
flagged in the 2026-07-09 refresh (this guide covered the
catalog/dashboard work over these editions but never described the
editions themselves). Covers the 29-module lineup read straight from
cartos-web/src/modules/industry/modules.ts (Tower 10 /
Milepost 10 incl. an Executive Overview portfolio surface / Blueprint 9
incl. a Portfolio View), the shared config-driven
IndustryModule surface + IndustryDashboard,
the edition.ts hostname-detection preset system extended to
tower/milepost/blueprint, the
engine+editions+instances table in §1
extended to all five live editions, and the per-project/portfolio-wide
access-grant RBAC that backs Milepost/Blueprint's scope model
(migrations 0036/0037, crates/cartos-data/src/industry.rs +
crates/cartos-api/src/industry.rs,
features/industry-access/ProjectAccessPanel.tsx, and its
modules/admin/ProjectAccessConfig.tsx User-Admin console
tab). §2 now also lists all five
live app subdomains, not just the marketing site. Earlier: the
operational-data / admin-layer / ask-the-map epic — ALL 4 phases shipped
to main and live on cartosatlas.com: Phase 1 a DB-backed
Datasource + Layer Catalog (migrations 0038/0040
catalog_features) scoped by
tenant/edition/module/group/role/user-type, with a tech_admin
CatalogConfig console and a portable
cat--prefixed reconciler +
CatalogLayersOverlay mounted in every MapLibre-native
module; Phase 2 real OpenStreetMap data (via
tools/osm_to_geojson.py + Overpass, ingested with the new
cartos-cli load-geojson region loader) live in all 7
MapLibre-native module families — Cartos Grid/Charlotte (293
substations, 1,278 transmission spans), Smart City/Abu Dhabi (95
hospitals, 201 schools), Maritime/Gulf (94 marine facilities), Emergency
Services/Abu Dhabi (56 stations), Cartos Tower/DFW (265 airfield
movement segments), Cartos Milepost/Indianapolis (1,537 motorway
segments), Cartos Blueprint/Las Vegas (363 construction sites) — plus a
live bottom stats bar and GET /api/catalog/kpi; Phase 3 an
attribute-driven symbology engine (lib/catalog-style.ts —
categorical/graduated color + size, dashed lines, polygon fill/outline)
with an in-panel style editor + on-map legend, and real 3D (polygon
fill-extrusions + deck.gl 3D point columns via
lib/catalog-3d.ts); Phase 4 two cross-module agent tools,
catalog_layers + query_catalog_layer ("ask the
map" — attribute filter, proximity, group-and-count, coordinate sample,
all scope-enforced) over any catalog layer the caller can see.
Alongside: per-edition full-screen dashboards — every
Grid + industry-edition department module now has its own
/<moduleId>/dashboard route
(shell/DashboardPage) that returns to its own map, not a
hardcoded Smart City; a new reusable
CatalogDashboard (ECharts charts,
auto-classified attribute breakdowns, computed alerts, reorderable
cards, theme switching) is the standard dashboard body for the industry
editions (Tower/Milepost/Blueprint), falling back to representative KPI
tiles where a module has no catalog data yet; Grid keeps its own
chart-rich KPI dashboard bound to live /api/grid data; and
a new CatalogKpiStrip pre-stocks the
native module dashboards (Smart City, Emergency, Maritime, Grid) with
real catalog feature counts. Two shell/auth fixes: the native Government
KPI strip no longer leaks onto Grid/industry-edition modules
(nativeModuleFromPath — it previously defaulted to Smart
City's Abu Dhabi data and a "Dashboard" link that bounced back to
/smart-city); the Grid/industry catalog stats bar
(MapStatsBar) moved off the map into the shell's bottom
footer slot alongside the native KPI strip; and the Keycloak realm's
login-page displayName was fixed from the retired "Cartos
Atlas" to "Cartos". Earlier: hard rebrand: this guide carries no
affiliation with the former parent org — every prior parent-org name and
the standalone former-partner-integration name are gone from this doc
and its rendered artifacts; the retired external integration module and
its dedicated embedding-doc reference dropped from the module catalog
table and nav table; Government edition now referred to only as
Cartos Atlas. Earlier: deployment-readiness hardening
Phases A–D: JWKS live refresh loop +
CARTOS_REQUIRE_TENANT_CLAIM, migration 0033 RLS on 5
previously-uncovered tables + opt-in cartos_app
non-superuser DB role, CI gate ci.yml +
cargo test 4/4, DB pool 8→50 + entitlement cache + shared
HTTP clients + router timeout/concurrency limits + 15 s KPI cache,
FlatGeobuf debounce + code-splitting + tab-visibility polling,
GET /readyz health probe + compose healthcheck,
iframe-bridge origin pinning, JSON logs + request-ID, migration 0034
dedup guard. Earlier: PR #123 — real connector adapter seam:
crates/cartos-api/src/connectors/ —
RealConnector trait, is_real() config-driven
dispatch, real_sync() registry;
connectors/esri_un.rs reference implementation verified
live against a public ArcGIS Feature Server; cartos-data
additions: get_config + upsert_real_assets;
grid::sync now routes to real adapter when
config.service_url is set, otherwise mock feed unchanged.
Earlier: PR #120 — Admin → Integrations tab: point-and-click CRUD for
Grid SoR connector registry (tech_admin-gated), migration
0032 unique-kind index, IntegrationsConfig.tsx, new
POST/PATCH/DELETE /api/grid/integrations{/:kind}
routes. Earlier: PRs #113–#117 — cloud-native data layer spikes:
serverless PMTiles vector tiles from MinIO, in-browser DuckDB-WASM
analytics over GeoParquet, FlatGeobuf bbox-streamed vector, COG
range-read raster, Turf.js client-side geoprocessing, and broad layer
export — all on the existing MinIO object store, no new services; spiked
/ proof-of-concept, production hardening TBD. Earlier: PRs #111 fix mock
SoR labels; PRs #102–#109 Cartos Grid all seven SoR connectors
end-to-end; PR #103 AIS analytics pushed to Cesium MDA globe). This is
the single orientation doc for everything built so far — platform,
modules, features, how to run it, the marketing site, and the GTM
artifacts.
Contents
- What Cartos is
- Live URLs & quick links
- Architecture at a glance
- Repository layout
- The platform (Phase 1 substrate)
- The modules
- §8.1 Cartos Grid — Electric Utility edition
- §8.2 Historic AIS analytics — Maritime module
- §8.3 Cartos Tower, Milepost & Blueprint — Airport, DOT & AEC editions
- §8.4 Cartos Africa — the One Africa edition
- §8.5 Cartos Guardian — Critical-Infrastructure Protection
- §8.6 Cartos Sentry — Reactive Crisis Monitoring
- §8.7 Cartos Harvest — Agriculture Intelligence
- Cross-cutting
features every module inherits
- §9.1 Agent intelligence surface — Cartos overhaul
- §9.2 Map Export & Mapbook
- §9.3 3D Data — Point Clouds & Gaussian Splats
- §9.4 Platform theme system
- §9.5 Cloud-native data layer — object-storage format spikes
- §9.6 Operational Data & Layer Catalog — real data, symbology, ask-the-map
- §9.7 Dashboards — per-edition & catalog-driven
- §9.8 Map tools — Time of Day & Measure
- §9.9 Map tools UI — Widget kit, Draw & Annotate, Navigate rail
- §9.10 Mapillary — Street Imagery overlay
- §9.11 Cost gate — nothing priced runs without explicit acceptance
- §9.12 Risk symbology & rich feature popups
- Data sources, integrations & keys
- HTTP API reference
- The marketing website & GTM artifacts
- Phase 2 — Analyst Intelligence Layer (Wave 2A built)
- Phase 3 — Cartos Field
- Reference docs
Jump to: §7.4 Tenant Capability System · §7.5 Cartos Field — Phase 3A · §8.1 Cartos Grid — Electric Utility · §8.2 Historic AIS analytics · §8.3 Cartos Tower/Milepost/Blueprint · §8.4 Cartos Africa — One Africa edition · §8.5 Cartos Guardian · §8.6 Cartos Sentry · §8.7 Cartos Harvest · §9.1 Cartos agent overhaul · §9.2 Map Export & Mapbook · §9.3 3D Data — Point Clouds & Gaussian Splats · §9.4 Platform theme system · §9.5 Cloud-native data layer spikes · §9.6 Layer Catalog — ask the map · §9.7 Dashboards · §9.8 Time of Day & Measure · §9.9 Draw & Navigate rail · §9.10 Mapillary street imagery · §13 Phase 2 Analyst Intelligence Layer · §13.4 Real segmentation model · §13.6 MCP server
1. What Cartos is
Cartos is a sovereign geospatial vertical-AI platform: ingest, serve, and visualize geospatial data inside your own territory, with an AI analyst wired in from the first click. Every "vertical" (city, port, emergency services…) is a slice over one shared data fabric, one agent, and one shell — not a separate product.
Engine + Editions + Instances model
Cartos is the horizontal ENGINE — the data fabric, agent, capability system, shell, and all cross-cutting features. Industries are licensed EDITIONS of that engine. An edition can be licensed as multiple INSTANCES by different organisations.
Ownership & licensing (reworked 2026-07-28 — see
EarthWatch3D-PRD/PRD.md §20 for the full
language). Cartos — the engine and every one of its
editions — is the sole and exclusive property of Steven P. Santovasi
(the "Owner"). The Owner grants Solution Strategy
Partners ("SSP") a single unrestricted, perpetual,
irrevocable, worldwide, royalty-free, sublicensable master
license to the whole Cartos platform and every edition — SSP
may use, host, deploy, modify, commercialize, distribute, and sublicense
Cartos for any purpose, at no cost, but the license does
not transfer ownership. Every downstream edition
instance below is delivered as a non-exclusive sublicense under
SSP's master license, not a direct grant from the Owner — e.g.
Cartos Atlas (the owner-held, first Government-edition instance) and
Cartos Grid (the RAiN Partners instance of the Electric-Utility edition)
are sublicensed instances, each the first, not the sole
instance of its edition, provided "as-is" at the state existing
at handoff.
| Edition | Instance(s) | Licensee |
|---|---|---|
| Government / Defense | Cartos Atlas | First, non-exclusive sublicensed instance under SSP's master license (not the sole Government version) |
| Electric Utility | Cartos Grid | RAiN Partners — non-exclusive sublicensed instance |
| Airport | Cartos Tower | Owner-owned reference instance (built 2026-07-07; not yet sublicensed externally) |
| Transportation / DOT | Cartos Milepost | Owner-owned reference instance (built 2026-07-07; not yet sublicensed externally) |
| Infrastructure Delivery / AEC | Cartos Blueprint | Owner-owned reference instance (built 2026-07-07; not yet sublicensed externally) |
| Government — Continental | Cartos Africa | Owner-owned reference instance — merged to main (PR
#127) and live at https://africa.cartosatlas.com;
standalone continental Government edition for African governments / NMAs
/ ministries |
| Government — Critical-Infrastructure Protection (proactive) | Cartos Guardian | Owner-owned reference instance — merged to main (PR
#133/#134) and live at https://guardian.cartosatlas.com;
UAE strike-risk protection, Strait-of-Hormuz focus |
| Government — Crisis Monitoring (reactive) | Cartos Sentry | Owner-owned reference instance — merged to main and
live at https://sentry.cartosatlas.com;
optical+SAR+thermal change detection, watches Guardian's highest-risk
assets |
| Agriculture Intelligence | Cartos Harvest | Owner-owned reference instance — merged to main
(tag cartos-harvest-v1) and LIVE at
harvest.cartosatlas.com; STAC-native crop
type/health/yield/water/boundaries/risk, anchored on Kenya + Nigeria.
See §8.7. |
A new industry segment gets a new edition; a new customer within that
segment gets a new sublicensed instance of the existing edition. All
nine editions above are now built and merged to
main; eight are also
live, each on its own subdomain of
cartosatlas.com (edition detected from hostname — see §8.3
for Tower/Milepost/Blueprint, §8.1 for Grid, §8.4 for Africa, §8.5
for Guardian, §8.6 for
Sentry); the ninth, Cartos Harvest, is LIVE at
harvest.cartosatlas.com (deployed 2026-07-29, Caddy vhost, or
Keycloak redirect origin yet) — see §8.7. Guardian
and Sentry are a deliberate pair — proactive protection
feeding a reactive monitor — not two independent verticals; see the
Guardian → Sentry handshake in §8.6.
The three phases
| Phase | Name | Status |
|---|---|---|
| I | The Sovereign Platform — ingest/serve/visualize + agent, live vertical modules | Live (built) · three more industry
editions — Cartos Tower (Airport, 10 modules), Cartos Milepost
(Transportation/DOT, 10 modules + portfolio/project RBAC), Cartos
Blueprint (Infrastructure PM/AEC, 9 modules + portfolio/project RBAC) —
built 2026-07-07, live on their own subdomains (see §8.3);
Cartos Grid live operational data (assets/outages/work
orders/vegetation/parcels/CIP projects + KPI API + agent tools);
all seven SoR connectors end-to-end (OMS/ADMS, IBM
Maximo, SAP PM, Esri Utility Network, Primavera P6, Microsoft Project,
File/CSV import); Admin → Integrations tab for
connector-registry CRUD (tech_admin); real
connector adapter seam
(crates/cartos-api/src/connectors/ —
RealConnector trait, config-driven dispatch; Esri UN
verified live against a public ArcGIS service; other connectors remain
mock until credentials supplied); Historic AIS analytics (vessel track
replay, density heatmap, port calls); AIS analytics pushed to Cesium MDA
globe via cartos.ais.set bridge; cloud-native data
layer spikes (PMTiles / GeoParquet+DuckDB-WASM / FlatGeobuf /
COG / Turf.js geoprocessing / broad export) proven on existing MinIO —
no new services; Grid Data panel; spiked / proof-of-concept;
Operational Data & Layer Catalog — all 4 phases
shipped (admin-managed datasources + map layers scoped by
tenant/edition/module/group/role/user-type; real OpenStreetMap data live
in 7 module families; attribute-driven symbology incl. real 3D; two
cross-module "ask the map" agent tools — see §9.6);
per-edition full-screen dashboards for every Grid +
industry module + a reusable catalog-driven dashboard (see §9.7);
Cartos Africa — One Africa edition, merged to main
and LIVE at africa.cartosatlas.com (9 continental Government
modules, country/subnational scope, foundation-layer fabric, Continental
Command dashboard, real data in all 9 modules, africa_scope
agent tool, see §8.4); holographic
point symbology + feature-type icons, an edition-switcher pulldown, and
session-vectors-above-rasters ordering shipped platform-wide the same
day; Cartos Guardian — critical-infrastructure protection,
merged to main and LIVE at
guardian.cartosatlas.com (5 modules, 103 curated UAE sites, a
transparent per-site strike-risk model,
grid/strategic-vector/POI-collateral layers, historical-imagery + live
AIS/ADS-B traffic overlays, guardian_strike_risk agent
tool, see §8.5);
Cartos Sentry — reactive crisis monitoring, merged to
main and LIVE at sentry.cartosatlas.com (3
modules, the platform's first background monitoring daemon + real S1/S2
model-free change detectors + incident state machine/alerts + fusion
confidence bands + commercial-tasking connectors + an InSAR coherence
context layer, watching Guardian's highest-risk assets, see §8.6);
Cartos Harvest — agriculture intelligence, merged to
main (tag cartos-harvest-v1) and LIVE at
harvest.cartosatlas.com (7 modules — command + crop
type/health/yield/water/boundaries/risk — a STAC-native crop pipeline
over Sentinel-2 via Microsoft Planetary Computer: KMeans
temporal-phenology crop classification (v0.2), NDVI-anomaly crop health,
season-NDVI yield proxy with inter-annual-CoV uncertainty,
gdal_polygonize field-boundary candidates + human review, a
farmer registry, and a composed 0–100 agri-risk score with an
index-insurance trigger; persisted derived products
(M03) — panel runs can be saved as lineage-tracked datasets,
browsable in a Saved Products panel and queryable by the
harvest_products agent tool; anchored on Kenya + Nigeria,
reuses the Cartos Africa country-scope + foundation fabric, see §8.7); a
ride-along Opus 5 thinking-block parser fix in
cartos-agent's Anthropic client (llm.rs) that
ended intermittent 502s on reasoning turns, platform-wide |
| II | Analyst Intelligence Layer — change detection, derived data, 3D reconstruction, auto-reporting | Wave 2A + Spike A built (derived-dataset graph, reporting, async job tier, real SegFormer segmentation over real imagery, real two-epoch Sentinel-2 change detection, compute-tier selection, GPU-sidecar deployable artifact, Compare + Map Export capability features refined to EarthWatch3D parity, Compare bidirectional camera sync + "Current map" source, 3D Data point clouds with true geospatial placement, Gaussian splats in MapLibre via three.js + SuperSplat SOGS importer, MCP server) · agent overhauled with rich rendering + export |
| III | Cartos Field — offline-first mobile field-capture edge | 3A + 3B built (backend + config console + analyst + agent + seed + media backend + module assignment + portable overlay; Flutter mobile app compiled in CI, Android APK live via Firebase App Distribution) |
This guide covers Phase I (§7), Phase II Wave 2A (§13), and Phase III (§7.5 + §14). Cartos Grid (the first vertical edition) is documented in §8.1; Cartos Tower, Milepost & Blueprint (three live vertical editions) in §8.3; Cartos Africa (the sixth live edition) in §8.4; Cartos Guardian (the seventh live edition) in §8.5; Cartos Sentry (the eighth live edition) in §8.6; Cartos Harvest (the ninth live edition) in §8.7.
Two different things are called "Phase 2": the RLS / group-access work (shipped — §7) and the Analyst Intelligence Layer (Wave 2A now built — §13). When in doubt, ask which is meant. "Phase 3A/3B" in this guide means the complete Cartos Field stack — sync backend, web config console, analyst module, media backend, module assignment, portable overlay, and the Flutter mobile app (CI-compiled, Android APK live via Firebase App Distribution; iOS pending Apple Developer enrollment).
2. Live URLs & quick links
| What | Where |
|---|---|
| Marketing site (live) | https://cartosatlas.com |
| Interface tour | https://cartosatlas.com/interface |
| Editions overview | https://cartosatlas.com/editions |
Commercial model (gated, password the demo password —
temporary) |
https://cartosatlas.com/commercial-model |
| App — Cartos Atlas (Government) | https://atlas.cartosatlas.com |
| App — Cartos Grid (Electric Utility) | https://grid.cartosatlas.com |
| App — Cartos Tower (Airport) | https://tower.cartosatlas.com |
| App — Cartos Milepost (Transportation/DOT) | https://milepost.cartosatlas.com |
| App — Cartos Blueprint (Infrastructure PM/AEC) | https://blueprint.cartosatlas.com |
| App — Cartos Africa (Government · continental) | https://africa.cartosatlas.com (see §8.4) |
| App — Cartos Guardian (Government · critical-infrastructure protection) | https://guardian.cartosatlas.com (see §8.5) |
| App — Cartos Sentry (Government · reactive crisis monitoring) | https://sentry.cartosatlas.com (see §8.6) |
| App — Cartos Harvest (Agriculture intelligence) | LIVE at harvest.cartosatlas.com (merged to
main, tag cartos-harvest-v1); also runnable
locally with ?edition=harvest or
VITE_CARTOS_EDITION=harvest (see §8.7) |
| App (local dev) | http://localhost:5173 (Vite dev) against
cartos-api on :3000 (or :4000
when Polyphony is up) |
The marketing site is hosted on Cloudflare Pages
(push main = publish). The app also runs
on a small stakeholder-demo VM — one Caddy origin, one edition per
subdomain, hostname-detected (deploy/Caddyfile.prod) — with
demo/seed data only, not sovereign infrastructure (per
deploy/DEPLOY.md); it is also locally and §12 for the
site.
3. Architecture at a glance
┌──────────────────────────────────────────────────┐
Browser │ cartos-web — React + Vite shell │
│ MapLibre (2D) · Cesium iframe (3D) · Dockable │
│ panels · AI agent chat · GeoWizard · overlays │
│ AnalysisOverlay · CompareOverlay (swipe) │
└───────────────┬──────────────────────────────────┘
│ HTTPS (Bearer JWT)
┌───────────────▼──────────────────────────────────┐
Rust │ cartos-api (axum) — JWT validated vs Keycloak │
workspace│ JWKS. Routes per module + agent + admin + layers │
│ ├─ cartos-agent LLM tool registry (Anthropic) │
│ ├─ cartos-data Postgres queries + RLS session │
│ ├─ cartos-core TenantContext (tenant/tier/roles/groups)
│ └─ model-job worker (SKIP-LOCKED → sidecar loop) │
└───────┬───────────────────────────────────────────┘
│ │ POST /infer (tier routing)
┌───────────┴──────┐ ┌───────▼────────────┐ ┌─────────────────┐
│ Postgres+PostGIS│ │ model-sidecar │ │ GPU sidecar │
│ (RLS tenant+team│ │ FastAPI :8300 │ │ (Dockerfile.gpu│
│ derived dataset │ │ SegFormer-b0/ADE │ │ CUDA 12.4 + │
│ graph + job Q) │ │ real imagery fetch │ │ SegFormer-b5) │
└──────────────────┘ │ change.detect (real)│ │ MODEL_GPU_SIDECAR_URL
└────────────────────-─┘ └─────────────────┘
┌──────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ Keycloak │ │ MinIO + Caddy │ │ mcp-server/ │
│ (SSO/JWT) │ │ object store + proxy │ │ stdio → AI host│
└──────────────────┘ └──────────────────────┘ └─────────────────┘
- Rust workspace carries the data fabric, agent, API,
and the Phase 2 model-job worker; a React shell renders
MapLibre 2D and Cesium 3D (the latter via an iframe bridge to the
sibling
../cesium_project_SPSapp). - Phase 2 model sidecar (
model-sidecar/, FastAPI:8300) is stateless — it never touches the DB. The Rust worker owns the Postgres SKIP-LOCKED queue, claims jobs, callsPOST /infer, and writes outputs as lineage-tracked derived datasets. The sidecar relocates CPU → GPU cluster with zero code change. A GPU sidecar variant (Dockerfile.gpu, CUDA 12.4 + SegFormer-b5) is the deployable artifact for a Full GPU tier; the API routes to it whenMODEL_GPU_SIDECAR_URLis set. - MCP server (
mcp-server/, Node +@modelcontextprotocol/sdk, stdio transport) exposes a curated read-only tool surface over cartos-api so any MCP-capable AI host (Claude Desktop/Code, Copilot, Gemini via a bridge) can "ask Cartos" directly. - Everything ships in containers you run in-territory
(
deploy/). - Tenant- and team-isolation is enforced at the database with row-level security across all tenant tables, not just in the app layer.
4. Repository layout
| Path | What it is |
|---|---|
crates/cartos-core |
Type primitives — TenantContext
(tenant_id, tier, roles,
groups), Role, Tier, plus the
declarative capability catalog.rs (FEATURES
registry + CATALOG of modules). |
crates/cartos-data |
Postgres queries + set_tenant_session /
set_admin_bypass helpers. Every read runs in a transaction
with RLS session vars set; WHERE filters are belt-and-braces.
capabilities.rs loads/writes per-tenant module + feature
enable state. field.rs is the data layer for Cartos Field
(apps + captured features). grid.rs is the data layer for
Cartos Grid operational tables (assets, outages, work orders, vegetation
segments, parcels, CIP projects, and aggregate KPI query).
grid_integrations.rs is the SoR connector registry + all
sync/import functions (sync_oms, sync_maximo,
sync_sap, sync_esri_un, sync_p6,
sync_msproject, import_csv_work_orders); also
exposes get_config(pool, tenant_id, kind) (reads a
connector's config JSONB — used by the real-adapter dispatch in
grid::sync) and
upsert_real_assets(pool, tenant_id, source, rows) (generic
batch UNNEST upsert into grid_assets keyed by
external_ref, shared by all real adapters).
ais.rs is the data layer for historic AIS (vessel_positions
track/density/port-call reads; insert_positions used by the
live ingester). catalog.rs is the data layer for the
Datasource + Layer Catalog (scoped datasource/layer
CRUD with secret-omitting projections,
list_module_layers/list_accessible_layers
cross-module scoped reads, module_kpi per-layer feature
counts,
list_layer_features/query_layer_features
internal-feature GeoJSON + filtered queries — see §9.6).
industry.rs is the data layer for the
industry-edition per-project RBAC (Cartos
Milepost/Blueprint) —
list_projects/caller_admin_scope/grant_scope/list_access/grant_access/caller_project_level/update_project/revoke_access
— see §8.3. |
crates/cartos-agent |
LLM tool registry (Tool trait,
register_*). Module-namespaced builtin tools; Anthropic
client (llm.rs). Tool surface narrowed by enabled modules
at call time. builtin/catalog_layers.rs +
builtin/query_catalog_layer.rs are the two cross-module
"ask the map" tools over the Layer Catalog. |
crates/cartos-api |
axum HTTP. JWT validation vs Keycloak JWKS. Per-module route
modules, kpi.rs, admin.rs (+
keycloak_admin.rs), mobility.rs,
layers.rs, scope.rs, field.rs
(Cartos Field routes), grid.rs (Cartos Grid list + KPI +
SoR integration endpoints — sync handler does config-driven
real-vs-mock dispatch), oms_sync.rs (six optional
background schedulers for mock SoR feeds — opt-in via
CARTOS_{OMS,MAXIMO,SAP,ESRI_UN,P6,MSPROJECT}_MOCK=1),
connectors/ (real connector adapter seam —
mod.rs: RealConnector trait +
is_real() + real_sync() registry;
connectors/esri_un.rs: reference Esri Utility Network real
adapter), ais.rs (historic AIS read endpoints),
ais_ingest.rs (opt-in live AIS ingester background task),
catalog.rs (Layer Catalog routes —
GET /api/catalog/layers, /api/catalog/kpi,
/api/catalog/layers/:id/features, tech_admin CRUD for
layers + datasources, bounded test-connection probe),
industry.rs (/api/industry/* — project-access
RBAC for Cartos Milepost/Blueprint, see §8.3).
Phase 2: derived.rs (M03 derived-dataset graph),
report.rs (M02 sessions + HTML/DOCX render),
jobs.rs (async model-job queue + background worker).
Capability routes in handlers.rs
(GET /api/capabilities) and admin.rs
(module/feature/user-module writes). |
crates/cartos-cli |
migrate, seed, and
load-geojson (the Phase-2 region loader — idempotently
ingests a GeoJSON FeatureCollection into the Layer Catalog as an
internal-PostGIS datasource + layer + catalog_features;
loader.rs). Runs as the postgres superuser → bypasses RLS
by design. |
crates/cartos-codec |
Shared codec/types. |
cartos-web |
React + Vite shell. Native modules (Smart City, Maritime, Emergency
Services, Cartos Grid — nine utility departments via
modules/grid/, Cartos
Tower/Milepost/Blueprint — 29 industry-edition department
modules via modules/industry/ and the shared
IndustryModule surface, see §8.3)
+ Cesium-iframe modules (Situational Awareness, Maritime Domain
Awareness). Capability context (useCapabilities()) drives
nav and panel gating. Portable feature components live in
src/features/ (e.g.
overture/OvertureOverlay.tsx,
analysis/AnalysisOverlay.tsx,
field-captures/FieldCapturesOverlay.tsx,
point-cloud/PointCloudOverlay.tsx,
maritime-analytics/MaritimeAnalyticsOverlay.tsx,
industry-access/ProjectAccessPanel.tsx — see §8.3).
lib/edition.ts resolves the active edition
(atlas/grid/tower/milepost/blueprint/full)
from a build flag, a ?edition= override, or the hostname,
and drives the nav lineup, brand wordmark, default route, and default
theme. Key shell additions: AgentMarkdown.tsx (GFM + inline
SVG chart renderer), lib/agent-export.ts (HTML/PDF/DOCX
export of agent conversations), lib/image-boost.ts (global
raster quality control), lib/point-cloud.ts (deck.gl
MapboxOverlay + Tile3DLayer /
PointCloudLayer; proj4 georeferencing; density
decimation), lib/gaussian-splats.ts (three.js overlay
canvas, MapLibre v5 defaultProjectionData.mainMatrix
projection split; @mkkellogg/gaussian-splats-3d
DropInViewer), lib/sogs.ts (SuperSplat SOGS
importer — .ssog / .sog / .zip
bundle decode via fflate, LOD-aware splat selection),
lib/themes.ts + lib/theme-context.tsx
(platform theme system — 10 palettes, CSS custom-property tokens on
:root, one global theme + per-module overrides, persisted
in localStorage), shell/ThemeSwitcher.tsx (palette picker
UI), shell/CartosMark.tsx (Cartos inline SVG mark on
splash/login/shell), lib/map-styles.ts →
recolorStyleAccent / resolveBasemapStyle /
holographic-theme / hillshade-theme keys
(theme-matched basemaps recoloured live on theme change),
lib/grid-data.ts (Grid operational layer fetch + MapLibre
source/layer reconciler, KPI fetch; DEPT_LAYERS maps each
Grid department to its default data layers;
fetchIntegrations, syncIntegration,
importGridCsv for SoR connector management),
lib/ais.ts (Historic AIS fetch helpers + MapLibre
ais-* source/layer helpers for track replay, density
heatmap, and port markers). Cloud-native data layer spike libs
(PRs #113–#117): lib/pmtiles-layer.ts (registers
pmtiles:// protocol,
addPmtilesAssets/removePmtilesAssets — vector
tile archive range-read from MinIO), lib/duckdb.ts
(lazy-boots DuckDB-WASM self-hosted via Vite ?url, fetches
a GeoParquet file from MinIO, exposes runDuckQuery for
ad-hoc columnar SQL), lib/flatgeobuf-layer.ts
(loadFgbAssets range-reads only the features inside the
current viewport bbox, re-reads on map moveend),
lib/cog-layer.ts (registers cog:// protocol
via @geomatico/maplibre-cog-protocol;
addCog/removeCog),
lib/layer-export.ts (broad client-side export:
exportGeoJSON / exportShapefile /
exportFlatGeobuf / exportCsv). Spike feature
panels: features/duckdb/DuckdbSpikePanel.tsx (DuckDB-WASM
analyst query workspace, launched from grid-capital),
features/geoprocessing/GeoprocessingPanel.tsx (Turf.js
buffer/convex hull/centroid over a live Grid layer),
features/export/ExportPanel.tsx (format picker + download +
GeoPackage link from MinIO). Operational Data & Layer
Catalog (see §9.6):
lib/catalog.ts (typed fetch client —
layers/datasources/KPI/features CRUD),
lib/catalog-layers.ts (reconcileCatalogLayers
— the cat--prefixed MapLibre source/layer reconciler,
style-aware), lib/catalog-style.ts (symbology engine —
ColorSpec/SizeSpec,
colorExpr/sizeExpr/heightExpr,
legend + classification helpers), lib/catalog-3d.ts
(deck.gl ColumnLayer overlay for 3D point columns),
features/catalog/CatalogLayersOverlay.tsx (the portable
per-module overlay + panel),
features/catalog/LayerStyleEditor.tsx (tech_admin live
style editor), features/catalog/CatalogLegend.tsx (on-map
legend), features/catalog/MapStatsBar.tsx (live bottom
stats bar, now rendered in the shell's footer slot for Grid/industry
modules), features/catalog/CatalogKpiStrip.tsx (live-data
tile strip mounted in native module dashboards),
features/catalog/CatalogDashboard.tsx (reusable ECharts
dashboard — the industry-edition dashboard standard),
modules/admin/CatalogConfig.tsx (tech_admin
datasource/layer console). Dashboards (see §9.7):
shell/DashboardPage.tsx (shared full-screen dashboard shell
for Grid + industry editions),
modules/grid/GridDashboardPage.tsx,
modules/industry/IndustryDashboardPage.tsx. |
overture-sidecar/ |
Python/DuckDB FastAPI sidecar for Overture Maps GeoParquet queries.
Proxied by cartos-api at /api/overture/*. See
§10. |
model-sidecar/ |
Phase 2 model sidecar (FastAPI :8300).
Stateless — never touches the DB. main.py implements
GET /healthz (Gate-0 check: ok + device + models_loaded +
segment_real), POST /infer (job-kind dispatch),
POST /render/docx (M02 DOCX),
POST /render/markdown-docx (agent conversation DOCX),
POST /render/geopdf (GeoPDF georeferencing via GDAL —
accepts PNG bytes + Mercator bbox, runs
gdal_translate -of PDF -a_srs EPSG:3857 -a_ullr, returns
PDF bytes). render.py = the DOCX renderer.
segment_real.py = the real SegFormer model (Spike A):
lazy-loads SegFormer-b0/ADE20K, fetches Esri World Imagery for the AOI
(fetch_aoi_image), and also tiles-based imagery
(fetch_aoi_tiles, used for Sentinel-2 change detection).
Two job handlers: infer.segment (real model when
SEGMENT_REAL=1 and ML stack present; deterministic mock
fallback) and change.detect (real two-epoch diff when epoch
tile URLs are supplied; mock fallback). Dockerfile.gpu =
CUDA 12.4 + cu124 torch + SegFormer-b5 GPU build. |
mcp-server/ |
Cartos MCP server (Node +
@modelcontextprotocol/sdk, stdio transport). Read-only
proxy over cartos-api. Eight tools: cartos_who_am_i,
cartos_capabilities,
cartos_smart_city_incidents,
cartos_maritime_vessels,
cartos_emergency_units,
cartos_derived_datasets (with optional aoi
bbox param), cartos_reports, cartos_report.
Config: CARTOS_API_URL + CARTOS_TOKEN.
dist/index.js is the compiled entry point. |
tools/export_report.py |
Operator tool: fetch a saved analysis session → write
.html + .pdf (headless-Chrome print) +
.docx (via API → sidecar). Usage:
python tools/export_report.py --session <uuid> [--api ...] [--narrate]. |
tools/build_pmtiles.py ·
tools/build_geoparquet.py · tools/build_cog.py
· tools/build_geopackage.py ·
tools/upload_minio.py ·
cartos-web/scripts/build-flatgeobuf.mjs |
Cloud-native data layer generators (spikes, PRs
#113–#117). One-off batch scripts that produce
object-storage-native formats from a GeoJSON/raster source and
optionally upload to MinIO. Scratch outputs go in
tools/_tmp/ (gitignored). upload_minio.py is
the shared MinIO upload helper (creds from env:
MINIO_ENDPOINT / MINIO_ACCESS_KEY /
MINIO_SECRET_KEY; defaults match
deploy/docker-compose.yml). See §9.5. |
tools/osm_to_geojson.py ·
tools/load_demo_catalog.sh ·
data/catalog/*.geojson |
Layer Catalog real-data acquisition (Phase 2, see §9.6).
osm_to_geojson.py queries the public OpenStreetMap Overpass
API (bbox + key/value, or an inline --query-str) and writes
a GeoJSON FeatureCollection. load_demo_catalog.sh is the
idempotent load manifest (runs cartos-cli load-geojson per
dataset, locally via cargo or in-container).
data/catalog/*.geojson are the nine acquired real datasets
(provenance + reproducible reload). |
deploy/ |
docker-compose (postgres + keycloak + minio + caddy +
overture-sidecar + cartos-api +
model-sidecar). The dev compose now includes the API and
both sidecars as Linux containers (see §5 Option A), sidestepping
Windows Smart App Control. Dockerfile.api builds the Rust
binary in a multi-stage container. The API in-container uses
CARTOS_KEYCLOAK_URL (internal service) and
CARTOS_KEYCLOAK_PUBLIC_URL (browser-facing issuer) as two
separate env vars. |
website/ |
Live marketing site at cartosatlas.com (Cloudflare
Pages; push main = publish). See §12. |
cartos-pricing/ |
Commercial model & government business case (internal + public-safe; docx/pdf). See §12. |
cartos_screenshots/ |
Source product screenshots → optimized to
website/images/*.webp via
website/tools/optimize-screenshots.py. |
cartos-field-mobile/ |
Flutter mobile app (com.cartos.field). CI-compiled
(Android APK live via Firebase App Distribution). Source layout:
lib/models.dart, lib/api/field_api.dart,
lib/db/local_store.dart,
lib/sync/sync_service.dart,
lib/sync/background_sync.dart,
lib/auth/auth_service.dart,
lib/offline/offline_maps.dart, lib/screens/.
See cartos-field-mobile/README.md. |
cartos-phase2/ |
Phase 2 planning + GTM doc set. The three Gate-0 build contracts in
cartos-phase2/docs/phase-2/specs/
(derived-dataset-schema.md,
session-result-object.md, gpu-job-contract.md)
are the canonical specs that Wave 2A implemented. ⚠
cartos-phase2/docs/strategy/ip-timing.md is time-sensitive
(public disclosure affects patentability). |
cartos-phase3-mobile/ |
Phase III planning + GTM doc set (MRD/PRD). The code is now in
cartos-field-mobile/. |
cartos-grid-PRD/ |
Cartos Grid (Electric Utility edition) PRD +
Capability Brief — documents the engine+editions model, the nine
department modules, the licensing arrangement with RAiN Partners, and
the deep-integration roadmap (operational data tables + system
integrations). See also EarthWatch3D-PRD/PRD.md §20 for the
non-exclusive Cartos Atlas Government edition framing. |
docs/ |
Engineering & GTM docs (see §14). |
7. The platform (Phase 1 substrate)
Every vertical inherits these. They are the moat.
Sovereign data fabric + team isolation (group access)
- Keycloak
groupsclaim →TenantContext.groups→ query filterowning_team IS NULL OR owning_team = ANY($groups)→ Postgres RLS policytenant_team_isolationas a second layer. - New customer tables almost always need
owning_team TEXT+ a(tenant_id, owning_team)index. - Admin writes set data scope via
PATCH /api/admin/scope(also exposed as theset_owning_teamagent tool); both set acartos.bypasssession var inside the transaction. - Migration 0033 closes the RLS gap on five tables
that previously lacked
ENABLE + FORCE ROW LEVEL SECURITY+ thetenant_team_isolationpolicy:field_apps,field_features,derived_dataset,analysis_session,model_job.session.rssave/get/list paths now go throughset_tenant_session. - Migration 0087 does the same for the three
Cartos Harvest tables added by migrations
0083–0085—crop_calendars,field_parcelsandfarmer_registry. Each described itself as scoped "per the platform convention" but carried no RLS policy, so the only boundary was the explicitWHEREfilter incartos-data;farmer_registryholds PII (name / national_id / phone).0087_harvest_rls.sqlapplies the same ENABLE + FORCE +tenant_team_isolationtreatment, and all nineset_tenant_sessioncall sites incartos-data/src/harvest.rsnow forward the caller's real groups instead of&[], socartos.groupsis actually populated (insert_field_candidatesandcreate_farmergained agroupsparameter for exactly this — theirRETURNINGclauses are evaluated against the read-side policy). Shipped + deployed 2026-07-29; verified on prod that all three reportrelrowsecurityandrelforcerowsecurity.⚠ The two halves must ship together. With RLS on and an empty groups slice,
INSERT … RETURNINGraises "new row violates row-level security policy", and every already-stored row with a non-NULLowning_teambecomes invisible. Do not cherry-pick the migration without thecartos-datachange, or vice versa. - Opt-in non-superuser DB role
(
cartos_app) —postgres/03-app-role.shcreates the role; setCARTOS_APP_DB_PASSWORDand theCARTOS_APP_DB_USERcompose var so the API runs ascartos_app(notpostgres) in production, makingFORCE ROW LEVEL SECURITYactually bite. SeeDEPLOY.md §3a. In dev thepostgresrole bypasses RLS by design; the WHERE filters carry the boundary. - Canonical contract:
docs/group-access-design.md— read it before adding queries, write paths, RLS policies, or detail-panel UI.
Authentication & access control
- Keycloak SSO, roles, and team-scoped data. JWT is RS256-validated against Keycloak's JWKS. The JWKS is now live-refreshed: on an unknown key-id the API refetches immediately (rate-limited to once per 30 s) and a 10-minute background loop keeps the cache warm — eliminating the fleet-wide auth outage that a Keycloak key rotation previously caused.
CARTOS_REQUIRE_TENANT_CLAIM=1— when set, the auth layer rejects (401) any JWT that lacks thecartos_tenant_idcustom claim. Fails closed. Flip only after adding the Keycloak protocol-mapper that emits the claim; leaving it unset preserves the existing behaviour (claim optional).- Realm roles in
deploy/keycloak/cartos-realm.json:admin— superuser, implies all three sub-admin tiers.provider_admin— which modules a client deployment has.tech_admin— which features per module.user_admin— users' teams + per-user module access.user,viewer— standard end-user tiers.
- The three sub-admin roles are additive to
admin—is_provider_admin()returns true for bothadminandprovider_admin, and so on. A dedicated tier role scopes a delegate to just their surface. - Native Admin module manages users, group
membership, and capabilities in-app via the Keycloak Admin REST API +
the capabilities API. 503s unless
CARTOS_KEYCLOAK_ADMIN_CLIENT_SECRETis set. - Realm
displayName(deploy/keycloak/cartos-realm.json) is now "Cartos" — Keycloak's "Sign in to …" login header previously still read the retired "Cartos Atlas" name; fixed and applied live to the production realm viakcadm. - For the planned model of federating Keycloak to a client's own
IdP/AD per sovereign deployment see
docs/identity-federation-design.md(design, not built).
The AI agent (agent-native from click one)
- Conversational control: ask a question, focus the map, run a
workflow — the agent orchestrates the platform's own tools.
POST /api/agent/chat(503 whenANTHROPIC_API_KEYis unset). - Opus 5 extended-thinking round-trip fix (2026-07-28, ships
with the Cartos Harvest merge, platform-wide): the Anthropic
client's
ContentBlockenum (crates/cartos-agent/src/llm.rs) now hasThinking(thinking+signature) andRedactedThinking(data) variants that serialize back unchanged — Anthropic requires athinkingblock to be echoed verbatim in the assistant turn that carries atool_use, or the follow-up request is rejected. Previously the parser didn't recognize these blocks, causing intermittent 502s on reasoning-heavy turns since the app agent was upgraded to Claude Opus 5; fixed for every module's tool calls, not just Harvest's. - Module-namespaced tool registry
(
crates/cartos-agent/src/builtin/):- Core:
who_am_i,current_time,viewport_focus - Smart City:
list_incidents,find_assets,nearest_sensors,list_jurisdictions - Maritime:
list_vessels,find_vessels,list_ports,list_port_incidents - Emergency Services:
list_emergency_units,list_emergency_incidents,nearest_available_unit,incidents_in_window,traffic_conditions,list_cameras - Overlays:
add_satellite_layer,list_satellite_layers - Admin:
set_owning_team - Field (cross-module):
list_field_apps(optionalmodulearg to scope to apps assigned to that module),summarize_field_captures - Cartos Grid (cross-module — work on any Grid department
surface):
grid_kpis(aggregate KPI summary: assets total/at-risk by kind, active outages + customers out, open work orders by kind, vegetation risk segments, parcels + encroachment count, CIP projects total/active/budget $M),grid_active_outages(list active/restoring outages sorted by customers affected; optionallimit),grid_open_work_orders(list open work orders by kind, priority, crew; optionalkindfilter +limit) - Layer Catalog — "ask the map" (cross-module, over every
accessible module):
catalog_layers(discover every map layer the caller can see, scope-filtered by tenant/group/role/user-type, with module/label/render_type/feature_count; optionalmodulefilter) andquery_catalog_layer(resolve a layer bylayer_key, re-authorize by scope, then attribute-equality filter, proximity filter (near_lon/near_lat/radius_m), group-and-count, a coordinate sample, or a One Africacountries(ISO3) scope filter — always returnsmatched_count). See §9.6. - Street imagery (cross-module, external call):
mapillary_street_imagery— searches Mapillary's public Graph API for crowd-sourced street-level photos inside a bbox or near a point+radius, withpano_only/since_year/limitfilters; returns each image's lon/lat, capture month, compass heading,is_pano, a thumbnail URL, and a Mapillary deep link.configured: falsewhen the server has noMAPILLARY_TOKEN. See §9.10. - One Africa (cross-module,
module()returnsNone, built on branchmap-africa):africa_scope— resolves country names, ISO3 codes, or an AU region (orwhole_continent) into the canonicalscope_iso3list plus each country's name/region/population and the combined population, reading the loadedadm0_countriesregistry; feedscope_iso3straight intoquery_catalog_layer's newcountriesargument so "ask the map" answers stay in scope. Not gated to a single Africa module by design — a country-scope tool has to reach all nine. See §8.4. - Cartos Guardian (cross-module, edition-wide):
guardian_strike_risk— ranks the UAE critical-infrastructure inventory by the per-site strike-risk model, filtered bycategory/min_tier/min_score/within_hormuz_km/shipping_only/limit; returnsmatched_count+tier_distribution+ ranked targets. See §8.5. - Cartos Harvest (cross-module, edition-wide,
crates/cartos-agent/src/builtin/harvest.rs, merged tomain): five tools.crop_calendar— planting/peak/harvest month windows and the current phenology stage (planting/vegetative/harvest/off-season) for a country's crops, optionally filtered bycountry(ISO3) /crop;harvest_country_profile— a country's crop-calendar summary for right now (which crops are planting, growing, or being harvested), keyed byiso3(both readcrop_calendars, migration0083, FAO GIEWS-seeded for KEN/NGA);parcel_fabric_summary— how many field parcels are delineated (candidate/approved/rejected, area) plus farmer-registry linkage coverage;harvest_products— the persisted derived-product catalog (crop-type/health/yield/water/risk runs saved via "Save to products"), optionalproductfilter, PNG bytes stripped;farmer_lookup— search the farmer registry by name/national-id substring, returns cooperative/region/crops/linked-parcel-count (PII, in-scope callers only). See §8.7.
- Core:
- Tools requiring the DB are disabled gracefully if Postgres is
unreachable (server still starts;
/healthzreports honestly). - Capability gating of the tool surface: the
/api/agent/chathandler loads the calling user's effective enabled modules (tenant enables minus per-user denials) and passes them intorespond_with_historyasenabled_modules. The registry'sto_anthropic_tools_filtereddrops any tool whosemodule()maps to a module not in that set; cross-module tools (who_am_i,current_time,viewport_focus, satellite tools) are always exposed. When the DB is unreachable the gate fails open. - Agent + data-source panel now scope to the real
module (fixed on branch
map-africa, applies to every edition): the shell used to derive the agent's module focus frommoduleFromPath(), which only knows the native trio and fell through tosmart_cityfor every Grid/industry/Africa route — so on a non-native map the agent claimed to be "in the Smart City module" and leaked Smart City's tools while the External Data Sources drawer loaded Smart City's source set.AppShell.tsxnow derivesagentFocusFromPath()(native routes keep their focus; Grid/industry/Africa send their own module id so the tool filter drops native tools and keeps the cross-module ones) andmoduleIdFromPath()(the catalog-canonical kebab id for the drawer); verified onafrica-health— agent panel reads "Health & Immunization", the drawer reads "africa-health · 49 live · 33 hidden", and Smart City's tools are gone. - Cartos agent overhaul (see §9.1 Agent intelligence surface for full detail): rich Markdown + inline SVG chart rendering, voice input (Web Speech API), resizable/fullscreen panel, and a conversation export menu (Print/PDF · Word/DOCX · HTML).
7.4 Tenant Capability System
Status: built and wired; merged to
main.
What it is: per-client sovereign configuration — a declarative, in-repo catalog of what modules and features can exist on the platform, plus per-tenant DB enable-state and per-user access overrides, with a three-tier admin surface to manage them.
The catalog
(crates/cartos-core/src/catalog.rs)
Two static constructs compiled into the binary:
FEATURES— global feature registry. Seventeen reusable capabilities:id Label What it is external-overlaysExternal overlays ArcGIS REST catalog + custom URL + identify popups satelliteSatellite imagery NASA GIBS WMTS layer catalog stacSTAC catalog STAC item discovery + on-map preview mobilityGlobal Mobility Live ADS-B aircraft, AIS vessels, TomTom traffic overtureOverture Maps Overture vector overlay: places, transportation, buildings, divisions buildings-3d3D buildings Overture extrusion + wireframe + Esri I3S photoreal mesh live-weatherLive Weather Animated weather on the Cesium globe: GPU wind-particle streamlines, wind-speed colour wash, direction arrows (live NOAA GFS; extensible). Surfaced in Cesium-backed modules via the iframe bridge. analysisAnalyst Intelligence Phase 2 GeoAI: run segmentation / change detection over the current view as async jobs, render the lineage-tracked derived dataset, export an auditable report (HTML/DOCX). Panel appears in the left dock, gated per module. compareSwipe Compare Before/after comparison of two raster layers (A vs B), camera-synced to the host module's map. Two view modes: Swipe (draggable divider wipes B over A) and Side-by-side (edge-to-edge panes, divider resizes the split). Both panes are fully interactive and bidirectionally camera-synced — pan or zoom from either side and both move together (a guard flag prevents the feedback loop). Each side (A/B) picks from a "Current map" option (clones the live host module's full map style — basemap plus active data/overlay layers — so you can compare what is actually on screen against any other source), a full grouped layer catalog — Basemap (OpenStreetMap, Carto Light/Dark), Esri (Imagery, Streets, Terrain, Topographic, Ocean, NatGeo), Satellite/GIBS (MODIS Terra/Aqua + VIIRS true color) — or a Custom URL (XYZ / WMS / WMTS / Esri REST tile template). Companion to two-epoch change detection. See §9. map-exportMap Export & Mapbook Consolidated left-dock sidebar panel. Create Map mode: four single-page layout templates (Classic Visual, Operational Briefing, Field Map, Compact A4 Brief); one-click PNG capture; OGC GeoPDF (server-side GDAL, georeferenced EPSG:3857). Mapbook mode: multi-page atlas (cover + labeled tile-grid index, one sheet per tile, optional appendix). Full plotter paper sizes: Letter, A4/A3/A2/A1/A0, Tabloid, ARCH A–E and E1, wide formats up to 42×60 and 48×60, plus a user-defined Custom width×height. Landscape or portrait. Data (CSV) section for per-module data exports. See §9.2. point-cloud3D Data — Point Clouds & Gaussian Splats GPU-free client-side 3D data viewer in MapLibre native modules. Point clouds / 3D Tiles: deck.gl MapboxOverlay+Tile3DLayer/PointCloudLayer; local LAS/LAZ (georeferenced or center-of-map), density slider, 90° pitch. Gaussian splats: three.js overlay canvas +@mkkellogg/gaussian-splats-3dDropInViewer; load from URL or local file (.splat/.ksplat/.ply/.spz); SuperSplat SOGS importer (.ssog/.sog/.zip— LOD-aware, decoded byfflate). Anchor, scale, heading, and flip controls. Pitch the map to 90° for a ground-level view. See §9.3.dashboardConfigurable KPI Dashboard An in-module KPI dashboard, admin-gated and on/off per module. Add/remove KPI cards from a metric catalogue, choose the chart type (stat / bar / line / pie / gauge via Apache ECharts), and drag to arrange layout — all editable live from inside the dashboard. Layout persists per user + module in localStorage. Metrics are sample data today — structured to bind to the module's live map data (asset/work-order/outage/vegetation layers) as those data layers ship. Currently the default feature in every Cartos Grid department module. catalogLayer Catalog Admin-managed datasources + map layers persisted per tenant/edition/module and scoped by group/role/user-type: add, hide, or remove hosted GIS layers (internal PostGIS, ArcGIS REST, WMS/WMTS/XYZ, GeoJSON, PMTiles, COG, FlatGeobuf), with credentials for private sources (write-only — never returned to clients). Renders in the module's map via a shared portable overlay + reconciler, with attribute-driven symbology (incl. 3D) and two cross-module "ask the map" agent tools. See §9.6. time-of-dayTime of Day Sun-position lighting: scrub apparent solar time (or animate it) for the map center + date to drive 3D building shading, hillshade illumination, and a day/twilight/night sky. Dependency-free solar engine; sunrise/golden/noon/sunset presets, a sun-elevation timeline, and a directional-shadow-approximation toggle. See §9.8. measureMeasure 3D-aware measurement panel: distance (polyline), area (polygon), and height (two points), in metres or feet. Per-vertex terrain elevation gives 3D slope length, per-segment horizontal/Δh/bearing, and a live results table; degrades to 2D when no terrain is loaded. See §9.8. drawDraw & Annotate Sketch + annotate on the map: points, lines, polygons, and rectangles with per-feature stroke/fill/width styling and optional text labels. Select-and-delete editing, a live feature list, Clear all, and Copy/Download of the drawing as GeoJSON. Persists per module + user in localStorage. See §9.9.mapillaryMapillary Street Imagery Crowd-sourced street-level imagery from Mapillary as a map overlay: a vector coverage layer (drive-path sequences + image points) with a captured-since filter and a 360°-panoramas-only toggle; click an image point to view the dated street-level photo with its compass heading in a floating popup. External community data (© Mapillary contributors, CC-BY-SA); requires a Mapillary access token. See §9.10. Features are not owned by any module — any feature can be attached to any module, either as a default or as an advanced cross-add.
CATALOG— installable modules. Platform modules, Cartos Grid's nine department modules, and (as of 2026-07-07, see §8.3 for the full write-up) 29 more department modules across three industry editions — Cartos Tower (Airport, 10), Cartos Milepost (Transportation/DOT, 10), Cartos Blueprint (AEC, 9) — each with amin_tierand adefault_featureslist (ids referencingFEATURES). Canonical kebab-case ids match the route slugs and the DBmodule_idcolumn:Platform modules (all editions):
id Label Vertical Min tier Default features smart-citySmart City urban Starter external-overlays,satellite,stac,mobility,overture,buildings-3d,analysis,compare,map-export,point-cloud,time-of-day,measure,draw,mapillarysituational-awarenessSituational Awareness defense Enterprise (none) — EarthWatch3D Cesium-iframe; external-layer and satellite pushes go via the bridge, not capability features. emergency-servicesEmergency Services public-safety Growth satellite,mobility,analysis,compare,map-export,point-cloud,time-of-day,measure,draw,mapillarymaritimeMaritime maritime Growth satellite,mobility,analysis,compare,map-export,point-cloud,time-of-day,measure,draw,mapillarymaritime-domain-awarenessMaritime Domain Awareness maritime Enterprise satellite,live-weatherenergyEnergy energy Enterprise (none — not yet built) agricultureAgriculture agriculture Enterprise (legacy placeholder — superseded by the seven harv-*Cartos Harvest modules below; no route, unreachable, kept only becausecatalog.rshasn't dropped theModuleDef)fieldCartos Field field-ops Growth satelliteCartos Grid — Electric Utility edition (nine department modules):
id Label Department Min tier Default features grid-commandGrid Command Executive cockpit Enterprise external-overlays,satellite,analysis,compare,map-export,dashboardgrid-operationsNetwork & Asset Operations Grid operations Enterprise external-overlays,satellite,stac,buildings-3d,analysis,compare,map-export,point-cloud,dashboardgrid-capitalCIP & Major Projects Capital improvement Enterprise external-overlays,satellite,analysis,compare,map-export,point-cloud,dashboardgrid-stormStorm Response Emergency / storm ops Enterprise external-overlays,satellite,mobility,analysis,compare,map-export,dashboardgrid-fleetFleet & Field Fleet / field ops Enterprise external-overlays,satellite,mobility,map-export,point-cloud,dashboardgrid-vegetationVegetation Management Vegetation / forestry Enterprise external-overlays,satellite,stac,analysis,compare,map-export,point-cloud,dashboardgrid-realestateReal Estate Management Real estate / ROW Enterprise external-overlays,satellite,overture,analysis,compare,map-export,dashboardgrid-environmentalEnvironmental Management Environmental / permitting Enterprise external-overlays,satellite,stac,analysis,compare,map-export,dashboardgrid-stakeholderStakeholder Management Stakeholder / public affairs Enterprise external-overlays,satellite,map-export,dashboardCartos Tower — Airport edition (ten department modules): every module carries the same five default features (
analysis,compare,map-export,dashboard,mapillary) — the leanIndustryModulesurface, see §8.3.id Label Department air-lightingTarmac Lighting Airfield · lighting air-surfaceSurface Condition Airfield · pavement air-stripingLane Striping Airfield · markings air-signageSignage Airfield · signage air-leaseProperty Lease Real estate air-projectsMajor Projects Capital projects air-emergencyEmergency Services ARFF / SMS air-vehicleVehicle Traffic Landside / ramp air-airtrafficAir Traffic Airside ops air-weatherWeather Meteorology Cartos Milepost — Transportation/DOT edition (ten department modules): same five default features;
dot-executiveis the portfolio surface, the rest are project-scoped (see the RBAC model in §8.3).id Label Department Scope dot-executiveExecutive Overview Executive · portfolio portfolio dot-pavementPavement Management Project · pavement project dot-facilityFacility Management Project · facilities project dot-stripingLane Striping Project · markings project dot-trafficLive Traffic Project · traffic project dot-roadworkCurrent Roadwork & Obstructions Project · work zones project dot-projectsMajor Projects Project · major projects project dot-crewCrew Status Project · field crews project dot-snowSnow Removal & Treatment Project · winter ops project dot-emergencyEmergency Response Project · emergency project Cartos Blueprint — Infrastructure PM/AEC edition (nine department modules): same five default features;
aec-portfoliois the portfolio surface, the rest are project-scoped.id Label Department Scope aec-portfolioPortfolio View Executive · portfolio portfolio aec-designDesign Status Project · design project aec-siteSite Selection Project · site selection project aec-constructabilityConstructability Review Project · constructability project aec-environmentalEnvironmental & Cultural Project · environmental project aec-inspectionConstruction Inspections Project · inspection project aec-safetySafety Report Project · HSE project aec-stakeholderStakeholder Management & Access Project · stakeholder project aec-trafficLive Traffic Project · traffic project Helper functions
module(id),feature(id),is_known_module,is_known_feature, andis_default_featureare exported for use by the API write-path validators.
DB schema — enable state
Two migrations add the persistence layer:
0007_capability_system.sql(tenant_modules,tenant_features):tenant_modules (tenant_id, module_id, enabled, updated_at)— Provider Admin level; which modules the client has switched on.tenant_features (tenant_id, module_id, feature_id, enabled, updated_at)— Tech Admin level; which features within each module.- The dev tenant (
tnt_dev_local) is seeded with all live modules enabled and all Smart City features on. 0016_enable_analysis_feature.sql— enablesanalysisas a default feature for the dev tenant on Smart City, Maritime, and Emergency Services. (live-weatheris seeded for Situational Awareness and Maritime Domain Awareness by the same migration.)0017_enable_compare_feature.sql— enablescompare(Swipe Compare) for the dev tenant on Smart City, Maritime, and Emergency Services.0018_enable_map_export_feature.sql— enablesmap-export(Map Export & Mapbook) for the dev tenant on Smart City, Maritime, and Emergency Services.0019_enable_point_cloud_feature.sql— enablespoint-cloud(3D Data — Point Clouds) for the dev tenant on Smart City, Maritime, and Emergency Services.0020_enable_grid_modules.sql— enables the five front-line Cartos Grid modules (grid-command,grid-operations,grid-capital,grid-storm,grid-fleet) and their default features for the dev tenant.0021_grid_modules_phase2.sql— enables the four remaining department modules (grid-vegetation,grid-realestate,grid-environmental,grid-stakeholder) and their default features for the dev tenant.0022_grid_modules_reenable.sql— re-enables any Grid module/feature rows that were not yet present after the split into two migrations.0023_grid_operational_data.sql— five Grid operational tables (grid_assets,grid_outages,grid_work_orders,grid_veg_segments,grid_parcels), each withtenant_id+owning_team+ GiST geometry index + thetenant_team_isolationRLS policy. Seeded with representative rows for the dev tenant (Charlotte NC for assets/work orders/parcels, central Florida for outages, central California for vegetation segments).0024_vessel_positions.sql—vessel_positionstime-series table (MMSI, name, ship type, SOG/COG/heading, timestamp, PostGISPoint) +port_aoispolygons (Jebel Ali, Khalifa Port, Port Rashid); both tenant + team scoped with the standard RLS policy. Seeded with 48 h of synthetic tracks (12 vessels × 96 fixes at 30-min intervals) so the historic AIS endpoints have data immediately.0025_grid_integrations_oms.sql—grid_integrationsconnector registry table (RLS, seeded with five connectors); addssource/external_ref/est_restoration/feedercolumns togrid_outages+ partial unique index for idempotent upserts; seeds OMS/ADMS asconnected, othersconfigured.0026_grid_maximo_sync.sql— addssource/external_reftogrid_work_orders+ upsert index; promotes Maximo connector toconnected.0027_grid_sap_sync.sql— promotes SAP PM connector toconnected.0028_grid_esri_un_sync.sql— addssource/external_reftogrid_assets+ upsert index; promotes Esri UN connector toconnected.0029_grid_projects_p6.sql— createsgrid_projectstable (CIP capital projects — phase/status/budget_musd/pct_complete/schedule + RLS + GiST index + upsert index); promotes P6 connector toconnectedtargetinggrid_projects.0030_grid_msproject_sync.sql— expands thekindCHECK constraint to includemsproject; registers Microsoft Project connector asconnected.0031_grid_file_csv.sql— registers thefile_csvconnector (uses the existinggrid_work_ordersupsert index from 0026; no schema change needed).0032_grid_integrations_unique_kind.sql— unique(tenant_id, kind)index ongrid_integrations; prevents duplicate connector registrations for the same kind within a tenant.0033_rls_remaining_tenant_tables.sql— closes the RLS gap: addsENABLE ROW LEVEL SECURITY+FORCE ROW LEVEL SECURITY+ thetenant_team_isolationpolicy tofield_apps,field_features,derived_dataset,analysis_session, andmodel_job. Combined with the opt-incartos_appnon-superuser DB role, FORCE RLS is enforced end-to-end in production.0034_grid_integrations_dedup_guard.sql— self-healing deduplication guard on the migration tracking index (drop→dedup→rebuild; no-op when clean).0035_enable_industry_modules.sql/0036_industry_project_access.sql/0037_industry_access_levels.sql— enable + per-project RBAC for the three industry editions, Cartos Tower/Milepost/Blueprint. See §8.3 for the full write-up.0038_catalog_datasources_layers.sql— the Datasource + Layer Catalog foundation (Phase 1):datasources(a connection to data — internal PostGIS or an external ArcGIS/WMS/WMTS/XYZ/GeoJSON/PMTiles/COG/FlatGeobuf source;authJSONB credentials, never serialized to clients) andmap_layers(a renderable layer bound to a datasource or standalone, with avisibilitytri-state on/off/hidden and astyleJSONB slot for the Phase-3 symbology engine). Both carrytenant_team_isolationRLS extended with role + user-type axes (visible_roles/visible_user_types, NULL/empty = visible to all) on top of the standard tenant +owning_teamscope. Additive — a disjointcat-source/layer prefix sits alongsideoverlay_layers(STAC) and Grid layers; nothing existing changes.0039_enable_catalog_seed.sql— enables thecatalogfeature forsmart-city+grid-command(dev tenant) and seeds two public demo datasources/layers (Esri World Imagery raster; USGS 24h earthquakes GeoJSON) so the render path is exercisable with no credentials.0040_catalog_features.sql— Phase 2A:catalog_features(layer_id FK →map_layersON DELETE CASCADE,propsJSONB,geom GEOMETRY(4326), GiST index, tenant-isolation RLS) — lets apostgis-internaldatasource host real feature data ingested by the region loader (cartos-cli load-geojson) instead of a live external service. Features inherit their parent layer's visibility (checked by the serving endpoint), so this table needs only tenant isolation plus the FK cascade.0041_enable_catalog_more_modules.sql— Phase 2B rollout: enablescatalogformaritime,emergency-services, and the three industry-edition modules that received real data (air-airtraffic,dot-executive,aec-portfolio).
0008_user_module_access.sql(user_module_denials):user_module_denials (tenant_id, user_id, module_id, created_at)— User Admin level; a row here means the user is denied that module even though the tenant has it enabled (deny-override model: no row = default allowed, so admins configure only exceptions rather than every user × module pair).
Effective
entitlements (crates/cartos-data/src/capabilities.rs)
load_entitlements(pool, tenant_id, user_id) returns an
Entitlements struct: modules (tenant_modules
enabled, minus user_module_denials) + features
(tenant_features enabled, as "module_id/feature_id"
strings). Helpers set_module_enabled,
set_feature_enabled, and
set_user_module_access are the write paths called by the
admin API handlers.
Cross-pollinating features
The global feature registry decouples features from modules: a Tech
Admin can attach any catalog feature to any module as an "advanced"
add-on, beyond that module's defaults. For example,
overture can be cross-added to the Maritime module so that
maritime operators see Overture POI and road data alongside their vessel
tracks. The API write-path
(PATCH /api/admin/features/{module}/{feature}) validates
the module id and feature id independently (not
checking membership) for exactly this reason.
The Overture overlay is now a portable feature
component
(cartos-web/src/features/overture/OvertureOverlay.tsx) that
owns its own layers, 3D buildings logic, and control panel. It is
mounted by any module that has the <module>/overture
capability enabled, passing in the module's map ref and a namespaced
panel id. Smart City and Maritime both mount it.
Three admin tiers
All three are Keycloak realm roles in
deploy/keycloak/cartos-realm.json; the full
admin role is a superset:
| Role | Gate method | What they can do |
|---|---|---|
provider_admin |
ctx.is_provider_admin() |
Toggle which modules a client deployment has
(PATCH /api/admin/modules/{id}) |
tech_admin |
ctx.is_tech_admin() |
Toggle features per module, including cross-adding advanced features
(PATCH /api/admin/features/{module}/{feature}) |
user_admin |
ctx.is_user_admin() |
View/revoke per-user module access
(GET/PATCH /api/admin/users/{id}/modules) |
Enforcement — three gates
Launcher nav (
ModuleNav):AppShellfetches/api/capabilitieson mount and stores the result in aCapabilitiesContext(useCapabilities()).ModuleNavreceivesenabledModulesfrom the shell; items not in the enabled set are hidden entirely. If the fetch fails,nullis left in context and the nav shows everything (fail open; hard enforcement is server-side).Per-module feature panels: modules read
useCapabilities()and gate their dockable panels behindenabled.features.includes("<module>/<feature>"). For example, Smart City wraps its Overture panel in a capability check forsmart-city/overture. TheOvertureOverlayis not even mounted when the feature is off. Theanalysispanel (Analyst Intelligence) is gated the same way —smart-city/analysis,maritime/analysis,emergency-services/analysis— and its dock button is hidden when the feature is disabled for that module.Agent tool surface: as described in §7.3, the
/api/agent/chathandler narrows the tool set to modules the calling user has enabled. A module whose tools are removed from the surface is invisible to the LLM.Hard data-route enforcement (HTTP 403 from module data routes when the module is disabled) is not yet implemented — the capability system currently gates the nav + panels + agent surface; the underlying REST routes for module data remain accessible to any authenticated user with a valid JWT.
Web admin surface
(cartos-web/src/modules/admin/)
The Admin module (AdminModule.tsx) is a tabbed
config with five tabs rendered based on the caller's roles:
- Modules (
provider_admin) —CapabilityConfig mode="modules": toggle each catalog module on/off for this tenant. Shows vertical, min tier, and default feature count. - Features (
tech_admin) —CapabilityConfig mode="features": for each enabled module, toggle its default features and cross-add any advanced feature from the global pool (e.g. addovertureto Maritime). - Field Apps (
tech_admin) —FieldAppsConfig: build bespoke field-capture apps (see §7.5). - Integrations (
tech_admin) —IntegrationsConfig: register and configure Cartos Grid system-of-record connectors in thegrid_integrationsregistry. Capabilities: add a connector (kind, display name, target table, initial status), change status (configured/connected/syncing/error), retarget it to a different Grid table (grid_assets/grid_outages/grid_work_orders/grid_projects/africa_parcels), update its config JSON (endpoint / credentials — write-only, not read back), or delete the registration. The known connector kinds (oms_adms,esri_un,maximo,sap,p6,msproject,file_csv, plusparcel_fabricandfeatureserveradded by migration0088for the Cartos Africa cadastre ingest — see §8.4) are validated by a DB CHECK; arbitrary new kinds are a future follow-up. Config/operation split: this tab owns connector configuration; operating a connector (Sync / Import CSV) is done from the Grid dashboard "Systems of record" chip strip.
Admin console usability pass (2026-07-29). The Modules and Features tabs gained module and feature filters — with nine editions and 67 department modules the ungated lists had become long enough to be hard to work in. The Catalog and Integrations tabs gained a Test button with a coloured status badge (
modules/admin/TestBadge.tsx, backed by a probe route incrates/cartos-api/src/grid.rs), so a datasource or connector endpoint can be checked in place instead of by triggering a sync and reading the outcome afterwards.
- Users & Teams (
user_admin) — existing team/group management plus a per-userUserModulesEditor(expandable inline): shows which tenant modules the user may access and lets an admin revoke specific ones (deny-override).
Changes take effect on each user's next page load (capabilities are fetched on mount).
Identity federation
docs/identity-federation-design.md documents the planned
model for sourcing users, teams, roles, and credentials per sovereign
deployment by federating Keycloak to the client's own IdP or Active
Directory. This is design only, not built — it is the
natural next step after the capability system for clients who require
that identity stays under their own control.
7.5 Cartos Field — Phase 3A/3B
Status: built and merged to
main. The full loop — config → capture → sync → analyst map — is in place, the Flutter mobile app compiles in CI and distributes Android APKs via Firebase App Distribution. Remaining work: on-device Android test (APK is live), iOS TestFlight (pending Apple Developer enrollment), and a minor definition-version reconciliation.
What it is: the full Cartos Field stack — a configurable, offline-first mobile field-capture platform that extends the sovereign data fabric to the field edge. What is built:
- Sync/field backend — DB tables, data layer
(
cartos-data), and API routes. - Web config console — a "Field Apps" tab in Admin · Configuration.
- Capability integration — Field is a first-class catalog module, route-gated.
- Analyst web module — captured ground truth on the
map (
/field). - Agent tools — cross-module; the agent can discover apps + summarize captures.
- Media backend — photos and signatures upload and
download via
/api/field/media. - Field Apps → Modules — field apps are assignable to platform modules; their captures surface on those modules' maps via a portable overlay.
- Demo seeds — two published apps (Asset Inspection → Smart City; Port & Vessel Inspection → Maritime) with sample captures.
- Flutter mobile app — schema-driven capture, geometry editor, photos + signatures, offline queue, offline basemap caching, background sync, OIDC/PKCE auth, CI-compiled on Android (APK distributed via Firebase App Distribution).
- CI/distribution — Android CI workflow; iOS CI workflow (signed upload to TestFlight gated on Apple secrets).
- Git infra fix —
.gitattributes(*.sql text eol=lf) preventing sqlx checksum failures from Windowsautocrlf.
Data model
(migration 0009_field_init.sql)
Two new tables join the data fabric:
field_apps — a versioned field-app
definition authored by a Tech Admin:
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | gen_random_uuid() |
tenant_id |
TEXT | Tenant scope |
slug |
TEXT | URL-safe identifier; UNIQUE(tenant_id, slug) |
name |
TEXT | Human label |
description |
TEXT | Optional |
definition |
JSONB | {"layers":[{key, label, geometry: point|line|polygon, color, fields:[{key, label, type, required, options?}]}]} |
version |
INTEGER | Bumped automatically whenever definition changes |
status |
TEXT | draft or published |
owning_team |
TEXT | Group-access scope (same pattern as all tenant data) |
created_at / updated_at |
TIMESTAMPTZ |
field_features — captured ground truth
synced from the mobile app:
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | Server-assigned |
client_id |
TEXT | Mobile-generated; UNIQUE(tenant_id, client_id) — the
idempotency key for store-and-forward |
tenant_id |
TEXT | |
app_id |
UUID FK | → field_apps(id) ON DELETE CASCADE |
layer_key |
TEXT | Which layer within the app definition |
geometry |
JSONB | GeoJSON geometry |
properties |
JSONB | Captured field values |
captured_by |
TEXT | User id (from JWT at sync time) |
captured_at |
TIMESTAMPTZ | Device timestamp |
device |
TEXT | Optional device identifier |
accuracy_m |
DOUBLE PRECISION | Optional GPS accuracy |
owning_team |
TEXT | Forwarded from the capture batch |
synced_at |
TIMESTAMPTZ | Server receive time |
Both tables carry (tenant_id, owning_team) indexes and
follow the same
owning_team IS NULL OR owning_team = ANY($groups)
group-access filter as all other Cartos tenant data (see
docs/group-access-design.md).
Field types supported in the definition schema:
text, number, choice
(comma-separated options), date,
boolean, photo, signature.
Data layer
(crates/cartos-data/src/field.rs)
list_apps(pool, tenant_id, groups)— tenant + group-filtered list.get_app(pool, tenant_id, slug)— single app by slug (no group filter — slug is needed by the mobile pull regardless of owning_team).create_app(pool, tenant_id, slug, name, description, owning_team)— insert.update_app(pool, tenant_id, slug, name?, description?, status?, definition?)— partial update; bumpsversiononly whendefinitionis supplied (viaCASE WHEN $6::jsonb IS NOT NULL THEN version + 1 ELSE version END).delete_app(pool, tenant_id, slug)— hard delete (cascades to features).sync_features(pool, tenant_id, app_id, captured_by, captures)— inserts a batch ofCaptureInputs inside a single transaction; each row usesON CONFLICT (tenant_id, client_id) DO NOTHINGso the call is idempotent and safe to replay. Returns the count of newly inserted rows.list_features(pool, tenant_id, app_id, groups, limit)— group-filtered, ordered bycaptured_at DESC, capped at 5 000.
API routes
(crates/cartos-api/src/field.rs,
src/media.rs)
Config operations require tech_admin
(or full admin); capture sync, feature reads, and media
endpoints are open to any authenticated tenant
user.
| Method | Route | Auth | Purpose |
|---|---|---|---|
| GET | /api/field/apps |
any tenant user | List all apps visible to the caller's groups (includes
modules array) |
| POST | /api/field/apps |
tech_admin | Create a new field app (slug + name required) |
| GET | /api/field/apps/{slug} |
any tenant user | Fetch a single app with its module assignments (mobile pulls the definition here) |
| PATCH | /api/field/apps/{slug} |
tech_admin | Update name, description, status, or definition; bumps version when definition changes |
| DELETE | /api/field/apps/{slug} |
tech_admin | Delete app and all its captured features (cascade) |
| PUT | /api/field/apps/{slug}/modules |
tech_admin | Set which catalog modules this app surfaces in (replaces the full set; validates against the module catalog) |
| POST | /api/field/apps/{slug}/sync |
any tenant user | Store-and-forward capture batch; idempotent. Returns
{received, inserted, duplicates} |
| GET | /api/field/apps/{slug}/features |
any tenant user | List captured features (default limit 1 000, max 5 000). Optional
?limit=N |
| GET | /api/field/captures/{module_id} |
any tenant user (host-module gate) | Returns all apps assigned to module_id + their
features. Gated in-handler by the caller's effective
modules (not cap_guard) — so a Smart City user reads Smart
City's field data without the field module. |
| POST | /api/field/media |
any tenant user | Raw-body upload (up to 25 MB; extension from ?ext= or
Content-Type). Returns {key, url, bytes, content_type}.
Files stored under CARTOS_MEDIA_DIR/{tenant}/{uuid}.{ext}
(swappable to MinIO/S3). |
| GET | /api/field/media/{key} |
any tenant user | Stream media back. Tenant-scoped: the key must start with the
caller's tenant_id/ prefix; .. is
rejected. |
ApiError::NotFound was added to the error enum to
produce clean HTTP 404 responses on unknown slugs.
Web
config console
(cartos-web/src/modules/admin/FieldAppsConfig.tsx)
A Field Apps tab appears in Admin · Configuration
alongside Modules, Features, and Users & Teams. It is gated on
tech_admin (the same check as the Features tab). The
component presents a two-pane layout:
- Left pane — app list: shows every field app
(
slug · vN · status) for this tenant. An inline create form takes a slug and a display name. - Right pane — definition editor: shown when an app
is selected. Editable fields: name, description, and status
(
draft/published) via a dropdown. The current version number is displayed read-only. A Save button issues aPATCHand shows the new version number on success.- Layers: add/remove layers; per-layer: key, label, geometry type (point / line / polygon — switches the default colour swatch), and a colour picker.
- Fields per layer: add/remove schema fields;
per-field: key, label, type (text / number / choice / date / boolean /
photo / signature), a
requiredcheckbox, and a comma-separatedoptionsinput (shown only forchoice). - Delete app: guarded by a confirm dialog; hard-deletes the app and all its captured features.
- Surfaces in modules: a multi-select listing every
catalog module. Selections call
PUT /api/field/apps/{slug}/modulesand persist viafield_app_modules. Apps assigned here appear in those modules'FieldCapturesOverlayand in thelist_field_appsagent tool when scoped to that module.
The mobile app pulls the published definition from
GET /api/field/apps/{slug} and submits captures (with media
keys already resolved) to
POST /api/field/apps/{slug}/sync.
Capability integration
Cartos Field is a first-class catalog module
(field, "Cartos Field", vertical field-ops) —
not a bolt-on. It plugs into all four capability gates:
- Catalog (
cartos-core): thefieldmodule ships withsatelliteas a default feature; new catalog modules are off by default until a Provider Admin enables them. - Route gate (
cap_guard): every/api/field/*route requires thefieldmodule enabled for the caller's tenant (403 otherwise), except/api/field/captures/{module_id}whichcap_guardexempts — that route gates in-handler by the host module instead, so users of Smart City, Maritime, etc. can read their module's field data without thefieldmodule. - Nav gate (
cartos-web): the "Cartos Field" nav entry +/fieldroute are auto-filtered against/api/capabilitiesenabled.modules. - Seeds:
migration 0010_enable_field_module.sqlenablesfield+satellitefor the dev tenant.migration 0011_field_app_modules.sqladds thefield_app_modulesmany-to-many table (tenant_id, app_id, module_id).
Analyst
web module
(cartos-web/src/modules/field/FieldModule.tsx)
A native MapLibre module at /field that closes the loop
config → capture → sync → analyst map. It lists the
tenant's published apps, and for the selected app renders its captured
field_features on the map — points, lines, and polygons
coloured by their layer's definition colour — with a click popup showing
each feature's attributes (including photo/signature thumbnails via
authenticated blob fetch using api().getBlob) plus capture
lineage (who / when / device / accuracy). A DockablePanel lists apps and
per-layer capture counts; the map fits bounds to the data.
Portable
captures overlay
(cartos-web/src/features/field-captures/FieldCapturesOverlay.tsx)
A self-contained portable React component that renders a host
module's assigned field captures on that module's MapLibre map.
It calls GET /api/field/captures/{module_id}, colours
features by their layer definition colour, and provides click-to-detail
with attribute table and photo/signature thumbnails (using
MediaImage, same authenticated getBlob pattern
as the analyst module). It is currently mounted in Smart City,
Maritime, and Emergency Services. The component is namespaced
(stable DockablePanel id per module) and is self-contained
— no coupling to the host module beyond a map ref and the module id.
Media backend
(crates/cartos-api/src/media.rs)
Photos and signatures flow through the API rather than directly to object storage, so the mobile contract is storage-backend-agnostic:
- Upload (
POST /api/field/media): raw body (one file per request, up to 25 MB). Extension is resolved from?ext=query param or theContent-Typeheader (jpeg/png/webp/heic/gif/pdf). File is written to$CARTOS_MEDIA_DIR/{tenant_id}/{uuid}.{ext}and the response carries{key, url, bytes, content_type}. Storage is swappable to MinIO/S3 without changing the mobile contract. - Download (
GET /api/field/media/{key}): streams the file back with the correct MIME type. Tenant isolation + path-traversal guard: the key must start with the caller'stenant_id/and..is rejected (403 otherwise).
Both routes sit under /api/field/ and are therefore
field-module gated (except the
captures/{module} carve-out described above).
Field
Apps → Modules (migration 0011_field_app_modules.sql)
A many-to-many table
field_app_modules (tenant_id, app_id, module_id) lets a
Tech Admin assign each field app to one or more platform modules.
Data-layer helpers:
set_app_modules(pool, tenant_id, app_id, modules)— replaces the full set (delete + re-insert in one transaction).app_modules(pool, tenant_id, app_id)— list modules for one app.all_app_modules(pool, tenant_id)— used bylist_appsto graftmodulesonto every app response in one query.apps_for_module(pool, tenant_id, module_id, groups)— used by theGET /api/field/captures/{module}handler and thelist_field_appsagent tool.
Seed assignments:
asset-inspection → smart-city;
port-inspection → maritime.
Agent tools
(cartos-agent)
Two cross-module tools (module returns
None — always exposed regardless of which module is active)
let the agent reason about field data:
list_field_apps— discover configured apps (slug, name, version, status, layer count). Accepts an optionalmoduleargument (e.g."smart-city") to scope the list to apps assigned to that module — useful when the user is working in a specific module context.summarize_field_captures(slug)— total synced features + a per-layer breakdown (with defined labels) + the latest capture time.
Demo seed
(cartos-cli seed)
seed_field adds two published apps with captured
features, both idempotent via deterministic UUIDs:
- Asset Inspection (
asset-inspection, assigned tosmart-city): inspection point layer (condition choice + notes + photo) + defect-run line layer (severity choice). Four captures around Reem Island (3 × inspection, 1 × defect line). - Port & Vessel Inspection
(
port-inspection, assigned tomaritime): vessel check point layer (vessel name + flag + status choice + photo) + spill area polygon layer (severity + notes). Four captures at Khalifa Port (3 × vessel points, 1 × spill polygon).
Flutter mobile app
(cartos-field-mobile/)
The native client is a Flutter app
(com.cartos.field). It is CI-compiled and the Android APK
is live. Key source files:
lib/models.dart—FieldApp,LayerDef,FieldDef,Capture,PendingMedia(photos/signatures queued for upload on sync).lib/api/field_api.dart— typed client for all/api/field/*endpoints includinguploadMedia.lib/db/local_store.dart— sqflite offline capture queue; captures are written locally first and marked synced only after the server confirms.lib/sync/sync_service.dart— store-and-forward sync: uploads pending media files first (writing returned keys intoproperties), then pushes the capture batch (idempotent via device-generatedclient_id). Safe to replay.lib/sync/background_sync.dart—workmanagerperiodic task (15-min on Android; opportunistic BGTaskScheduler on iOS). Drains the offline queue from a headless isolate using a stored refresh token — no UI needed.lib/auth/auth_service.dart— OIDC Authorization-Code + PKCE against the same Keycloak realm (cartos-mobileclient). Sovereign IdP federation drops in upstream for free when that design is implemented.lib/screens/capture_screen.dart— schema-driven capture form: layer picker, schema-typed field inputs (text / number / choice / date / boolean / photo viaimage_picker/ signature via thesignaturepad), geometry section. Point layers offer "Capture here" (current GPS fix) or "Pick on map"; line/polygon layers require the map geometry editor. Capture is written to the local queue first, surviving offline.lib/screens/capture_map_screen.dart—maplibre_glgeometry editor. Tap to add vertices (point: one tap replaces; line: ordered polyline ≥ 2 vertices; polygon: closed ring ≥ 3 vertices, auto-closed). Undo / Clear toolbar. "Cache this area offline" button triggers a basemap tile download for the current view.lib/offline/offline_maps.dart—OfflineMapswraps the MapLibre offline-region API:downloadArea(bounds, name, minZoom=10, maxZoom=15)with a progress callback. A progress dialog is shown in-app. Regions are listed and deletable from the Offline maps manager screen (offline_maps_screen.dart).
CI / distribution:
.github/workflows/field-mobile-ci.yml(Android,ubuntu-latest): runs on every push tocartos-field-mobile/**. Generates the Android platform shell (flutter create --platforms=android), injects theflutter_appauthredirect scheme, bumps Kotlin to 1.9.22 (required bymaplibre_gl), adds runtime permissions (INTERNET, ACCESS_FINE_LOCATION, CAMERA), runsflutter pub get+flutter analyze(non-blocking), thenflutter build apk --debug. Publishesapp-debug.apkas a build artifact. Firebase App Distribution step (usingwzieba/Firebase-Distribution-Github-Action) distributes to thefield-testersgroup on merge-to-main or manual dispatch when repo secretsFIREBASE_ANDROID_APP_IDandFIREBASE_SERVICE_ACCOUNTare set. Skips silently until both are present..github/workflows/field-ios-testflight.yml(iOS,macos-14): runs a no-signing compile check (flutter build ios --release --no-codesign). Signed build + TestFlight upload (viaapple-actions/upload-testflight-build) are gated on Apple secrets (APPSTORE_ISSUER_ID,APPSTORE_KEY_ID,APPSTORE_API_PRIVATE_KEY,IOS_DIST_CERT_P12,IOS_DIST_CERT_PASSWORD,IOS_TEAM_ID). TBD pending Apple Developer enrollment. Run on demand only (workflow_dispatch) as of 2026-07-29 — amacos-14runner costs 10× a Linux one and the compile step is not gated on the secrets, so it was billing a full iOS release build on every mobile push while unable to ship anything. Trigger it withgh workflow run "Field mobile — iOS / TestFlight" --ref main; restore the push trigger once the six secrets are set and one manual run is green. The Android job below still runs on every mobile push.
Remaining 3B items: on-device Android field test (APK distributable now); iOS TestFlight (pending Apple enrollment); minor definition-version reconciliation.
8. The modules
The canonical nav lineup (reorderable — see §9):
| Module | Type | What it surfaces |
|---|---|---|
| Smart City | Native (MapLibre + dashboard) | Assets, sensors, incidents & jurisdictions with live telemetry, KPIs, sensor-reading charts, AI summaries, and Overture Maps overlays with 3D buildings. All feature panels capability-gated (see §7.4). |
| Situational Awareness | Cesium iframe | A photoreal 3D common operating picture in a streaming globe (bridge
to ../cesium_project_SPS). Catalog
default_features: [] — this is the EarthWatch3D iframe;
native MapLibre overlays (analysis, compare, export…) do not apply.
External-layer and satellite pushes are handled via the bridge panels,
not capability features. |
| Emergency Services | Native (MapLibre + ops dashboard) | Units, incidents (+ history window), traffic & CCTV cameras with a live operations dashboard. |
| Maritime | Native (MapLibre + dashboard) | Vessels, ports & port incidents across territorial waters. Historic AIS analytics overlay (track replay, density heatmap, port-call counts — see §8.2). Can carry the Overture overlay as an advanced cross-added feature (Tech Admin). |
| Maritime Domain Awareness | Cesium iframe | Wide-area maritime coverage, tracks & context rendered in 3D. |
| Cartos Field | Native (MapLibre) | Field-captured ground truth: published apps + their captured
features (point/line/polygon) coloured by layer, with attribute +
lineage + photo/signature thumbnail popups (see §7.5). Capability-gated
on the field module. A portable
FieldCapturesOverlay also surfaces each module's assigned
captures directly on Smart City, Maritime, and Emergency Services
maps. |
| Energy | Planned | Disabled nav placeholder (Phase 1+). |
| Cartos Grid (nine modules) | Native (MapLibre + configurable KPI dashboard) | The Electric Utility edition — nine switchable department surfaces
on the shared GridModule.tsx component. See §8.1. |
| Cartos Tower, Milepost & Blueprint (29 modules) | Native (MapLibre + configurable KPI dashboard) | Three more industry editions — Airport (10), Transportation/DOT
(10), Infrastructure PM/AEC (9) — switchable department surfaces on the
shared IndustryModule.tsx component. See §8.3. |
| Cartos Africa / One Africa (nine modules) | Native (MapLibre + IndustryModule, country-scope +
foundation-layer overlays) |
Standalone continental Government edition for African governments, NMAs, and ministries. See §8.4. |
| Cartos Guardian (five modules) | Native (MapLibre + IndustryModule, strike-risk
overlay) |
UAE critical-infrastructure protection, per-site strike-risk model, Strait of Hormuz focus. See §8.5. |
| Cartos Sentry (three modules) | Native (MapLibre + IndustryModule, monitoring/incident
overlays) |
Reactive crisis monitoring — optical/SAR/thermal change detection watching Guardian's highest-risk assets. See §8.6. |
| Cartos Harvest (seven modules) | Native (MapLibre + IndustryModule, country-scope +
foundation-layer + STAC crop panels) |
Agriculture intelligence — STAC-native crop
type/health/yield/water/boundaries/risk. Merged to
main (tag cartos-harvest-v1) and LIVE at
harvest.cartosatlas.com. See §8.7. |
| Admin | Native | Four-tab config surface — Modules (Provider Admin), Features (Tech Admin, with cross-add picker), Field Apps (Tech Admin — see §7.5), Users & Teams (User Admin, incl. per-user module access). Not in the module nav; opened via the user badge. Tabs shown only for the roles the caller holds. |
Overture Maps overlay (portable feature)
The Overture overlay is a portable feature component
(cartos-web/src/features/overture/OvertureOverlay.tsx)
mounted by any module that has the <module>/overture
capability enabled. It currently appears in Smart City (default feature)
and can be cross-added to Maritime or any other module by a Tech Admin.
The component owns its own MapLibre layers, 3D buildings logic, and
dockable control panel; the mounting module only passes a map ref and a
namespaced panel id.
Architecture: a Python/DuckDB FastAPI sidecar
(overture-sidecar/main.py, port :8200) queries
the public Overture GeoParquet directly from S3 via DuckDB spatial,
snaps the viewport bbox to a ~2 km grid for cache reuse, and returns
GeoJSON. cartos-api proxies it at
GET /api/overture/{theme} (auth required). The sidecar is
the first instance of the "Python sidecar" shape the platform
anticipates for heavy data work (DuckDB + parquet kept out of the Rust
hot path). In production it runs as the overture-sidecar
service in docker-compose.prod.yml; the API is pointed at
it via OVERTURE_SIDECAR_URL.
Four themes (independent toggles, each with a per-theme zoom gate below which the panel doesn't load):
| Theme | Geometry | Zoom gate | Rendering |
|---|---|---|---|
| Places (POI) | Point | ≥ 12 | Circles with category-grouped colours (9 groups: Food, Shopping, Health, Education, Lodging, Transport, Finance, Civic, Leisure) + name labels at zoom ≥ 15 |
| Transportation | Line | ≥ 13 | Road-class colour + width coding (motorway through cycleway, 11 classes) with zoom-interpolated line width |
| Buildings | Polygon | ≥ 14 | Fill + optional 3D extrusion (see below) |
| Divisions | Polygon | ≥ 9 | Dashed boundary outlines + 5 % fill |
All themes respond to click-for-attributes, showing a floating popup with the feature's Overture properties.
Smart City — 3D buildings
The Buildings theme exposes a 3D buildings sub-panel with three independent, simultaneously-togglable rendering modes:
Overture extrusion (MapLibre fill-extrusion):
- Footprints extruded by height in metres using the Overture
heightattribute; falls back tonum_floors × 3, then a default of 8 m. - Live controls: fill colour, opacity, outline toggle + outline colour.
- "Extrude" toggle switches between a flat fill and the 3D extrusion.
Overture wireframe (deck.gl LineLayer):
- True 3D box edges — base ring, roof ring, and vertical edges — built
from the same footprint GeoJSON, rendered as discrete 3D line segments
via a deck.gl
LineLayeroverlay. Only the structural edges are drawn (no cap triangulation), giving a clean wireframe without spaghetti artefacts. - Uses the same height logic as the extrusion so the wireframe is always aligned with the solid.
- Independent controls: edge colour, line width (pixels), and a "wireframe only (hide fill)" mode that disables the MapLibre extrusion layer while leaving the wire on.
Esri 3D Buildings (deck.gl Tile3DLayer + I3SLoader):
- Photoreal 3D mesh from Esri's free global I3S Building Scene Layer
service
(
basemaps3d.arcgis.com/arcgis/rest/services/Esri3D_Buildings_v1). - Rendered via a
MapboxOverlay(non-interleaved, i.e. deck.gl's own canvas above MapLibre's). A coordinate-system compatibility shim remaps@loaders.gl/i3s4.4 numeric enum values to the string enum expected by@deck.gl/core9.3. - Controls: opacity and an optional tint colour (when disabled, mesh uses its own natural materials).
- Gated at zoom ≥ 15. Below that, Esri streams coarse merged-mesh LODs rather than per-building geometry — the gate prevents users from seeing indistinct blobs. Even at zoom ≥ 15, mesh quality varies by region: well-covered urban areas look detailed; sparse regions may be flat extruded masses.
- Esri 3D and the Overture Buildings theme are independent — both can be enabled simultaneously to compare the photoreal mesh against the Overture extrusion.
New web dependencies added: deck.gl
^9.3.3, @loaders.gl/i3s ^4.4.2,
@loaders.gl/core ^4.4.2
(cartos-web/package.json).
Source files: cartos-web/src/lib/overture.ts (theme
catalogue, colour expressions, fetch helper),
cartos-web/src/lib/buildings-3d.ts (deck.gl overlay handle
— createBuildings3D → Buildings3DHandle),
cartos-web/src/features/overture/OvertureOverlay.tsx (the
portable component),
cartos-web/src/modules/smart-city/SmartCityModule.tsx
(mounts it).
Native module shape:
<Module>Module.tsx (MapLibre + DockablePanels) +
<Module>Dashboard.tsx. Iframe
modules use lib/iframe-bridge.ts.
8.1 Cartos Grid — Electric Utility edition
Status: built and merged to
main(PR #67 surfaces; PRs #92–#95 live operational data; PRs #102, #104–#109 all seven SoR connectors). Nine department module surfaces, configurable KPI dashboard, capability-gated; plus six live operational data tables (assets, outages, work orders, vegetation segments, parcels, CIP projects) with real PostGIS geometry, tenant/team RLS, live map layers in every department, KPI API, three Grid agent tools, and a full connector registry for seven system-of-record integrations (OMS/ADMS, IBM Maximo, SAP PM, Esri Utility Network, Primavera P6, Microsoft Project, File/CSV import) — all wired end-to-end.
What it is: the first vertical
edition of the Cartos engine, licensed to RAiN
Partners for the US electric utility market. Nine
department-level module surfaces are delivered via one shared
config-driven component
(cartos-web/src/modules/grid/GridModule.tsx) driven by the
config table in modules/grid/modules.ts
(GRID_MODULES). Adding a new department is a new entry in
that config — no new component needed.
The nine department modules
| Module id | Label | Department badge | Panels included | Default map centre |
|---|---|---|---|---|
grid-command |
Grid Command | Executive cockpit | dashboard, basemap, analysis, compare, export | CONUS overview (z4) |
grid-operations |
Network & Asset Operations | Grid operations | dashboard, basemap, analysis, compare, export, pointcloud | Charlotte NC (z10) |
grid-capital |
CIP & Major Projects | Capital improvement program | dashboard, basemap, analysis, compare, export, pointcloud | Charlotte NC (z9) — CIP projects map layer live (P6 + MS Project SoR) |
grid-storm |
Storm Response | Emergency / storm ops | dashboard, basemap, analysis, compare, export | Florida (z6) |
grid-fleet |
Fleet & Field | Fleet / field ops | dashboard, basemap, export, pointcloud | Charlotte NC (z10) |
grid-vegetation |
Vegetation Management | Vegetation / forestry | dashboard, basemap, analysis, compare, export, pointcloud | Central California (z7) |
grid-realestate |
Real Estate Management | Real estate / ROW | dashboard, basemap, analysis, compare, export | Charlotte NC (z9) |
grid-environmental |
Environmental Management | Environmental / permitting | dashboard, basemap, analysis, compare, export | Charlotte NC (z8) |
grid-stakeholder |
Stakeholder Management | Stakeholder / public affairs | dashboard, basemap, export | Charlotte NC (z9) |
Each module renders a HUD bar (module label +
department badge) across the top of the map. The PanelDock
shows only the panels declared for that module (the panels
array in GridModuleConfig); the dashboard
panel is in every module's default set.
Configurable KPI
Dashboard (GridDashboard.tsx)
A new dashboard capability feature
(registered in catalog.rs) that is the default on every
Grid module. The dashboard component:
- Loads a metric catalogue (
METRICS) of ~18 utility KPIs scoped per department (SAIDI, SAIFI, customers-out, open work orders, crew/fleet utilization, asset health, CapEx vs plan, projects status, hardening miles, vegetation risk, ROW encroachments, environmental permits, stakeholder items, and more). - On mount, loads the persisted card layout from
localStorage(key:cartos.grid-dashboard.<moduleId>.<userId>). Falls back to the top-6 department-relevant metrics. - Edit mode (toggle with the "Edit" button): add a
KPI from a dropdown picker (scoped to the current module), remove a card
(✕), switch its chart type via an inline
<select>, and drag-to-reorder cards. "Reset" returns to the default 6 cards. - Five chart types via Apache ECharts
(
echarts-for-react):stat(headline number + delta vs prior period, colour-coded by good/bad direction),bar,line,pie(donut),gauge. - The toolbar always shows a note: "Sample KPIs — bind to live map data as layers ship" (values are representative sample data; the metric structure is ready to accept live API data when operational layers arrive).
- Layout (card order + chart type choices) persists per user + module in localStorage; a different user or a different Grid module gets an independent dashboard.
Engine reuse
All nine Grid modules inherit every engine cross-cutting feature via
the shared GridModule.tsx: theme-matched basemaps, the
analysis / compare / map-export /
point-cloud / dashboard overlays from
PanelDock, the full capability-gate system (gated per
grid-<dept>/<feature>), the agent chat panel,
and the platform theme switcher. Three Grid-specific agent
tools are now registered (cross-module — module()
returns None so they surface on every Grid department):
grid_kpis, grid_active_outages, and
grid_open_work_orders (see §7 — The AI agent, for
descriptions).
Live operational data (PRs #92–#95)
Migration
0023_grid_operational_data.sql adds five
operational tables; migration
0029_grid_projects_p6.sql adds a sixth. Together
these six tables make every department map surface live:
| Table | Geometry | Key fields | What it holds |
|---|---|---|---|
grid_assets |
Geometry(Geometry,4326) — point or LineString |
kind (transformer/pole/conductor/switch/substation),
status, health (healthy/at_risk/critical),
install_year, source,
external_ref |
Network assets — point equipment and conductor feeder lines.
source/external_ref added by migration 0028
(Esri UN) to support idempotent upserts |
grid_outages |
Point |
status (active/restoring/resolved), cause,
customers_affected, started_at,
restored_at, source,
external_ref, est_restoration,
feeder |
Active and historic outages.
source/external_ref/est_restoration/feeder
added by migration 0025 (OMS/ADMS) |
grid_work_orders |
Point |
kind (inspect/repair/replace/veg/other),
status (open/in_progress/done), priority,
crew, asset_id FK, due_date,
source, external_ref |
Field work orders, optionally linked to an asset.
source/external_ref added by migration 0026
(Maximo) |
grid_veg_segments |
LineString |
risk (critical/high/moderate/low),
span_miles, last_trimmed |
Vegetation encroachment spans along transmission/distribution corridors |
grid_parcels |
Polygon |
kind (easement/fee/row), encroachment
(none/structures/vegetation/access/other), area_acres |
Land parcels, easements, and right-of-way |
grid_projects |
Point |
name, phase
(planning/design/permitting/construction/closeout), status
(on_track/at_risk/delayed/complete), budget_musd,
pct_complete, start_date,
finish_date, source,
external_ref |
CIP capital-improvement projects. Fed by Primavera P6 and Microsoft Project SoR connectors |
All five tables are tenant + team scoped
(owning_team IS NULL OR owning_team = ANY($groups)) with
the tenant_team_isolation Postgres RLS policy (same pattern
as all other Cartos data). GiST indexes on geom and
compound indexes on (tenant_id, owning_team) and
(tenant_id, <status-key>). Seeded for
tnt_dev_local with representative rows at each department's
map centre (Charlotte NC, central Florida, central California).
API endpoints
(crates/cartos-api/src/grid.rs, registered in
main.rs):
| Method | Route | Returns |
|---|---|---|
| GET | /api/grid/assets |
Assets as {items: GridAssetRow[]} (geometry as
geojson string) |
| GET | /api/grid/outages |
Outages as {items: GridOutageRow[]} |
| GET | /api/grid/work-orders |
Work orders as {items: GridWorkOrderRow[]} |
| GET | /api/grid/vegetation |
Vegetation segments as {items: GridVegRow[]} |
| GET | /api/grid/parcels |
Parcels as {items: GridParcelRow[]} |
| GET | /api/grid/projects |
CIP projects as {items: GridProjectRow[]} |
| GET | /api/grid/kpi |
Aggregate GridKpis — assets total/at-risk by kind,
active outages + customers out, open work orders by kind, vegetation
segment count by risk level, parcels total + encroached count,
plus projects_total / projects_active
/ projects_budget_musd |
All endpoints require a Bearer JWT; they are not
gated by a specific Grid module capability (any authenticated tenant
user can read). Each query runs inside a set_tenant_session
transaction (RLS session vars) with a belt-and-braces
owning_team IS NULL OR owning_team = ANY($groups) WHERE
filter. Limit: 5 000 rows.
Frontend — Grid Data panel (lib/grid-data.ts,
GridModule.tsx):
GRID_LAYER_METAmaps each of the fiveGridLayerKeys (assets,outages,work_orders,vegetation,parcels) to its endpoint URL and a swatch colour.DEPT_LAYERSdeclares which data layers each department surfaces by default: e.g.grid-commanddefaults to["assets","outages"],grid-vegetationto["vegetation"],grid-environmentalto["parcels","vegetation"],grid-capitalto["projects","assets"].fetchGridLayerfetches a layer endpoint and builds aGeoJSON.FeatureCollection(parses each row'sgeojsonstring).reconcileGridLayersadds/removes MapLibregrid-*sources and layers to match the active set, with per-layer paint expressions: assets → health-coded circles (#e2607acritical /#e9a23bat_risk /#2ecc96healthy) + conductor lines; outages → status-coded circles with radius proportional tocustomers_affected; work orders → priority-coded circles; vegetation → risk-coded lines; parcels → encroachment-coded fill + outline polygons.fetchGridKpiscallsGET /api/grid/kpiand returns theGridKpisstruct. The dashboard KPI cards inGridDashboard.tsxare now bound to this live data —customers_out,outages_active,work_orders_open,projects_total,projects_active,projects_budget_musd, etc. map directly to the API response fields.- A Grid Data panel in the
PanelDocklists the available layers with toggle checkboxes and colour swatches; toggling a layer adds/removes it from the active set and triggers a fetch if the data has not yet been loaded for that department. - Clicking a
grid-*circle or polygon on the map opens a detail popup with the row's key attributes.
Cloud-native data layer — object-storage format spikes (Grid Data panel, PRs #113–#117)
Status: spikes / proofs-of-concept. Demo data only (synthetic Grid assets + CIP projects). Public-read MinIO bucket in dev; not yet access-gated or hardened. See §9.5 for the full implementation reference.
The Grid Data panel has a second group of toggles below the live operational layer checkboxes, labelled "Cloud-native (spike)". These drive five object-storage-native format experiments — zero new services; the browser range-reads everything directly from MinIO:
- PMTiles · MinIO (assets): checkbox toggle —
addPmtilesAssets/removePmtilesAssetsfromlib/pmtiles-layer.ts; rendersgrid-assets.pmtilesvia thepmtiles://protocol. - FlatGeobuf · MinIO (bbox-streamed): checkbox toggle
—
loadFgbAssets/removeFgbAssetsfromlib/flatgeobuf-layer.ts; re-reads on mapmoveend(only the viewport bbox is fetched). - COG · MinIO (raster): checkbox toggle —
addCog/removeCogfromlib/cog-layer.ts; rendersdemo-surface.tifviacog://.
The DuckDB-WASM panel is launched from the
grid-capital (CIP & Major Projects) module (distinct
launcher button in the panel dock). It loads
grid-projects.parquet from MinIO into an in-browser DuckDB
instance and presents a SQL query input; results render in a table. No
analytics server; PostGIS is untouched.
The Geoprocessing panel (buffer / convex hull /
centroid via Turf.js) and Export panel (GeoJSON /
Shapefile / FlatGeobuf / CSV client-side + GeoPackage from MinIO) are
available on all Grid modules via the panel dock, as portable
features/ components following the standard
DockablePanel contract.
Systems-of-record (SoR) integrations (PRs #102, #104–#109)
All seven connectors are now wired end-to-end. A new
grid_integrationsregistry table tracks each connector's status, last sync timestamp, and last sync result. Six connectors run realistic mock/synthetic feeds that demonstrate the live-integration pattern (ready for production credential swap); the seventh (File/CSV) ingests real uploaded data today. Every sync is idempotent — upsert keyed by(tenant_id, external_ref)— and tenant/RLS scoped.
Connector registry table — grid_integrations
(migration 0025):
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | |
tenant_id / owning_team |
TEXT | Tenant + group scope; full tenant_team_isolation
RLS |
kind |
TEXT | oms_adms / esri_un / maximo /
sap / p6 / msproject /
file_csv |
name |
TEXT | Human-readable connector label |
target |
TEXT | Which Grid table the connector feeds |
status |
TEXT | configured / connected /
syncing / error |
config |
JSONB | Vendor/feed metadata |
last_sync |
TIMESTAMPTZ | Time of most recent sync (null until first sync) |
last_result |
JSONB | Result summary from the last sync call |
The seven connectors:
| # | Kind | Connector | Target table | Migration | Mock rows | Notes |
|---|---|---|---|---|---|---|
| 1 | oms_adms |
OMS / ADMS (outage management) | grid_outages |
0025 | ~45 distribution incidents | Status/cause/customers evolve each sync; drives storm map + customers-out KPI |
| 2 | maximo |
IBM Maximo (work management) | grid_work_orders |
0026 | ~60 work orders | source='maximo', ref prefix MAX-;
open/critical-open counts |
| 3 | sap |
SAP PM (plant maintenance) | grid_work_orders |
0027 | ~50 work orders | source='sap', ref prefix SAP-; shares the
sync_work_orders helper |
| 4 | esri_un |
Esri Utility Network | grid_assets |
0028 | ~80 devices | Kind/status/health/install_year evolve; drives asset-health KPI + assets map layer |
| 5 | p6 |
Primavera P6 (CIP schedule) | grid_projects |
0029 | ~40 CIP projects | Introduced the new grid_projects table; phase/status/%
complete evolve |
| 6 | msproject |
Microsoft Project Schedule | grid_projects |
0030 | ~30 CIP projects | source='msproject', ref prefix MSP-;
shares the sync_projects helper |
| 7 | file_csv |
File / CSV import | grid_work_orders |
0031 | (real data) | The only non-mock connector; operator uploads a CSV; bad rows skipped + counted |
CSV import (connector 7 —
file_csv):
Operator uploads a work-order CSV via the "Import
CSV" file-picker on the dashboard SoR chip. The API
(crates/cartos-api/src/grid.rs → import_csv) parses a
header-driven comma-separated file and batches a single
UNNEST upsert into grid_work_orders.
Required CSV columns: external_ref, lon,
lat. Optional columns: kind,
status, priority, crew,
due_date — defaulted/normalised if absent or outside the
allowed value set.
A column template is available at
docs/samples/grid-work-orders.csv:
external_ref,kind,status,priority,crew,due_date,lon,lat
CSV-1001,inspect,open,high,Crew 7,2026-07-15,-80.84,35.23
Re-uploading the same file updates existing rows
(upsert on external_ref) rather than creating duplicates.
The API response carries
{imported, created, updated, skipped}.
New grid_projects table (migration
0029):
CIP capital-improvement projects table, fed by both P6 and MS Project schedulers:
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | |
tenant_id / owning_team |
TEXT | Tenant + group scope + RLS |
name |
TEXT | Project name (e.g. "CIP Substation 001") |
phase |
TEXT | planning / design /
permitting / construction /
closeout |
status |
TEXT | on_track / at_risk / delayed
/ complete |
budget_musd |
NUMERIC(12,2) | Capital budget in $M |
pct_complete |
INT | Percentage complete (0–100) |
start_date / finish_date |
DATE | Planned schedule |
source |
TEXT | p6 or msproject |
external_ref |
TEXT | Upsert key; partial unique index
(tenant_id, external_ref) |
geom |
Geometry(Point,4326) |
Project location |
The grid_projects table carries the full
tenant_team_isolation RLS policy (same pattern as all other
Grid tables). GiST index on geom; compound indexes on
(tenant_id, status) and
(tenant_id, owning_team).
New Grid KPIs from grid_projects:
The GET /api/grid/kpi response now includes three
additional fields:
| Field | Type | What it counts |
|---|---|---|
projects_total |
i64 | All CIP projects (any source) |
projects_active |
i64 | Projects where status != 'complete' |
projects_budget_musd |
f64 | Sum of budget_musd for active projects ($M) |
CIP projects map layer (grid-capital
module):
DEPT_LAYERS["grid-capital"] now defaults to
["projects", "assets"]. Project features are rendered as
status-coded circles on the map: delayed → red,
at_risk → amber, complete → grey,
on_track → violet (#a78bfa). Clicking a
project circle opens the standard Grid detail popup. This makes the
grid-capital (CIP & Major Projects) module
live.
Frontend — dashboard SoR strip
(GridDashboard.tsx):
The GridDashboard component loads connectors via
fetchIntegrations and renders a "Systems of
record:" chip strip below the KPI cards:
- Each chip shows the connector's short name + a status badge ("● live" or "pending").
- A chip tooltip shows the target table and last sync time.
- Clicking the sync button on a connected mock chip
calls
syncIntegration→POST /api/grid/integrations/{kind}/syncand refreshes both the KPI panel and the chip strip. - The
file_csvchip shows an "Import CSV" label that triggers a<input type="file">file-picker; the selected file is read as text and passed toimportGridCsv→POST /api/grid/integrations/file_csv/import. The result message (e.g. "Imported 5 (+3 ~2)") is shown inline for a few seconds.
API surface (SoR endpoints):
| Method | Route | Auth | Purpose |
|---|---|---|---|
| GET | /api/grid/integrations |
any tenant user | List connectors + status / last_sync / last_result |
| POST | /api/grid/integrations |
tech_admin | Register a connector — body
{kind, name, target, status?} |
| PATCH | /api/grid/integrations/{kind} |
tech_admin | Partial update — name / target / status / config (null args = keep existing) |
| DELETE | /api/grid/integrations/{kind} |
tech_admin | Remove a connector registration |
| POST | /api/grid/integrations/{kind}/sync |
any tenant user | Run a mock connector sync; returns sync-result JSON |
| POST | /api/grid/integrations/file_csv/import |
any tenant user | Upload a work-order CSV; body {csv: "..."} |
| GET | /api/grid/projects |
any tenant user | CIP project rows as {items: GridProjectRow[]} |
Calling sync on file_csv returns a
400 Bad Request with a clear message directing the caller
to the import endpoint instead.
Opt-in continuous mock schedulers
(crates/cartos-api/src/oms_sync.rs):
Six background tasks — one per mock connector — can be enabled
individually via env vars to make map layers and KPIs evolve
continuously as if receiving live feeds. file_csv has no
scheduler (it is upload-driven).
| Connector | Env var to enable | Tenant var | Interval var | Default interval |
|---|---|---|---|---|
| OMS/ADMS | CARTOS_OMS_MOCK=1 |
CARTOS_OMS_TENANT |
CARTOS_OMS_INTERVAL_SECS |
120 s |
| IBM Maximo | CARTOS_MAXIMO_MOCK=1 |
CARTOS_MAXIMO_TENANT |
CARTOS_MAXIMO_INTERVAL_SECS |
180 s |
| SAP PM | CARTOS_SAP_MOCK=1 |
CARTOS_SAP_TENANT |
CARTOS_SAP_INTERVAL_SECS |
180 s |
| Esri UN | CARTOS_ESRI_UN_MOCK=1 |
CARTOS_ESRI_UN_TENANT |
CARTOS_ESRI_UN_INTERVAL_SECS |
300 s |
| Primavera P6 | CARTOS_P6_MOCK=1 |
CARTOS_P6_TENANT |
CARTOS_P6_INTERVAL_SECS |
300 s |
| MS Project | CARTOS_MSPROJECT_MOCK=1 |
CARTOS_MSPROJECT_TENANT |
CARTOS_MSPROJECT_INTERVAL_SECS |
300 s |
All tenant vars default to tnt_dev_local; all interval
vars have a minimum of 30 s.
Real connector adapter seam (PR #123)
PR #123 ships the seam that lets any connector flip from mock feed to live system-of-record without any schema or UI change.
connectors/esri_unis the reference implementation — verified against a public ArcGIS Feature Server (fetched and upserted 25 point features intogrid_assets). The other five connectors (oms_adms,maximo,sap,p6,msproject) are still mock feeds; they go live once an operator pastes aservice_urlinto their config.file_csvis upload-driven and does not use this seam.
How it works —
crates/cartos-api/src/connectors/:
The grid::sync handler reads the connector's stored
config via gi::get_config once up front, then calls
connectors::is_real(&config). If
config.service_url is a non-empty string the handler routes
to connectors::real_sync(kind, …); otherwise the existing
mock feed runs unchanged. The gap is explicit: if a connector has a
service_url set but no real adapter is wired yet,
real_sync returns a 400 with a clear message
rather than silently falling back.
// grid::sync (simplified)
let config = gi::get_config(pool, &ctx.tenant_id, &kind).await?;
if connectors::is_real(&config) {
return Ok(Json(connectors::real_sync(&kind, pool, &ctx.tenant_id, &config).await?));
}
// … existing mock feeds below …
RealConnector trait
(connectors/mod.rs):
pub trait RealConnector {
async fn sync(
&self,
pool: &PgPool,
tenant_id: &str,
config: &serde_json::Value,
) -> Result<serde_json::Value, ApiError>;
}Adding a new real adapter = implement RealConnector for
a new struct + add one arm to real_sync. No migration, no
UI change.
connectors/esri_un.rs — reference
implementation:
Queries any ArcGIS Feature/Map Server layer
(config.service_url) for its point features as GeoJSON and
upserts them into grid_assets. Config keys (set via
Admin → Integrations → ⚙):
| Key | Required | Default | Notes |
|---|---|---|---|
service_url |
yes | — | .../FeatureServer/0 or
.../MapServer/0 |
token |
no | — | ArcGIS token for secured services |
where |
no | 1=1 |
SQL filter clause |
id_field |
no | OBJECTID |
Attribute used as the external_ref key |
name_field |
no | — | Attribute used as the asset's display name |
asset_kind |
no | pole |
grid_assets.kind; clamped to the allowed CHECK set |
max |
no | 500 |
resultRecordCount cap |
Scope today: only point features are mapped
(line/polygon geometries are skipped). external_ref is
formatted UN-{id}. The sync response includes
{source, mode:"real", service_url, fetched, upserted, created, updated}.
cartos-data additions (PR #123):
get_config(pool, tenant_id, kind)— reads a connector'sconfigJSONB column; returns{}if the connector is not registered. Called once bygrid::syncbefore the real-vs-mock branch.RealAssetstruct —{external_ref, kind, name, lon, lat}— the minimal shape every real adapter assembles before calling the shared upsert.upsert_real_assets(pool, tenant_id, source, rows)— generic batchUNNESTupsert intogrid_assetskeyed by(tenant_id, external_ref)partial unique index; same idempotent pattern as the mock feeds; returns{created, updated}. Source-tagged so real and mock rows coexist without collision.
Operator workflow — flipping a connector mock → real:
- In Admin → Integrations, click ⚙ on the target
connector (e.g.
esri_un). - Set
{"service_url": "https://…/FeatureServer/0", "token": "…"}in the config field and save (PATCH /api/grid/integrations/{kind}). - Click Sync on the dashboard SoR chip —
grid::syncnow detectsservice_urland runs the real adapter instead of the mock feed. - To revert: clear
service_urlfrom the config and sync — the mock feed resumes.
Data layer
(crates/cartos-data/src/grid_integrations.rs):
list_integrations(pool, tenant_id, groups)— tenant + group-filtered list, connected-first sort.create_integration(pool, tenant_id, kind, name, target, status, config)— register one connector; duplicate kind hits the unique(tenant_id, kind)index added by migration 0032.update_integration(pool, tenant_id, kind, name?, target?, status?, config?)— partial patch viaCOALESCE;NULLargs leave existing values unchanged; returns rows-affected (0 = unknown kind).delete_integration(pool, tenant_id, kind)— remove a connector row; returns rows-affected (0 = unknown kind).sync_oms(pool, tenant_id)— OMS/ADMS mock upsert intogrid_outages.sync_work_orders(pool, tenant_id, source, ref_prefix, n)— shared helper for Maximo + SAP PM.sync_maximo(pool, tenant_id)→sync_work_orders(…, "maximo", "MAX-", 60).sync_sap(pool, tenant_id)→sync_work_orders(…, "sap", "SAP-", 50).sync_esri_un(pool, tenant_id)— Esri UN mock upsert intogrid_assets(~80 devices).sync_projects(pool, tenant_id, source, ref_prefix, n)— shared helper for P6 + MS Project.sync_p6(pool, tenant_id)→sync_projects(…, "p6", "P6-", 40).sync_msproject(pool, tenant_id)→sync_projects(…, "msproject", "MSP-", 30).import_csv_work_orders(pool, tenant_id, rows)— single-batchUNNESTupsert of pre-validatedCsvWorkOrderrows intogrid_work_orders; updatesfile_csvconnector'slast_sync/last_result.
Every function runs inside a set_tenant_session
transaction (RLS session vars) and updates the
grid_integrations row's status,
last_sync, and last_result on completion, so
the dashboard chip strip reflects the outcome immediately.
Source files:
crates/cartos-data/src/grid_integrations.rs ·
crates/cartos-api/src/grid.rs (SoR handlers:
integrations, create_integration,
update_integration, delete_integration,
sync, import_csv) ·
crates/cartos-api/src/oms_sync.rs (six background
schedulers) · cartos-web/src/lib/grid-data.ts
(fetchIntegrations, syncIntegration,
importGridCsv, createIntegration,
updateIntegration, deleteIntegration;
CONNECTOR_KINDS / GRID_TARGET_TABLES /
INTEGRATION_STATUSES constants; projects layer
meta, GridKpis.projects_*) ·
cartos-web/src/modules/admin/IntegrationsConfig.tsx (Admin
→ Integrations tab UI) ·
cartos-web/src/modules/grid/GridDashboard.tsx (SoR chip
strip + Import CSV picker) ·
crates/cartos-data/migrations/0025_grid_integrations_oms.sql
· crates/cartos-data/migrations/0026_grid_maximo_sync.sql ·
crates/cartos-data/migrations/0027_grid_sap_sync.sql ·
crates/cartos-data/migrations/0028_grid_esri_un_sync.sql ·
crates/cartos-data/migrations/0029_grid_projects_p6.sql ·
crates/cartos-data/migrations/0030_grid_msproject_sync.sql
· crates/cartos-data/migrations/0031_grid_file_csv.sql ·
crates/cartos-data/migrations/0032_grid_integrations_unique_kind.sql
· docs/samples/grid-work-orders.csv (CSV column template) ·
crates/cartos-api/src/connectors/mod.rs
(RealConnector trait + is_real +
real_sync registry) ·
crates/cartos-api/src/connectors/esri_un.rs (Esri UN
reference real adapter).
Source files (all §8.1):
cartos-web/src/modules/grid/GridModule.tsx ·
modules/grid/modules.ts ·
modules/grid/GridDashboard.tsx ·
modules/grid/GridDashboard.css ·
modules/grid/GridModule.css ·
cartos-web/src/lib/grid-data.ts ·
crates/cartos-data/src/grid.rs ·
crates/cartos-data/src/grid_integrations.rs ·
crates/cartos-api/src/grid.rs ·
crates/cartos-api/src/oms_sync.rs ·
crates/cartos-api/src/connectors/mod.rs ·
crates/cartos-api/src/connectors/esri_un.rs ·
crates/cartos-agent/src/builtin/grid_kpis.rs ·
grid_active_outages.rs ·
grid_open_work_orders.rs ·
crates/cartos-core/src/catalog.rs (vertical
"utility", nine ModuleDef entries) ·
crates/cartos-data/migrations/0020_enable_grid_modules.sql
·
crates/cartos-data/migrations/0021_grid_modules_phase2.sql
·
crates/cartos-data/migrations/0022_grid_modules_reenable.sql
·
crates/cartos-data/migrations/0023_grid_operational_data.sql
·
crates/cartos-data/migrations/0025_grid_integrations_oms.sql
– 0031_grid_file_csv.sql · cartos-grid-PRD/
(PRD + Capability Brief — deep-integration roadmap).
8.2 Historic AIS analytics — Maritime module
Status: built and merged to
main(PRs #96–#98). Vessel track replay, density heatmap, and port-call analytics are live in the Maritime module via theMaritimeAnalyticsOverlay. The opt-in live AIS ingester (ais_ingest.rs) can continuously append fixes from the VesselFinder feed. MDA (the Cesium iframe module) is a noted gap — it receives live vessels via the iframe bridge but does not yet use the new historic endpoints.
What it is: a portable DockablePanel
overlay
(features/maritime-analytics/MaritimeAnalyticsOverlay.tsx)
mounted in the Maritime module that surfaces three views of the
accumulated vessel_positions time-series:
| View | Description |
|---|---|
| Tracks | Pick a vessel from a dropdown (ordered by fix count in the last N
hours); a track polyline is drawn on the map via
ais-track-* MapLibre sources/layers. A time
scrubber lets the analyst replay the vessel's path
step-by-step; a Play button auto-advances the scrubber.
The track colour encodes SOG (speed over ground). |
| Density | All vessel fixes in the selected window are binned to a 0.05° cell
grid and rendered as a heatmap
(ais-density-heat + circle layer) — bright cells indicate
high-traffic corridors. Cell size is fixed at 0.05° (configurable in
fetchDensity). |
| Port calls | For each seeded port_aoi polygon, the count of distinct
vessels and total fixes within it over the window is returned and shown
as labelled markers (ais-port-*). |
Time window options: 24 h, 48 h,
7 days (168 h). All queries are scoped by the calling
user's tenant_id and groups.
Data model
(migration 0024_vessel_positions.sql)
Two tables follow the standard Cartos tenant/team-scoped shape:
vessel_positions — append-only AIS
time-series:
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | |
tenant_id / owning_team |
TEXT | Tenant + group scope |
mmsi |
BIGINT | Vessel identifier |
name |
TEXT | Vessel name (optional) |
ship_type |
INT | AIS ship type code |
sog |
NUMERIC(5,1) | Speed over ground (knots) |
cog |
INT | Course over ground (degrees) |
heading |
INT | True heading |
ts |
TIMESTAMPTZ | Position fix timestamp |
geom |
Geometry(Point,4326) |
WGS 84 position |
Indexes: (tenant_id, mmsi, ts) (track queries),
(tenant_id, ts) (window scans),
(tenant_id, owning_team), GiST on geom
(port-call spatial join).
port_aois — named polygon AOIs for
port-call detection:
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | |
tenant_id / owning_team |
TEXT | |
name |
TEXT | Port name (e.g. "Jebel Ali") |
geom |
Geometry(Polygon,4326) |
Port boundary polygon |
Both tables carry the tenant_team_isolation RLS policy.
Seeded with 12 vessels × 96 fixes (48 h at 30-min intervals) routed
toward three UAE Gulf ports (Jebel Ali, Khalifa Port, Port Rashid) so
port-call queries return populated results immediately.
API endpoints
(/api/mobility/history/*)
Registered in main.rs alongside the existing
/api/mobility/* live feeds:
| Method | Route | Query params | Returns |
|---|---|---|---|
| GET | /api/mobility/history/vessels |
?hours=48 (1–720) |
VesselSummary[] — distinct vessels (MMSI, name, fix
count, first/last seen) sorted by fix count; max 500 |
| GET | /api/mobility/history/track |
?mmsi=<int>&hours=48 |
TrackPoint[] — ordered positions (ts, lon, lat, sog,
cog, heading) for one vessel; max 5 000 |
| GET | /api/mobility/history/port-calls |
?hours=48 |
PortCall[] — per-port distinct vessel count + total fix
count within the port_aoi polygon |
| GET | /api/mobility/history/density |
?hours=48&cell=0.05 |
DensityCell[] — (lon, lat, count) binned to
cell-degree grid; max 5 000 cells |
mmsi is required for the track endpoint; all others are
optional. hours clamps to 1–720. cell clamps
to 0.005–1.0.
Opt-in
live ingester (crates/cartos-api/src/ais_ingest.rs)
maybe_spawn(db) is called from main at
startup. It no-ops unless CARTOS_AIS_INGEST=1 is set in the
environment, keeping the default deployment quiet. When enabled:
| Env var | Default | Effect |
|---|---|---|
CARTOS_AIS_INGEST |
(unset) | Set to 1 to enable |
CARTOS_AIS_TENANT |
tnt_dev_local |
Tenant to attribute fixes to |
CARTOS_AIS_BBOX |
53.0,23.5,57.0,26.5 (UAE Gulf) |
Bounding box for VesselFinder feed pull |
CARTOS_AIS_INTERVAL_SECS |
300 (5 min) |
Poll interval; minimum 30 s |
The ingester samples fetch_vesselfinder (same function
used by the live Global Mobility feed) and calls
ais::insert_positions to append the current fixes into
vessel_positions. Over time this builds a real historic
dataset that the analytics endpoints can query.
AIS analytics in the MDA Cesium globe (PR #103):
The previously documented gap — "historic AIS not yet surfaced in the
Cesium MDA iframe" — is now closed. PR #103 extends the iframe
bridge with a cartos.ais.set message and adds a
new portable component AisBridgeControls.tsx that surfaces
the same three analytics views (Tracks / Density / Port calls) in any
Cesium-backed module:
AisPayloadtype inlib/iframe-bridge.tscarries one of four modes:track(ordered{lon,lat}polyline + scrubber head + label),density(binned{lon,lat,count}cells),ports(named{lon,lat,vessels}markers), orclear(removes all AIS entities).bridge.setAis(payload)sendscartos.ais.setto the iframe host. The EarthWatch3D Cesium app (../cesium_project_SPS) handles the message and renders tracks, density heatmap, and port markers on the globe.AisBridgeControls.tsx(mounted in the MDA module) replicates the Maritime module's analytics panel controls — vessel picker, time window, mode toggle, time scrubber, Play button — but callsbridge.setAisinstead of drawing MapLibre layers. On unmount it sends{mode: "clear"}to clean up globe entities.
The MDA module now has full feature parity with the native Maritime module for historic AIS analytics, running on the photoreal 3D Cesium globe.
Source files:
cartos-web/src/features/maritime-analytics/MaritimeAnalyticsOverlay.tsx
· MaritimeAnalyticsOverlay.css ·
cartos-web/src/features/maritime-analytics/AisBridgeControls.tsx
· cartos-web/src/lib/ais.ts ·
cartos-web/src/lib/iframe-bridge.ts
(AisPayload type, bridge.setAis) ·
crates/cartos-data/src/ais.rs ·
crates/cartos-api/src/ais.rs ·
crates/cartos-api/src/ais_ingest.rs ·
crates/cartos-data/migrations/0024_vessel_positions.sql.
8.3 Cartos Tower, Milepost & Blueprint — Airport, DOT & AEC editions
Status: built and merged to
main2026-07-07, live on their own subdomains on the stakeholder-demo VM —tower./milepost./blueprint.cartosatlas.com(deploy/Caddyfile.prod; see §2). 29 department module surfaces across three editions, a shared config-drivenIndustryModulecomponent, per-project/portfolio-wide access-grant RBAC for the two project-scoped editions (Milepost, Blueprint), and real OpenStreetMap data seeded into each edition's flagship surface (air-airtraffic/dot-executive/aec-portfolio— see §9.6). Live operational data + connectors (mirroring the Cartos Grid path in §8.1) are the next increment.
What it is: three more vertical editions of the Cartos engine — the same "one shared component + a config table" pattern that built Cartos Grid, applied to three new verticals in one pass:
| Edition | Industry | Modules | Sample geography | Scope model |
|---|---|---|---|---|
| Cartos Tower | Airport | 10 | Dallas–Fort Worth | flat — no portfolio/project split |
| Cartos Milepost | Transportation / DOT | 10 | Indiana | Executive Overview (portfolio) + 9 project-scoped surfaces |
| Cartos Blueprint | Infrastructure delivery / AEC | 9 | Las Vegas | Portfolio View (portfolio) + 8 project-scoped surfaces |
All three ship from one config file,
cartos-web/src/modules/industry/modules.ts —
TOWER_MODULES, MILEPOST_MODULES, and
BLUEPRINT_MODULES (each a
Record<string, IndustryModuleConfig> keyed by module
id), merged into INDUSTRY_MODULES for route generation +
lookup. Each IndustryModuleConfig carries an
id (also the route slug + catalog/DB module_id
— the air- / dot- / aec- prefixes
are retained even though the edition brand is
Tower/Milepost/Blueprint), a label, a
department HUD badge, a one-line blurb, a map
center/zoom, a panels list, 2–4
useCases bullets, sample kpis, and an optional
scope: "portfolio" | "project" (unset = flat, as on every
Tower module).
The
module lineup (from modules.ts — do not hand-maintain
elsewhere)
Cartos Tower (Airport) — ten airfield/landside
departments, DFW-anchored: Tarmac Lighting
(air-lighting), Surface Condition
(air-surface), Lane Striping (air-striping),
Signage (air-signage), Property Lease
(air-lease), Major Projects (air-projects),
Emergency Services (air-emergency, ARFF/SMS), Vehicle
Traffic (air-vehicle, landside/ramp), Air Traffic
(air-airtraffic, airside ops), Weather
(air-weather). Flat — no portfolio/project split; every
module is its own independent surface.
Cartos Milepost (Transportation/DOT) — an Executive Overview
portfolio tab + nine project surfaces, Indiana-anchored:
Executive Overview (dot-executive,
portfolio — every DOT project on one map,
status/health/cost/schedule roll-up by district), then project-scoped:
Pavement Management (dot-pavement), Facility Management
(dot-facility), Lane Striping (dot-striping),
Live Traffic (dot-traffic), Current Roadwork &
Obstructions (dot-roadwork), Major Projects
(dot-projects), Crew Status (dot-crew), Snow
Removal & Treatment (dot-snow), Emergency Response
(dot-emergency).
Cartos Blueprint (Infrastructure PM/AEC) — a Portfolio View +
eight project surfaces, Las Vegas-anchored: Portfolio View
(aec-portfolio, portfolio — every capital
project's status/health + assignable view rights), then project-scoped:
Design Status (aec-design), Site Selection
(aec-site), Constructability Review
(aec-constructability), Environmental & Cultural
(aec-environmental), Construction Inspections
(aec-inspection), Safety Report (aec-safety,
HSE), Stakeholder Management & Access
(aec-stakeholder), Live Traffic (aec-traffic,
construction/haul routes).
Full per-module tables (id / label / department, plus scope for Milepost and Blueprint) are in §7.4.
The
IndustryModule surface
(cartos-web/src/modules/industry/IndustryModule.tsx)
A lean cousin of GridModule — one shared, config-driven
component renders every department in all three editions. Adding a
department is a new entry in modules.ts, not a new
component. Per instance it renders:
- A themed MapLibre map (default basemap
holographic-theme, so it tracks the active platform theme) + a HUD header showing the module'sdepartmentbadge,label, andblurb. - The engine's portable overlays, each capability-gated per module
(
<module-id>/<feature>): Analyst (AnalysisOverlay), Compare (CompareOverlay), Map Export (MapExportOverlay), and the Layer Catalog (CatalogLayersOverlay) — plus a Basemap switcher panel. - A per-module KPI dashboard
(
IndustryDashboard, docked or full-screen via/<moduleId>/dashboard— see §9.7): prepends live tiles fromGET /api/catalog/kpiwhen the module has real catalog data, then the config's representativekpistiles and itsuseCasesbullets. Every industry module now renders the reusableCatalogDashboard(ECharts, attribute breakdowns, computed alerts) withIndustryDashboardas the fallback for modules without catalog data yet (mirroring Cartos Grid's own dashboard, §9.7). - On a
scope: "project"surface: a HUD Project dropdown, populated fromGET /api/industry/projects?edition=— only the projects the caller has been granted (default-deny; see the RBAC model below). Selecting a project flies the map to itslon/lat/zoom. The selected project shows a status pill + health dot; a caller witheditoradminaccess sees an inline ✎ Edit control to change status/health viaPATCH /api/industry/projects/:id(server re-checks the access level;view-only is rejected). - On a
scope: "portfolio"surface: a "⚙ Manage project access" button visible to User-Admins, openingProjectAccessPanelin a dockable panel right on the map — the same grant-management UI as the User-Admin console tab (below).
Edition selection
(cartos-web/src/lib/edition.ts)
The same Edition type used by Cartos Atlas/Grid now
includes tower, milepost, and
blueprint. Selection precedence: a
VITE_CARTOS_EDITION build flag → a ?edition=
query override (persisted in sessionStorage, so a splash
link survives the SPA redirect) → the hostname
(tower. / milepost. /
blueprint.<domain>) → full. Each edition
preset carries a brand wordmark, its modules
nav lineup (the same ids listed above), a home default
route, and a defaultTheme:
| Edition | Brand | Home route | Default theme | Dev port |
|---|---|---|---|---|
tower |
CARTOS TOWER | /air-airtraffic |
red |
5175 |
milepost |
CARTOS MILEPOST | /dot-executive |
purple |
5176 |
blueprint |
CARTOS BLUEPRINT | /aec-portfolio |
desert |
5177 |
editionOrigin(target) derives the URL for the in-app
edition switcher (dev: the target's Vite port; prod:
<target>.<domain>), so the header switcher and
the splash page can link straight to any edition from any other.
Industry project RBAC — per-project & portfolio-wide access grants
Cartos Milepost and Cartos Blueprint are
project-scoped editions: an executive sees the whole
portfolio, but individual departments/agencies/teams (or specific users)
should only see the projects relevant to them. This is the same
group concept as docs/group-access-design.md
— extended from row-level owning_team scoping to an
explicit grants table, because portfolio access needs
to be assignable (an admin decision), not just inherited from a
Keycloak group membership.
**Data model —
crates/cartos-data/migrations/0036_industry_project_access.sql
0037_industry_access_levels.sql:**
| Table | Purpose |
|---|---|
industry_projects |
The portfolio's projects — tenant_id,
edition (milepost | blueprint),
code, name, status
(on_track|at_risk|delayed|complete),
health
(green|amber|red),
lon/lat/zoom. Unique on
(tenant_id, edition, code). |
industry_project_access |
Grants: assign a project (project_id) — or the
whole portfolio (project_id IS NULL) — to
a subject: subject_type group (a
dept/agency/team, modelled as a Keycloak group name) or
user (a username). access_level is
view | edit | admin (0037;
shipped view-only in 0036). One grant per
(project-or-portfolio, subject) — re-granting updates the
level. |
Both tables carry the standard tenant_isolation RLS
policy (FORCE RLS); this is the second layer — the
primary boundary is the query's WHERE clause, per the fabric's
convention (docs/group-access-design.md).
Access is default-deny. list_projects
(crates/cartos-data/src/industry.rs) returns a project only
when the caller is an admin (see_all — any of the three
admin tiers), or a grant — portfolio-wide or
project-specific — matches one of the caller's groups or
their username. Each returned row is annotated with the
caller's effective access_level: the
strongest matching grant (admin > edit >
view), computed in SQL via a CASE max(...)
over every matching grant row.
Access levels (0037) and delegation:
| Level | Grants |
|---|---|
view |
See the project. |
edit |
view + modify the project's
status/health (and other mutable fields) via
PATCH /api/industry/projects/:id. |
admin |
edit + manage that project's (or portfolio's)
access grants — a project-admin can grant/revoke access for the
project(s) they administer without being a tenant User-Admin.
caller_admin_scope is the delegation check: a
portfolio-wide admin grant covers everything in that
edition; a project-specific admin grant covers only that
project. |
API — crates/cartos-api/src/industry.rs,
/api/industry/*:
| Method | Route | Access |
|---|---|---|
| GET | /api/industry/projects?edition= |
Any authenticated caller — default-deny (server filters to what they're granted; admins see all) |
| GET | /api/industry/access?edition= |
User-Admin (tenant tier) — full grant list for the edition |
| POST | /api/industry/access |
User-Admin, or a project-admin delegating within
their scope —
{edition, project_id?, subject_type, subject_id, subject_label?, access_level?}
(idempotent upsert) |
| DELETE | /api/industry/access/:id |
User-Admin, or a project-admin delegating within their scope |
| PATCH | /api/industry/projects/:id |
Caller must hold edit or admin on that
project (view-only → 403) |
edition is a closed set (milepost,
blueprint — Tower carries no project-access model, since it
has no portfolio/project split) validated in-handler; an unknown edition
400s rather than accepting free text.
UI — two places, one component:
features/industry-access/ProjectAccessPanel.tsx lets an
admin grant the entire portfolio or a single
project to a group (dept/agency/team) or an
individual user, pick the access level, and revoke any
existing grant (split into "Portfolio-wide" and "By project" lists).
It's reused in two places:
- In-map, on the Portfolio surface
(
dot-executive/aec-portfolio) via the "⚙ Manage project access" button, for User-Admins. - User-Admin console — a new "Project
Access" tab
(
modules/admin/ProjectAccessConfig.tsx) with an edition switch (Milepost / Blueprint), for managing the whole tenant's grants in one place without hunting through the map.
Dev seed (0036): sample projects for
both editions (e.g. I-465 Reconstruction,
SR-37 → I-69 Upgrade for Milepost;
Terminal C Expansion,
Water Reclamation Upgrade for Blueprint) plus demonstrable
grants — the whole Milepost portfolio → city_planning
(promoted to admin in 0037), one Milepost project → the
restricted user (default-deny demo), Terminal C →
maritime_ops (promoted to edit in 0037), the
whole Blueprint portfolio → demo.
Deployment — dedicated subdomains
All five Cartos editions (Atlas, Grid, Tower, Milepost, Blueprint)
are served by one production build behind
one Caddy origin (deploy/Caddyfile.prod) —
the app resolves its edition from the request hostname, so there is no
per-edition build or deploy. The 2026-07-07 change added three DNS A
records (tower. / milepost. /
blueprint.<domain>) and three redirect URIs/web
origins on the shared cartos-web Keycloak client; login
always happens against the Atlas Keycloak realm and redirects back to
whichever host started it. See §2 for the live URLs and
Source files:
cartos-web/src/modules/industry/modules.ts
(TOWER_MODULES / MILEPOST_MODULES /
BLUEPRINT_MODULES / INDUSTRY_MODULES),
IndustryModule.tsx, IndustryModule.css,
IndustryDashboard.tsx, IndustryDashboard.css,
IndustryDashboardPage.tsx ·
cartos-web/src/lib/edition.ts ·
cartos-web/src/lib/industry-projects.ts (typed API client +
editionOfModule) ·
cartos-web/src/features/industry-access/ProjectAccessPanel.tsx
+ .css ·
cartos-web/src/modules/admin/ProjectAccessConfig.tsx ·
crates/cartos-data/src/industry.rs ·
crates/cartos-api/src/industry.rs ·
crates/cartos-data/migrations/0035_enable_industry_modules.sql,
0036_industry_project_access.sql,
0037_industry_access_levels.sql ·
crates/cartos-core/src/catalog.rs (the 29
ModuleDef entries) ·
deploy/Caddyfile.prod.
8.4 Cartos Africa — the One Africa edition
Status: merged to
main(PR #127) and LIVE at https://africa.cartosatlas.com. Cartos Africa stands alongside the other five live editions on its own sovereign subdomain — a dedicated Caddy vhost + Let's Encrypt cert + Keycloak redirect origin (deploy/Caddyfile.prod,deploy/render-realm.py), hostname → edition detection selects it automatically like every other edition, and it's reachable directly (not only via?edition=africa). Real data is loaded in prod across all 9 modules and symbology is applied (real-counts table below).africa-parcelnow also has a real cadastral ingest — a LADM-aligned parcel + party store (migration0086, deployed + verified on prod) fed by an Esri Parcel Fabric adapter and a generic ArcGIS FeatureServer adapter — see the parcel-fabric subsection below. The remaining honest data caveat (the flood module's raster/COG/SAR layers are license-flagged references) is still accurate — read it before demoing this edition.
What it is: a sixth Cartos edition
— Cartos Africa, a standalone continental-scale
Government edition for African governments, national
mapping agencies (NMAs), and ministries. It provides an
operational layer over a free/open national base-map
substrate — the same "one shared data fabric, one agent, one shell"
pattern as every other edition, scoped to a continent instead of a
single national government. Driven end-to-end from a prompt package
(prompts/map-africa/00-master-prompt.md +
01–09 per-module prompts +
10-prd-update.md + a normative
99-data-sources-appendix.md license-posture appendix).
The nine modules
| Module id | Label | Department | Notes |
|---|---|---|---|
africa-command |
Continental Command | Command · continental | The roll-up: initiative KPIs, country-onboarding board, cross-module charts (Continental Command dashboard below) |
africa-food |
Food Sustainability & Agriculture | Agriculture · food security | Cropland extent, crop health, food-security phase tracking |
africa-health |
Health & Immunization | Health · immunization | Facility inventory, access, campaign micro-planning, cold-chain coverage |
africa-border |
Border Management | Security · borders | Boundaries, crossing points, corridor monitoring |
africa-port |
Port Management | Maritime · ports | Port infrastructure, AIS traffic, hinterland corridors |
africa-land |
Land Use & Land Cover | Environment · LULC | Annual land-cover time series, vegetation classes, change detection |
africa-parcel |
Parcel Fabric Development | Land admin · cadastre | Esri Parcel Fabric + generic ArcGIS cadastre ingest → LADM-aligned (ISO 19152) parcel + party store (see the subsection below) |
africa-flood |
Flood & Climate Risk | Resilience · climate | Flood hazard/extent, rainfall, surface water, climate projections |
africa-settlement |
Settlement Management | Urban · settlements | Settlement extents, population, growth, informal settlements |
All nine are config entries in
cartos-web/src/modules/africa/modules.ts
(AFRICA_MODULES: Record<string, AfricaModuleConfig>,
AfricaModuleConfig is a type alias of
IndustryModuleConfig — same
id/label/department/blurb/
center/zoom/panels/useCases/kpis/optional
scope shape as Tower/ Milepost/Blueprint).
africa-command renders scope: "portfolio" (a
continental view); the other eight are flat (no per-module scope split —
the country scope, below, is the cross-cutting
equivalent). Registered in
crates/cartos-core/src/catalog.rs as nine
ModuleDefs (vertical: "government") with a
rich shared default feature set (analysis, compare, map-export,
dashboard, catalog, satellite, stac, overture, external-overlays,
buildings-3d, time-of-day, measure, draw, mapillary, point-cloud) — no
new FeatureDef was introduced; Africa modules just opt into
features every other edition already has. Migration
0052_enable_africa_modules.sql enables the modules +
default features for the dev tenant (new catalog modules are OFF by
default; mirrors 0035 for Tower/Milepost/Blueprint).
The AfricaModule surface
(cartos-web/src/modules/africa/AfricaModule.tsx) rides the
shared IndustryModule component without forking
it — it injects edition-specific overlays through
IndustryModule's new overlays render-prop
({ mapRef, mapReadyRef, mapEpoch, storageKey, moduleId }):
the country-scope boundary/mask layer, the
CountryScopePanel selector, and the
FoundationLayersPanel, all wrapped in a
<CountryScopeProvider> so every Africa module shares
one country-scope state. Default theme: Desert (sand
& amber dusk — the same palette Cartos Blueprint uses, since
Blueprint is also defaultTheme: "desert"; each edition
lives on its own subdomain (hostname detection picks the theme
per-session), so both being live at once is a cosmetic palette reuse,
not a UI collision).
Edition selection
cartos-web/src/lib/edition.ts's Edition
type gained "africa". The preset:
brand: "ONE AFRICA", home: "/africa-command",
defaultTheme: "desert", dev port 5178. Same
precedence as every other edition — VITE_CARTOS_EDITION
build flag → ?edition= query override → hostname
(africa.cartosatlas.com in prod, live). Also selectable in
prod via the header edition-switcher pulldown (§9 — shipped
2026-07-18 platform-wide, replacing the old pill row).
Country-scope system — the edition's signature cross-cutting state
The one interaction unique to a continental edition: which countries (and optionally which subnational units) are in scope, filtering every module's map layers, KPIs, dashboards, and agent answers at once.
CountryScopeContext(cartos-web/src/lib/country-scope.tsx) — a{iso3: string[], adm1?: string[]}scope, emptyiso3= whole continent. Persisted per edition origin insessionStorage(cartos.africa.scope) so it survives the SPA catch-all redirect.toggle/setCountries/addRegion(AU-region quick-add) /toggleAdm1/clearAdm1/clear. Any country-level change resets the ADM1 drill-down (an ADM1 selection belongs to one country).CountryScopePanel(features/africa/CountryScopePanel.tsx) — an always-visible 🌍 HUD chip (label shows flags for ≤3 countries, else a count, else "All Africa") that opens aDockablePanel: a search box, five AU-region quick-group buttons (North/West/Central/East/Southern —lib/africa-countries.ts'sAU_REGIONS), a scrollable country list with flags + checkmarks, and — when exactly one country is selected — a sub-national drill-down listing that country's ADM1 units (fetched vialib/africa-adm1.ts'sfetchAdm1/adm1UnitsFor) as toggleable chips.- The reference data:
lib/africa-countries.tsis the canonical all 54 African Union member states (ISO3, name, flag emoji, AU region, ~2024 population in millions, a centroid) — the source of truth for the selector, independent of the loaded boundary geometry. The boundary geometry itself is geoBoundaries (CC-BY 4.0, explicitly not GADM per the license appendix): ADM0 (all 54 countries,data/catalog/africa_adm0.geojson) and a priority-country ADM1 set (147 subnational units across Kenya, Nigeria, Egypt, Ghana, Ethiopia, and South Africa — the countries with the richest loaded use-case data —data/catalog/africa_adm1_priority.geojson), built bytools/build_africa_countries.py/tools/build_africa_adm1.pyand loaded into the catalog underafrica-command(layer keysadm0_countries/adm1_priority) viatools/load_demo_catalog.sh's--tag-countryand--tag-adm1mechanism (below). - Rendering:
features/africa/AfricaScopeLayer.tsx(afr-prefixed sources/layers) draws the country boundary outline, a selection highlight, and a mask that dims out-of-scope territory, re-adding onstyle.load(mig0053/0057hide the raw ADM0/ADM1 catalog layers from the generic Catalog panel's auto-render soAfricaScopeLayeris the only thing drawing them, and the Foundation panel's rasters insert below the mask so selection stays legible). - Server-side propagation: the scope isn't just a
client-side map filter.
GET /api/catalog/layers/:id/featuresgained optionalcountry_iso3/adm1query params, andGET /api/catalog/kpigainedcountries/adm1— both parsed by newparse_iso3/parse_csvhelpers incrates/cartos-api/src/catalog.rsand threaded intocartos-data'slist_layer_features/module_kpi, so a country selection also narrows the KPI numbers and the dashboard charts, not just what's drawn on the map.crates/cartos-cli/src/loader.rs'sload-geojsongained the tagging flags:--tag-countrypoint-in-polygons every loaded feature against ADM0 to stamp aniso3property,--tag-adm1does the same against the priority ADM1 set — this is what makes thecountries/adm1filters (client and server) actually work on the loaded use-case data. - The agent: the
africa_scopetool (§7) resolves free-form country/region input to the canonicalscope_iso3, which feedsquery_catalog_layer's newcountriesargument (§9.6).
The foundation-layer fabric
FoundationLayersPanel
(features/africa/FoundationLayersPanel.tsx) is the
continental imagery/terrain/land-cover/boundary substrate every module
rides on — a 🗺️ HUD chip opening a DockablePanel grouped by
category (Imagery/Terrain/Land Cover/Buildings &
3D/Transport/Boundaries/Population), driven by a typed registry
(lib/africa-data-catalog.ts's
FOUNDATION_LAYERS). Each entry carries a license badge —
open / view-only /
open·nc / licensed /
derived — and a mechanism:
xyz/wms entries render live (the
afr-fnd- raster reconciler adds/removes a MapLibre raster
source+layer below the country-scope mask, per-layer opacity slider,
prefs persisted in localStorage); reference
entries (Overture Buildings, Google Open Buildings 2.5D, Overture
Transportation, WorldPop population, Vantor/Maxar premium imagery) are
documented upgrades, not rendered here — they'd need a
server-side proxy (never a client-keyed premium source). The same
renderable subset is also seeded into the standard Layer Catalog as
africa-command datasources/map-layers (migration
0054, visibility off by default) so it's
admin-configurable and agent-queryable like any other catalog layer, in
addition to living in this dedicated panel. Normative license
posture
(prompts/map-africa/99-data-sources-appendix.md):
geoBoundaries, not GADM; GLO-30 DSM is the
default terrain story, not FABDEM (FABDEM is non-commercial; a
licensed FABDEM+ is offered only as a reference upgrade); Esri
imagery is view-only under Esri's ToS; commercial
sources are never client-keyed — always a server-proxied
datasource.
The Continental Command dashboard
features/africa/AfricaCommandDashboard.tsx — the bespoke
dashboard body for africa-command (routed via
AfricaDashboardPage.tsx, which gives the eight use-case
modules the standard CatalogDashboard instead, same
fallback pattern as Tower/Milepost/Blueprint — §9.7). Reads the
same cartos.africa.scope sessionStorage key the map/panel
write, so the command view reflects whatever the operator has focused on
(falls back to the whole continent). Rolls up:
- Initiative KPIs — countries onboarded (of 54), foundation layers live, population covered, SDI-harmonized percentage (representative tiles, not yet bound to a real onboarding-tracking table).
- A country-onboarding board — per-country rows
(flag, name, region, population) cross-referenced against
countriesWithAdm1(does this country have a loaded subnational fabric) and live per-country counts pulled from the real catalog data (health facilities viacountByIso3over theafrica-healthlayer'siso3property, ports via theafrica-portlayer). - Cross-module roll-up charts (ECharts, reusing
CatalogDashboard's chart theme system) — realGET /api/catalog/kpifeature/layer counts for each of the eight use-case modules, scoped by the current country selection via the newcountries/adm1KPI params above.
The parcel fabric — cadastral ingest (mig 0086)
Status: merged to
mainand deployed + verified on prod (migration0086applied; live-ingest tested end-to-end against a real public Esri parcel service).
africa-parcel went from a Natural-Earth built-up-extent
reference (below) to a real, source-agnostic, LADM-aligned (ISO
19152) parcel + party store — both a real Esri Parcel Fabric
and a generic ArcGIS parcel/cadastre FeatureServer map
INTO it, so an operator can "go back and forth" between sources on one
schema.
- The store (
africa_parcels+africa_parcel_parties, migrationcrates/cartos-data/migrations/0086_africa_parcels.sql) —africa_parcels≈ LADM LA_SpatialUnit + LA_BAUnit (the parcel + its administrative attributes) + an LA_RRRtenure_type(freehold/leasehold/customary/ public/informal/unknown);africa_parcel_parties≈ LA_Party (owner/ occupant/lessee/customary-holder/administrator) linked to a parcel. A fabric record's lineage (CreatedByRecord/RetiredByRecord) collapses intorecord_name/record_type/recorded_dateplus astatuslifecycle (candidate|active|historic|rejected—RetiredByRecordset ⇒historic,IsSeed⇒candidate). Geometry is GeoJSON JSONB + a centroidclon/clatbbox-filter pair, per the platform convention; rows are tenant +owning_teamscoped. Ingest is idempotent on(tenant_id, source, external_id)— a re-sync upserts geometry/attributes in place but aCASE WHEN reviewed_at IS NOT NULLguard preserves any human review ofstatus/tenure_type/use_classacross re-ingest. (Distinct from Cartos Harvest'sfield_parcels— crop-field polygons for the agriculture edition, a different table entirely; see §8.7.) - Two ingest adapters, one store
(
crates/cartos-api/src/africa_parcel.rs,POST /api/africa/parcels/ingest,tech_admin-gated) — fetch mirrors the ArcGIS REST pattern already used byconnectors/esri_un.rs(/query?f=geojson, an optional server-held token, CORS handled server-side so the browser never talks to ArcGIS directly):parcel_fabric— the resolvedFieldMapdefaults match Esri's documented Parcel Fabric information model:GlobalID(external id),Name(parcel ref + label),Type,StatedArea/StatedAreaUnit,CalculatedArea,CreatedByRecord/RecordType/RecordedDate,IsSeed,RetiredByRecord. Ready to point at any real ArcGIS Enterprise Parcel Fabric'sParcels/Recordsfeature layers.featureserver— a config-drivenfield_mapfor any generic ArcGIS parcel/cadastreFeatureServer(an NMA's own cadastre feed, for example); defaults are tuned to Esri's public Tax_Parcels LRM sample schema (OBJECTID/PAN/PARCEL_POLY_NAME/PARCEL_TYPE/PARCEL_BLOCK/PARCEL_LOT/PARCEL_SUB/LRM_GEOM_DOC_AREA/Shape__Area/LRM_GEOM_DOC/RecordDate), every field name overridable per-call.- Every ingested feature also gets a best-effort
infer_tenure()guess (substring match on the source parcel-type string against lease/customary/freehold/public/informal) when no explicit tenure field is mapped — anything unrecognized staysunknownfor a human reviewer.
- Routes (
crates/cartos-api/src/main.rs) —POST /api/africa/parcels/ingest(pull a service into the store,tech_admin);GET /api/africa/parcels(list, scope + bbox/status/iso3/source_kind filters);GET /api/africa/parcels/summary?by=(group-by rollup onstatus|tenure_type|source_kind|use_class);GET /api/africa/parcels/:id(one parcel + its parties);PATCH /api/africa/parcels/:id(human review — status/tenure/use-class, stampsreviewer+reviewed_at);GET /api/africa/parcels/export— the LADM-shaped GeoJSON tenure export (LA_SpatialUnit/LA_RRR-named properties:su_id,upi,rrr_tenure_type,su_lifecycle, …) for GeoPackage/Shapefile conversion downstream — the "LADM-aligned tenure export" use case, now real. - Web
(
features/africa/ParcelFabricPanel.tsx,lib/africa-parcel.ts, mounted inAfricaModule.tsxonafrica-parcelonly) — a 🧾 draggable chip opening a panel: atech_admin-only ingest form (mode toggle Esri Parcel Fabric / ArcGIS parcel service, service URL, token, ISO3 tag), "Load parcels in view" (bbox-scoped fetch → map fill/line overlay,afr-parcel-prefixed), colour-by Tenure or Lifecycle, click-to-open a parcel's detail + review form (tenure/use/status dropdowns, linked parties), and an Export LADM button that downloads the GeoJSON tenure package. - Honesty (read before demoing): no public live Esri
ParcelFabricServerexists — a real fabric is an ArcGIS Enterprise deployment, typically secured — so theparcel_fabricadapter is real code mapped against Esri's documented fabric schema and is ready to point at a real Enterprise fabric the moment a customer has one; it hasn't been exercised against a live fabric instance. The working live demo runs on thefeatureserveradapter — verified end-to-end on prod against a real public Esri Tax_Parcels service (8 parcels ingested → summary → LADM export, then cleaned up). Both adapters land in the identical LADM store, so switching a tenant from the demo path to a real Enterprise fabric later is a configuration change, not a rebuild. - Now a saved connector, not just an ad-hoc call
(migration
0088_africa_parcel_integrations.sql, shipped + deployed 2026-07-29). The ingest above is a one-shotPOSTwith the service URL and token in the request body — fine for a demo, wrong for an operational feed. It is now also registered in the Admin → Integrations tab (see §7.4) as a credentialed, re-syncable connector on the existingRealConnectorseam — the same registry that backs Cartos Grid's systems-of-record, and its only other consumer, so there is no Grid-dashboard leakage. Migration0088relaxes thegrid_integrations.kindCHECK to admitparcel_fabricandfeatureserverand seeds both for the dev tenant as configured · pending endpoint. The ingest core was extracted torun_parcel_ingest(pool, tenant, owning_team, body)inafrica_parcel.rs, shared by the ad-hoc handler andconnectors/parcel_fabric.rs, which deserializes the stored config (service_url+ token held server-side, plusiso3/where/max/field_map/source) and runs it; the connectorkindselects the ingest mode.grid.rsreturns a graceful "set service_url first" for an unconfigured cadastre connector rather than failing opaquely. Net effect: an NMA's cadastre becomes a scheduled, re-syncable feed whose credentials never reach the browser, and the idempotent-upsert + human-review-preservation guarantees above hold across every re-sync.
Real data — what's actually loaded, and the honest caveats
tools/load_demo_catalog.sh's One Africa section (loads
after the Tower/Milepost/Blueprint sections) tags every feature against
ADM0/ADM1 as it loads (--tag-country --tag-adm1), so
country-scope filtering works on all of it. Symbology
(categorical/graduated colour, sized markers, legends) is applied by
migrations 0055/0056/0059
(health/port point styling — 0059 fixes a malformed
SizeSpec in 0055/0056 that
silently broke marker rendering) and 0060 (the five
remaining use-case layers). Real, verifiable feature counts as loaded on
dev:
| Module | Dataset | Source | Real feature count |
|---|---|---|---|
africa-command |
ADM0 country boundaries | geoBoundaries (CC-BY 4.0) | 54 (all AU member states) |
africa-command |
ADM1 priority subnational units | geoBoundaries (CC-BY 4.0) | 147 (Kenya · Nigeria · Egypt · Ghana · Ethiopia · South Africa) |
africa-health |
Health facilities | OpenStreetMap (Nigeria + Kenya) | 7,379 |
africa-port |
Ports & harbours | OpenStreetMap (Nigeria + Kenya + South Africa) | 435 |
africa-food |
Markets & food supply | OpenStreetMap (Nigeria + Kenya) | 3,011 |
africa-land |
Protected areas / conservancies | OpenStreetMap (Nigeria + Kenya) | 406 |
africa-flood |
Rivers | Natural Earth (public domain) | 91 |
africa-flood |
Lakes | Natural Earth (public domain) | 33 |
africa-settlement |
Settlements & populated places | Natural Earth (public domain) | 1,285 |
africa-border |
Border crossings | Curated anchor corridors (public knowledge) | 14 |
africa-parcel |
Built-up extent — reference only | Natural Earth urban areas (public domain) | 1,928 |
Two things this table is deliberately honest about, so nobody mistakes a demo slice for a finished capability:
- The
builtup_referencerow above is a background layer, not the parcel fabric. It's Natural Earth's urban-area polygons, loaded once as a backdrop showing where a parcel fabric roughly sits — it was never meant to be one, and it isn't touched by the real ingest below.africa-parcelnow has a separate, real cadastral pipeline: the parcel-fabric subsection above — a LADM-aligned parcel + party store fed by either a real Esri Parcel Fabric or a generic ArcGIS cadastre FeatureServer (migration0086, deployed + verified on prod). That covers the ingest half of the fabric — an existing authoritative cadastre flowing in. The other half — the SAM2 segmentation pipeline that generates parcel candidates from imagery (prompts/map-africa/07-parcel-fabric-development.md) — is still not built; it remains the module's roadmap item for countries without a digitized cadastre to ingest from. africa-flood's raster/COG/SAR layers are license-flagged references, not rendered rasters. Migration0058seeds two renderable base rasters (Esri World Ocean Base, GLO-30 DSM hillshade — bothvisibility: offuntil toggled) plus fourreference: trueCOG placeholders — Digital Earth Africa surface-water (WOfS), Copernicus GloFAS flood hazard, FABDEM+ bare- earth DTM (licensed), and Sentinel-1 SAR (flood-extent mapping) — each correctly attributed and captioned as needing the catalog's COG render path (§9.5) or a licensed/authenticated feed before they can render. Only the rivers/lakes vectors are live today.
Real geographies are pre-fetched for the countries listed above
because the public Overpass API was unreachable for an extended stretch
during the build (noted in CLAUDE.md); acquired datasets
are committed under data/catalog/africa_*.geojson for
provenance and reproducible reload, same convention as the other six
editions' OSM data (§9.6).
Known gotchas (for the next session that touches this edition)
- Symbology migrations
(
0055/0056/0059/0060) must run after the loader on a fresh deploy —migratealone no-ops theUPDATE map_layersstatements if the rows the loader creates don't exist yet. - Natural Earth's
populated_places_simplelayer uses lowercase property keys — a schema trap the settlements loader had to account for. - sqlx embeds migrations at compile time — a new
migration file needs
crates/cartos-data/src/lib.rstouched +cartos-clirebuilt beforemigratewill pick it up (same gotcha noted for the Grid connectors work).
What's next
The SAM2 segmentation pipeline that generates
africa-parcel candidates from imagery (the ingest side — an
existing cadastre flowing in — is now built, above); pointing the
parcel_fabric adapter at a real ArcGIS Enterprise fabric
once a customer has one (it's been verified end-to-end only against the
documented schema replayed through a public FeatureServer, not against a
live ParcelFabricServer); wiring the foundation panel's
reference-only COG/SAR layers to the catalog's COG render
path; deeper per-country data beyond the six priority countries (tracked
separately, see memory/africa_deeper_country_data_tbd.md);
a real country-onboarding tracking table behind the Continental Command
KPIs (today representative tiles).
Source files:
cartos-web/src/modules/africa/modules.ts,
AfricaModule.tsx, AfricaDashboardPage.tsx ·
cartos-web/src/lib/edition.ts ·
cartos-web/src/lib/country-scope.tsx,
africa-countries.ts, africa-adm1.ts,
africa-data-catalog.ts ·
cartos-web/src/features/africa/AfricaScopeLayer.tsx,
CountryScopePanel.tsx,
FoundationLayersPanel.tsx,
AfricaCommandDashboard.tsx,
ParcelFabricPanel.tsx (+ their .css) ·
cartos-web/src/lib/africa-parcel.ts ·
crates/cartos-agent/src/builtin/africa_scope.rs ·
crates/cartos-agent/src/builtin/query_catalog_layer.rs
(countries arg) ·
crates/cartos-data/src/africa_parcel.rs ·
crates/cartos-api/src/africa_parcel.rs ·
crates/cartos-data/migrations/0086_africa_parcels.sql ·
crates/cartos-api/src/catalog.rs
(parse_iso3/parse_csv, KPI/features query
params) · crates/cartos-data/src/catalog.rs
(africa_countries, AfricaCountryRow,
FeatureQuery.countries) ·
crates/cartos-cli/src/loader.rs
(--tag-country/--tag-adm1) ·
crates/cartos-core/src/catalog.rs (the nine
ModuleDef entries) ·
crates/cartos-data/migrations/0052–0061 ·
tools/build_africa_countries.py,
build_africa_adm1.py ·
tools/load_demo_catalog.sh (One Africa section) ·
data/catalog/africa_*.geojson ·
prompts/map-africa/00–10,
99-data-sources-appendix.md ·
deploy/Caddyfile.prod (africa. vhost) ·
deploy/render-realm.py (Keycloak redirect/web-origin for
the africa host).
8.5 Cartos Guardian — Critical-Infrastructure Protection
Status: merged to
main(PR #133/#134) and LIVE at https://guardian.cartosatlas.com (2026-07-22). Documented here for the first time in this guide (it shipped between the 2026-07-18 and 2026-07-26 refreshes). Deployed the same way as every other edition — its own Caddy vhost + Let's Encrypt cert + Keycloak redirect origin, hostname-detected.
What it is: a seventh Cartos
edition — Cartos Guardian — for
identifying and protecting UAE civil infrastructure that could
be a strike target, with a transparent per-site strike-risk
score. Raised 2026-07-22 in response to the UAE
infrastructure-strike threat context; covers all UAE, with the immediate
view concentrated on the Strait of Hormuz (~56.0°E,
26.0°N). A pure IndustryModule clone (the One Africa
pattern — GuardianModule wraps IndustryModule
and injects the risk panel through the overlays
render-prop; modules live in
cartos-web/src/modules/industry/modules.ts). Guardian is
the proactive half of a pair with Cartos
Sentry (§8.6) —
Guardian scores what could be hit, Sentry watches whether
it has been.
The five modules
| Module id | Label | Covers |
|---|---|---|
guard-command |
Command & Risk | The roll-up COP (critical_sites, 50 features, union of every domain layer) + the Strike-Risk panel |
guard-energy |
Energy Infrastructure | Power plants (incl. Barakah nuclear), transmission/pipelines, petroleum ports (Fujairah/Ruwais/Das/Zirku) |
guard-utilities |
Utilities & Transport | Desalination, bridges/tunnels, airports, transport corridors |
guard-emergency |
Emergency Services | Police/fire/EMS |
guard-defense |
Defense & Military | Military sites (Al Dhafra, Al Minhad, Fujairah Naval…) |
Home /guard-command. Default theme Red
(Crimson · tactical). Camera centre [56.0, 26.0] zoom 6.6
(the Hormuz view). Every module carries the same rich default feature
set: mobility, Overture, External Data Sources, Mapillary, satellite,
STAC (the SAR/Sentinel-1 path), plus analysis / compare
/ map-export / dashboard / catalog / buildings-3d / time-of-day /
measure / draw / point-cloud — 16 features, no new
FeatureDef needed. Registered in
crates/cartos-core/src/catalog.rs as five
ModuleDefs
(vertical: "critical-infrastructure"); migration
0069 enables the modules
- features for the dev tenant.
The strike-risk model — the differentiator
Every site carries a transparent 0–100 strike-risk score, computed once offline and baked into the GeoJSON so map, popups, panel, and the agent tool all read the same number (no live re-scoring):
risk = 100 · (0.45 · criticalityN + 0.30 · hormuzExposure + 0.25 · categoryWeight)
hormuzExposure is a haversine proximity to the strait
chokepoint (56.45°E, 26.55°N; decays to 0 at 550 km), +0.12 if the site
ships via the strait. Tiers: Critical ≥80 / High ≥65 / Elevated
≥50 / Moderate ≥35 / Low. Computed in
tools/build_guardian_infra.py; the Fujairah cluster
tops the UAE ranking (oil terminal, naval base, power/desal,
the ADCOP pipeline terminus — the closest strategic node to Hormuz on
the Gulf-of-Oman side).
features/guardian/GuardianRiskPanel.tsx (a floating "⚠
Strike Risk" surface) shows a threat summary, a tier bar, ranked Top
Targets (click → fly-to + pin), a per-site factor breakdown, and a model
explainer; migration 0071 recolours the Command COP layer
by risk_tier (crimson→green threat map, radius scaled by
risk_score). GuardianRiskKpis
(features/guardian/) replaces the placeholder "—" dashboard
tiles on every guard-* module (both the in-map dashboard
and the full-screen /guard-*/dashboard) with live headline
tiles (assessed / Critical / High≥65 / mean / peak / strait-exposed ≤200
km) + a tier bar + a top-6 targets table.
Real data — what's actually loaded
103 curated UAE critical-infrastructure sites across
the five modules, each with criticality (1–5),
category/subtype/operator/emirate/
hormuz_shipping attributes — curated, not
exhaustive Overpass (for critical infrastructure the known
strategic targets beat a bulk OSM pull). Layer breakdown:
guard-energy/energy_petroleum (22 — power plants +
petroleum ports + 3 pipelines),
guard-utilities/utilities_transport (15),
guard-emergency/emergency_services (11),
guard-defense/military_sites (5),
guard-command/critical_sites (50, the union COP). Symbology
(categorical colour + feature-type glyphs —
bolt/flame/droplet/triangle/med-cross/alert — radius graduated by
criticality) via migration 0070.
A deeper-data build (also merged + live) added five
more capabilities, all license-verified (a Fable-model source deep-dive;
OSM data joins on our own osm_id, so ODbL share-alike never
touches the produced map):
- Grid vulnerability —
guard-energy/grid_network(migration0072): 1,309 OSM substations + 1,342 HV transmission lines, voltage-weighted risk — 2,651 features. - Strategic vector layers (migration
0073):generation_plants(131 OSMpower=plant),bridges_tunnels(827),government_sites(988 — government buildings + embassies/diplomatic missions). - POI collateral —
guard-command/poi_risk(2,500 soft-targets within 5 km of a critical site, scored sensitivity × proximity × target-risk, including 1,030 fuel/chemical storage tanks as a secondary-hazard layer) — drives a dedicated "POI Risk" tab in the Strike-Risk panel. - Historical imagery overlay
(
GuardianImageryOverlay) — a NASA GIBS time-slider: MODIS TrueColor + Fire/Burn (dated) and VIIRS Black Marble night-lights (static — a power-outage read). - Live traffic overlay
(
GuardianLiveOverlay) — reuses the existing/api/mobility/vessels(AIS) and/api/mobility/aircraft(ADS-B) feeds, same-origin, no new key or infra; tankers highlighted.
The agent tool
guardian_strike_risk
(crates/cartos-agent/src/builtin/, cross-module,
edition-wide like africa_scope) ranks the UAE inventory by
the risk model — filters on
category/min_tier/min_score/within_hormuz_km/
shipping_only/limit — and returns
matched_count + tier_distribution + ranked
targets. Backed by cartos_data::catalog::guardian_targets
(reads the guard-command/critical_sites union, scope-gated,
ranked by risk_score).
Known gotchas
- Prod data isn't shipped by
ship-deploy.sh(SHIP_PATHScoverscrates/cartos-web/deploy/config/Cargo*, notdata//tools/) — a fresh deploy needs theguard_*.geojsonfilesscp'd +docker cp'd into the API container, thencartos-cli load-geojsonrun in-container, then the symbology migrations (0070/0071) re-applied viapsql— migrate before load, same trap as every other edition's loader-vs-migration sequencing. - Prod
RUST_LOG=cartos_api=infosuppressescartos-cliload logs — a successful load prints nothing and exits 0; don't grep for a "complete" string underset -e. - Overpass area queries time out; bbox +
out geom/out centerwith a descriptiveUser-Agentworks (a 406 without one) — used for the grid/ strategic-vector pulls intools/build_guardian_{grid,extra}.py. - Keycloak redirect URIs for a new subdomain must be added
live via
kcadm(docker exec deploy-keycloak-1→kcadm.sh-s "redirectUris+=...") — Keycloak's--import-realmskips existing realms, so re-runningrender-realm.pyalone does not update a live realm.
What's next
GEM Global Integrated Power Tracker (CC-BY, email-gated) for authoritative plant data; a Sentinel-2 titiler for 10 m historical imagery; Overture Places POI enrichment; SAR/Sentinel-1 change-monitoring via STAC — this is exactly what Cartos Sentry now does, watching Guardian's highest-risk sites (see §8.6); an aisstream/ADS-B- Exchange procurement upgrade for contract-grade live traffic feeds.
Source files:
cartos-web/src/modules/industry/modules.ts
(GUARDIAN_MODULES) ·
cartos-web/src/modules/guardian/GuardianModule.tsx ·
cartos-web/src/features/guardian/
(GuardianRiskPanel.tsx, GuardianRiskKpis.tsx,
GuardianImageryOverlay.tsx,
GuardianLiveOverlay.tsx) ·
cartos-web/src/lib/guardian-risk.ts ·
crates/cartos-agent/src/builtin/guardian_strike_risk.rs ·
crates/cartos-data/src/catalog.rs
(guardian_targets) ·
crates/cartos-core/src/catalog.rs (five
guard-* ModuleDefs) ·
crates/cartos-data/migrations/0069–0074 ·
tools/build_guardian_infra.py,
build_guardian_grid.py,
build_guardian_extra.py ·
tools/load_demo_catalog.sh (guardian section) ·
data/catalog/guard_*.geojson · guardian-PRD/
(PRD + Capability Brief, build.py → HTML/PDF/DOCX) ·
deploy/Caddyfile.prod (guardian. vhost) ·
deploy/render-realm.py (Keycloak redirect/web-origin for
the guardian host).
8.6 Cartos Sentry — Reactive Crisis Monitoring
Status: merged to
mainand LIVE at https://sentry.cartosatlas.com (2026-07-23, still actively shipping features through 2026-07-26 — every item below is browser-verified live). Own sovereign subdomain, same pattern as every other edition (Caddy vhost + Let's Encrypt cert + Keycloak redirect origin, hostname-detected).
What it is: an eighth Cartos
edition — Cartos Sentry — the
reactive counterpart to Cartos
Guardian (§8.5).
Guardian scores what could be hit; Sentry watches whether
it has been. Optical + SAR + thermal satellite change
detection for critical-infrastructure crisis monitoring: watch
Guardian's highest-risk assets → autonomously detect an event (fire /
structural damage / flood / blast / spill) → open a scored incident with
cross-modality confidence and before/after evidence → report + alert.
Raised 2026-07-23 from an SSP concept mockup; PRD scaffolded in
sentry-PRD/ (mirrors the Guardian/Cartos Atlas PRD pipeline
— build_sentry.py → internal/public × md/html/pdf/docx).
~60% of Sentry reuses shipped Cartos (the Phase-2
change-detection pipeline — job queue, model sidecar, derived datasets —
STAC incl. sentinel-1-rtc SAR, the Compare swipe tool,
reports, the asset catalog, the IndustryModule shell,
measure/draw/annotate/ export/dashboard); four things are
genuinely new and described below: (1) a monitoring
daemon — the platform's first background ingestion
loop, (2) real model-free detectors, (3) an
incident state machine + alert dispatch — the
platform's first outbound alert channel, (4) commercial tasking
connectors.
Ethos: no fake certainty. Every honest limit below is intentional, not an oversight — Sentry ships confidence bands, not invented percentages, and a degraded model never fabricates an incident (the "mock guard," below).
The three modules
| Module id | Label | Covers |
|---|---|---|
sentry-command |
Crisis Command | Watchlist management + AOI pick + Ops-View entry |
sentry-detect |
Change Detection | Local/GeoTIFF imagery upload, the change-compare compositor |
sentry-incidents |
Incidents | The incident console — alert feed, detail, transitions, tasking, reports |
Home /sentry-command. Default theme
Camo (field olive, #a3b635 — flipped from
an initial Ember/orange during the build per Steven's direction; brand
chrome, the AOI-draw tool, and the watchlist/control accents all swept
olive; the P1/P2/P3 priority ramp stayed semantic red/amber/grey).
Registered in crates/cartos-core/src/catalog.rs as three
ModuleDefs (vertical: "crisis-monitoring");
migration 0075 enables the modules for the dev tenant.
1 · The monitoring watchlist + daemon
The daemon's source of truth is an editable monitoring
watchlist (sentry_watchlist, migration
0076) — name / asset_key / category / lon /
lat / priority (P1/P2/P3) / risk_score /
interval_hours / modalities
(optical/SAR/thermal) / enabled / owning_team.
Seeded from Guardian's top-10 strike-risk sites
(ORDER BY risk_score DESC LIMIT 10 against
guard-command/critical_sites; priority + scan interval
derive from the Guardian tier: risk ≥80 → P1/12h, ≥65 → P2/24h, else
P3/72h) — this is the Guardian → Sentry handshake: a
strike-risk score becomes a watch priority, which later sizes the
tasking spend a P1 incident can defensibly justify. CRUD via
/api/sentry/watchlist (tenant + group-scoped) and
features/sentry/WatchlistPanel.tsx (inline-editable table:
enabled toggle, priority, interval hours, per-modality toggles, risk,
delete, click-to-fly, add-at-map-centre, and a relative-time "Scanned"
column).
crates/cartos-api/src/sentry_monitor.rs — the
platform's first background ingestion loop — is an opt-in
tokio interval task (CARTOS_SENTRY_MONITOR=1,
mirrors the AIS ingester's pattern) that on each tick finds watchlist
rows due for a scan (sentry::due_for_scan,
interval_hours elapsed since
last_processed_at) and, per due asset+modality:
STAC-searches Planetary Computer over a small AOI
(crates/cartos-api/src/sentry_screen.rs, ±0.02° box, 60-day
window), picks two well-separated epochs (newest =
post; earlier, ≥10 days apart = pre; optical filters cloud cover ≤40%),
resolves a per-epoch tilejson XYZ URL, and calls the sidecar's
change.detect directly (synchronous — no
job-queue polling; daemon detections don't persist a derived dataset the
way an analyst-run job does). Optical routes to
sentinel-2-l2a, SAR to sentinel-1-rtc; thermal
is handled separately via FIRMS (below). Dedup:
sentry_incident::has_open_incident skips an asset that
already has an open incident. A
MAX_ROWS_PER_TICK cap bounds the work per
tick, and every modality fails independently and gracefully (a transient
STAC network error on one source doesn't block the others).
⚠ The mock guard. Only a detection whose
model_versionstartss1-ors2-is trusted as real; a degraded/misconfigured sidecar that would otherwise fall back to the mock detector never raises a fabricated incident. This is the load-bearing honesty check for the whole daemon.
Tunables (prod compose):
CARTOS_SENTRY_MONITOR_INTERVAL_SECS (tick period),
CARTOS_SENTRY_MONITOR_SCREEN (0 = heartbeat only, no real
detection), CARTOS_SENTRY_AOI_HALF_DEG (default 0.02),
CARTOS_SENTRY_WINDOW_DAYS (default 60),
CARTOS_SENTRY_MIN_CHANGE_PCT (default 5, tuned to
10 in prod to quiet residual coastal noise — see precision
hardening below), CARTOS_SENTRY_MAX_ROWS_PER_TICK (default
6), CARTOS_SENTRY_THERMAL_DAYS (1–5, FIRMS NRT's hard
cap).
2 · Real model-free detectors
The sidecar's change.detect
(model-sidecar/) routes on the STAC collection to two
model-free, CPU detectors (numpy + Pillow, no torch —
distinct from the Phase-2 SegFormer path used for uploads, below):
- S1 backscatter log-ratio
(
s1_change.py,model_version s1-backscatter-logratio-0.1) — Δσ⁰ dB per cell over VV polarization; ≥4 dB →new_structure/removed, ≥2 dB →modified. - S2 NBR·NDWI (
s2_change.py,model_version s2-nbr-ndwi-0.1) — asks the Planetary Computer titiler to render the index expression as grayscale and recovers it linearly;dNBR > 0.15→ burn,NDWI+ΔNDWI→ flood.
Plus FIRMS VIIRS active-fire
(crates/cartos-api/src/firms.rs — NASA FIRMS
VIIRS_SNPP_NRT 375 m near-real-time area-CSV client,
header-mapped parse → GeoJSON, unit-tested). Thermal is a
hotspot feed, not a two-epoch comparison — any active
fire inside a watched AOI is a candidate regardless of %-change; needs
CARTOS_FIRMS_MAP_KEY (degrades gracefully — "no key
configured" — without one).
Multi-image baseline masking
(optical_change_baseline in s2_change.py)
samples a per-cell mean/σ (μ/σ z-score) across several prior epochs to
suppress ordinary seasonal/tidal swings before flagging a cell as
changed — the false-positive-suppression backbone the precision
hardening below builds on. ⚠ GET /healthz still
advertises mock-change-0.1 (a static registry
string) — the mock only actually fires when neither S1 nor S2 is
selected for the collection in play.
3 · Incident state machine + alerts
The reactive core. Migration 0078:
sentry_incident + sentry_incident_event (audit
timeline) + sentry_alert_channel (webhook config) +
sentry_alert (dispatch log).
crates/cartos-data/src/sentry_incident.rs implements a
validated state machine —
new → acknowledged → investigating → resolved | dismissed,
with resolved/dismissed
reopening back to investigating — plus
derive_severity (change % + detection class + watchlist
priority → critical/high/medium/low), full CRUD, timeline, channels, and
record_alert. Routes
(crates/cartos-api/src/sentry_incident.rs):
/api/sentry/incidents (+/:id,
/transition, /assign, /confirm,
/note), /api/sentry/alerts,
/api/sentry/alert-channels (create/delete,
tech_admin, http(s)-only webhooks). Alert
dispatch fires on incident open: in-app is always logged;
webhook channels whose min_severity is met get the incident
POSTed (non-fatal — a failed webhook is logged, not fatal to the
incident). Web: features/sentry/IncidentsPanel.tsx
(severity-sorted, open-first alert feed + a detail view with transition
buttons, assign, notes, and the audit timeline, camo-accented) on
sentry-incidents and sentry-command; a
"Raise incident" button appears in the shared Analysis
result panel when EDITION === "sentry", turning any
analyst-run change.detect result (dominant class, mean
confidence, AOI centroid) into an incident.
4 · Fusion confidence bands
The PRD's "no fake 87%." Incidents carry a Low / Medium /
High / Confirmed band fused from independent
cross-modality corroboration — optical, SAR, and thermal each fail
independently, so ≥2 agreeing modalities → High; a lone detection tops
out at Medium unless very strong — combined with detector strength
(derive_confidence_band, unit-tested,
cartos-data). The daemon now collects detections
from every configured modality per screen, not just the first
hit, storing per-modality detection.evidence alongside the
fused band. "Confirmed" is analyst-only
(POST /api/sentry/incidents/:id/confirm → sets the band +
writes a timeline event). Migration 0079
(confidence_band column). UI: a band chip + a confidence
block (per-modality evidence bars + the Confirm button) on the incident
detail.
5 · Incident Ops View — the canvas compositor
From an incident, a 🛰 Ops View renders pre/post
satellite scenes over the AOI in a canvas compositor
(features/sentry/OpsViewPanel.tsx +
ImageCompare.tsx) with tabs Optical (O) / SAR (S) /
Thermal (T) / Δ Change / 🏚 Damage / ⌆ Coherence. Four compare
modes over static AOI crops (the Planetary Computer titiler's
item/bbox/{minx,miny,maxx,maxy}.png, CORS * so
canvas getImageData works): Swipe
(draggable divider), Side-by- side,
Overlay (per-side colourise natural/grey/red/blue/green
+ independent opacity), and Change (per-pixel composite
— grey=unchanged, red=appeared/post-brighter,
blue=removed/pre-brighter, with a sensitivity control).
GET /api/sentry/incidents/:id/imagery?modality= resolves
the epoch pair on demand (sentry_screen::resolve_view,
reusing the same STAC+tilejson machinery as the daemon); the AOI comes
from the incident's stored detection.imagery.bbox (daemon
incidents) or the incident point. The Thermal tab
resolves a Landsat surface-temperature pair
(landsat-c2-l2 lwir11, inferno colormap,
120-day window) through the same compositor, plus FIRMS hotspots as a
map overlay with a count and a last-1/3/7/10-day selector (⚠ over a
small ±0.02° AOI, Landsat's 100 m thermal resolution reads coarse — a
wider AOI resolves more detail).
6 · Commercial tasking connectors
The crisis-hour escalation surface — open imagery is
24–72h latency, so an incident that needs a fresh, higher-resolution
look now tasks a commercial vendor over the AOI; the Guardian
strike-risk → priority handshake is what makes the spend defensible (a
P1 asset justifies a priority order). Migration 0080
(sentry_tasking_order) +
crates/cartos-data/src/sentry_tasking.rs +
crates/cartos-api/src/sentry_tasking.rs:
| Route | Purpose |
|---|---|
GET /api/sentry/tasking/vendors |
Vendor registry — Umbra/Capella/ICEYE/Maxar/Planet, real sensor specs + illustrative cost bands |
POST /api/sentry/tasking/feasibility |
{bbox, priority} → per-vendor est. cost (area km² ×
rate × priority multiplier — P1 1.5× / P2 1.0× / P3 0.75×, with a
min-order floor) + earliest-collect window (rapid/standard ×
priority) |
POST /api/sentry/tasking/search |
{bbox, days} → a live bounded crawl of
Umbra's free open-data STAC archive |
GET/POST /api/sentry/tasking/orders |
List / place a cost-visible order — records intent, linked to the incident timeline |
POST /api/sentry/tasking/orders/:id/status |
Advance the order lifecycle |
Web features/sentry/TaskingPanel.tsx (AOI from the
viewport or an incident, P1/P2/P3 selector, reactive vendor cost cards,
an open-archive search with thumbnails, and the order log) on
sentry-command/sentry-detect/
sentry-incidents; a "🛰 Task imagery"
button on the incident detail hands over the AOI + severity-derived
priority. Honest by design: the credentialed vendors
(Capella/ICEYE/Maxar/Planet) are reported as "requires vendor API
credentials," never fabricated; an order records intent, it does not fly
a collect; cost figures are illustrative anchors, consistent with the
cartos-pricing convention. Umbra's open archive is a deep
year → month → day → item tree with no spatial extent on interior nodes,
so the crawl uses a recency-first depth-first search
(date-sorted, newest-first, a 90-fetch/ 12-second budget) plus a live
proof-of-life sample of the latest collects (unfiltered by AOI, clearly
labelled) — the archive's ~20 sites rarely cover the Gulf demo AOIs, so
the sample keeps the panel honestly "live" without pretending coverage
that isn't there.
7 · InSAR coherence context layer
Done the infra-fit way, not the expensive way: not a
live coherence-loss detector (that needs SLC imagery + SNAP/GPU, or an
async HyP3/Earthdata connector — the documented v2 path) but a
pre-computed expected-coherence context layer from the
open Global Seasonal Sentinel-1 Coherence dataset
(Kellndorfer et al., AWS
sentinel-1-global-coherence-earthbigdata, seasonal
2019–2020 median, 3 arcsec). Sidecar POST /render/coherence
crops the global VRT over the AOI via GDAL /vsicurl
+ -projwin (fetches only the AOI's bytes) →
mean/median/low-fraction stats + a colourised RGBA PNG (red =
decorrelated, green = coherent).
GET /api/sentry/incidents/:id/coherence picks the season
from the AOI's hemisphere + month and adds a High/Moderate/ Low
interpretation. The ⌆ Coherence Ops-View tab drapes the
PNG over Esri. It qualifies the amplitude detectors —
change over normally-coherent ground is more likely real; change over
already-decorrelated ground is more likely noise — it is a
surface-stability layer, not a live-event detector, and
bridges into the precision hardening below (the principled path to a
sand mask, since NBR/NDWI/NDVI genuinely can't separate bare sand from
built surfaces).
8 · Thermal colour ramps + change-over-Esri
Two Ops-View refinements: (a) selectable thermal colour
ramps (inferno/magma/plasma/viridis/turbo/hot/jet/rainbow) via
a colormap query param on the imagery endpoint, a 🎨 Ramp
selector in the T tab. (b) "⧉ Change over Esri" for
SAR/optical/thermal — sidecar POST /render/change-raster
derives per-pixel appeared(red)/removed(blue) rasters from a tab's
pre/post AOI crops as two georeferenced RGBA PNGs;
GET /api/sentry/incidents/:id/change-raster?modality=
resolves the crop URLs; a toggle drapes both over the high-res Esri
basemap with independent appeared/removed transparency sliders.
9 · Local + GeoTIFF/COG imagery upload → change.detect
An analyst uploads their own before/after scenes and compares them in
the same compositor as the satellite Ops View.
POST /api/sentry/uploads (raw body, tenant-scoped, reuses
the Field media seam) for plain images (PNG/JPEG/WebP) —
features/sentry/LocalImageryPanel.tsx on
sentry-detect and sentry-command.
Georeferenced GeoTIFF/COG uploads go through the
sidecar POST /render/raster-preview (GDAL CLI:
gdalinfo -json for the WGS84 extent bounds,
gdal_translate -scale for a 1024 px web PNG — no Python
GDAL bindings needed, CLI only) →
POST /api/sentry/geo-uploads (80 MB) stores the rendered
PNG + returns {url, bbox}. Uploaded imagery also feeds real
SegFormer change detection
(POST /render/local-change, the Phase-2 model, base64) —
for a georeferenced pair the change cells (appeared=red, removed=blue)
drape over Esri on the map at their real AOI and can
raise a genuinely georeferenced incident. ⚠ Added a persistent
media-data volume (/data/media,
CARTOS_MEDIA_DIR) in prod — cartos-api
previously had no media volume, so uploads (and Field media) were lost
on container recreate; see
memory/prod_deploy_postgres_safety.md.
10 · xBD damage assessment
Per-building 4-class damage (no-damage / minor /
major / destroyed) over an incident's AOI. Honest by design:
this is a change-magnitude classifier, not a trained xView2 CNN
(a real xBD model needs the xBD dataset, a GPU, and sub-metre imagery;
Sentinel's 10 m resolution is triage-grade, not building- grade).
Sidecar POST /render/damage-assess fetches the pre/post AOI
crop, samples each building footprint's change, and classifies it;
GET /api/sentry/incidents/:id/damage pulls the footprints
from the overture-sidecar
(/overture/buildings?bbox=) and runs the assessment. The
🏚 Damage Ops-View tab colours buildings by class over
Esri with per-class counts.
11 · Precision hardening — killing the two documented false-positive sources
The daemon's first prod runs autonomously raised two incidents ("Surface change · AUH" 34% and "Burn scar · Das Island" 27.8%) that were almost certainly false positives — S2 NBR/NDWI swings 25–35% between two dates over heterogeneous coastal AOIs (water + sand + built) with no real event, and Gulf gas flares light up FIRMS across nearly every energy asset. Two masks, tuned against live data:
- Persistent-water masking
(
s2_change.py) — cells that are open water in every epoch (baseline NDWI > 0.10) are masked; tidal/wave/sun-glint NDWI swings over standing water were the #1 coastal false positive, while a real flood rises from a dry baseline and survives the mask. Returns awater_maskedcount surfaced as a KPI + summary note. - Persistent gas-flare masking
(
sentry_screen.rs) — FIRMS hotspots are grouped by proximity clustering (connected components at ≤3.5 km, reuniting a facility's stacks regardless of any grid-cell boundary); a cluster is a persistent flare iff it recurs on ≥3 distinct days and spans ≤10 km (a bounded footprint, not a spreading wildfire) — only non-persistent hotspots raise a thermal incident, and a ≥120 MW FRP escape lets a genuine major fire/explosion through even inside a known flare cluster (routine Gulf flares peak ~68 MW). Tuned and verified against the UAE's actual live flares — Das Island (68 hotspots/4 days), Zakum (9/4), and the multi-stack Ruwais terminal (10/3, which required the switch from a fixed grid to proximity clustering to reunite spatially-spread stacks) — all correctly suppressed, while an isolated 1-day Jebel Ali detection and synthetic spreading-wildfire/anomalous-FRP guards correctly still raise.
Prod also tuned CARTOS_SENTRY_MIN_CHANGE_PCT from 5 →
10 once baseline masking landed.
12 · Sentry operations dashboard
features/sentry/SentryDashboard.tsx (ECharts,
camo-themed) — Sentry has no catalog layers, so it doesn't fall back to
the generic CatalogDashboard like Tower/Milepost/Blueprint
do; it's driven by the real Sentry data plane
(incidents/watchlist/alerts/tasking orders): a KPI strip (open incidents
+ critical/high, confirmed, assets watched + enabled, P1 assets, scans
overdue, alerts sent, tasking spend), an incidents-raised-over-14-days
line, severity and confidence-band donuts, a status funnel,
detection-modality and watchlist-priority bars, a recent-incidents table
(severity/band/status/age), and an alert-dispatch log.
SentryDashboardPage.tsx wraps the shared
DashboardPage; routed for every sentry-*
module.
13 · Incident report/export
One-click auditable record from the incident detail: 📄
Report opens a self-contained, print-ready styled HTML report
(dark camo + a print media query) and DOCX ↓ downloads
the same via the sidecar's /render/markdown-docx (reusing
the M02 §13.2 render
path). sentry_report.rs renders header, severity +
confidence-band chips, detection metrics, a per-modality evidence table,
KPIs, and the full audit timeline. The report embeds the actual
pre/post AOI crop thumbnails (built from the incident's stored
tile URL + bbox, not just dates) — HTML as <img>
tags, DOCX via an extended sidecar build_markdown_docx that
resolves  lines to add_picture.
GET /api/sentry/incidents/:id/report.{html,docx} (tenant +
group scoped, read-only — no migration).
HTTP API reference
| Method | Route | Purpose |
|---|---|---|
| GET/POST | /api/sentry/watchlist |
List / add a monitored asset |
| PATCH/DELETE | /api/sentry/watchlist/:id |
Edit / remove a monitored asset |
| GET/POST | /api/sentry/incidents |
List / open an incident |
| GET | /api/sentry/incidents/:id |
Incident detail |
| POST | /api/sentry/incidents/:id/transition |
Advance the state machine |
| POST | /api/sentry/incidents/:id/assign |
Assign to an analyst |
| POST | /api/sentry/incidents/:id/confirm |
Analyst-only — set confidence band to Confirmed |
| POST | /api/sentry/incidents/:id/note |
Add a timeline note |
| GET | /api/sentry/incidents/:id/imagery?modality= |
Resolve the Ops-View pre/post pair for O/S/T |
| GET | /api/sentry/incidents/:id/damage |
xBD-style per-building damage classes |
| GET | /api/sentry/incidents/:id/coherence |
Expected-coherence context layer |
| GET | /api/sentry/incidents/:id/change-raster?modality= |
Per-pixel appeared/removed raster |
| GET | /api/sentry/incidents/:id/report.html |
.docx |
Auditable incident report |
| GET | /api/sentry/tasking/vendors |
Commercial-tasking vendor registry |
| POST | /api/sentry/tasking/feasibility |
Per-vendor cost + earliest-collect estimate |
| POST | /api/sentry/tasking/search |
Live Umbra open-archive search |
| GET/POST | /api/sentry/tasking/orders |
List / place a tasking order |
| POST | /api/sentry/tasking/orders/:id/status |
Advance an order's status |
| GET | /api/sentry/alerts |
Alert dispatch log |
| GET/POST | /api/sentry/alert-channels |
List / create a webhook alert channel — tech_admin |
| DELETE | /api/sentry/alert-channels/:id |
Remove a channel — tech_admin |
| POST | /api/sentry/uploads |
GET /api/sentry/uploads/*key |
Upload / serve a local before/after image |
| POST | /api/sentry/geo-uploads |
Upload a georeferenced GeoTIFF/COG → rendered PNG + bbox |
| POST | /api/sentry/detect-local |
Run change detection over an uploaded pair |
Migrations 0075–0080: 0075
enables the three modules; 0076
sentry_watchlist; 0077 adds
last_processed_at + sentry_scan;
0078
sentry_incident/sentry_incident_event/sentry_alert_channel/sentry_alert;
0079 confidence_band; 0080
sentry_tasking_order.
Honest limits (read before demoing this edition)
- Open imagery has a 24–72h detection latency. FIRMS active-fire is the only hours-class open feed (~30 min–3h) — and it's fire-only. Crisis-hour reactivity needs commercial tasking (§6 above); the strategic v2 headline is a sovereign SAR constellation JV — an in-house feed no open-data competitor has.
- xBD damage is triage, not a trained model (§10) — Sentinel resolution, change-magnitude classification, not xView2.
- The coherence layer is a stability baseline, not an event detector (§7).
- Sand is honestly not index-separable from built surfaces with NBR/NDWI/ NDVI alone — the coherence layer is the principled mitigation path, not yet wired as a hard gate.
/healthzstill advertisesmock-change-0.1as a registry string even though S1/S2 are the real, live-verified detectors for their collections.
Known gotchas
CARTOS_SENTRY_MONITOR=1(and its_SCREEN/_AOI_HALF_DEG/etc. siblings) must be in the composeenvironment:block, not just.env.prod— the latter only feeds interpolation, it doesn't reach the container.MODEL_SIDECAR_URLunset defaults tohttp://localhost:8300, which is unreachable from thecartos-apicontainer itself — this silently broke both the daemon'schange.detectcalls and the Analysis-panel job worker until fixed tohttp://model-sidecar:8300indocker-compose.prod.yml.- FIRMS's NRT area-CSV day range is 1–5 (a 7 or 10-day request 400s) — the handler, the daemon, and the compose default all clamp to this.
- Deploying any Ops-View/sidecar feature (coherence, change-raster,
raster-preview, damage-assess) needs a model-sidecar
rebuild, not just
cartos-api— those endpoints live in the Python image.
Source files:
cartos-web/src/modules/sentry/
(SentryModule.tsx, SentryDashboardPage.tsx) ·
cartos-web/src/features/sentry/
(SentryControl, WatchlistPanel,
IncidentsPanel, OpsViewPanel,
ImageCompare, LocalImageryPanel,
TaskingPanel, SentryDashboard + their
.css) · cartos-web/src/lib/sentry.ts ·
crates/cartos-api/src/sentry.rs,
sentry_monitor.rs, sentry_screen.rs,
sentry_incident.rs, sentry_tasking.rs,
sentry_report.rs, sentry_upload.rs,
firms.rs · crates/cartos-data/src/sentry.rs,
sentry_incident.rs, sentry_tasking.rs ·
model-sidecar/s1_change.py, s2_change.py,
main.py (/render/coherence,
/render/change-raster, /render/raster-preview,
/render/damage-assess, /render/local-change) ·
crates/cartos-core/src/catalog.rs (three
sentry-* ModuleDefs) ·
crates/cartos-data/migrations/0075–0080 ·
sentry-PRD/ (PRD + build_sentry.py) ·
deploy/Caddyfile.prod (sentry. vhost) ·
deploy/render-realm.py (Keycloak redirect/web-origin for
the sentry host) · docker-compose.prod.yml
(MODEL_SIDECAR_URL, CARTOS_SENTRY_MONITOR*,
CARTOS_FIRMS_MAP_KEY, the /data/media
volume).
8.7 Cartos Harvest — Agriculture Intelligence
Status: merged to
main, taggedcartos-harvest-v1(2026-07-28) — deployed 2026-07-29 and LIVE at https://harvest.cartosatlas.com. Theharvestbranch (scoping + Phase-0 skeleton + prompts 01–07 crop-pipeline slices + de-branding + H1–H4 depth passes) is merged, and Harvest now has its own Caddy vhost, Keycloak redirect origin and subdomain like every other edition in this guide. It is also reachable from within Cartos Africa. Every capability below is verified against real Kenya/Nigeria Sentinel-2 imagery — it can equally be run locally with?edition=harvest(see §2). Scoping doc:docs/harvest-ai-scoping.md. Buildable prompt suite:prompts/harvest/(mirrorsprompts/map-africa/).
What it is: a ninth Cartos edition
— Cartos Harvest (internally "HarvestAI") —
STAC-native crop intelligence: crop type
classification, crop health monitoring, yield forecasting,
water-stress/drought detection, field-boundary delineation, and
agri-risk/finance analytics, for ministries of agriculture,
agri-insurers/lenders, and extension services. The scoping doc is
explicit that this is a platform, not a map layer: "HarvestAI should
be designed as a production-grade agricultural intelligence platform,
not merely as a map layer or an imagery viewer." It reuses
the Cartos Africa country-scope + foundation-layer fabric
rather than rebuilding it (a CountryScopeProvider wraps
every Harvest module; HarvestModule.tsx injects
AfricaScopeLayer/CountryScopePanel/FoundationLayersPanel/
AvailableHerePanel from features/africa/
verbatim) and rides the same config-driven IndustryModule
surface as every industry edition since Tower/Milepost/Blueprint. Anchor
countries (Phase-0 pilot AOIs): Kenya and Nigeria —
already seeded in the Africa edition's country-scope.
The seven modules
| Module id | Label | Covers |
|---|---|---|
harv-command |
Harvest Command | Cross-country food-security overview, crop-calendar status by country/season, country onboarding, cross-module alerts roll-up |
harv-croptype |
Crop Type & Land Use | Multi-temporal crop-type classification (seasonal phenology), parcel-level class + confidence, cropland extent/LULC context |
harv-health |
Crop Health | NDVI/NDRE/NDWI/NDMI time series, anomaly detection vs a multi-year baseline, stress alerts + flag-for-field-visit |
harv-yield |
Yield Forecast | Season-NDVI biomass/yield-proxy regression with inter-annual-CoV uncertainty, driver attribution, an illustrative t/ha readout |
harv-water |
Water & Drought | NDMI/NDWI moisture-anomaly water-stress + drought-severity detection; ET (WaPOR)/rainfall (CHIRPS) as reference layers; UAE greenhouse/CEA anchor (planned) |
harv-boundaries |
Field Boundaries & Registry | gdal_polygonize candidate field-boundary delineation →
human-in-the-loop review → the authoritative parcel fabric; a farmer
registry (name/national-id/phone/cooperative/crops/region, PII
in-scope-only) linked via field_parcels.owner_ref |
harv-risk |
Risk & Finance | Composed 0–100 portfolio agri-risk score (yield outlook + in-season stress + inter-annual variability) with driver attribution + an index-insurance parametric trigger |
Home /harv-command. Default theme
Verdant (#7cc945 — "cropland green &
chlorophyll", a new palette in lib/themes.ts distinct from
the tactical Emerald/the Africa Savanna gold). Module-id prefix
harv- (disjoint from
grid-/air-/dot-/aec-/africa-/guard-/sentry-).
Map-overlay layer prefix hrv- (hrv-anom-,
hrv-cls-, hrv-yield-, hrv-water-,
hrv-parcel-, hrv-risk-,
hrv-flags-). Registered in
crates/cartos-core/src/catalog.rs as seven
ModuleDefs (vertical: "agriculture",
industry_of_vertical("agriculture") → "harvest"); the
platform-level agriculture placeholder
ModuleDef these supersede is now dead (no route — see §7.4) and the disabled
"Agriculture" nav placeholder was removed from
ModuleNav.tsx in favour of the seven real
harv-* nav entries. Migration 0082 enables the
seven modules + the platform's full default feature set
(analysis, compare, map-export,
dashboard, catalog, satellite,
stac, overture,
external-overlays, buildings-3d,
time-of-day, measure, draw,
mapillary, point-cloud) for the dev tenant,
and grants the harvest edition via
tenant_editions (the Access-tab machinery from §7.4) — no new
FeatureDefs, Harvest reuses the existing capability catalog
end to end.
The STAC crop pipeline — the moat
The scoping doc is normative about the engineering position:
crop type is a temporal + spectral problem, not a resolution
problem (classify from phenological signatures over a season,
not single-date RGB); STAC/COG is the authoritative AI layer,
tiles are UX only; and every output carries source +
date + confidence — no fabricated certainty, the same
discipline as Guardian's strike-risk score and Sentry's confidence
bands. Concretely, the Rust API
(crates/cartos-api/src/harvest.rs, 1,062 lines) does live
STAC orchestration over Microsoft Planetary Computer's public,
keyless STAC + titiler
(https://planetarycomputer.microsoft.com/api/{stac,data}/v1)
— sentinel-2-l2a for all seven slices below — and the
model-sidecar (model-sidecar/main.py) does the numpy/GDAL
compute. Seven pipeline slices, each its own /api/harvest/*
handler + panel:
crop-index(GET /api/harvest/crop-index) — STAC-searches for the least-cloudy Sentinel-2 L2A scene over the AOI + date window and returns a keyless, CORS-enabled Planetary Computer titiler URL that band-maths a vegetation index on the fly via theexpressionparam (no raw-COG read on our side, no client key): NDVI(B08-B04)/(B08+B04), NDRE(B08-B05)/(B08+B05)(red-edge/nitrogen), NDWI(B03-B08)/(B03+B08)(canopy/surface water), NDMI(B08-B11)/(B08+B11)(vegetation moisture). All are ratios → scale-invariant, no reflectance scaling needed. Rendered inCropTemporalPanel.tsxas a MapLibreimagesource with a date/index scrubber — the temporal-context view every other slice builds on.crop-type(GET /api/harvest/crop-type) — fetches a season of grayscale NDVI AOI crops (single-band PNGs, rescale −1..1) and posts them to the sidecar'sPOST /render/crop-classify, which builds a per-pixel NDVI time series and, as of v0.2, KMeans-clusters pixels by the shape of their whole time-series (bounded 2–6 clusters, each labelled by its member pixels' mean peak-NDVI + amplitude — a step up from v0.1's flat per-pixel thresholds, since two pixels with the same peak but different seasonality now land in different clusters) into five classes (bare/built/water, sparse veg/grassland, mixed/low-vigor crop, annual cropland — the strong seasonal NDVI swing is the hallmark, dense/ perennial veg);model_version: "harvest-phenology-kmeans-0.2", with a graceful fallback to the v0.1 rule-based thresholds (model_version: "harvest-phenology-baseline-0.1") if scikit-learn is unavailable or the AOI has too few valid pixels. Both are the honest label-free baseline the scoping doc prescribes; a supervised classifier scaffold (model-sidecar/crop_classify_gpu.py+train_crop_classifier.py+requirements.gpu.txt— a Prithvi/Clay fine-tune seam,MODEL_GPU-gated) now exists but is unverified on the CPU dev box, not wired into the default handler — see "What's next".CropTypePanel.tsxrenders the class PNG + a legend + per-class fraction/mean-peak-NDVI breakdown.crop-health(GET /api/harvest/crop-health) — the target month's NDVI vs the same month's NDVI across up to N prior years (a real multi-year baseline, not a fixed threshold), diffed by the sidecar'sPOST /render/ndvi-anomalyinto a diverging red(stressed)→pale→ green(vigorous) map plusstressed_frac/vigorous_frac.CropHealthPanel.tsxoverlays it and adds a lightweight "flag for field visit" — drops a marker (hrv-flags-*) for ground-truthing, the honest bridge to Cartos Field.crop-yield(GET /api/harvest/crop-yield) — season-mean NDVI (an established biomass proxy) relative to the prior-year mean, viaPOST /render/yield-proxy; uncertainty is the inter-annual coefficient of variation of the regional baseline mean (not per-pixel noise, which would blow the band up), clamped to a credible 5–35% CoV range. An optional illustrative t/ha reference per crop (Africa smallholder, FAOSTAT-order, explicitly not a quote) scales the index.YieldPanel.tsxshows the map, the area-index (expected-vs-normal %), and the uncertainty band;model_version: "harvest-yield-viproxy-0.1".crop-water(GET /api/harvest/crop-water) — reuses the same index-agnostic anomaly differ against NDMI/NDWI instead of NDVI: a below-baseline moisture anomaly is the water-stress/drought signal, with a drought-severity tier derived from the dry-area fraction.WaterPanel.tsx; ET (WaPOR) and rainfall (CHIRPS) are foundation-layer reference rows, not yet wired into the score; SPI/VHI and a UAE greenhouse/CEA anchor are documented follow-ups.field-candidates(POST /api/harvest/field-candidates) — the biggest departure from a pure raster render: fetches a season of NDVI crops, posts toPOST /render/field-segment, which classifies the seasonal-phenology cropland mask then vectorises contiguous cropland via GDAL'sgdal_polygonizeinto candidate field polygons (area_ha + centroid peak-NDVI), and persists them into the newfield_parcelstable (migration0084) asstatus='candidate'.FieldBoundariesPanel.tsxshows the map + a review queue —PATCH /api/harvest/parcels/:idpromotes a candidate toapprovedorrejected, the human-in-the-loop step that turns a label-free baseline into the authoritative parcel fabric.GET /api/harvest/parcels(+/summary) reads the fabric back,?bbox=-filterable.portfolio-risk(GET /api/harvest/portfolio-risk) — composes three drivers from the above into a transparent 0–100 agri-risk score, explicitly modelled on Guardian's strike-risk formula:
wherescore = 100 · (0.45 · yield_risk + 0.35 · stress_risk + 0.20 · variability_risk)yield_riskcomes from the yield-proxy area-index,stress_riskfrom the NDVI-anomaly stressed fraction, andvariability_riskfrom the yield-proxy's inter-annual CoV — each clamped 0–1 against a credible band. Tiers mirror Guardian's (Critical ≥80/High ≥65/Elevated ≥50/Moderate ≥35/Low). A transparent, auditable index-insurance parametric trigger fires when biomass < 80% of normal OR in-season stress > 25% of area —index_trigger.reasonexplains which.RiskPanel.tsxcolours each field parcel by a per-parcel risk (the AOI base score modulated by that parcel's own NDVI vigor) and shows the driver breakdown + trigger status. This is portfolio risk analytics for insurers/lenders/ministries, explicitly NOT personalised financial advice (the same "no fabricated certainty" discipline as Sentry).
All seven POST/GET handlers share s2_search (STAC POST
to /search), parse_bbox (rejects degenerate or
>2° AOIs — a titiler/STAC cost guard), and date_window
(YYYY-MM → whole month, YYYY-MM-DD → ±15 days,
absent → last 45 days). Data layer:
crates/cartos-data/src/harvest.rs
(list_crop_calendars, season_stage,
insert_field_candidates, parcel read/patch helpers) — same
tenant + owning_team scoping convention as every other
module.
Persisted products (M03) — the Saved Products panel
Every analytics run (crop-type, crop-health, crop-yield, crop-water,
portfolio-risk) has a "Save to products" option; when
ticked, the shared maybe_save helper
(harvest.rs) persists the whole result body as a
lineage-tracked derived_dataset row via
the same M03 store every other edition's saved analyses use (§13.1 — reuses
migration 0013, no new migration), naming
it from the AOI centroid + target date and stamping
tool_ref (crop_classify /
ndvi_anomaly / yield_proxy /
moisture_anomaly / portfolio_risk) and
tool_version (the handler's model_version,
e.g. harvest-phenology-kmeans-0.2).
GET /api/harvest/products?bbox=&kind= lists them
newest-first (PNG bytes stripped for the list view — see
strip_png), scoped by tenant + owning_team;
GET /api/harvest/products/:id returns one in full (incl.
the rendered PNG). The Saved Products panel
(SavedProductsPanel.tsx) browses the list with per-product
headline stats (top crop-type class, mean NDVI-anomaly delta,
yield-vs-normal %, drought severity, risk score/tier) and is mounted
alongside the other harvest panels in HarvestModule.tsx.
The harvest_products agent tool reads the identical store,
so "what's the latest yield forecast we ran?" answers from what was
actually saved, not a live re-run.
Crop-calendar / agro-ecological-zone model
crop_calendars (migration 0083) makes every
module season-aware: country + agro-zone + crop rows
carry plant_start/plant_end/peak/
harvest_start/harvest_end (months 1–12, cyclic
— a window may wrap year end), a season_label (e.g. "Long
rains", "Short rains"), and a source (seeded
'FAO GIEWS'). Seeded for the two Phase-0 anchors:
Kenya (6 rows — long/short-rains maize, Rift Valley
wheat, Mwea-scheme irrigated rice, semi-arid sorghum, highland beans)
and Nigeria (6 rows — early/main-season maize, Fadama
wet-season rice, Sudan-savanna sorghum, Sahel millet, perennial
cassava). GET /api/harvest/crop-calendar?country=&crop=
returns each row annotated with its current phenology
stage (planting/vegetative/ harvest/off-season) computed from
today's month — the same stage math backs both the
crop_calendar agent tool and the
HarvestCommandDashboard's season-aware KPI tiles and
per-country stage board.
Harvest Command — the dashboard
harv-command gets a bespoke dashboard
(HarvestCommandDashboard.tsx, not the generic
CatalogDashboard) — food-security + crop-intelligence KPIs,
a country-onboarding board, and ECharts charts, all driven live by
/api/harvest/crop-calendar (reusing
AfricaCommandDashboard.css's hero/ grid/board chrome for
visual continuity with Continental Command). Every figure reflects the
current month's phenology; crop-type/health/yield map
tiles are marked pending until the STAC pipeline lands data for a given
country (Phase-0 anchors only cover Kenya + Nigeria so far).
Foundation-layer catalogue
lib/harvest-data-catalog.ts follows the exact
lib/africa-data-catalog.ts pattern — a typed registry
rendered by the reused FoundationLayersPanel, grouped by
category with license badges: Imagery (Esri World
Imagery view-only, Sentinel-2 Cloudless EOX CC-BY-NC-SA — flagged
open-nc/never embed, Sentinel-2 L2A + Sentinel-1 GRD SAR as
STAC references), Indices (MODIS NDVI 16-day via GIBS,
the derived NDVI-anomaly layer), Cropland (DE Africa
Cropland Extent 10m CC-BY, ESA WorldCereal 10m CC-BY, the derived
crop-type layer), Land Cover (ESA WorldCover 10m CC-BY,
Esri/Impact Observatory Annual LULC view-only), Soils
(iSDAsoil Africa 30m, SoilGrids 250m, both CC-BY), Water &
Rainfall (FAO WaPOR v3 ET, CHIRPS rainfall — both open),
Terrain (Esri World Hillshade over Copernicus GLO-30
DSM, OpenTopoMap contours), Boundaries (geoBoundaries
ADM0/ADM1 — not GADM, same license posture as Africa),
Population (WorldPop 100m CC-BY). The Africa edition's
AvailableHerePanel ("Available Here"
georeference-aware registry — see §8.4) is mounted
verbatim too, so a country/zoom-narrowed candidate-source list surfaces
automatically without any Harvest-specific registry work.
Farmer registry —
completes harv-boundaries
farmer_registry (migration 0085) is a
tenant-scoped table of farmers/ land-holders (name,
national_id, phone, gender,
cooperative, crops[], region,
owning_team) — the PII fields (name/national-id/phone) are
served only to in-scope callers, same convention as everywhere else in
the platform. field_parcels.owner_ref (mig
0084) is the join that turns a delineated field polygon
into an operational unit for extension, insurance, and subsidy
workflows. Endpoints: GET/POST /api/harvest/farmers (list —
optional q name/national-id search — and register),
POST /api/harvest/parcels/:id/owner (link a parcel to a
farmer, from the Field Boundaries review panel),
GET /api/harvest/registry/coverage (farmer count, parcels
owned, coverage %). FarmerRegistryPanel.tsx is the UI:
search, register (with a crops comma-list input), and a coverage strip;
it's mounted in harv-boundaries alongside
FieldBoundariesPanel.tsx.
The agent tools
Five edition-wide tools (module() returns
None, same pattern as
africa_scope/guardian_strike_risk), all in
crates/cartos-agent/src/builtin/harvest.rs:
crop_calendar— planting/peak/harvest windows + current stage, filterable bycountry(ISO3)/cropsubstring, answers "what's the current crop season in Kenya?" / "when is maize planted in Nigeria?".harvest_country_profile— a per-country summary (iso3required) of which crops are planting/growing/harvesting right now, answers "give me the agriculture picture for Kenya." Bothcrop_calendarandharvest_country_profilereadcartos_data::harvest::list_crop_calendarsand return a cleanNotFound(not a crash) whencrop_calendarsisn't loaded for a tenant, or the queried country isn't one of the two anchors.parcel_fabric_summary— how many field parcels are delineated and their review-status breakdown (candidate/approved/rejected + area) plus registry coverage (farmers, parcels linked, % owned);NotFoundif no parcels have been delineated yet.harvest_products— the persisted-product catalog (see "Persisted products" above), optionalproductfilter (crop_classify/ndvi_anomaly/yield_proxy/moisture_anomaly/portfolio_risk) +limit; PNG bytes stripped;NotFoundif nothing has been saved yet.farmer_lookup— search the farmer registry by name/national-id substring (or list all), returns cooperative/region/crops/linked-parcel count per match (max 25); PII, in-scope callers only.
Data & license posture
Same license-class convention as Africa
(open/open-nc/view-only/
commercial), with the same two landmines flagged:
geoBoundaries, never GADM (non-commercial) for
boundaries; Copernicus GLO-30 DSM, never FABDEM
(non-commercial) as the default terrain. Decision 2026-07-27
(locked in the scoping doc): ship Phase 0/1 fully
open — Sentinel-1/2, DE Africa, WorldCereal, WaPOR, CHIRPS,
WorldCover, geoBoundaries, GLO-30 — no commercial imagery licensed yet;
Planet/Vantor/Airbus are a future opt-in with the
server-side datasource-proxy seam (already shipped for Africa — §8.4) wired but
nothing keyed. GPU: local GPU is available and targeted
for the Phase-1 foundation-model classifier. A
supervised-classifier scaffold now exists
(model-sidecar/crop_classify_gpu.py — a Prithvi
multi-temporal crop-classification fine-tune seam,
MODEL_GPU-gated, defaults to a public HuggingFace
checkpoint that's domain-mismatched for Africa until
fine-tuned on local labels via train_crop_classifier.py +
requirements.gpu.txt) but is unverified on the CPU
dev box — not a shipped capability, a documented depth
follow-up. The v0.2 KMeans temporal-phenology clustering (§ above) stays
the graceful, label-free fallback
(crop_classify_gpu.available() gates the GPU path; unset
falls back to KMeans).
Relationship to
Cartos Africa — africa-food
Cartos Africa already ships an africa-food module
(cropland extent, crop health, soils, IPC food-security phase tracking —
see §8.4). The
scoping decision: Harvest owns the deep crop-ML stack;
africa-food stays a light food-security teaser.
Migration 0082 includes a commented-out
module_industries cross-deploy block
(harv-croptype/harv-health →
africa) using the editions-as-first-class machinery from §7.4 — held deliberately until
the crop modules carry real data beyond the two pilot countries, so an
empty module doesn't show up in the Africa nav. Do it from the Access
tab once ready, or uncomment the block.
Known gotchas (for the next session that touches this edition)
- Deployed 2026-07-29 —
harvest.cartosatlas.com, its Caddy vhost and its Keycloak redirect origin are all live, following the deploy patterns from §§8.4–8.6. The remaining gotchas below still apply to any fresh environment. field-segmentneeds GDAL'sgdal_polygonizeCLI on the model-sidecar image — the sidecar shells out to it, so a Docker rebuild ofmodel-sidecar(not justcartos-api) is required before field-boundary detection works in a deployed environment.- The browser calls
planetarycomputer.microsoft.comdirectly for the rendered index/class PNGs (CropTemporalPanelet al. set them as MapLibreimagesources) — a production CSP needsplanetarycomputer.microsoft.comin bothimg-srcandconnect-src, not just allow-listed server-side (the Rust STAC search is server-side, but the titiler PNG fetch is client-side). - sqlx embeds migrations at compile time —
crates/cartos-data/src/lib.rsneeds 0082–0085 registered +cartos-clirebuilt beforemigratepicks them up (the same trap every edition's migration set has hit). - AOI size is capped at ~2° (
parse_bbox) to bound STAC/titiler cost — a portfolio-wideharv-riskview over a whole country will 400; the panel needs to stay zoomed to a workable AOI, a UX gap not yet addressed. - The GPU classifier scaffold is unverified —
crop_classify_gpu.py/train_crop_classifier.pycompile but have not been run against a CUDA host; don't represent Harvest as GPU-model-backed until that verification happens.
What's next
Real deploy (own subdomain, Caddy vhost, Keycloak redirect origin,
migrate + data load in the sequence every other edition has learned the
hard way — see the gotchas in §§8.4–8.6); verify the supervised
Prithvi/Clay crop classifier scaffold on a CUDA host and
fine-tune on local labels (the honest follow-up to the v0.2 KMeans
phenology baseline, called out in every relevant
model_version string); the UAE
greenhouse/controlled-environment anchor + SPI/VHI drought
indices for harv-water; per-AOI-product agent
tools (today's tools are
calendar/fabric/registry/products-catalog — a
crop_index/crop_health agent tool over a named
AOI is the natural next slice, matching
query_catalog_layer's pattern); and the held Africa
cross-deploy once crop coverage broadens past
Kenya/Nigeria.
Source files:
docs/harvest-ai-scoping.md · prompts/harvest/
(master + 7 build prompts + PRD-update + data-source appendix) ·
cartos-web/src/modules/harvest/ (modules.ts —
HARVEST_MODULES, HarvestModule.tsx,
HarvestDashboardPage.tsx) ·
cartos-web/src/features/harvest/
(CropCalendarPanel.tsx, CropTemporalPanel.tsx,
CropTypePanel.tsx, CropHealthPanel.tsx,
YieldPanel.tsx, WaterPanel.tsx,
FieldBoundariesPanel.tsx,
FarmerRegistryPanel.tsx, RiskPanel.tsx,
SavedProductsPanel.tsx,
HarvestCommandDashboard.tsx +
harvest-panels.css) ·
cartos-web/src/lib/harvest.ts (fetch client) ·
cartos-web/src/lib/harvest-data-catalog.ts
(foundation-layer registry) ·
crates/cartos-api/src/harvest.rs (17 route handlers) ·
crates/cartos-data/src/harvest.rs ·
crates/cartos-agent/src/builtin/harvest.rs
(CropCalendar, HarvestCountryProfile,
ParcelFabricSummary, HarvestProducts,
FarmerLookup) ·
crates/cartos-core/src/catalog.rs (seven
harv-* ModuleDefs, harvest
IndustryDef) ·
crates/cartos-data/migrations/0082–0085 ·
model-sidecar/main.py (/render/crop-classify,
/render/ndvi-anomaly, /render/yield-proxy,
/render/field-segment) ·
model-sidecar/crop_classify_gpu.py,
train_crop_classifier.py, requirements.gpu.txt
(unverified GPU scaffold) · cartos-web/src/lib/edition.ts
(harvest preset) ·
cartos-web/src/lib/themes.ts (verdant theme) ·
crates/cartos-agent/src/llm.rs
(ContentBlock::Thinking/RedactedThinking — the
Opus 5 fix, engine-wide not Harvest-specific).
9. Cross-cutting features every module inherits
- Dockable floating panels
(
DockablePanel/PanelDock): users arrange panels to taste (persisted in localStorage, ID-namespaced per module); an admin can snapshot the defaults for everyone via the user badge. This is the standard for all map-surface overlays. ThePanelDocklauncher rail includes named slots foranalysis(icon: mini chart),compare,map-export, andpointcloud(icon: hexagon wireframe, label "3D Data"), each shown or hidden per the module's corresponding capability feature. - Edition switcher
(
shell/EditionSwitcher.tsx): a header pulldown — accent dot + current edition name + caret, click-outside/Escape to close — that lists every edition (Atlas/Grid/Tower/Milepost/Blueprint/Africa) and jumps to its subdomain viaeditionOrigin(). Shipped 2026-07-18 as a compact pulldown (was a horizontal pill row) so the module-nav tabs fit on one header row. - GeoWizard — data-source marketplace
(
shell/GeoWizard.tsx): tabs for UAE Data, Living Atlas / ArcGIS Online search (no account needed), ArcGIS Hub, and Custom URL. Results add as styled, persistable layers. - Live raster overlays, added via a uniform
reconciler pattern (a panel emits
Active<X>Layer[]; the module adds/removes raster sources prefixedarcgis-/gibs-/stac-and re-adds onstyle.load):- ArcGIS REST services
(
ArcGisLayersPanel, AD-SDI catalog + custom URL + identify popups). - NASA GIBS satellite — a 22-layer curated WMTS
catalog in every module's Satellite Data tab
(
SatelliteDataPanel,nasa-gibs-catalog); native maps render via MapLibre, iframe modules receive layers over the bridge. - STAC catalogs (
StacCatalogPanel,lib/stac.ts) — now a two-tab panel:- Search tab: collection picker + viewport bbox search + "Add" to make a layer active.
- Manage tab: lists every active overlay with on/off
visibility toggle, per-layer opacity slider, delete, and a
Save button (persists the set via
/api/layers). The Manage tab also hosts the Image Boost controls (see below).
- ArcGIS REST services
(
- Global Image Boost
(
lib/image-boost.ts): a single raster-quality control that applies to every active data layer simultaneously — STAC, ArcGIS imagery, and NASA GIBS. Controls exposed in the STAC panel's Manage tab:- Smooth tile boundaries (MapLibre
raster-resampling: linearvsnearest). - Contrast × and Saturation ×
multipliers (mapped to MapLibre's
raster-contrast/raster-saturation[-1,1]range; UI speaks human ×-multipliers). - Master Opacity % — multiplies each layer's own opacity.
- Persisted in
localStorage(cartos.image-boost); re-applied by the host module's raster reconciler after each basemap swap or style reload.
- Smooth tile boundaries (MapLibre
- Layer persistence:
/api/layers(list/save/delete) so discovered overlays survive reloads. - Layer styling on the fly
(
LayerStylePopover,lib/layer-style*). - Map tools: export (
lib/map-export.ts), camera/bookmarks (lib/map-camera.ts), geocoding (lib/geocode.ts), aLegend, and identify/floating-popup. Measure and Time of Day are full capability features with their own dock panels — see §9.8. - Global Mobility live feeds
(
lib/mobility.ts→/api/mobility/*): live aircraft (adsb.lol, no key), vessels (VesselFinder/aisstream, no key), and traffic tiles (TomTom — needs a key; serves transparent tiles without one). - Swipe Compare
(
features/compare/CompareOverlay.tsx, capabilitycompare, PR #48 + refined in PR #56 + PR #58): A/B comparison of two raster layers, camera-synced to the host module's map. Opening the overlay places two additional synced MapLibre maps above the module map; closing restores the host map. Two view modes selectable in the panel:- Swipe — a draggable divider wipes layer B over layer A; drag handles are clamped 2–98 % of the viewport width.
- Side-by-side — left and right maps fill
edge-to-edge panes; the draggable divider resizes the split between them
(left map clipped to its own pane). Bidirectional camera sync
(PR #58): both panes are fully interactive in both modes. Pan
or zoom from the left pane or the right pane — both maps move together.
A
syncingguard flag prevents themove → jumpTo → movefeedback loop. (Previously the right pane was non-interactive and only followed the left.) Each side (A/B) now chooses from a full grouped layer catalog plus a "Current map" source (PR #58): - Current map: clones the live host module's complete map
style (basemap + active data/overlay layers) at selection time via
map.getStyle(). Use this to compare exactly what is on screen — with its active STAC, ArcGIS, GIBS, or Overture overlays — against any other source. - Basemap: OpenStreetMap, Carto Light, Carto Dark.
- Esri: Satellite (World Imagery), Streets, Terrain, Topographic, Ocean, NatGeo.
- Satellite (GIBS): MODIS Terra True Color, MODIS Aqua True Color, VIIRS SNPP True Color — near-real-time (~2 days back) from NASA GIBS.
- Custom URL: an XYZ tile template, a WMS/WMTS GetTile URL,
or an Esri REST tile URL with
{z}/{x}/{y}(or{z}/{y}/{x}) placeholders. Companion to two-epoch change detection — set A/B to before/after imagery before runningchange.detect. Catalog featurecompare; default on Smart City, Maritime, and Emergency Services (migration0017). Gated per module the same way asanalysis.
- Map Export & Mapbook
(
features/map-export/MapExportOverlay.tsx, capabilitymap-export, PR #51 + PR #53 + refined in PR #56): consolidated into the left-dock sidebar panel (same pattern as Analysis and Compare). See §9.2 for full detail. Catalog featuremap-export; default on Smart City, Maritime, and Emergency Services (migration0018). - Time of Day
(
features/time-of-day/TimeOfDayOverlay.tsx, capabilitytime-of-day, 2026-07-10): a sun-position lighting panel — scrub or animate apparent solar time to drive 3D building shading, hillshade illumination, and a day/twilight/night sky. See §9.8. Default on Smart City, Maritime, and Emergency Services (no DB migration needed — a static catalog default, same asanalysis/compare). - Measure
(
features/measure/MeasureOverlay.tsx, capabilitymeasure, 2026-07-10): the measure tool upgraded to a 3D-aware Distance/Area/Height panel with terrain-derived slope length. Replaces the old inline Smart City distance/area button. See §9.8. Default on Smart City, Maritime, and Emergency Services. - Draw & Annotate
(
features/draw/DrawOverlay.tsx, capabilitydraw, 2026-07-11): sketch/markup panel — Select · Point · Line · Polygon · Rectangle, per-feature stroke/fill/width + optional label, a live feature list, Clear all, Copy/Download GeoJSON. Default on Smart City, Maritime, and Emergency Services. See §9.9. - Navigate compass rail
(
features/navigate/NavWidget.tsx, 2026-07-11): the always-on, collapsible, drag + resizable HUD that replaced MapLibre's default zoom/compass/geolocate/fullscreen controls in every native, Cartos Grid, and industry-edition module — camera controls, a multi-CRS coordinate readout (WGS84/Web Mercator/UTM/MGRS/State Plane), and a toggleable UTM/MGRS grid overlay. Not capability-gated — always present. See §9.9. - Widget kit
(
features/widgets/widget-kit.css, 2026-07-11): the sharedwk-*holographic-HUD CSS language behind every map-tool panel (Measure, Time of Day, Draw, Navigate), theme-token-driven so a platform theme recolours the whole kit. See §9.9. - KPI strip + sensor-readings charts
(
KpiStrip,SensorReadingsChart,chart-themes), with themeable dashboards (ops-floor dark / holographic). - Module nav reordering: drag to reorder (persisted in localStorage), with a reset to the default lineup.
- Platform theme system — see §9.4.
- Cartos rebrand (
shell/CartosMark.tsx): the Cartos inline SVG mark is now shown on the splash screen, login page, and app shell. Operator and edition logos have been removed from the engine default; editions re-skin the mark via their own branding configuration. This makes the engine identity neutral so any licensed edition can apply its own visual identity without forking the core.
9.1 Agent intelligence surface — Cartos overhaul
Status: built and merged to
main(PRs #37, #40).
The agent panel
(cartos-web/src/shell/AgentChatPanel.tsx) is a global
sidebar in AppShell. It was overhauled to become a full
intelligence surface:
Window presence
The agent sidebar is resizable: drag the left edge
to set the width (300–900 px); the choice persists in
localStorage. A fullscreen/expand toggle
pops the panel out to cover the entire app for deep analysis work; a
restore button returns to the sidebar layout.
"Ask the map" — voice input
A microphone button below the prompt textarea uses
the browser's native Web Speech API
(SpeechRecognition / webkitSpeechRecognition,
Chromium-based browsers). Speaking transcribes into the prompt box; the
user reviews the text and hits Enter. No backend is involved — purely
client-side. Unavailable in non-Chromium browsers (an alert
informs the user).
Rich response rendering
Assistant answers are rendered as GitHub-flavored
Markdown via react-markdown +
remark-gfm (AgentMarkdown.tsx). Supported in
the panel:
- Headings, bold, bullet/numbered lists, inline code, fenced code blocks.
- Markdown tables — the primary format for tool results (vessels, incidents, sensors, KPIs).
- Links (
[label](url)) and images () when the agent returns real URLs. - A custom fenced **
```chart** block: the agent emits a JSON payload and the client renders it as an **inline SVG chart** (bar / line / pie) —AgentMarkdown.tsx > Chart. The agent system prompt (crates/cartos-agent/src/agent.rs`) instructs the model to use chart blocks only when they add value and only with real tool-returned data. - For non-trivial analytical answers, the system prompt directs the
agent to append
## Summary(2–4 sentences) and## Recommendations(bulleted, actionable list) — establishing a consistent report structure.
The same AgentMarkdown component is used for the
export path (see below), so tables and SVG charts carry
through to the rendered report faithfully.
Comprehensive export
An Export button (visible when the conversation has at least one turn) opens a three-option menu:
| Option | How it works |
|---|---|
| Print / PDF | Builds a Cartos-styled HTML report client-side via
buildAgentReportHtml (re-renders each assistant turn
through AgentMarkdown with
renderToStaticMarkup) → opens in a new tab → triggers
window.print(). Browser prints to PDF. |
| Word (DOCX) | Concatenates the conversation into a single Markdown document
(conversationMarkdown), POSTs to
POST /api/agent/export/docx (cartos-api), which proxies the
Python model-sidecar's markdown→docx renderer. Download is triggered
client-side on the blob response. |
| HTML | Downloads the same Cartos-styled HTML file
(downloadHtml) — portable, self-contained, no server
round-trip. |
The export is titled from the first user question (truncated to 70
chars). Live map screenshots in the export are a known
follow-up — the global agent panel has no handle to the active
module's map ref; that bridge is planned once a map-ref context is added
to AppShell.
Agent system prompt
crates/cartos-agent/src/agent.rs
SYSTEM_PROMPT was updated to direct the agent to respond in
GitHub-flavored Markdown, use tables for tabular data, optionally end
with Summary/Recommendations sections, and emit chart code-fences for
visual data — while staying strictly grounded in tool-returned data.
MCP server — built (PR #43)
The Cartos MCP server is built and operational. See §13.6 for full documentation.
9.2 Map Export & Mapbook
Status: built and wired as a first-class capability feature (PRs #51, #53, #56); merged to
main. Migration0018enables it for the dev tenant on Smart City, Maritime, and Emergency Services.
Source:
cartos-web/src/features/map-export/MapExportOverlay.tsx ·
cartos-web/src/lib/mapbook.ts
A recreation of the EarthWatch3D advanced export and mapbook, ported
to the MapLibre native modules and refined to full EarthWatch3D parity
(PR #56). It is a portable capability feature
(map-export) that slots into the PanelDock
rail alongside Analysis and Compare — wired identically, and addable to
any native map module via Admin → Features.
Panel layout (PR #56 refinement)
Export is now consolidated into the left-dock sidebar
panel — the same DockablePanel pattern as Analysis
and Compare. The old top-bar "Export" menu that existed in Smart City
has been removed; all export flows live in the panel. A Create
Map | Mapbook mode toggle at the top of the panel selects which
workflow is shown. A Data (CSV) section at the bottom
of the panel exposes per-module data tables as CSV downloads
(DataExport[] prop), portable across any host module.
Create Map mode
The Create Map tab exports the current view. It offers four EarthWatch3D-parity single-page layout templates, each with a preferred default paper size and orientation:
| Template | Default paper | Default orientation | Description |
|---|---|---|---|
| Classic Visual | Tabloid | Landscape | Single-page header + map + plate row. The baseline. |
| Operational Briefing | Tabloid | Landscape | Hero map + right sidebar: stat tiles (zoom/centre/bearing), mission notes (free text), overview inset (auto-zoomed out ~4 levels). |
| Field Map | A3 | Portrait | Max-map portrait with neatline + bottom sheet plate (project, date, centre coords, zoom). Caveat line for datum/declination. Print-and-fold. |
| Compact A4 Brief | A4 | Portrait | One-pager: header, stat tiles (zoom/centre/bearing/date), map, notes section. |
Selecting a template automatically seeds the paper size and orientation; both are still overridable. All templates include a bearing-aware north arrow, geodesic scale bar, neatline frame, optional classification banner (SEC/FOUO/CONFIDENTIAL/SECRET/TOP SECRET — auto-coloured), and a bounds/projection footer.
Three export buttons are always present in Create Map mode:
- Export cartographic PDF — builds the selected template via jsPDF (direct vector drawing; unit = inches) entirely in the browser.
- Export GeoPDF (georeferenced) — captures north-up + nadir and server-sides the georeferencing via GDAL (see below).
- Capture PNG — one-click
canvas.toDataURLdownload.
Paper sizes
The full plotter paper range is now available (down from five sizes in the original build):
| Group | Sizes |
|---|---|
| Office | Letter (8.5×11), A4, Tabloid (11×17) |
| ISO | A3, A2, A1, A0 |
| ARCH (plotter) | ARCH A (9×12), ARCH B (12×18), ARCH C (18×24), ARCH D (24×36), ARCH E (36×48), ARCH E1 (30×42) |
| Wide format | Wide 30×42, Wide 36×48, Wide 36×60, Wide 42×60, Wide 48×60 |
| Custom | User-defined width × height in inches |
All sizes support landscape and portrait orientations (the panel swaps the longer and shorter dimension accordingly).
Mapbook mode
A multi-page atlas from the current view, assembled via jsPDF:
- Cover page — AOI overview map + labeled tile-grid index showing which sheet covers which cell. A sheet-plate row records scale, sheet count, paper, and generation timestamp.
- Sheet per tile — the map flies to each tile's bbox
(
fitBounds+'idle'event), captures the canvas, and writes a sheet with a plate label (e.g. "Sheet B-3"), scale bar, north arrow, and a mini index grid indicating the current tile position. View is restored after the last sheet. - Appendix (optional) — free-text notes page.
The tile grid auto-computes rows × columns from a chosen scale (1:N) and an overlap percentage (0–40 %), or the user sets the grid dimensions manually. Maximum 12 × 12 tiles.
OGC GeoPDF export (PR #53)
A true OGC GeoPDF — georeferenced, measurable, opens correctly placed in QGIS/ArcGIS. Available in both Create Map and Mapbook views via the "Export GeoPDF (georeferenced)" button.
How it works — server-side GDAL:
captureNorthUpinlib/mapbook.tsforces a north-up nadir capture (bearing = 0, pitch = 0) and records the current map bbox as Mercator corners, then restores the prior bearing/pitch.- The PNG + the Mercator bbox are
POSTed toPOST /api/export/geopdfin cartos-api. - cartos-api proxies the request to the model-sidecar's
POST /render/geopdfendpoint. - The sidecar runs
gdal_translate -of PDF -a_srs EPSG:3857 -a_ullr <xmin> <ymax> <xmax> <ymin>(fromgdal-bin), tagging the PNG with Web Mercator corners. - The resulting
.pdfbytes are streamed back asapplication/pdfand downloaded.
CRS — EPSG:3857 (Web Mercator): the MapLibre canvas
is Mercator; tagging as EPSG:4326 would geometrically distort the image
at non-equatorial latitudes. GIS tools open and measure correctly in
EPSG:3857. Nadir/top-down only:
captureNorthUp zeroes bearing and pitch so the extent is
axis-aligned (required for a four-corner affine georeference).
Oblique/3D views require GCP warping — the documented follow-up.
Verified: gdalinfo reports
Driver: PDF/Geospatial PDF, CRS EPSG:3857, corners matching
the AOI bbox. gdal-bin is installed in both
model-sidecar/Dockerfile and
Dockerfile.gpu.
Technical notes
preserveDrawingBuffer: trueis required on the MapLibre map constructor. Omitting it causescanvas.toDataURLto return a blank image (the canvas is cleared after each WebGL frame by default). Smart City, Maritime, and Emergency Services all set this; any new native map module that mountsMapExportOverlaymust also set it.- jsPDF (
cartos-web/package.json) is the only new npm dependency from the original PR #51. No server round-trip for PNG/PDF/Mapbook — all assembled in the browser. GeoPDF is the exception (model-sidecar GDAL round-trip). - Capture adapted from EarthWatch3D (Cesium):
canvas.toDataURLinstead ofCesium.resolutionScale;map.fitBounds()+map.once('idle', ...)per tile instead ofcamera.flyTo+waitForTilesLoaded. - Remaining follow-ups: oblique/3D GCP warping for GeoPDF (non-nadir views); legend-image fetch; higher-than-screen-DPI capture.
9.3 3D Data — Point Clouds & Gaussian Splats
Status: Point clouds — PR #55 (initial), PR #57 (true geospatial placement, density slider, 90° pitch). Gaussian splats — PR #63 (three.js overlay canvas + SuperSplat SOGS importer). All merged to
main. Migration0019enables the feature for the dev tenant. Compare: basemapsholographic-themeandhillshade-themewere also shipped in PR #59 (theme-matched basemaps in the Compare overlay layer catalog).
Source:
cartos-web/src/features/point-cloud/PointCloudOverlay.tsx ·
cartos-web/src/lib/point-cloud.ts ·
cartos-web/src/lib/gaussian-splats.ts ·
cartos-web/src/lib/sogs.ts
The point-cloud capability feature now serves
two 3D data types from one PanelDock
panel: point clouds / 3D Tiles (via deck.gl) and Gaussian splats (via
three.js). A Cloud | Splat mode toggle at the top of
the panel selects which viewer is active. The overlay persists while the
module is mounted. It is mounted in Smart City, Maritime, Emergency
Services, and all nine Cartos Grid department modules
(grid-operations, grid-capital,
grid-fleet, grid-vegetation by default). A
Tech Admin can cross-add it to any other native map module.
Pitch the map (right-drag / ctrl-drag) up to
90° for a full ground-level "standing" view. Smart
City, Maritime, Emergency Services, and all Grid modules set
maxPitch: 90 on their MapLibre map constructor (MapLibre
5.24 allows up to 180°; the old default was 60°).
Two source modes
3D Tiles service: Paste a tileset.json
URL — any OGC 3D Tiles 1.0 point cloud (.pnts) or mesh.
Rendered via Tile3DLayer + Tiles3DLoader from
@loaders.gl/3d-tiles. On load the map flies to the
tileset's cartographicCenter. Works with Cesium ion
tilesets, Potree conversions, and hosted municipal or survey
datasets.
Local LAS / LAZ file: Drag any .las or
.laz file; parsed by LASLoader from
@loaders.gl/las. After parsing, placement is determined by
the Georeferenced | Center of map toggle:
| Mode | When to use | How it works |
|---|---|---|
| Georeferenced (default) | File has known coordinates | Geographic (lon/lat) files: x/y fall in the lon/lat
range → rendered at true position using
COORDINATE_SYSTEM.LNGLAT — no CRS input needed.
Projected files (e.g. UTM): user enters a CRS (EPSG
code, EPSG:nnnn string, or raw +proj=…);
resolveProjDef resolves it, proj4 reprojects
the cloud's x/y centroid once to WGS 84 lon/lat, and every point rides
as a metre offset from that origin. The map flies to the cloud. UAE
projected files are typically EPSG:32640 (UTM zone
40N). |
| Center of map | CRS is unknown | Cloud is anchored at the current view centre — handy for visualising structure/shape when geographic context is not needed. |
If the georeferenced path is selected but no CRS is supplied (or the supplied string is unrecognised), the overlay falls through to center-of-map placement and shows a status message prompting the user to enter an EPSG/proj4.
resolveProjDef handles:
EPSG:4326, EPSG:3857, all UTM north zones
(EPSG 32601–32660), all UTM south zones (EPSG 32701–32760, adds
+south), and any raw +proj=… string. Any other
EPSG code currently returns null (center-of-map
fallback).
Density slider (PR #57)
A density slider (2–100 %) decimates large local
clouds before they are sent to the GPU. The library keeps every Nth
point (step = round(1/fraction)), so a 10 M-point file at
10 % renders ~1 M points. This keeps WebGL throughput interactive on
typical hardware without discarding the raw parse — raising the slider
back to 100 % re-renders the full cloud.
Controls summary
| Control | Range | Effect |
|---|---|---|
| Point size | 1–8 px | Rendered radius of each point in the deck.gl
PointCloudLayer |
| Density | 2–100 % | Decimation: keep every Nth point (applied to local files; tilesets are already streamed by the server) |
| Georeferenced / Center of map | toggle | Placement mode for local LAS/LAZ |
| CRS input | EPSG code / +proj string | Required for projected local files in Georeferenced mode |
| Hide / Show | button | Toggles layer visibility without clearing the data |
| Clear | button | Removes the loaded cloud or tileset |
Technical notes
- deck.gl
MapboxOverlay(non-interleaved): the point cloud renders on its own canvas above MapLibre's. The overlay instance is created once per module mount and destroyed on unmount; closing the panel does not destroy it. proj4reprojection: only the cloud's planar centroid(cx, cy)is reprojected. Each point is then stored as a metre offset(x − cx, y − cy, z − minZ)from that origin, usingCOORDINATE_SYSTEM.METER_OFFSETS. This is correct for survey-scale clouds (< ~100 km extent); large continental clouds would need a different approach.- Elevation: for projected georeferenced files,
zis subtracted byminZso the cloud sits on the ground at its true elevation above the origin. Geographic files useCOORDINATE_SYSTEM.LNGLATand carry z as the raw altitude value. - Color: if the LAS file has
COLOR_0attribute data, it is forwarded to thePointCloudLayer; otherwise a flat steel-blue[120, 200, 255]is used. - New npm dependencies (PR #55/57):
proj4^2.20.9,@types/proj4^2.5.6,@loaders.gl/las^4.4.3,@loaders.gl/3d-tiles^4.4.3 (@loaders.gl/corewas already present for the I3S buildings path). preserveDrawingBuffer: not required for the point-cloud overlay (deck.gl renders to its own canvas), but all three host modules already set it for Map Export.
Gaussian splats (PR #63)
Gaussian splat files (.splat, .ksplat,
.ply, .spz, and SuperSplat SOGS bundles) are
rendered in MapLibre via a dedicated transparent overlay
canvas driven by MapLibre's per-frame matrices — not a MapLibre
custom layer injecting into the default framebuffer (which is not
compositable by three.js). The overlay canvas sits above the map canvas
with pointer-events: none.
Rendering architecture
(lib/gaussian-splats.ts):
- three.js +
@mkkellogg/gaussian-splats-3dDropInViewer: the splat viewer runs in the dedicated canvas;DropInViewerhandles the per-frame Gaussian sort, splatting shader, and alpha blending. - MapLibre v5 matrix split: MapLibre v5 exposes the
mercator→clip matrix as
args.defaultProjectionData.mainMatrixon each render call. Gaussian splats need a conventional perspective projection (P · View) because the shader derives per-splat screen size from the focal lengths inP. The render loop splitsmainMatrixintoprojectionMatrix(the realP) andView = inv(P) · mainMatrix, giving the depth sorter the true eye position. Falls back to bakingmainMatrixas the full projection ifPis unusable. - Anchor + scale + heading: splats carry no CRS. The
scene is anchored at a chosen
lng/lat(default: map centre at load time) with a user scale (×0.01 – ×10), heading (0–359°, yaw about the up axis), and a flip-Y toggle (capture up-axis varies). Auto-fit scales the splat to fill ~60 m on the map so it is immediately findable. zoomToSplat: auto-flies to the anchor at a zoom calculated from the splat's bounding box; pitch set to 60° so the splat is visible in 3D. "Zoom to splat" button re-triggers this.- Placement controls: anchor at map centre ("Recentre"), fine-tune lat/lon inputs.
SuperSplat SOGS importer (lib/sogs.ts, PR
#63):
@mkkellogg/gaussian-splats-3d has no built-in SOGS
decoder. lib/sogs.ts fills the gap:
- Accepts files ending in
.ssog,.sog, or.zip(the SuperSplat SOGS export formats). - Uses
fflate(unzipSync) to unpack the ZIP archive. - Detects LOD / tiled bundles
(
lod-meta.json+ per-level directories, each with ameta.json+ per-tile.splatshards): picks the finest LOD level that fits a per-frame splat budget (the viewer CPU-sorts each frame, so splat count matters) by summing each level's splat count from the meta. - Decodes the chosen tiles' interleaved position (quantised × 3
floats) + SH-0 colour (3 bytes) + opacity (1 byte) + scale (3 floats) +
quaternion rotation (4 bytes) into the antimatter15
.splatbinary layout and returns aBlobthat theDropInViewercan load natively. - For non-LOD SOGS (single-level): reads the lone
meta.jsonand splat shard directly.
Splat panel controls:
| Control | Effect |
|---|---|
| Mode toggle (Cloud / Splat) | Switches between the point-cloud and Gaussian-splat viewers |
| URL input | Load a .splat / .ksplat /
.ply / .spz from a URL |
| Local file drag/drop | Load any supported format including .ssog /
.sog / .zip (SOGS) |
| Scale (×0.01 – ×10) | World-space scale of the splat relative to its anchor |
| Heading (0–359°) | Yaw about the up axis to align the splat to north |
| Flip Y | Vertical flip (capture up-axis varies between tools) |
| Lng / Lat inputs | Fine-tune the anchor position |
| Recentre | Set anchor to the current map centre |
| Zoom to splat | Fly to the anchor + auto-zoom + 60° pitch |
| Hide / Show | Toggle visibility without clearing the scene |
| Clear | Remove the loaded splat |
preserveDrawingBuffer note: the splat
overlay uses its own WebGLRenderer with
preserveDrawingBuffer: true (keeps the frame visible
between browser composite passes); it does not share the MapLibre
canvas.
New npm dependencies (PR #63): three
(three.js), @mkkellogg/gaussian-splats-3d,
fflate (zip decompression for SOGS bundles).
9.4 Platform theme system
Status: built and merged to
main(PR #67).
Source: cartos-web/src/lib/themes.ts ·
lib/theme-context.tsx · lib/themes.css ·
shell/ThemeSwitcher.tsx · lib/map-styles.ts
(recolorStyleAccent, resolveBasemapStyle)
The Cartos engine has a platform-level theme system that re-skins the entire app — including the login screen, splash, all module UIs, and the basemap — without any page reload. Being engine-level, it works equally across every edition (Cartos Atlas, Cartos Grid, and future editions).
Resolution model
There is ONE global theme for the whole app. Any
module may set a per-module override that applies while
that module is active. Active theme = current module's override if set,
else the global theme. Both the global choice and the per-module
override map are persisted independently in
localStorage (cartos.theme.global and
cartos.theme.modules), so a deployment or user keeps its
look across reloads.
The 10 palettes
Each palette is a ThemeOption with a
ThemeKey, a label, a tagline, and a
ThemeTokens set (12 CSS custom-property values + a
scheme: "dark" | "light" flag):
| Key | Label | Tagline |
|---|---|---|
holographic |
Holographic | Cartos signature · cyan-on-night |
green |
Emerald | Night-ops green |
purple |
Violet | Deep ultraviolet |
red |
Crimson | Tactical red |
camo |
Camo | Field olive & khaki |
dark |
Carbon | Monochrome dark |
light |
Daylight | Ink on glass (light scheme) |
desert |
Desert | Sand & amber dusk |
rainbow |
Rainbow | Full spectrum |
tiedye |
Tie-Dye | Good vibrations |
applyThemeTokens(key) writes all 12 tokens onto
:root as CSS custom properties (--bg,
--bg-elev-1, --bg-elev-2,
--border, --text, --text-dim,
--accent, --accent-dim, --good,
--bad, --warn) and sets
color-scheme; the whole UI re-skins without a React
re-render because everything references these variables. The
data-cartos-theme attribute on <html>
enables CSS selectors for per-theme overrides.
bootTheme() is called before first paint (in
main.tsx before ReactDOM.render) so there is
no flash of the default palette on load.
Theme-matched basemaps
(lib/map-styles.ts)
Two new basemap keys in the map-styles catalog take the active theme accent:
| Key | Base | Effect |
|---|---|---|
holographic-theme |
Dark vector tiles | The road hierarchy, labels, and water are recoloured to the theme accent; background stays near-black. |
hillshade-theme |
Terrain + hillshade | Same recolouring applied on top of the 3D-ish terrain texture. |
resolveBasemapStyle(key, accent?) checks a
themed: true flag on the basemap definition; when true it
calls recolorStyleAccent(baseStyle, accent), which walks
the MapLibre StyleSpecification layer list and replaces
every colour expression matching the target hue with the new accent.
themeAccent(key) reads the accent synchronously from
THEMES (not from the DOM) so the recolour happens before
the style is applied and there is no flicker. These two basemaps are
listed in the Compare overlay's layer catalog (PR #59: "Holographic" and
"Hillshade-3D" entries), and appear as native basemap options in
GridModule and all other native map modules.
ThemeSwitcher UI
(shell/ThemeSwitcher.tsx)
A palette picker accessible from the app shell (gear/settings icon in
the user badge area). Shows all 10 palettes as colour swatches; clicking
one calls applyThemeTokens and
saveGlobalTheme. A per-module override dropdown lets power
users set a different look for a specific module without changing the
global default.
Floating panels now follow the theme accent (fixed 2026-07-18, shipped platform-wide)
Every dockable/floating panel — Basemap, Analysis, Compare, Map
Export, the Navigate rail, AgentChatPanel,
ArcGisLayersPanel, GeoWizard,
LayerStylePopover, SatelliteDataPanel,
SensorReadingsChart, StacCatalogPanel,
UserBadge, DockablePanel's own chrome, and
more — hardcoded the holographic cyan
(#00d9ff, rgba(0,217,255,…)) for borders,
title bars, active states, glows, and resize chips, so on any
non-holographic edition (Cartos Africa/Blueprint = Desert amber, Cartos
Tower = crimson) the map recoloured to the theme but every popup stayed
cyan. themes.ts now also writes two RGB-triple custom
properties on :root — --accent-rgb and
--bg-rgb (hexToRgbTriple) — so CSS can build
themed translucent colours with
rgba(var(--accent-rgb), 0.2); a 27-file CSS sweep (299
replacements) changed every hardcoded cyan literal to
var(--accent) / rgba(var(--accent-rgb), x),
and DockablePanel's glass background now uses
rgba(var(--bg-rgb), …) so a panel's body tints too
(warm-dark on Desert, light on Daylight). Zero-regression for
Holographic by construction — its --accent is
#00d9ff, so the swept vars resolve to the exact prior cyan.
Verified in-browser on Africa (Desert theme): Basemap panel, panel rail,
and the Navigate widget all render amber.
"Satellite · Esri" is now a global basemap option (fixed 2026-07-18, shipped platform-wide)
lib/map-styles.ts's esri-imagery basemap
(Esri World Imagery + a thin reference overlay) was labelled
"Ortho · UAE HR" with a UAE-specific tagline, even
though the underlying Esri World Imagery service is a standard global
high-res aerial layer (sub-meter over most metros worldwide, not just
Abu Dhabi/Dubai). Relabelled "Satellite · Esri" / "Esri
World Imagery · global high-res aerial" so it reads correctly in every
edition's basemap pulldown, including Cartos Africa's Foundation Layers
registry, which reuses the same public Esri tile endpoint.
9.5 Cloud-native data layer — object-storage format spikes
Status: spikes / proofs-of-concept on
main(PRs #113–#117). All formats are proven end-to-end on the existing MinIO object store — zero new services beyond what Phase 1 already runs. Demo / synthetic data. Public-read MinIO bucket in dev; not yet access-gated (no signed URLs) or hardened for production input. Production hardening is scoped as TBD in Phase 2 M03.
The platform evaluated GeoLibre 1.0 (opengeos; MIT) as a potential
dependency and adopted the underlying permissive-licensed
libraries directly (not the GeoLibre app). The result
is five object-storage-native data formats that the browser range-reads
directly from MinIO, each exercised in the Cartos Grid Grid Data
panel and its companion feature panels. All five spikes reuse
the existing MinIO object store (deploy/) and expose
toggles/launchers in the existing GridModule.tsx panel
system.
The six format experiments
| Format | PR | What it proves | Key library |
|---|---|---|---|
| PMTiles (serverless vector tiles) | #113 | A single .pmtiles archive in MinIO is range-read by the
browser via the pmtiles:// protocol — no tile server, no
per-request computation |
pmtiles (Protocol) registered via MapLibre
addProtocol |
| GeoParquet + DuckDB-WASM (in-browser analytics) | #114 | A .parquet file is fetched from MinIO and queried with
full columnar SQL inside the browser (DuckDB-WASM, self-hosted via Vite
?url — no CDN) |
@duckdb/duckdb-wasm |
| FlatGeobuf (bbox-streamed vector) | #115 | A .fgb with a packed Hilbert R-tree index is
range-read: the browser requests only the features in the
current viewport; re-reads on moveend |
flatgeobuf (fgb.deserialize with bbox
rect) |
| COG (Cloud-Optimized GeoTIFF, range-read raster) | #115 | A .tif in MinIO is tile-fetched via a
cog:// MapLibre protocol (geotiff.js under the hood) — no
raster server, no tile pre-generation |
@geomatico/maplibre-cog-protocol |
| Turf.js client-side geoprocessing | #116 | Buffer / convex hull / centroid computed entirely in the browser over a live Grid layer (no API round-trip, PostGIS untouched) | @turf/turf |
| Broad export | #117 | A live Grid layer downloaded as GeoJSON / Shapefile (.zip) / FlatGeobuf / CSV (all client-side); GeoPackage produced by a batch tool and served from MinIO | flatgeobuf + @mapbox/shp-write |
Format generators (reusable operator scripts)
One-off batch scripts; scratch outputs go in tools/_tmp/
(gitignored). tools/upload_minio.py is a shared helper used
by all generators (creds from env: MINIO_ENDPOINT /
MINIO_ACCESS_KEY / MINIO_SECRET_KEY; defaults
match deploy/docker-compose.yml).
| Script | Format produced | Key dependency |
|---|---|---|
tools/build_pmtiles.py |
PMTiles v3 archive (points only — pure-Python tiler; for line/polygon use tippecanoe in production) | mapbox-vector-tile, shapely,
pmtiles |
tools/build_geoparquet.py |
GeoParquet (column-major, compatible with DuckDB
read_parquet) |
pyarrow, geopandas |
tools/build_cog.py |
Cloud-Optimized GeoTIFF (rasterio GDAL COG driver) | rasterio |
tools/build_geopackage.py |
GeoPackage (.gpkg) — pure-Python SQLite + GeoPackageBinary; no GDAL/fiona needed | shapely, sqlite3 |
cartos-web/scripts/build-flatgeobuf.mjs |
FlatGeobuf with spatial index (Node) | flatgeobuf npm |
Env var
VITE_CARTOS_TILES_URL — MinIO base URL the browser uses
for all object-storage format range-reads (default
http://localhost:9000; set in
cartos-web/.env.local). Consumed by
cartos-web/src/lib/env.ts → CARTOS_TILES_URL.
Web surface
All five format experiments surface in the Cartos Grid modules:
- Grid Data panel — "Cloud-native (spike)" group
(
GridModule.tsx): three checkboxes toggle PMTiles (addPmtilesAssets), FlatGeobuf (loadFgbAssets+ re-read onmoveend), and COG (addCog) layers independently from the live operational layers. Each is fully idempotent (add/remove on style reload viamapEpoch). - DuckDB-WASM panel
(
features/duckdb/DuckdbSpikePanel.tsx): launched only from thegrid-capital(CIP & Major Projects) module via a dedicated dock button. Lazily boots DuckDB-WASM (vialib/duckdb.ts → getDb()), fetchesgrid-projects.parquetfrom MinIO once, registers aprojectsview (geometry column excluded soSELECT *is tidy), and exposes a SQL input. Results render in a plain table. Theview name:projects; queries likeSELECT status, COUNT(*) FROM projects GROUP BY statuswork immediately. - Geoprocessing panel
(
features/geoprocessing/GeoprocessingPanel.tsx): available on all Grid modules. Pick a live Grid layer (assets / vegetation / parcels / projects), choose an operation (bufferNmetres / convex hull / centroid), and the result renders as a derived fill/line/circle layer instantly in the browser via@turf/turf. Area of the result is displayed in the panel. - Export panel
(
features/export/ExportPanel.tsx): available on all Grid modules. Pick a live Grid layer and a format; all vector downloads (GeoJSON / Shapefile ZIP / FlatGeobuf / CSV) are generated client-side bylib/layer-export.tswith no server round-trip. A GeoPackage link points to the MinIO-hosted file produced bybuild_geopackage.py.
All three panels are portable features/ components
following the DockablePanel contract (same pattern as
OvertureOverlay, AnalysisOverlay, etc.) and
can be mounted in any module.
Production hardening — TBD (Phase 2 M03)
| Gap | Plan |
|---|---|
| Public-read MinIO bucket (no auth on object storage) | Signed URLs from MinIO / S3, or an authenticated range-request
passthrough proxy in cartos-api |
| PMTiles builder is points-only (pure-Python tiler) | Use tippecanoe for arbitrary-geometry (line/polygon)
tilesets |
| COG path uses synthetic demo raster | Feed real imagery / DEM / change-detection output rasters |
| GeoParquet fetched whole into DuckDB browser memory | Partition by spatial grid + use DuckDB's native HTTP range reads for large files |
| In-browser GeoPackage read | sql.js or @loaders.gl/gpkg for a
browser-side read path |
Source files:
cartos-web/src/lib/pmtiles-layer.ts ·
cartos-web/src/lib/duckdb.ts ·
cartos-web/src/lib/flatgeobuf-layer.ts ·
cartos-web/src/lib/cog-layer.ts ·
cartos-web/src/lib/layer-export.ts ·
cartos-web/src/features/duckdb/DuckdbSpikePanel.tsx ·
cartos-web/src/features/geoprocessing/GeoprocessingPanel.tsx
· cartos-web/src/features/export/ExportPanel.tsx ·
tools/build_pmtiles.py ·
tools/build_geoparquet.py · tools/build_cog.py
· tools/build_geopackage.py ·
tools/upload_minio.py ·
cartos-web/scripts/build-flatgeobuf.mjs.
9.6 Operational Data & Layer Catalog — real data, symbology, ask-the-map
Status: built and merged to
main— Phases 1–4 (the original epic) plus a 2026-07-18 Phase 5 (holographic symbology, feature icons, vector-over-raster ordering) all shipped, live on cartosatlas.com. The keystone for the operational-data / admin-layer / ask-the-map epic (plan:curious-waddling-widget.md). A DB-backed catalog of datasources and map layers that admins manage, real open-data feeds populate, a symbology engine styles (including in real 3D), and the agent can now query directly — across every MapLibre-native module the caller can see.
What it is: datasources (a connection
to data) + map_layers (a renderable layer bound to one)
generalize the earlier single-purpose tables
(grid_integrations, overlay_layers) into one
admin-managed catalog that spans every MapLibre-native
module — not just Cartos Grid. Additive: a disjoint
cat--prefixed source/layer namespace sits alongside the
existing overlay_layers (STAC) and grid-*
render paths; nothing pre-existing changes.
Phase 1 — Datasource + Layer Catalog foundation
- Data model (migration
0038, §7.4):datasourcescarriestype(postgis-internal/arcgis-rest/wms/wmts/xyz-raster/geojson-url/pmtiles/cog/flatgeobuf),url,configJSONB, andauthJSONB credentials that are write-only — never serialized to any client (the API exposes only ahas_authboolean);secret_refis a seam for a future secrets vault.map_layerscarriesmodule,edition,layer_key(→ thecat-<layer_key>MapLibre source id),render_type(raster/geojson/vector/pmtiles/cog/arcgis-identify),source_config(reconciler-ready tiles/url/sublayer), astyleJSONB slot (consumed by the Phase-3 symbology engine), and avisibilitytri-state (on/off/hidden). - Scope model: both tables extend the standard tenant
+
owning_teamRLS with two new axes —visible_roles TEXT[]andvisible_user_types TEXT[](NULL/empty = visible to all, same convention asowning_team IS NULL).TenantContext::user_type()(crates/cartos-core/src/tenant.rs) derives a coarseadmin/operator/vieweruser-type from roles (a seam for a future dedicated JWT claim);role_slugs()exposes the caller's realm roles for thevisible_rolescheck. New session GUCs (cartos.roles,cartos.user_type) are set byset_scope_sessionalongside the existingcartos.tenant_id/cartos.groups/cartos.bypass. - API
(
crates/cartos-api/src/catalog.rs):GET /api/catalog/layers?module=is a cross-module route (module-gated in-handler, like/api/layers) returning the caller's scope-filtered, non-hidden layers;GET /api/catalog/admin/layers+ full layer/datasource CRUD +PATCH .../visibility+POST .../test-connection(a bounded, type-aware connectivity probe — ArcGISf=json, WMS/WMTSGetCapabilities, an XYZ tile-0/0/0 fetch — timed at 8 s, recordsstatus+last_result) aretech_admin-gated. - Admin console
(
cartos-web/src/modules/admin/CatalogConfig.tsx): a new tab that lists + adds datasources (with a write-only credentials textarea and a ⚡ Test button) and map layers (module / key / label / render type / datasource / visibility / scope), with delete on both. - Rendering:
lib/catalog-layers.ts(reconcileCatalogLayers) is a reconciler modeled on the existingextdata-layerspattern — adds/removescat-*MapLibre sources/layers to match the caller's visible set, re-adding onstyle.load.features/catalog/CatalogLayersOverlay.tsxis the portable per-module panel (sameDockablePanelcontract asOvertureOverlay/AnalysisOverlay), mounted feature-gated (<module>/catalog) in Smart City, Maritime, Emergency Services, Cartos Grid, and the industry-editionIndustryModule.
Phase 2 — real data (region loader + 7 modules populated)
2A — internal-PostGIS layers + region loader (migration
0040,catalog_features:layer_idFK →map_layersON DELETE CASCADE,propsJSONB,geom GEOMETRY(4326), GiST index). Real gov/open GIS can now be hosted inside Cartos as an internal catalog layer instead of only proxying an external live service.GET /api/catalog/layers/:id/featuresserves a layer's internal features as a GeoJSONFeatureCollection— scope-checked (any caller who may see the parent layer may fetch its features); the catalog overlay streams from this endpoint for layers flaggedsource_config.internal === true(external public sources are still fetched client-side, unchanged). The newcartos-cli load-geojsonsubcommand (crates/cartos-cli/src/loader.rs) idempotently ingests a GeoJSON FeatureCollection file into apostgis-internaldatasource +map_layersrow +catalog_featuresrows — re-running refreshes the features and updates the layer in place; it forces 2D and makes geometry valid (e.g. drops a Z-elevation dimension from USGS quake data). Runs under the migrator's superuserDATABASE_URL(RLS bypassed for the load; scope columns are set from CLI flags and enforced at read time).2B — real OpenStreetMap data, 7 module families.
tools/osm_to_geojson.pyqueries the public Overpass API (bbox + key/value tag, or an inline--query-strfor multi-tag sets) and writes a GeoJSON FeatureCollection;tools/load_demo_catalog.shis the idempotent load manifest (cartos-cli load-geojsonper dataset). Migrations0039and0041enable thecatalogfeature for the modules below. Real, verifiable counts as loaded on dev:Module (edition) Region Dataset(s) Real feature count grid-command(Cartos Grid)Charlotte, NC Electric substations ( power=substation) + transmission line segments (power=line)293 substations · 1,278 transmission segments smart-city(Cartos Atlas)Abu Dhabi Hospitals + schools 95 hospitals · 201 schools maritime(Cartos Atlas)Gulf coast Marinas / lighthouses / ferry terminals 94 emergency-services(Cartos Atlas)Abu Dhabi Fire + police stations 56 air-airtraffic(Cartos Tower · Airport edition)Dallas–Fort Worth Runway + taxiway movement-area segments 265 dot-executive(Cartos Milepost · DOT edition)Indianapolis Motorway segments 1,537 aec-portfolio(Cartos Blueprint · AEC edition)Las Vegas Construction sites 363 (Cartos Tower, Milepost, and Blueprint are three industry editions — Airport, Transportation/DOT, and AEC — that shipped 2026-07-07; see §8.3 for the full write-up. Cesium-iframe modules — Situational Awareness, Maritime Domain Awareness — are excluded; the catalog overlay is MapLibre-native only.) OSM tags become feature
propertiesverbatim, which is what powers the attribute-driven symbology (Phase 3) and dashboard breakdowns (§9.7) without any bespoke schema work. Nodes/way-centers becomePoint; ways becomeLineStringor (if closed)Polygon. Acquired datasets are committed underdata/catalog/*.geojsonfor provenance and reproducible reload.2C — live dashboards from real data.
catalog::module_kpi(per-visible-layer feature counts, scope-filtered) backsGET /api/catalog/kpi?module=→{layer_count, total_features, layers:[{key,label,render_type,feature_count}]}. This single endpoint feeds three surfaces: the bottom-of-mapMapStatsBar, theCatalogKpiStriptiles in native dashboards, and theCatalogDashboardcharts — see §9.7.
Phase 3 — symbology engine (attribute-driven styling, incl. real 3D)
Attribute-driven styling for catalog layers, rendered from the
persisted map_layers.style JSONB slot
(cartos-web/src/lib/catalog-style.ts):
- 3A — engine core.
ColorSpecissimple(one color),categorical(per-attribute-value color stops + an "other" fallback), orgraduated(numeric breaks → a color ramp);SizeSpecissimpleorgraduated.colorExpr/sizeExprcompile a spec into a MapLibrematch(categorical) orstep(graduated) expression;heightExprcompiles aHeightSpec(a fixed value, or an attribute × scale) into a numeric expression for extrusion height. The reconciler (catalog-layers.ts) threads a layer's style through and applies it to the geojson fill/line/circle paint properties on add and every reconcile, so style edits take effect live; a layer with no style falls back to a simple default. - 3B — style editor + on-map legend.
features/catalog/LayerStyleEditor.tsx(in the Catalog panel, tech_admin only) offers per-geometry color mode — single / by-category / graduated — derived from a real sample of the layer's own features: the attribute list (propertyKeys), the most-common distinct values for categorical stops (distinctValues), and even numeric breaks over the attribute's range for graduated classes (numericBreaks); plus size/width sliders and a dashed-line toggle. Edits apply live on the map via the reconciler; Save persists tomap_layers.stylevia a style-onlyPATCH(the input structs were made partial/serde-default so a style-only patch — previously a 422 — now succeeds).features/catalog/CatalogLegend.tsxrenders an on-map legend for any visible, attribute-driven (classified) layer, independent of whether the style panel is open. - 3C — real 3D. Two independent 3D modes, both
style-configurable and persisted:
- Polygon extrusions (native MapLibre
fill-extrusion):
style.fill.extrude {height, base}renders acat-<key>-extrudelayer and hides the flat fill; extruded polygons remain clickable. - 3D point columns (deck.gl
ColumnLayer,lib/catalog-3d.ts):style.point.threeD {height, radius}drives a per-mapMapboxOverlaysingleton (same integration pattern aslib/point-cloud.ts/lib/buildings-3d.ts) with color + elevation accessors derived from the sameColorSpec/HeightSpecthe 2D path uses (colorAccessor/heightAccessorincatalog-style.ts); the flat circle layer is hidden while 3D is active. The overlay is torn down on module unmount. - Demo styling (
tools/set_demo_styles.sql): Grid substations render as emerald 3D columns; Las Vegas construction sites render extruded; emergency stations are categorical byamenity; runways/taxiways categorical byaeroway; motorways graduated by lane count; transmission lines dashed.
- Polygon extrusions (native MapLibre
fill-extrusion):
Phase 4 — "ask the map" (agent tools)
Two cross-module agent tools
(crates/cartos-agent/src/builtin/, module()
returns None so they're always available regardless of
which module is open) let the agent discover and query any catalog layer
the user is entitled to see — see §7 for the full
tool-registry list:
catalog_layers— lists every layer the caller can access (the same tenant/group/role/user-type scope the UI uses), with module,layer_key, label,render_type, andfeature_count; afeature_count > 0layer isqueryable. Optionalmodulefilter. This is the discovery step — "what data exists".query_catalog_layer— resolves a layer bylayer_key(re-authorized by scope before any feature read — a user can never query data they couldn't otherwise see), then supports an attribute-equality filter (where_attr/where_value), a proximity filter (near_lon/near_lat/radius_mvia PostGISST_DWithinon geography), a group-and-count (group_by), a coordinate sample (ST_PointOnSurface), or acountriesISO3 array that restricts the query to features carrying a matchingiso3property — the One Africa country-scope filter, fed straight from theafrica_scopeagent tool'sscope_iso3output (§8.4). Every response includesmatched_count. Answers questions like "how many substations are there?", "list fire stations within 5000m of 54.37,24.47", or "health facilities in West Africa" (africa_scope(region:'West')→query_catalog_layer(..., countries: scope_iso3)).
Data layer (crates/cartos-data/src/catalog.rs):
list_accessible_layers (cross-module scoped list + feature
counts), find_layer_by_key (scoped resolve),
query_layer_features (a QueryBuilder —
optional filters, incl. countries/adm1, bind
as parameters, never string-interpolated).
list_layer_features (the
GET /api/catalog/layers/:id/features handler) and
module_kpi (GET /api/catalog/kpi) also gained
optional country_iso3/countries +
adm1 query params so the Africa dashboards and the map's
own feature fetch can filter server-side, not just client-side. Verified
against real dev data (e.g. emergency stations: 39 police, 17 fire via
group_by; Kenya health facilities via
africa_scope + query_catalog_layer:
1,810).
Phase 5 — holographic point symbology, feature-type icons, vector-over-raster ordering
Shipped 2026-07-18, platform-wide (every module with catalog point/vector data, not just Africa):
- Holographic point symbology. Every catalog point
layer now renders as a 3-layer stack instead of a flat circle, with zero
per-layer config: a blurred glow halo in the feature's
own data-driven color
(
style.point.halo {enabled, scale, opacity}), a bright core orb (fill + a light#dff6ffrim-light stroke + a faint circle-blur so it reads as 3D), and an animated sonar-pulse selection ring (rAF, 1.4 s expand/fade) when a point is clicked — filtered by feature id viagenerateIdon the geojson source, one active pulse at a time. Colors stay attribute-driven (categorical/graduated), so a hospital glows red, a clinic blue, etc. Hit-testing stays on the core circle layer so the glow/pulse layers are non-interactive; deck.gl 3D-column layers hide the flat stack as before. - Feature-type icons. From zoom 9, a classified glyph
draws on top of the orb — medical cross, anchor, cart, tree, gate,
droplet, bolt, graduation cap — auto-selected from the feature's own
attributes (
amenity/harbour/natural/boundary/settlement_class/power/…) vialib/catalog-icons.ts, no per-layer config required (health → cross, market → cart, port → anchor, protected area → tree, settlement → building…). Optional override viastyle.point.icon(a fixed icon id, or categorical by attribute). Icons register lazily through MapLibre'sstyleimagemissingevent; a fallback<img>data-URI path covers a Chrome build wherecreateImageBitmap(svgBlob)throws. Migration0061gives Africa's border crossings (notypeattribute to classify from) an explicitgateicon. Icon hit-testing is included, so clicking the glyph selects/pulses the feature. - Session vectors always draw above rasters.
raiseVectorsToTop(map)moves everycat-*vector layer (fill/line/circle/extrude) above all other layers, preserving relative order, with an "already on top" short-circuit; called at the end ofreconcileCatalogLayersand from a newstyledatalistener inCatalogLayersOverlayso any raster add — foundation, satellite, GIBS, STAC, ArcGIS, catalog raster, or a basemap swap — re-raises the operational vector data back on top (the listener's ownmoveLayerno-ops its next fire, so it's self-terminating). The Compare tool'swithSessionVectors()copies the host map'scat-*vector sources+layers onto every non-"current" pane (raster/basemap/holographic/hillshade), appended last so the session's vectors draw over the compared raster in both panes, matching the main map ("current" already snapshots them).
Deferred
A private-credential datasource must never be fetched client-side —
that gap is closed by design (such sources need a server-side proxy — a
later phase); a secrets vault (secret_ref is the seam,
credentials are plaintext JSONB today); consolidating the
cat- / grid-* / overlay_layers
reconcilers into one shared implementation; wiring the
pmtiles / cog render_types for
catalog layers to the §9.5
spike libraries (today the two are separate paths).
Source files:
crates/cartos-data/migrations/0038_catalog_datasources_layers.sql
· 0039_enable_catalog_seed.sql ·
0040_catalog_features.sql ·
0041_enable_catalog_more_modules.sql ·
0061_africa_border_icon.sql ·
crates/cartos-data/src/catalog.rs ·
crates/cartos-api/src/catalog.rs ·
crates/cartos-core/src/tenant.rs (user_type(),
role_slugs()) ·
crates/cartos-cli/src/loader.rs (load-geojson)
· crates/cartos-agent/src/builtin/catalog_layers.rs ·
query_catalog_layer.rs ·
cartos-web/src/lib/catalog.ts ·
lib/catalog-layers.ts (raiseVectorsToTop) ·
lib/catalog-style.ts · lib/catalog-3d.ts ·
lib/catalog-icons.ts · lib/layer-style.ts ·
features/catalog/CatalogLayersOverlay.tsx ·
LayerStyleEditor.tsx · CatalogLegend.tsx ·
features/compare/CompareOverlay.tsx
(withSessionVectors) ·
modules/admin/CatalogConfig.tsx ·
tools/osm_to_geojson.py ·
tools/load_demo_catalog.sh ·
tools/set_demo_styles.sql ·
data/catalog/*.geojson.
9.7 Dashboards — per-edition & catalog-driven
Status: built and merged to
main. Every Grid + industry-edition department module now has its own full-screen dashboard bound to its own data, and a reusable catalog-driven dashboard component is the new standard for the industry editions. Two shell bugs the work surfaced are fixed alongside it.
Per-edition full-screen dashboards
Previously only the native Government-edition modules (Smart City,
Maritime, Emergency Services) had a full-screen
/<module>/dashboard route; Cartos Grid and the
industry editions only had an in-map dock panel. Now every department
module gets its own full-screen dashboard that shows its own
data and returns to its own map on close:
shell/DashboardPage.tsx— the shared full-screen shell: an eyebrow/title header and a "← Back to map" link parameterized by the module you came from (never a hardcoded Smart City), wrapping a scrollable body. Rendered in theAppShellcontent area; the shell already suppresses the native KPI strip on/…/dashboardroutes.modules/grid/GridDashboardPage.tsx/modules/industry/IndustryDashboardPage.tsx— thin route wrappers that reuse the existingGridDashboard/ (now)CatalogDashboardbodies, keyed to the samelocalStoragelayout key as the in-map dock panel, so a user's card layout and choices carry over between the dock panel and the full-screen view.App.tsxnow registers a/<moduleId>/dashboardroute for every Grid and industry-edition module.MapStatsBargained an optionaldashboardToprop that renders a "Full dashboard →" link in the bottom bar; with it set, the bar always renders (even before any catalog data has landed) so the dashboard is always reachable.
CatalogDashboard
— the reusable, catalog-driven standard
features/catalog/CatalogDashboard.tsx extracts the Smart
City dashboard's "caliber" — full ECharts graphs, counts, computed
alerts, a theme selector, and reorderable cards — into a component
driven entirely by a module's real catalog data, so any
edition gets the same bar without bespoke chart-wiring:
- Data-overview card — a horizontal bar of feature
counts per internal layer (from
GET /api/catalog/kpi). - One auto-classified attribute-breakdown chart per internal
layer — a best-attribute heuristic (
breakdown()) scores every property key on the layer's real features (favoring a curated list —amenity,aeroway,power,highway,kind,status, … — plus population coverage and a sensible distinct-value count) and renders it as a donut (≤ 6 categories) or a horizontal bar. - Computed Alerts & signals card — flags empty layers (loaded but zero features) and dominant categories (≥ 55 % of a layer sharing one attribute value), falling back to "All layers loaded — no anomalies detected".
- Reorderable (drag, persisted per module + user in
localStorage), a theme selector (reuses the platform's ECharts themes — §9.4), and a live footer (module id, layer count, feature count, last refresh). - Takes an optional
fallback— when a module has no catalog data yet, it renders the module's prior representative KPI-tile view instead of an empty state.IndustryDashboardPage(Tower/Milepost/Blueprint) now rendersCatalogDashboardwith the old flat-tileIndustryDashboardas that fallback. Cartos Grid keeps its own chart-richGridDashboard, already bound to real/api/grid/*KPIs; the native modules (Smart City, Emergency, Maritime) keep their own existing dashboards.
CatalogKpiStrip
— pre-stocking the native module dashboards
The industry-edition dashboards already prepended LIVE tiles built
from catalog data; the native module dashboards did not.
features/catalog/CatalogKpiStrip.tsx extracts that pattern
into a small, portable strip — one tile per internal catalog layer with
feature_count > 0 — mounted in
SmartCityDashboard,
EmergencyServicesDashboard, MaritimeDashboard,
and GridDashboard (keyed to the active department
moduleId). Renders nothing when a module has no catalog
data, so it is safe to mount everywhere. Every module's popup dashboard
now opens pre-stocked with its real loaded data (hospitals &
schools, stations, ports, substations & transmission) alongside its
existing operational KPIs.
Two shell fixes this work surfaced
- Native KPI strip no longer leaks onto Grid/industry
modules. The shell's
moduleFromPath()fell back to"smart_city"for any route that wasn't Maritime or Emergency Services — including every Grid and industry-edition module — so the shell rendered the native GovernmentKpiStrip(Abu Dhabi Smart City / Emergency data) at the bottom of those maps, and its "Dashboard" link opened/smart-city/dashboardand returned to/smart-cityon close. A newnativeModuleFromPath()returns aCartosModuleonly for the three native Government modules andnullotherwise (Grid, the industry editions, GIQ, Field, admin); the nativeKpiStripnow renders only when it's non-null. Grid & industry modules lose nothing — they already carry their own bottom bar and dock-panel dashboard (now with live catalog KPIs). - The catalog KPI bar moved off the map into the shell's
footer slot. The Grid/industry
MapStatsBar("LIVE · N layers · N features · … · Full dashboard") previously floated over the bottom of the map.AppShell's footerkpi-slotnow renders the nativeKpiStripfor native modules orMapStatsBarfor Grid/industry modules (resolved via a newcatalogModuleFromPath()againstGRID_MODULES/INDUSTRY_MODULES) — the same 64px slot, consistently, across every module.MapStatsBarwas restyled from a floating pill to a full-width footer bar to match; the floating mounts were removed fromGridModule,IndustryModule, andSmartCityModule(which keeps its native footerKpiStrip).
Source files:
cartos-web/src/shell/DashboardPage.tsx ·
DashboardPage.css · cartos-web/src/App.tsx ·
cartos-web/src/modules/grid/GridDashboardPage.tsx ·
cartos-web/src/modules/industry/IndustryDashboardPage.tsx ·
cartos-web/src/features/catalog/CatalogDashboard.tsx ·
CatalogDashboard.css ·
cartos-web/src/features/catalog/CatalogKpiStrip.tsx ·
CatalogKpiStrip.css ·
cartos-web/src/features/catalog/MapStatsBar.tsx ·
MapStatsBar.css ·
cartos-web/src/shell/AppShell.tsx
(nativeModuleFromPath, catalogModuleFromPath)
· cartos-web/src/modules/grid/GridDashboard.tsx ·
cartos-web/src/modules/smart-city/SmartCityDashboard.tsx ·
cartos-web/src/modules/emergency-services/EmergencyServicesDashboard.tsx
·
cartos-web/src/modules/maritime/MaritimeDashboard.tsx.
9.8 Map tools — Time of Day & Measure
Status: built and merged to
main2026-07-10. Both arePanelDockrail items — feature-gated capability features (time-of-day,measure) — default on Smart City, Maritime, and Emergency Services (staticdefault_featuresincrates/cartos-core/src/catalog.rs; no new DB migration was needed, the same mechanism that already coversanalysis/compare). Cross-add to any other native map module via Admin → Features.cargo check+tsc+vite buildclean at ship.
Time of Day
(features/time-of-day/TimeOfDayOverlay.tsx)
Source:
cartos-web/src/lib/time-of-day.ts (the solar engine +
guarded MapLibre "apply" calls) ·
cartos-web/src/features/time-of-day/TimeOfDayOverlay.tsx
(the panel).
A sun-position lighting panel that drives real map lighting from
lat/lon + date + apparent solar time, instead of a static default light.
lib/time-of-day.ts is a dependency-free
NOAA/SunCalc-style solar-geometry engine (the production CSP blocks
external code, so it's inlined rather than an npm package) — it computes
sun altitude/azimuth for any date/lat/solar-hour, the day's
sunrise/golden-hour/solar-noon/sunset, and an altitude curve for the
slider — then translates a sun position into three guarded MapLibre
calls:
setLight— 3D fill-extrusion / catalog-extrusion building shading: colour blends night-blue → day-warm-white with a golden tint near the horizon; intensity and the light's polar/azimuthal position track the sun.- Hillshade illumination —
hillshade-illumination-directionfollows the sun's azimuth, andhillshade-highlight-color/hillshade-shadow-colorshift with the day/twilight/night phase (only applied if the current basemap has ahillshadelayer). setSky— a day/twilight/night sky + horizon + fog gradient (MapLibre v5; guarded no-op on older styles).
Every map.* call is wrapped in try/catch so it no-ops
safely mid-style-load or on a basemap without terrain/hillshade — the
same defensive pattern as the other overlay reconcilers. The panel
re-applies on mapEpoch so a basemap swap keeps the
lighting, and restores MapLibre's neutral default light/sky on close or
unmount (clearTimeOfDay).
Panel controls:
| Control | Detail |
|---|---|
| Solar time + date | Big HH:MM solar readout; a date picker (drives the
day's declination) |
| Sun-elevation curve | An inline SVG altitude-vs-time curve for the day, with twilight/day/night colour bands and a draggable cursor dot showing the current position |
| Slider | Scrubs solar time 0–24 h across the curve |
| alt / az / compass / phase | Numeric altitude + azimuth, a 16-point compass label, and the day/twilight/night phase word |
| Presets | Sunrise · Golden (evening golden-hour start, sun at 6° descending) · Noon (always 12:00 solar) · Sunset — computed from the day's sunrise/set hour angle, disabled where the sun never reaches that altitude (polar edge case) |
| Play + speed | Animates solar time forward via requestAnimationFrame;
15 min/s, 1 h/s, 3 h/s, or 6 h/s |
| Shadows toggle | Strengthens hillshade contrast (hillshade-exaggeration)
as a lightweight directional-shadow approximation — see
the TODO below |
| Use map center | Snaps lat/lon to the current map view centre |
Shadows — known approximation, not real cast
shadows. The toggle currently just deepens hillshade contrast;
it doesn't cast building shadows onto the ground. Real cast shadows need
a deck.gl SunLight shadow map that owns the 3D geometry
(buildings), which MapLibre's native fill-extrusion layer
doesn't expose — noted as a TODO directly in
lib/time-of-day.ts and the overlay's own doc comment; not
yet scheduled.
Measure
(features/measure/MeasureOverlay.tsx)
Source: cartos-web/src/lib/measure.ts
(geodesic + 3D math) ·
cartos-web/src/features/measure/MeasureOverlay.tsx (the
panel).
The measure tool, previously an inline Smart-City-only distance/area
button, is now a portable, 3D-aware capability feature with its own
measure- MapLibre source/layers (owns its lifecycle:
re-adds after a basemap swap via mapEpoch, tears down on
close/unmount) — the same overlay contract as
TimeOfDayOverlay and CatalogLayersOverlay.
Three modes, selected in the panel:
- Distance — click to build a polyline; primary readout = total 3D (slope) length.
- Area — click to build a polygon (auto-closed on finish); primary = spherical-excess area.
- Height — click two points; primary = the signed vertical Δh between them.
Clicking the map appends a vertex (each click is deferred ~220 ms so
the terminating double-click doesn't also drop two stray vertices —
MapLibre fires click+click before
dblclick); double-click finishes the shape. Undo removes
the last vertex; Clear resets. A m | ft toggle switches
every readout between metric (m/km) and imperial (ft/mi, ft²/acre)
units, persisted per host module in localStorage.
3D from terrain: each vertex's elevation comes from
map.queryTerrainElevation (the native modules already run a
hillshade terrain/DEM style — see §9.3). Per
segment, lib/measure.ts computes:
| Field | How |
|---|---|
horiz |
Great-circle (haversine) distance between the two vertices |
dh |
Height delta, end − start (0 when either elevation is unknown) |
dist3d |
Slope distance = hypot(horiz, dh) |
brg |
Initial bearing, clockwise from true north (0–360°) |
The panel shows a primary readout (3D for Distance, area for Area,
signed Δh for Height) with horizontal + Δh secondary figures, and a live
per-segment table
(# | 3D | horiz | Δh | brg). Where the basemap has no
terrain/DEM loaded, every vertex's elevation is null, so
dh is treated as 0 and 3D == horizontal — the panel shows a
"2D — no terrain" note rather than failing.
Selection-click guard: an optional
armedRef prop is set true while the tool is
actively collecting vertices, so the host module's own
feature-selection/identify click handlers can bail
(if (armedRef.current) return;) instead of firing under a
measure click — Smart City's prior measuringRef pattern,
now shared by Maritime and Emergency Services too.
9.9 Map tools UI — Widget kit, Draw & Annotate, Navigate rail
Status: built and merged to
main2026-07-11.cargo check+tsc+vite buildclean at ship. Draw is a feature-gated capability (draw, default on Smart City/Maritime/Emergency Services); the Navigate rail, its coordinate readout, and its grid overlay are not capability-gated — they render unconditionally in every native module, every Cartos Grid department module, and every industry-edition (IndustryModule) surface, replacing MapLibre's built-in nav control.
Widget kit
(features/widgets/widget-kit.css)
A shared "mission-control" HUD visual language for the floating
map-tool panels — Measure, Time of Day, Draw, and Navigate all speak it
now (Measure and Time of Day were re-skinned into it as part of this
work; Draw and Navigate are native). A glassy translucent panel with a
cyan-accent glow, segmented controls, mono data readouts, colour
swatches, sliders, and toggle switches, all class-prefixed
wk-
(wk-hd/wk-seg/wk-btn/wk-sl/wk-sw/
wk-chip/wk-table/…) to stay out of the global
CSS cascade. Theme-aware by construction: every colour
is expressed through the app's theme CSS custom properties
(--accent, --bg-elev-1, --border,
--text, --text-dim, --accent-ink)
combined with color-mix() for tints and glows, so switching
a platform theme (§9.4)
recolours every map-tool panel live, with no per-panel styling code.
Draw
& Annotate (features/draw/DrawOverlay.tsx, capability
draw, migration 0043)
A portable drawing/annotation tool following the same overlay
contract as MeasureOverlay/TimeOfDayOverlay:
it owns a draw--prefixed MapLibre source/layer set, re-adds
them after a basemap swap (mapEpoch), persists to
localStorage, and tears down cleanly on close/unmount.
Five modes (segmented control, top of the panel):
| Mode | Behaviour |
|---|---|
| Select | Click a drawn feature to select it, then Delete removes it. (Vertex editing/dragging is out of scope by design — select + delete only.) |
| Point | Click the map to place a point immediately. |
| Line | Click to add vertices; double-click / Enter / the Finish button ends the line (≥ 2 vertices). |
| Polygon | Click to add vertices (auto-closed on finish); double-click / Enter / Finish closes it (≥ 3 vertices). |
| Rectangle | Two corner clicks define the box. |
Clicks in Line/Polygon mode are deferred ~220 ms before committing a
vertex so a terminating double-click doesn't also drop two stray
vertices (MapLibre fires click+click before
dblclick — the same pattern MeasureOverlay
uses). Esc cancels the in-progress draft; Undo removes the last vertex.
Freehand pointer-drag drawing is intentionally not
implemented — the interaction is kept click-based.
Per-feature styling: each drawn feature carries its own stroke colour (8-swatch preset row + a custom colour picker), fill colour + fill-opacity slider, stroke-width slider (1–10 px), and an optional text label rendered as a MapLibre symbol layer at the feature's centroid. Styling is picked before drawing and baked onto the feature at commit time (not editable after, short of deleting and redrawing).
Feature list + export: a live list of every drawn
feature (colour swatch, label or "unlabeled", kind, a per-row delete ✕);
Clear all (confirm-gated); Copy
GeoJSON to the clipboard and Download GeoJSON
(a FeatureCollection with
kind/label/stroke/fill/fill-opacity/
stroke-width as feature properties — portable to any other
GIS tool). Persisted per module + user in
localStorage
(cartos.draw.<module>.<username>), reloaded
whenever that identity changes.
Selection-click guard: while any draw mode other
than Select is active, an armedRef (the same contract
MeasureOverlay uses) is set true so the host
module's own feature-selection/identify click handlers bail — drawing
never triggers an identify popup underneath, and vice versa.
Navigate —
the compass rail (features/navigate/NavWidget.tsx)
A collapsible, tall-and-thin vertical rail, default top-right over
the map. Collapsed it's a slim glass "NAV" nub; expanded it's a narrow
panel that drives the live camera and shows coordinate readouts. It
replaced MapLibre's default
NavigationControl/GeolocateControl/FullscreenControl
in every native, Grid, and industry module —
lib/nav-controls.ts now only adds the two built-in MapLibre
scale bars (metric + imperial, bottom-left).
Camera controls (unchanged behaviour from the old built-ins, now in the widget-kit HUD):
- A compass rose SVG that rotates to
-bearing(compass-card behaviour), with a needle that foreshortens as pitch increases (reads as a tilted 3D camera); clicking the rose resets bearing + pitch to north/flat (map.easeTo({bearing: 0, pitch: 0})). - Zoom −/+ buttons with a live
zoomreadout. - A North button (same reset-north action) and, where
the MapLibre instance supports it (v5
setProjection), a globe ⇄ mercator projection toggle. - A pitch slider (0–85°).
- Locate me (
navigator.geolocation, flies to the fix, states locating/located/denied/unsupported) and Fullscreen.
Multi-CRS coordinate readout
(lib/coords.ts, new deps proj4 +
mgrs): a compact always-visible CRS picker (all five
reachable in the thin rail) plus live Center (on
move) and Cursor (on
mousemove) readouts, each with a one-click
copy-to-clipboard button.
| CRS | Format |
|---|---|
| WGS84 | Decimal or DMS (toggle), lat.fff, lng.fff or
D°M'S"H |
| Web Mercator (EPSG:3857) | Grouped easting/northing metres; "n/a" beyond ±85.06° latitude |
| UTM | Zone + hemisphere + grouped easting/northing metres (proj4, WGS84 ellipsoid); "n/a" beyond ±84° |
| MGRS | Via the mgrs package, 1 m default accuracy, spaced as
17S PV 12345 67890; "n/a" beyond ±84° |
| State Plane (SPCS) | Best-effort 21-zone CONUS NAD83/US-survey-feet
table (SPCS_ZONES in lib/coords.ts) — the
point's lon/lat is matched against each zone's bounding box (first match
wins); a point outside every covered zone returns a clear "n/a — outside
a supported State Plane zone" message rather than a wrong number |
Every formatter is defensive — out-of-domain points or a proj4
failure degrade to "—" or an explicit "n/a …"
string, never a thrown error. Note: the map itself
still only ever renders in MapLibre's own Mercator or Globe projection
(a MapLibre limitation) — CRS selection drives the readout, not
what's actually projected on screen.
UTM/MGRS grid overlay
(lib/grid-overlay.ts, Off · UTM · MGRS toggle
in the rail): a toggleable graticule rendered as
grid--prefixed MapLibre line/label layers, rebuilt on
moveend while active.
- UTM mode — per overlapping 6°-wide UTM zone,
constant-easting and constant-northing lines generated by converting
UTM↔︎lng/lat with
proj4, sampled per line (12 points) so they curve correctly under Web Mercator; zone-boundary meridians drawn heavier with the zone number. - MGRS mode — 6°×8° Grid-Zone-Designator (GZD) bands
labelled via
mgrs.forwardat each band centre, with the UTM 100 km grid inside them labelled by its 2-letter 100 km-square id. - Zoom-adaptive spacing: the interior grid interval is derived from the actual viewport span (not coarse zoom bands) so roughly six divisions are always visible, snapped to a "nice" 1/2/5×10ⁿ value (…, 100 m, 200 m, 500 m, 1 km, …) so labels stay round; smooth at every zoom level rather than jumping between bands.
- Vertical easting labels: easting-line labels are rotated vertical, inset from the map edges, to align with their gridlines without overlapping the northing labels.
- Style controls (shown once a mode is active): a colour picker (defaults to the live theme accent), a line-style select (Solid/Dashed/ Dotted), and a thickness slider (0.5–3 px) — all applied live.
- Capped at 400 generated line features / 260 labels per rebuild for
performance; every
map.*call is guarded so a mid-style-load rebuild never throws.
Move + resize: the expanded rail is drag-by-header
and resize (bottom / left / right / both bottom corners) just like
DockablePanel, clamped inside the map container and
reflowed on container resize (ResizeObserver); double-click
the header to reset to the default top-right anchor. Geometry,
open/collapsed state, selected CRS, WGS84 dec/DMS choice, grid mode, and
grid style all persist in localStorage
(cartos.nav.* keys).
Universal drag + resize
Every floating map surface now shares the same interaction:
header-drag to move, edge/corner-drag to resize, clamped to the map
container. DockablePanel already worked this way; this
round extended it to the rest of the map chrome:
- The Navigate rail (above).
CatalogLegend(§9.6) gained a close button and the same drag behaviour.- Every
showFloatingPopupwindow (lib/floating-popup.ts) — the draggable/resizable attribute popup used by identify clicks across the platform — including the ArcGIS identify popup (shell/ArcGisLayersPanel.tsx), which was the last surface still using a nativemaplibregl.Popup; converting it means every map popup in Cartos now shares the one floating-window style.
Ops
gotcha — new FeatureDefs need a backfill migration
load_entitlements reads the tenant_features
DB enable-state rows, not the compile-time
default_features in
crates/cartos-core/src/catalog.rs — so adding a feature to
a module's compile-time defaults after a tenant has already
been seeded does not retroactively surface it; the launcher panel stays
gated off until a backfill migration inserts the enable-state row.
Migration 0042_enable_map_tools.sql (Time of Day +
Measure), 0043_enable_draw.sql (Draw), and
0044_enable_mapillary.sql (Mapillary, below) all do this —
an idempotent
INSERT … ON CONFLICT … DO UPDATE SET enabled = true scoped
to tenants that already have the relevant native module enabled. Run
cargo run -p cartos-cli -- migrate and restart
cartos-api after adding any new default feature to an
existing module.
9.10 Mapillary — Street Imagery overlay
Status: built and merged to
main2026-07-13.tscandcargo build -p cartos-core -p cartos-agent -p cartos-apiclean at ship. Capability featuremapillary, portable like Overture — attachable to any module via the capability system, not owned by one.
What it is: crowd-sourced street-level photography from Mapillary (Meta) as a map overlay, plus a cross-module "ask the map" agent tool over the same data. It is deliberately framed as external, community-contributed reference imagery — © Mapillary contributors, CC-BY-SA — not sovereign ground truth (that's Cartos Field); the panel and the popup both carry the attribution.
Web
overlay (features/mapillary/MapillaryOverlay.tsx,
lib/mapillary.ts)
Same portable-overlay contract as OvertureOverlay /
MeasureOverlay: owns its own MapLibre source/layers,
re-adds them after a basemap swap (mapEpoch), tears down on
close/unmount, and persists its panel prefs to localStorage
per panel id.
- Coverage comes straight from Mapillary's
mly1_publicMVT vector tileset (tiles.mapillary.com/maps/vtp/mly1_public/2/{z}/{x}/{y}) into a MapLibrevectorsource — no server of ours, no proxy, no sidecar. Two layers:mapillary-sequences(drive-path lines, thesequencesource-layer) andmapillary-images(individual photo points, theimagesource-layer, rendered from zoom ≥ 14). - Click a photo point and the overlay resolves it
through the Mapillary Graph API
(
graph.mapillary.com/{id}, fields incl.thumb_1024_url,captured_at,compass_angle,is_pano) and shows the dated street-level photo with its compass heading in a floating popup (showFloatingPopup— drag/resize like every other map popup, see §9.9 Universal drag + resize), with the CC-BY-SA attribution and an "Open in Mapillary ↗" deep link. - Panel controls: coverage on/off, an opacity slider,
a "captured since" year slider (2013 → current year), and a
360°-panoramas-only toggle (filters both the sequence and image layers
live via
setFilter). All four persist per panel. - New
mapillarypanel kind (camera icon) registered inshell/PanelDock.tsxalongside Measure/Time of Day/Draw/Catalog. - No token, no map layers: without a configured
client token the panel renders a setup notice ("Set
VITE_MAPILLARY_TOKEN… in the web app's env and reload") and adds nothing to the map —hasMapillaryToken()gates every effect.
Agent
tool — mapillary_street_imagery
(crates/cartos-agent/src/builtin/mapillary_street_imagery.rs)
A cross-module tool (module() →
None, side_effects() →
ExternalCalls) registered via
register_mapillary in builtin/mod.rs,
available in every module regardless of which one is open (same shape as
the catalog
"ask the map" tools). It searches the Mapillary Graph API's
images endpoint —
graph.mapillary.com/images?bbox=… — for photos inside a
bounding box, or (if
near_lon/near_lat/radius_m are
given instead) a degree-box computed around a point. Optional
pano_only (filters client-side, a limit-aware
over-fetch keeps result counts honest), since_year
(server-side start_captured_at filter), and
limit (1–50, default 20). Each returned image carries
lon/lat, a formatted YYYY-MM capture month, compass
heading, is_pano, a thumb_url, and a
mapillary_url deep link. Answers questions like "is
there street imagery of this intersection?", "show me recent
photos near 54.37,24.47", or "any 360° panoramas of this
site?". Reads the server-side MAPILLARY_TOKEN env var
at run time (not baked in at build time); when it's
unset the tool returns
{"configured": false, "message": "…"} instead of erroring,
so the agent can explain rather than fail.
Where it's on by default
Added to default_features in
crates/cartos-core/src/catalog.rs for the native trio —
Smart City, Emergency Services, Maritime — and every
department module of the three industry editions built on the shared
IndustryModule surface — Cartos Tower
(air-*), Cartos Milepost
(dot-*), Cartos Blueprint
(aec-*). Not a Cartos Grid default;
available there (and anywhere else) as a Tech Admin cross-add via the
capability system (§7.4).
Config & deploy
| Where | Env var | Notes |
|---|---|---|
Web (cartos-web/src/lib/env.ts) |
VITE_MAPILLARY_TOKEN |
A free client access token from mapillary.com → Developers. Client-side by design — that's what Mapillary issues client tokens for. Without it: setup notice, no map layers. |
Agent (crates/cartos-agent) |
MAPILLARY_TOKEN |
Server-side token, read at run time. Without it: the tool answers
configured: false rather than erroring. |
Migration 0044_enable_mapillary.sql
backfills an enabled tenant_features row for every tenant
that already has one of the carrying modules on — the same "new default
feature needs a backfill" gotcha as migrations
0042/0043 above. Deploy needs a
cartos-api rebuild (new agent tool) and the migration
run.
Source files:
crates/cartos-core/src/catalog.rs (FEATURES
entry + default_features) ·
crates/cartos-data/migrations/0044_enable_mapillary.sql ·
cartos-web/src/features/mapillary/MapillaryOverlay.tsx ·
cartos-web/src/lib/mapillary.ts ·
cartos-web/src/lib/env.ts (MAPILLARY_TOKEN) ·
cartos-web/src/shell/PanelDock.tsx (panel kind) ·
crates/cartos-agent/src/builtin/mapillary_street_imagery.rs
· crates/cartos-agent/src/builtin/mod.rs
(register_mapillary).
9.11 Cost gate — nothing priced runs without explicit acceptance
Everything Cartos does today is free at the point of use: open imagery, open vector data, CPU inference. The capabilities that would change that — commercial satellite tasking, VHR archive pulls, a rented GPU tier — share one property: they spend the customer's money, per call, at a price known only at request time.
The rule (migration 0090): a priced operation
never runs on the strength of a user having asked for it. It
produces a quote; a human sees the price, the breakdown and
what it buys; a human accepts; only then does it run.
Four properties make that more than a confirmation dialog:
| fingerprinted | the quote binds to a hash of the exact request, so an acceptance cannot be replayed against a larger, costlier one |
| single-use | consumed atomically by exactly one run |
| expiring | provider prices move; a stale quote is not consent to today's price |
| capped anyway | an accepted quote still cannot breach the tenant's hard cap — consent and solvency are different questions |
The agent may request and display a quote. It cannot accept one. The accepting identity comes from the JWT and is recorded on the quote. An agent that can accept its own quotes is a spending machine with extra steps.
Tables: cost_quote, usage_ledger
(append-only, the billing substrate), tenant_budget
(soft/hard caps + approval threshold). All ENABLE + FORCE RLS;
tenant_budget carries the tenant-only policy since a budget
has no owning_team.
Routes: POST /api/cost/quote,
GET /api/cost/quotes,
POST /api/cost/quotes/:id/{accept,decline},
GET /api/cost/budget.
Nothing is priced yet. No provider is wired, so the gate currently has nothing to gate. That is deliberate: it exists before the first paid capability, not bolted on after one.
9.12 Risk symbology & rich feature popups
Two upgrades to how catalog layers read, both platform-wide rather than per-edition, because both are driven by what is in the data instead of by configuration.
Risk symbology
(cartos-web/src/lib/catalog-risk.ts). Any catalog layer
whose features carry risk_score (0–100) and
risk_tier is automatically rendered as severity: tier
colour, tier-graduated size, two staggered pulsing target
rings, and an optional name label. Nothing is configured per
layer — which is the point. Guardian's 50 strike targets and its 2 500
collateral-risk POIs are the same code path, and any layer that later
adopts the same two field names inherits the treatment.
style.point.risk = { enabled: false } opts out.
Tier colours are imported from guardian-risk.ts rather
than duplicated: those are pinned to migration 0071's
categorical stops, and a second copy would drift the day someone edited
one and not the other.
The orb is sized relative to the feature-type glyph it carries — 3× its width — not to a fixed pixel radius, and drawn at 50 % opacity. Both choices are forced by the glyphs: they are opaque, non-SDF bitmaps (so they cannot be tinted), which means an orb that merely matches a glyph shows no colour at all, and orbs large enough to read must be translucent or they hide each other.
Size steps by tier, not by raw score — a geometric
ladder where each tier is 70 % of the one above it,
derived from the RISK_TIERS order so it cannot drift from
the tier list. A continuous score ramp made a 79 and an 81 nearly the
same size even though they are different tiers, which is the
distinction the map is communicating; a multiplicative step makes each
tier read as a rank below the last. The glyph rides the same ladder, so
the symbol scales as a unit and the orb:glyph ratio holds; the pulse
rings follow automatically, being built from the same radius
expression.
| tier | multiplier | orb radius @z12 | glyph @z12 |
|---|---|---|---|
| Critical | 0.980 | 21.6 px | 14.4 px |
| High | 0.686 | 15.1 px | 10.1 px |
| Elevated | 0.480 | 10.6 px | 7.4 px |
| Moderate | 0.367 † | 8.1 px | 7.4 px † |
| Low | 0.367 † | 8.1 px | 7.4 px † |
† Floored. Unbounded, the ladder takes a Low glyph to ~3.5 px — a featureless dot. The glyph therefore floors, and because flooring the glyph alone would let it outgrow its shrinking orb, the orb floors too, at the multiplier that still leaves a visible ring of tier colour around a floored glyph (2.2× rather than the full 3×). ⚠ Consequence, deliberate: below the floor the symbols stop shrinking, so Moderate and Low render at the same size and are told apart by colour alone. Below that point "smaller" and "readable" cannot both hold.
Density changes what is legible, so the detector adapts to feature count:
| sparse (≤ 200) | dense (> 600) | |
|---|---|---|
| name labels | on | off |
| pulsing rings | High and above | Critical only |
| glyph appears from | z9 | z12 |
| orb below the glyph zoom | damped ×0.5 | damped ×0.22 |
Labels are off on dense layers for two reasons, not one: 2 500 overlapping names are unreadable regardless, and Guardian's POI names are Arabic while MapLibre's RTL text plugin is not loaded in this app. A user toggle (
Aain the layer row) overrides the default either way.
Rich feature popups
(cartos-web/src/lib/feature-popup.ts). The previous popup
was a 20-row key: value dump, which made a nuclear plant at
risk 94 look exactly like a bus stop. Three tiers of treatment now apply
automatically:
- Risk hero — tier-coloured band, the score as a large numeral, a gauge.
- Score drivers — the
f_*model factors as labelled bars, and criticality/sensitivity as 1–5 pips (an ordinal, not a measurement). - Grouped attributes — identity promoted to the
header, internal plumbing hidden, keys humanised, values formatted by
unit convention (
*_km,*_m2,*_pct, years without a thousands separator, booleans as ✓/✗).
A layer carrying none of those fields still gets headings, humanised keys and formatted values, so the improvement is not a Guardian special case.
⚠ A bug worth remembering. The new label layer set
no text-font, so MapLibre fell back to its default
Open Sans Regular — which OpenFreeMap's glyph server
404s. The labels would have shipped silently
blank: no error, no console warning, just nothing drawn.
grid-overlay.ts had already solved this, so
basemapTextFont moved to map-styles.ts and
both call sites share it. Its fallback is now a single
font, because MapLibre requests a font stack as one comma-joined path
and OpenFreeMap 404s any stack it cannot serve whole.
⚠ How this was verified, and why it mattered.
tsc and vite build were clean before any of
the above was found. The real layers were pulled read-only from prod and
driven through the actual reconciler in a headless MapLibre harness
(CDP-driven Chrome, real WebGL, real glyphs, real 50/2 500-feature
payloads). That harness caught the blank labels and two
expressions that nested a zoom ramp inside a
*, which MapLibre rejects outright — none of which a type
check can see. Consistent with the Stage-0 lesson in §13.8:
compiling is not evidence that a map renders.
Clustering and the heatmap (both per-layer toggles in the layer row, offered only where the data carries risk fields).
Clustering collapses a layer into severity-coloured clusters
at or below z11, sized by point count and labelled with it. Clicking a
cluster drills in —
getClusterExpansionZoom then an eased fly to the split —
and hovering one shows a pointer, since an expandable thing that looks
inert is not much use. A cluster is deliberately kept out of the
feature-popup hit set: it is an aggregate, not a feature, and the popup
would otherwise render a card describing MapLibre's own cluster
bookkeeping. ⚠ A cluster takes the colour of the worst tier
inside it, never an average — one Critical site among forty Low
ones is exactly what an operator must not lose at overview zoom, and a
mean would bury it
(clusterProperties: maxSev = max(tier rank)). ⚠
cluster cannot be changed on a live MapLibre source, so
flipping the toggle tears the source down and rebuilds it; and a
clustered source still emits its clusters as points, so every
layer in the point stack gains a
["!", ["has", "point_count"]] filter or clusters render
twice.
The heatmap answers a different question from the orbs — not "where is this asset and how bad is it" but "where does risk concentrate", which 2 500 discrete orbs cannot show at all. Weight is the risk score, so a cluster of Critical sites burns hotter than the same count of Elevated ones; raw density would let a car park of low-risk POIs outrank a lone nuclear plant. Five ramps ship (Risk tiers — the default, reusing the tier palette so heatmap and orbs agree about what red means — plus Inferno, Viridis, Turbo, Thermal). ⚠ Intensity is deliberately low: at the obvious values a 2 500-point layer saturates, every populated area pins to the top of the ramp, and the result looks like a heatmap while carrying no information. Turning the heatmap on also suppresses the glow halo and pulse rings, which otherwise muddy it.
Known limit. MapLibre's collision index contains
only symbol layers, so a label can still land on a neighbouring
feature's orb (circles are not in the index). The dark text halo keeps
it readable, and the Aa toggle turns labels off; it is most
visible in the dense Dubai cluster at continental zoom.
Live on prod (d4805bc) since 2026-08-01 — visible on Guardian under
Critical Sites and POI Collateral
Risk.
10. Data sources, integrations & keys
| Integration | Used for | Key / config |
|---|---|---|
| Postgres + PostGIS | The sovereign data fabric (tenant + team RLS) | DATABASE_URL (:5433 on this box) |
| Keycloak | SSO, JWT/JWKS, roles & groups; admin REST | realm cartos;
CARTOS_KEYCLOAK_ADMIN_CLIENT_SECRET |
| Anthropic | The AI agent | ANTHROPIC_API_KEY (agent 503s without) |
| NASA GIBS | Satellite WMTS overlays | none (public) |
| ArcGIS REST / Online / Hub | Vector/raster service overlays + Living Atlas | none for public services |
| STAC | Imagery catalog discovery | per-catalog |
| VesselFinder / aisstream | Live vessels (Global Mobility) | reused from EarthWatch3D .env |
| adsb.lol | Live aircraft (Global Mobility) | none |
| TomTom | Traffic tiles | TOMTOM_API_KEY (optional) |
| Overture Maps | POI, roads, building footprints & divisions (Smart City overlay) | none (public S3 GeoParquet via the sidecar);
OVERTURE_SIDECAR_URL (default
http://localhost:8200) |
| Esri 3D Buildings (I3S) | Photoreal 3D building mesh (Smart City, deck.gl overlay) | none (free public service) |
| Mapillary | Crowd-sourced street-level imagery overlay (mapillary
feature, §9.10) +
the mapillary_street_imagery agent tool |
VITE_MAPILLARY_TOKEN (web, client token) /
MAPILLARY_TOKEN (agent, server token) — free tokens from
mapillary.com → Developers; external community data, CC-BY-SA |
| geoBoundaries | Cartos Africa ADM0/ADM1 country + subnational boundaries (§8.4) | none (public, CC-BY 4.0) — never GADM, per license posture |
| Natural Earth | Cartos Africa rivers/lakes/settlements/built-up-extent reference vectors (§8.4) | none (public domain) |
| Esri Parcel Fabric / generic ArcGIS parcel FeatureServer | Cartos Africa's africa-parcel cadastral ingest —
LADM-aligned parcel + party store (§8.4) |
none for public services; an optional ArcGIS token for secured services is held server-side per ingest call, never returned to the client |
| Microsoft Planetary Computer (STAC) | Cartos Sentry's epoch resolution for real change detection —
sentinel-2-l2a (optical), sentinel-1-rtc
(SAR), landsat-c2-l2 (thermal) (§8.6);
also Cartos Harvest's whole crop pipeline —
sentinel-2-l2a STAC search + keyless titiler band-math
(NDVI/NDRE/NDWI/NDMI) fetched client-side by the panels
(§8.7, live
at harvest.cartosatlas.com — requires the
img-src/connect-src CSP allowance for
Planetary Computer) |
none (public STAC + titiler) |
| FAO GIEWS crop calendars | Cartos Harvest's crop_calendars seed data (Kenya +
Nigeria) (§8.7) |
none (public reference; hand-transcribed into migration
0083) |
| NASA FIRMS | Cartos Sentry thermal/active-fire hotspots (VIIRS_SNPP_NRT) (§8.6) | CARTOS_FIRMS_MAP_KEY (free; degrades gracefully
unset) |
| AWS Global Seasonal Sentinel-1 Coherence (Kellndorfer et al.) | Cartos Sentry's InSAR expected-coherence context layer, GDAL
/vsicurl crop (§8.6) |
none (public COGs) |
| Umbra open-data archive | Cartos Sentry commercial-tasking live SAR-collect search (§8.6) | none (free static STAC) |
| NASA GIBS (historical) + VesselFinder/aisstream + adsb.lol (live traffic) | Cartos Guardian historical-imagery time-slider + live AIS/ADS-B traffic overlays (§8.5) | reuses the existing GIBS/mobility integrations above, no new keys |
cartos-api loads a gitignored workspace-root
.env via dotenvy; the Anthropic / aisstream /
TomTom keys are reused from EarthWatch3D's .env.
11. HTTP API reference
All /api/* routes require a Bearer JWT except
/healthz.
| Method | Route | Purpose |
|---|---|---|
| GET | /healthz |
Liveness (unauthenticated) |
| GET | /readyz |
Readiness probe (unauthenticated) — 200 when the DB is reachable,
503 otherwise. Used by the compose healthcheck and
web depends_on: cartos-api condition: service_healthy. |
| GET | /api/me |
Returns the caller's TenantContext |
| GET | /api/capabilities |
Catalog (catalog + features) + this user's
effective enabled modules / features. Drives
nav gating, feature panels, and admin config UIs. |
| POST | /api/agent/chat |
Agent conversation (503 without Anthropic key) |
| POST | /api/agent/export/docx |
Export an agent conversation as DOCX — POSTs
{title, markdown} to the model-sidecar's markdown→docx
renderer and streams the result back. |
| POST | /api/export/geopdf |
Produce a georeferenced OGC GeoPDF — accepts a PNG
capture + Mercator bbox, proxies to the model-sidecar's
POST /render/geopdf (GDAL gdal_translate
server-side), returns application/pdf. 502 when the
model-sidecar is unreachable. |
| GET | /api/kpi/smart-city/summary ·
/api/kpi/maritime/summary ·
/api/kpi/emergency-services/summary |
Dashboard KPI summaries |
| GET | /api/smart-city/{incidents,assets,sensors,jurisdictions} |
Smart City data |
| GET | /api/sensors/:id/readings |
Sensor time series |
| GET | /api/maritime/{vessels,ports,port-incidents} |
Maritime data |
| GET | /api/emergency/{units,incidents,incidents/history,traffic,cameras} |
Emergency Services data |
| GET | /api/overture/:theme |
Overture Maps GeoJSON for viewport bbox (themes: places
/ buildings / transportation /
divisions); proxies the Python sidecar. 502 when sidecar is
unreachable. |
| GET | /api/mobility/{aircraft,vessels,status} ·
/api/mobility/traffic/:z/:x/:y |
Global Mobility live feeds + traffic tile proxy |
| GET | /api/mobility/history/vessels |
Historic AIS — distinct vessels in last N hours
(?hours=48); max 500 |
| GET | /api/mobility/history/track |
Historic AIS — one vessel's ordered track
(?mmsi=<int>&hours=48); max 5 000 fixes |
| GET | /api/mobility/history/port-calls |
Historic AIS — per-port vessel count + fix count within
port_aois (?hours=48) |
| GET | /api/mobility/history/density |
Historic AIS — vessel density cells
(?hours=48&cell=0.05); max 5 000 cells |
| GET | /api/catalog/layers?module= |
Layer Catalog — caller-visible, non-hidden layers for a module (scope-filtered by tenant/group/role/user-type); module-gated in-handler |
| GET | /api/catalog/kpi?module=&countries=&adm1= |
Layer Catalog — live per-layer feature counts for a module:
{layer_count, total_features, layers}.
countries (comma-separated ISO3) / adm1
(comma-separated ADM1 ids) narrow the counts — the Cartos Africa
country-scope filter (§8.4); no-op on
non-Africa layers. |
| GET | /api/catalog/layers/:id/features?country_iso3=&adm1= |
Layer Catalog — a layer's internal PostGIS features as a GeoJSON FeatureCollection (scope-checked against the parent layer); same Cartos Africa country/ADM1 filter as the KPI route |
| GET | /api/catalog/admin/layers |
Layer Catalog — all layers for the tenant —
tech_admin |
| POST | /api/catalog/layers |
Create a map layer — tech_admin |
| PATCH/DELETE | /api/catalog/layers/:id |
Update / delete a map layer — tech_admin |
| PATCH | /api/catalog/layers/:id/visibility |
Set visibility on | off |
hidden — tech_admin |
| GET/POST | /api/catalog/datasources |
List (no secrets) / create a datasource —
tech_admin |
| PATCH/DELETE | /api/catalog/datasources/:id |
Update / delete a datasource — tech_admin |
| POST | /api/catalog/datasources/:id/test-connection |
Bounded (8 s), type-aware connectivity probe; records status +
result — tech_admin |
| GET | /api/grid/assets |
Grid network assets (point + line geometry, health-coded) |
| GET | /api/grid/outages |
Grid outages (point, status/cause/customers_affected) |
| GET | /api/grid/work-orders |
Grid work orders (point, kind/status/priority/crew) |
| GET | /api/grid/vegetation |
Grid vegetation segments (LineString, risk/span_miles) |
| GET | /api/grid/parcels |
Grid parcels / ROW (Polygon, kind/encroachment/area_acres) |
| GET | /api/grid/projects |
CIP capital projects (point, phase/status/budget_musd/pct_complete) |
| GET | /api/grid/kpi |
Aggregate Grid KPI summary (assets, outages, work orders, veg, parcels, CIP projects) |
| GET | /api/grid/integrations |
List SoR connector registry rows (kind, name, target, status, last_sync, last_result) |
| POST | /api/grid/integrations |
Register a connector — tech_admin; body
{kind, name, target, status?} |
| PATCH | /api/grid/integrations/:kind |
Update connector name/target/status/config —
tech_admin; partial patch (null fields keep existing
value) |
| DELETE | /api/grid/integrations/:kind |
Remove a connector registration — tech_admin |
| POST | /api/grid/integrations/:kind/sync |
Run a mock connector sync; returns sync-result JSON.
file_csv returns 400 (use import endpoint). |
| POST | /api/grid/integrations/file_csv/import |
Upload work-order CSV; body {csv: "..."}; returns
{imported, created, updated, skipped} |
| GET | /api/industry/projects?edition= |
Cartos Milepost/Blueprint projects the caller may see (default-deny; admins see all) — see §8.3 |
| PATCH | /api/industry/projects/:id |
Update a project's status/health/location — requires
edit or admin access on it |
| GET | /api/industry/access?edition= |
List access grants for an edition — user_admin |
| POST | /api/industry/access |
Grant a project or the whole portfolio to a group or user —
user_admin, or a project-admin delegating within their
scope |
| DELETE | /api/industry/access/:id |
Revoke a grant — user_admin, or a project-admin
delegating within their scope |
| — | /api/sentry/* (23 routes — watchlist, incidents +
transitions/confirm/notes, imagery/damage/coherence/change-raster,
reports, tasking, alerts, uploads) |
Cartos Sentry — see the full route table in §8.6 |
| — | /api/africa/parcels/* (5 routes — ingest
(tech_admin), list, summary, :id
(GET/PATCH), export) |
Cartos Africa cadastral parcel fabric — LADM-aligned (ISO 19152)
parcel + party store, two ingest adapters (Esri Parcel Fabric
information model + generic ArcGIS cadastre FeatureServer) landing in
one schema; export returns LADM-shaped GeoJSON — see the parcel-fabric
subsection in §8.4 (merged to
main, deployed + verified on prod) |
| — | /api/harvest/* (17 routes — crop-calendar,
crop-index, crop-type,
crop-health, crop-yield,
crop-water, field-candidates,
parcels + /summary + /:id
(GET/PATCH) + /:id/owner, portfolio-risk,
products + /:id, farmers
(GET/POST), registry/coverage) |
Cartos Harvest STAC crop pipeline + persisted products (M03) + farmer registry — see §8.7 (live at harvest.cartosatlas.com) |
| GET/POST/DELETE | /api/layers · /api/layers/:id |
Persisted overlay layers |
| GET | /api/field/apps |
List field apps with module assignments (tenant + group filtered) |
| POST | /api/field/apps |
Create field app — tech_admin |
| GET | /api/field/apps/:slug |
Fetch one field app with module assignments (mobile pulls definition here) |
| PATCH | /api/field/apps/:slug |
Update meta or definition; bumps version when definition changes —
tech_admin |
| DELETE | /api/field/apps/:slug |
Delete app + captured features (cascade) —
tech_admin |
| PUT | /api/field/apps/:slug/modules |
Set module assignments (replaces full set) —
tech_admin; validates against module catalog |
| POST | /api/field/apps/:slug/sync |
Store-and-forward capture batch; idempotent; returns
{received, inserted, duplicates} |
| GET | /api/field/apps/:slug/features |
List captured features; optional ?limit=N (max 5
000) |
| GET | /api/field/captures/:module_id |
Module's assigned apps + features; gated in-handler by host module (cap_guard exempted) |
| POST | /api/field/media |
Upload photo/signature (raw body, ≤ 25 MB; ?ext= or
Content-Type for extension) |
| GET | /api/field/media/*key |
Download media; tenant-scoped; path-traversal guarded |
| GET | /api/derived/datasets |
List derived datasets (tenant+group scoped);
?aoi=w,s,e,n for AOI-bounded provenance query;
?limit=N (max 5 000) |
| POST | /api/derived/datasets |
Mint a dataset — tech_admin |
| GET | /api/derived/datasets/:id |
One dataset + its inputs lineage edge IDs |
| GET | /api/reports/sessions |
List analysis sessions (tenant+group scoped) — id/title/status/created_at |
| POST | /api/reports/sessions |
Persist a SessionResult — tech_admin |
| GET | /api/reports/sessions/:id |
Full SessionResult body |
| GET | /api/reports/sessions/:id/report.html |
Rendered HTML report; ?narrate=true adds a grounded LLM
narrative |
| GET | /api/reports/sessions/:id/report.docx |
DOCX export (proxied to the Python sidecar
POST /render/docx) |
| GET | /api/jobs |
List model jobs (tenant+group scoped) |
| POST | /api/jobs |
Submit a model job — tech_admin; returns
{job_id, status, kind}. routing.tier =
"local" (CPU/GPU sidecar at MODEL_SIDECAR_URL)
or "full" (GPU sidecar at
MODEL_GPU_SIDECAR_URL). |
| GET | /api/jobs/:id |
Job status + outputs |
| GET | /api/jobs/backends |
Reports both compute tiers — local + gpu — each with device, model,
health from /healthz. Used by the Analysis panel to show
availability. |
| POST | /api/jobs/:id/cancel |
Cancel a queued/running job — tech_admin |
| PATCH | /api/admin/scope |
Set a row's owning_team (admin) |
| PATCH | /api/admin/modules/:module_id
{ enabled } |
Provider Admin — toggle a module for this tenant. 403 without
provider_admin or admin. |
| PATCH | /api/admin/features/:module_id/:feature_id
{ enabled } |
Tech Admin — toggle a feature within a module (default or advanced
cross-add). 403 without tech_admin or
admin. |
| GET | /api/admin/users/:user_id/modules |
User Admin — list tenant modules + whether this user may access each. |
| PATCH | /api/admin/users/:user_id/modules/:module_id
{ enabled } |
User Admin — grant or revoke a module for a specific user (deny-override). |
| GET | /api/admin/users · /api/admin/groups |
Keycloak admin (503 without secret) |
| PUT/DELETE | /api/admin/users/:user_id/groups/:group_id |
Add / remove user ↔︎ group |
| POST | /api/cost/quote |
Price an operation and DO NOT run it — returns a quote with price, breakdown and expiry (§9.11) |
| GET | /api/cost/quotes |
Recent quotes for this tenant |
| POST | /api/cost/quotes/:id/accept |
A human authorises the price. Identity comes from the JWT and is recorded; the agent cannot call this on a user's behalf |
| POST | /api/cost/quotes/:id/decline |
Decline an open quote |
| GET | /api/cost/budget?period=day|month|total |
Spend, soft/hard caps, remaining |
12. The marketing website & GTM artifacts
website/ —
live at https://cartosatlas.com
- Cloudflare Pages, git-connected to
main→ pushingmainpublishes. No CI/wrangler in the repo (the connection lives in the Cloudflare dashboard). - Pages:
index,vision(a standalone African-continental base-map narrative / UAE Base Map),interface(product-screenshot tour),platform(Phase I),editions(the engine→editions→instances story across all eight live editions — Atlas, Grid, Tower, Milepost, Blueprint, Cartos Africa (live at africa.cartosatlas.com, see §8.4), Cartos Guardian (live at guardian.cartosatlas.com, see §8.5), and Cartos Sentry (live at sentry.cartosatlas.com, see §8.6) — Guardian and Sentry are oneditions.htmlas text-only cards pending live product screenshots, same convention as every other edition once one is captured — plus a ninth, Cartos Harvest, now shown as a live card (merged tomain, tagcartos-harvest-v1, live subdomain + product screenshots now published yet) (see §8.7)),analyst-layer(Phase II),field(Phase III),why,guide(this doc, public-safe), andcommercial-model(pricing). Note the naming overlap:vision.html's aspirational "built for African nations" narrative predates and is distinct from the concrete, code-built Cartos Africa edition — the vision page now links through to it. - Plain HTML/CSS/JS, inline-SVG charts (no chart
library), strict CSP in
_headers(script-src 'self'— scripts must be external files, e.g.gate.js). images/*.webpare optimized fromcartos_screenshots/viawebsite/tools/optimize-screenshots.py(re-run after adding captures).- The footer CARTOS wordmark on every page links to
commercial-model.html.
cartos-pricing/
— commercial model & business case
build.pyis the single source of truth (pricing pillars, tier ACVs, unit economics, revenue/breakeven, customer ROI, competitive TCO, departments, personas) and emits two docs from the same data:- Internal
cartos-commercial-model.html— full financials. - Public-safe
website/commercial-model.html— our margins / cost-to-deliver / revenue / breakeven stripped; published on the site.
- Internal
make_docx.pybuilds the.docx; a headless-Chrome print builds the.pdf.- All figures are illustrative planning anchors, not quotes.
- The public page sits behind a temporary soft password
gate (
the demo password):website/gate.js, only the SHA-256 is embedded,noindex. It's demo-grade, not real security (content still ships in source) — replace with Cloudflare Access for real auth.GATE_PASSWORDlives inbuild.py.
To regenerate: python cartos-pricing/build.py, then (for
docx/pdf) the rasterize + make_docx.py steps in
cartos-pricing/README.md.
13. Phase 2 — Analyst Intelligence Layer (Wave 2A built)
Status: Wave 2A fully built (PRs #28–#33, #35–#36, #37, #40); Spike A delivered real segmentation and real two-epoch change detection (PRs #45, #49); compute-tier selection + GPU sidecar deployable artifact (PRs #46, #47); Swipe Compare capability feature (PR #48); MCP server (PR #43); Gaussian splats in MapLibre + SuperSplat SOGS importer (PR #63, Wave 2C M04 client-serving). The full loop — queue → real model sidecar → derived-dataset → report → MCP exposure — is in place and CPU-verified end-to-end. Remaining: multispectral geospatial FM (Prithvi/TerraMind on a GPU; the upgrade path is documented); M04 server-side splat generation (needs GPU — the client viewer is complete); M05 3D building reconstruction.
Phase 2 turns the sovereign platform into one that reasons about change, derives analysis-ready data, and writes auditable reports. Five modules in three waves, with new Gate-0 infra built in Wave 2A:
| ID | Module | Wave | Status |
|---|---|---|---|
| M03 | GeoAI agents & derived datasets (lineage-tracked — the moat) | 2A | Built |
| M02 | Comprehensive reporting (HTML/DOCX/PDF from a session) | 2A | Built |
| M01 | Temporal change detection (two epochs → change layer + confidence) | 2B | Built — real model (SegFormer diff over real Sentinel-2 tiled imagery) |
| M04 | Gaussian-splat serving (photoreal 3D in the map) | 2C | Client viewer built (PR #63 — three.js overlay canvas + SuperSplat SOGS importer in MapLibre native modules; see §9.3). Server-side splat generation (from imagery/LiDAR) requires a GPU and is gated. |
| M05 | AOI → 3D LOD 2.5/3 buildings (reconstruct from imagery) | 2C | Planned |
The three Gate-0 build contracts in
cartos-phase2/docs/phase-2/specs/
(derived-dataset-schema.md,
session-result-object.md, gpu-job-contract.md)
define the spec that Wave 2A implements.
On mock vs real (Spike A / PR #45, #49): both model handlers now run real models on real imagery when the ML stack is present.
infer.segmentruns SegFormer-b0 (fine-tuned on ADE20K) over real Esri World Imagery fetched for the AOI — 150 ADE classes bucketed to built / vegetation / water / bare.change.detectfetches real Sentinel-2 tiles (or any{z}/{x}/{y}tile source) for two dated epochs, segments each with SegFormer, diffs per cell, and returns typed changes (new_structure/removed/modified, from→to class, confidence). Deterministic mocks remain as guarded fallbacks (no ML stack,SEGMENT_REAL=0, or no epochs provided). Accuracy caveat: ADE20K is a ground-scene model; accuracy on overhead imagery is rough. The upgrade path to a multispectral geospatial foundation model (Prithvi-EO / TerraMind) on a GPU is documented inmodel-sidecar/README.gpu.md. The async queue, SKIP-LOCKED worker, lineage graph, reporting pipeline, and web UI are all production-shaped and unchanged by the model swap.
⚠ cartos-phase2/docs/strategy/ip-timing.md is
time-sensitive (public disclosure affects patentability).
13.1 M03 Derived-dataset graph (the moat)
Migration: 0013_derived_dataset.sql
The derived_dataset table is the lineage-tracked store
every GeoAI tool writes into. It is the platform's durable moat: every
analysis result is versioned, provenanced, and queryable over an
AOI.
Schema — derived_dataset:
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | gen_random_uuid() |
tenant_id |
TEXT | Tenant scope |
kind |
TEXT | segmentation, change, splat,
etc. |
name |
TEXT | Human label |
aoi |
geometry(Polygon,4326) |
AOI polygon; GiST-indexed for spatial queries |
version |
INTEGER | Auto-incremented; supersedes self-FK enables versioned
chains |
supersedes |
UUID FK | Previous version of this dataset (nullable) |
tool_ref |
TEXT | Job kind / tool name (e.g. infer.segment) |
tool_version |
TEXT | Model or tool version string |
params |
JSONB | Run parameters |
session_id |
UUID FK | Owning analysis session (nullable) |
storage_uri |
TEXT | MinIO/S3 key for large outputs (nullable; small mock outputs inline
in metadata) |
licensing |
JSONB | IP/licensing annotations |
status |
TEXT | pending, ready,
superseded |
metadata |
JSONB | Arbitrary output metadata + inline result GeoJSON (mock) |
owning_team |
TEXT | Group-access scope (same pattern as all tenant data) |
created_by |
JSONB | {"actor":"job","id":"<job_id>"} or
{"actor":"user","id":"..."} |
created_at |
TIMESTAMPTZ |
Lineage edges — dataset_input:
dataset_input (output_id → derived_dataset, input_id → derived_dataset)
— a directed edge declaring that output_id was produced
from input_id. The worker inserts these automatically from
any UUID-valued entry in the job's inputs object.
Data layer
(crates/cartos-data/src/derived.rs):
create_dataset(pool, tenant_id, created_by, new)— inserts with auto-version increment (1 + max existing version for sametool_ref/kind/aoicombo); insertsdataset_inputedges.get_dataset(pool, tenant_id, id, groups)— single dataset, group-filtered.inputs_of(pool, id)— list of input dataset IDs (the lineage edge sources).datasets_for_aoi(pool, tenant_id, bbox, groups, limit)— provenance query over a bounding box; returns all datasets whoseaoiintersects the box.list_datasets(pool, tenant_id, groups, limit)— flat list, group-filtered.
API surface
(crates/cartos-api/src/derived.rs):
| Method | Route | Auth | Purpose |
|---|---|---|---|
| GET | /api/derived/datasets |
any tenant user | List datasets; ?aoi=w,s,e,n for provenance over AOI;
?limit=N |
| GET | /api/derived/datasets/:id |
any tenant user | One dataset + its inputs lineage IDs |
| POST | /api/derived/datasets |
tech_admin | Mint a dataset manually (GeoAI tools use the data layer directly) |
13.2 M02 Comprehensive reporting
Migration:
0014_analysis_session.sql
The analysis_session table stores a full
SessionResult as JSONB (group-scoped). Reports are rendered
deterministically from the structured fields — nothing is invented —
with an optional grounded LLM narrative layered on top.
SessionResult object
(crates/cartos-core/src/session_result.rs):
The canonical schema (schema_version = 1) records an ordered Operation trace → methodology, plus KPIs, findings, assumptions, and figures:
| Field | Type | Notes |
|---|---|---|
schema_version |
u32 | 1 |
session_id |
String | Unique session identifier |
tenant_id |
String | |
title |
String | |
aoi |
GeoJSON Polygon (opt) | |
inputs |
Vec<String> | Consumed dataset IDs |
produced |
Vec<String> | Output dataset IDs |
operations |
Vec<Operation> | Ordered tool trace (seq, tool, tool_version, params, input_refs, output_refs, summary, duration_ms) |
kpis |
Vec<Kpi> | label, value, unit (opt), trend (opt) |
findings |
Vec<Finding> | label, confidence (opt), detail (opt) |
assumptions |
Vec<String> | Caveats and data-quality assumptions |
figures |
Vec<Figure> | id, kind (map/chart/table), caption |
started_at / finished_at |
DateTime<Utc> | |
status |
SessionStatus | ok, partial, failed |
Report renderer
(crates/cartos-api/src/report.rs):
render_report_html(sr, narrative) produces a
self-contained HTML report (inline CSS, Cartos design tokens — same
#0A1A2F navy / #00A3E0 accent palette as the
guide and the app). Sections: cover (title, status badge, session ID,
tenant, timestamps) · optional grounded narrative · KPI strip · Findings
table · Methodology & Process (from the operations trace — the audit
trail) · Assumptions · Figures (captions; image embedding arrives with
the viewport-capture slice) · Data Lineage (inputs / produced
datasets).
Grounded LLM narrative (optional):
?narrate=true on the HTML report route passes the
SessionResult's structured fields (KPIs, findings,
assumptions, operations, figure captions — nothing else) to a
strictly-grounded Anthropic prompt. The model is told: use ONLY those
numbers and claims, no markdown, no headings, 2–4 paragraphs. The
deterministic skeleton renders regardless; the narrative fails
gracefully to None if the API key is unset or the call
errors.
DOCX export:
GET /api/reports/sessions/:id/report.docx proxies the
session body to the sidecar's POST /render/docx
(model-sidecar/render.py, python-docx). Mirrors the HTML
skeleton: cover, KPIs table, findings table, methodology table,
assumptions bullets, figures, lineage. The proxy streams the bytes back
as a download attachment.
PDF export: headless-Chrome print of the HTML report. Two paths:
- In-browser: the rendered HTML (opened via "Open" in the Analysis overlay) can be printed to PDF via the browser's native print.
- Operator tool:
tools/export_report.py --session <uuid>fetches the report, writes.html+.pdf(Chrome) +.docx(via API) to a local directory. Usage:python tools/export_report.py --session <uuid> [--api http://localhost:4000] [--narrate].
API surface
(crates/cartos-api/src/report.rs):
| Method | Route | Auth | Purpose |
|---|---|---|---|
| POST | /api/reports/sessions |
tech_admin | Persist a SessionResult |
| GET | /api/reports/sessions |
any tenant user | List sessions (id/title/status/created_at) |
| GET | /api/reports/sessions/:id |
any tenant user | Full SessionResult body |
| GET | /api/reports/sessions/:id/report.html |
any tenant user | Styled HTML report; ?narrate=true adds LLM
narrative |
| GET | /api/reports/sessions/:id/report.docx |
any tenant user | DOCX download (proxied to sidecar) |
13.3 Async model-job tier & model sidecar
Migration: 0015_model_job.sql
The model_job table
Postgres SKIP-LOCKED queue with a full job lifecycle:
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | |
tenant_id |
TEXT | |
idempotency_key |
TEXT | UNIQUE(tenant_id, idempotency_key) partial — prevents
duplicate submits |
kind |
TEXT | Job kind string matching a sidecar handler (e.g.
infer.segment, change.detect) |
status |
TEXT | queued → running → succeeded
| failed | cancelled |
inputs |
JSONB | Input refs (dataset IDs or arbitrary params) |
params |
JSONB | Run parameters passed to the sidecar |
aoi |
JSONB | GeoJSON Polygon AOI |
routing |
JSONB | {sovereign: bool, priority: "normal"|"high"} |
session_id |
UUID FK | Owning session (nullable) |
owning_team |
TEXT | Group-access scope |
outputs |
JSONB | Produced derived dataset references (written on completion) |
metrics |
JSONB | {duration_ms, device, est_cost_usd} — reported by
sidecar |
error |
JSONB | Error detail on failure |
progress |
FLOAT | 0–1 (future streaming progress) |
claimed_by |
TEXT | Worker ID that claimed the job |
created_by |
JSONB | |
created_at / started_at /
finished_at |
TIMESTAMPTZ |
Data layer (crates/cartos-data/src/jobs.rs):
submit_job (idempotent), get_job,
list_jobs (group-scoped), claim_next_job (FOR
UPDATE SKIP LOCKED — safe for multiple workers),
complete_job, fail_job,
cancel_job.
The background
worker (crates/cartos-api/src/jobs.rs)
run_worker(state) is spawned in main (as a
tokio::spawn) when the DB is reachable. It polls the queue
with a 1.5 s idle sleep, claims one job at a time (SKIP-LOCKED makes it
safe to run multiple workers), calls POST /infer on the
sidecar, and for each output in the response:
- Parses any UUID-valued entry in
job.inputsas a lineage edge. - Mints a
derived_datasetrow viaderived::create_dataset. - Calls
jobs::complete_jobwriting the produced dataset IDs intooutputs.
On sidecar error the job is marked failed with a
retryable error JSON. The worker is intentionally simple — it does not
retry; a failed job can be resubmitted.
The model sidecar
(model-sidecar/main.py)
Stateless by design. It never touches the database.
The only contract with cartos-api is POST /infer (input +
AOI JSON → outputs JSON) and GET /healthz. This means the
sidecar relocates from CPU to a local GPU to a customer's GPU cluster
purely by changing where it is deployed — no code change, no DB
credential.
GET /healthz — the Gate-0 health
check:
{
"ok": true,
"gpu": false,
"device": "cpu",
"models_loaded": ["infer.segment@segformer-b0-ade", "[email protected]"],
"segment_real": true
}segment_real is true when the ML stack
(torch + transformers) is installed and SEGMENT_REAL is not
0. Device is auto-detected: tries
torch.cuda.is_available(), falls back to
nvidia-smi, then "cpu". Override with
MODEL_DEVICE.
POST /infer — job-kind dispatch:
{
"kind": "infer.segment",
"tenant_id": "...",
"session_id": "...",
"inputs": {},
"params": { "grid": 8 },
"aoi": { "type": "Polygon", "coordinates": [...] }
}Response carries outputs (array of
{kind, name, result, metadata, tool_version}),
metrics ({duration_ms, device, est_cost_usd}),
and summary.
POST /render/docx — M02 DOCX export
(model-sidecar/render.py): receives a
SessionResult JSON, returns .docx bytes via
python-docx. Cartos-branded (cover · KPIs · findings · methodology ·
assumptions · figures · lineage). Proxied at
GET /api/reports/sessions/:id/report.docx.
Compute-tier selection (PR #46): the Analysis panel offers two tiers:
| Tier | UI label | sidecar | env var |
|---|---|---|---|
local |
Local (CPU/GPU) | MODEL_SIDECAR_URL (default :8300) |
always configured |
full |
Full GPU | MODEL_GPU_SIDECAR_URL |
optional; unset → tier rejected with clear
tier_unavailable error, no silent fallback |
Jobs carry routing.tier; the worker routes accordingly.
GET /api/jobs/backends reports both tiers' availability,
device, and model version (probes /healthz on each
configured sidecar).
GPU sidecar deployable artifact (PR #47):
model-sidecar/Dockerfile.gpu — CUDA 12.4 + cuDNN, cu124
torch wheels, bakes SegFormer-b5 (640) at build time so
first inference is offline and fast. SEGMENT_MODEL_ID env
selects the model (b0 for CPU, b5 for GPU; larger models and the
Prithvi/TerraMind multispectral path are documented in
README.gpu.md). Deploy to any GPU host (RunPod, Lambda, AWS
g5, Azure NC, on-prem/sovereign GPU box) with the NVIDIA Container
Toolkit; set MODEL_GPU_SIDECAR_URL and restart
cartos-api.
Architecture decision: cartos-api owns
the queue AND the worker; the sidecar is stateless (never touches the
DB). This realizes the "hybrid: local CPU/GPU now, wired for a
sovereign/customer GPU cluster later" plan from the Gate-0 contract.
Run (dev):
cd model-sidecar && python -m uvicorn main:app --host 0.0.0.0 --port 8300
Dependencies:
pip install fastapi uvicorn python-docx pillow (+
torch transformers for the real segmentation model;
optional — mock fallback works without them). System package
gdal-bin is required for the
POST /render/geopdf endpoint (both Dockerfile
and Dockerfile.gpu install it via apt-get; for
a native dev install run apt-get install gdal-bin or the
equivalent).
13.4 M01 Temporal change detection — real model
Status: built — real model (PRs #45, #49). Both the segmentation and change detection handlers run a real model on real imagery. Deterministic mocks remain as guarded fallbacks.
Spike A — real
segmentation (segment_real.py, PR #45)
infer.segment runs SegFormer-b0
fine-tuned on ADE20K
(nvidia/segformer-b0-finetuned-ade-512-512 from
HuggingFace, via transformers). The pipeline:
- Fetch a 512 × 512 RGB image of the AOI from Esri World
Imagery (
fetch_aoi_image— public, no auth). - Run SegFormer → 150 ADE class logits, upsampled to
grid × 8resolution. - Bucket each pixel's class label to one of four land-cover classes
(
built/vegetation/water/bare) using a keyword match against ADE20K label strings. - Summarize each grid cell by dominant bucket →
{i, j, cls, confidence}. - Output a
segmentationderived dataset (GeoJSON polygons).
The model is lazy-loaded once and cached; available()
returns True only when torch +
transformers are importable. Model and tensor move to CUDA
if detected, else CPU. SEGMENT_MODEL_ID selects the model
(default nvidia/segformer-b0-finetuned-ade-512-512; the GPU
Dockerfile sets nvidia/segformer-b5-finetuned-ade-640-640).
Accuracy caveat: ADE20K is a ground-scene model, not a
remote-sensing model. Over overhead imagery, results are plausible but
rough. The upgrade to a multispectral geospatial foundation
model (Prithvi-EO / TerraMind) is the documented next step.
Real two-epoch change detection (PR #49)
change.detect now runs a real path when two epoch
imagery sources are supplied:
- Tiled imagery input (
fetch_aoi_tiles): mosaic XYZ tiles covering the AOI bbox from any{z}/{x}/{y}tile URL (Planetary Computer Sentinel-2, NASA GIBS, Esri, OSM, etc.) — auto-selecting a zoom where the tile count is ≤ 24, then cropping to the exact bbox. This is a higher-resolution path than the single-export approach. - Segment epoch A and epoch B independently using the SegFormer path above.
- Diff per grid cell: if the dominant class changed, record the cell
as a change with
cls = "new_structure" | "removed" | "modified",from,to, andconfidence = min(conf_a, conf_b). - Output a
changederived dataset (GeoJSON polygons of changed cells only).
Web UI flow (updated AnalysisOverlay):
- "Find Sentinel-2 imagery" button does a STAC search (Microsoft
Planetary Computer
sentinel-2-l2a) over the current map view bounding box, returning ~2 years of acquisitions. - The analyst clicks two scenes (A = before, B = after) from the results list; cloud cover is shown.
- "Run" submits
change.detectwithinputs.epoch_a = {tiles: <tile URL>, label: <date>}andinputs.epoch_bin the same shape. Verified: ~42 s on CPU for a typical urban AOI.
Mock fallback: when no epoch imagery is supplied, or the ML stack is unavailable, the deterministic mock (~18% of cells changed, hash-based) remains active and is clearly labelled.
change_cells function signature
(segment_real.py):
change_cells(bbox, epoch_a, epoch_b, grid) → (changed_cells, distribution, pct)
changed_cells is a list of
{i, j, cls, from, to, confidence}. The Rust worker is
unchanged — all the model logic is in the sidecar.
13.5 Phase 2 web UI — Analysis overlay (capability feature)
Status: built and wired as a first-class capability feature (PRs #35, #36); merged to
main. Migration0016seeds the feature on for the dev tenant.
Source:
cartos-web/src/features/analysis/AnalysisOverlay.tsx
The Analysis overlay is no longer a floating pill — it is now a
controlled DockablePanel that the
PanelDock launcher rail opens and closes (the same pattern
as Overture, STAC, and other feature panels). It is registered in the
catalog as the analysis feature ("Analyst Intelligence")
and is:
- Wired in
PanelDock.tsxas theanalysispanel kind (icon: mini chart; label: "Analysis") at the bottom of the launcher rail. - Gated per-module by the tenant's enabled features: Smart City checks
smart-city/analysis, Maritime checksmaritime/analysis, Emergency Services checksemergency-services/analysis. The dock button is absent and the component is not mounted when the feature is off. - Mounted in Smart City, Maritime, and Emergency Services native dock modules. Cartos Field has no panel dock yet, so the overlay is not there.
- Added to the Admin → Features console — a Tech
Admin can toggle the
analysisfeature for any module (including cross-adding it to a module where it isn't a default), identical to how Overture is cross-added.
Props accepted: mapRef, mapReadyRef,
mapEpoch, open, onClose,
panelId, storageKey — the same
controlled-panel contract as OvertureOverlay.
The overlay wires together the full Phase 2 loop in the browser:
- Compute tier: a toggle (Local / Full GPU) selects
the routing tier for the job. The panel reads
GET /api/jobs/backendson mount and shows which tiers are available and their device/model. - Run (Segmentation): select "Segmentation" →
POST /api/jobswithkind: "infer.segment"and the current map view as the AOI → pollGET /api/jobs/:idevery 1.2 s → on success, fetch the producedDerivedDatasetfrom/api/derived/datasets/:id. - Run (Change detection): select "Change detection" →
"Find Sentinel-2 imagery" triggers a STAC search over the view; pick
epoch A (before) and epoch B (after) from the results list; "Run"
submits
change.detectwith the two tile URLs and dates. - Render: the dataset's
metadata.resultGeoJSON is painted on the map as fill + line layers, colour-coded by classification class (built→ red,vegetation→ green,water→ blue,bare→ purple; change:new_structure→ amber,removed→ red,modified→ yellow). KPIs frommetadata.kpisare shown in the panel. - Save as report: constructs a
SessionResultfrom the dataset (one operation trace entry, KPIs forwarded) andPOSTs it to/api/reports/sessions. - Open / DOCX: lists saved sessions; "Open" fetches
the HTML report (
getBlob) and opens it in a new tab; "DOCX" downloads viagetBlobfrom/report.docx.
Run and Save are gated to tech_admin. The overlay
re-establishes its map layers after a style reload (via
mapEpoch).
13.6 MCP server — Cartos as an AI tool host
Status: built — PR #43. Verified: stdio handshake + live tool calls proxied to cartos-api.
What it is: mcp-server/ (TypeScript,
Node 20+, @modelcontextprotocol/sdk 1.x, zod).
Compiled to dist/index.js. Lets an MCP-capable AI host —
Claude Desktop, Claude Code, GitHub Copilot, Gemini via a
function-calling bridge — "ask Cartos" by calling the platform's live
data routes as MCP tools, with full tenant/group scoping enforced by
cartos-api.
Design: read-only proxy over cartos-api. The MCP
server holds no state, no DB credentials, no model weights.
Authentication is a Keycloak bearer token (CARTOS_TOKEN)
supplied by the operator. All scoping (tenant, team groups, module
enablement) is enforced by cartos-api on each request — the MCP layer
cannot bypass it.
Tools exposed:
| Tool | Route proxied | Purpose |
|---|---|---|
cartos_who_am_i |
GET /api/me |
Tenant, tier, roles, team scopes for the connected token. Call first. |
cartos_capabilities |
GET /api/capabilities |
Enabled modules + features for this tenant. |
cartos_smart_city_incidents |
GET /api/smart-city/incidents |
Active Smart City incidents (id, kind, severity, location, status). |
cartos_maritime_vessels |
GET /api/maritime/vessels |
Tracked vessels (position, type, status). |
cartos_emergency_units |
GET /api/emergency/units |
Emergency units + current availability. |
cartos_derived_datasets |
GET /api/derived/datasets[?aoi=w,s,e,n] |
Lineage-tracked GeoAI outputs. Optional aoi bbox for
provenance over an area. |
cartos_reports |
GET /api/reports/sessions |
Saved analysis sessions (id, title, status, created_at). |
cartos_report |
GET /api/reports/sessions/:id |
Full structured SessionResult (KPIs, findings,
methodology trace). |
Config: two env vars — CARTOS_API_URL
(default http://localhost:4000) and
CARTOS_TOKEN (a valid Keycloak bearer token for the Cartos
Atlas tenant).
Build and wire into Claude Desktop:
cd mcp-server
npm install
npm run build # → dist/index.jsAdd to claude_desktop_config.json (Claude Desktop) or
the equivalent for your host:
{
"mcpServers": {
"cartos": {
"command": "node",
"args": ["<abs-path>/mcp-server/dist/index.js"],
"env": {
"CARTOS_API_URL": "http://localhost:4000",
"CARTOS_TOKEN": "<paste a token>"
}
}
}
}Get a dev token:
curl -s -X POST http://localhost:8080/realms/cartos/protocol/openid-connect/token \
-d grant_type=password -d client_id=cartos-web \
-d username=demo -d password=•••••••• | jq -r .access_tokenDev tokens expire; for a long-lived integration use a service-account / client-credentials grant.
Transport: stdio (the Claude Desktop/Code standard). stdout is the MCP channel — the server logs only to stderr. An HTTP/SSE transport for non-desktop hosts and a service-account auth flow are the documented next steps.
Write actions: not exposed by design. Running a GeoAI job, saving a report, or setting data scope should be added deliberately with an auth review before exposure to an orchestrating AI.
13.7 Roadmap — remaining Phase 2 waves
| Item | Status |
|---|---|
| Multispectral geospatial foundation model (Prithvi-EO / TerraMind) — swap SegFormer for a true remote-sensing FM over Sentinel-2/HLS multispectral input via STAC; needs ≥16 GB VRAM | Next upgrade — path documented in
model-sidecar/README.gpu.md (terratorch + HLS
STAC fetch); gated on GPU target |
M02 figure capture — viewport screenshot attached to
SessionResult figures |
Planned — requires a map-ref context in AppShell so the global agent panel can capture the active map |
| MCP write actions (run a GeoAI job, save a report) | Planned — add deliberately with auth review |
| MCP HTTP/SSE transport | Planned — for non-stdio hosts |
| Map Export & Mapbook (PNG, cartographic PDF, multi-page atlas) | Built — PR #51, capability map-export
(see §9.2) |
GeoPDF / GDAL georeferencing — OGC GeoPDF output (server-side
gdal_translate, EPSG:3857, opens in QGIS/ArcGIS) |
Built — PR #53 (see §9.2 OGC GeoPDF export) |
| Compare: Swipe | Side-by-side toggle + full grouped layer catalog (Basemap/Esri/GIBS + Custom URL) | Built — PR #56 (see §9 Compare) |
| Compare: bidirectional camera sync (both panes interactive, guard-flag feedback-loop prevention) + "Current map" source option (clones live host map style — basemap + active data layers) for side A and/or B | Built — PR #58 (see §9 Compare) |
| Map Export: EarthWatch3D parity — left-dock panel consolidation, Create Map | Mapbook toggle, four layout templates, full plotter paper sizes up to 48×60 + Custom, Data (CSV) section | Built — PR #56 (see §9.2) |
3D Data — Point Clouds: true geospatial placement (Georeferenced |
Center of map toggle), proj4 CRS reprojection for projected
LAS/LAZ (any UTM zone + EPSG:4326/3857 + raw +proj=…), density slider
(2–100 %), 90° pitch in native modules |
Built — PR #57 (see §9.3) |
| GeoPDF oblique / 3D GCP warping — georeference non-nadir (tilted/oblique) views via control-point warping | Planned follow-up to PR #53 |
| M04 Gaussian splats — client viewer (three.js overlay + SuperSplat SOGS importer in MapLibre) | Built — PR #63 (see §9.3) |
| M04 Gaussian splats — server-side generation (from imagery/LiDAR on a GPU) | Wave 2C — gated on GPU target |
| Cartos Grid — Electric Utility edition (nine department module surfaces, configurable KPI dashboard, engine + editions model) | Built — PR #67 (see §8.1) |
| Cartos Grid — live operational data (five tables: assets/outages/work orders/vegetation/parcels; RLS; seed; live map layers; KPI API; three agent tools) | Built — PRs #92–#95 (see §8.1 Live operational data) |
Cartos Grid — all seven SoR connectors end-to-end
(grid_integrations registry, OMS/ADMS + Maximo + SAP PM +
Esri UN + P6 + MS Project mock feeds, File/CSV real import,
grid_projects CIP table, Capital Program module live,
dashboard SoR chip strip, six opt-in schedulers) |
Built — PRs #102, #104–#109 (see §8.1 Systems-of-record integrations) |
Historic AIS analytics — vessel_positions time-series,
port_aois, track replay, density heatmap, port-call counts,
opt-in live ingester |
Built — PRs #96–#98 (see §8.2) |
Historic AIS analytics pushed to Cesium MDA globe via
cartos.ais.set bridge (AisBridgeControls.tsx,
AisPayload in iframe-bridge) |
Built — PR #103 (see §8.2) |
| Platform theme system (10 palettes, CSS token system, per-module overrides, theme-matched basemaps) | Built — PR #67 (see §9.4) |
Cartos engine rebrand (CartosMark.tsx, edition-neutral
shell) |
Built — PR #67 |
Cartos Tower, Milepost & Blueprint — three more industry
editions (29 department modules total via the shared
IndustryModule surface, edition.ts hostname
detection extended to 5 editions, per-project/portfolio-wide
access-grant RBAC for Milepost/Blueprint, dedicated subdomains) |
Built 2026-07-07 (see §8.3). Live operational data + SoR connectors (mirroring Cartos Grid) are the next increment. |
Time of Day — sun-position lighting panel (dependency-free
NOAA/SunCalc-style solar engine; drives 3D building shading, hillshade
illumination, sky) + 3D-aware Measure panel (Distance/Area/Height,
terrain-derived slope length, m/ft) — capability features
time-of-day/measure in Smart City, Maritime,
Emergency Services |
Built 2026-07-10 (see §9.8). Shadows toggle is a hillshade-contrast approximation; real deck.gl cast shadows are a documented follow-up. |
| Map tools UI + navigation suite — shared "widget kit" HUD design language; Draw & Annotate capability feature (Select/Point/Line/Polygon/Rectangle, styling, GeoJSON export); always-on Navigate compass rail (camera controls, multi-CRS coordinate readout, UTM/MGRS grid overlay, replaces MapLibre's default nav controls); universal drag+resize on every floating map surface incl. the ArcGIS identify popup | Built 2026-07-11 (see §9.9) |
Mapillary crowd-sourced street imagery — portable
mapillary capability feature (coverage vector overlay +
click-for-photo popup, default-on in the native trio + all three
industry editions) and the cross-module
mapillary_street_imagery agent tool (bbox/point+radius
search, pano/year filters) |
Built 2026-07-13 (see §9.10) |
Cartos Africa — the sixth live edition, a standalone continental
Government edition for African governments/NMAs/ministries (9
continental Government modules, country/ADM1 scope, foundation-layer
fabric, Continental Command dashboard, real OSM/Natural Earth data in
all 9 modules, africa_scope agent tool) + engine-wide
ride-alongs (global "Satellite · Esri" basemap, agent/data-source
module-scoping fix, theme-following floating panels, edition-switcher
pulldown, holographic point symbology + feature icons,
vector-over-raster ordering) |
Built + merged to main (PR #127) — LIVE at
africa.cartosatlas.com (see §8.4). Next: real
parcel segmentation, COG/SAR foundation layers, deeper per-country
data. |
| Cartos Harvest — the ninth edition, STAC-native crop intelligence (7 modules — command + crop type/health/yield/water/boundaries/risk; live STAC orchestration over Microsoft Planetary Computer Sentinel-2; v0.2 KMeans temporal-phenology crop classification; a composed 0–100 agri-risk score + index-insurance trigger; a farmer registry; persisted derived products (M03) + a Saved Products panel; reuses the Africa country-scope + foundation fabric; five agent tools) | Merged to main, tagged
cartos-harvest-v1 — LIVE at
harvest.cartosatlas.com (see §8.7). Next:
verify the supervised Prithvi/Clay GPU classifier scaffold, UAE
greenhouse anchor, per-AOI agent tools. |
Opus 5 extended-thinking round-trip fix —
ContentBlock::Thinking/RedactedThinking in the
Anthropic client (crates/cartos-agent/src/llm.rs), fixing
intermittent 502s on reasoning-heavy agent turns, platform-wide |
Built — shipped with the Cartos Harvest merge (see §7 The AI agent) |
| M05 AOI → 3D LOD 2.5/3 buildings (reconstruct from imagery) | Wave 2C — planned |
| Cloud-native data layer — object-storage format spikes (PMTiles serverless vector tiles from MinIO, GeoParquet + DuckDB-WASM in-browser analytics, FlatGeobuf bbox-streamed vector, COG range-read raster, Turf.js client-side geoprocessing, broad layer export); all on existing MinIO, no new services; Grid Data panel toggles | Spiked / proof-of-concept — PRs #113–#117 (see §9.5). Demo data only. Production hardening (signed URLs, tippecanoe, real rasters, partitioned GeoParquet) is TBD in Phase 2 M03. |
Deployment-readiness hardening (Phases A–D) — JWKS
live refresh (unknown-kid refetch + 10-min loop),
CARTOS_REQUIRE_TENANT_CLAIM, migration 0033 RLS on all
tenant tables, opt-in cartos_app non-superuser DB role, CI
gate (ci.yml + cargo test 4/4), DB pool 8→50 +
entitlement cache + shared HTTP clients + router timeout/concurrency +
15 s KPI cache, FlatGeobuf debounce + vite code-splitting +
tab-visibility polling, GET /readyz + compose healthcheck,
non-root API container, iframe-bridge origin pinning, JSON logs +
request-ID, migration 0034 dedup guard. |
Done (code-level) — see
docs/deployment-readiness.md for the full done-vs-deferred
ledger. Deferred infra: CDN, API replicas, managed Postgres/PgBouncer,
the demo password → Cloudflare Access, Cesium proxy auth,
map-key restrictions. |
14. Phase 3 — Cartos Field
Phase 3A + 3B — built and merged to
main. See §7.5 for
the full implementation detail.
| Component | Status |
|---|---|
Sync/field backend (migration 0009,
/api/field/*, cartos-data/field.rs) |
Built |
Web config console (Admin · Field Apps tab,
FieldAppsConfig.tsx) |
Built |
Capability integration (field catalog module,
cap_guard route gate, migration 0010) |
Built |
Analyst web module (/field,
FieldModule.tsx) — captures on the map + photo
thumbnails |
Built |
Agent tools (list_field_apps with module
arg, summarize_field_captures) |
Built (cross-module) |
Media backend (POST/GET /api/field/media,
src/media.rs) |
Built |
Field Apps → Modules (migration 0011,
field_app_modules M2M,
PUT /api/field/apps/:slug/modules) |
Built |
Portable FieldCapturesOverlay (Smart City, Maritime,
Emergency Services) |
Built |
| Demo seeds (Asset Inspection → smart-city; Port Inspection → maritime) | Built |
Flutter mobile app (cartos-field-mobile/) —
schema-driven capture, geometry editor, photos + signatures, offline
queue + basemap caching, background sync, OIDC/PKCE |
Built (CI-compiled) |
Android CI + Firebase App Distribution
(field-mobile-ci.yml) |
Live — APK published on every merge to main /
manual dispatch to field-testers group |
iOS CI + TestFlight (field-ios-testflight.yml) —
compile check; signed upload gated on Apple secrets |
Compile CI live, run on demand
(workflow_dispatch since 2026-07-29 — a
macos-14 runner bills at 10× and the compile is not
secret-gated); signed upload TBD (pending Apple
Developer enrollment) |
| IdP federation | Deferred — designed in
docs/identity-federation-design.md; decoupled; triggers on
first sovereign-client onboarding |
Remaining 3B items:
- On-device Android field test (APK available now via Firebase App Distribution).
- iOS TestFlight signed upload (pending Apple Developer enrollment; no-signing compile check runs now).
- Minor definition-version reconciliation.
What it is: a configurable, offline-first
mobile field-capture platform — the sovereign
ground-truth edge that feeds the loop and multiplies seats (~10× field
vs analyst). Planning doc set: cartos-phase3-mobile/
(MRD/PRD).
15. Reference docs
| Doc | What |
|---|---|
docs/deployment-readiness.md |
Deployment-readiness hardening ledger — done vs deferred (security, throughput, observability, topology). Read before a production launch. |
docs/group-access-design.md |
Canonical group-access contract + RLS + admin write paths. |
docs/identity-federation-design.md |
Planned model for federating Keycloak to a client's own IdP/AD per sovereign deployment (design, not built). |
docs/phase-1-plan.md |
Broader phase planning. |
docs/build-manual-deltas.md |
Divergences from the original build manual (Smart City first; EarthWatch3D iframe). |
docs/demo-script.md |
Demo walkthrough. |
docs/cesium-audit.md |
Cesium app audit. |
cartos-phase2/docs/phase-2/specs/derived-dataset-schema.md |
Gate-0 contract: derived-dataset table + lineage edge schema.
Implemented by migration 0013 + cartos-data::derived. |
cartos-phase2/docs/phase-2/specs/session-result-object.md |
Gate-0 contract: SessionResult schema
(schema_version=1). Implemented by
cartos-core::SessionResult + migration 0014. |
cartos-phase2/docs/phase-2/specs/gpu-job-contract.md |
Gate-0 contract: sidecar POST /infer request/response +
worker lifecycle. Implemented by migration 0015 + jobs.rs +
model-sidecar/main.py. |
mcp-server/README.md |
MCP server build + Claude Desktop wiring + token refresh. |
model-sidecar/README.gpu.md |
GPU sidecar deploy (RunPod/Lambda/AWS/Azure/on-prem) + multispectral FM upgrade path. |
cartos-pricing/README.md |
How to regenerate the commercial model + docx/pdf. |
website/README.md |
Site structure + deploy. |
cartos-grid-PRD/ |
Cartos Grid (Electric Utility edition) PRD + Capability Brief — engine+editions model, nine department modules, RAiN Partners licensing, deep-integration roadmap (operational data tables + system integrations). |
guardian-PRD/ |
Cartos Guardian PRD + Capability Brief — the strike-risk model, five modules, deeper-data roadmap. See §8.5. |
sentry-PRD/ |
Cartos Sentry PRD (PRD.md +
build_sentry.py) — the reactive crisis-monitoring product
case. See §8.6. |
EarthWatch3D-PRD/PRD.md §20 |
Cartos Atlas Government edition framing (non-exclusive; engine+editions model from the edition perspective). |
CLAUDE.md |
Context for Claude Code sessions (layout, conventions, startup). |
README.md |
Public-facing quick start (uses :5432 — adjust to
:5433 here). |
Questions or something out of date? Update this guide alongside the change.
Live site: https://cartosatlas.com