diff --git a/.github/workflows/habit-checkins.yml b/.github/workflows/habit-checkins.yml
new file mode 100644
index 00000000..025d191f
--- /dev/null
+++ b/.github/workflows/habit-checkins.yml
@@ -0,0 +1,33 @@
+name: Habit check-ins checks
+
+on:
+ pull_request:
+ paths:
+ - 'applications/habit-checkins/**'
+ - '.github/workflows/habit-checkins.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'applications/habit-checkins/**'
+ - '.github/workflows/habit-checkins.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: ubuntu-24.04
+ defaults:
+ run:
+ working-directory: applications/habit-checkins
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
+ with:
+ dotnet-version: '10.0.401'
+ - run: dotnet restore --locked-mode
+ - run: dotnet tool restore
+ - run: dotnet build -c Release --no-restore
+ - run: dotnet test -c Release --no-build --filter 'Category!=Cloud'
+# Cloud acceptance is explicit, with a dedicated service and no Cloud secrets in CI.
diff --git a/applications/habit-checkins/.config/dotnet-tools.json b/applications/habit-checkins/.config/dotnet-tools.json
new file mode 100644
index 00000000..1611eda7
--- /dev/null
+++ b/applications/habit-checkins/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "10.0.12",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
+}
diff --git a/applications/habit-checkins/.env.example b/applications/habit-checkins/.env.example
new file mode 100644
index 00000000..36fc6cc6
--- /dev/null
+++ b/applications/habit-checkins/.env.example
@@ -0,0 +1,9 @@
+PGHOST=your-cloud-service-hostname
+PGPORT=5432
+PGDATABASE=postgres
+PGUSER=habit_app
+PGPASSWORD=replace-with-runtime-password
+PGSSLMODE=verify-full
+PGSSLROOTCERT=/absolute/path/cloud-ca.pem
+APP_TOKENS=user-a:replace-with-random-token-at-least-32-characters,user-b:another-random-token-at-least-32-characters
+APP_URL=http://127.0.0.1:8080
diff --git a/applications/habit-checkins/.gitignore b/applications/habit-checkins/.gitignore
new file mode 100644
index 00000000..66ccd933
--- /dev/null
+++ b/applications/habit-checkins/.gitignore
@@ -0,0 +1,4 @@
+**/bin/
+**/obj/
+.env
+*.pem
diff --git a/applications/habit-checkins/Directory.Build.props b/applications/habit-checkins/Directory.Build.props
new file mode 100644
index 00000000..c20d1030
--- /dev/null
+++ b/applications/habit-checkins/Directory.Build.props
@@ -0,0 +1,8 @@
+
+
+ enable
+ enable
+ true
+ true
+
+
diff --git a/applications/habit-checkins/HabitApi/CalendarContract.cs b/applications/habit-checkins/HabitApi/CalendarContract.cs
new file mode 100644
index 00000000..67cbe281
--- /dev/null
+++ b/applications/habit-checkins/HabitApi/CalendarContract.cs
@@ -0,0 +1,31 @@
+using System.Globalization;
+namespace HabitApi;
+
+public static class CalendarContract
+{
+ public static readonly DateOnly FirstDate = new(2000, 1, 1);
+ public static readonly DateOnly LastDate = new(2100, 12, 31);
+
+ public static DateOnly Date(string input)
+ {
+ if (!DateOnly.TryParseExact(input, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
+ || date < FirstDate || date > LastDate)
+ throw new ApiProblem(400, "date_must_be_yyyy_mm_dd_between_2000_and_2100");
+ return date;
+ }
+
+ public static (DateOnly Start, DateOnly End) Month(string input)
+ {
+ if (input.Length != 7) throw new ApiProblem(400, "month_must_be_yyyy_mm");
+ var start = Date(input + "-01");
+ return (start, start.AddMonths(1));
+ }
+}
+
+public sealed class ApiProblem(int status, string code) : Exception(code)
+{
+ public int Status
+ {
+ get;
+ } = status;
+}
diff --git a/applications/habit-checkins/HabitApi/HabitApi.csproj b/applications/habit-checkins/HabitApi/HabitApi.csproj
new file mode 100644
index 00000000..e16f37b0
--- /dev/null
+++ b/applications/habit-checkins/HabitApi/HabitApi.csproj
@@ -0,0 +1,15 @@
+
+
+ net10.0
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
diff --git a/applications/habit-checkins/HabitApi/HabitDb.cs b/applications/habit-checkins/HabitApi/HabitDb.cs
new file mode 100644
index 00000000..ed4b6954
--- /dev/null
+++ b/applications/habit-checkins/HabitApi/HabitDb.cs
@@ -0,0 +1,105 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+namespace HabitApi;
+
+public sealed class Habit
+{
+ public Guid Id
+ {
+ get;
+ set;
+ } = Guid.NewGuid();
+ public required string OwnerId
+ {
+ get;
+ set;
+ }
+ public required string Name
+ {
+ get;
+ set;
+ }
+ public bool Archived
+ {
+ get;
+ set;
+ }
+ public DateTime CreatedAt
+ {
+ get;
+ set;
+ } = DateTime.UtcNow;
+ public List CheckIns
+ {
+ get;
+ set;
+ } = [];
+}
+
+public sealed class CheckIn
+{
+ public Guid Id
+ {
+ get;
+ set;
+ } = Guid.NewGuid();
+ public Guid HabitId
+ {
+ get;
+ set;
+ }
+ public Habit Habit
+ {
+ get;
+ set;
+ } = null!;
+ public DateOnly CompletedOn
+ {
+ get;
+ set;
+ }
+ public DateTime CreatedAt
+ {
+ get;
+ set;
+ } = DateTime.UtcNow;
+}
+
+public sealed class HabitDb(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Habits => Set();
+ public DbSet CheckIns => Set();
+ protected override void OnModelCreating(ModelBuilder model)
+ {
+ model.HasDefaultSchema("habits");
+ model.Entity(e =>
+ {
+ e.ToTable("habit", t => t.HasCheckConstraint("habit_name_nonblank", "length(btrim(name)) > 0"));
+ e.HasKey(x => x.Id);
+ e.Property(x => x.Id).HasColumnName("id").ValueGeneratedNever();
+ e.Property(x => x.OwnerId).HasColumnName("owner_id").HasMaxLength(64).IsRequired();
+ e.Property(x => x.Name).HasColumnName("name").HasMaxLength(100).IsRequired();
+ e.Property(x => x.Archived).HasColumnName("archived");
+ e.Property(x => x.CreatedAt).HasColumnName("created_at");
+ e.HasIndex(x => new { x.OwnerId, x.CreatedAt, x.Id }).HasDatabaseName("habit_owner_created");
+ });
+ model.Entity(e =>
+ {
+ e.ToTable("check_in", t => t.HasCheckConstraint("check_in_date_bound", "completed_on BETWEEN DATE '2000-01-01' AND DATE '2100-12-31'"));
+ e.HasKey(x => x.Id);
+ e.Property(x => x.Id).HasColumnName("id").ValueGeneratedNever();
+ e.Property(x => x.HabitId).HasColumnName("habit_id");
+ e.Property(x => x.CompletedOn).HasColumnName("completed_on").HasColumnType("date");
+ e.Property(x => x.CreatedAt).HasColumnName("created_at");
+ e.HasOne(x => x.Habit).WithMany(x => x.CheckIns).HasForeignKey(x => x.HabitId).OnDelete(DeleteBehavior.Cascade);
+ e.HasIndex(x => new { x.HabitId, x.CompletedOn }).IsUnique().HasDatabaseName("uq_check_in_habit_date");
+ });
+ }
+}
+
+// dotnet-ef uses this explicit design-time path and the credential supplied to its process.
+public sealed class MigrationFactory : IDesignTimeDbContextFactory
+{
+ public HabitDb CreateDbContext(string[] args) => new(new DbContextOptionsBuilder()
+ .UseNpgsql(PgConfig.ConnectionString(), pg => pg.MigrationsHistoryTable("__EFMigrationsHistory", "habits")).Options);
+}
diff --git a/applications/habit-checkins/HabitApi/HabitService.cs b/applications/habit-checkins/HabitApi/HabitService.cs
new file mode 100644
index 00000000..65cf7b81
--- /dev/null
+++ b/applications/habit-checkins/HabitApi/HabitService.cs
@@ -0,0 +1,94 @@
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+namespace HabitApi;
+
+public sealed record HabitView(Guid Id, string Name, bool Archived, DateTime CreatedAt);
+public sealed record CheckInView(Guid Id, DateOnly CompletedOn, DateTime CreatedAt);
+
+public sealed class HabitService(HabitDb db)
+{
+ public async Task Create(string owner, string? name)
+ {
+ if (string.IsNullOrWhiteSpace(name) || name.Length > 100 || name.Contains('\0')) throw new ApiProblem(400, "invalid_habit_name");
+ var habit = new Habit { OwnerId = owner, Name = name.Trim() };
+ db.Habits.Add(habit);
+ await db.SaveChangesAsync();
+ return View(habit);
+ }
+
+ public Task> List(string owner) => db.Habits.AsNoTracking()
+ .Where(h => h.OwnerId == owner).OrderByDescending(h => h.CreatedAt).ThenByDescending(h => h.Id)
+ .Take(100).Select(h => new HabitView(h.Id, h.Name, h.Archived, h.CreatedAt)).ToListAsync();
+
+ private async Task LockedOwned(string owner, Guid id)
+ {
+ // All archive/check-in/undo paths lock this row before inspecting state.
+ var rows = await db.Habits.FromSqlInterpolated($"SELECT * FROM habits.habit WHERE id={id} AND owner_id={owner} FOR UPDATE").ToListAsync();
+ return rows.SingleOrDefault() ?? throw new ApiProblem(404, "habit_not_found");
+ }
+
+ public async Task Complete(string owner, Guid id, DateOnly date)
+ {
+ await using var tx = await db.Database.BeginTransactionAsync();
+ var habit = await LockedOwned(owner, id);
+ var existing = await db.CheckIns.SingleOrDefaultAsync(c => c.HabitId == id && c.CompletedOn == date);
+ if (existing is not null)
+ {
+ await tx.CommitAsync();
+ return View(existing);
+ }
+ if (habit.Archived) throw new ApiProblem(409, "habit_archived");
+ var checkIn = new CheckIn { HabitId = id, CompletedOn = date };
+ db.CheckIns.Add(checkIn);
+ try
+ {
+ await db.SaveChangesAsync();
+ }
+ catch (DbUpdateException e) when (e.InnerException is PostgresException
+ { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "uq_check_in_habit_date" })
+ {
+ // Discard the attempted write and tracking before reading the durable winner.
+ await tx.RollbackAsync();
+ db.ChangeTracker.Clear();
+ var winner = await db.CheckIns.AsNoTracking().SingleAsync(c => c.HabitId == id && c.CompletedOn == date);
+ return View(winner);
+ }
+ await tx.CommitAsync();
+ return View(checkIn);
+ }
+
+ public async Task Undo(string owner, Guid id, DateOnly date)
+ {
+ await using var tx = await db.Database.BeginTransactionAsync();
+ await LockedOwned(owner, id);
+ // Undo is permitted even after archive.
+ var checkIn = await db.CheckIns.SingleOrDefaultAsync(c => c.HabitId == id && c.CompletedOn == date);
+ if (checkIn is not null)
+ {
+ db.CheckIns.Remove(checkIn);
+ await db.SaveChangesAsync();
+ }
+ await tx.CommitAsync();
+ }
+
+ public async Task Archive(string owner, Guid id)
+ {
+ await using var tx = await db.Database.BeginTransactionAsync();
+ var habit = await LockedOwned(owner, id);
+ habit.Archived = true;
+ await db.SaveChangesAsync();
+ await tx.CommitAsync();
+ return View(habit);
+ }
+
+ public async Task> History(string owner, Guid id, string month)
+ {
+ var (start, end) = CalendarContract.Month(month);
+ if (!await db.Habits.AnyAsync(h => h.Id == id && h.OwnerId == owner)) throw new ApiProblem(404, "habit_not_found");
+ return await db.CheckIns.AsNoTracking().Where(c => c.HabitId == id && c.CompletedOn >= start && c.CompletedOn < end)
+ .OrderBy(c => c.CompletedOn).Take(31).Select(c => new CheckInView(c.Id, c.CompletedOn, c.CreatedAt)).ToListAsync();
+ }
+
+ private static HabitView View(Habit h) => new(h.Id, h.Name, h.Archived, h.CreatedAt);
+ private static CheckInView View(CheckIn c) => new(c.Id, c.CompletedOn, c.CreatedAt);
+}
diff --git a/applications/habit-checkins/HabitApi/Migrations/20261002120012_Initial.Designer.cs b/applications/habit-checkins/HabitApi/Migrations/20261002120012_Initial.Designer.cs
new file mode 100644
index 00000000..f1e68738
--- /dev/null
+++ b/applications/habit-checkins/HabitApi/Migrations/20261002120012_Initial.Designer.cs
@@ -0,0 +1,114 @@
+//
+using System;
+using HabitApi;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace HabitApi.Migrations
+{
+ [DbContext(typeof(HabitDb))]
+ [Migration("20261002120012_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("habits")
+ .HasAnnotation("ProductVersion", "10.0.12")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("HabitApi.CheckIn", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CompletedOn")
+ .HasColumnType("date")
+ .HasColumnName("completed_on");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("HabitId")
+ .HasColumnType("uuid")
+ .HasColumnName("habit_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("HabitId", "CompletedOn")
+ .IsUnique()
+ .HasDatabaseName("uq_check_in_habit_date");
+
+ b.ToTable("check_in", "habits", t =>
+ {
+ t.HasCheckConstraint("check_in_date_bound", "completed_on BETWEEN DATE '2000-01-01' AND DATE '2100-12-31'");
+ });
+ });
+
+ modelBuilder.Entity("HabitApi.Habit", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Archived")
+ .HasColumnType("boolean")
+ .HasColumnName("archived");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("name");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("owner_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId", "CreatedAt", "Id")
+ .HasDatabaseName("habit_owner_created");
+
+ b.ToTable("habit", "habits", t =>
+ {
+ t.HasCheckConstraint("habit_name_nonblank", "length(btrim(name)) > 0");
+ });
+ });
+
+ modelBuilder.Entity("HabitApi.CheckIn", b =>
+ {
+ b.HasOne("HabitApi.Habit", "Habit")
+ .WithMany("CheckIns")
+ .HasForeignKey("HabitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Habit");
+ });
+
+ modelBuilder.Entity("HabitApi.Habit", b =>
+ {
+ b.Navigation("CheckIns");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/applications/habit-checkins/HabitApi/Migrations/20261002120012_Initial.cs b/applications/habit-checkins/HabitApi/Migrations/20261002120012_Initial.cs
new file mode 100644
index 00000000..d0ded40d
--- /dev/null
+++ b/applications/habit-checkins/HabitApi/Migrations/20261002120012_Initial.cs
@@ -0,0 +1,83 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace HabitApi.Migrations
+{
+ ///
+ public partial class Initial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "habits");
+
+ migrationBuilder.CreateTable(
+ name: "habit",
+ schema: "habits",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ owner_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ archived = table.Column(type: "boolean", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_habit", x => x.id);
+ table.CheckConstraint("habit_name_nonblank", "length(btrim(name)) > 0");
+ });
+
+ migrationBuilder.CreateTable(
+ name: "check_in",
+ schema: "habits",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ habit_id = table.Column(type: "uuid", nullable: false),
+ completed_on = table.Column(type: "date", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_check_in", x => x.id);
+ table.CheckConstraint("check_in_date_bound", "completed_on BETWEEN DATE '2000-01-01' AND DATE '2100-12-31'");
+ table.ForeignKey(
+ name: "FK_check_in_habit_habit_id",
+ column: x => x.habit_id,
+ principalSchema: "habits",
+ principalTable: "habit",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "uq_check_in_habit_date",
+ schema: "habits",
+ table: "check_in",
+ columns: new[] { "habit_id", "completed_on" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "habit_owner_created",
+ schema: "habits",
+ table: "habit",
+ columns: new[] { "owner_id", "created_at", "id" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "check_in",
+ schema: "habits");
+
+ migrationBuilder.DropTable(
+ name: "habit",
+ schema: "habits");
+ }
+ }
+}
diff --git a/applications/habit-checkins/HabitApi/Migrations/HabitDbModelSnapshot.cs b/applications/habit-checkins/HabitApi/Migrations/HabitDbModelSnapshot.cs
new file mode 100644
index 00000000..efc80fbc
--- /dev/null
+++ b/applications/habit-checkins/HabitApi/Migrations/HabitDbModelSnapshot.cs
@@ -0,0 +1,111 @@
+//
+using System;
+using HabitApi;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace HabitApi.Migrations
+{
+ [DbContext(typeof(HabitDb))]
+ partial class HabitDbModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("habits")
+ .HasAnnotation("ProductVersion", "10.0.12")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("HabitApi.CheckIn", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CompletedOn")
+ .HasColumnType("date")
+ .HasColumnName("completed_on");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("HabitId")
+ .HasColumnType("uuid")
+ .HasColumnName("habit_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("HabitId", "CompletedOn")
+ .IsUnique()
+ .HasDatabaseName("uq_check_in_habit_date");
+
+ b.ToTable("check_in", "habits", t =>
+ {
+ t.HasCheckConstraint("check_in_date_bound", "completed_on BETWEEN DATE '2000-01-01' AND DATE '2100-12-31'");
+ });
+ });
+
+ modelBuilder.Entity("HabitApi.Habit", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Archived")
+ .HasColumnType("boolean")
+ .HasColumnName("archived");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("name");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("owner_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId", "CreatedAt", "Id")
+ .HasDatabaseName("habit_owner_created");
+
+ b.ToTable("habit", "habits", t =>
+ {
+ t.HasCheckConstraint("habit_name_nonblank", "length(btrim(name)) > 0");
+ });
+ });
+
+ modelBuilder.Entity("HabitApi.CheckIn", b =>
+ {
+ b.HasOne("HabitApi.Habit", "Habit")
+ .WithMany("CheckIns")
+ .HasForeignKey("HabitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Habit");
+ });
+
+ modelBuilder.Entity("HabitApi.Habit", b =>
+ {
+ b.Navigation("CheckIns");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/applications/habit-checkins/HabitApi/PgConfig.cs b/applications/habit-checkins/HabitApi/PgConfig.cs
new file mode 100644
index 00000000..c1bdd565
--- /dev/null
+++ b/applications/habit-checkins/HabitApi/PgConfig.cs
@@ -0,0 +1,24 @@
+using Npgsql;
+namespace HabitApi;
+
+public static class PgConfig
+{
+ public static string Required(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value
+ ? value : throw new InvalidOperationException($"Missing {name}");
+
+ public static string ConnectionString() => new NpgsqlConnectionStringBuilder
+ {
+ Host = Required("PGHOST"),
+ Port = int.Parse(Environment.GetEnvironmentVariable("PGPORT") ?? "5432"),
+ Database = Environment.GetEnvironmentVariable("PGDATABASE") ?? "postgres",
+ Username = Required("PGUSER"),
+ Password = Required("PGPASSWORD"),
+ SslMode = SslMode.VerifyFull,
+ RootCertificate = Required("PGSSLROOTCERT"),
+ SearchPath = "habits",
+ MaxPoolSize = 5,
+ Timeout = 15,
+ CommandTimeout = 30,
+ GssEncryptionMode = GssEncryptionMode.Disable
+ }.ConnectionString;
+}
diff --git a/applications/habit-checkins/HabitApi/Program.cs b/applications/habit-checkins/HabitApi/Program.cs
new file mode 100644
index 00000000..a29ea560
--- /dev/null
+++ b/applications/habit-checkins/HabitApi/Program.cs
@@ -0,0 +1,61 @@
+using System.Text.Json.Serialization;
+using HabitApi;
+using Microsoft.EntityFrameworkCore;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = 4096);
+builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("APP_URL") ?? "http://127.0.0.1:8080");
+builder.Services.AddProblemDetails();
+builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow);
+builder.Services.AddSingleton(new Tokens(PgConfig.Required("APP_TOKENS")));
+builder.Services.AddDbContext(options => options.UseNpgsql(PgConfig.ConnectionString(),
+ pg => pg.MigrationsHistoryTable("__EFMigrationsHistory", "habits")));
+builder.Services.AddScoped();
+var app = builder.Build();
+app.UseExceptionHandler();
+app.Use(async (context, next) =>
+{
+ if (context.Request.Path == "/health" && context.Request.Method == "GET")
+ {
+ await next(context);
+ return;
+ }
+ var user = context.RequestServices.GetRequiredService().Resolve(context.Request.Headers.Authorization);
+ if (user is null)
+ {
+ context.Response.StatusCode = 401;
+ context.Response.Headers.WWWAuthenticate = "Bearer";
+ await context.Response.WriteAsJsonAsync(new { error = "authentication_required" });
+ return;
+ }
+ context.Items["owner"] = user;
+ try
+ {
+ await next(context);
+ }
+ catch (ApiProblem e)
+ {
+ context.Response.StatusCode = e.Status;
+ await context.Response.WriteAsJsonAsync(new { error = e.Message });
+ }
+});
+
+app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
+app.MapPost("/habits", async (CreateHabit body, HttpContext ctx, HabitService service) =>
+ Results.Json(await service.Create(Owner(ctx), body.Name), statusCode: 201));
+app.MapGet("/habits", async (HttpContext ctx, HabitService service) => Results.Ok(await service.List(Owner(ctx))));
+app.MapPut("/habits/{id:guid}/check-ins/{date}", async (Guid id, string date, HttpContext ctx, HabitService service) =>
+ Results.Ok(await service.Complete(Owner(ctx), id, CalendarContract.Date(date))));
+app.MapDelete("/habits/{id:guid}/check-ins/{date}", async (Guid id, string date, HttpContext ctx, HabitService service) =>
+{
+ await service.Undo(Owner(ctx), id, CalendarContract.Date(date));
+ return Results.NoContent();
+});
+app.MapPost("/habits/{id:guid}/archive", async (Guid id, HttpContext ctx, HabitService service) =>
+ Results.Ok(await service.Archive(Owner(ctx), id)));
+app.MapGet("/habits/{id:guid}/history", async (Guid id, string month, HttpContext ctx, HabitService service) =>
+ Results.Ok(await service.History(Owner(ctx), id, month)));
+app.Run();
+
+static string Owner(HttpContext context) => (string)context.Items["owner"]!;
+public sealed record CreateHabit(string? Name);
diff --git a/applications/habit-checkins/HabitApi/Tokens.cs b/applications/habit-checkins/HabitApi/Tokens.cs
new file mode 100644
index 00000000..0dc16d1a
--- /dev/null
+++ b/applications/habit-checkins/HabitApi/Tokens.cs
@@ -0,0 +1,33 @@
+using System.Security.Cryptography;
+using System.Text;
+namespace HabitApi;
+
+public sealed class Tokens
+{
+ private readonly List<(byte[] Digest, string User)> entries = [];
+
+ public Tokens(string configuration)
+ {
+ var seen = new HashSet(StringComparer.Ordinal);
+ foreach (var entry in configuration.Split(','))
+ {
+ var fields = entry.Split(':');
+ if (fields.Length != 2 || fields[0].Length is < 1 or > 64
+ || fields[0].Any(c => !char.IsAsciiLetterOrDigit(c) && c != '-' && c != '_')
+ || fields[1].Length is < 32 or > 256 || !seen.Add(fields[1]))
+ throw new InvalidOperationException("Invalid APP_TOKENS mapping");
+ entries.Add((SHA256.HashData(Encoding.UTF8.GetBytes(fields[1])), fields[0]));
+ }
+ }
+
+ public string? Resolve(string? authorization)
+ {
+ if (authorization is null || !authorization.StartsWith("Bearer ", StringComparison.Ordinal)
+ || authorization.Length > 263) return null;
+ var digest = SHA256.HashData(Encoding.UTF8.GetBytes(authorization[7..]));
+ string? user = null;
+ foreach (var entry in entries)
+ if (CryptographicOperations.FixedTimeEquals(entry.Digest, digest)) user = entry.User;
+ return user;
+ }
+}
diff --git a/applications/habit-checkins/HabitApi/packages.lock.json b/applications/habit-checkins/HabitApi/packages.lock.json
new file mode 100644
index 00000000..4411ad73
--- /dev/null
+++ b/applications/habit-checkins/HabitApi/packages.lock.json
@@ -0,0 +1,216 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "Microsoft.EntityFrameworkCore": {
+ "type": "Direct",
+ "requested": "[10.0.12, )",
+ "resolved": "10.0.12",
+ "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12",
+ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Design": {
+ "type": "Direct",
+ "requested": "[10.0.12, )",
+ "resolved": "10.0.12",
+ "contentHash": "I/IwEkUEUoa62UMJHh2+04oAs+gZz4QEunpZ3MGtAf12PZUcTduZLuGZEp3FAZ7wkmkSThZez41P8tgFj8QYKQ==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.Build.Framework": "18.0.2",
+ "Microsoft.CodeAnalysis.CSharp": "5.0.0",
+ "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0",
+ "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0",
+ "Microsoft.EntityFrameworkCore.Relational": "10.0.12",
+ "Microsoft.Extensions.DependencyModel": "10.0.12",
+ "Mono.TextTemplating": "3.0.0",
+ "Newtonsoft.Json": "13.0.4"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Relational": {
+ "type": "Direct",
+ "requested": "[10.0.12, )",
+ "resolved": "10.0.12",
+ "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "10.0.12"
+ }
+ },
+ "Npgsql": {
+ "type": "Direct",
+ "requested": "[10.0.3, )",
+ "resolved": "10.0.3",
+ "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w=="
+ },
+ "Npgsql.EntityFrameworkCore.PostgreSQL": {
+ "type": "Direct",
+ "requested": "[10.0.3, )",
+ "resolved": "10.0.3",
+ "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)",
+ "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)",
+ "Npgsql": "10.0.3"
+ }
+ },
+ "Humanizer.Core": {
+ "type": "Transitive",
+ "resolved": "2.14.1",
+ "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
+ },
+ "Microsoft.Build.Framework": {
+ "type": "Transitive",
+ "resolved": "18.0.2",
+ "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA=="
+ },
+ "Microsoft.CodeAnalysis.Analyzers": {
+ "type": "Transitive",
+ "resolved": "3.11.0",
+ "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg=="
+ },
+ "Microsoft.CodeAnalysis.Common": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==",
+ "dependencies": {
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0"
+ }
+ },
+ "Microsoft.CodeAnalysis.CSharp": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==",
+ "dependencies": {
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.Common": "[5.0.0]"
+ }
+ },
+ "Microsoft.CodeAnalysis.CSharp.Workspaces": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.CSharp": "[5.0.0]",
+ "Microsoft.CodeAnalysis.Common": "[5.0.0]",
+ "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
+ "System.Composition": "9.0.0"
+ }
+ },
+ "Microsoft.CodeAnalysis.Workspaces.Common": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.Common": "[5.0.0]",
+ "System.Composition": "9.0.0"
+ }
+ },
+ "Microsoft.CodeAnalysis.Workspaces.MSBuild": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==",
+ "dependencies": {
+ "Humanizer.Core": "2.14.1",
+ "Microsoft.Build.Framework": "17.11.31",
+ "Microsoft.CodeAnalysis.Analyzers": "3.11.0",
+ "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
+ "Microsoft.VisualStudio.SolutionPersistence": "1.0.52",
+ "Newtonsoft.Json": "13.0.3",
+ "System.Composition": "9.0.0"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg=="
+ },
+ "Microsoft.EntityFrameworkCore.Analyzers": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ=="
+ },
+ "Microsoft.Extensions.DependencyModel": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "rDPQVTxh/zMTDF7wHlRqL/jjZbwjAP6315ydBxj51pZW7qkAwGwjoSiMutxYI91xmOE3ZXjAGHbYRqzyJV7Urw=="
+ },
+ "Microsoft.VisualStudio.SolutionPersistence": {
+ "type": "Transitive",
+ "resolved": "1.0.52",
+ "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w=="
+ },
+ "Mono.TextTemplating": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==",
+ "dependencies": {
+ "System.CodeDom": "6.0.0"
+ }
+ },
+ "Newtonsoft.Json": {
+ "type": "Transitive",
+ "resolved": "13.0.4",
+ "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A=="
+ },
+ "System.CodeDom": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
+ },
+ "System.Composition": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==",
+ "dependencies": {
+ "System.Composition.AttributedModel": "9.0.0",
+ "System.Composition.Convention": "9.0.0",
+ "System.Composition.Hosting": "9.0.0",
+ "System.Composition.Runtime": "9.0.0",
+ "System.Composition.TypedParts": "9.0.0"
+ }
+ },
+ "System.Composition.AttributedModel": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA=="
+ },
+ "System.Composition.Convention": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==",
+ "dependencies": {
+ "System.Composition.AttributedModel": "9.0.0"
+ }
+ },
+ "System.Composition.Hosting": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==",
+ "dependencies": {
+ "System.Composition.Runtime": "9.0.0"
+ }
+ },
+ "System.Composition.Runtime": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA=="
+ },
+ "System.Composition.TypedParts": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==",
+ "dependencies": {
+ "System.Composition.AttributedModel": "9.0.0",
+ "System.Composition.Hosting": "9.0.0",
+ "System.Composition.Runtime": "9.0.0"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/applications/habit-checkins/HabitCheckins.slnx b/applications/habit-checkins/HabitCheckins.slnx
new file mode 100644
index 00000000..693f9171
--- /dev/null
+++ b/applications/habit-checkins/HabitCheckins.slnx
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/applications/habit-checkins/HabitTests/CloudTests.cs b/applications/habit-checkins/HabitTests/CloudTests.cs
new file mode 100644
index 00000000..340290c8
--- /dev/null
+++ b/applications/habit-checkins/HabitTests/CloudTests.cs
@@ -0,0 +1,181 @@
+using System.Security.Authentication;
+using HabitApi;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+using Npgsql;
+using Xunit;
+
+namespace HabitTests;
+
+[Trait("Category", "Cloud")]
+public sealed class CloudTests
+{
+ private static HabitDb Db(string? applicationName = null)
+ {
+ var connection = new NpgsqlConnectionStringBuilder(PgConfig.ConnectionString());
+ if (applicationName is not null) connection.ApplicationName = applicationName;
+ return new HabitDb(new DbContextOptionsBuilder().UseNpgsql(connection.ConnectionString).Options);
+ }
+
+ private static async Task Create()
+ {
+ await using var db = Db();
+ return (await new HabitService(db).Create("user-a", "Cloud fixture")).Id;
+ }
+ private static async Task Remove(Guid id)
+ {
+ await using var db = Db();
+ await db.Habits.Where(h => h.Id == id).ExecuteDeleteAsync();
+ }
+
+ [Fact]
+ public async Task NaturalKeyRejectsDuplicateDatabaseWriters()
+ {
+ var id = await Create();
+ async Task Insert()
+ {
+ await using var db = Db();
+ db.CheckIns.Add(new CheckIn { HabitId = id, CompletedOn = new DateOnly(2026, 10, 5) });
+ try
+ {
+ await db.SaveChangesAsync();
+ return true;
+ }
+ catch (DbUpdateException error)
+ {
+ var pg = Assert.IsType(error.InnerException);
+ Assert.Equal(PostgresErrorCodes.UniqueViolation, pg.SqlState);
+ Assert.Equal("uq_check_in_habit_date", pg.ConstraintName);
+ return false;
+ }
+ }
+ try
+ {
+ var results = await Task.WhenAll(Insert(), Insert());
+ Assert.Single(results, success => success);
+ await using var db = Db();
+ Assert.Equal(1, await db.CheckIns.CountAsync(c => c.HabitId == id));
+ }
+ finally
+ {
+ await Remove(id);
+ }
+ }
+
+ private static async Task WaitForRowLock(HabitDb keeper)
+ {
+ await using var command = new NpgsqlCommand(
+ "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE pg_backend_pid() = ANY(pg_blocking_pids(pid)))",
+ (NpgsqlConnection)keeper.Database.GetDbConnection(),
+ (NpgsqlTransaction)keeper.Database.CurrentTransaction!.GetDbTransaction());
+
+ for (var attempt = 0; attempt < 100; attempt++)
+ {
+ // Statistics views retain a snapshot within a transaction; refresh before observing the contender.
+ await using var refresh = new NpgsqlCommand("SELECT pg_stat_clear_snapshot()",
+ (NpgsqlConnection)keeper.Database.GetDbConnection(),
+ (NpgsqlTransaction)keeper.Database.CurrentTransaction!.GetDbTransaction());
+ await refresh.ExecuteNonQueryAsync();
+ if ((bool)(await command.ExecuteScalarAsync())!) return;
+ await Task.Delay(100);
+ }
+ Assert.Fail("Contending service did not reach the parent row lock");
+ }
+
+ [Fact]
+ public async Task ArchiveCommitsBeforeBlockedCheckInRejectsNewCompletion()
+ {
+ var id = await Create();
+ try
+ {
+ await using var keeper = Db();
+ await using var tx = await keeper.Database.BeginTransactionAsync();
+ var rows = await keeper.Habits.FromSqlInterpolated($"SELECT * FROM habits.habit WHERE id={id} FOR UPDATE").ToListAsync();
+ var contender = Task.Run(async () =>
+ {
+ await using var db = Db("habit-check-contender");
+ return await Assert.ThrowsAsync(() => new HabitService(db).Complete("user-a", id, new DateOnly(2026, 10, 5)));
+ });
+ await WaitForRowLock(keeper);
+ rows.Single().Archived = true;
+ await keeper.SaveChangesAsync();
+ await tx.CommitAsync();
+ Assert.Equal(409, (await contender.WaitAsync(TimeSpan.FromSeconds(30))).Status);
+ Assert.Equal(0, await keeper.CheckIns.CountAsync(c => c.HabitId == id));
+ }
+ finally
+ {
+ await Remove(id);
+ }
+ }
+
+ [Fact]
+ public async Task CheckInCommitsBeforeBlockedArchiveRetainsCompletion()
+ {
+ var id = await Create();
+ try
+ {
+ await using var keeper = Db();
+ await using var tx = await keeper.Database.BeginTransactionAsync();
+ await keeper.Habits.FromSqlInterpolated($"SELECT * FROM habits.habit WHERE id={id} FOR UPDATE").ToListAsync();
+ keeper.CheckIns.Add(new CheckIn { HabitId = id, CompletedOn = new DateOnly(2026, 10, 5) });
+ await keeper.SaveChangesAsync();
+ var contender = Task.Run(async () =>
+ {
+ await using var db = Db("habit-archive-contender");
+ return await new HabitService(db).Archive("user-a", id);
+ });
+ await WaitForRowLock(keeper);
+ await tx.CommitAsync();
+ Assert.True((await contender.WaitAsync(TimeSpan.FromSeconds(30))).Archived);
+ Assert.Equal(1, await keeper.CheckIns.CountAsync(c => c.HabitId == id));
+ }
+ finally
+ {
+ await Remove(id);
+ }
+ }
+
+ [Fact]
+ public async Task RollbackAfterFlushLeavesNoHabit()
+ {
+ var id = Guid.NewGuid();
+ await using var db = Db();
+ await using (var tx = await db.Database.BeginTransactionAsync())
+ {
+ db.Habits.Add(new Habit { Id = id, OwnerId = "user-a", Name = "Rollback" });
+ await db.SaveChangesAsync();
+ await tx.RollbackAsync();
+ }
+ db.ChangeTracker.Clear();
+ Assert.False(await db.Habits.AnyAsync(h => h.Id == id));
+ }
+
+ [Fact]
+ public async Task RuntimeCannotCreateTablesOrReadMigrationHistory()
+ {
+ await using var connection = new NpgsqlConnection(PgConfig.ConnectionString());
+ await connection.OpenAsync();
+ foreach (var sql in new[] { "CREATE TABLE habits.forbidden (id int)", "SELECT * FROM habits.\"__EFMigrationsHistory\"" })
+ {
+ await using var command = new NpgsqlCommand(sql, connection);
+ var error = await Assert.ThrowsAsync(async () => await command.ExecuteNonQueryAsync());
+ Assert.Equal(PostgresErrorCodes.InsufficientPrivilege, error.SqlState);
+ }
+ await using var encrypted = new NpgsqlCommand("SELECT ssl FROM pg_stat_ssl WHERE pid=pg_backend_pid()", connection);
+ Assert.True((bool)(await encrypted.ExecuteScalarAsync())!);
+ }
+
+ [Fact]
+ public async Task WrongCaFailsCertificateTrustWithPositiveControl()
+ {
+ await using var positive = new NpgsqlConnection(PgConfig.ConnectionString());
+ await positive.OpenAsync();
+ var wrong = new NpgsqlConnectionStringBuilder(PgConfig.ConnectionString())
+ { RootCertificate = "/etc/ssl/certs/ca-certificates.crt", Pooling = false };
+ await using var negative = new NpgsqlConnection(wrong.ConnectionString);
+ var error = await Assert.ThrowsAsync(() => negative.OpenAsync());
+ Assert.IsType(error.InnerException);
+ Assert.Contains("certificate", error.ToString(), StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/applications/habit-checkins/HabitTests/ContractTests.cs b/applications/habit-checkins/HabitTests/ContractTests.cs
new file mode 100644
index 00000000..ea0dfdad
--- /dev/null
+++ b/applications/habit-checkins/HabitTests/ContractTests.cs
@@ -0,0 +1,33 @@
+using HabitApi;
+using Xunit;
+namespace HabitTests;
+
+public sealed class ContractTests
+{
+ [Fact]
+ public void ExplicitCalendarDatesIncludeLeapDay()
+ {
+ Assert.Equal(new DateOnly(2024, 2, 29), CalendarContract.Date("2024-02-29"));
+ foreach (var invalid in new[] { "2026-02-29", "2026-2-03", "2026-10-01T00:00:00Z", "1999-12-31", "2101-01-01" })
+ Assert.Throws(() => CalendarContract.Date(invalid));
+ }
+ [Fact]
+ public void MonthlyRangeIsHalfOpenAndCrossesYearBoundary()
+ {
+ var (start, end) = CalendarContract.Month("2026-12");
+ Assert.Equal(new DateOnly(2026, 12, 1), start);
+ Assert.Equal(new DateOnly(2027, 1, 1), end);
+ Assert.Throws(() => CalendarContract.Month("2026-2"));
+ }
+ [Fact]
+ public void TokensBindUsersAndRejectAmbiguousMappings()
+ {
+ var a = new string('a', 48);
+ var b = new string('b', 48);
+ var tokens = new Tokens($"user-a:{a},user-b:{b}");
+ Assert.Equal("user-a", tokens.Resolve("Bearer " + a));
+ Assert.Equal("user-b", tokens.Resolve("Bearer " + b));
+ Assert.Null(tokens.Resolve("Bearer " + new string('c', 48)));
+ Assert.Throws(() => new Tokens($"user-a:{a},user-b:{a}"));
+ }
+}
diff --git a/applications/habit-checkins/HabitTests/HabitTests.csproj b/applications/habit-checkins/HabitTests/HabitTests.csproj
new file mode 100644
index 00000000..515093d7
--- /dev/null
+++ b/applications/habit-checkins/HabitTests/HabitTests.csproj
@@ -0,0 +1,15 @@
+
+
+ net10.0
+ false
+ true
+
+
+
+
+
+ all
+
+
+
+
diff --git a/applications/habit-checkins/HabitTests/packages.lock.json b/applications/habit-checkins/HabitTests/packages.lock.json
new file mode 100644
index 00000000..f6fb1e97
--- /dev/null
+++ b/applications/habit-checkins/HabitTests/packages.lock.json
@@ -0,0 +1,224 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "Microsoft.NET.Test.Sdk": {
+ "type": "Direct",
+ "requested": "[18.10.1, )",
+ "resolved": "18.10.1",
+ "contentHash": "SxCFvJE/2ltUQgCgv6+Ixagohw4sZc9hM7Wid2foaNM7paBscktoHLoRDz91fW8wbHG87Rg9Ku59/8PXumqUrA==",
+ "dependencies": {
+ "Microsoft.CodeCoverage": "18.10.1",
+ "Microsoft.TestPlatform.TestHost": "18.10.1"
+ }
+ },
+ "xunit": {
+ "type": "Direct",
+ "requested": "[2.9.3, )",
+ "resolved": "2.9.3",
+ "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
+ "dependencies": {
+ "xunit.analyzers": "1.18.0",
+ "xunit.assert": "2.9.3",
+ "xunit.core": "[2.9.3]"
+ }
+ },
+ "xunit.runner.visualstudio": {
+ "type": "Direct",
+ "requested": "[3.1.5, )",
+ "resolved": "3.1.5",
+ "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA=="
+ },
+ "Microsoft.CodeCoverage": {
+ "type": "Transitive",
+ "resolved": "18.10.1",
+ "contentHash": "tV7tCyp/t+DuwQugLkCeUN7NBFFB4fEGRPr0gkMQ2nQgAvQQjmGlZZi1dsLzbyoe/A0mbnKybmkIh8qhT9p4Zw=="
+ },
+ "Microsoft.EntityFrameworkCore": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12",
+ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12",
+ "Microsoft.Extensions.Caching.Memory": "10.0.12",
+ "Microsoft.Extensions.Logging": "10.0.12"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg=="
+ },
+ "Microsoft.EntityFrameworkCore.Analyzers": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ=="
+ },
+ "Microsoft.EntityFrameworkCore.Relational": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "10.0.12",
+ "Microsoft.Extensions.Caching.Memory": "10.0.12",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Logging": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Caching.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "sI3A6MUAyZwccLnwq0cXuRmC3WBtXieimBWafXSfhRJ4jnM2uGVXrqImDDVyW27u4vVaTOGlJxNJd8d1FGvCNQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Caching.Memory": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "fhC3DHcBcFF4bXvHIxFtXJYNut7q7ZItqNFcgWfrIgmpVImlcEKNHhE7ztpNSPJYMHYYStS5QGND2I5mC26yeA==",
+ "dependencies": {
+ "Microsoft.Extensions.Caching.Abstractions": "10.0.12",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Options": "10.0.12",
+ "Microsoft.Extensions.Primitives": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw=="
+ },
+ "Microsoft.Extensions.Logging": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection": "10.0.12",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Options": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Options": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
+ "Microsoft.Extensions.Primitives": "10.0.12"
+ }
+ },
+ "Microsoft.Extensions.Primitives": {
+ "type": "Transitive",
+ "resolved": "10.0.12",
+ "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ=="
+ },
+ "Microsoft.TestPlatform.ObjectModel": {
+ "type": "Transitive",
+ "resolved": "18.10.1",
+ "contentHash": "o+PuLDRVp1iwiVAPBADZItXgm9Ck23XtPhE4kZtZVi0uYnMiiz1Dx9Cjh75kJ+ijfqnORL3VOTOaQVKi0f/ixg=="
+ },
+ "Microsoft.TestPlatform.TestHost": {
+ "type": "Transitive",
+ "resolved": "18.10.1",
+ "contentHash": "KYz2omqkoz9E2mWa4fdgT+Ycie1wi5968EmyJlusjlp87RxxtA2FvDm8tcBEYIDzLnkWGnsKjE4d13Z7/rxkQA==",
+ "dependencies": {
+ "Microsoft.TestPlatform.ObjectModel": "18.10.1"
+ }
+ },
+ "Npgsql": {
+ "type": "Transitive",
+ "resolved": "10.0.3",
+ "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.0"
+ }
+ },
+ "Npgsql.EntityFrameworkCore.PostgreSQL": {
+ "type": "Transitive",
+ "resolved": "10.0.3",
+ "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)",
+ "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)",
+ "Npgsql": "10.0.3"
+ }
+ },
+ "xunit.abstractions": {
+ "type": "Transitive",
+ "resolved": "2.0.3",
+ "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
+ },
+ "xunit.analyzers": {
+ "type": "Transitive",
+ "resolved": "1.18.0",
+ "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
+ },
+ "xunit.assert": {
+ "type": "Transitive",
+ "resolved": "2.9.3",
+ "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
+ },
+ "xunit.core": {
+ "type": "Transitive",
+ "resolved": "2.9.3",
+ "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
+ "dependencies": {
+ "xunit.extensibility.core": "[2.9.3]",
+ "xunit.extensibility.execution": "[2.9.3]"
+ }
+ },
+ "xunit.extensibility.core": {
+ "type": "Transitive",
+ "resolved": "2.9.3",
+ "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
+ "dependencies": {
+ "xunit.abstractions": "2.0.3"
+ }
+ },
+ "xunit.extensibility.execution": {
+ "type": "Transitive",
+ "resolved": "2.9.3",
+ "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
+ "dependencies": {
+ "xunit.extensibility.core": "[2.9.3]"
+ }
+ },
+ "habitapi": {
+ "type": "Project",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "[10.0.12, )",
+ "Microsoft.EntityFrameworkCore.Relational": "[10.0.12, )",
+ "Npgsql": "[10.0.3, )",
+ "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/applications/habit-checkins/README.md b/applications/habit-checkins/README.md
new file mode 100644
index 00000000..b7403534
--- /dev/null
+++ b/applications/habit-checkins/README.md
@@ -0,0 +1,144 @@
+# Habit check-ins with ASP.NET Core and EF Core
+
+A small account-scoped API backed by ClickHouse Managed Postgres in ClickHouse Cloud. Create habits, mark an explicit calendar date complete, undo it, and archive a habit while keeping history. Repeating a check-in returns the stored completion, including its original ID, instead of inserting a duplicate. There is no frontend, streak calculation or inferred timezone.
+
+Pinned stack: .NET SDK 10.0.401 / ASP.NET Core 10.0.12, EF Core 10.0.12 (including Relational, Design and dotnet-ef), Npgsql and its EF provider 10.0.3. The provider's NuGet metadata supports EF versions 10.0.4–10.x; all Microsoft EF packages are explicitly aligned to 10.0.12. NuGet lockfiles pin transitive dependencies. `global.json` disables SDK roll-forward and preview SDKs.
+
+## API and date contract
+
+Demo bearer tokens in `APP_TOKENS` bind to immutable user IDs. Generate different random tokens with `openssl rand -hex 24` and keep them secret. Retain the same identity when rotating a token. Ownership is derived from the server mapping; a submitted `ownerId` is an unknown JSON property and rejected. Token digests use SHA-256 and constant-time comparison. There is no registration, token issuance or persisted revocation system.
+
+Every habit operation filters by authenticated owner. An inaccessible/missing habit returns 404. The shared runtime database role is trusted application infrastructure: API ownership checks are not Postgres row-level security, and that database credential can access both users' rows. End users receive API tokens, not database passwords. Remote deployment requires HTTPS at a trusted proxy; the API binds to loopback by default and has no cookie authentication or CORS configuration.
+
+| Method | Route | Behavior |
+|---|---|---|
+| GET | `/health` | Anonymous process liveness, not database readiness |
+| POST | `/habits` | JSON `name`; returns 201 and habit |
+| GET | `/habits` | Newest 100 owned habits, including archived |
+| PUT | `/habits/{id}/check-ins/{date}` | 200 on creation/replay; 409 for a new archived completion |
+| DELETE | `/habits/{id}/check-ins/{date}` | Undo; repeated undo returns 204 |
+| POST | `/habits/{id}/archive` | Repeat-safe archive; retains existing history |
+| GET | `/habits/{id}/history?month=2026-10` | Owned monthly completions, ascending date, at most 31 |
+
+Supply dates exactly as `yyyy-MM-dd`, from 2000-01-01 through 2100-12-31; supply months as `yyyy-MM` in that range. A date is a client-chosen calendar label, represented by `DateOnly` and Postgres `date`. The server does not derive it from a clock, timezone or timestamp. Dates can be in the past or future within the documented range. Names must be nonblank, no embedded NUL, and at most 100 input UTF-16 code units; stored names are trimmed. JSON request bodies are capped at 4096 bytes.
+
+Archive rejects a **new** completion with 409. An existing habit/date completion can still be replayed with 200. Undo remains allowed after archive; once undone, completing that date again is a new completion and returns 409. There is no unarchive or API habit deletion. Lists order by `createdAt DESC, id DESC`, with no pagination or total count. Errors use 400 for invalid input, 401 for invalid/missing tokens, 404 for inaccessible habits, 409 for new archived completion and 413 for oversized JSON.
+
+## Build
+
+Install the pinned .NET SDK, Python 3 and a PostgreSQL client. In this directory:
+
+```sh
+dotnet restore --locked-mode
+dotnet tool restore
+dotnet build -c Release --no-restore
+dotnet test -c Release --no-build --filter 'Category!=Cloud'
+```
+
+Three database-independent tests cover explicit date/month boundaries and token identity mapping. CI uses these commands without Cloud credentials. Cloud tests do not use EF's in-memory provider or a local database.
+
+## Create a dedicated Cloud service
+
+Install [clickhousectl](https://github.com/ClickHouse/clickhousectl) and authenticate using your Cloud API credentials. These visible commands match clickhousectl 0.5.0. Select a supported size/region for your account; the fixture used c6gd.large in us-east-1, Postgres 18, without HA. Creating a service incurs charges. Check current service options in the [ClickHouse Managed Postgres quickstart](https://clickhouse.com/docs/products/managed-postgres/quickstart) and delete your dedicated test service afterward.
+
+```sh
+clickhousectl cloud postgres create --name habit-checkins-demo \
+ --provider aws --region us-east-1 --size c6gd.large \
+ --pg-version 18 --ha-type none --json > service-private.json
+chmod 600 service-private.json
+# Set SERVICE_ID to the returned id. Poll until state is running:
+clickhousectl cloud postgres get "$SERVICE_ID" --json
+clickhousectl cloud postgres certs get "$SERVICE_ID" --output cloud-ca.pem
+```
+
+Save the receipt outside the repository: it contains the initial administrator password, which later `get` calls do not return. Copy `.env.example` to a private environment file and fill the hostname, absolute CA path, runtime password and generated tokens. Do not paste connection strings or credentials into source/history.
+
+`PgConfig` builds a connection with `SSL Mode=VerifyFull` and the downloaded `Root Certificate`; there is no arbitrary certificate-validation callback or trust bypass. `PGSSLMODE=verify-full` also protects the visible `psql` commands. The Npgsql 10 GSS encryption preference is disabled explicitly so this example uses the specified TLS path.
+
+## Bootstrap, migrate, grant and seed
+
+Use a fresh dedicated service. Set `PGHOST`, `PGPORT=5432`, `PGDATABASE=postgres`, `PGSSLMODE=verify-full`, `PGSSLROOTCERT` and administrator `PGUSER` / `PGPASSWORD`. Privately export two different random database passwords as `MIGRATION_PASSWORD` and `APP_PASSWORD`.
+
+```sh
+psql -X -v ON_ERROR_STOP=1 \
+ -v migration_password="$MIGRATION_PASSWORD" \
+ -v app_password="$APP_PASSWORD" -f sql/bootstrap.sql
+```
+
+The administrator creates two roles and a schema `habits` owned by `habit_migration`. Runtime `habit_app` receives schema USAGE. PUBLIC loses CREATE on `public` on this dedicated fixture. No database CREATE grant is needed for migrations in the existing owned schema. psql password arguments can be visible to other local users on a shared machine; keep fixture setup private.
+
+Run the committed EF migration explicitly with the migration credential:
+
+```sh
+PGUSER=habit_migration PGPASSWORD="$MIGRATION_PASSWORD" \
+ dotnet ef database update --project HabitApi --configuration Release --no-build
+PGUSER=habit_migration PGPASSWORD="$MIGRATION_PASSWORD" \
+ dotnet ef migrations has-pending-model-changes --project HabitApi --configuration Release --no-build
+```
+
+The design-time factory uses only the credential supplied to that command. The migration creates habits, child check-ins, their foreign key, a named unique habit/date index and an owner/list index. EF history is in `habits.__EFMigrationsHistory`. First migration on an empty schema may log a missing-history SELECT before creating the table; the command then applies the initial migration. Normal API startup never calls `Migrate`, `EnsureCreated` or any DDL method.
+
+With the administrator credential still in the shell, grant only table CRUD, then seed using runtime:
+
+```sh
+psql -X -v ON_ERROR_STOP=1 -f sql/grants.sql
+PGUSER=habit_app PGPASSWORD="$APP_PASSWORD" \
+ psql -X -v ON_ERROR_STOP=1 -f sql/seed.sql
+```
+
+Repeat migration and seed safely: EF reports up-to-date and the seed's fixed IDs/natural key use `ON CONFLICT DO NOTHING`. Runtime cannot create tables or read EF migration history. To add a future migration, change the model, run `dotnet ef migrations add NAME --project HabitApi`, review the generated migration/snapshot, and apply it through the same owner-only path.
+
+## Start and try a check-in
+
+Start a fresh shell and load only the runtime environment/tokens. Keep administrator and migration credentials out of the API process.
+
+```sh
+dotnet HabitApi/bin/Release/net10.0/HabitApi.dll
+```
+
+The default address is `http://127.0.0.1:8080`. An explicit `APP_URL` override supports a trusted deployment. Set `USER_TOKEN` privately to a configured token:
+
+```sh
+curl -sS http://127.0.0.1:8080/habits \
+ -H "Authorization: Bearer $USER_TOKEN" -H 'Content-Type: application/json' \
+ -d '{"name":"Read a chapter"}'
+# Set HABIT_ID to the response id.
+curl -sS -X PUT http://127.0.0.1:8080/habits/$HABIT_ID/check-ins/2026-10-05 \
+ -H "Authorization: Bearer $USER_TOKEN"
+# Repeat the same PUT: 200, the same persisted completion id.
+curl -sS 'http://127.0.0.1:8080/habits/'"$HABIT_ID"'/history?month=2026-10' \
+ -H "Authorization: Bearer $USER_TOKEN"
+```
+
+A check-in transaction first locks the owned habit row with parameterized `FOR UPDATE`. Completion, undo and archive all follow this cooperative locking protocol. If completion commits before archive, it stays in history; if archive commits first, the waiting new completion returns 409. Direct writers that ignore this protocol could break archive behavior. The unique index remains a database backstop for duplicate habit/date rows, independent of cooperative locking.
+
+EF inserts the check-in after checking for an existing row. Only SQLSTATE 23505 for `uq_check_in_habit_date` is treated as a repeat: the transaction is rolled back and the durable winner is read. Other database errors propagate. Monthly history uses a half-open range from the first day to the next month, projects only completion fields, orders by date, and caps at 31. The unique index also supports that range query.
+
+## Cloud acceptance
+
+Use a dedicated fixture with the two `.env.example` identities and runtime credentials:
+
+```sh
+dotnet test -c Release --no-build --filter 'Category=Cloud'
+python3 scripts/acceptance.py
+```
+
+Six native Cloud tests exercise independent duplicate writers (one success, one exact named 23505), both archive/check-in lock orderings, real rollback after saving, runtime permission denials/encrypted connection, and wrong-CA certificate failure with a positive same-endpoint control. Ordering tests hold a parent lock, observe a blocked service through `pg_blocking_pids` with refreshed statistics snapshots, then commit and assert the result.
+
+The HTTP script starts the production DLL on loopback 8081 with only runtime credentials. It checks date/name/body bounds, forged ownership and cross-user reads/writes, eight concurrent duplicate check-ins returning one stored ID, four archive/check-in races, retained history, archived replay/undo behavior, ascending monthly projection and persistence after process restart. Tests remove their generated rows using the runtime credential; do not point them at production data. `APP_LOG` selects a private application log, default `/tmp/habit-api.log`. The wrong-CA control uses the system CA bundle, which must not include the dedicated Cloud CA.
+
+## Cleanup
+
+Stop the application. Switch to administrator credentials on your dedicated fixture and optionally remove only the example schema/roles, then delete the dedicated service:
+
+```sh
+psql -X -v ON_ERROR_STOP=1 -f sql/cleanup.sql
+clickhousectl cloud postgres delete "$SERVICE_ID"
+clickhousectl cloud postgres list --json
+```
+
+Deletion is asynchronous. Confirm your recorded ID disappears; do not delete an unrelated resource. Remove private local receipts when no longer needed.
+
+## Sources
+
+[Npgsql security](https://www.npgsql.org/doc/security.html), [Npgsql EF provider](https://www.npgsql.org/efcore/), [EF concurrency](https://learn.microsoft.com/en-us/ef/core/saving/concurrency), [ASP.NET Core APIs](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/apis?view=aspnetcore-10.0), [EF migration application](https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/applying).
diff --git a/applications/habit-checkins/global.json b/applications/habit-checkins/global.json
new file mode 100644
index 00000000..1769bc16
--- /dev/null
+++ b/applications/habit-checkins/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "10.0.401",
+ "rollForward": "disable",
+ "allowPrerelease": false
+ }
+}
diff --git a/applications/habit-checkins/scripts/acceptance.py b/applications/habit-checkins/scripts/acceptance.py
new file mode 100644
index 00000000..4851e92f
--- /dev/null
+++ b/applications/habit-checkins/scripts/acceptance.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+"""HTTP checks against the dedicated Cloud fixture; uses only Python's standard library."""
+import os, json, urllib.request, urllib.error, subprocess, time, concurrent.futures, uuid
+
+BASE = "http://127.0.0.1:8081"
+TOKENS = {e.split(":")[0]: e.split(":")[1] for e in os.environ["APP_TOKENS"].split(",")}
+IDS = []
+
+
+def req(method, path, owner="user-a", body=None):
+ headers = {"Content-Type": "application/json"}
+ if owner:
+ headers["Authorization"] = "Bearer " + TOKENS[owner]
+ data = json.dumps(body).encode() if body is not None else None
+ try:
+ with urllib.request.urlopen(
+ urllib.request.Request(BASE + path, data, headers, method=method),
+ timeout=60,
+ ) as r:
+ s = r.read()
+ return r.status, json.loads(s) if s else None
+ except urllib.error.HTTPError as e:
+ s = e.read()
+ try:
+ body = json.loads(s) if s else None
+ except ValueError:
+ body = None
+ return e.code, body
+
+
+def expect(code, result):
+ assert result[0] == code, (code, result)
+ return result[1]
+
+
+def create(owner="user-a"):
+ h = expect(201, req("POST", "/habits", owner, {"name": "Read a chapter"}))
+ IDS.append(h["id"])
+ return "/habits/" + h["id"]
+
+
+def start():
+ env = {
+ k: v
+ for k, v in os.environ.items()
+ if k
+ in {
+ "PATH",
+ "HOME",
+ "DOTNET_ROOT",
+ "LANG",
+ "PGHOST",
+ "PGPORT",
+ "PGDATABASE",
+ "PGUSER",
+ "PGPASSWORD",
+ "PGSSLROOTCERT",
+ "APP_TOKENS",
+ }
+ }
+ env["APP_URL"] = BASE
+ env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
+ f = open(os.environ.get("APP_LOG", "/tmp/habit-api.log"), "a")
+ p = subprocess.Popen(
+ ["dotnet", "HabitApi/bin/Release/net10.0/HabitApi.dll"],
+ env=env,
+ stdout=f,
+ stderr=f,
+ )
+ for _ in range(90):
+ if p.poll() is not None:
+ raise RuntimeError("Application exited; inspect private log")
+ try:
+ if req("GET", "/health", None)[0] == 200:
+ return p
+ except OSError:
+ pass
+ time.sleep(1)
+ p.terminate()
+ raise RuntimeError("Application startup timeout")
+
+
+def stop(p):
+ p.terminate()
+ p.wait(timeout=30)
+
+
+p = None
+try:
+ p = start()
+ expect(401, req("GET", "/habits", None))
+ for name in ["", " ", "x" * 101, "Nul\0name"]:
+ expect(400, req("POST", "/habits", body={"name": name}))
+ expect(400, req("POST", "/habits", body={"name": "Forged", "ownerId": "user-b"}))
+ expect(413, req("POST", "/habits", body={"name": "x" * 5000}))
+ path = create()
+ for date in [
+ "2026-02-29",
+ "2026-2-03",
+ "1999-12-31",
+ "2101-01-01",
+ "2026-10-03T00:00:00Z",
+ ]:
+ expect(400, req("PUT", path + "/check-ins/" + date))
+ for month in ["2026-13", "2026-2", "1999-12"]:
+ expect(400, req("GET", path + "/history?month=" + month))
+ for method, suffix in [
+ ("PUT", "/check-ins/2026-10-03"),
+ ("DELETE", "/check-ins/2026-10-03"),
+ ("POST", "/archive"),
+ ("GET", "/history?month=2026-10"),
+ ]:
+ expect(404, req(method, path + suffix, "user-b"))
+ assert all(
+ h["id"] != path.split("/")[-1]
+ for h in expect(200, req("GET", "/habits", "user-b"))
+ )
+ first = expect(200, req("PUT", path + "/check-ins/2026-10-03"))
+ again = expect(200, req("PUT", path + "/check-ins/2026-10-03"))
+ assert first["id"] == again["id"] and again["completedOn"] == "2026-10-03"
+ expect(200, req("PUT", path + "/check-ins/2026-09-30"))
+ expect(200, req("PUT", path + "/check-ins/2026-11-01"))
+ history = expect(200, req("GET", path + "/history?month=2026-10"))
+ assert len(history) == 1 and history[0]["id"] == first["id"]
+ print(
+ "PASS explicit date/month validation, input bounds, forged-owner rejection and cross-user reads/writes",
+ flush=True,
+ )
+ with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
+ cp = create()
+ jobs = [pool.submit(req, "PUT", cp + "/check-ins/2026-10-05") for _ in range(8)]
+ results = [expect(200, j.result()) for j in jobs]
+ assert len({r["id"] for r in results}) == 1
+ assert len(expect(200, req("GET", cp + "/history?month=2026-10"))) == 1
+ for _ in range(4):
+ ap = create()
+ a = pool.submit(req, "PUT", ap + "/check-ins/2026-10-05")
+ b = pool.submit(req, "POST", ap + "/archive")
+ complete = a.result()
+ expect(200, b.result())
+ assert complete[0] in [200, 409], complete
+ hist = expect(200, req("GET", ap + "/history?month=2026-10"))
+ assert len(hist) == (1 if complete[0] == 200 else 0)
+ expect(409, req("PUT", ap + "/check-ins/2026-10-06"))
+ expect(200, req("POST", path + "/archive"))
+ expect(200, req("POST", path + "/archive"))
+ assert expect(200, req("GET", path + "/history?month=2026-10")) == history
+ assert expect(200, req("PUT", path + "/check-ins/2026-10-03")) == history[0]
+ expect(409, req("PUT", path + "/check-ins/2026-10-04"))
+ expect(204, req("DELETE", path + "/check-ins/2026-10-03"))
+ expect(204, req("DELETE", path + "/check-ins/2026-10-03"))
+ assert expect(200, req("GET", path + "/history?month=2026-10")) == []
+ expect(409, req("PUT", path + "/check-ins/2026-10-03"))
+ print(
+ "PASS eight duplicate check-ins share one durable id; four archive/check-in races; archived replay/undo and history retention",
+ flush=True,
+ )
+ month = create()
+ for day in [31, 1, 15]:
+ expect(200, req("PUT", month + "/check-ins/2026-10-" + str(day).zfill(2)))
+ before = expect(200, req("GET", month + "/history?month=2026-10"))
+ assert [c["completedOn"] for c in before] == [
+ "2026-10-01",
+ "2026-10-15",
+ "2026-10-31",
+ ] and len(before) <= 31
+ stop(p)
+ p = start()
+ assert expect(200, req("GET", month + "/history?month=2026-10")) == before
+ print(
+ "PASS ordered bounded monthly history and production-process restart persistence",
+ flush=True,
+ )
+finally:
+ if p is not None and p.poll() is None:
+ stop(p)
+ if IDS:
+ safe = ",".join("'" + str(uuid.UUID(id)) + "'" for id in IDS)
+ subprocess.run(
+ [
+ "psql",
+ "-X",
+ "-v",
+ "ON_ERROR_STOP=1",
+ "-c",
+ "DELETE FROM habits.habit WHERE id IN (" + safe + ")",
+ ],
+ check=True,
+ stdout=subprocess.DEVNULL,
+ )
diff --git a/applications/habit-checkins/sql/bootstrap.sql b/applications/habit-checkins/sql/bootstrap.sql
new file mode 100644
index 00000000..0c017d5a
--- /dev/null
+++ b/applications/habit-checkins/sql/bootstrap.sql
@@ -0,0 +1,6 @@
+\set ON_ERROR_STOP on
+CREATE ROLE habit_migration LOGIN PASSWORD :'migration_password';
+CREATE ROLE habit_app LOGIN PASSWORD :'app_password';
+REVOKE CREATE ON SCHEMA public FROM PUBLIC;
+CREATE SCHEMA habits AUTHORIZATION habit_migration;
+GRANT USAGE ON SCHEMA habits TO habit_app;
diff --git a/applications/habit-checkins/sql/cleanup.sql b/applications/habit-checkins/sql/cleanup.sql
new file mode 100644
index 00000000..43cfe92d
--- /dev/null
+++ b/applications/habit-checkins/sql/cleanup.sql
@@ -0,0 +1,4 @@
+\set ON_ERROR_STOP on
+DROP SCHEMA IF EXISTS habits CASCADE;
+DROP ROLE IF EXISTS habit_app;
+DROP ROLE IF EXISTS habit_migration;
diff --git a/applications/habit-checkins/sql/grants.sql b/applications/habit-checkins/sql/grants.sql
new file mode 100644
index 00000000..d6dcb218
--- /dev/null
+++ b/applications/habit-checkins/sql/grants.sql
@@ -0,0 +1,2 @@
+\set ON_ERROR_STOP on
+GRANT SELECT, INSERT, UPDATE, DELETE ON habits.habit, habits.check_in TO habit_app;
diff --git a/applications/habit-checkins/sql/seed.sql b/applications/habit-checkins/sql/seed.sql
new file mode 100644
index 00000000..5943dfc6
--- /dev/null
+++ b/applications/habit-checkins/sql/seed.sql
@@ -0,0 +1,8 @@
+\set ON_ERROR_STOP on
+INSERT INTO habits.habit (id, owner_id, name, archived, created_at)
+VALUES ('00000000-0000-0000-0000-000000000001', 'user-a', 'Read a chapter', false, now()),
+ ('00000000-0000-0000-0000-000000000002', 'user-b', 'Stretch', false, now())
+ON CONFLICT (id) DO NOTHING;
+INSERT INTO habits.check_in (id, habit_id, completed_on, created_at)
+VALUES ('00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', DATE '2026-10-01', now())
+ON CONFLICT (habit_id, completed_on) DO NOTHING;