Skip to content

The settlement model

The settlement workflow (rozliczenie) finds and applies every outstanding routine operation in one call: missing distributed session copies, unapplied @Transfer directives, and the monthly PU batch. It exists so the repository can be settled by a scheduled CI pipeline — the pipeline spawns a short-lived daemon on the runner, drives it over loopback REST, and proposes the result as a merge request. The CI daemon is the ordinary nerthusd, with the same write gate and hooks. For the plain-language introduction, start at the settlement overview.

Concepts

  • Settlement (rozliczenie) — making the committed repository match what session metadata implies: every distributed session present in each participant file, every @Transfer moved, the month's PU awarded. Transcript fetching stays with the log-fetch step (logs model).
  • Pending work is derived, never recorded. The workflow reads actual state from the committed ledger echo nerthus.ledger.md plus the executing machine's private ledgers for PU and transfers (architecture), and from the session copies in participants' @plik files for distribution (sessions model). On a fresh CI clone the private ledgers are empty, so the echo alone decides. Re-running it against a settled repository is a no-op.
  • Ephemeral daemon — a nerthusd process booted for one pipeline job and shut down at the end. It is the standard daemon: the write gate, the BeforeWrite/After* hooks, audit, and ?dryRun all run unmodified; the CI job is simply one more single consumer on loopback (architecture).

What counts as pending

Kind Pending when Applied as
Distribution The session block carries @Pliki, but a computed target file lacks its header distribute-session (header-keyed upsert)
Transfers The session has @Transfer directives and its header is in neither the private transfer ledger nor the echo apply-transfers
PU The target month has PU-bearing sessions the private ledger has not counted, and none of them is echoed award-pu for the month

PU is the only stage that settlement exists for. Closing a session already applies its transfers and delivers its intel (sessions model), so a normally-closed session reaches the sweep with nothing to move. Only the monthly PU cap genuinely cannot be computed one session at a time. The transfer stage stays as a catch-up for the paths that skip closing — a session distributed via distribute-session alone, or one closed while the transfer service was failing — and for repositories whose sessions predate closing applying transfers at all.

Rules behind the table:

  • Distribution stays explicit. A session that was never distributed (no @Pliki section in its block) is not pending — the Narrator distributes or closes it deliberately (sessions model). Settlement only re-converges sessions that were already distributed once and have since drifted: a new participant file, a copy lost in a merge, a target added by an entity's changed @plik. Re-distribution is idempotent, so repairing is safe.
  • Transfers and PU derive their done-state from both ledgers. A header the machine's private ledger counts, or one listed in nerthus.ledger.md, was already settled; the workflow skips it before invoking the service. The drift guards (currency model, PU model) keep their refusal semantics for interactive calls and never fire during settlement. Because settled work is skipped before any service runs, the After* hooks — including the Discord PU message — fire only on first application; a re-run cannot duplicate a notification.
  • A partially echoed PU month is refused. The monthly base is applied per batch, so awarding only the un-echoed remainder of a month would double-count the base. The report marks the month partial, names the echoed sessions, and a human resolves it. settled (all echoed) and none (no PU-bearing sessions) are clean no-ops.
  • The default month is the previous calendar month (UTC) — a pipeline scheduled for the 1st settles the month that just ended. Pass month to override.

The route and its report

One closed-table route: POST /workflows/settle (capability workflow.settle, Write-flagged; routes and envelopes in the API reference). No role bundle carries workflow.settle — today it resolves only through admin.all (Koordynator, Namiestnik, the machine token), which is deliberate: settlement spans session.distribute, currency.write, and pu.award at once. ?dryRun=true computes the full plan, previews the would-be moves and awards, and writes nothing.

The order of application is fixed: distribution repairs, then transfers (oldest session first), then PU last — so a PU failure never blocks the rest. One failed item does not abort the others; every item lands in the report and ok turns false. PU keeps its fail-early contract: an unresolved @PU name aborts that batch with nothing written, and the report carries the structured unresolved list instead of the route throwing 422.

{
  "month": "2026-07",
  "applied": true,
  "ok": true,
  "distribution": { "pending": 1, "items": [
    { "header": "2026-07-01, Eraster rozmawia z Tussalem, Anward",
      "missingTargets": ["Postaci/Gracze/Lord Tussal.md"],
      "distributedTo": ["Wątki/Intrygi w Thuzal.md", "Postaci/Gracze/Eraster.md",
                        "Postaci/Gracze/Lord Tussal.md"],
      "skipped": [], "applied": true }
  ] },
  "transfers": { "pending": 1, "settled": 2, "items": [
    { "header": "2026-07-01, Eraster rozmawia z Tussalem, Anward", "applied": true,
      "alreadyApplied": false, "warnings": [],
      "moves": [ { "Kind": "currency", "Item": "Korony Elanckie",
                   "Amount": 10, "From": "Eraster", "To": "Lord Tussal" } ] }
  ] },
  "pu": { "month": "2026-07", "status": "applied", "counted": 1, "echoed": 0,
          "awards": [ { "Character": "Eraster", "Granted": 1.20 },
                      { "Character": "Lord Tussal", "Granted": 5.00 } ],
          "countedSessions": ["2026-07-01, Eraster rozmawia z Tussalem, Anward"] }
}

