Skip to content

Centralize deployment-mode gating behind a single capability definition #152

@CarsonDavis

Description

@CarsonDavis

Centralize deployment-mode gating behind a single capability definition

Summary

MMGIS ships in two deployment shapes: the full application and the lean angle, which turns off a set of features (geodata management, drawing, the sidecar proxies, the on-disk mission filesystem, the link shortener, server-side raster utilities) and turns on a few others (the dashboard publish flow). Today the decision about which features each mode includes is not written down in one place — it is duplicated across roughly thirty call sites, in three different idioms, on three surfaces (the server, the admin configuration UI, and the no-backend published dashboard).

This issue is to make a single authoritative definition of what each deployment mode includes, and have every gate read from it instead of re-deciding locally.

This is a pure restructuring: no behavior changes in either mode. Both the full and lean deployments must behave exactly as they do today. The only thing that moves is where the on/off decision lives.

Motivation

  • Drift is a production risk. The same fact — e.g. "drawing is off in lean" — is currently asserted in several unrelated places that can silently disagree. A gate that gets missed or contradicted means a feature is wrongly available (or wrongly absent) in a deployment, which is a production-affecting bug, not a cosmetic one.
  • "What does lean exclude?" should not require a codebase search. Today it does. A reviewer or new contributor cannot answer it without grepping the whole repo and mentally reconciling three different phrasings of the same idea.
  • Adding or changing a mode is O(every call site). A third deployment shape, or moving one feature between modes, currently means finding and editing every scattered check. It should mean editing one definition.
  • The decision is expressed inconsistently. The server, the admin UI, and the dashboard frontend each ask the question in their own idiom, so even a careful reader has to learn three vocabularies for one concept.

Scope

