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 /syncruns 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
pushormrmode. - 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:
- Boot parameters
-SyncIntervalMinutes/-SyncPublishMode— highest precedence, but only when explicitly bound. A bound-SyncIntervalMinutes 0force-disables sync for that boot, the operator's one-boot escape hatch. .nerthus/local/sync.json— the enablement file in the gitignored, operator-managedlocal/directory:
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
SyncStaleandGET /syncreports"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, orNoSyncBranchstop 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, reportsdiverged, and staleness fires on schedule; recovery is an operator act. - Publish is a write and passes the write gate with one named
-ForPublishexemption: 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). Thengit 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 localvm/branch. - Push fails — the branch is stranded: the worktree stays on it, still serving
the writes, and
GET /syncshowscurrentBranch≠branch. 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 fetchsucceeds 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]::TickCount64deltas — 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
SyncStalesignals 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 Graczy — Players 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
/syncroute rows, the write gate, wire error ids, the dry-run contract - Configuration —
sync_max_age_min,sync_branch, thelocal/directory - Settlement model — the ledger and the Council review the ledger rule preserves
- Set up pipelines — the CI settlement job and its separate credential