Bookmark geographical places into shareable groups — Blazor WASM + ASP.NET Minimal API + PostgreSQL
  • C# 92.7%
  • HTML 2.5%
  • CSS 2.2%
  • JavaScript 1.9%
  • Dockerfile 0.4%
  • Other 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Robert Marshall 426a7e9175
All checks were successful
CI / build (push) Successful in 3m18s
CI / container-images (push) Successful in 3m15s
CI / e2e (push) Successful in 5m14s
Migrate the remaining inline confirmations to ConfirmationModal (#213)
Task 221. One visual language for "are you sure?" rather than two — presentation consolidation,
not a redesign, with every site keeping its own wording, destructive styling,
disabled-while-in-flight states, focus behaviour and Escape semantics.

PlaceFormPanel's delete, GroupFormPanel's delete and PendingInvitationRow's decline all move to
the shared ConfirmationModal. GroupFormPanel's cascading place-count wording is preserved
deliberately: group deletion cascades, so that text is load-bearing rather than decoration.

ConfirmationModal grew a Busy parameter, disabling Confirm and Cancel while a mutation is in
flight. ADR-0136's own Alternatives anticipated and deferred exactly that, which review confirmed
by reading the record rather than taking it on trust, so the shared component grew instead of a
second variant being forked and no new ADR was needed.

The decline site was structurally the hardest and not a markup swap. Nesting a confirmation
inside a row that itself goes inert would have inertified the confirmation — precisely the bug
ADR-0136's SuppressedByStackedOverlay episode already found in a real browser while every bUnit
assertion passed. The confirmation and its state moved up to Account, which is now also the first
ConfirmationModal stacked over ordinary page content rather than another ModalOverlay-hosted
panel, so Account's own page content carries a new inert binding.

Four sites are excluded, each with its reason recorded in code rather than left unremarked.
PlaceForm's move confirmation stays a real form submit, because a button in a separate stacked
modal sits outside the form and reopens the second-entry-point problem ADR-0066 forbids — the
exception the ticket itself predicted. GroupMemberRow stays inline because ADR-0065 chose that
deliberately for the list-scoped case and ADR-0136 never named it among this ticket's sites;
converting it would have silently reopened a decision nobody revisited. GroupMembersPanel has no
confirm markup of its own. And Account deletion has no UI at all — only an API client method —
so the ticket's premise that it was a site to migrate was simply wrong.

Each migrated site was watched to fail on its own, as the ticket required rather than as a
representative sample: wiring the destructive action to fire without confirmation reddened
PlaceFormPanel's permanence-naming test on an unexpected DELETE, GroupFormPanel's place-count
test the same way, and Account's cancel test by making the confirmation vanish before Cancel
could be clicked.

DeclineInvitationJourneyTests is added as the one genuinely new browser-only path, since Account
is the first non-panel ConfirmationModal host and bUnit is blind to inert. It caught a real
locator bug on its first run. GroupFormPanel's delete still has no E2E coverage — a pre-existing
gap, named rather than deepened, and worth a follow-up given stacking bugs here are invisible
below a real browser.

Review found no defects, checking behaviour preservation, the Account inert stacking, Busy, and
all four exclusions against the source and the cited ADRs. It could not re-derive a per-site
mutation itself — the sandbox blocks file edits in the review worktree — and said so plainly
rather than implying otherwise.

Run #892 at d755c66: build, e2e and container-images all green, tied to the SHA through the run's
own logs, with the E2E suite at 63/63 in 3m14s. Coverage held from a Release build with the
merged Cobertura report, WebUI reading 91.7% against its 91.6% baseline.

Merged with an admin override: branch protection requires one approving review, which Forgejo will not accept on a self-authored pull request.
2026-08-21 15:30:07 +00:00
.config Enforce a per-assembly coverage ratchet in CI 2026-08-06 18:15:49 +01:00
.forgejo/workflows Correct the e2e contention diagnosis for task 260 (#205) 2026-08-21 08:35:35 +00:00
docs Measure the drawer's inert window in a real browser (task 197) 2026-08-21 15:52:28 +01:00
scripts Refuse rather than warn when the ratchet can't tell the configuration 2026-08-21 07:23:58 +01:00
src Migrate the remaining inline confirmations to ConfirmationModal 2026-08-21 16:18:51 +01:00
tests Migrate the remaining inline confirmations to ConfirmationModal 2026-08-21 16:18:51 +01:00
.dockerignore Provision and apply the schema from Compose, ahead of the API 2026-08-13 09:49:42 +01:00
.editorconfig Resolve the joint image's API address against the page, not a baked path 2026-08-16 20:35:36 +01:00
.env.example Make OIDC configuration optional, off when the section is absent 2026-08-16 18:22:20 +01:00
.gitignore Enforce a per-assembly coverage ratchet in CI 2026-08-06 18:15:49 +01:00
CLAUDE.md Stop explaining the two-part naming exception, just name it 2026-08-06 17:08:02 +01:00
CONTRIBUTING.md Refuse a Debug-measured coverage report in the ratchet 2026-08-21 06:59:04 +01:00
coverage-baseline.json Await the dismiss that raced an import in flight, and raise the floor 2026-08-20 14:16:10 +01:00
Directory.Build.props Indent with tabs 2026-08-03 18:43:35 +01:00
docker-compose.yml Make OIDC configuration optional, off when the section is absent 2026-08-16 18:22:20 +01:00
global.json Disable SDK roll-forward so a build uses the pinned version 2026-08-04 09:33:05 +01:00
LICENSE Initial commit 2026-08-02 17:13:05 +00:00
logo.png Update imagery 2026-08-17 06:51:04 +00:00
PlaceMark.slnx Build the register and sign-in E2E journey, on a schedule 2026-08-10 09:40:08 +01:00
README.md Make OIDC configuration optional, off when the section is absent 2026-08-16 18:22:20 +01:00
user-secrets.example.json Make OIDC configuration optional, off when the section is absent 2026-08-16 18:22:20 +01: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 provision command that creates the database roles and 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.

Runtime topology

A different question from the dependency diagram below, which is about what references what in the solution — this is about what talks to what once the three deployables are actually running, and it is the same shape whether that is dotnet run on the host or the full Compose profile's containers (ADR-0106):

┌────────────────────────┐
│        Browser         │
└────────────┬───────────┘
             │  1. GET /  — static files: HTML, JS, the .wasm runtime, wwwroot/appsettings.json
             ▼
┌────────────────────────┐
│    PlaceMark.WebUI     │
└────────────────────────┘

┌────────────────────────┐
│        Browser         │
└────────────┬───────────┘
             │  2. Every call the running app makes from here on, including auth — straight
             │     from the browser, cross-origin, gated by the API's own CORS allowlist
             ▼
┌────────────────────────┐
│     PlaceMark.Api      │
└────────────┬───────────┘
             │  3. Dapper over Npgsql
             ▼
┌────────────────────────┐
│       PostgreSQL       │
└────────────────────────┘

PlaceMark.WebUI is a static file server — the Blazor dev server locally, nginx in the full Compose profile — that hands the browser a Blazor WebAssembly app in step 1 and then takes no further part: there is no server-side process relaying anything to the API on the browser's behalf, which is why step 2 is a second, separate connection the browser makes for itself. The browser is the one caller of PlaceMark.Api. That is exactly why the API's CORS allowlist has to name the WebUI's own origin rather than trusting a same-process caller, and why the WebUI's own Api:BaseAddress has to be an address the browser can reach rather than a Docker-network hostname the two containers share.

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.

That is the only part the compiler enforces. Five further rules matter just as much and are invisible to it — an edge between two leaves is acyclic whichever way it points, no package reference is ever a build error, and returning an entity from an endpoint compiles without a warning. They are enforced instead by tests/PlaceMark.Architecture.Tests. Four of the five are read from the .csproj files themselves rather than the compiled assemblies, for the reason ADR-0030 measures: a reference nothing has used yet is dropped from the assembly it was compiled into, and a package reference is not in one at all. The fifth needed a different mechanism — see below.

  • 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.
  • PlaceMark.WebUI reaches PlaceMark.Contracts and nothing else, transitively as well as directly. Everything it references is compiled into the payload a browser downloads, so WebUI → PlaceMark.Infrastructure — which builds green — puts Npgsql there.
  • Infrastructure concerns stay out of the domain. Adding a NuGet package such as Dapper or Npgsql to PlaceMark.Domain compiles perfectly happily, and nothing in a package's name marks it as infrastructure, so the enforced rule is that the project depends on nothing at all — a package is only the usual way in. A FrameworkReference puts the whole of ASP.NET Core in a class library without one, and so does building it with Microsoft.NET.Sdk.Web, so both leaves are pinned to the bare Microsoft.NET.Sdk and may not import a build file of their own. For the same reason the repository holds exactly one MSBuild build file — Directory.Build.props at the root, which reaches every project and so may declare nothing any of them could not — and a second .props, .targets, .rsp or .user anywhere in the tree fails the same test. What counts as being in the repository is git's answer, so a file your IDE writes and .gitignore excludes is not your problem; a committed one is.
  • PlaceMark.Contracts depends on nothing either, for the same reason stated the other way up: what is added there is imposed on the server and on the browser payload at once.

The fifth rule needed a different mechanism, because it is a question about compiled type signatures rather than about the project graph, and reading a .csproj cannot answer it:

  • 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. EndpointHandlers finds every mapped handler by parsing PlaceMark.Api with Roslyn (Microsoft.CodeAnalysis.CSharp) for which method a Map(Get|Post|Put|Delete|Patch) call names, then reflects on that method's compiled signature — the one place in this project a ProjectReference to PlaceMark.Api, and a dependency beyond the test framework and Shouldly, exist at all, both confined to this rule (ADR-0070, which also records why an earlier, hand-rolled version of this reader was replaced). It is not complete: an IResult-typed handler, an object or dynamic return, and an anonymous type carry nothing for reflection to unwrap, so a violation behind any of those shapes would pass — named in full in EntitiesOffTheWireTests's own remarks and in ADR-0070's Consequences.

PlaceMark.WebUI is a separate deployable: 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.

A sixth check, not one of ADR-0009's five, guards a narrower thing: which assemblies a project grants InternalsVisibleTo. PlaceMark.Api grants PlaceMark.Api.Tests and DynamicProxyGenAssembly2 (NSubstitute proxying an internal interface, NS2003) and PlaceMark.WebUI grants PlaceMark.WebUI.Tests; InternalsVisibleToTests pins both sets exactly and asserts every other project in the solution grants none, so widening a grant or adding one elsewhere fails until the change is deliberate. Unlike every check above, this one reads each project's compiled assembly manifest rather than its .csproj — the item form, a hand-written [assembly: InternalsVisibleTo(...)] attribute, an AssemblyAttribute item, and a grant inherited from Directory.Build.props all compile to the same manifest entry, and a .csproj-only reading missed two of those four in review. ADR-0074 records the choice, and why it is not a contradiction of ADR-0030's own "read the project files" rule: that rule's objection was that an unused ProjectReference never reaches the compiled output, so the manifest under-reports the project graph; an InternalsVisibleTo grant always reaches the manifest, because it has no effect otherwise, so for this one question the manifest is the complete account and the .csproj is the partial one.

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").WithTags("Places");

        // Combined with the group's prefix, this is GET /places/{id}.
        places.MapGet("/{id:guid}", GetPlace);

        return endpoints;
    }

    /// <summary>Returns the place with the given id.</summary>
    /// <param name="id">The place's id.</param>
    /// <response code="200">The place.</response>
    /// <response code="404">No place has that id, or it is in a group you cannot see.</response>
    internal static async Task<Ok<PlaceResponse>> GetPlace(Guid id, CancellationToken cancellationToken) => 
}

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.

