Code style¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
The code-style contract for implementing Nerthus.Core: comment style, naming,
.NET patterns, the compiled C# substrate, error handling, and the project-specific
code shapes (thin-client wrappers, daemon handlers, the dry-run plumbing). This page
covers code — the data grammar (the Polish @tag schema, entity headings,
session headers) is owned by the entity model,
the tag schema, and the session model;
documentation authoring is covered in
Write documentation.
Where these conventions apply¶
| Tree | Role | How much logic |
|---|---|---|
client/*.ps1 |
thin REST wrappers (Verb-Nerthus*) |
minimal — discover + one HTTP call |
daemon/**/*.ps1 |
the server (owns all data) | the bulk; full conventions apply |
src/Nerthus.Core.Substrate/*.cs |
compiled C# (namespace Nerthus) |
performance-critical hot paths |
ci/*.ps1 |
pipeline drivers (settle, close, report, map checkup, the test-repo seeder) | a job's worth — boot a daemon, drive it over HTTP |
vm/*.ps1 |
operator-run provisioning (.env import, edge publish, Discord registration) |
one task per script |
docker/entrypoint.ps1 |
the fleet image's boot script | clone, render the sync config, exec the daemon |
tests/**/*.ps1 |
Pester suites and fixture builders | conventions in Run tests |
All PowerShell targets pwsh 7+. All files are UTF-8 no BOM.
The analyze CI job runs Invoke-ScriptAnalyzer -Path . -Recurse from the repository root and
keeps the records whose severity is Error or ParseError, so the lint gate covers every tree
above — ci/ and vm/ included. A severity filter on the invocation would select which rules
run, by the severity each rule declares for itself, rather than which findings are reported,
and a rule declared Warning emits Error records. The job also names its blind spot: a rule that
throws analysed nothing on the file it threw on, so it prints rules that failed to run: N rather
than passing in silence.
Nerthus.Core.Dashboard is a separate repository with its own TypeScript gates, listed on
the dashboard.
Comment styles¶
Every daemon .ps1 opens with a <# ... #> block containing .SYNOPSIS and
.DESCRIPTION — the file's purpose, its helpers, module-level data, and the design
rationale, before any code:
<#
.SYNOPSIS
Shared daemon plumbing: paths, config, the C# substrate loader, structured
logging, JSON helpers, temporal-value resolution, and atomic writes.
.DESCRIPTION
Every other daemon file builds on this one. ...
Helpers:
- Initialize-NerthusCSharp: Add-Type the prebuilt substrate assembly, once
- Write-NerthusFileUtf8: UTF-8 no BOM, newline-style preserving file write
Module-level data:
- $script:NerthusCSharpLoaded: guard so the substrate is compiled once per process
#>
Client cmdlet files are grouped per subsystem (client/Cmdlets-Entities.ps1)
and open with a brief .SYNOPSIS-only block plus a pointer to the relevant
documentation page.
Every function — exported cmdlet, service, or helper — carries its own minimal
<# .SYNOPSIS #> immediately after the opening brace:
function Get-NerthusEntity {
<# .SYNOPSIS Fetch one entity by name, temporal values resolved as of an optional date. #>
[CmdletBinding()] param( ... )
...
}
Single-line # comments precede logical blocks and explain why, not what.
End-of-line # comments clarify non-obvious values or decisions.
# Hoist all `using` directives above every file body (Add-Type compiles one unit).
$Usings = [System.Collections.Generic.SortedSet[string]]::new()
$HasRange = $M.Groups['from'].Success # the optional "(from:to)" suffix matched
Comment prose. Write for someone reading at hour three. Vary the sentence shape; a
paragraph where every line runs claim-then-dash-then-consequence reads like a metronome. Em
dashes, X, not Y negations, and three-part lists are emphasis, and emphasis is rationed: one
dash a paragraph is plenty, and a contrast earns its place only when the wrong reading is live
in the reader's mind. Prefer plain verbs (is, runs, holds) over serves as or
represents, and drop the intensifier vocabulary — crucial, robust, seamless name
nothing, while a precision word like idempotent or fail-closed stays. Repeating the one
canonical term is correct here; do not cycle synonyms for rhythm (one term per concept, per
Write documentation).
Operational warnings to stderr use a [WARN FunctionName] prefix:
Naming conventions¶
PascalCase for all variables, no exceptions. $script: scope for module-level
data shared across functions in a file/module:
Functions follow Verb-Noun with approved verbs only:
Get Set New Remove Resolve Test Invoke Find Add Open Close Start Stop Initialize Send Compare Connect Grant Revoke Receive
- Public client cmdlets are
Verb-Nerthus<Noun>(e.g.Get-NerthusEntity,Invoke-NerthusPUAssignment); the lifecycle pairStart-Nerthus/Stop-Nerthushas an empty noun. The.psm1loader exports only names matching this surface (see Module manifest & loader). - Daemon-internal functions also carry the
Nerthusinfix (Build-NerthusModel,Set-NerthusEntityTagInFile). A service whose natural name is taken by a client cmdlet appendsService(Resolve-NerthusNameService,Get-NerthusEntityPathService) so the two never collide when both are loaded in one session. HTTP glue functions areHandle-<Name>(Handle-GetEntity) —Handleis a deliberate exemption from the approved-verb gate, which protects only the exported module surface. A handful of pure-local helpers omit the infix (Test-EntitySchema). Nothing daemon-side is ever exported — the daemon is dot-sourced by its boot script, not imported as a module.
.NET over cmdlets¶
Prefer .NET static methods over PowerShell cmdlets for performance and cross-platform consistency:
# File I/O
[System.IO.File]::ReadAllLines($FilePath)
[System.IO.File]::Exists($Path)
[System.IO.Directory]::GetFiles($ClientDir, '_*.ps1')
[System.IO.Path]::Combine($RepoRoot, '.nerthus')
# Strings
[string]::IsNullOrWhiteSpace($Value)
# Collections
[System.Collections.Generic.List[string]]::new()
[System.Collections.Generic.Dictionary[string, hashtable]]::new()
[System.Collections.Generic.HashSet[string]]::new()
# StringBuilder / Regex / Dates
[System.Text.StringBuilder]::new()
[regex]::new($Pattern, 'Compiled')
[datetime]::DaysInMonth($Y, $Mo)
Compiled C# types (src/Nerthus.Core.Substrate/, namespace Nerthus)¶
Performance-critical code is C# and is called directly — the class inventory and
each class's purpose are in Architecture. Sources live
under src/Nerthus.Core.Substrate/ and ship as a prebuilt assembly:
Initialize-NerthusCSharp (daemon/services/Common.ps1) Add-Types
<ModuleRoot>/lib/Nerthus.Core.Substrate.dll, guarded by a PSTypeName probe on
Nerthus.BKTree plus $script:NerthusCSharpLoaded. The probe decides, because
Add-Type loads into the process while $script: state belongs to one runspace.
Nothing is compiled at daemon start, and lib/ is a gitignored build output that a
fresh clone does not have — see Architecture for the
build and the framework constraint. Rules: all C# lives in the substrate project (no
inline Add-Type heredocs); the namespace is Nerthus; Add-Type is a hard
requirement — there is no PowerShell fallback shadowing these types.
One exemption: Nerthus.SignalGate
The SIGTERM handler in daemon/Server.ps1 is compiled inline, guarded by its own
try/catch, and that placement is deliberate. The substrate is one assembly and a hard
requirement, so a type that fails to build takes the whole daemon down with it.
SignalGate is best-effort by design: a runtime that cannot register the handler
still boots, and the stale-port probe recovers what a missed graceful stop leaves behind.
Keeping it out of the substrate is what preserves that degradation. A second inline type
needs the same argument or it belongs in the substrate project.
Small conventions¶
One rule, one line each:
[void]$Tags.Add(@{ Key = 'typ'; Value = $B.type }) # [void] suppresses unwanted output
$Record = [PSCustomObject]@{ Name = $Name; Type = $Type } # structured output
$Server = [ordered]@{ bind = '127.0.0.1'; port = 0 } # [ordered] where key order matters
return $Record # always the explicit return keyword
return @{ StatusCode = 200; Body = $Record } # handlers return a hashtable
String comparison¶
[System.StringComparer]::OrdinalIgnoreCase # dictionary/hashset comparer
[string]::Equals($A, $B, [System.StringComparison]::OrdinalIgnoreCase) # explicit comparison
[string]::Equals($Pattern, $Actual, 'OrdinalIgnoreCase') # string-literal shorthand
$Text.EndsWith($Suffix, [System.StringComparison]::OrdinalIgnoreCase)
Note
Polish text carries diacritics — compare canonical (diacritic) forms. The ASCII→diacritic rule is owned by the tag schema.
Parameter declarations¶
[CmdletBinding()] precedes param() on the same line. Parameters are PascalCase
and typed; Mandatory, ValidateSet, and ValueFromPipelineByPropertyName are used
where they apply. SupportsShouldProcess is declared by every mutating client
cmdlet (it feeds the dry-run plumbing)
and, daemon-side, only by the file-level write primitives in
daemon/services/Write.ps1 and the importer (Import-NerthusRepo, which derives
its dry-run from it) — other services take an -Apply switch instead.
function Set-NerthusEntity {
<# .SYNOPSIS Append/update a (possibly dated) entity tag. #>
[CmdletBinding(SupportsShouldProcess)] param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)] [string]$Name,
[Parameter(Mandatory)] [string]$Tag, [Parameter(Mandatory)] [string]$Value,
[string]$Daemon, [string]$Token
)
...
}
Every client cmdlet carries the -Daemon/-Token passthrough pair (convention
specified in the API reference).
Error handling¶
throw for fatal/unrecoverable errors; try/catch for non-fatal per-item failures;
[System.Console]::Error.WriteLine() for warnings that must not interrupt execution.
throw "Entity '$Name' not found in index"
try { Sync-NerthusModel -Daemon $Daemon }
catch { [System.Console]::Error.WriteLine("[WARN Sync-NerthusModel] $_") }
Fail-early validation throws a structured ErrorRecord whose TargetObject is
the future HTTP error body. The exemplar is the monthly PU batch (semantics in the
PU model) — any unresolved character name aborts the
whole run with no partial writes:
$ErrObj = [PSCustomObject]@{ error = 'PUUnresolvedCharacters'; month = $Month
unresolved = @($Unresolved); applied = $false }
throw [System.Management.Automation.ErrorRecord]::new(
[System.InvalidOperationException]::new("Unresolved character name(s) in PU for $Month"),
'PUUnresolvedCharacters', [System.Management.Automation.ErrorCategory]::InvalidData, $ErrObj)
The dispatcher recognizes the error id and serializes the TargetObject verbatim
as the response body. The error ids, their status mapping, and the wire shapes
are an API contract, owned by the API reference — never invent an
ad-hoc error body in a handler.
Precompiled regex¶
Patterns used across calls are compiled and stored at $script: scope, or as a local
before a loop — e.g. the importer's legacy map-record matcher:
$LineRe = [regex]::new('^\s*-\s*Id:\s*(?<id>\d+)\s*;\s*Nazwa:\s*(?<name>.+?)\s*,\s*Url:\s*(?<url>https?://\S+)\s*$', 'Compiled')
Project-specific patterns¶
Thin-client wrapper¶
A client cmdlet does one thing: make one HTTP call via Invoke-NerthusApi
(the thin-client rule is owned by Architecture).
Path segments built from user input are always URL-escaped — Polish entity names
contain spaces and diacritics (Gildia Teologów):
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
}
}
The single write gate¶
Every route the closed table marks Write = $true funnels through
Assert-NerthusWriteAllowed before its handler runs — never write around it
(gate and exemptions specified in the API reference).
Daemon handler shape¶
A handler maps a request $Context to @{ StatusCode; Body } and contains no domain
logic — it calls a services/ function:
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 }
}
Dry-run plumbing (-WhatIf → ?dryRun → -Apply)¶
The client owns the -WhatIf → ?dryRun translation. By default a mutating
cmdlet does not abort on a declined ShouldProcess — it appends ?dryRun=true
and still makes the call, so the daemon computes the real preview. (The cmdlets
that instead short-circuit locally under -WhatIf are enumerated in the
API reference.)
$Path = "/entities/$([System.Uri]::EscapeDataString($Name))"
if (-not $PSCmdlet.ShouldProcess($Name, "Set @$Tag")) { $Path += '?dryRun=true' }
return Invoke-NerthusApi -Method PATCH -Path $Path -Body @{ tag = $Tag; value = $Value } -Daemon $Daemon -Token $Token
The daemon never sees -WhatIf. The dispatcher parses the query into
$Context.DryRun; a handler either short-circuits with a would* preview body:
if ($Context.DryRun) { return @{ StatusCode = 200; Body = @{ wouldSet = $Name; tag = $Key; value = $B.value } } }
or forwards the flag as a service -Apply switch — -Apply:(-not $Context.DryRun) —
and the service returns Applied = $false plus a Preview instead of writing. The
file-level write primitives in daemon/services/Write.ps1 and Import-NerthusRepo
are the only daemon code declaring SupportsShouldProcess; handlers invoke them
with -Confirm:$false. Which
routes honor ?dryRun is specified in the API reference.
Logging¶
The daemon writes structured JSONL via Write-NerthusLog to
.nerthus/log/{operational,request,audit}.jsonl (streams and retention owned by
the logging model); the audit stream is the durable,
never-rotated source of truth. Stderr [WARN FunctionName] lines are for
boot-time and non-fatal in-flight warnings only — they are not a log stream.
Module manifest & loader¶
The manifest (Nerthus.Core.psd1) uses PowerShell data syntax with inline #
field comments. The loader (Nerthus.Core.psm1) carries the same file-level
<# .SYNOPSIS .DESCRIPTION #> block. It dot-sources the underscore-prefixed
client/_*.ps1 shared helpers first (never exported), then every other
client/*.ps1 (cmdlets grouped per subsystem). It exports by function name:
only names matching the approved-verb Verb-Nerthus\w* pattern, empty noun
allowed for Start-Nerthus/Stop-Nerthus — see $VerbNounPattern in
Nerthus.Core.psm1.
Daemon .ps1 files are dot-sourced by the daemon boot (daemon/Start-NerthusDaemon.ps1),
not at module import time.
Git commit messages¶
One logical change per commit. The subject is one line:
The whole line stays within 72 characters, scope optional. A body is allowed and
short: one paragraph, at most ten lines, saying what the change does and why as of
now (a measured number, the defect a test found, the ruling applied), never history
narration. Machine trailers (Task:, Co-Authored-By:) follow after a blank line.
The summary is lowercase, imperative, no trailing period; Polish domain nouns are
fine. The type set is closed:
| Type | Use for |
|---|---|
feat |
new behavior (route, cmdlet, service) |
fix |
bug fix |
docs |
documentation, README, comments only |
test |
tests/fixtures only |
refactor |
no behavior change |
chore |
manifest, layout, housekeeping |
ci |
pipeline config |
merge |
a local --no-ff integration |
feat: token sweep at boot
fix: sse replay bound
docs: commit syntax
test: contract layer
chore: initial import
fix(tests): re-pin the two moved ci sites
The limit was 30 characters until 2026-08-31
It was retired because the tree did not obey it: 26 of the dashboard's last 300 subjects fit,
and 169 subjects across three repositories carried a scope this section forbade. A rule obeyed
by a twelfth of commits misleads whoever trusts it. The
regime owns the ruling; the commit-grammar gate reads a merge
request's own commits and never history, so nothing before that date is asked to comply.
Forbidden in code & comments¶
- No implementation-plan identifiers — name the contract or behavior, not the plan that introduced it.
- No history narration — a comment says what the code does and why, as of now, never what it used to do, what bug was fixed, or what the discarded alternative was beyond one clause of current-state rationale. "The next sweep is scheduled from sweep start" — yes; "previously this counted from completion, which starved the cadence" — no. Git history owns the second sentence.
- No plugin/agnostic machinery — Nerthus is the one and only system (non-goals in Architecture).
- No client-side domain logic — the client never parses Markdown or computes PU.
- No hardcoded secrets — tokens live in gitignored
.nerthus/state, the Discord bot token included (host-local.nerthus/local/discord.json, or a masked CI var). The committed index carries only Discord channel names (@discord) — lore, not secrets. Config stays gitignored, never in source (policy in Configuration).
See also¶
- Architecture — the daemon/client spine, the C# substrate, hooks
- API reference — routes, envelopes, middleware,
-WhatIf/?dryRun, capabilities - Tag schema · Session model — the data grammar
- Run tests — Pester conventions and fixtures
- Write documentation — documentation-authoring rules