Git sync¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: none, retired with Core.
This page explains the in-daemon git sync that keeps a long-lived fleet host of nerthusd converged with origin and safe to write to: the scheduler tick, the publish modes including converge-only replicas, the ledger rule, the SyncStale / SchemaTooNew refusals at the write gate, and how direct human pushes coexist with the daemon through the post-converge lint. The host setup — the container role, the bootstrap flow, verification — lives in Bootstrap a fleet host; the roster and the failover model live in Fleet.
Why the daemon owns the sync¶
A fleet host 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 contributor device, serving loopback REST against its own clone of repozytorium-fabularne, authenticated to GitLab as a dedicated bot account.
The daemon runs the sync itself. A scheduler inside the serve loop fetches and converges with origin, publishes accepted writes back when it is the primary, and feeds the freshness check at the single write gate. The governing principle is fail-closed: a host that cannot situate itself against origin refuses writes (SyncStale) rather than accumulating changes on a stale base.
Git history attributes every fleet publish to the bot; the human actor lives in the 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.
Roles: one primary publishes, replicas converge only¶
The fleet gives sync a role. Exactly one host is primary; it publishes accepted writes to origin. Every other host is a replica: it converges with origin and serves current data, but never publishes. The role is set by the container at boot, which renders the host-local sync.json from the host's NERTHUS_ROLE:
primaryboots with the mode the fleet'spublish_modesetting names, and withpublishMode: mrwhen it names nothing.pushbelongs to a Rada-operated box listed inrada_hosts; on a contributor devicefleet:validaterefuses it (fleet.settings.yml).replicaboots-ReadOnlywithpublishMode: none— it fetches and fast-forwards, and its tick skips the publish stage entirely.
Because only one host writes to origin at a time, the fleet needs no lock and no leader election in the daemon. Origin push atomicity is the serializer. When the primary goes dark, automatic failover flips one replica's line to primary, and its next converge boots it read-write.
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. Every host does this, primary and replica alike. - Publish — committing and pushing lore bytes the daemon has already accepted (API writes, or an operator's sanctioned hand edits) to origin. A fleet primary publishes in
mrmode; a replica'spublishMode: noneskips it. - 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. On a fleet host the container entrypoint writes it from the host's role:
Enabled ⇔ the resolved interval is ≥ 1. publishMode is push, mr, or none; setting it alone never enables sync. A replica's -ReadOnly boot implies none. A file that omits the key at all leaves the daemon on push, which is the home operator's case — a fleet host is never told nothing, because the entrypoint renders the key from the host's role and the fleet's publish_mode. Every boot path reads the file, including a client auto-spawn that passes only -Repo — a crashed container plus one restart boots an equally protected instance, never a silent unsynced writer on the host'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 monotonic comparison; a backward wall-clock step from a laptop's suspend-and-resume can neither stall the tick nor fire it early.
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 (a home or CI daemon), the sync tick is inert.
The same seam carries the map-checkup tick, the time-budgeted CDN version sweep (Locations model), and the close-reminder and staging-age ticks on the primary. The first two run only where schedules.<workload>.owner reads fleet, read fresh on every tick, so a committed handover to CI lands with the next sync and needs no restart (Configuration). Their index edits ride the next sync publish like any other write.
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, skipped when none |
| 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 |
| 6b. Lint | Post-converge semantic lint when the converge pulled foreign source commits |
| 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 host 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". A replica innonemode never reaches publish. - 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, and it is the replica's whole job. - 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¶
The primary commits with git add -A and the message Zmiany z hosta (<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|staging 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.
none mode (replicas). The tick fetches, converges, verifies, heals, and records — and skips the publish stage. A replica serves exactly what origin holds and originates nothing. -ReadOnly implies this mode.
mr mode (a fleet primary — the 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. A contributor device publishes this way so every daemon write meets review.
- 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 host's serving view until the merge request merges and syncs back. A closed, unmerged merge request is permanent loss from that host's perspective; recovery is manual, from the remote branch.
push mode (a Rada-operated box). 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).
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. The first sync after adoption is one of these: the importer seeds the echo from nerthus.pu-sessions.md (Adoption), so a freshly adopted repository publishes its adoption output as a merge request rather than a push, and the seeded history reaches other clones when a Radny merges it. The adopting host itself is protected the moment the import finishes — its own echo is on disk. Settle output carries the monthly money and PU movements, and the Rada 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 primary this uses the fallback style: commit on <branch>, push HEAD to vm/<stamp> titled Rozliczenie z hosta (<host>) <stamp>, reset conditional on push success.
Direct pushes and the post-converge lint¶
Humans push to the lore repository's main directly, and they always will. There is no protect-main stage — none is possible on the Free tier, and none is wanted. A narrator fixing a typo, a Radny editing the Uprawnienia table, an operator correcting an entity by hand: each is a plain git push, a first-class input the fleet treats as normal forever.
What guards against damage is detection, not prevention. After any converge that pulls foreign commits touching source files, the daemon runs a post-converge semantic lint. It never blocks the converge — the bytes are already on origin, and refusing to serve them would only make the host stale. It reports what it finds:
- duplicate
###headings of one type across entity files, which the model would otherwise silently shadow first-block-wins (a name shared by a Lokacja and the Mapa depicting it is by design and passes); - double-active temporal rows;
- dangling name references, including
(auto)claims (the claim-ledger's(auto)exemption does not apply here, because origin can now move under the host); - foreign edits to
Gracze.mdornerthus.pu-sessions.mdoutside a settle window.
Findings land in .nerthus/state/converge-lint.json, publish an event, and surface on GET /claims/lint. On the primary the transitions raise a notice in the fleet tech channel (Fleet). The lint runs on every converge, fleet-wide, so hand-edit drift is caught at the next tick on whichever host converges first, not weeks later when a write happens to trip over it.
Session landing serializes at origin¶
Session writes do not go through the sync tick's publish. A session accumulates as a host-local staged draft, and closing it lands the whole session in one atomic transaction that converges fresh main, gates the content, materializes it, and pushes inside a bounded compare-and-set loop. Origin push atomicity is what serializes closes across the fleet: any town host can take a session, and the first close to land wins. The session model owns that transaction; this page owns only the ordinary tick beneath it.
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 host 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 fleet's digest-pinned upgrade wave (Fleet) is what makes this rare and orderly. 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.
The corpus checkouts tick too¶
A host may hold checkouts other than the lore one, and each converges on a schedule of its own. GET /sync reports each under its own key beside the lore block, POST /sync ticks each and reports it under the same key, and both are absent on a host that does not hold that corpus — an honest answer rather than an empty object implying a corpus.
| key | corpus | publishMode |
why |
|---|---|---|---|
logi |
the transcript archive | push |
every host that fetches a transcript writes one, and its tick pushes it |
lang |
the annotation tree | none |
fetch-only. No host writes here: one CI job regenerates the whole tree and opens a merge request, so the publish stage is skipped entirely rather than refused by the write gate |
dziel |
Repozytorium Dzieł | none |
fetch-only. Nothing on a host writes works, and the external search tier is what reads them |
adnotacje |
the labelled-sample store | mr |
the second corpus a daemon writes, since 2026-09-05 (ruling H125.1): Kto to? rulings deposit confirmed rows on the host, and each batch goes out as a vm/* merge request the daemon asks GitLab to merge when store-gate is green (published.mergeRequest on the tick report; a failure is a warning and the merge request waits for a person). mr, not push: the store carries its own validator with no allow_failure, and a row it refuses must not land because a daemon asked |
probki |
the finetuning sample store | none |
fetch-only, on the same terms. Contributors deposit into it, so a frozen checkout exports rows that predate what a consumer was told to expect |
mc |
the moderators repository | none |
fetch-only. One account has write, and the split exists so that merge rights on the lore repository do not reach the tariff a sanction is judged against |
Every block carries the same fields as the lore block, and the field that distinguishes them is publishMode, reported rather than implied.
mc is the one read-only corpus a daemon writes into. The moderation record store, the evidence blobs and the disclosure log live under <mc>/.nerthus/moderation, and nothing commits or pushes them. Converge-only skips the publish stage whole, so no moderation byte leaves the host; where an incoming path overlaps a locally dirty one the fast-forward refuses and the tick reports diverged with its reason.
lang exists because nothing else fetches that checkout. The container clones a corpus only into an empty volume — a container recreated onto an existing volume takes no clone, and from there the sync tick owns it. Without a tick of its own, a host would serve the annotations it was born with until somebody deleted its volume, with /health green throughout. For a fetch-only corpus the failure this key makes visible is not divergence; it is staleness.
Each tick runs CorpusOnly: no entity model to rebuild, no prose for the converge lint to read, no after-sync hook. Each writes its stamp under a runtime directory of its own — runtime/logi/, runtime/lang/ — rather than over the lore repository's, and each is caught separately in the serve loop, so one corpus that cannot fetch never stops another converging.
Taking up a commit is announced¶
Every fast-forward — the lore checkout's and every corpus's — posts one plain-content line to the tech channel: the host, the repository, the short sha it moved to, and the people who wrote the commits it took up, read as author fields with --no-merges so a merge commit does not name the merger for everyone's work. One message per host, because the half nobody could see was never that main moved but that a particular host is now serving those bytes; a host that stays quiet while its peers announce is the signal.
Only a fast-forward announces. A tick that pushed is a host reporting its own write, a tick that found nothing has no news, and a sha already announced is silent — the last announced sha per repository sits in runtime/announce/<corpus>.json. The knobs, including the channel and how to switch it off, are integrations.discord.converge_announce.
Only the primary delivers today. nerthus-agent.sh renders DISCORD_BOT_TOKEN for the primary role alone, so a replica builds its announce and records a SKIP with reason no_token in its own delivery log rather than posting. On a single-primary fleet that is the whole reading; beyond it the per-host picture is partial until replicas are given a token, which is a secrets decision rather than a config one.
The announce for a corpus is made by the tick's caller rather than from inside the tick. A corpus tick runs against a view that deliberately carries no delivery machinery — no integrations block, no bot token, no log directory for the delivery record — so a send from inside it would fail on the first of those.
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 ## Postacie Graczy section (— Players model), nerthus.contributors.md, 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 host's audit log. A host whose whole clone has diverged follows Recover a diverged host.
Conflict frequency is proportional to concurrent home + primary write windows; a fleet with one interactive writer effectively never conflicts.
Example¶
The primary is torneg, a contributor device, so it publishes in mr mode. 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 hosta (torneg) 20260701-201004 as nerthus-bot, pushes it as the merge request vm/20260701-201004, and appends the sync.publish audit line (actor: machine, the mode, the branch, the commit SHA) that ties the bot commit to Anward's write.
A Radny merges that request the same evening, alongside an unrelated one from a home operator. The next tick on torneg fetches, fast-forwards, reports action: converged with the changed files, and the model self-heals. The replica at werbin.nerthus.pl converges the same commit on its own tick, serving it read-only. On August 1st the settle workflow runs against the primary: the batch touches nerthus.ledger.md, so it goes out as merge request Rozliczenie z hosta (torneg) 20260801-031602 for Rada review — here the ledger rule and the host's own mode ask for the same thing.
See also¶
- Architecture — availability shapes,
.nerthus/layout, hooks, model self-heal - Fleet — the roster, roles, the health worker, failover, the tech channel
fleet.settings.yml—publish_mode,rada_hosts,sync_interval_min- Bootstrap a fleet host — the setup and recovery procedures
- Session model — the staged-close transaction that lands session writes
- API reference — the
/syncroute rows, the write gate, wire error ids, the dry-run contract - Configuration —
sync_max_age_min,sync_branch, thelocal/directory