Documenting an endpoint

The API describes itself at /openapi/v1.json, with Swagger UI over it at /swagger. Both are served in every environment except Production, where neither is registered and both addresses 404. Nothing has to be added to Program.cs for a new endpoint to appear in either.

The prose comes from XML doc comments, read at build time and published as written. Four rules follow from that, and three of them fail silently when broken — ADR-0029 has the measurements:

  • A handler is a named method, at least internal. A lambda has nowhere to put a doc comment, and a private handler is skipped by the generator without a diagnostic — the operation is published with no summary and nothing says so.
  • <summary> becomes the operation summary; <response code="404"> becomes that status code's description. Both are what a reader of the UI sees against the endpoint.
  • <remarks> becomes the operation's description, so it is read by every caller of the API. A note addressed to the next person editing the file goes in an ordinary // comment, outside the doc comment.
  • The group declares a tag.WithTags("Places") — which is how the document groups a feature's operations. Left off, the document names the class the handler happens to sit in.

Request and response types carry their own doc comments in PlaceMark.Contracts, which become the schema descriptions. That project generates a documentation file, so an undocumented public member is a build error: every type on the wire is described.

The UI's Authorize button holds a bearer token, which it sends as Authorization: Bearer <token> on every request made from the page. The document declares the scheme, and an endpoint that calls .RequireAuthorization() gets the matching security requirement on its own operation automatically — nothing to add beside the call itself. See ADR-0034.

