Skip to content

Git sync

This page explains the design of the in-daemon git sync that keeps a long-lived VM instance of nerthusd safe to write to: the scheduler tick, the publish modes, the ledger rule, and the SyncStale / SchemaTooNew refusals at the write gate. The setup procedures — systemd unit, bot identity and SSH key, local/sync.json, verification — live in Deploy the VM.

Why the daemon owns the sync

The VM instance is the third availability shape, next to the auto-spawned home daemon and the one-job CI daemon (Architecture). It is a long-lived nerthusd on a server, serving loopback REST against its own clone of repozytorium-fabularne, authenticated to GitLab as a dedicated bot account.

The daemon itself runs the sync. A scheduler inside the serve loop fetches and converges with origin, publishes accepted writes back, and feeds the freshness check at the single write gate. The governing principle is fail-closed: a VM that cannot situate itself against origin refuses writes (SyncStale) rather than accumulating changes on a stale base.

Git history attributes every VM publish to the bot; the human actor lives in the VM's host-local audit log. Every publish writes an op: sync.publish audit record carrying the actor (machine for scheduled ticks, the caller for POST /sync), the mode, branch, commit SHA, and file count. Together with the per-write audit records that precede it, the log ties a bot commit back to the people whose writes it carries (Logs & Discord). Commit messages hold host and timestamp only, never actor names.

