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
@Transfermoved, 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.mdplus the executing machine's private ledgers for PU and transfers (architecture), and from the session copies in participants'@plikfiles 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
nerthusdprocess booted for one pipeline job and shut down at the end. It is the standard daemon: the write gate, theBeforeWrite/After*hooks, audit, and?dryRunall 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
@Plikisection 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, theAfter*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) andnone(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
monthto 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 onpwshcontainer 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:
- Boot
daemon/Start-NerthusDaemon.ps1 -Repo <clone> -NoDiscordas 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 carriesadmin.all(permissions). - Gate:
POST /workflows/settle?dryRun=true. Apartialmonth, anunresolvedPU name, or any item error fails the job here, before any write. - Settle:
POST /workflows/settle, thenPOST /admin/shutdown. - Write back: if anything was applied, commit the mutated files (the distributed
copies,
nerthus.entities.md,nerthus.ledger.md) to asettle/<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
@Transferdirective used to be enacted only by aworkflow.settlecaller, and on the CI path that meant a human reading the merge request before any coin moved. Nowsession.writeis 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¶
- Architecture — the daemon lifecycle,
.nerthus/layout, the ledger echo - API reference — the route row, envelope, and status codes
- Setting up pipelines — schedule setup, tokens, testing, triage, late settlement
- Sessions model · PU model · Currency model — the three settled subsystems
- Running tests — the daemon boot pattern the driver reuses