An endpoint that also calls .RequireGroupCapability(...) or .RequireGroupCapabilityForPlace(...) (ADR-0043) gets a sentence stating which group role it needs appended to its own description automatically, the same way — nothing to add beside that call either. The bearer requirement above only ever says a token is needed; this is the part of an endpoint's authentication requirements that comes from an endpoint filter rather than IAuthorizeData, so it needed its own transformer (GroupCapabilityDescriptionTransformer) to reach the document at all.

A snapshot of the document is also committed at docs/openapi/v1.json, regenerated by hand, for anyone who cannot reach a Development-environment instance — which includes the full local stack's own API container (docker compose --profile full up), since neither it nor a real deployment sets ASPNETCORE_ENVIRONMENT, and the framework's own default is Production. That is deliberate, not accidental: the deployed API stays closed in every environment ADR-0111 names, and the committed snapshot is the supported answer for a reader who cannot reach a Development-environment host. OpenApiSnapshotTests.CommittedSnapshot_MatchesTheLiveDocument (PlaceMark.Api.Tests) checks the two agree on every run — see ADR-0108 for why the file exists and ADR-0111 for the check.

Requiring authentication

An endpoint that must be signed in calls .RequireAuthorization(), on its route or on its group — the framework's default policy, "authenticated, nothing further":