Concepts

  • Sync tick — one pass of probe → fetch → status → publish → converge → verify → heal → record. The scheduler runs it on an interval; POST /sync runs it on demand.
  • Converge — fast-forwarding the local sync branch onto origin/<branch>, so the daemon serves exactly what origin holds.
  • Publish — committing and pushing lore bytes the daemon has already accepted (API writes, or an operator's sanctioned hand edits) to origin, in push or mr mode.
  • Own-work marker — the SHA of the last commit a tick created (lastLocalCommitSha). It distinguishes the daemon's own unpushed commit, which is safe to retry, from foreign history, which is never re-pushed.
  • Stranded branch — the mr-mode state after a failed push: the worktree stays on its vm/ branch, still serving the writes, until a retry lands.
  • Freshness — a sync-enabled daemon refuses writes once its last successful converge is older than server.sync_max_age_min.

Enablement is host-local by construction

Enablement never travels through the committed repo. A committed "sync on" key would turn every clone — every home laptop, every CI runner — into a syncing instance behind failing fetches. Sync is therefore enabled by exactly two host-local sources, resolved per key at boot:

  1. Boot parameters -SyncIntervalMinutes / -SyncPublishMode — highest precedence, but only when explicitly bound. A bound -SyncIntervalMinutes 0 force-disables sync for that boot, the operator's one-boot escape hatch.
  2. .nerthus/local/sync.json — the enablement file in the gitignored, operator-managed local/ directory:
{ "intervalMinutes": 5, "publishMode": "push" }

Enabled ⇔ the resolved interval is ≥ 1. publishMode is push or mr and defaults to push; setting it alone never enables sync. Every boot path reads the file, including a client auto-spawn that passes only -Repo — a crashed systemd daemon plus one cmdlet boots an equally protected syncing instance, never a silent unsynced writer on the VM's clone.

Two committed keys tune (but can never enable) sync: server.sync_max_age_min (default 60) and server.sync_branch (default empty = auto-resolve from origin/HEAD) — key semantics in Configuration. The remote is always literally origin; a second remote is not a supported topology. Config is read once at boot: an interval change needs a restart, and POST /sync covers "sync now".

Boot also refuses a second daemon when runtime/daemon.port exists and that port answers GET /health — two schedulers interleaving fetch/rebase/commit on one worktree could commit each other's half-rebased state.

The scheduler — a tick in the serve loop

The daemon has one dispatch thread; the scheduler lives inside it. The serve loop accepts requests with a bounded wait (1 s), and the top of every iteration runs the due check — after each idle timeout and after every dispatched request, so request pressure can never starve the tick. When nothing is due, the check is a single datetime comparison.

Properties that follow:

  • Ticks run on the dispatch thread. A tick never runs concurrently with a request, so the daemon's single-threaded assumptions hold without locks.
  • A running tick delays queued requests by at most one git-operation duration — every git call has a hard timeout (60 s network, 15 s local) and is killed on expiry, never left as a bare hung subprocess.
  • No special boot tick. Discovery files and the listener come up first; the first tick fires within ~1 s of boot. Until the first converge succeeds, writes are refused SyncStale and GET /sync reports "lastTick": null, "stale": true.
  • A persistently failing tick retries on the interval, not in a hot loop.
  • On a daemon with sync disabled (every host but the VM), the sync tick is inert.

The same seam carries the map-checkup tick, the time-budgeted CDN version sweep (Locations model). Its index edits ride the next sync publish like any other write; the checkup never touches nerthus.ledger.md, so the ledger rule below is not triggered.

The tick pipeline

Step What it does
0. Probe Cached substrate check: git binary, work tree, sync branch
0b. Debris Deliberate crash recovery: abort a leftover rebase, drop a stale index.lock
1. Fetch git fetch origin; on failure the tick records and stops
2. Status HEAD and origin/<branch> SHAs, dirty files, ahead/behind counts
3. Publish Commit + push accepted writes — gated, mode-aware
4. Converge git merge --ff-only origin/<branch> on the sync branch
5. Verify Freshness check; renews the SyncStale clock on success
6. Heal Model self-heal after converged bytes changed on disk
7. Record In-memory state, the runtime/sync.json stamp, hook + SSE

Rules the steps enforce:

  • Probe failure disarms the scheduler, never the gate. GitMissing, NotAGitRepo, or NoSyncBranch stop ticks, but the gate keeps failing closed — a VM whose git binary vanishes in an OS update must not quietly become an ungated writer whose changes never publish.
  • The publish trigger is: dirty worktree, OR a stranded branch, OR the daemon's own unpushed commit (HEAD == lastLocalCommitSha). Never bare "ahead of origin".
  • Foreign ahead-commits mean diverged, never a re-push. Commits the tick did not create appear ahead of origin only after upstream history was rewritten — rebasing them back would resurrect, bot-attributed, exactly what an operator removed. The tick skips publish, reports diverged, and staleness fires on schedule; recovery is an operator act.
  • Publish is a write and passes the write gate with one named -ForPublish exemption: staleness is skipped (publishing already-accepted writes is convergence work), while ReadOnly mode and schema drift block publish exactly like any write (API reference). A ReadOnly or schema-drifted tick still fetches and converges — a read-only daemon serving current data is the point.
  • Verify defines freshness: origin/<branch> is an ancestor of HEAD on the sync branch — HEAD equals origin, or carries only the daemon's own push-pending commits on top. A stranded state never renews freshness.
  • Heal calls the standard model self-heal (Architecture); the fingerprint is change-scoped, so an upstream commit touching nothing model-relevant costs a stat pass, not a reindex.

Tick outcomes land in lastTick.action: published, converged, noop, publish-retry-pending, diverged, fetch-failed, converge-blocked-dirty, disarmed, dry-run.

Publish modes

Both modes commit with git add -A and the message Zmiany z VM (<host>) <utc-stamp>. One honest consequence: add -A publishes any dirty lore bytes, including an operator's SSH hand edits — hand edits are sanctioned writes and reaching origin is desired, but the commit is bot-attributed. Private state cannot leak: .nerthus/state|runtime|local|cache|log are gitignored, and boot heals the ignore file. Commits run with signing disabled per invocation so a host signing policy without a key cannot eat a batch.

push mode (default, recommended). Interactive writes at home already land on the default branch under the operator's own push; the capability ACL was the authorization. The bot needs Maintainer membership. Sequence: commit on <branch>git pull --rebase origin <branch> → push.

  • Push fails — the commit stays on <branch>. The next tick sees clean + ahead + own-work and retries the push with no new commit.
  • Rebase conflicts — abort, then ship the commit as a merge-request branch: push HEAD to vm/<utc-stamp> with the merge-request push options, title suffixed (konflikt). Then git reset --hard origin/<branch> — the reset is required (a diverged local default never fast-forwards again) and conditional on the push succeeding (an unconditional reset after a failed push would destroy the accepted writes).

mr mode (review gate). Every batch becomes a merge request: checkout vm/<utc-stamp> → commit → push with the merge-request push options. The bot needs only Developer membership, and vm/* must be unprotected.

  • Push succeeds — checkout <branch> and delete the local vm/ branch.
  • Push fails — the branch is stranded: the worktree stays on it, still serving the writes, and GET /sync shows currentBranchbranch. New writes accepted meanwhile are committed onto the same stranded branch, then the push retries. The stranded state clears only after a successful push and a clean checkout back.
  • Clean + ahead in mr mode is foreign by definition (mr mode never commits to <branch>) → diverged.

Warning

In mr mode, published changes leave the VM's serving view until the merge request merges and syncs back — mr mode suits write-rarely VMs. A closed, unmerged merge request is permanent loss from the VM's perspective; recovery is manual, from the remote branch.

The ledger rule — why MR-only for settlement batches

A batch that touches nerthus.ledger.md publishes as a merge request regardless of the configured mode. Settle output carries the monthly money and PU movements, and the Council review of that batch is a decided contract (Settlement model) — an unattended bot must never land it directly on the default branch. On a push-mode daemon this uses the fallback style: commit on <branch>, push HEAD to vm/<stamp> titled Rozliczenie z VM (<host>) <stamp>, reset conditional on push success.

Freshness at the write gate — SyncStale and SchemaTooNew

The single write gate enforces freshness only on a sync-enabled daemon; home, laptop, and CI daemons are untouched by construction, because enablement is host-local. The check:

  • Keyed to converge, not fetch. git fetch succeeds during a force-push, a deleted remote branch, or a persistent conflict — states where the serving HEAD is arbitrarily old. Fetch success proves connectivity; converge success proves the base is current.
  • Monotonic clock. The gate compares [Environment]::TickCount64 deltas — an NTP wall-clock step can neither extend the staleness window nor fire spurious refusals. The clock is never restored across restarts: a rebooted syncing daemon fails closed until its first converge, ~1 s after boot.
  • Fail-closed. "Never converged" is stale. Reads stay unaffected.
  • The healthy case never fires: a 5-minute interval sits far inside the default 60-minute bound, so SyncStale signals persistent failure.

The 403 carries its own remedy ladder:

SyncStale: last successful converge <timestamp|never>; max age 60 min — POST /sync;
check network/SSH credentials; persistent? GET /sync names the cause;
or disable sync (remove .nerthus/local/sync.json and restart)

The rungs cover the three real causes: a transient outage (POST /sync clears it — the route is control-plane, reachable while writes are frozen), broken credentials or network, and diverged, where re-running the tick cannot help. The last rung is the accidental-enable case.

SchemaTooNew is the coupled check, shipped because auto-pull makes "on-disk index format newer than the daemon" a routine state: a home operator upgrades the module and re-imports, pushes; the VM pulls the bumped schema.json; its older daemon must refuse writes — SchemaTooNew: on-disk schema <X> exceeds expected <Y> — update the module and restart the daemon — instead of writing against a format it does not understand. The remedy is the module-update step in Deploy the VM. Wire mapping for both error ids is in the API reference.

Status, the stamp, and observability

GET /sync is read-only and does no network I/O — the origin SHA is as of the last fetch, and origin.fetchedAtUtc is its honesty bound. currentBranch differing from branch exposes a stranded mr-mode state. An enabled host with a broken substrate reports "enabled": true, "git": false, "error": "GitMissing", "stale": true — and the gate is refusing writes.

POST /sync runs a tick immediately and returns the tick report. A fetch, publish, or converge failure is 200 with ok: false; "not runnable here" is a structured 409: SyncDisabled (the route never bootstraps enablement, because a tick on a home clone would publish the operator's uncommitted worktree as a bot commit), NotAGitRepo, GitMissing, or NoSyncBranch (no server.sync_branch configured and origin/HEAD unresolvable). ?dryRun=true is a named deviation from the dry-run contract: it still fetches — refreshing the origin refs is the point — and skips only commit, push, and merge.

Every tick mirrors its state to the .nerthus/runtime/sync.json stamp for post-mortems; the gate never reads it (freshness is in-memory only). Boot restores exactly two fields from it — lastLocalCommitSha and strandedBranch — so a restart mid-retry does not mistake the daemon's own commit for foreign history or orphan a stranded branch.

The capabilities are sync.read and sync.run; no default role bundle carries them, and admin.all resolves both (capability reference, permissions model). The cmdlets are Get-NerthusSync and Invoke-NerthusSync; routes and envelopes in the API reference.

Conflict recovery — the generated index

The machine sections of nerthus.entities.md are regenerated wholesale, so two daemons writing between syncs produce large non-semantic hunks — generated-index conflicts are not hand-mergeable, and hand-merging them in a merge request risks corruption-by-mismerge. The recipe: take origin's version of the machine sections wholesale; hand-merge only the durable roster sections (## Gracze / ## Postacie GraczyPlayers model) and any conflicted hand-authored lore files; re-run the idempotent importer to regenerate the machine sections from the merged sources (Adoption); re-apply any discarded writes through the daemon — they are visible in the VM's audit log.

Conflict frequency is proportional to concurrent home + VM write windows; a VM that is the only interactive writer effectively never conflicts.

Example

The VM daemon serves the community dashboard. Anward closes and distributes ### 2026-07-01, Eraster rozmawia z Tussalem, Anward; the daemon writes the session copies into Wątki/Intrygi w Thuzal.md and Lord Tussal's charfile, and the audit log attributes the distribution to Anward's gracz:<Margonem ID> identity. Within five minutes the tick commits Zmiany z VM (nerthus-vm-01) 20260701-201004 as nerthus-bot, pushes it to main, and appends the sync.publish audit line (actor: machine, the commit SHA) that ties the bot commit to Anward's write. That night a home operator merges an unrelated MR; the next VM tick fetches, fast-forwards, reports action: converged with the changed files, and the model self-heals. On August 1st someone runs the settle workflow against the VM: the batch touches nerthus.ledger.md, so despite push mode it goes out as merge request Rozliczenie z VM (nerthus-vm-01) 20260801-031602 for Council review.

See also

  • Architecture — availability shapes, .nerthus/ layout, hooks, model self-heal
  • Deploy the VM — the setup and recovery procedures
  • API reference — the /sync route rows, the write gate, wire error ids, the dry-run contract
  • Configurationsync_max_age_min, sync_branch, the local/ directory
  • Settlement model — the ledger and the Council review the ledger rule preserves
  • Set up pipelines — the CI settlement job and its separate credential