Skip to content

Add a feature

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 file-level write primitives in daemon/services/Write.ps1 declare SupportsShouldProcess; 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.

  • 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.

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 = "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

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. Add your row (method, path, cmdlet, capability, Write flag) to the matching page's table, and an example CI can run against the fixture, 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. Cover at minimum:

  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.