var places = endpoints.MapGroup("/places").RequireAuthorization();

A caller with no token, an expired one, one signed with the wrong key, or one that fails validation for any other reason is refused before the endpoint runs, with the same RFC 9457 Problem Details body every other refusal carries — 401, a fixed detail that never says which defect the token had, and, unusually for this API, a WWW-Authenticate: Bearer header, which RFC 7235 requires of a 401 and which none of this API's other refusals had reason to add.

The token carries only who the caller is — their id, read in a handler with User.FindFirst("sub") — never a role or a group membership (ADR-0032). An endpoint that needs to know what the caller may do resolves that from group_memberships, through a named authorisation policy ADR-0003 centralises. No such policy exists yet — building the first one is task 73 onwards'. See ADR-0034 for the rest of what was decided validating a token: why a rejected one is answered this way rather than through ApiExceptionHandler, and why the OpenAPI requirement above needs no separate declaration.

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/
  PlaceMark.Architecture.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.
  • PlaceMark.Architecture.Tests is the exception in the other direction: a test project with no production project of its own, and the only one that references nothing. Its subject is the solution's shape rather than any assembly's behaviour, and it reads the .csproj files to judge it (ADR-0030). Referencing the projects it judges would add edges to the graph under test and would show it nothing extra.
  • Test naming, and which project a test belongs in, are covered in CLAUDE.md's ## Tests section rather than repeated here.
  • 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 exact .NET SDK version named in global.json, not merely "a .NET 10 SDK". rollForward is disable (ADR-0036): a newer SDK already on PATH does not satisfy the pin, deliberately, because CI installs the pinned version fresh on every run and the two disagreeing on an analyser rule is exactly the bug that made this required. Install it and put it ahead of any other dotnet on PATH:

