Skip to content

Config as code

A project’s shape — collections, schemas, policies, functions, roles, integrations, webhooks, buckets, vector configs, OAuth config — is one serializable document. That is what makes it reviewable in a pull request and promotable between environments.

Need Primitive
Serialize a project’s shape ConfigService.export() — deterministic and byte-comparable
Show a reviewable diff plan() — a dry diff with before/after fragments per change
Apply a config import() — idempotent merge-upsert
Never leak secrets Export redaction, plus an importer that rejects any document carrying one

Over HTTP those are GET /api/v1/admin/config-export and POST /api/v1/admin/config-import (with ?dryRun=1 to plan). Through the CLI they are grove pull, grove status and grove push.

Every array is sorted by name and keys are emitted in a fixed order, so two exports of the same shape are byte-identical apart from exportedAt. The bundle form strips even that.

This is what makes a config diff meaningful: a commit appears only when the shape actually changed, and git status stays clean after a no-op pull.

The single most important property here. OAuth exports carry clientId and enabled only. Integration secrets, webhook signing secrets and BYO Mongo URLs never appear at all.

Defense in depth runs the other way too: the importer refuses any document containing clientSecret or secretEnc, so a hand-edited commit cannot smuggle one in.

Two consequences to plan for:

  • A newly imported OAuth provider has no secret, and sign-in stays broken until an operator enters one.
  • An imported webhook arrives disabled with no secret, and enabling it requires a secret on file. A shape export can never produce a live outbound sender.

Plan fragments are built from the same redacted views as the export, so a fragment cannot contain a secret either.

Merge-upsert by name. Create what is missing, update what exists, never delete what is absent. A second import of the same document plans as all-unchanged.

Everything validates before anything applies — schemas, policies, function-code syntax, role shapes, OAuth entries, duplicate names, plus state-dependent checks like built-in role collisions and HTTP endpoint conflicts. All collected and reported together, and nothing applies if any problem exists.

Not transactional. Steps apply in dependency order: collections → schemas → policies → indexes → vectors → roles → end-user roles → integrations → functions → webhooks → buckets → oauth. A mid-apply failure leaves earlier upserts in place; re-running converges.

Import is gated on config:manage, plus each composed operation re-checks its own permission (collections:manage, policies:manage, roles:manage, functions:manage, auth:config:read/write). A custom role needs the full set.

Terminal window
grove pull # project shape → groveback/
git add groveback && git commit -m "add orders collection"
# open a PR — the diff reads as the change
grove push # after review, apply it

Two guards make this safe in a team:

  • push refuses if the project moved since your last pull. Each pull records a sha256 per file in .grove/pull.json; if someone else pushed in the meantime, applying yours would silently clobber theirs. --force overrides.
  • --dry-run exits 2 when there are changes, so CI can gate a PR without parsing output:
Terminal window
grove push --dry-run || test $? -eq 2 && echo "config changes pending review"

Import never deletes. grove push --prune does, and only for functions, webhooks, integrations, roles and end-user roles.

Collections and buckets are never pruned — they hold data, so orphans are reported with advice to remove them deliberately. Vector configs and OAuth providers attach to a collection or a provider slot, so they are excluded too.

Every name is printed before anything is deleted, and confirmed separately from the apply.

The same machinery, without files:

Terminal window
curl -X POST "$URL/api/v1/control/environments/promote?dryRun=1" \
-H "authorization: Bearer $CONTROL_TOKEN" -H 'content-type: application/json' \
-d '{"fromProjectId":"proj_staging","toProjectId":"proj_prod"}'

Both projects must be in the same family and active, and you must be Owner of both. Like every import, it never deletes: resources that exist only in the target are untouched and do not appear in the plan.

The intended direction of truth is worth stating plainly: the platform is the source of truth, and git is a versioned backup and audit trail. Every admin-side change to a project’s shape can be mirrored as a commit; restoring means feeding a committed bundle back through import().

Bidirectional sync is explicitly out of scope. Two-way reconciliation is error-prone, and the redaction asymmetry — git never has secrets, the platform does — makes true bidirectionality leaky by construction.

  • Data. No documents, no files, no embeddings. Local data comes from committed fixtures.
  • Connections. The connection registry holds credentials.
  • Appearance. A project’s color and icon are cosmetic and excluded.
  • BYO Mongo URLs. Never exported, never inherited.

See the bundle format for the exact file layout and document shape.