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.
The primitives
Section titled “The primitives”| 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.
Determinism is the point
Section titled “Determinism is the point”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.
No secret ever reaches git
Section titled “No secret ever reaches git”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.
Import semantics
Section titled “Import semantics”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.
The git workflow
Section titled “The git workflow”grove pull # project shape → groveback/git add groveback && git commit -m "add orders collection"# open a PR — the diff reads as the changegrove push # after review, apply itTwo guards make this safe in a team:
pushrefuses if the project moved since your lastpull. Each pull records a sha256 per file in.grove/pull.json; if someone else pushed in the meantime, applying yours would silently clobber theirs.--forceoverrides.--dry-runexits 2 when there are changes, so CI can gate a PR without parsing output:
grove push --dry-run || test $? -eq 2 && echo "config changes pending review"Deleting
Section titled “Deleting”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.
Promoting between environments
Section titled “Promoting between environments”The same machinery, without files:
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.
Git-backed backup
Section titled “Git-backed backup”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.
What is not config
Section titled “What is not config”- Data. No documents, no files, no embeddings. Local data comes from committed fixtures.
- Connections. The connection registry holds credentials.
- Appearance. A project’s
colorandiconare cosmetic and excluded. - BYO Mongo URLs. Never exported, never inherited.
See the bundle format for the exact file layout and document shape.