curl -fsSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh
bash dotnet-install.sh --jsonfile global.json           # installs to ~/.dotnet by default
export PATH="$HOME/.dotnet:$PATH"                       # put this in your shell profile too

That last line matters on any machine with a distribution-packaged dotnet already on PATH — Linux Mint's /usr/bin/dotnet among them — because the SDK a build actually uses is whichever dotnet resolves first, not merely whichever versions happen to be installed somewhere. Skipping either step doesn't build against the wrong SDK: dotnet refuses to run at all, naming the version global.json asked for, the file it read it from, and every SDK it did find, for example:

A compatible .NET SDK was not found.

Requested SDK version: 10.0.100
global.json file: /path/to/PlaceMark/global.json

Installed SDKs:
10.0.110 [/usr/lib/dotnet/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).
# --launch-profile https matters: the front end's own committed dev config expects the API on
# https://localhost:7117, and the bare default profile only serves http://localhost:5017, which
# leaves every request from the front end failing with a network error. Both ports are served
# either way — http://localhost:5017/swagger works the same regardless — so this changes nothing
# about how you reach the API directly.
dotnet run --project src/PlaceMark.Api --launch-profile https

# Run the front end. Browse it at http://localhost:5169 — dotnet run with no profile argument
# picks the http one.
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 — and that account is the cluster's superuser, which nothing but provisioning ever connects as:

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

Provision the roles

Do this once, on a freshly created database, before applying the schema. It creates the three login roles everything else connects as, each holding only what its own job needs (ADR-0031):

Role Used by Holds
placemark_app the API SELECT, INSERT, UPDATE, DELETE on the tables — no CREATE, no ownership
placemark_upgrade the schema upgrade command the right to create and own everything in the database
placemark_seed the development seed command SELECT, INSERT and MAINTAIN — enough to take the seed guard's lock, not enough to change or remove a row (ADR-0031 records what MAINTAIN does reach)
ConnectionStrings__PlaceMarkProvisioningTarget='Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark;Password=placemark' \
PLACEMARK_APP_ROLE_PASSWORD='placemark_app' \
PLACEMARK_UPGRADE_ROLE_PASSWORD='placemark_upgrade' \
PLACEMARK_SEED_ROLE_PASSWORD='placemark_seed' \
  dotnet run --project src/PlaceMark.Database -- provision

The application password above is the one user-secrets.example.json carries, and the other two are the ones the commands below use. All three are as public as the Compose credentials and are only useful against a database bound to 127.0.0.1 on your own machine. Choose your own if you would rather, and change the connection strings that use them to match.

PLACEMARK_SEED_ROLE_PASSWORD is optional, and leaving it out is how a production provisioning creates no seed role at all — and how any provisioning drops one the database already had, which the command says out loud. Absence means the same thing on every run rather than on the first only. The connection string variable is a third one, distinct from both the schema command's and the seed's, for the reason those two differ from each other: this credential can create roles, so nothing already pointed at a database can reach it.

Passwords must be printable ASCII, and are refused before anything is opened otherwise: the command sends PostgreSQL a SCRAM verifier rather than the password itself, so that a failed run cannot leave a password in the server log, and the normalisation the server would apply to anything outside that range is not reproduced here.

Running it again is a password rotation rather than a failure. It does not create the database — Compose does that, as a deployment's own provisioning would. Pointed at a database from before this existed it will grant the application role its four privileges on the tables that are already there, but not on the ones a later script adds, and the journal stays in public where the application can reach it — so such a database is still best thrown away with docker compose down -v.

