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 explicitreturnkeyword. - A service that mutates files takes an
-Applyswitch. Only the low-level file-write primitives declareSupportsShouldProcess— those indaemon/services/Write.ps1, plus the repo reindexerImport-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 = $truewhen the route mutates lore or index files, or durable governance and identity state. The flag funnels the request throughAssert-NerthusWriteAllowed, the single write gate. - Every
GETrow declaresPool:$truewhen the handler only reads the published generation and may be answered on a reader thread,$falseto stay on the accept loop.RoutePool.Guard.Tests.ps1fails on an omitted key, and equally on aPoolkey on a non-GET row — the dispatcher checks the method before the row, so the flag there is inert. - A
GETmay also declareReplica = $truewhen its answer is a function of the committed tree alone, and anETagscriptblock 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 awould*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/-Tokenpassthrough pair; bind pipeline input by property name so objects flow (Get-NerthusPlayer 'Eraster' | Get-NerthusPlayerCharacter). - A mutating cmdlet declares
SupportsShouldProcess; by default a declinedShouldProcessappends?dryRun=trueand 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:
- 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.
- 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.