GitOps
GitOps lets you manage a Subspace instance’s configuration, custom functions, pages, agents, skills, MCP servers, judges, settings, and the installed plugin set, as files in a git repository instead of clicks in the app. You get review, diffs, and rollback for the configuration surface the same way you already get them for code, and the deployed state of an instance becomes exactly what’s checked into a branch.
Read-only reconcile
Section titled “Read-only reconcile”Point a server at a repository (SUBSPACE_GITOPS_REPO, plus SUBSPACE_GITOPS_BRANCH,
SUBSPACE_GITOPS_PATH, and SUBSPACE_GITOPS_POLL) and it reconciles that repo’s
pages/** directory into the knowledge base. The repo layout mirrors the OKF
filesystem mirror path for path: pages/** holds canonical OKF markdown, and the
blessed authoring flow is literally copying from the export, cp -r ~/Subspace/okf/<dirs> repo/pages/. skills/<slug>/SKILL.md packages work as
directories too, and their helper assets (references/*.md and so on) sync down to
the volume alongside the skill; those assets never become pages themselves and are
never deleted by the sync.
The server reconciles on three triggers:
- Boot: a blocking sync runs before the agent workflow host starts, so repo-pinned workflow bundles are guaranteed to be on disk before anything tries to run them.
- Poll:
SUBSPACE_GITOPS_POLLseconds between checks (default 300;0disables polling and leaves boot and webhook sync as the only triggers). - Webhook: see deploy ergonomics below.
A failing repo sync logs the failure, records it in state, and keeps serving the last successfully applied configuration. A bad commit never bricks boot.
An applied page is read-only in the app
Section titled “An applied page is read-only in the app”Each page the sync applies carries metadata.gitops {path, repo}. It never carries
the commit hash, that lives in the sync manifest and in the page’s version history as
a gitops:<sha> source, so a no-op reconcile doesn’t mint spurious page versions.
A managed page is fully locked in the editor: bullets never enter edit mode and the
title can’t be renamed. The write path rejects every top-level mutation with a
gitops-managed error, including merges targeting a managed page. Two UI affordances
make this legible without leaving the app:
- An open in repo chip links straight to the file on your forge (
https/sshremotes resolve to<web-ui>/blob/<branch>/<subPath>/pages/<path>; a local path repo has no link to offer). - A drift banner appears when the live canonical hash of a managed page no longer
matches the hash that was applied, which happens when an unmanaged rename elsewhere
ripples a
[[link]]rewrite into a managed page’s body. Drift is informational in read-only mode; write-back mode is what resolves it automatically.
Coexistence is per page, not a global switch: everything the repo doesn’t manage stays fully editable in the app, exactly as before.
The plugin set
Section titled “The plugin set”An optional plugins.json in the repo (["spec", …] or
{"plugins": […], "exact"?: true | "dry-run"}) pins the plugin set the same way
SUBSPACE_PLUGINS does, and repo pins win if both are set. By default this is
additive-and-pinning: it installs and pins the listed plugins but never disables
anything it doesn’t mention.
Exact-state mode is the opt-in alternative: with "exact": true, the repo’s
plugin list is the enabled set. Any enabled plugin the repo doesn’t name gets
disabled, using the same cascade-disable semantics as disabling a plugin by hand.
"exact": "dry-run" runs the same computation but only records what would be
disabled, surfaced through gitops.status, so you can preview the effect of turning
on exact-state mode before you commit to it. Tarball path specs aren’t supported
under exact mode.
Write-back mode
Section titled “Write-back mode”Set SUBSPACE_GITOPS_MODE=write-back (the default is readonly) and managed pages
become editable in the app again, with a difference: every edit exports back to the
repo as a commit instead of being rejected. A dispatcher consumer watches for command
bursts on managed pages and coalesces them (roughly a 750ms debounce) into one commit
containing the canonical OKF for the changed pages, with the metadata.gitops marker
stripped before it’s written to the file. The commit is authored as the acting user
(Name <email> from their session) with committer Subspace GitOps, and the commit
message is subspace: update <paths>.
Push authenticates the same way pull does, through the gitops.git.token credential
described below; a push race (someone else pushed between your fetch and your push)
retries once over a freshly reset checkout.
SUBSPACE_GITOPS_WRITEBACK_BRANCH chooses the branch policy:
- Direct-to-branch (the default): write-back commits land on the same branch the sync reads from, so git and the knowledge base never diverge.
- A PR side branch: the sync keeps serving the main branch’s content, and your edits land on a side branch. Merging that PR lands your own content back as a no-op reconcile.
Conflicts, where upstream and the app both changed the same page since the last
apply, resolve in favor of the app: the reconcile skips that file and records
write-back-conflict:<path> in gitops.status. The next write-back commits the app’s
state over upstream and clears the flag. In other words, once you’ve edited a managed
page in the app, the app’s version wins until you push a change from the repo side
that the app hasn’t since overridden.
Write-back is also what resolves ripple drift automatically: a [[link]] rewrite from
an unmanaged rename that touched a managed page’s body gets picked up by the same
consumer and committed, instead of lingering as a drift banner indefinitely.
Deploy ergonomics
Section titled “Deploy ergonomics”Webhook-triggered sync
Section titled “Webhook-triggered sync”POST /gitops/webhook is a public endpoint that triggers an immediate sync on a forge
push event, so a deploy lands in seconds instead of waiting for the next poll
interval. Authentication is a shared secret held in ops.secrets under
gitops.webhook.secret (env-seeded from SUBSPACE_GITOPS_WEBHOOK_SECRET), verified
constant-time as either a GitHub or Gitea X-Hub-Signature-256 HMAC or a GitLab
X-Gitlab-Token header. If no secret is configured, the endpoint returns 403 to
everyone; it never falls open. The sync runs detached, the webhook returns 202 {queued: true} immediately, and a burst of webhook deliveries coalesces onto the same
advisory-lease single-flight the poll and boot triggers use, so a rapid sequence of
pushes doesn’t queue up redundant syncs.
Git credentials via the secrets broker
Section titled “Git credentials via the secrets broker”Private https remotes authenticate with a broker-held token: ops.secrets entry
gitops.git.token (env-seeded from SUBSPACE_GITOPS_TOKEN, with
SUBSPACE_GITOPS_GIT_USERNAME overriding the default x-access-token basic-auth
username). The token is injected per git invocation through GIT_ASKPASS: the
on-disk helper script itself is secret-free, and the token only ever rides
child-process environment variables, never landing in the checkout or in a remote
URL at rest. If no token is configured, the sync falls back to ambient
authentication (ssh-agent, a configured credential helper), exactly as before this
credential path existed.
Sync mechanics
Section titled “Sync mechanics”The sync engine (apps/server/src/gitops/) is careful about a few things that matter
in production:
- Advisory-lease single-flight: concurrent triggers (boot, poll, webhook) never run two reconciles at once.
- Fast paths: an unchanged commit, or an unchanged file blob within a changed commit, skips reapplication.
- Per-file transaction isolation: one broken OKF file fails and records a
file-error:<path>state row, cleared automatically once the file is fixed, while every other file in the same commit still applies. - Identity resolution: a page’s identity resolves manifest path first, then frontmatter id, then slug, adopting an existing page on collision (with a pre-adopt version snapshot) rather than refusing. The repo winning a collision is an explicit source-of-truth declaration, not an accident.
- Unmanage on vanish: when a path disappears from the repo, the page’s
metadata.gitopsmarker is stripped and the page survives, editable again, rather than being deleted.
The OKF filesystem mirror is GitOps-aware too: it reverts any external, on-disk edit to a managed file back to the canonical content, and re-exports the file if it’s unlinked rather than treating that as a delete. Plugin installs skip any page a GitOps manifest already manages, so the two provisioning paths never fight over the same page.