The API's connection string

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

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 up and after provisioning the roles — PlaceMark.Database is a command as well as a library, and it is the same command CI and any deployment run (ADR-0023):

# As placemark_upgrade: the only role that may create anything, and a credential the API never
# holds. Change the port, password or database name here too if .env changes them.
ConnectionStrings__PlaceMark='Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark_upgrade;Password=placemark_upgrade' \
  dotnet run --project src/PlaceMark.Database

Against the local Compose database with its default credentials, the upgrade launch profile sets that same variable for you, so the command above shortens to:

dotnet run --project src/PlaceMark.Database --launch-profile upgrade

Only when named. dotnet run auto-selects the first profile and applies its variables over whatever the caller already set, so the first profile in that file is deliberately empty: the bare command still reads the caller's own environment and nothing else, exactly as it did before the file existed. A single profile setting the connection string would have silently redirected every bare invocation — including this repository's own CI step, which sets the variable explicitly — and did so when this file first shipped.

The profile sets a real environment variable rather than adding a configuration source, so the rule below still holds exactly. It cannot see .env: override POSTGRES_PORT, POSTGRES_DB or PLACEMARK_UPGRADE_ROLE_PASSWORD there and the profile needs the same change, or use the explicit command above instead.

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 schema_history.schemaversions — a schema of its own, which the application role cannot reach at all, because a credential able to delete a journal row can make the next upgrade re-run every script (ADR-0031). To open a session as the superuser:

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_seed;Password=placemark_seed' \
  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.

It runs as placemark_seed, which can read and insert and little else: a seed pointed at the wrong database cannot edit or remove a row there, and there is no such role in production at all. It does hold MAINTAIN, which is what lets it take the lock the guard above is decided under — without that one grant the guard would fail rather than refuse (ADR-0031).

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.

Full local stack

docker compose up above starts only Postgres, exactly as it always has — API and WebUI containers run only if you ask for the full profile (ADR-0106):

docker compose --profile full up -d --build --wait

This is for exercising the whole application through a real browser without running either project from source — the same containers task 108 built, wired together. It is not what dotnet test uses (Testcontainers starts its own throwaway Postgres) and it is not required for day-to-day development, where dotnet run against the plain Postgres-only stack above is faster to iterate on.

Provisioning and the schema are both applied automatically here, unlike the plain Postgres workflow above (task 205): two more full-profile services, provision then schema-upgrade, run ahead of api and gate it — docker compose --profile full up --wait genuinely does not return until both have finished, and a failed one stops api starting at all rather than leaving it to fail every request. Nothing to run by hand, and a fresh volume works from a clean clone with no .env. Re-running the command above is a no-op: provision resets the roles' passwords rather than failing on a role that already exists, and schema-upgrade reports nothing pending.

No seed role is created by default, even here — the same default provision always has (above), and CI's own provisioning step shares: supplying no PLACEMARK_SEED_ROLE_PASSWORD creates none. Set it in .env if you also use the seed command against this same Postgres and want the role to survive this running again — leaving it unset does not skip creating the role, it drops one that already exists, on every run (ProvisionCommand's own documented behaviour). The seed command itself still has to be run by hand, exactly as it does against the plain Postgres workflow — nothing here writes seed data.

Then browse to http://localhost:8080 — not http://localhost:5169, the dotnet run dev port. /health/ready on the API container (http://localhost:8081/health/ready) answers 200 as soon as the container is up: the schema is already applied by the time it starts.