In scope — the surfaces where mode currently decides what runs or shows:

  • Server-side feature modules that mount (or don't) per mode.
  • Environment-flag values that are derived from the mode.
  • The admin configuration UI hiding fields/pages that don't apply to a mode.
  • The published-dashboard frontend, which has no backend and must answer or hide calls accordingly.
  • The interactive-tool registry skipping tools a mode excludes.

Out of scope:

  • Re-deciding which features lean includes (that's settled in the deployment ADR).
  • Any change to runtime behavior in either mode.
  • Reworking how a backend-less dashboard answers individual data calls (that dispatcher stays as-is — see the gotcha in the plan).

Acceptance criteria

Verifiable without reading code:

  1. One place answers "what does each mode include." A reader can determine the full set of features lean turns off (and on) by reading a single definition, not by searching call sites.
  2. No behavior change. Full and lean deployments behave identically to before the change — same routes present/absent, same UI, same dashboard output.
  3. Tables still exist in lean. Features gated off in lean still have their database tables created, so flipping a deployment's mode later needs no data migration. (Recommended: extend the lean CI leg to assert this directly, rather than only confirming the lean app boots.)
  4. Adding a hypothetical third mode touches one definition, not every gate — demonstrable by inspection of the change's shape.
  5. Behavior is verified in both modes. The full and lean shapes both pass the test suite in CI, so a change that breaks either shape fails before merge. This relies on a both-modes test setup that lands as a prerequisite (see Dependencies): it runs the existing suite once per mode and adds explicit checks that lean-excluded features are actually absent in lean.
  6. The admin UI expresses mode decisions one way — by declared capability. The mechanism (a component declares the capability it needs; the form engine hides it when unmet) already shipped on the branch; what remains is migrating the admin UI's few remaining raw mode-string checks onto it, so no admin-UI code compares the mode string directly.

Dependencies

This refactor sits at the end of a test-readiness chain and lands last:

  1. jsdom/Vitest test-environment migration (Run the browser-dependent unit tests in a DOM-capable test environment #148, PR Run unit tests in a jsdom environment via Vitest #150) — the unit suite runs on the supported Node version.
  2. Stale-unit-test fixes (Fix the stale unit tests revealed by the jsdom test-environment migration #149) — that suite goes green.
  3. Both-modes CI coverage (Run the test suite against both deployment modes (full and lean) in CI #151) — the suite runs in full and lean; this is the safety net that checks this refactor's "no behavior change" claim automatically on its own PR.
  4. This issue — the gating consolidation, built on top.

Step 3 (#151) is the load-bearing dependency: it must be in place and green before this work starts, so a broken lean gate turns a CI leg red rather than reaching production. (#148 and #149 are separate test issues; this refactor does no test-infra work of its own.)

Right-sizing note

This maps to roughly one focused refactor PR. Note: the admin-UI declared-capability mechanism (acceptance #6) already shipped on the branch — the form engine already hides a component when its declared capability is unmet. What remains on the UI side is migrating a few raw mode-string checks onto that mechanism, which belongs inside this consolidation rather than as a separate slice.

Forward note

The central capability definition is a transitional artifact. Enumerating every gated feature by name in the core is, structurally, the kind of core-knows-every-feature coupling the plugin-marketplace direction aims to dissolve; the durable half of this design is that a module or tool declares the capability it needs and a seam decides. Expect the eventual plugin manifest to subsume the central definition — the on/off decision coming from the app's manifest, not a hardcoded core map. Building the definition now is still worthwhile; just don't entrench it.


Implementation sketch — rough guide, written as of 42b9e0ad on 2026-06-16; re-verify against latest before relying on it

The fuller design write-up lives at docs/adr/deployment/lean/prs/capability-gating-redesign.md (local working doc). This is the condensed start map.

Current call-site inventory (the ~30 sites)

Backend mode (isFull/isLean from API/Backend/Utils/deploymentMode.js) — 14 sites:

  • API/Backend/Datasets/setup.js, Geodatasets/setup.js, Draw/setup.js, Shortener/setup.jsif (isFull()) around route mounts; Deployments/setup.js — inverse if (isLean()).
  • API/Backend/Upload/uploadRouter.jsif (isLean()) branches storage (S3 vs disk), not on/off. See Gotcha 3.
  • API/Backend/Utils/routes/utils.jsif (isFull()) block around ~6 routes (getprofile, getbands, getminmax, queryTilesetTimes, ll2aerll, chronice, proj42wkt).
  • API/Backend/Config/setup.jsisLean() forces the WITH_* flags false and injects DEPLOYMENT_MODE into the SPA.
  • API/updateTools.jsif (isLean() && name === 'Draw') continue.
  • adjacent-servers/adjacent-servers-proxy.js, adjacent-servers/adjacent-servers.jsif (!isFull()) return.
  • scripts/server.jsif (isFull()) mounts the Missions/ static middleware.
  • scripts/init-db.js&& isFull() gates mmgis-stac DB creation.
  • configuration/env.js + infrastructure/ecs/*.json — set/default the mode.

Admin config UI (isLeanMode / isCapabilityEnabled from configure/src/core/capabilities.js): the declarative path is already merged (commits e9bdff39 / 7e737af6) — Maker.js honors requiresCapability and metaconfigs/layer-tile-config.json uses it. Remaining: migrate the raw isLeanMode() calls still in Panel.js (×3), SaveBar.js, DeploymentsWatcher.js onto declared capabilities.

Dashboard frontend (isStaticBuild from src/pre/capabilities.ts, commit a4041f0c): the predicate is already extracted and in use across the src/pre/staticHandlers.js dispatcher, calls.js, ServiceUrls.js, and ~10 tool/UI sites (Map_, TimeUI, MeasureTool, IdentifierTool, Coordinates, Login, Viewer_, LandingPage, essence.js, LayerManager/lib/utils/titiler.ts). These don't need extraction — the remaining frontend work is unifying the two predicate modules' shape, not hunting raw SERVER comparisons.

The move

  1. One backend capability definition (API/Backend/Utils/capabilities.js) — a readable map of capability → enabling modes, resolved through one accessor. Keep the form a predicate over a small context (matching the rule-function shape already shipped in the Configure module), with booleans / mode-lists as sugar for the common single-axis case and full predicates only where a capability needs two axes — so a future composite condition doesn't force a rewrite from a flat boolean table. deploymentMode.js stays the single env read; this becomes the single meaning. Two contracts, not one: the backend throws at boot on an unknown capability (a typo is a deploy error); the frontend warns and hides (already the shipped Configure behavior), so a render-time gate fails safe instead of white-screening.
  2. Gate whole modules at the discovery seam, not in each module body. API/setups.js auto-discovers API/Backend/*/setup.js; have each gated module declare capability: "<name>" and let setups.js decide whether to call its lifecycle hooks. Gate onceInit and onceStarted (both feature-presence) on the capability; run onceSynced unconditionally (model sync — see the gotcha). Leave the seam's existing envs aggregation and priority sort untouched — a gated-off module's env declarations are harmless. Avoids hand-maintaining a wiring list (which would fight upstream merge-back).
  3. Partial gates (Missions mount, utils endpoints, STAC db, derived WITH_* values) keep a local check but ask the definition by capability name instead of comparing the mode string. Granularity: collapse the sidecar cluster — proxy mount, spawn, STAC-db creation, WITH_* flags — into one localSidecars capability (reuse the name the Configure module already uses), not one row per mechanism; they always move together. Keep localMissions (the on-disk mission filesystem + GDAL/SPICE utils) as its own capability — it is not a sidecar. Split finer only when a real reanimation need appears (YAGNI).
  4. Frontend: one consistent shape per build context, not one shared file. Keep deliberate twins — Essence keyed on build personality (SERVER, webpack-substituted), Configure keyed on server mode (DEPLOYMENT_MODE, Pug-injected). A lean admin is lean + node; a dashboard is static; don't conflate the signals. A forced shared module would couple two separate webpack trees for little gain — if drift protection is wanted, add a parity test (the trick already used for staticHandlers).
  5. Admin form engine — the generic requiresCapability mechanism is already merged; the remaining work is migrating the raw isLeanMode() calls (Panel.js / SaveBar.js / DeploymentsWatcher.js) onto declared capabilities (acceptance Point cloud layer implementation in MMGIS using deck.gl #6).
  6. Tool-registry skip becomes a capability filter (down-payment toward per-tool plugin manifests) instead of a hardcoded tool name.

Gotchas (the expensive-to-rediscover ones)

  • GOTCHA — gate the init hook only, never the sync hook. The gated modules deliberately gate route mounts while running model sync in both modes, so tables exist regardless of mode. Verified as of this SHA: all five gated modules already split this way (model requires are top-level; sync work runs outside the gate). The seam must preserve this — gate the init pass, run the sync pass unconditionally. A naive central wiring list gets this wrong and silently stops creating tables in lean (breaks acceptance [Plugins] 26.2 Objective 3: Build AirQuality Tool in MMGIS #3).
  • GOTCHA — do NOT derive the dashboard's call dispatcher from the capability table. A call's static disposition is not a pure function of its capability: at least one call is gated off in the lean backend yet computed client-side in a dashboard (the proj4→WKT conversion). The no-backend dispatcher carries per-call bake/compute/drop/reroute info the on/off table doesn't have; leave it hand-maintained (it has its own parity test).
  • GOTCHA — the upload path is a storage fork, not a toggle. One isLean() site picks S3-vs-disk persistence, a behavior fork between two live paths in both modes. It does not fit the capability definition or the module seam; leave it a direct mode check.
  • Caller-must-gate coupling. Once the gate moves out of a module body, that module's init hook is only safe because the discovery seam is its sole caller. The doc-comment "only the seam may call init" is a reminder, not enforcement — the real enforcement is the both-modes CI matrix asserting gated routes are absent (and tables present) in lean. Lead with the matrix; treat the comment as secondary.

Safety net

A both-modes (full + lean) CI matrix is what pins behavior across this refactor — the existing suite run once per mode. It should land as a small prerequisite PR before this work (see Dependencies). Recommended to extend the lean leg to assert tables exist in lean, not just that routes are absent.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions