Bookmark geographical places into shareable groups — Blazor WASM + ASP.NET Minimal API + PostgreSQL
  • C# 95%
  • CSS 3.8%
  • HTML 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Robert Marshall 0fa1e41dd6
All checks were successful
CI / build (push) Successful in 46s
Indent with tabs (#24)
`indent_style = tab` on the root rule, with the exceptions enumerated and each stating its own reason: YAML because a tab is illegal there, Markdown because a leading tab is a code block, JSON because the file is not ours alone to reformat, and SQL because a merged script is immutable under ADR-0021 — so tabs would reach only new scripts and leave the rule disagreeing with every script in the tree.

The build-file `indent_size = 2` rule is deleted rather than updated. Under `indent_style = tab` with no `tab_width` of its own, that line would have made a tab render two columns wide in build files and four everywhere else — the setting would have changed meaning, not merely lost it.

Whitespace only, checked independently rather than asserted. Review verified the string literals through the compiler — the `#US` heap, `Constant` blobs, `CustomAttribute` blobs and `#Strings` across all ten assemblies — which covers attribute arguments, const-field blobs and the Razor-generated C# that a source-level sweep never sees. The only differences are the commit SHA in `AssemblyInformationalVersion`, two `CallerFilePath` literals, and one whitespace-only text node in `MainLayout.razor` between the brand link and the nav: four spaces to one tab, inconsequential because `.page-header` is `display: flex` and nothing sets `white-space: pre*`.

`dotnet format` does not touch the interior of a multi-line raw string literal, so the SQL bodies in `DatabaseSeeder.cs` and three test files keep 8-space indentation — the only spaces left in any `.cs` file. They render as before at `tab_width = 4`. Reindenting them by hand is precisely the operation that can silently alter a literal, so it was not done.

Merged with an admin override: branch protection requires one approving review, which Forgejo will not accept on a self-authored pull request.
2026-08-03 18:08:41 +00:00
.forgejo/workflows Apply schema scripts from a console command, rehearsed in CI (#19) 2026-08-03 12:20:25 +00:00
docs Blazor layout shell and theming foundation (#23) 2026-08-03 17:28:16 +00:00
src Indent with tabs 2026-08-03 18:43:35 +01:00
tests Indent with tabs 2026-08-03 18:43:35 +01:00
.editorconfig Stop the root comment claiming a tab is illegal in JSON and SQL 2026-08-03 19:05:31 +01:00
.env.example Add a Docker Compose Postgres for local development (#7) 2026-08-02 20:16:02 +00:00
.gitignore Define the configuration and secrets strategy (#9) 2026-08-02 21:07:06 +00:00
CLAUDE.md Use Dapper and DbUp instead of EF Core (#14) 2026-08-03 09:38:24 +00:00
CONTRIBUTING.md Apply schema scripts from a console command, rehearsed in CI (#19) 2026-08-03 12:20:25 +00:00
Directory.Build.props Indent with tabs 2026-08-03 18:43:35 +01:00
docker-compose.yml Add a Docker Compose Postgres for local development (#7) 2026-08-02 20:16:02 +00:00
global.json Convert the solution to the .slnx format (#5) 2026-08-02 19:21:09 +00:00
LICENSE Initial commit 2026-08-02 17:13:05 +00:00
PlaceMark.slnx Indent with tabs 2026-08-03 18:43:35 +01:00
README.md Refuse an unreadable charset before routing, not after the exception 2026-08-03 15:24:24 +01:00
user-secrets.example.json Define the configuration and secrets strategy (#9) 2026-08-02 21:07:06 +00:00

PlaceMark

CI

PlaceMark is a web app for bookmarking geographical places — a set of coordinates with a name and description. Places are organised into groups, and groups can be shared with other users, each member holding a role within the group (Owner, Editor or Viewer). Every place belongs to exactly one group; each user gets a personal default group when their account is created.

The app is currently being built from a groomed backlog. Not all of the structure below is populated yet.

Architecture

Three deployables — a Blazor WebAssembly front end, an ASP.NET Core Minimal API, and PostgreSQL — live in a single solution:

Project Kind Responsibility
PlaceMark.Domain Class library Entities and domain rules. References nothing.
PlaceMark.Infrastructure Class library Data access: Dapper over Npgsql, hand-written SQL, snake_case schema.
PlaceMark.Database Console command and library The schema itself: .sql scripts embedded as resources, applied by DbUp either by running the project or by calling it, plus the seed command that fills a development database. References nothing.
PlaceMark.Contracts Class library Request and response DTOs shared by the API and the front end. No project or package references.
PlaceMark.Api ASP.NET Core Minimal API HTTP endpoints, authentication, authorisation.
PlaceMark.WebUI Blazor WebAssembly User interface; maps via Leaflet + OpenStreetMap.

Dependency direction

PlaceMark.Api  →  PlaceMark.Infrastructure  →  PlaceMark.Domain

PlaceMark.Api  →  PlaceMark.Contracts  ←  PlaceMark.WebUI

Project references enforce the direction itself: PlaceMark.Domain and PlaceMark.Contracts have no project references at all, so anything pointing back at them from the layer above closes a cycle and fails the build. PlaceMark.Contracts has no NuGet dependencies either — it is the wire format and nothing else.

That is the only part the build enforces. Three further rules matter just as much, and nothing but review enforces any of them:

  • PlaceMark.Domain and PlaceMark.Contracts never reference each other. Both are leaves, so an edge either way is perfectly acyclic and would compile — the build has nothing to object to. The domain models the problem; contracts model the wire. They change for different reasons and must be free to differ.
  • Infrastructure concerns stay out of the domain. Adding a NuGet package such as Dapper or Npgsql to PlaceMark.Domain compiles perfectly happily.
  • Domain entities are never exposed over the wire. Endpoints accept and return PlaceMark.Contracts types and map to and from entities explicitly. PlaceMark.Api references PlaceMark.Contracts directly and reaches PlaceMark.Domain transitively through PlaceMark.Infrastructure, so both are in scope and returning an entity straight from an endpoint would build without complaint.

PlaceMark.WebUI is a separate deployable and does not reference the server-side projects. It talks to PlaceMark.Api over HTTP through a single typed HTTP client, sharing only PlaceMark.Contracts with it so that both sides compile against the same request and response shapes rather than against two copies that can drift apart.

Adding an API endpoint

Program.cs composes the API; it does not describe it. Every route lives in a feature folder under src/PlaceMark.Api/, holding one static class with one extension method on IEndpointRouteBuilder that opens a MapGroup for the feature's prefix and maps its routes inside it. Health/HealthEndpoints.cs is the worked example — copy its shape:

namespace PlaceMark.Api.Places;

internal static class PlacesEndpoints
{
    public static IEndpointRouteBuilder MapPlacesEndpoints(this IEndpointRouteBuilder endpoints)
    {
        var places = endpoints.MapGroup("/places");

        // Combined with the group's prefix, this is GET /places/{id}.
        places.MapGet("/{id:guid}", async (Guid id, CancellationToken cancellationToken) => );

        return endpoints;
    }
}

Then add one line — app.MapPlacesEndpoints(); — beside the others in Program.cs. The group is the point: a policy, filter or rate limit declared on it is inherited by every route added to the feature afterwards, so it cannot be forgotten. Anything that applies to the API as a whole rather than to one feature belongs in Program.cs, so the feature files stay ignorant of it.

The endpoint returns PlaceMark.Contracts types, never entities — see the dependency rules above.

Validating a request

Rules live on the contract, as System.ComponentModel.DataAnnotations attributes:

public sealed record CreatePlaceRequest(
    [property: Required][property: StringLength(120)] string Name,
    [property: Range(-90d, 90d)] double Latitude,
    [property: Range(-180d, 180d)] double Longitude);

A group whose routes accept a body declares the filter that enforces them, once, and every route added to the group afterwards inherits it:

var places = endpoints.MapGroup("/places").AddEndpointFilter<RequestValidationFilter>();

That is the whole wiring. A request that breaks a rule is refused before the endpoint runs, with the 400 described below carrying an errors member naming each field that failed. Nothing checks that a group remembers the line, so this is one of the rules review has to hold — ADR-0025 explains why it is a group-level convention rather than something the framework applies for us, and what that choice cost.

A rule no attribute can state — one that needs another row, or two fields compared — is checked in the endpoint, which throws ValidationFailedException itself for the same response.

Failing a request

An endpoint that must refuse a request throws, and the pipeline turns the exception into an RFC 9457 Problem Details response. Three exceptions in src/PlaceMark.Api/Errors/ name the status they want:

throw new NotFoundException("No group with that id exists.");   // 404
throw new ValidationFailedException("Latitude must be between -90 and 90.");  // 400
throw new ConflictException("That user is already a member of the group.");   // 409

Their message is sent to the caller, as the detail of the response body — that is what they are for, and it makes the message part of the API's public surface. Never put an internal identifier, a connection string or a database error in one.

ValidationFailedException takes an optional dictionary of field names and messages as well, which the response carries as an errors member alongside the detail. The validation filter above fills it in, keyed by the name each field travels under on the wire — [JsonPropertyName] and all — so a client can match a message to the input it came from. An endpoint refusing a request for a reason that belongs to no particular field leaves the dictionary out. Either way the body is the same shape.

A body the framework cannot bind at all — {"latitude":"nope"} — reaches neither the filter nor the endpoint, and needs nothing written for it: Program.cs sets ThrowOnBadRequest, so it arrives at the same handler and is answered with the same 400. The detail says only that the request could not be read. What went wrong goes to the log instead, and only ever as names: the parameter, and the JSON path that failed to bind. The parser's own message quotes the payload text it choked on, so it is deliberately not logged.

A Content-Type naming a character set that does not exist is refused earlier still, by a middleware before the endpoints, because that one has to be caught on the way in: an endpoint taking no body never looks at the header, so it would otherwise fault on its own and there would be no way to tell that fault from the caller's bad header. ADR-0025 records the measurement.

The refusals still answered with a bare status code and no body are an unsupported Content-Type (415) and a body over Kestrel's size limit (413), because the framework refuses both without throwing — see ADR-0025 and ticket #138.

Every other exception becomes a 500 whose body carries a status, a type, a title and the request's traceId, and nothing drawn from the exception itself. This is the same in Development, and there is no developer exception page — the handler answers first, deliberately, so that one failure shape is exercised everywhere. What the page would have shown is in the console instead: the exception, with its stack trace, logged at Error under the same traceId the response body quotes, so pasting that id into the log finds the record. ADR-0022 records why, what it costs, and why the handler sits immediately inside UseHttpLogging in Program.cs.

Repository layout

PlaceMark.slnx
src/
  PlaceMark.Domain/
  PlaceMark.Infrastructure/
  PlaceMark.Database/
    Scripts/
  PlaceMark.Contracts/
  PlaceMark.Api/
  PlaceMark.WebUI/
tests/
  PlaceMark.Domain.Tests/
  PlaceMark.Infrastructure.Tests/
  PlaceMark.Api.Tests/
  PlaceMark.WebUI.Tests/

Architectural decision records live in docs/adr/. The database schema — tables, columns, constraints and indexes, with the reasoning behind each — is docs/data-model.md.

Naming conventions

  • Projects are named PlaceMark.<Area>, where <Area> is the layer or deployable (Domain, Infrastructure, Contracts, Api, WebUI). The project name, its folder name and its root namespace are always identical.
  • Production projects live under src/; test projects live under tests/.
  • Every production project that contains behaviour has exactly one corresponding test project, named <Project>.Tests — for example PlaceMark.Domain is tested by PlaceMark.Domain.Tests. There are two exceptions. PlaceMark.Contracts declares the wire format and holds no behaviour, so there is nothing to assert about it in isolation; it acquires PlaceMark.Contracts.Tests on the day it acquires logic, and not before. PlaceMark.Database is tested from PlaceMark.Infrastructure.Tests, because what its scripts produce is only observable through a real PostgreSQL — and that project already owns the container fixture, which applies those same scripts for its own tests. A second test project would mean a second fixture and a second container to assert against the same schema.
  • Test classes are named <ClassUnderTest>Tests, and test methods follow <MethodName>_<Scenario>_<ExpectedResult> (for example GetPlace_PlaceNotFound_ReturnsNull).
  • Folders within a project are named for the feature or concern they hold, not the technical pattern — API endpoints are grouped per feature via MapGroup extension methods rather than a monolithic Program.cs.
  • British English spellings are used throughout identifiers and prose (authorisation, initialise, colour). Third-party and framework symbols keep their own spelling.
  • Database objects use snake_case, written that way in the schema scripts and bound to PascalCase members by Dapper's MatchNamesWithUnderscores.
  • Architectural decisions are recorded as ADRs in docs/adr/; see ADR-0001 for what warrants one.

Getting started

Requires the .NET 10 SDK. Building needs nothing else. dotnet test now needs Docker, and so does the database: the integration tests start a throwaway PostgreSQL through Testcontainers, on the same image docker-compose.yml pins. They fail rather than skip when no Docker daemon is reachable — a database test that quietly passes without a database is worth less than no test.

# Restore and build everything
dotnet build

# Run the whole test suite
dotnet test

# Run the API (from the repository root; needs a connection string — see Database, below)
dotnet run --project src/PlaceMark.Api

# Run the front end
dotnet run --project src/PlaceMark.WebUI

Database

PostgreSQL runs locally under Docker Compose v2 — no database needs installing on the host.

docker compose up -d --wait   # start, and block until it accepts connections
docker compose down           # stop, keeping the data
docker compose down -v        # stop and destroy the data

The data lives in the named volume placemark_postgres-data, so it survives docker compose down and reboots; only -v throws it away. Nothing starts the container for you, so run up again after a reboot — deliberate, so that the repository does not hold port 5432 on days you are not using it.

It listens on 127.0.0.1 only, so it is not reachable from other machines. The database, username and password all default to placemark, on port 5432:

Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark;Password=placemark

That string reaches the API through user secrets, and the API now refuses to start without it — so a fresh clone that has not applied user-secrets.example.json fails at start-up with an instruction rather than on the first request that needs a row. Nothing connects at start-up, though: the data source is built lazily and opens a connection when something asks for one.

Nothing wraps a retry policy or a circuit breaker around Npgsql, deliberately. At ADR-0007's one instance and one small database the realistic failure is an outage or a wrong credential, which a retry turns from a fast error into a slow one; the full argument, and what would have to change to reverse it, is in the comment beside the registration in src/PlaceMark.Infrastructure/Database/DatabaseServiceCollectionExtensions.cs.

The schema — tables, columns, constraints, indexes and the reasoning behind each — is designed in docs/data-model.md, created by DbUp from the .sql scripts in src/PlaceMark.Database/Scripts/ (ADR-0021) and queried with Dapper (ADR-0020).

The database above starts empty, so apply the scripts to it once after docker compose upPlaceMark.Database is a command as well as a library, and it is the same command CI and any deployment run (ADR-0023):

# The connection string is the one printed above — change it here too if .env changes the port,
# the password or the database name.
ConnectionStrings__PlaceMark='Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark;Password=placemark' \
  dotnet run --project src/PlaceMark.Database

That variable is the only place this command looks. It reads no appsettings.json and no user secrets, so ADR-0014's layering — which is the API's — does not apply to it, and it is spelt out as a decision in ADR-0023 rather than left as an oversight. The reason is that this command alters a database: a layered lookup would let it run with nothing set and silently upgrade whichever database some other layer named, which is a bad way to find out which one you were pointed at. Unset, it does nothing and says which variable to set.

It prints the full resource name of each script it applied, or says the database is already up to date, and exits non-zero naming the failing script if one fails. Run it again after pulling a change that adds a script; running it when there is nothing to do is a no-op, because DbUp journals what it has applied in the schemaversions table. To open a session:

docker compose exec postgres psql -U placemark -d placemark

Seed data for development

The same project has a second command, which fills a development database with something to look at: three users, a personal group each, one group they share carrying every role and an unanswered invitation, and eight places spread across both hemispheres (ADR-0026).

ConnectionStrings__PlaceMarkSeedTarget='Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark;Password=placemark' \
  dotnet run --project src/PlaceMark.Database -- seed

That is a different variable from the one above, deliberately. Nothing a host or a deployment already sets points this command anywhere, so seeding a database is an act of its own rather than one extra word typed in a shell that still holds production's credential. It is also the guard: the command refuses a database holding any account it did not create, so a database with real users in it is left alone.

Apply the schema first — the seed says so rather than reporting an undefined table if you have not. Running it twice is safe and changes nothing: every identifier is a constant, so a second run inserts nothing and reports that it did. It tops up rather than resets, though, so a seeded row you have edited stays edited, and one you have replaced — a personal group deleted and recreated under a new identifier, say — makes the next run fail on a unique index without writing anything; docker compose down -v is how you start again. Note that the seeded accounts carry no password hash — nothing hashes one yet — so they cannot sign in.

Nothing applies the schema when the API starts, deliberately — a database is upgraded by somebody running that command, not by a process restarting.

To change any of it — a port clash with a Postgres already on 5432, or a password you would rather not run with — copy .env.example to .env and uncomment what you need; the file explains each key. .env is gitignored, and the defaults live in docker-compose.yml, so a fresh clone works without one. Postgres reads the credentials only when it first initialises the volume, so changing them afterwards means docker compose down -v first, which destroys the existing data.

Configuration

appsettings.json and appsettings.Development.json are committed, so they hold only values that are safe to publish. Every secret — the connection string today, signing keys and client secrets when ADR-0002 is built — is supplied from outside the working tree. The reasoning, and what was rejected, is in ADR-0014.

Locally that means user secrets, which live in your profile — ~/.microsoft/usersecrets/ on Linux and macOS, %APPDATA%\Microsoft\UserSecrets\ on Windows — and not in the repository, so they cannot be committed at all. user-secrets.example.json, in the repository root, lists what the API expects, already filled in with the defaults the database above serves. Apply it unedited — do not copy it into the project directory, for the reason in the warning below:

dotnet user-secrets set --project src/PlaceMark.Api < user-secrets.example.json

dotnet user-secrets list --project src/PlaceMark.Api      # what is currently set
dotnet user-secrets remove "ConnectionStrings:PlaceMark" --project src/PlaceMark.Api

PowerShell has no < redirection, so on Windows pipe it instead:

Get-Content user-secrets.example.json | dotnet user-secrets set --project src/PlaceMark.Api

If you changed the credentials or port in .env, set the one value rather than editing the template, so that your real connection string never exists inside the working tree:

dotnet user-secrets set "ConnectionStrings:PlaceMark" "Host=..." --project src/PlaceMark.Api

Where a value comes from

Configuration sources are read in this order, and later sources win:

# Source Use it for
1 appsettings.json values that are the same everywhere and safe to publish
2 appsettings.{Environment}.json values that differ by environment and are safe to publish
3 User secrets — Development only anything sensitive, on your own machine
4 Environment variables anything sensitive, in a deployed environment
5 Command-line arguments overriding something for a single run

So an environment variable beats both files and your user secrets, and a command-line argument beats everything. Nest keys with a double underscore in an environment variable, and with a colon on the command line:

ConnectionStrings__PlaceMark='Host=...' dotnet run --project src/PlaceMark.Api
dotnet run --project src/PlaceMark.Api -- --ConnectionStrings:PlaceMark='Host=...'

Two things about that order are worth knowing before they surprise you:

  • User secrets are only read when the environment is Development. Running locally with ASPNETCORE_ENVIRONMENT=Production does not merely change which appsettings file applies — it drops the entire user secrets store, silently, and every secret reads back as null. That is also what makes it safe to keep a working password in the store: a deployed instance ignores it.
  • The five rows are the sources worth setting a value in, not the whole chain. A debug view also shows PlaceMark.Api.settings[.{Environment}].json probed between rows 2 and 3, which PlaceMark does not use, and the host's own configuration chained in at the very end, which re-supplies the ASPNETCORE_- and DOTNET_-prefixed variables and the command line that started the process. Neither changes where any key above comes from.

Deployed environments get their configuration from environment variables — the one mechanism every host offers, and the reason nothing needs a committed file. A managed secret store that is not exposed as environment variables would need its provider registering explicitly in Program.cs; none is registered today.

Three ways to leak a secret

Nothing in the framework or the build will stop you doing any of these. They are conventions, enforced by review, and a credential that reaches the history is there permanently — it must be treated as compromised and rotated, not merely deleted:

  • Putting a secret in appsettings.json or appsettings.Development.json. It simply works — no warning, no error — and if your user secrets also set the key, your machine keeps using the secret and never shows you that the committed value is being used anywhere else.
  • Creating src/PlaceMark.Api/secrets.json. User secrets belong in your profile, but this is not merely the wrong place for them: on a fresh clone it is a working one. The configuration system loads it, so the application runs, and the file is inside the repository — the .gitignore entry for secrets.json is what stops it being committed. The behaviour disappears as soon as you have run any dotnet user-secrets command, so nobody who has used the tool can reproduce it or will catch it in review; ADR-0014 records exactly when it applies. Apply user-secrets.example.json with the command above rather than copying it anywhere.
  • Putting a secret in PlaceMark.WebUI. The front end is WebAssembly: everything it ships, including anything under wwwroot, is downloaded to the browser and readable by the user. Configuration for the front end is therefore public by definition — an API base URL is fine, a key of any kind never is. Anything that must stay private stays in PlaceMark.Api.

Which browser origins may call the API

PlaceMark.WebUI is served from a different origin to PlaceMark.Api, so every call it makes is cross-origin and the browser makes it only if the API has said that origin may. The allowlist is configuration, under Cors:AllowedOrigins, and there is no wildcard in any environment:

Environment Where the origins come from
Development src/PlaceMark.Api/appsettings.Development.json, already listing both ports the WebUI's launch profiles use
Deployed Cors__AllowedOrigins__0, Cors__AllowedOrigins__1, … as environment variables

An origin is a scheme, a host and a non-default port, and nothing elsehttps://placemark.uk, not https://placemark.uk/. The policy compares it as text, so a trailing slash or a path matches nothing; the API refuses to start on one rather than letting it present as a browser-only failure weeks later. Configuring no origins at all is not an error: it means no browser may call the API, and every other caller is unaffected.

Alongside the origins, the API permits four request headers — Authorization, Content-Type, traceparent and tracestate — and no credentials, since authentication is a bearer token rather than a cookie. ADR-0024 explains each of those, and why the trace context headers matter to the logging below.

Logging

Both applications log through the built-in Microsoft.Extensions.Logging, to the console and nowhere else. There is no file, no log server and no OpenTelemetry collector — the reasoning, and what would justify adding one, is in ADR-0015.

The API logs one record per request, with the method, path, status code and duration:

info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9]
      => SpanId:ec4ced0dfbc0f6a8, TraceId:5f287f0daad95f34ce00346c7ee240d0, ParentId:0000000000000000 => ...
      Request and Response:
      Method: GET
      Path: /places/42
      StatusCode: 404
      Duration: 2.0788

Deployed environments get the same records as one JSON object per line instead, because appsettings.json sets Logging:Console:FormatterName to json and appsettings.Development.json overrides it to simple. That is the whole of the difference, and neither is expressed in code.

TraceId is the correlation id. It comes from the W3C Trace Context traceparent header: send one and the API adopts it, so every record it writes about that request — including the stack trace of anything that fails — carries the caller's id. Nothing else needs to be threaded through, and there is deliberately no X-Correlation-Id.

curl -H 'traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' \
  http://localhost:5017/places/42

The front end does not send one yet, so a click in the browser cannot currently be traced to the request it causes. It is now allowed to: the header is on the API's CORS allowlist, which ADR-0015 named as one of the two things in the way. The other is the typed HTTP client that has still to be written, which is where the header would be attached.

Levels come from the Logging:LogLevel section, so they follow the same precedence as everything else above — an environment variable beats the settings files:

Logging__LogLevel__Default=Debug dotnet run --project src/PlaceMark.Api

Writing a log statement

logger.LogInformation("…") will not compile. CA1848 is an error here (ADR-0013), so every log statement goes through the [LoggerMessage] source generator, which builds the delegate once instead of formatting the message on every call:

using Microsoft.Extensions.Logging;

namespace PlaceMark.Api.Places;

internal static partial class PlaceLog
{
    [LoggerMessage(Level = LogLevel.Information, Message = "Place {PlaceId} added to group {GroupId}")]
    public static partial void PlaceAdded(ILogger logger, Guid placeId, Guid groupId);
}
PlaceLog.PlaceAdded(logger, place.Id, group.Id);

Both the class and the method must be partial, and the method must be static and return void; the generator writes the body. The named placeholders become the record's structured fields, so PlaceId is queryable in the JSON output rather than buried in a sentence — which is the point of the message template, not merely its syntax.

Two things that will cost you an afternoon

  • Microsoft.AspNetCore is set to Warning, which would swallow the request log, since the middleware writes it at Information. appsettings.json therefore names Microsoft.AspNetCore.HttpLogging explicitly. Delete that line and request logging stops, with no error and nothing in the output to say why.
  • Silencing Microsoft.AspNetCore.Hosting silences the correlation id, everywhere, including on the exception records. ASP.NET Core only starts a request Activity when something is listening for it, and that logger being enabled is one of the things it counts — so setting the category to None leaves the request log intact but strips TraceId from every line, even when the caller supplied a traceparent. A test covers it.

And one for the front end: it reads wwwroot/appsettings.json, which is downloaded by the browser. Log levels are all it may ever hold — see ADR-0014. A deployed client defaults to Warning so it stays quiet in a console the user can read; wwwroot/appsettings.Development.json turns it up, and is fetched only when the .NET dev server tells the client it is running in Development.

Continuous integration

Every pull request to main, and every merge into it, runs .forgejo/workflows/ci.yml on a Forgejo Actions runner: restore, build in Release, test, apply the schema scripts to a throwaway PostgreSQL, then verify formatting — five steps in a single job, in that order. Any of them failing fails the run, and a failing run blocks the merge.

The last is the one that catches people out, because it fails on things the compiler is perfectly happy with: layout the formatting rules disagree with, or a file saved with a UTF-8 byte order mark. It is worth running before you push:

dotnet format --verify-no-changes

What the schema step does and does not prove

It starts a PostgreSQL service container, applies the scripts to it through the same command a deployment runs, and then runs that command a second time. The first run must apply at least one script under the journal key prefix PlaceMark.Database.Scripts. — a rename of the project, its root namespace or the Scripts folder re-runs every script against a database that already has them (ADR-0021), and this is the second thing to catch it, after the unit test that pins the exact resource name — and the second run must find nothing pending.

So a script that does not apply cleanly from empty fails on the pull request that introduces it. What the step does not do is deploy: there is no test environment and no production for it to reach, so nothing here proves a script applies to a database that already holds a previous schema and real data. ADR-0023 records why that half of the ticket was left unbuilt rather than written against environments that do not exist.

Why the workflow looks the way it does

It is written entirely out of run: steps, checking the repository out with git by hand and installing the SDK with Microsoft's dotnet-install.sh. That is not a stylistic preference. The runner on this instance is act_runner v6.4.0, which cannot execute Forgejo Actions actions at all — it clones the action, then fails the step with no error message, and the post step reports the action's own dist/index.js missing from the container. Five runs eliminated the node runtime version, the host, the cache directory and the uses: syntax in turn; the header comment in the workflow records what was ruled out, so nobody has to repeat it.

Two costs follow, and neither is small:

  • There is no NuGet cache. This is not a shortcut taken for simplicity — the runner's cache server is reachable only through the actions/cache protocol, so with actions unusable the cache is unreachable by any means. Every run therefore pulls roughly 240 MB of packages, most of it the Blazor WebAssembly runtime packs.
  • The SDK is downloaded and installed on every run, because there is no tool cache in the job container and no setup-dotnet to populate one.

Together these make a run take minutes where it should take seconds. Both are recovered by a single revert — back to actions/checkout, actions/setup-dotnet and actions/cache — the day the runner is upgraded past this defect. Until then the pipeline is correct but slow, which is the right way round.

Contributing

See CONTRIBUTING.md. main is protected: all changes arrive through a reviewed pull request.

Licence

Released under the MIT Licence — see LICENSE.