Two things about this that are easy to get wrong, and are exactly why ADR-0106 exists:

  • The WebUI's own API address has to be one your browser can reach, not a Docker-network hostname. Api__BaseAddress is fetched by the browser, not resolved inside the Compose network (ADR-0104), so docker-compose.yml sets it to the API's own published host port (http://localhost:8081 by default) — never http://api:8080, which resolves only between containers and would fail in the browser with a network error.
  • The API's CORS allowlist has to name the WebUI's own published origin. Cors__AllowedOrigins__0 is computed from the same WEBUI_PORT the WebUI container is published on, so overriding that port cannot silently reopen the gap. This is the same class of failure Rob hit once already from a related port mismatch between dotnet run's own two projects (ADR-0104's own Context) — worth knowing before assuming a failed request here is a CORS bug in the API itself.

Override API_PORT/WEBUI_PORT and the rest of this profile's own configuration in .env, the same file and mechanism the plain Postgres workflow already uses — .env.example lists every key.

docker compose --profile full down       # stop, keeping postgres-data
docker compose --profile full down -v    # stop and destroy it

Configuration

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

The JWT signing key (Jwt:SigningKey) has no default anywhere, ever — not even a generated one — so the API refuses to start without it. It must be base64 and decode to at least 32 bytes; generate a real one the same way user-secrets.example.json's throwaway development key was generated:

openssl rand -base64 32

See ADR-0032 for why a shorter or merely base64-shaped value is refused, and what that check can and cannot actually verify.

External sign-in (Oidc:Authority, Oidc:ClientId, Oidc:ClientSecret, Oidc:RedirectUri, Oidc:WebUiRedirectUri) is optional to configure (ADR-0154): with the whole Oidc section absent — no key at all under it — the API starts, local accounts (register/login) work exactly as normal, and the OIDC endpoints simply are not mapped. user-secrets.example.json omits the section entirely, so a fresh clone starts with external sign-in off, no placeholder values to invent or remove. The moment the section carries even one key, though, it is judged a deliberate attempt to configure the flow, and every value is validated exactly as before: a missing value, or an authority, redirect URI or WebUI redirect URI that is not a well-formed absolute http/https URL, stops the API before it serves a request — see ADR-0032's flow and ADR-0057 for the one part of it left to this ticket. Configure all five together to point this at a real provider — no provider is named anywhere in code.

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.

# -k: the dev cert is self-signed. Plain http://localhost:5017 works too, but 307-redirects to
# this address when the API is running its https profile (see Getting started, above), which
# hides the response behind a redirect unless curl is also told to follow it.
curl -k -H 'traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' \
  https://localhost:7117/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, provision the roles on a throwaway PostgreSQL, apply the schema scripts to it as the schema-upgrade role, then verify formatting — six steps in a single job, in that order. Any of them failing fails the run.

Branch protection on main names CI / * as a required status check, so an ordinary merge cannot land while the run is red. No merge this repository actually makes is ordinary, though: CLAUDE.md's own git workflow section documents that main also requires one approving review Forgejo will not accept on a self-authored pull request, so every merge here goes through the admin override (merge_pull_request with force_merge: true) — documented as bypassing a failing check too, not only the missing approval. What actually keeps a red run off main in practice is the reviewed-PR loop's own discipline, applied before the override is ever reached for real — nothing in this repository's own merge process runs through branch protection un-overridden.

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, creates the three roles on it with the provision command, applies the scripts as placemark_upgrade through the same command a deployment runs, and then runs that command a second time. Running it as the least-privilege role rather than as the container's superuser is deliberate: it is what a deployment does, and it leaves the tables owned by the role that owns them everywhere else (ADR-0031). 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.

Why the install step checks its own SDK version

global.json pins the SDK with rollForward: disable (ADR-0036), so dotnet-install.sh --jsonfile global.json either fetches SDK 10.0.100 exactly or exits non-zero — there is no version it could silently substitute. The install step compares the version it just installed against global.json's anyway, and fails the run if they differ. That is deliberately redundant with dotnet-install.sh's own exit code today: it is there for the day this step gains a fallback, a mirror or a cached copy that stops honouring the pin while still exiting zero, which is the same silent-substitution failure this ADR responds to, arriving from the other direction — CI running an unpinned SDK rather than a developer's machine doing so.

Contributing

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

Licence

Released under the MIT Licence — see LICENSE.