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 explicitreturnkeyword. - A service that mutates files takes an
-Applyswitch. Only the file-level write primitives indaemon/services/Write.ps1declareSupportsShouldProcess; 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 = $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.
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 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
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. 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:
- 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.