pu.status is one of none, pending (dry run), applied, settled, partial, unresolved, or error. A failed item carries error in place of its result fields; a partial month adds pu.echoedHeaders (the split's already-settled sessions); an unresolved batch adds pu.unresolved — the grouped token list from the PU model. The route always answers 200 with the report; the caller (the cmdlet, the CI driver) inspects ok.

The CI driver

The pipeline lives in the lore repository (repozytorium-fabularne), not in this module. This module ships two files under ci/:

  • ci/settle.gitlab-ci.yml — a job template the lore repo includes; it runs on pwsh container images on a schedule.
  • ci/Invoke-NerthusSettleCi.ps1 — the driver: boot the daemon, gate on a dry run, settle, shut down, commit to a branch, push, open a merge request.

GitLab-side operations — the include, the schedule, the CI/CD variables and tokens, testing a run, triaging a red one, and settling a month whose execution date has passed — are covered in setting up pipelines.

The driver's steps, in order:

  1. Boot daemon/Start-NerthusDaemon.ps1 -Repo <clone> -NoDiscord as a detached process and poll .nerthus/runtime/{daemon.port,daemon.token} + GET /health — the same boot mechanism the contract test suite uses (running tests). The driver refuses to run when a daemon is already serving the repo, and accepts discovery files only from the process it spawned. Authorization needs no CI secret: the boot-minted machine token carries admin.all (permissions).
  2. Gate: POST /workflows/settle?dryRun=true. A partial month, an unresolved PU name, or any item error fails the job here, before any write.
  3. Settle: POST /workflows/settle, then POST /admin/shutdown.
  4. Write back: if anything was applied, commit the mutated files (the distributed copies, nerthus.entities.md, nerthus.ledger.md) to a settle/<timestamp> branch, push it, and open a merge request. The pipeline never pushes the default branch — the MR review is the human confirmation of what was settled, and merging it is what publishes the new echo lines to every clone.

Discord stays silent: the driver boots the daemon with -NoDiscord, which disables delivery for that instance regardless of the committed config — so even a committed @prfwebhook tag (the one webhook source — logs model) cannot send from a runner. Notifications belong to the home daemon; nothing leaves the pipeline before the merge request is reviewed.

Racing a live daemon

A home daemon, the pipeline, and a syncing VM daemon (sync) may all try to settle the same session. The committed echo arbitrates for all three writers: whoever merges first wins, and a loser's re-run derives the work as already settled and no-ops. If the settle branch goes stale before merging (home settled the same headers meanwhile), the merge conflict is confined to append-only echo lines and the next scheduled run converges cleanly. The home daemon's own drift guards still refuse an interactive re-settlement after pulling CI-settled echo lines — that protection is unchanged.

Cmdlet surface

Invoke-NerthusSettle — route, parameters, and the envelope in the API reference.

Example

The lore repo's July is unsettled: ### 2026-07-01, Eraster rozmawia z Tussalem, Anward credits Eraster and Lord Tussal 0,2 PU each and was distributed to Wątki/Intrygi w Thuzal.md, but Lord Tussal's own file never gained its @plik copy. On August 1st the scheduled job dry-runs (repair 1 copy, 1 pending transfer, PU pending), settles, and opens a merge request whose diff shows Lord Tussal's file gaining the session block, ten Korony moving from Eraster to Lord Tussal, Eraster's pu_suma moving 127,90 → 129,10, and the new PU echo line: - PU ### 2026-07-01, Eraster rozmawia z Tussalem, Anward (rozliczono 2026-08-01 03:16).

Design decisions

  • Never-distributed sessions. Settlement never distributes a session that lacks @Pliki — distribution stays an explicit Narrator act.
  • Transfers moved to close. Coin moves when the Narrator closes the session, not at month end, because the fiction says it changed hands at the table. The cost is a lost review gate: a Narrator's @Transfer directive used to be enacted only by a workflow.settle caller, and on the CI path that meant a human reading the merge request before any coin moved. Now session.write is enough (sessions model). The transaction ledger and the reconciliation report are what catch an abuse after the fact.
  • Partial PU months. Hard refusal (partial), manual resolution; the per-batch monthly base makes remainders un-awardable.
  • MR-only write-back. The pipeline never pushes the default branch; the merge is the Council's review of the settlement.
  • VM publish of ledger batches. A syncing VM daemon publishes any nerthus.ledger.md-touching batch as a merge request regardless of its publish mode (sync) — the MR-only review stands for every deployment shape.
  • An operator action-queue file. Deferred: a committed request file (declarative assertions drained by this route) is designed but not built — it waits for a named operator who can author requests but cannot run the daemon.
  • The map checkup is a sibling workflow, not a settle stage. Settlement derives pending work from committed state offline; the checkup does outbound HTTP with its own daily cadence and failure modes, so it runs as POST /workflows/map-checkup (locations model) on its own schedule, sharing only the CI plumbing (ci/NerthusCiCommon.ps1).

See also