diff --git a/.github/workflows/capacity-board.yml b/.github/workflows/capacity-board.yml
new file mode 100644
index 00000000..a533150d
--- /dev/null
+++ b/.github/workflows/capacity-board.yml
@@ -0,0 +1,39 @@
+name: Capacity Board checks
+
+on:
+ pull_request:
+ paths:
+ - 'applications/capacity-board/**'
+ - '.github/workflows/capacity-board.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'applications/capacity-board/**'
+ - '.github/workflows/capacity-board.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: ubuntu-24.04
+ defaults:
+ run:
+ working-directory: applications/capacity-board
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
+ with:
+ dotnet-version: '10.0.401'
+ - name: Locked native checks
+ run: |
+ dotnet restore --locked-mode
+ dotnet restore Tests/Tests.csproj --locked-mode
+ dotnet format --verify-no-changes --no-restore
+ dotnet format Tests/Tests.csproj --verify-no-changes --no-restore
+ dotnet build -c Release --no-restore
+ dotnet run --project Tests/Tests.csproj -c Release --no-restore
+ dotnet publish -c Release --no-restore -o /tmp/capacity-published
+ python3 test/preflight.py /tmp/capacity-published/CapacityBoard
+# Real Cloud/browser acceptance is explicit and uses no Cloud secrets in CI.
diff --git a/applications/capacity-board/.env.example b/applications/capacity-board/.env.example
new file mode 100644
index 00000000..0acb11b6
--- /dev/null
+++ b/applications/capacity-board/.env.example
@@ -0,0 +1,10 @@
+PGHOST=your-cloud-host
+PGPORT=5432
+PGDATABASE=postgres
+PGUSER=capacity_app
+PGPASSWORD=replace-with-runtime-password
+PGSSLROOTCERT=/private/path/ca.pem
+ADMIN_USER=cloud-created-user
+ADMIN_PASSWORD=cloud-created-password
+MIGRATION_PASSWORD=replace-with-separate-password
+APP_ORIGIN=http://127.0.0.1:5000
diff --git a/applications/capacity-board/.gitignore b/applications/capacity-board/.gitignore
new file mode 100644
index 00000000..e8d8496e
--- /dev/null
+++ b/applications/capacity-board/.gitignore
@@ -0,0 +1,6 @@
+**/bin/
+**/obj/
+.env
+*.pem
+node_modules/
+__pycache__/
diff --git a/applications/capacity-board/CapacityBoard.csproj b/applications/capacity-board/CapacityBoard.csproj
new file mode 100644
index 00000000..9514f7e2
--- /dev/null
+++ b/applications/capacity-board/CapacityBoard.csproj
@@ -0,0 +1,16 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+ true
+ $(DefaultItemExcludes);Tests/**
+
+
+
+
+
+
+
+
diff --git a/applications/capacity-board/Components/App.razor b/applications/capacity-board/Components/App.razor
new file mode 100644
index 00000000..77bf1b74
--- /dev/null
+++ b/applications/capacity-board/Components/App.razor
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/applications/capacity-board/Components/Pages/Board.razor b/applications/capacity-board/Components/Pages/Board.razor
new file mode 100644
index 00000000..495a2d5f
--- /dev/null
+++ b/applications/capacity-board/Components/Pages/Board.razor
@@ -0,0 +1,152 @@
+@page "/"
+@inject BoardRepository Repository
+@implements IDisposable
+Capacity Board
+
+
+
+ SYNTHETIC WORKSPACE
+ Capacity Board
+ Plan one sprint within 30 points. Saves replace the complete allocation.
+ A trusted local workspace with no sign-in or tenant isolation.
+
+ @if (committed is null)
+ {
+ @message
+
+ }
+ else
+ {
+
+ Committed points@committed.Total
+ Remaining capacity@committed.Remaining
+ Loaded revision@committed.Revision
+
+
+
+ }
+
+
+@code {
+ private readonly CancellationTokenSource lifetime = new();
+ private BoardSnapshot? committed;
+ private Draft draft = new();
+ private SaveRequest? retry;
+ private bool pending;
+ private string message = "Loading committed allocation…";
+ private string DraftTotal
+ {
+ get
+ {
+ try { return draft.Items.Sum(item => Allocation.Points(item.Points)).ToString(); }
+ catch (BoardError) { return "Check points"; }
+ }
+ }
+
+ protected override Task OnInitializedAsync() => Reload();
+
+ private async Task Reload()
+ {
+ if (pending) return;
+ pending = true;
+ try
+ {
+ var snapshot = await Repository.ReadAsync(lifetime.Token);
+ committed = snapshot;
+ draft = new Draft { Items = snapshot.Items.Select(item => new EditItem
+ { Id = item.Id, Name = item.Name, Points = item.Points.ToString(System.Globalization.CultureInfo.InvariantCulture) }).ToList() };
+ retry = null;
+ message = "Committed allocation loaded. Changes stay in this editor until saved.";
+ }
+ catch (Exception) { message = "The board couldn't be loaded. Try loading again."; }
+ finally { pending = false; }
+ }
+
+ private void Add() { if (!pending && draft.Items.Count < 20) draft.Items.Add(new EditItem()); }
+ private void Remove(EditItem item) { if (!pending) draft.Items.Remove(item); }
+
+ private Task Save(EditContext context)
+ {
+ if (pending || retry is not null || committed is null) return Task.CompletedTask;
+ try
+ {
+ var request = new SaveRequest(Guid.NewGuid(), committed.Revision,
+ draft.Items.Select(item => new WorkItem(item.Id, item.Name, Allocation.Points(item.Points))).ToArray());
+ return SaveRequest(request);
+ }
+ catch (BoardError)
+ {
+ message = "Each effort must be a whole number from 0 to 30. Your draft is preserved.";
+ return Task.CompletedTask;
+ }
+ }
+ private Task Retry() => pending || retry is null ? Task.CompletedTask : SaveRequest(retry);
+
+ private async Task SaveRequest(SaveRequest request)
+ {
+ pending = true;
+ try
+ {
+ var result = await Repository.SaveAsync(request, lifetime.Token);
+ committed = result;
+ draft = new Draft { Items = result.Items.Select(item => new EditItem
+ { Id = item.Id, Name = item.Name, Points = item.Points.ToString(System.Globalization.CultureInfo.InvariantCulture) }).ToList() };
+ retry = null;
+ message = $"Saved revision {result.Revision}. {result.Total} points committed.";
+ }
+ catch (BoardError error)
+ {
+ retry = null;
+ message = error.Code switch
+ {
+ "stale_revision" => "Newer committed state exists. Your draft is preserved. Reload explicitly before editing the newer revision.",
+ "over_capacity" => "The draft exceeds capacity. Reduce its points; your values are preserved.",
+ "revision_limit" => "This teaching board has reached its retained-save limit.",
+ "operation_conflict" => "That operation ID belongs to different content. Reload committed state.",
+ "busy" => "All database operations are busy. Your draft is preserved; try saving again.",
+ _ => "Use at most 20 items, names of 1–80 characters without controls, and 0–30 integer points."
+ };
+ }
+ catch (Exception)
+ {
+ retry = request;
+ message = "The save couldn't be confirmed. Retry the original save, or reload committed state. Editing is paused to keep that retry exact.";
+ }
+ finally { pending = false; }
+ }
+ public void Dispose() { lifetime.Cancel(); lifetime.Dispose(); }
+ private sealed class Draft { public List Items { get; set; } = []; }
+ private sealed class EditItem
+ {
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Name { get; set; } = "";
+ public string Points { get; set; } = "0";
+ }
+}
diff --git a/applications/capacity-board/Components/Routes.razor b/applications/capacity-board/Components/Routes.razor
new file mode 100644
index 00000000..ad10e13e
--- /dev/null
+++ b/applications/capacity-board/Components/Routes.razor
@@ -0,0 +1,7 @@
+
+
+
+
+
+ Page not found. Open the board
+
diff --git a/applications/capacity-board/Components/_Imports.razor b/applications/capacity-board/Components/_Imports.razor
new file mode 100644
index 00000000..2d557094
--- /dev/null
+++ b/applications/capacity-board/Components/_Imports.razor
@@ -0,0 +1,8 @@
+@using Microsoft.AspNetCore.Components
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.AspNetCore.Components.Routing
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using CapacityBoard.Components
+@using CapacityBoard.Domain
+@using CapacityBoard.Data
diff --git a/applications/capacity-board/Data/BoardRepository.cs b/applications/capacity-board/Data/BoardRepository.cs
new file mode 100644
index 00000000..17783eb7
--- /dev/null
+++ b/applications/capacity-board/Data/BoardRepository.cs
@@ -0,0 +1,128 @@
+using System.Text.Json;
+using CapacityBoard.Domain;
+using Dapper;
+using Npgsql;
+
+namespace CapacityBoard.Data;
+
+// This service keeps only a data source and admission gate, never an open connection.
+public sealed class BoardRepository(NpgsqlDataSource source) : IDisposable
+{
+ private readonly SemaphoreSlim admission = new(4);
+
+ public async Task ReadAsync(CancellationToken cancellation = default)
+ {
+ if (!await admission.WaitAsync(0, cancellation)) throw new BoardError("busy");
+ try
+ {
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
+ deadline.CancelAfter(TimeSpan.FromSeconds(10));
+ await using var conn = await source.OpenConnectionAsync(deadline.Token);
+ return await ReadOnAsync(conn, null, deadline.Token);
+ }
+ finally { admission.Release(); }
+ }
+
+ private static async Task ReadOnAsync(NpgsqlConnection conn, NpgsqlTransaction? tx,
+ CancellationToken cancellation)
+ {
+ // One joined statement observes parent and children from the same read snapshot.
+ var rows = (await conn.QueryAsync(new CommandDefinition("""
+ SELECT b.id AS BoardId, b.title, b.capacity, b.revision,
+ i.id AS ItemId, i.name, i.points
+ FROM capacity_board.boards b
+ LEFT JOIN capacity_board.work_items i ON i.board_id = b.id
+ WHERE b.id = @Id ORDER BY i.position
+ """, new { Id = Allocation.BoardId }, tx, 4, cancellationToken: cancellation))).ToArray();
+ if (rows.Length == 0) throw new BoardError("missing_board");
+ var first = rows[0];
+ return new BoardSnapshot(first.BoardId, first.Title, first.Capacity, first.Revision,
+ rows.Where(row => row.ItemId.HasValue)
+ .Select(row => new WorkItem(row.ItemId!.Value, row.Name!, row.Points!.Value)).ToArray());
+ }
+
+ public async Task SaveAsync(SaveRequest request, CancellationToken cancellation = default)
+ {
+ var save = Allocation.Validate(request);
+ if (!await admission.WaitAsync(0, cancellation)) throw new BoardError("busy");
+ try
+ {
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
+ deadline.CancelAfter(TimeSpan.FromSeconds(10));
+ var ct = deadline.Token;
+ await using var conn = await source.OpenConnectionAsync(ct);
+ await using var tx = await conn.BeginTransactionAsync(ct);
+ CommandDefinition Command(string sql, object? parameters = null) =>
+ new(sql, parameters, tx, 4, cancellationToken: ct);
+ var board = await conn.QuerySingleAsync(Command("""
+ SELECT capacity, revision FROM capacity_board.boards WHERE id = @Id FOR UPDATE
+ """, new { Id = Allocation.BoardId }));
+ var previous = await conn.QuerySingleOrDefaultAsync(Command("""
+ SELECT fingerprint, response::text AS Response
+ FROM capacity_board.saves WHERE operation_id = @OperationId AND board_id = @BoardId
+ """, new { save.OperationId, BoardId = Allocation.BoardId }));
+ if (previous is not null)
+ {
+ if (previous.Fingerprint != save.Fingerprint) throw new BoardError("operation_conflict");
+ var response = JsonSerializer.Deserialize(previous.Response)
+ ?? throw new BoardError("unavailable");
+ await tx.CommitAsync(ct);
+ return response;
+ }
+ if (board.Revision != save.ExpectedRevision) throw new BoardError("stale_revision");
+ if (board.Revision > Allocation.MaxSaves) throw new BoardError("revision_limit");
+ if (save.Items.Sum(item => item.Points) > board.Capacity) throw new BoardError("over_capacity");
+ await conn.ExecuteAsync(Command("DELETE FROM capacity_board.work_items WHERE board_id = @Id",
+ new { Id = Allocation.BoardId }));
+ for (var position = 0; position < save.Items.Length; position++)
+ {
+ var item = save.Items[position];
+ await conn.ExecuteAsync(Command("""
+ INSERT INTO capacity_board.work_items(board_id,id,position,name,points)
+ VALUES (@BoardId,@Id,@Position,@Name,@Points)
+ """, new { BoardId = Allocation.BoardId, item.Id, Position = position, item.Name, item.Points }));
+ }
+ await conn.ExecuteAsync(Command("""
+ UPDATE capacity_board.boards SET revision = revision + 1 WHERE id = @Id
+ """, new { Id = Allocation.BoardId }));
+ var result = await ReadOnAsync(conn, tx, ct);
+ await conn.ExecuteAsync(Command("""
+ INSERT INTO capacity_board.saves(operation_id,board_id,fingerprint,revision,response)
+ VALUES (@OperationId,@BoardId,@Fingerprint,@Revision,CAST(@Response AS jsonb))
+ """, new
+ {
+ save.OperationId,
+ BoardId = Allocation.BoardId,
+ save.Fingerprint,
+ result.Revision,
+ Response = JsonSerializer.Serialize(result)
+ }));
+ await tx.CommitAsync(ct);
+ return result;
+ }
+ finally { admission.Release(); }
+ }
+
+ public void Dispose() => admission.Dispose();
+
+ private sealed class SnapshotRow
+ {
+ public Guid BoardId { get; set; }
+ public string Title { get; set; } = "";
+ public int Capacity { get; set; }
+ public int Revision { get; set; }
+ public Guid? ItemId { get; set; }
+ public string? Name { get; set; }
+ public int? Points { get; set; }
+ }
+ private sealed class ParentRow
+ {
+ public int Capacity { get; set; }
+ public int Revision { get; set; }
+ }
+ private sealed class RetainedRow
+ {
+ public string Fingerprint { get; set; } = "";
+ public string Response { get; set; } = "";
+ }
+}
diff --git a/applications/capacity-board/Data/Database.cs b/applications/capacity-board/Data/Database.cs
new file mode 100644
index 00000000..dedf5395
--- /dev/null
+++ b/applications/capacity-board/Data/Database.cs
@@ -0,0 +1,33 @@
+using Npgsql;
+
+namespace CapacityBoard.Data;
+
+public static class Database
+{
+ public static NpgsqlDataSource Create(string expectedRole)
+ {
+ string Required(string name) => Environment.GetEnvironmentVariable(name)
+ ?? throw new InvalidOperationException($"Missing {name}");
+ if (Required("PGUSER") != expectedRole)
+ throw new InvalidOperationException("Wrong database role");
+ var connection = new NpgsqlConnectionStringBuilder
+ {
+ Host = Required("PGHOST"),
+ Port = int.Parse(Required("PGPORT")),
+ Database = Required("PGDATABASE"),
+ Username = Required("PGUSER"),
+ Password = Required("PGPASSWORD"),
+ RootCertificate = Required("PGSSLROOTCERT"),
+ SslMode = SslMode.VerifyFull,
+ Timeout = 5,
+ CommandTimeout = 4,
+ MaxPoolSize = 4,
+ MinPoolSize = 0,
+ ConnectionIdleLifetime = 60,
+ ConnectionLifetime = 300,
+ ApplicationName = "capacity-board",
+ Options = "-c statement_timeout=4000 -c lock_timeout=2000 -c idle_in_transaction_session_timeout=6000"
+ };
+ return NpgsqlDataSource.Create(connection.ConnectionString);
+ }
+}
diff --git a/applications/capacity-board/Domain/Allocation.cs b/applications/capacity-board/Domain/Allocation.cs
new file mode 100644
index 00000000..aac461ee
--- /dev/null
+++ b/applications/capacity-board/Domain/Allocation.cs
@@ -0,0 +1,75 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace CapacityBoard.Domain;
+
+public sealed record WorkItem(Guid Id, string Name, int Points);
+public sealed record BoardSnapshot(Guid Id, string Title, int Capacity, int Revision, WorkItem[] Items)
+{
+ public int Total => Items.Sum(item => item.Points);
+ public int Remaining => Capacity - Total;
+}
+public sealed record SaveRequest(Guid OperationId, int ExpectedRevision, WorkItem[] Items);
+public sealed record CanonicalSave(Guid OperationId, int ExpectedRevision, WorkItem[] Items, string Fingerprint);
+
+public sealed class BoardError(string code) : Exception(code)
+{
+ public string Code { get; } = code;
+}
+
+public static class Allocation
+{
+ public static readonly Guid BoardId = Guid.Parse("11111111-1111-4111-8111-111111111111");
+ public const int MaxSaves = 25;
+
+ public static CanonicalSave Validate(SaveRequest request)
+ {
+ if (request.OperationId == Guid.Empty || request.ExpectedRevision is < 1 or > MaxSaves + 1 ||
+ request.Items is null || request.Items.Length > 20)
+ throw new BoardError("invalid_allocation");
+ var seen = new HashSet();
+ var items = new WorkItem[request.Items.Length];
+ for (var index = 0; index < request.Items.Length; index++)
+ {
+ var item = request.Items[index];
+ if (item is null || item.Id == Guid.Empty || !seen.Add(item.Id) || item.Points is < 0 or > 30)
+ throw new BoardError("invalid_allocation");
+ items[index] = item with { Name = Name(item.Name) };
+ }
+ // Row order is retained: a retry must describe the same complete ordered set.
+ var payload = JsonSerializer.Serialize(new { request.ExpectedRevision, Items = items });
+ var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)));
+ return new CanonicalSave(request.OperationId, request.ExpectedRevision, items, fingerprint);
+ }
+
+ public static int Points(string? input)
+ {
+ if (input is null || input.Length is < 1 or > 2 ||
+ !int.TryParse(input, System.Globalization.NumberStyles.None,
+ System.Globalization.CultureInfo.InvariantCulture, out var value) || value > 30)
+ throw new BoardError("invalid_points");
+ return value;
+ }
+
+ public static string Name(string? input)
+ {
+ if (input is null || input.Length > 160)
+ throw new BoardError("invalid_name");
+ // Reject invalid UTF-16 before encoding or sending text to PostgreSQL.
+ for (var index = 0; index < input.Length; index++)
+ {
+ var ch = input[index];
+ if (char.IsControl(ch)) throw new BoardError("invalid_name");
+ if (char.IsHighSurrogate(ch))
+ {
+ if (++index >= input.Length || !char.IsLowSurrogate(input[index]))
+ throw new BoardError("invalid_name");
+ }
+ else if (char.IsLowSurrogate(ch)) throw new BoardError("invalid_name");
+ }
+ var name = input.Trim();
+ if (name.Length is < 1 or > 80) throw new BoardError("invalid_name");
+ return name;
+ }
+}
diff --git a/applications/capacity-board/Program.cs b/applications/capacity-board/Program.cs
new file mode 100644
index 00000000..771526b3
--- /dev/null
+++ b/applications/capacity-board/Program.cs
@@ -0,0 +1,108 @@
+using System.Reflection;
+using CapacityBoard.Components;
+using CapacityBoard.Data;
+using Microsoft.AspNetCore.Http.Connections;
+using Npgsql;
+
+var preflight = args.Contains("--preflight");
+if (args.Contains("migrate"))
+{
+ await using var migration = Database.Create("capacity_migration");
+ await using var connection = await migration.OpenConnectionAsync();
+ using var resource = Assembly.GetExecutingAssembly().GetManifestResourceStream("CapacityBoard.sql.migrate.sql")
+ ?? throw new InvalidOperationException("Migration resource missing");
+ var sql = await new StreamReader(resource).ReadToEndAsync();
+ await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 15 };
+ await command.ExecuteNonQueryAsync();
+ Console.WriteLine("Reviewed migration applied.");
+ return;
+}
+if (args.Contains("--check-db"))
+{
+ await using var check = Database.Create("capacity_app");
+ await using var connection = await check.OpenConnectionAsync();
+ Console.WriteLine("Actual Npgsql factory connection succeeded.");
+ return;
+}
+var originText = Environment.GetEnvironmentVariable("APP_ORIGIN") ?? "http://127.0.0.1:5000";
+if (!Uri.TryCreate(originText, UriKind.Absolute, out var origin) || origin.Scheme != "http" ||
+ origin.Host != "127.0.0.1" || origin.Port < 1024 || origin.AbsolutePath != "/" ||
+ !string.IsNullOrEmpty(origin.Query) || !string.IsNullOrEmpty(origin.Fragment) ||
+ !string.IsNullOrEmpty(origin.UserInfo))
+ throw new InvalidOperationException("APP_ORIGIN must be an http://127.0.0.1 local origin");
+var expectedOrigin = origin.GetLeftPart(UriPartial.Authority);
+var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+{
+ Args = args,
+ ContentRootPath = AppContext.BaseDirectory
+});
+builder.WebHost.UseUrls(expectedOrigin);
+builder.WebHost.ConfigureKestrel(options =>
+{
+ options.Limits.MaxRequestBodySize = 16 * 1024;
+ options.Limits.MaxConcurrentConnections = 32;
+ options.Limits.MaxConcurrentUpgradedConnections = 32;
+ options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(5);
+ options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(30);
+});
+builder.Services.Configure(options => options.ShutdownTimeout = TimeSpan.FromSeconds(12));
+if (!preflight)
+{
+ builder.Services.AddSingleton(_ => Database.Create("capacity_app"));
+ builder.Services.AddSingleton();
+ builder.Services.AddRazorComponents().AddInteractiveServerComponents(options =>
+ {
+ options.DetailedErrors = false;
+ options.DisconnectedCircuitMaxRetained = 10;
+ options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromSeconds(30);
+ options.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(5);
+ }).AddHubOptions(options =>
+ {
+ options.MaximumReceiveMessageSize = 16 * 1024;
+ options.MaximumParallelInvocationsPerClient = 1;
+ options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
+ options.KeepAliveInterval = TimeSpan.FromSeconds(10);
+ });
+}
+var app = builder.Build();
+app.UseWebSockets();
+app.Use(async (context, next) =>
+{
+ var sentOrigin = context.Request.Headers.Origin.ToString();
+ var signalMutation = context.Request.Path.StartsWithSegments("/_blazor") &&
+ (context.Request.Method != "GET" || context.WebSockets.IsWebSocketRequest);
+ if (context.Request.Host.Value != origin.Authority ||
+ (sentOrigin.Length > 0 && sentOrigin != expectedOrigin) ||
+ (signalMutation && sentOrigin != expectedOrigin))
+ {
+ context.Response.StatusCode = 403;
+ return;
+ }
+ context.Response.Headers.CacheControl = "no-store";
+ context.Response.Headers.ContentSecurityPolicy = "frame-ancestors 'none'";
+ context.Response.Headers.XContentTypeOptions = "nosniff";
+ await next();
+});
+app.MapGet("/health", () => Results.Ok(new { ready = true }));
+if (!preflight)
+{
+ // Verify startup connectivity; dispose it before serving any circuit.
+ await using (var startup = await app.Services.GetRequiredService().OpenConnectionAsync()) { }
+ app.UseStaticFiles();
+ app.UseAntiforgery();
+ app.MapRazorComponents().AddInteractiveServerRenderMode(options =>
+ {
+ options.DisableWebSocketCompression = true;
+ }).Add(endpoint =>
+ {
+ // ASP.NET Core 10 exposes dispatcher options through endpoint metadata.
+ var dispatcher = endpoint.Metadata.OfType().FirstOrDefault();
+ if (dispatcher is not null)
+ {
+ dispatcher.Transports = HttpTransportType.WebSockets | HttpTransportType.LongPolling;
+ dispatcher.ApplicationMaxBufferSize = 16 * 1024;
+ dispatcher.TransportMaxBufferSize = 16 * 1024;
+ }
+ });
+}
+await app.RunAsync();
diff --git a/applications/capacity-board/README.md b/applications/capacity-board/README.md
new file mode 100644
index 00000000..001bb19a
--- /dev/null
+++ b/applications/capacity-board/README.md
@@ -0,0 +1,150 @@
+# Capacity Board
+
+A small C#/Blazor Interactive Server board allocates a synthetic sprint's 30 effort points on [ClickHouse Managed Postgres](https://clickhouse.com/docs/products/managed-postgres/overview). An editor changes a complete set of up to twenty work items and saves against the revision displayed in that editor. The committed summary stays separate from the unsaved draft.
+
+This is one trusted local operator workspace with **no authentication or tenant isolation**. It binds loopback and checks Host/browser Origin; those controls are not operator identity. There is no employee data, external issue tracker, productivity score or calendar integration.
+
+## Transactions and circuit lifetime
+
+The repository acquires one connection from a singleton NpgsqlDataSource per operation. It never retains an open connection or transaction in a Blazor circuit or across user think time. The component retains only bounded draft values, the loaded snapshot/revision and an uncertain original save request.
+
+One joined read observes the parent and ordered children from one statement snapshot, including an empty child set. A save:
+
+1. Validates and canonicalizes its complete ordered item set, expected revision and operation UUID.
+2. Locks the single board row; checks retained operations before stale-revision and save-limit checks.
+3. Rejects a changed payload under a retained UUID, stale revision or aggregate total above capacity.
+4. Deletes the old children and inserts the complete replacement on that same connection/transaction.
+5. Increments the revision and inserts an immutable audit/result row, then **awaits COMMIT** before returning saved state.
+
+A failed insertion or COMMIT rolls back child replacement, revision and audit. Matching operation retries return their original saved snapshot even after later saves. A changed ordered set, point/name or expected revision conflicts. Names are trimmed before fingerprinting; row order is retained. The fixture allows 25 successful saves (initial revision 1 through revision 26). Exact retained retries still work at that limit; new operations are rejected. History is not automatically deleted.
+
+Names must contain 1–80 UTF-16 code units after trimming; controls and malformed surrogate pairs are rejected before UTF-8 encoding. A raw name is bounded before trimming. UUIDs must be nonzero/unique within the complete set; effort is an exact integer 0–30. The browser retains point fields as bounded strings until validation, so malformed numeric input cannot silently save an earlier parsed value. There are no currency values.
+
+The circuit disables local repeated dispatch while a save is pending. A stale save preserves its values and displayed revision, with an explicit reload action; it never silently rebases edits. An uncertain save pauses editing and retains the exact request for the Retry original save button. Reload discards the draft/retry. Cancellation or a disconnect does not prove that an operation did not commit. After actual process restart, a new circuit reloads committed Postgres state; unsaved circuit values have no durable guarantee.
+
+## Role and resource bounds
+
+The runtime role can SELECT the board/items/saves, UPDATE only board revision, INSERT/DELETE children, and INSERT retained saves. It cannot change capacity/title, update children, mutate/delete existing audit, access migration history, become the migration role, or create schema/application/temp objects. The schema owner alone runs reviewed migrations.
+
+Database constraints enforce bounded per-item points, nonzero UUIDs, FK, unique item positions and retained operation/revision uniqueness. **Direct trusted runtime SQL can bypass the application's aggregate-capacity, revision and audit protocol** by changing children or permitted revision metadata. The Cloud check explicitly demonstrates an aggregate total above capacity through direct SQL. The API's parent lock and validation coordinate cooperating application operations; shared server credentials are not per-user RLS.
+
+Npgsql uses VerifyFull with the full official Cloud CA. Pool size is 4, minimum idle 0, open/acquisition timeout 5 seconds, command timeout 4 seconds, idle lifetime 60 seconds and connection lifetime 300 seconds. Postgres statement/lock/idle-transaction limits are 4/2/6 seconds. Four local admission slots reject additional operations as busy; repository operations request cancellation after 10 seconds. These controls don't guarantee an end-to-end HTTP deadline or undo a possible commit.
+
+Kestrel permits 32 normal and 32 upgraded connections, 16 KiB request bodies, five-second request headers and thirty-second keepalive. SignalR retains its one parallel invocation per circuit, has 16 KiB received messages/buffers and allows WebSockets/LongPolling. Disconnected circuits retain at most ten entries for thirty seconds; JS interop timeout is five seconds. WebSocket compression is disabled; frame ancestors are denied. Native antiforgery remains enabled for component form posts. SignalR negotiation/messages and WebSocket upgrade require the configured local Origin; any supplied foreign Origin and mismatched Host are rejected. Non-browser clients can forge these headers, reinforcing the local trusted-workspace scope.
+
+## Native build
+
+Tested 2 October 2026 on Ubuntu 24.04 ARM64: .NET SDK 10.0.401, ASP.NET Core/runtime 10.0.12, Dapper 2.1.89 and Npgsql 10.0.3. global.json pins the SDK; both projects have committed NuGet lockfiles.
+
+```sh
+sudo apt-get update
+sudo apt-get install -y curl ca-certificates git postgresql-client python3 libicu74 xz-utils
+curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
+bash /tmp/dotnet-install.sh --version 10.0.401 --install-dir "$HOME/.dotnet"
+export DOTNET_ROOT="$HOME/.dotnet" PATH="$HOME/.dotnet:$PATH"
+cd /path/to/examples/applications/capacity-board
+dotnet restore --locked-mode
+dotnet restore Tests/Tests.csproj --locked-mode
+dotnet format --verify-no-changes --no-restore
+dotnet format Tests/Tests.csproj --verify-no-changes --no-restore
+dotnet build -c Release --no-restore
+dotnet run --project Tests/Tests.csproj -c Release --no-restore
+dotnet publish -c Release --no-restore -o /private/path/capacity-published
+python3 test/preflight.py /private/path/capacity-published/CapacityBoard
+```
+
+Four local domain groups cover canonical fingerprints, UUID/count/revision bounds, Unicode/controls and bounded integer parsing. Separate compiled preflight verifies listener/Host/Origin rejection and confirmed SIGTERM exit without a database. CI uses no Cloud credentials. Launch the published executable; its content root is the published directory, so assets resolve independently of the shell's directory.
+
+## Create a dedicated Cloud fixture
+
+Creating the service starts billing. Check your organization's supported shape; the tested modest AWS shape was:
+
+```sh
+umask 077
+export ORG_ID=your-organization-id
+clickhousectl cloud postgres create --org-id "$ORG_ID" \
+ --name capacity-board-demo --provider aws --region us-east-1 \
+ --size c6gd.large --pg-version 18 --ha-type none --json > /private/path/create.json
+export PG_ID=your-created-service-id
+clickhousectl cloud postgres get "$PG_ID" --org-id "$ORG_ID"
+# Repeat get until state is running before fetching the official CA.
+clickhousectl cloud postgres certs get "$PG_ID" --org-id "$ORG_ID" \
+ --output /private/path/ca.pem
+```
+
+Save the returned hostname, administrator username and one-time password privately. `--output` writes the PEM; preserve the full official bundle and refresh it after rotation. The runtime factory checks both certificate and hostname, without a trust callback bypass.
+
+## Explicit roles, migration and seed
+
+Copy .env.example to a private mode-600 setup.env outside the checkout. Fill in the service connection/CA, administrator credentials, separate migration password and runtime password in PGPASSWORD. Quote shell-sensitive values and export fields to children:
+
+```sh
+set -a; source /private/path/setup.env; set +a
+export PGSSLMODE=verify-full
+APP_PASSWORD=$PGPASSWORD
+export PGUSER=$ADMIN_USER PGPASSWORD=$ADMIN_PASSWORD
+psql -X -v MIGRATION_PASSWORD="$MIGRATION_PASSWORD" -v APP_PASSWORD="$APP_PASSWORD" -f sql/bootstrap.sql
+export PGUSER=capacity_migration PGPASSWORD=$MIGRATION_PASSWORD
+/private/path/capacity-published/CapacityBoard migrate
+psql -X -f sql/grants.sql
+psql -X -f sql/seed.sql
+```
+
+Bootstrap creates schema/roles and revokes PUBLIC database CREATE/TEMP and public-schema CREATE on this dedicated fixture. The schema owner needs no database CREATE. Embedded versioned SQL and an advisory transaction lock make repeated migration safe. Seed repeats without resetting an existing board, revision or allocation. Startup never performs DDL; migration mode rejects runtime credentials.
+
+## Run and edit
+
+```sh
+export PGUSER=capacity_app PGPASSWORD=$APP_PASSWORD
+unset ADMIN_USER ADMIN_PASSWORD MIGRATION_PASSWORD
+/private/path/capacity-published/CapacityBoard
+```
+
+Open http://127.0.0.1:5000. APP_ORIGIN can select another local port at or above 1024; it must remain an HTTP origin using 127.0.0.1, without path/query/userinfo. Use that exact origin in the browser. Startup checks runtime connectivity before serving. Health checks process readiness rather than continuously probing the database.
+
+The seeded Workshop sprint has three items totaling nine points. Edit several names/point values and compare the draft total with the committed summary; Save allocation commits the complete set. A second browser tab has its own loaded revision. If another tab saves first, stale values stay visible until you explicitly reload. Local pending/uncertain state doesn't replace the database's lock/revision checks.
+
+## Destructive dedicated acceptance
+
+Stop the app first. Export a private setup/test shell with both runtime and owner/admin fields; the compiled browser child receives only runtime connection/CA/origin fields. Native tests assume a freshly seeded dedicated fixture. For empty-schema reproduction, restore administrator credentials and run cleanup, then repeat the documented bootstrap/migrate/grants/seed. Repeat migrate and seed twice to verify retained setup state.
+
+```sh
+set -a; source /private/path/setup.env; set +a
+export PGSSLMODE=verify-full PGUSER=capacity_app
+export WRONG_CA=/private/path/wrong-ca.pem
+openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj /CN=UnrelatedAcceptanceCA \
+ -keyout /private/path/wrong-ca.key -out "$WRONG_CA"
+dotnet run --project Tests/Tests.csproj -c Release --no-build -- --cloud
+# Reseed an isolated owner fixture for the browser suite:
+APP_PASSWORD=$PGPASSWORD
+export PGUSER=capacity_migration PGPASSWORD=$MIGRATION_PASSWORD
+psql -X -c 'TRUNCATE capacity_board.saves,capacity_board.work_items,capacity_board.boards'
+psql -X -f sql/seed.sql
+export PGUSER=capacity_app PGPASSWORD=$APP_PASSWORD
+export CAPACITY_EXECUTABLE=/private/path/capacity-published/CapacityBoard
+export EVIDENCE_DIR=/private/path/acceptance
+cd test
+npm ci
+npx playwright install --with-deps chromium
+node browser.mjs
+```
+
+Browser tools were tested with Node 24.21.0/Playwright 1.63.0/Chromium 153.0.8010.12. Install them natively; test/package-lock.json pins the browser dependency. Seven native Cloud groups cover typed UUID/int/null/UTC mapping, retained changed-payload conflict, second-child rollback, actual deferred COMMIT failure, two observed blocked sessions/read progress/stale rejection, all 25 saves/replay at cap, actual grants/native constraints and certificate-specific negatives with same-service positive control. Test-only pool warm-up does not change runtime limits.
+
+The browser helper covers two real circuits, preserved stale form, overcapacity/invalid input, security defaults, saved UI only after an independently observed deferred COMMIT hold, uncertain-commit retry, original-process exit before replacement, and desktop/mobile bounds. Owner-only fault triggers and reseeding are test fixtures. No production downgrade or durable unsaved-draft claim is made. Evidence stays outside the checkout.
+
+## Cleanup
+
+Stop the app. Optional schema cleanup must restore administrator fields that the run shell unset:
+
+```sh
+set -a; source /private/path/setup.env; set +a
+export PGUSER=$ADMIN_USER PGPASSWORD=$ADMIN_PASSWORD PGSSLMODE=verify-full
+psql -X -f sql/cleanup.sql
+clickhousectl cloud postgres delete "$PG_ID" --org-id "$ORG_ID"
+clickhousectl cloud postgres list --org-id "$ORG_ID"
+```
+
+Confirm the exact ID is absent; PUBLIC revocations remain after schema cleanup.
+
+Primary references: [Blazor circuit DI lifetime](https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/dependency-injection?view=aspnetcore-10.0), [server security guidance](https://learn.microsoft.com/en-us/aspnet/core/blazor/security/interactive-server-side-rendering?view=aspnetcore-10.0), [Dapper 2.1.89 CommandDefinition](https://github.com/DapperLib/Dapper/blob/2.1.89/Dapper/CommandDefinition.cs), [Npgsql data source and transactions](https://www.npgsql.org/doc/basic-usage.html), [Npgsql VerifyFull](https://www.npgsql.org/doc/security.html). The pinned ASP.NET Core 10 release exposes dispatcher settings through endpoint metadata; the source uses that released path rather than assuming a later ConfigureConnection property.
diff --git a/applications/capacity-board/Tests/CloudChecks.cs b/applications/capacity-board/Tests/CloudChecks.cs
new file mode 100644
index 00000000..c6aa2d82
--- /dev/null
+++ b/applications/capacity-board/Tests/CloudChecks.cs
@@ -0,0 +1,215 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Text.Json;
+using CapacityBoard.Data;
+using CapacityBoard.Domain;
+using Dapper;
+using Npgsql;
+
+public static class CloudChecks
+{
+ private static void Check(bool condition, string detail)
+ {
+ if (!condition) throw new Exception(detail);
+ }
+ private static async Task Rejected(Func operation, string code)
+ {
+ try { await operation(); }
+ catch (BoardError error) when (error.Code == code) { return; }
+ throw new Exception($"Expected {code}");
+ }
+ private static async Task SqlRejected(NpgsqlDataSource source, string sql, string state)
+ {
+ await using var conn = await source.OpenConnectionAsync();
+ try { await conn.ExecuteAsync(new CommandDefinition(sql, commandTimeout: 4)); }
+ catch (PostgresException error) when (error.SqlState == state) { return; }
+ throw new Exception($"Expected SQLSTATE {state}");
+ }
+ private static string Describe(BoardSnapshot value) => JsonSerializer.Serialize(value);
+
+ public static async Task Run()
+ {
+ await using var source = Database.Create("capacity_app");
+ var ownerBuilder = new NpgsqlConnectionStringBuilder(source.ConnectionString)
+ {
+ Username = "capacity_migration",
+ Password = Environment.GetEnvironmentVariable("MIGRATION_PASSWORD"),
+ ApplicationName = "capacity-board-owner"
+ };
+ await using var owner = NpgsqlDataSource.Create(ownerBuilder.ConnectionString);
+ var observerBuilder = new NpgsqlConnectionStringBuilder(source.ConnectionString)
+ {
+ Username = Environment.GetEnvironmentVariable("ADMIN_USER"),
+ Password = Environment.GetEnvironmentVariable("ADMIN_PASSWORD"),
+ ApplicationName = "capacity-board-observer"
+ };
+ await using var observer = NpgsqlDataSource.Create(observerBuilder.ConnectionString);
+ using var repository = new BoardRepository(source);
+ async Task Sql(string sql)
+ {
+ await using var conn = await owner.OpenConnectionAsync();
+ await conn.ExecuteAsync(new CommandDefinition(sql, commandTimeout: 4));
+ }
+ async Task Scalar(string sql)
+ {
+ await using var conn = await owner.OpenConnectionAsync();
+ return await conn.ExecuteScalarAsync(new CommandDefinition(sql, commandTimeout: 4));
+ }
+ async Task Reset()
+ {
+ await Sql("DELETE FROM capacity_board.saves; DELETE FROM capacity_board.work_items; UPDATE capacity_board.boards SET revision=1;");
+ }
+ var initial = await repository.ReadAsync();
+ Check(initial.Id == Allocation.BoardId && initial.Total == 9 && initial.Revision == 1, "seed mapping");
+ var request = new SaveRequest(Guid.NewGuid(), 1, [new WorkItem(Guid.NewGuid(), " Café ✨ ", 4)]);
+ var saved = await repository.SaveAsync(request);
+ Check(saved.Revision == 2 && saved.Items[0].Name == "Café ✨" && saved.Items[0].Points == 4, "typed UUID/int mapping");
+ var empty = await repository.SaveAsync(new SaveRequest(Guid.NewGuid(), 2, []));
+ Check(empty.Items.Length == 0 && empty.Revision == 3 && empty.Total == 0, "left-join nullable mapping");
+ var replay = await repository.SaveAsync(request);
+ Check(Describe(replay) == Describe(saved), "retained original result after a later save");
+ await Rejected(() => repository.SaveAsync(request with { Items = [request.Items[0] with { Points = 5 }] }), "operation_conflict");
+ await using (var conn = await source.OpenConnectionAsync())
+ {
+ var timestamp = await conn.ExecuteScalarAsync("SELECT created_at FROM capacity_board.saves ORDER BY revision LIMIT 1");
+ Check(timestamp.Kind == DateTimeKind.Utc, "timestamptz UTC mapping");
+ }
+ Console.WriteLine("PASS typed UUID/int/null/UTC mappings and immutable exact replay");
+
+ var rollback = new SaveRequest(Guid.NewGuid(), empty.Revision,
+ [new WorkItem(Guid.NewGuid(), "First inserted", 2), new WorkItem(Guid.NewGuid(), "Reject second", 3)]);
+ await Sql("""
+ CREATE FUNCTION capacity_board.reject_child() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN
+ IF NEW.name = 'Reject second' THEN RAISE EXCEPTION 'test-only rejection' USING ERRCODE='23514'; END IF;
+ RETURN NEW;
+ END $$;
+ CREATE TRIGGER test_child BEFORE INSERT ON capacity_board.work_items FOR EACH ROW EXECUTE FUNCTION capacity_board.reject_child();
+ """);
+ try
+ {
+ try { await repository.SaveAsync(rollback); throw new Exception("failure missing"); }
+ catch (PostgresException error) when (error.SqlState == "23514") { }
+ Check(Describe(await repository.ReadAsync()) == Describe(empty), "child replacement rollback");
+ Check(await Scalar("SELECT count(*)::int FROM capacity_board.saves") == 2, "audit unchanged");
+ }
+ finally { await Sql("DROP TRIGGER test_child ON capacity_board.work_items; DROP FUNCTION capacity_board.reject_child();"); }
+ var recovered = await repository.SaveAsync(rollback);
+ Check(recovered.Revision == 4 && recovered.Items.Length == 2, "later valid request after failure");
+ Console.WriteLine("PASS second-child failure rolls back replacement and preserves revision/audit; recovery");
+
+ var deferred = new SaveRequest(Guid.NewGuid(), 4, [new WorkItem(Guid.NewGuid(), "Deferred commit", 6)]);
+ await Sql("""
+ CREATE FUNCTION capacity_board.reject_commit() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN RAISE EXCEPTION 'test-only deferred rejection' USING ERRCODE='23514'; END $$;
+ CREATE CONSTRAINT TRIGGER test_commit AFTER INSERT ON capacity_board.saves DEFERRABLE INITIALLY DEFERRED
+ FOR EACH ROW EXECUTE FUNCTION capacity_board.reject_commit();
+ """);
+ try
+ {
+ try { await repository.SaveAsync(deferred); throw new Exception("commit failure missing"); }
+ catch (PostgresException error) when (error.SqlState == "23514") { }
+ Check(Describe(await repository.ReadAsync()) == Describe(recovered), "commit rollback full snapshot");
+ Check(await Scalar("SELECT count(*)::int FROM capacity_board.saves") == 3, "deferred audit rollback");
+ }
+ finally { await Sql("DROP TRIGGER test_commit ON capacity_board.saves; DROP FUNCTION capacity_board.reject_commit();"); }
+ Check((await repository.SaveAsync(deferred)).Revision == 5, "same UUID after rolled back COMMIT");
+ Console.WriteLine("PASS actual deferred COMMIT failure rolls back children/revision/audit and same-UUID recovery");
+
+ // Warm only the test pool, so both independent sessions are available before holding the parent.
+ var warm = await Task.WhenAll(source.OpenConnectionAsync().AsTask(), source.OpenConnectionAsync().AsTask());
+ foreach (var connection in warm) await connection.DisposeAsync();
+ await using (var holder = await owner.OpenConnectionAsync())
+ await using (var transaction = await holder.BeginTransactionAsync())
+ {
+ await holder.ExecuteAsync(new CommandDefinition("SELECT id FROM capacity_board.boards FOR UPDATE", transaction: transaction));
+ async Task Attempt(string name)
+ {
+ try { await repository.SaveAsync(new SaveRequest(Guid.NewGuid(), 5, [new WorkItem(Guid.NewGuid(), name, 10)])); return "saved"; }
+ catch (BoardError error) { return error.Code; }
+ }
+ var first = Attempt("Independent one");
+ var second = Attempt("Independent two");
+ var blocked = false;
+ for (var attempt = 0; attempt < 100; attempt++)
+ {
+ await using var observation = await observer.OpenConnectionAsync();
+ await observation.ExecuteAsync("SELECT pg_stat_clear_snapshot()");
+ if (await observation.ExecuteScalarAsync("SELECT count(*)::int FROM pg_stat_activity WHERE application_name='capacity-board' AND cardinality(pg_blocking_pids(pid))>0") == 2)
+ { blocked = true; break; }
+ await Task.Delay(10);
+ }
+ Check(blocked && !first.IsCompleted && !second.IsCompleted, "two actual blocked sessions");
+ var before = await repository.ReadAsync();
+ Check(before.Revision == 5 && !first.IsCompleted && !second.IsCompleted, "independent coherent read before release");
+ await transaction.CommitAsync();
+ var results = await Task.WhenAll(first, second);
+ Check(results.Count(x => x == "saved") == 1 && results.Count(x => x == "stale_revision") == 1, "one stale writer");
+ }
+ Check((await repository.ReadAsync()).Revision == 6, "one committed revision");
+ Console.WriteLine("PASS actual independent parent-lock contention, read progress and stale rejection");
+
+ await Reset();
+ SaveRequest? final = null;
+ BoardSnapshot? finalResult = null;
+ for (var revision = 1; revision <= Allocation.MaxSaves; revision++)
+ {
+ final = new SaveRequest(Guid.NewGuid(), revision, [new WorkItem(Guid.NewGuid(), "Bounded save", revision % 10)]);
+ finalResult = await repository.SaveAsync(final);
+ }
+ Check(finalResult!.Revision == 26 && await Scalar("SELECT count(*)::int FROM capacity_board.saves") == 25, "actual retained cap");
+ Check(Describe(await repository.SaveAsync(final!)) == Describe(finalResult), "exact final save replay at cap");
+ await Rejected(() => repository.SaveAsync(new SaveRequest(Guid.NewGuid(), 26, [])), "revision_limit");
+ Console.WriteLine("PASS 25 actual retained saves, exact replay at cap and new-operation rejection");
+
+ foreach (var sql in new[] {
+ "CREATE TABLE capacity_board.forbidden(id int)", "CREATE TEMP TABLE forbidden(id int)",
+ "CREATE SCHEMA forbidden", "SET ROLE capacity_migration", "DELETE FROM capacity_board.boards",
+ "UPDATE capacity_board.boards SET capacity=29", "UPDATE capacity_board.work_items SET points=1",
+ "UPDATE capacity_board.saves SET fingerprint=repeat('A',64)", "DELETE FROM capacity_board.saves",
+ "SELECT * FROM capacity_board.schema_versions" })
+ await SqlRejected(source, sql, "42501");
+ await SqlRejected(source, "INSERT INTO capacity_board.work_items(board_id,id,position,name,points) VALUES ('11111111-1111-4111-8111-111111111111','55555555-5555-4555-8555-555555555555',20,'Invalid',1)", "23514");
+ await SqlRejected(source, "INSERT INTO capacity_board.work_items(board_id,id,position,name,points) VALUES ('99999999-9999-4999-8999-999999999999','55555555-5555-4555-8555-555555555555',0,'Invalid',1)", "23503");
+ await using (var conn = await source.OpenConnectionAsync())
+ {
+ await conn.ExecuteAsync("""
+ INSERT INTO capacity_board.work_items(board_id,id,position,name,points) VALUES
+ ('11111111-1111-4111-8111-111111111111','55555555-5555-4555-8555-555555555555',1,'Direct trusted SQL',30);
+ """);
+ }
+ Check((await repository.ReadAsync()).Total > 30, "trusted role can bypass aggregate protocol");
+ Console.WriteLine("PASS actual runtime grants/native constraints; direct SQL aggregate-capacity bypass disclosed");
+ await Reset();
+
+ async Task TrustFailure(string variable, string replacement, string description)
+ {
+ var original = Environment.GetEnvironmentVariable(variable);
+ try
+ {
+ Environment.SetEnvironmentVariable(variable, replacement);
+ await using var bad = Database.Create("capacity_app");
+ try { await using var connection = await bad.OpenConnectionAsync(); }
+ catch (NpgsqlException error)
+ {
+ Check(error.ToString().Contains("AuthenticationException") &&
+ (error.ToString().Contains("certificate", StringComparison.OrdinalIgnoreCase) ||
+ error.ToString().Contains("RemoteCertificate", StringComparison.OrdinalIgnoreCase)), "certificate-specific failure");
+ Console.WriteLine($"PASS actual Npgsql {description} certificate failure");
+ return;
+ }
+ throw new Exception("TLS negative connected");
+ }
+ finally { Environment.SetEnvironmentVariable(variable, original); }
+ }
+ await TrustFailure("PGSSLROOTCERT", Environment.GetEnvironmentVariable("WRONG_CA")!, "wrong-CA");
+ var addresses = await Dns.GetHostAddressesAsync(Environment.GetEnvironmentVariable("PGHOST")!);
+ await TrustFailure("PGHOST", addresses.First(x => x.AddressFamily == AddressFamily.InterNetwork).ToString(), "wrong-host");
+ await using (var positive = Database.Create("capacity_app"))
+ await using (var conn = await positive.OpenConnectionAsync())
+ Check(await conn.ExecuteScalarAsync("SELECT ssl FROM pg_stat_ssl WHERE pid=pg_backend_pid()"), "positive same-service TLS");
+ Console.WriteLine("PASS same-service official-CA/DNS positive TLS control");
+ Console.WriteLine("All seven Cloud groups passed; browser suite uses a freshly reseeded owner fixture.");
+ }
+}
diff --git a/applications/capacity-board/Tests/Program.cs b/applications/capacity-board/Tests/Program.cs
new file mode 100644
index 00000000..8b0c03f8
--- /dev/null
+++ b/applications/capacity-board/Tests/Program.cs
@@ -0,0 +1,45 @@
+using CapacityBoard.Domain;
+
+if (args.Contains("--cloud"))
+{
+ await CloudChecks.Run();
+ return;
+}
+
+void Check(bool condition) { if (!condition) throw new Exception("Assertion failed"); }
+void Rejected(Action action)
+{
+ try { action(); }
+ catch (BoardError) { return; }
+ throw new Exception("Expected validation rejection");
+}
+var id = Guid.NewGuid();
+var operation = Guid.NewGuid();
+var original = new SaveRequest(operation, 1, [new WorkItem(id, " Demo ", 4)]);
+var canonical = Allocation.Validate(original);
+Check(canonical.Items[0].Name == "Demo");
+Check(canonical.Fingerprint == Allocation.Validate(original with
+{ Items = [new WorkItem(id, "Demo", 4)] }).Fingerprint);
+Check(canonical.Fingerprint != Allocation.Validate(original with
+{ Items = [new WorkItem(id, "Demo", 5)] }).Fingerprint);
+Console.WriteLine("PASS canonical exact-payload fingerprints");
+Rejected(() => Allocation.Validate(original with { Items = [original.Items[0], original.Items[0]] }));
+Rejected(() => Allocation.Validate(original with
+{
+ Items = Enumerable.Range(0, 21)
+ .Select(_ => new WorkItem(Guid.NewGuid(), "Item", 1)).ToArray()
+}));
+Rejected(() => Allocation.Validate(original with { OperationId = Guid.Empty }));
+Rejected(() => Allocation.Validate(original with { ExpectedRevision = int.MaxValue }));
+Console.WriteLine("PASS complete-set UUID/count/revision bounds");
+Check(Allocation.Name("✨ café") == "✨ café");
+Check(Allocation.Name(new string('a', 80)).Length == 80);
+Rejected(() => Allocation.Name(new string('a', 81)));
+Rejected(() => Allocation.Name("bad\0name"));
+Rejected(() => Allocation.Name("\ud800"));
+Rejected(() => Allocation.Name("\udc00"));
+Console.WriteLine("PASS Unicode/control/UTF-16 boundary");
+foreach (var value in new[] { "1.5", "-1", "31", "1000000000000", "", " 2" })
+ Rejected(() => Allocation.Points(value));
+Check(Allocation.Points("0") == 0 && Allocation.Points("30") == 30);
+Console.WriteLine("PASS bounded integer form parsing");
diff --git a/applications/capacity-board/Tests/Tests.csproj b/applications/capacity-board/Tests/Tests.csproj
new file mode 100644
index 00000000..7b3113bc
--- /dev/null
+++ b/applications/capacity-board/Tests/Tests.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ CapacityBoard.Tests
+ net10.0
+ enable
+ enable
+ true
+ true
+
+
+
diff --git a/applications/capacity-board/Tests/packages.lock.json b/applications/capacity-board/Tests/packages.lock.json
new file mode 100644
index 00000000..0b0c5c2a
--- /dev/null
+++ b/applications/capacity-board/Tests/packages.lock.json
@@ -0,0 +1,40 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "Dapper": {
+ "type": "Transitive",
+ "resolved": "2.1.89",
+ "contentHash": "1VjFE664wnxvuhlgP8kS4PeFFF4jqD3VkbIHW6Zlc52zuibgcz5ICnIHcQXlTp6pURpPNGLV0ZpNe5x9KePJnA=="
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.0",
+ "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA=="
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.0",
+ "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0"
+ }
+ },
+ "Npgsql": {
+ "type": "Transitive",
+ "resolved": "10.0.3",
+ "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.0"
+ }
+ },
+ "capacityboard": {
+ "type": "Project",
+ "dependencies": {
+ "Dapper": "[2.1.89, 2.1.89]",
+ "Npgsql": "[10.0.3, 10.0.3]"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/applications/capacity-board/global.json b/applications/capacity-board/global.json
new file mode 100644
index 00000000..fb1af520
--- /dev/null
+++ b/applications/capacity-board/global.json
@@ -0,0 +1 @@
+{"sdk":{"version":"10.0.401","rollForward":"disable"}}
diff --git a/applications/capacity-board/packages.lock.json b/applications/capacity-board/packages.lock.json
new file mode 100644
index 00000000..081af35d
--- /dev/null
+++ b/applications/capacity-board/packages.lock.json
@@ -0,0 +1,25 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "Dapper": {
+ "type": "Direct",
+ "requested": "[2.1.89, 2.1.89]",
+ "resolved": "2.1.89",
+ "contentHash": "1VjFE664wnxvuhlgP8kS4PeFFF4jqD3VkbIHW6Zlc52zuibgcz5ICnIHcQXlTp6pURpPNGLV0ZpNe5x9KePJnA=="
+ },
+ "Microsoft.AspNetCore.App.Internal.Assets": {
+ "type": "Direct",
+ "requested": "[10.0.12, )",
+ "resolved": "10.0.12",
+ "contentHash": "+sIBo9AMBZH46qHWHQdfzcGMt+RuBaPYrUMOAM9VjA7MQ9QtIez3lq1C3E05+Yroa94KKt3DSWaxmCI4H28a9Q=="
+ },
+ "Npgsql": {
+ "type": "Direct",
+ "requested": "[10.0.3, 10.0.3]",
+ "resolved": "10.0.3",
+ "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w=="
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/applications/capacity-board/sql/bootstrap.sql b/applications/capacity-board/sql/bootstrap.sql
new file mode 100644
index 00000000..79e6103b
--- /dev/null
+++ b/applications/capacity-board/sql/bootstrap.sql
@@ -0,0 +1,7 @@
+\set ON_ERROR_STOP on
+CREATE ROLE capacity_migration LOGIN PASSWORD :'MIGRATION_PASSWORD';
+CREATE ROLE capacity_app LOGIN PASSWORD :'APP_PASSWORD';
+REVOKE CREATE ON SCHEMA public FROM PUBLIC;
+REVOKE CREATE, TEMP ON DATABASE postgres FROM PUBLIC;
+GRANT CONNECT ON DATABASE postgres TO capacity_migration, capacity_app;
+CREATE SCHEMA capacity_board AUTHORIZATION capacity_migration;
diff --git a/applications/capacity-board/sql/cleanup.sql b/applications/capacity-board/sql/cleanup.sql
new file mode 100644
index 00000000..f76f439e
--- /dev/null
+++ b/applications/capacity-board/sql/cleanup.sql
@@ -0,0 +1,5 @@
+\set ON_ERROR_STOP on
+DROP SCHEMA IF EXISTS capacity_board CASCADE;
+DROP OWNED BY capacity_app, capacity_migration;
+DROP ROLE capacity_app;
+DROP ROLE capacity_migration;
diff --git a/applications/capacity-board/sql/grants.sql b/applications/capacity-board/sql/grants.sql
new file mode 100644
index 00000000..730cd737
--- /dev/null
+++ b/applications/capacity-board/sql/grants.sql
@@ -0,0 +1,6 @@
+\set ON_ERROR_STOP on
+GRANT USAGE ON SCHEMA capacity_board TO capacity_app;
+GRANT SELECT ON capacity_board.boards, capacity_board.work_items, capacity_board.saves TO capacity_app;
+GRANT UPDATE (revision) ON capacity_board.boards TO capacity_app;
+GRANT INSERT, DELETE ON capacity_board.work_items TO capacity_app;
+GRANT INSERT ON capacity_board.saves TO capacity_app;
diff --git a/applications/capacity-board/sql/migrate.sql b/applications/capacity-board/sql/migrate.sql
new file mode 100644
index 00000000..d8bc5c97
--- /dev/null
+++ b/applications/capacity-board/sql/migrate.sql
@@ -0,0 +1,40 @@
+BEGIN;
+SELECT pg_advisory_xact_lock(7311002);
+CREATE TABLE IF NOT EXISTS capacity_board.schema_versions (
+ version integer PRIMARY KEY,
+ applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
+);
+DO $migration$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM capacity_board.schema_versions WHERE version = 1) THEN
+ CREATE TABLE capacity_board.boards (
+ id uuid PRIMARY KEY,
+ title varchar(80) NOT NULL,
+ capacity integer NOT NULL CHECK (capacity BETWEEN 1 AND 30),
+ revision integer NOT NULL DEFAULT 1 CHECK (revision BETWEEN 1 AND 26)
+ );
+ CREATE TABLE capacity_board.work_items (
+ board_id uuid NOT NULL REFERENCES capacity_board.boards(id),
+ id uuid NOT NULL CHECK (id <> '00000000-0000-0000-0000-000000000000'),
+ position integer NOT NULL CHECK (position BETWEEN 0 AND 19),
+ name varchar(80) NOT NULL CHECK (
+ char_length(btrim(name)) BETWEEN 1 AND 80 AND name !~ '[[:cntrl:]]'
+ ),
+ points integer NOT NULL CHECK (points BETWEEN 0 AND 30),
+ PRIMARY KEY (board_id, id),
+ UNIQUE (board_id, position)
+ );
+ CREATE TABLE capacity_board.saves (
+ operation_id uuid PRIMARY KEY CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'),
+ board_id uuid NOT NULL REFERENCES capacity_board.boards(id),
+ fingerprint text NOT NULL CHECK (fingerprint ~ '^[0-9A-F]{64}$'),
+ revision integer NOT NULL CHECK (revision BETWEEN 2 AND 26),
+ response jsonb NOT NULL CHECK (octet_length(response::text) <= 16000),
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ UNIQUE (board_id, revision)
+ );
+ INSERT INTO capacity_board.schema_versions(version) VALUES (1);
+ END IF;
+END
+$migration$;
+COMMIT;
diff --git a/applications/capacity-board/sql/seed.sql b/applications/capacity-board/sql/seed.sql
new file mode 100644
index 00000000..81277397
--- /dev/null
+++ b/applications/capacity-board/sql/seed.sql
@@ -0,0 +1,16 @@
+\set ON_ERROR_STOP on
+BEGIN;
+SELECT pg_advisory_xact_lock(7311002);
+DO $seed$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM capacity_board.boards WHERE id = '11111111-1111-4111-8111-111111111111') THEN
+ INSERT INTO capacity_board.boards(id,title,capacity) VALUES
+ ('11111111-1111-4111-8111-111111111111','Workshop sprint',30);
+ INSERT INTO capacity_board.work_items(board_id,id,position,name,points) VALUES
+ ('11111111-1111-4111-8111-111111111111','22222222-2222-4222-8222-222222222222',0,'Prepare the demo',5),
+ ('11111111-1111-4111-8111-111111111111','33333333-3333-4333-8333-333333333333',1,'Review the guide',3),
+ ('11111111-1111-4111-8111-111111111111','44444444-4444-4444-8444-444444444444',2,'Record the walkthrough',1);
+ END IF;
+END
+$seed$;
+COMMIT;
diff --git a/applications/capacity-board/test/browser.mjs b/applications/capacity-board/test/browser.mjs
new file mode 100644
index 00000000..5906a968
--- /dev/null
+++ b/applications/capacity-board/test/browser.mjs
@@ -0,0 +1,248 @@
+import assert from 'node:assert/strict';
+import { spawn, execFileSync } from 'node:child_process';
+import { once } from 'node:events';
+import { createWriteStream, mkdirSync } from 'node:fs';
+import { join } from 'node:path';
+import http from 'node:http';
+import { randomBytes } from 'node:crypto';
+import { chromium } from 'playwright';
+
+const origin = process.env.APP_ORIGIN || 'http://127.0.0.1:5000';
+const evidence = process.env.EVIDENCE_DIR;
+assert(evidence, 'EVIDENCE_DIR must point outside the repository');
+mkdirSync(evidence, { recursive: true });
+const connectionFields = ['PGHOST', 'PGPORT', 'PGDATABASE', 'PGSSLROOTCERT'];
+const owner = { PATH: process.env.PATH, PGSSLMODE: 'verify-full',
+ PGUSER: 'capacity_migration', PGPASSWORD: process.env.MIGRATION_PASSWORD };
+const observer = { ...owner, PGUSER: process.env.ADMIN_USER, PGPASSWORD: process.env.ADMIN_PASSWORD };
+for (const field of connectionFields) { owner[field] = process.env[field]; observer[field] = process.env[field]; }
+const runtime = { PATH: process.env.PATH, DOTNET_ROOT: process.env.DOTNET_ROOT,
+ APP_ORIGIN: origin, ASPNETCORE_ENVIRONMENT: 'Production', PGUSER: 'capacity_app', PGPASSWORD: process.env.PGPASSWORD };
+for (const field of connectionFields) runtime[field] = process.env[field];
+const sql = (query, env = owner) => execFileSync('psql', ['-X', '-v', 'ON_ERROR_STOP=1', '-tAc', query],
+ { env, encoding: 'utf8', timeout: 10000 }).trim();
+async function sqlSession(env) {
+ const client = spawn('psql', ['-X', '-qAt', '-P', 'pager=off', '-v', 'ON_ERROR_STOP=1'], { env });
+ let pending;
+ let buffer = '';
+ client.stdout.on('data', chunk => {
+ buffer += chunk;
+ let newline;
+ while ((newline = buffer.indexOf('\n')) >= 0) {
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
+ if (!pending) continue;
+ if (line === pending.marker) { clearTimeout(pending.timer); pending.resolve(pending.rows.join('\n')); pending = null; }
+ else pending.rows.push(line);
+ }
+ });
+ client.on('exit', () => { if (pending) pending.reject(new Error('SQL control session exited')); });
+ const query = statement => new Promise((resolve, reject) => {
+ assert(!pending, 'SQL controls are sequential');
+ const marker = randomBytes(8).toString('hex');
+ const timer = setTimeout(() => reject(new Error('SQL control timeout')), 5000);
+ pending = { marker, timer, resolve, reject, rows: [] };
+ client.stdin.write(statement + `;\nSELECT '${marker}';\n`);
+ });
+ await query('SELECT 1');
+ return { query, close: async () => { const exited = once(client, 'exit'); client.stdin.end(); await exited; } };
+}
+const snapshotQuery = `SELECT json_build_object('revision',b.revision,'items',
+ (SELECT json_agg(json_build_object('id',id,'name',name,'points',points) ORDER BY position)
+ FROM capacity_board.work_items WHERE board_id=b.id))::text FROM capacity_board.boards b`;
+const snapshot = () => sql(`SELECT json_build_object('revision',b.revision,'items',
+ (SELECT json_agg(json_build_object('id',id,'name',name,'points',points) ORDER BY position)
+ FROM capacity_board.work_items WHERE board_id=b.id))::text FROM capacity_board.boards b`);
+let child;
+async function start() {
+ child = spawn(process.env.CAPACITY_EXECUTABLE, [], { env: runtime });
+ const log = createWriteStream(join(evidence, `server-${child.pid}.log`));
+ child.stdout.pipe(log); child.stderr.pipe(log);
+ for (let attempt = 0; attempt < 100; attempt++) {
+ if (child.exitCode !== null) throw new Error('server exited before readiness');
+ try { if ((await fetch(`${origin}/health`)).status === 200) return child.pid; } catch {}
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ throw new Error('server did not become ready');
+}
+async function stop() {
+ const ending = child;
+ const exited = once(ending, 'exit');
+ ending.kill('SIGTERM');
+ const [code] = await Promise.race([exited, new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('server shutdown timeout')), 15000).unref())]);
+ assert.equal(code, 0, 'original process must exit successfully before replacement');
+ assert.notEqual(ending.exitCode, null);
+ return ending.pid;
+}
+async function socketStatus(token, sentOrigin) {
+ return new Promise((resolve, reject) => {
+ const request = http.request(`${origin}/_blazor?id=${encodeURIComponent(token)}`, {
+ headers: { Upgrade: 'websocket', Connection: 'Upgrade', 'Sec-WebSocket-Version': '13',
+ 'Sec-WebSocket-Key': randomBytes(16).toString('base64'), ...(sentOrigin ? { Origin: sentOrigin } : {}) }
+ });
+ request.on('response', response => { response.resume(); resolve(response.statusCode); });
+ request.on('upgrade', (_, socket) => { socket.destroy(); resolve(101); });
+ request.on('error', reject); request.setTimeout(3000, () => request.destroy(new Error('handshake timeout')));
+ request.end();
+ });
+}
+const expectMessage = async (page, text) => page.getByRole('status').filter({ hasText: text }).waitFor();
+const load = async page => {
+ await page.goto(origin);
+ await expectMessage(page, 'Committed allocation loaded');
+};
+const waitRevision = async (page, value) => {
+ await page.waitForFunction(value => document.querySelector('#loaded-revision')?.textContent === String(value), value);
+};
+const save = page => page.getByRole('button', { name: 'Save allocation', exact: true }).click();
+let browser;
+let holder;
+let control;
+try {
+ const firstPid = await start();
+ assert.equal((await fetch(`${origin}/_blazor/negotiate?negotiateVersion=1`, { method: 'POST' })).status, 403);
+ assert.equal((await fetch(`${origin}/_blazor/negotiate?negotiateVersion=1`, {
+ method: 'POST', headers: { Origin: 'https://evil.example' } })).status, 403);
+ const negotiated = await fetch(`${origin}/_blazor/negotiate?negotiateVersion=1`, {
+ method: 'POST', headers: { Origin: origin } });
+ assert.equal(negotiated.status, 200);
+ const negotiation = await negotiated.json();
+ assert.deepEqual(negotiation.availableTransports.map(value => value.transport), ['WebSockets', 'LongPolling']);
+ assert.equal(await socketStatus(negotiation.connectionToken, 'https://evil.example'), 403);
+ assert.equal(await socketStatus(negotiation.connectionToken), 403);
+ // Static component form posts retain the framework antiforgery path.
+ const unprotectedForm = await fetch(origin, { method: 'POST', headers: { Origin: origin,
+ 'Content-Type': 'application/x-www-form-urlencoded' }, body: '_handler=allocation' });
+ assert.equal(unprotectedForm.status, 400);
+ console.log('PASS real negotiation/WebSocket Origin rejection, configured transports and native antiforgery rejection');
+
+ browser = await chromium.launch({ headless: true });
+ const errors = [];
+ const contextA = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
+ const contextB = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
+ const a = await contextA.newPage(); const b = await contextB.newPage();
+ for (const page of [a, b]) page.on('pageerror', error => errors.push(error.message));
+ await load(a); await load(b);
+ assert.equal(await a.locator('#committed-total').textContent(), '9');
+ await a.getByLabel('Item 1 points', { exact: true }).fill('8');
+ await b.getByLabel('Item 1 name', { exact: true }).fill('Second circuit draft');
+ await b.getByLabel('Item 1 points', { exact: true }).fill('7');
+ await save(a); await expectMessage(a, 'Saved revision 2');
+ await save(b); await expectMessage(b, 'Newer committed state exists');
+ assert.equal(await b.getByLabel('Item 1 name', { exact: true }).inputValue(), 'Second circuit draft');
+ assert.equal(await b.getByLabel('Item 1 points', { exact: true }).inputValue(), '7');
+ await waitRevision(b, 1);
+ assert.equal(JSON.parse(snapshot()).revision, 2);
+ console.log('PASS two actual circuits, stale rejection and preserved draft/displayed revision');
+
+ await b.getByRole('button', { name: 'Reload committed state' }).click(); await waitRevision(b, 2);
+ await b.getByLabel('Item 1 points', { exact: true }).fill('30');
+ await b.getByLabel('Item 2 points', { exact: true }).fill('30');
+ const beforeCapacity = snapshot();
+ await save(b); await expectMessage(b, 'draft exceeds capacity');
+ assert.equal(snapshot(), beforeCapacity);
+ assert.equal(await b.getByLabel('Item 1 points', { exact: true }).inputValue(), '30');
+ await b.getByLabel('Item 1 points', { exact: true }).fill('-1');
+ await save(b); await expectMessage(b, 'whole number from 0 to 30');
+ assert.equal(snapshot(), beforeCapacity);
+ await b.getByRole('button', { name: 'Reload committed state' }).click();
+ await expectMessage(b, 'Committed allocation loaded');
+ await b.getByLabel('Item 1 name', { exact: true }).fill('Bad\u0000name');
+ await save(b); await expectMessage(b, 'names of 1–80');
+ assert.equal(snapshot(), beforeCapacity);
+ console.log('PASS real overcapacity/invalid-integer/NUL rejection without losing the draft or committing changes');
+
+ sql(`CREATE FUNCTION capacity_board.hold_commit() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN IF NEW.response::text LIKE '%Commit held demo%' THEN PERFORM pg_advisory_xact_lock(7521002); END IF;
+ RETURN NEW; END $$;
+ CREATE CONSTRAINT TRIGGER test_hold AFTER INSERT ON capacity_board.saves DEFERRABLE INITIALLY DEFERRED
+ FOR EACH ROW EXECUTE FUNCTION capacity_board.hold_commit();`);
+ control = await sqlSession(observer);
+ holder = spawn('psql', ['-X', '-qAt', '-v', 'ON_ERROR_STOP=1'], { env: owner });
+ holder.stdin.write("SET application_name='capacity-board-held-ui'; SELECT pg_advisory_lock(7521002);\n");
+ let heldReady = false;
+ for (let attempt = 0; attempt < 50; attempt++) {
+ const granted = await control.query("SELECT count(*) FROM pg_locks l JOIN pg_stat_activity a ON a.pid=l.pid WHERE l.locktype='advisory' AND l.granted AND a.application_name='capacity-board-held-ui'");
+ if (granted === '1') { heldReady = true; break; }
+ assert.equal(holder.exitCode, null, 'holder must remain alive');
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ assert(heldReady, 'independent observer must see the actual granted advisory lock');
+ await a.getByLabel('Item 1 name', { exact: true }).fill('Commit held demo');
+ const beforeHeld = snapshot();
+ await save(a);
+ await a.getByRole('button', { name: 'Saving…' }).waitFor();
+ let observed = false;
+ for (let attempt = 0; attempt < 40; attempt++) {
+ const count = await control.query("SELECT pg_stat_clear_snapshot(); SELECT count(*) FROM pg_stat_activity WHERE application_name='capacity-board' AND cardinality(pg_blocking_pids(pid))>0");
+ if (count.trim() === '1') { observed = true; break; }
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ assert(observed, 'actual COMMIT blocked in deferred trigger');
+ assert.equal((await control.query(snapshotQuery)).trim(), beforeHeld);
+ await waitRevision(a, 2);
+ assert(await a.getByRole('button', { name: 'Saving…' }).isDisabled());
+ const released = once(holder, 'exit');
+ holder.stdin.end('SELECT pg_advisory_unlock(7521002);\n');
+ await released; holder = null;
+ await expectMessage(a, 'Saved revision 3');
+ await control.close(); control = null;
+ sql('DROP TRIGGER test_hold ON capacity_board.saves; DROP FUNCTION capacity_board.hold_commit()');
+ console.log('PASS actual deferred COMMIT hold: UI stays pending and shows saved state only after release');
+
+ sql(`CREATE FUNCTION capacity_board.reject_ui_commit() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN IF NEW.response::text LIKE '%Uncertain demo%' THEN RAISE EXCEPTION 'test-only UI commit failure' USING ERRCODE='23514'; END IF;
+ RETURN NEW; END $$;
+ CREATE CONSTRAINT TRIGGER test_reject AFTER INSERT ON capacity_board.saves DEFERRABLE INITIALLY DEFERRED
+ FOR EACH ROW EXECUTE FUNCTION capacity_board.reject_ui_commit();`);
+ await a.getByLabel('Item 1 name', { exact: true }).fill('Uncertain demo');
+ await a.getByLabel('Item 1 points', { exact: true }).fill('9');
+ const beforeFailure = snapshot(); const beforeAudits = Number(sql('SELECT count(*) FROM capacity_board.saves'));
+ await save(a); await expectMessage(a, "save couldn't be confirmed");
+ assert.equal(snapshot(), beforeFailure);
+ assert.equal(Number(sql('SELECT count(*) FROM capacity_board.saves')), beforeAudits);
+ assert(await a.getByLabel('Item 1 name', { exact: true }).isDisabled());
+ assert.equal(await a.getByLabel('Item 1 name', { exact: true }).inputValue(), 'Uncertain demo');
+ sql('DROP TRIGGER test_reject ON capacity_board.saves; DROP FUNCTION capacity_board.reject_ui_commit()');
+ await a.getByRole('button', { name: 'Retry original save' }).click(); await expectMessage(a, 'Saved revision 4');
+ assert.equal(Number(sql('SELECT count(*) FROM capacity_board.saves')), beforeAudits + 1);
+ console.log('PASS actual COMMIT error gives uncertain-save UI, paused exact payload and successful original retry');
+
+ await a.getByLabel('Item 1 name', { exact: true }).fill('Unsaved browser value');
+ const durable = snapshot();
+ assert.equal(await stop(), firstPid);
+ const secondPid = await start(); assert.notEqual(secondPid, firstPid);
+ const contextC = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
+ const c = await contextC.newPage(); c.on('pageerror', error => errors.push(error.message));
+ await load(c); await waitRevision(c, 4);
+ assert.equal(snapshot(), durable);
+ assert.equal(await c.getByLabel('Item 1 name', { exact: true }).inputValue(), 'Uncertain demo');
+ console.log(`PASS original process ${firstPid} exited 0 before replacement ${secondPid}; new circuit reloads committed state`);
+
+ await c.getByLabel('Item 1 name', { exact: true }).fill('Café demo ✨');
+ await c.getByLabel('Item 1 points', { exact: true }).fill('7');
+ await save(c); await expectMessage(c, 'Saved revision 5');
+ await c.screenshot({ path: join(evidence, 'desktop.png'), fullPage: true });
+ const mobileContext = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true });
+ const mobile = await mobileContext.newPage(); mobile.on('pageerror', error => errors.push(error.message));
+ await load(mobile);
+ await mobile.screenshot({ path: join(evidence, 'mobile.png'), fullPage: true });
+ for (let index = 3; index < 20; index++) await mobile.getByRole('button', { name: 'Add work item' }).click();
+ await mobile.getByLabel('Item 20 name', { exact: true }).waitFor();
+ await mobile.waitForFunction(() => [...document.querySelectorAll('button')].find(button => button.textContent === 'Add work item')?.disabled);
+ assert(await mobile.getByRole('button', { name: 'Add work item' }).isDisabled());
+ await mobile.getByLabel('Item 20 name', { exact: true }).fill('Long synthetic item '.repeat(4));
+ assert(await mobile.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
+ await mobile.screenshot({ path: join(evidence, 'mobile-max-draft.png'), fullPage: true });
+ assert.equal(errors.length, 0, JSON.stringify(errors));
+ console.log('PASS escaped Unicode, desktop/mobile, bounded 20-item long-label draft and no page errors');
+ console.log('All seven real browser groups passed; no unsaved-draft persistence across process restart is claimed');
+} finally {
+ if (holder && holder.exitCode === null) { holder.kill('SIGTERM'); await once(holder, 'exit'); }
+ for (const [trigger, table, fn] of [['test_hold', 'saves', 'hold_commit'], ['test_reject', 'saves', 'reject_ui_commit']]) {
+ try { sql(`DROP TRIGGER IF EXISTS ${trigger} ON capacity_board.${table}; DROP FUNCTION IF EXISTS capacity_board.${fn}()`); } catch {}
+ }
+ if (control) await control.close();
+ if (browser) await browser.close();
+ if (child && child.exitCode === null) { child.kill('SIGTERM'); await once(child, 'exit'); }
+}
diff --git a/applications/capacity-board/test/package-lock.json b/applications/capacity-board/test/package-lock.json
new file mode 100644
index 00000000..d844c259
--- /dev/null
+++ b/applications/capacity-board/test/package-lock.json
@@ -0,0 +1,43 @@
+{
+ "name": "capacity-board-browser-checks",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "capacity-board-browser-checks",
+ "devDependencies": {
+ "playwright": "1.63.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/applications/capacity-board/test/package.json b/applications/capacity-board/test/package.json
new file mode 100644
index 00000000..f7fa984b
--- /dev/null
+++ b/applications/capacity-board/test/package.json
@@ -0,0 +1 @@
+{"name":"capacity-board-browser-checks","private":true,"type":"module","devDependencies":{"playwright":"1.63.0"}}
diff --git a/applications/capacity-board/test/preflight.py b/applications/capacity-board/test/preflight.py
new file mode 100644
index 00000000..a5c6dd23
--- /dev/null
+++ b/applications/capacity-board/test/preflight.py
@@ -0,0 +1,35 @@
+import os
+import signal
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+
+exe = sys.argv[1]
+env = {**os.environ, "APP_ORIGIN": "http://127.0.0.1:5000"}
+child = subprocess.Popen([exe, "--preflight"], env=env, stdout=subprocess.DEVNULL)
+try:
+ for attempt in range(100):
+ try:
+ with urllib.request.urlopen("http://127.0.0.1:5000/health", timeout=1) as response:
+ assert response.status == 200
+ break
+ except (OSError, urllib.error.URLError):
+ time.sleep(0.1)
+ else:
+ raise AssertionError("compiled listener did not start")
+ for headers in [{"Host": "evil.example"}, {"Origin": "https://evil.example"}]:
+ request = urllib.request.Request("http://127.0.0.1:5000/health", headers=headers)
+ try:
+ urllib.request.urlopen(request, timeout=2)
+ raise AssertionError("untrusted host/origin accepted")
+ except urllib.error.HTTPError as error:
+ assert error.code == 403
+ child.send_signal(signal.SIGTERM)
+ assert child.wait(timeout=15) == 0
+ print("PASS compiled loopback listener, Host/Origin rejection and confirmed SIGTERM exit")
+finally:
+ if child.poll() is None:
+ child.kill()
+ child.wait(timeout=5)
diff --git a/applications/capacity-board/wwwroot/app.css b/applications/capacity-board/wwwroot/app.css
new file mode 100644
index 00000000..0b245465
--- /dev/null
+++ b/applications/capacity-board/wwwroot/app.css
@@ -0,0 +1,173 @@
+* {
+ box-sizing:border-box
+}
+body {
+ margin:0;
+ background:#f4f6fa;
+ color:#142238;
+ font:16px/1.55 system-ui,sans-serif
+}
+main {
+ max-width:980px;
+ margin:40px auto;
+ padding:0 24px
+}
+h1 {
+ font-size:42px;
+ letter-spacing:-1.4px;
+ margin:6px 0
+}
+h2 {
+ margin:0;
+ font-size:24px
+}
+.eyebrow {
+ font-size:12px;
+ letter-spacing:2px;
+ font-weight:700;
+ color:#366c87
+}
+header p {
+ margin:8px 0
+}
+.scope,.hint,.footnote {
+ color:#53677a;
+ font-size:14px
+}
+.summary {
+ display:grid;
+ grid-template-columns:repeat(3,1fr);
+ gap:16px;
+ margin:30px 0
+}
+.summary div,.editor {
+ background:white;
+ border:1px solid #dbe3ed;
+ border-radius:14px;
+ padding:22px
+}
+.summary span {
+ display:block;
+ color:#53677a;
+ font-size:14px
+}
+.summary strong {
+ display:block;
+ font-size:32px
+}
+.editor-heading {
+ display:flex;
+ justify-content:space-between;
+ gap:20px;
+ align-items:center
+}
+.item {
+ display:grid;
+ grid-template-columns:1fr 100px 150px;
+ gap:14px;
+ align-items:end;
+ margin:18px 0
+}
+label {
+ font-size:14px;
+ font-weight:600
+}
+input {
+ display:block;
+ width:100%;
+ border:1px solid #afbdcb;
+ border-radius:7px;
+ padding:11px;
+ font:inherit;
+ color:inherit;
+ margin-top:5px
+}
+input:focus,button:focus {
+ outline:3px solid #afdaef;
+ outline-offset:2px
+}
+button {
+ border:1px solid #285f7a;
+ border-radius:7px;
+ padding:11px 16px;
+ background:#285f7a;
+ color:white;
+ font:600 14px system-ui;
+ cursor:pointer
+}
+.secondary {
+ background:white;
+ color:#285f7a
+}
+button:disabled {
+ opacity:.55;
+ cursor:default
+}
+.actions {
+ display:flex;
+ gap:12px;
+ flex-wrap:wrap;
+ margin-top:24px
+}
+fieldset {
+ border:0;
+ padding:0;
+ margin:0;
+ min-width:0
+}
+.message {
+ padding:12px 0 0;
+ color:#285f7a
+}
+.reconnect {
+ display:none
+}
+.components-reconnect-show,.components-reconnect-failed,.components-reconnect-rejected {
+ display:block;
+ position:fixed;
+ bottom:20px;
+ left:20px;
+ right:20px;
+ background:#fff6da;
+ border:1px solid #b3954f;
+ border-radius:10px;
+ padding:20px;
+ z-index:20
+}
+@media(max-width:640px) {
+ main {
+ margin:24px auto;
+ padding:0 16px
+ }
+ h1 {
+ font-size:34px
+ }
+ .summary {
+ gap:8px
+ }
+ .summary div {
+ padding:13px
+ }
+ .summary span {
+ font-size:12px
+ }
+ .summary strong {
+ font-size:28px
+ }
+ .editor {
+ padding:18px
+ }
+ .editor-heading {
+ display:block
+ }
+ .item {
+ grid-template-columns:1fr 80px
+ }
+ .item button {
+ grid-column:1 / -1;
+ text-align:left
+ }
+ .actions button {
+ width:100%
+ }
+}