Skip to content

Add a feature

Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: Work on Nerthus.Platform.

Every new behavior follows one path through the codebase: a daemon service function, a row in the closed route table, an HTTP handler, and a thin Verb-Nerthus* client wrapper. This page is the recipe.

The ground rule comes from the architecture: the daemon owns all data, and every cmdlet wraps exactly one HTTP call. If a behavior is not a route, no client can do it — there is no plugin system and no client-side fast path.

1. Write the service function

Add a function to the subsystem's file under daemon/services/, or start a new file for a new subsystem. All domain logic lives here — parsing, projection, computation, writes.

  • Name it Verb-Nerthus<Noun> with an approved verb (the list is in step 4).
  • If the natural name is taken by a client cmdlet, append Service (Resolve-NerthusNameService) so the two never collide when both are loaded in one session.
  • Follow the code style: a <# .SYNOPSIS #> on every function, PascalCase variables, .NET statics over cmdlets, the explicit return keyword.
  • A service that mutates files takes an -Apply switch. Only the low-level file-write primitives declare SupportsShouldProcess — those in daemon/services/Write.ps1, plus the repo reindexer Import-NerthusRepo; handlers invoke them with -Confirm:$false.

2. Add the route row

Register the route in daemon/routes.ps1. The table is closed — fixed at build time, never extended at runtime — so this row is the feature's public contract: method, path, required capability, Write flag, and on a GET the Pool flag.

  • Paths are lowercase under /v1/api, kebab-case where needed (/workflows/award-pu); {name} segments capture URL-decoded path parameters.
  • Declare exactly one capability id in the <resource>.<action>[.own] grammar — see the capability reference.
  • Set Write = $true when the route mutates lore or index files, or durable governance and identity state. The flag funnels the request through Assert-NerthusWriteAllowed, the single write gate.
  • Every GET row declares Pool: $true when the handler only reads the published generation and may be answered on a reader thread, $false to stay on the accept loop. RoutePool.Guard.Tests.ps1 fails on an omitted key, and equally on a Pool key on a non-GET row — the dispatcher checks the method before the row, so the flag there is inert.
  • A GET may also declare Replica = $true when its answer is a function of the committed tree alone, and an ETag scriptblock over the request context for conditional reads.

Note

The Write flag is declared per route, never derived from the HTTP method. A POST whose body is just a query (like POST /resolve) carries no flag and is never gated.

3. Write the handler

Add a Handle-<Name> function to daemon/handlers/Handlers.ps1. A handler maps the request $Context to @{ StatusCode; Body } and contains no domain logic — it calls the service. This real handler is the shape to copy:

function Handle-GetEntity { param($Context)
    $D = $Context.Daemon; $At = Get-NerthusActiveOn $Context
    $Res = Resolve-NerthusNameService -NameIndex $D.NameIndex -Name $Context.PathParams['name'] -ActiveOn $At
    if (-not $Res) { return @{ StatusCode = 404; Body = @{ error = 'EntityNotFound'; detail = "Entity not found: $($Context.PathParams['name'])" } } }
    return @{ StatusCode = 200; Body = $Res.Owner }
}
  • Collections return the list envelope { "count": N, "items": [...] } — never paginated.
  • Error ids are a wire contract: an error body is { "error": "<id>", ... } with a stable, tested id string, and the dispatcher maps ids to status codes mechanically. Never invent an ad-hoc error body — the ids and shapes are specified in the API reference.
  • A mutating handler honors $Context.DryRun: either short-circuit with a would* preview body, or forward the flag to the service as -Apply:(-not $Context.DryRun).

4. Write the thin client wrapper

Add the cmdlet to the subsystem's client/Cmdlets-*.ps1 file. It does one thing: build the request from bound parameters and make one HTTP call via Invoke-NerthusApi. The verb must come from the approved set:

Get Set New Remove Resolve Test Invoke Find Add Open Close Start Stop Initialize Send Compare Connect Grant Revoke Receive

Nerthus.Core.psm1 enforces that set with a regex over every exported name, so a verb outside it fails the module load rather than review.

This real wrapper is the shape to copy:

function Get-NerthusEntity {
    <# .SYNOPSIS Fetch one entity by name, temporal values resolved as of an optional date. #>
    [CmdletBinding()] param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [string]$Name,
        [datetime]$ActiveOn, [string]$Daemon, [string]$Token
    )
    process {
        $Q = @{}; if ($PSBoundParameters.ContainsKey('ActiveOn')) { $Q['activeOn'] = $ActiveOn.ToString('yyyy-MM-dd') }
        return Invoke-NerthusApi -Method GET -Path "/entities/$([System.Uri]::EscapeDataString($Name))" -Query $Q -Daemon $Daemon -Token $Token
    }
}
  • Always URL-escape path segments built from user input — Polish entity names carry spaces and diacritics (Gildia Teologów).
  • Carry the -Daemon/-Token passthrough pair; bind pipeline input by property name so objects flow (Get-NerthusPlayer 'Eraster' | Get-NerthusPlayerCharacter).
  • A mutating cmdlet declares SupportsShouldProcess; by default a declined ShouldProcess appends ?dryRun=true and still makes the call, so the daemon computes the real preview.
  • Unwrap the list envelope and emit one [PSCustomObject] per item.
  • No client-side domain logic, ever — the client never parses Markdown or computes PU.

5. Add the API reference row

Every route lives on the per-domain page it belongs to under the API reference — the page's Routes table plus a worked, executed example. The reference lives in this repo (nerthus.docs), so the row is a companion merge request to the code change in nerthus.core. Add your row (method, path, cmdlet, capability, Write flag) to the matching page's table, and an example the module CI can run against the fixture — nerthus.core's pester job clones this repo and replays every example — so GET /routes, the reference, and the code stay in lockstep. A route or cmdlet missing from that reference is a documentation bug, not an option. If the feature introduces a new concept, place its prose per the documentation guide.

6. Test it

Tests come in two layers — see run the tests for the suites, the fixture repo, and the mock policy.

A route is the one change that carries a mandatory test floor, and it is not waivable. A route is wire contract: it is published in GET /routes, it is in the reference, and a client outside this estate calls it. Both of these, every time:

  1. Service tests in the smoke suite over the fixture repo: the happy path, edge cases, Polish-diacritic data — and, for a mutating service, the write gate's read-only refusal, asserting on the bytes written back.
  2. One contract-layer route test, called through real HTTP against a booted daemon: one success and one 403, asserting the exact status code and the stable error id.

Together with the reference row of step 5, those are what "done" means for a route.

Everywhere else — an internal refactor, a cmdlet that gains a parameter, a chore — what a change is tested with is the author's judgement, with one standing exception: a fix carries red-then-green proof. The merge request shows the test failing at the parent commit and passing at the tip, because a regression test that would have passed before the fix binds nothing. A feat needs coverage; only a fix needs the proof. The regime owns both rules.