fix(db): repair fresh migration chain
CI and Deploy / test (pull_request) Successful in 5m19s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 20:47:36 +02:00
parent 191de69c48
commit 74a1e0d845
15 changed files with 297 additions and 35 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ Updated: 2026-08-15
- **Required:** After the current pull request passes CI and is approved, follow `docs/release-candidate-review.md` and `docs/release-checklist.md` on the production host. Confirm the admin-only version badge matches the deployed commit, then run the authenticated application workspace, Career, CV, attachment, email-verification and rollback checks.
- **Recommended:** Verify backup/restore before deployment, then exercise login, existing application counts, Career Workspace, public CV refresh/download, AI, and attachments in order.
- **Current access check:** Read-only SSH access is confirmed. All four JobTracker containers are healthy with zero observed restarts, but root free space is now 36 GiB (83% used). The production checkout is at `de937d25dc5e` / app version `157` and has an unreviewed mode-only change to `deploy/deploy.sh`. No production change or deployment was attempted.
- **Current status:** PR 28 now includes SEC-009 commit `842e793`; current remote CI still needs confirmation. The local release matrix passes (backend 657/657, frontend 237/237, build and Chromium 9/9). Read-only PROD-001 inventory found the JobTracker Ollama and frontend published on all host interfaces, the newest gzip-valid MariaDB backup dated 2026-08-02, no observed scheduled JobTracker backup, and no owner-file/key/tombstone recovery bundle. Close these rollout gates before deployment; see `docs/production/production-ai-hardware-assessment.md`.
- **Current status:** PR 28 includes the current release-readiness work; current remote CI still needs confirmation. The local release matrix includes backend 680/680, frontend 237/237, build and Chromium 9/9. A disposable MariaDB 11.8 fresh/restart rehearsal now passes all 29 migrations with 49 tables and provider-correct sampled types; this does not replace the required backup/restore and production rollout rehearsal. Read-only PROD-001 inventory found the JobTracker Ollama and frontend published on all host interfaces, the newest gzip-valid MariaDB backup dated 2026-08-02, no observed scheduled JobTracker backup, and no owner-file/key/tombstone recovery bundle. Close these rollout gates before deployment; see `docs/production/production-ai-hardware-assessment.md`.
## Account deletion retention and restore policy
+119
View File
@@ -0,0 +1,119 @@
using System.Data;
using System.Text.RegularExpressions;
using JobTrackerApi.Data;
using JobTrackerApi.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class MigrationChainTests
{
private const string SnapshotMigration = "20260711181039_SyncModelSnapshot";
[Fact]
public async Task Blank_sqlite_chain_reaches_latest_and_is_idempotent()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = Context(connection);
await db.Database.MigrateAsync();
await db.Database.MigrateAsync();
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
Assert.Equal(3, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM sqlite_master
WHERE type = 'table' AND name IN ('AspNetUsers', 'AiInteractions', 'AiUsageRecords');
"""));
Assert.Equal(10, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM pragma_table_info('JobApplications')
WHERE name IN ('OwnerUserId', 'ShortSummary', 'TailoredCvText', 'TailoredCvUpdatedAt',
'LastReminderEmailSentAt', 'RecruiterMessageDraft', 'SalaryMin', 'SalaryMax',
'SalaryCurrency', 'SalaryPeriod');
"""));
}
[Fact]
public async Task Populated_pre_job_split_database_preserves_rows_through_latest()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = Context(connection);
var migrator = db.GetService<IMigrator>();
await migrator.MigrateAsync(SnapshotMigration);
await ExecuteAsync(connection, """
INSERT INTO Companies (Name) VALUES ('Migration fixture');
INSERT INTO JobApplications
(CompanyId, JobTitle, DateApplied, Status, ResponseReceived, OwnerUserId, ShortSummary)
VALUES
(1, 'Preserved role', '2026-07-01 09:30:00', 'Applied', 0, 'owner-1', 'Preserved summary');
""");
await migrator.MigrateAsync();
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT JobTitle, DateApplied, SavedAt, OwnerUserId, ShortSummary
FROM JobApplications WHERE Id = 1;
""";
await using var reader = await command.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
Assert.Equal("Preserved role", reader.GetString(0));
Assert.Equal(reader.GetString(1), reader.GetString(2));
Assert.Equal("owner-1", reader.GetString(3));
Assert.Equal("Preserved summary", reader.GetString(4));
Assert.False(await reader.ReadAsync());
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
[Fact]
public async Task MariaDb_script_keeps_new_identifiers_and_bootstrap_types_provider_safe()
{
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns((string?)null);
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseMySql(
"Server=127.0.0.1;Database=script_only;User=none;Password=none;",
new MariaDbServerVersion(new Version(11, 0, 0)))
.Options;
await using var db = new JobTrackerContext(options, currentUser.Object);
var script = db.GetService<IMigrator>().GenerateScript();
Assert.Contains("`OwnerUserId` varchar(255)", script, StringComparison.Ordinal);
Assert.Contains("CONSTRAINT `FK_AccountDeletionFiles_Request`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUsers`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `AiInteractions`", script, StringComparison.Ordinal);
Assert.All(
Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value),
identifier => Assert.True(identifier.Length <= 64, $"MariaDB constraint identifier exceeds 64 characters: {identifier}"));
}
private static JobTrackerContext Context(SqliteConnection connection)
{
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns((string?)null);
return new JobTrackerContext(
new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options,
currentUser.Object);
}
private static async Task<T> ScalarAsync<T>(SqliteConnection connection, string sql)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
return (T)Convert.ChangeType(await command.ExecuteScalarAsync() ?? throw new DataException(), typeof(T));
}
private static async Task ExecuteAsync(SqliteConnection connection, string sql)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync();
}
}
@@ -11,6 +11,7 @@ namespace JobTrackerApi.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
var mysql = ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase);
migrationBuilder.CreateTable(
name: "Companies",
columns: table => new
@@ -39,7 +40,20 @@ namespace JobTrackerApi.Migrations
ResponseDate = table.Column<DateTime>(type: "TEXT", nullable: true),
Notes = table.Column<string>(type: "TEXT", nullable: true),
CoverLetterText = table.Column<string>(type: "TEXT", nullable: true),
JobUrl = table.Column<string>(type: "TEXT", nullable: true)
JobUrl = table.Column<string>(type: "TEXT", nullable: true),
// These stable columns predated EF ownership and were historically supplied by
// startup reconciliation. Include them for new databases so the later SQLite
// DateApplied rebuild has a complete source shape under standalone EF tooling.
OwnerUserId = table.Column<string>(type: mysql ? "varchar(255)" : "TEXT", nullable: true),
ShortSummary = table.Column<string>(type: mysql ? "longtext" : "TEXT", nullable: true),
TailoredCvText = table.Column<string>(type: mysql ? "longtext" : "TEXT", nullable: true),
TailoredCvUpdatedAt = table.Column<DateTime>(type: mysql ? "datetime(6)" : "TEXT", nullable: true),
LastReminderEmailSentAt = table.Column<DateTime>(type: mysql ? "datetime(6)" : "TEXT", nullable: true),
RecruiterMessageDraft = table.Column<string>(type: mysql ? "longtext" : "TEXT", nullable: true),
SalaryMin = table.Column<decimal>(type: mysql ? "decimal(18,2)" : "TEXT", nullable: true),
SalaryMax = table.Column<decimal>(type: mysql ? "decimal(18,2)" : "TEXT", nullable: true),
SalaryCurrency = table.Column<string>(type: mysql ? "varchar(8)" : "TEXT", nullable: true),
SalaryPeriod = table.Column<string>(type: mysql ? "varchar(16)" : "TEXT", nullable: true)
},
constraints: table =>
{
@@ -12,6 +12,50 @@ namespace JobTrackerApi.Migrations
protected override void Up(MigrationBuilder migrationBuilder)
{
var mysql = ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase);
// Identity historically came from startup reconciliation rather than an EF migration.
// Ensure the base user table exists so a blank standalone EF chain can reach this
// additive migration. Normal application startup already created it, making this a no-op.
migrationBuilder.Sql(mysql
? """
CREATE TABLE IF NOT EXISTS `AspNetUsers` (
`Id` varchar(255) NOT NULL,
`UserName` varchar(256) NULL,
`NormalizedUserName` varchar(256) NULL,
`Email` varchar(256) NULL,
`NormalizedEmail` varchar(256) NULL,
`EmailConfirmed` tinyint(1) NOT NULL,
`PasswordHash` longtext NULL,
`SecurityStamp` longtext NULL,
`ConcurrencyStamp` longtext NULL,
`PhoneNumber` longtext NULL,
`PhoneNumberConfirmed` tinyint(1) NOT NULL,
`TwoFactorEnabled` tinyint(1) NOT NULL,
`LockoutEnd` datetime(6) NULL,
`LockoutEnabled` tinyint(1) NOT NULL,
`AccessFailedCount` int NOT NULL,
CONSTRAINT `PK_AspNetUsers` PRIMARY KEY (`Id`)
) CHARACTER SET=utf8mb4;
"""
: """
CREATE TABLE IF NOT EXISTS "AspNetUsers" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY,
"UserName" TEXT NULL,
"NormalizedUserName" TEXT NULL,
"Email" TEXT NULL,
"NormalizedEmail" TEXT NULL,
"EmailConfirmed" INTEGER NOT NULL,
"PasswordHash" TEXT NULL,
"SecurityStamp" TEXT NULL,
"ConcurrencyStamp" TEXT NULL,
"PhoneNumber" TEXT NULL,
"PhoneNumberConfirmed" INTEGER NOT NULL,
"TwoFactorEnabled" INTEGER NOT NULL,
"LockoutEnd" TEXT NULL,
"LockoutEnabled" INTEGER NOT NULL,
"AccessFailedCount" INTEGER NOT NULL
);
""");
migrationBuilder.AddColumn<string>(
name: "PendingEmail",
table: "AspNetUsers",
@@ -47,7 +47,7 @@ namespace JobTrackerApi.Migrations
`ByteSize` bigint NOT NULL,
`Sha256` char(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
CONSTRAINT `PK_AccountDeletionFiles` PRIMARY KEY (`Id`),
CONSTRAINT `FK_AccountDeletionFiles_AccountDeletionRequests_AccountDeletionRequestId`
CONSTRAINT `FK_AccountDeletionFiles_Request`
FOREIGN KEY (`AccountDeletionRequestId`) REFERENCES `AccountDeletionRequests` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
""");
@@ -11,6 +11,49 @@ namespace JobTrackerApi.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// AiInteractions was historically reconciler-owned and its migration is intentionally
// a no-op. A blank standalone EF chain still needs an empty source table for the
// content-free legacy backfill below; application startup already created the same
// table, so CREATE IF NOT EXISTS preserves existing rows and mixed-version startup.
migrationBuilder.Sql(ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase)
? """
CREATE TABLE IF NOT EXISTS `AiInteractions` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`Module` varchar(64) NOT NULL,
`Mode` longtext NULL,
`Title` longtext NOT NULL,
`Provider` longtext NOT NULL,
`ResultJson` longtext NOT NULL,
`InputCharacterCount` int NOT NULL DEFAULT 0,
`OutputCharacterCount` int NOT NULL DEFAULT 0,
`EstimatedTokenCount` int NOT NULL DEFAULT 0,
`CreatedAtUtc` datetime(6) NOT NULL,
CONSTRAINT `PK_AiInteractions` PRIMARY KEY (`Id`),
CONSTRAINT `FK_AiInteractions_JobApplications_JobApplicationId`
FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
"""
: """
CREATE TABLE IF NOT EXISTS "AiInteractions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_AiInteractions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"JobApplicationId" INTEGER NOT NULL,
"Module" TEXT NOT NULL,
"Mode" TEXT NULL,
"Title" TEXT NOT NULL,
"Provider" TEXT NOT NULL,
"ResultJson" TEXT NOT NULL,
"InputCharacterCount" INTEGER NOT NULL DEFAULT 0,
"OutputCharacterCount" INTEGER NOT NULL DEFAULT 0,
"EstimatedTokenCount" INTEGER NOT NULL DEFAULT 0,
"CreatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_AiInteractions_JobApplications_JobApplicationId"
FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
""");
if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase))
{
migrationBuilder.Sql("""
@@ -402,8 +402,6 @@ public static class StartupInitializationExtensions
{
// EF migrations are used for the app schema. In some environments `dotnet ef` isnt available,
// so create the ASP.NET Core Identity tables directly if they dont exist yet.
if (HasTable(c, "AspNetUsers")) return;
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetRoles" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY,
@@ -2014,6 +2012,8 @@ public static class StartupInitializationExtensions
{
using var migrationScope = app.Services.CreateScope();
var migrationDb = migrationScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
if (useSqliteBootstrap)
{
var migrator = migrationDb.Database.GetService<IMigrator>();
while (migrationDb.Database.GetPendingMigrations().FirstOrDefault() is { } migration)
{
@@ -2021,6 +2021,17 @@ public static class StartupInitializationExtensions
ReconcileSchema();
}
}
else
{
// MariaDB ALTER operations do not rebuild tables from a snapshot, so they do
// not need SQLite's per-migration reconciliation. Reconciling between its
// historical migrations can add a later column (for example Companies.Source)
// immediately before the migration that owns it, producing a duplicate-column
// failure on a clean database. Apply the chain first, then use the common final
// reconciliation pass for provider-safe repairs and reconciler-owned tables.
migrationDb.Database.Migrate();
}
}
catch (Exception ex)
{
app.Logger.LogError(ex, "Database migration failed during startup initialization.");
+2 -1
View File
@@ -1,6 +1,6 @@
# Technical debt
Last reconciled: 2026-07-31
Last reconciled: 2026-08-15
This ledger contains verified engineering debt only. Product ideas belong in the roadmaps and
operator/external dependencies belong in `BLOCKERS.md`.
@@ -40,6 +40,7 @@ operator/external dependencies belong in `BLOCKERS.md`.
| Priority | Debt | Current decision / trigger |
|---|---|---|
| P1 | EF migrations and startup reconciliation still share historical schema ownership. Blank SQLite EF migration, populated upgrade, and fresh/restarted MariaDB now pass, but the two mechanisms remain tightly coupled. | Keep the compatibility bootstraps and provider-specific ordering covered by `MigrationChainTests`. Consolidate ownership only through an expand/verify/contract migration after a production restore rehearsal; do not rewrite applied migration history. |
| P1 | `JobApplication` still duplicates opportunity data now owned by `Job`. | Startup now backfills missing `Job` rows and both create paths dual-write. Keep compatibility reads until the production report and restore rehearsal pass; observe one release, then remove the legacy columns. |
| P1 | Background workers assume one API instance. Restart recovery is durable, but there is no row lease for concurrent workers. | Add database leasing only before deploying more than one backend replica. |
| P2 | Production log aggregation is still deployment-owned; Compose now bounds each container's local logs to 3 × 10 MB. | Add an OTLP/Seq sink only before multi-host operation or when incident-response needs exceed `docker logs`. |
+1
View File
@@ -217,3 +217,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-183 | Owner-filtered job-choice API test; correspondence Jest; frontend production build | Repository root / `job-tracker-ui` | Remove the email compose/thread-move selectors' false 100-job ceiling | PASS — backend search finds the oldest target among 130 owned rows and excludes another tenant; correspondence 20/20 proves debounced server search, compose selection and thread-move selection; TypeScript/production build passes | Synthetic rows/JSDOM only; no provider, email or production action | MAIL-001 exhaustive job selection gap closed |
| V-184 | Shared synchronous AI provider decorator, durable/workspace suppression scopes, quota exception handler and full backend | Repository root | Make numeric Free/Pro AI limits universal without double-counting already-reserved work | PASS — focused shared-provider/accounting suite 25/25 and backend 674/674; success finalizes measured characters, Free/exhausted requests stop before provider I/O, workspace/operation scopes create no second row, and quota failures return stable 429 details | Fake in-process provider and SQLite only; no model, Stripe, MariaDB or production call | POL-001 repository accounting gap closed; Stripe lifecycle and production smoke remain |
| V-185 | Stripe gateway seam, mocked checkout/webhook lifecycle, entitlement tests and full backend | Repository root | Prove checkout identity and downgrade safety without using external Stripe | PASS — entitlement/billing 33/33 and backend 677/677; configured `price_` and stable user metadata reach Checkout, active grants Pro, `past_due` revokes it, canceled replay remains revoked without duplicate role mutation, non-AI profile data survives, and `prod_` in the price setting fails closed | In-process fake only; no Stripe network, customer, secret mutation, MariaDB or production call | Local POL-001 Stripe lifecycle gap closed; configured Stripe account journey remains blocked |
| V-186 | Migration-chain regression tests, EF model parity/scripts, direct EF SQLite, real application startup and disposable MariaDB 11.8 fresh/restart | Repository root / disposable local databases | Close the historical blank-chain defect without changing applied production state or losing populated rows | PASS — migration tests 3/3 and backend 680/680; blank SQLite reaches all 29 migrations twice, an older populated checkpoint preserves job title/date/owner/summary, EF-only SQLite subsequently serves `/health`, and fresh/restarted MariaDB serves `/health` with 29 migrations, 49 tables and provider-correct sampled ID/owner/decimal/timestamp types. MariaDB script constrains identifiers to 64 characters | Synthetic disposable databases only; no production migration, downgrade, backup restore or private row. Migration/reconciler dual ownership remains JT-019 architectural debt | Blank-chain blocker closed; production restore/rollout remains gated |
+32 -9
View File
@@ -1,6 +1,6 @@
# Database ownership and startup order
> 2026-07-19. Which component creates which table, in what order, and why a clean MariaDB install used
> Updated 2026-08-15. Which component creates which table, in what order, and why a clean MariaDB install used
> to fail. Read this before adding a table or touching `StartupInitializationExtensions`.
## The problem this document exists to prevent
@@ -22,28 +22,35 @@ provider.
## Startup order
`InitializeJobTrackerAsync` runs exactly this sequence:
`InitializeJobTrackerAsync` runs this provider-aware sequence:
```
1. Connect
2. ReconcileSchema() ← pass 1: repair existing schema, create reconciler-owned tables
3. Database.Migrate() ← create every migration-owned table
4. ReconcileSchema() ← pass 2: everything pass 1 had to skip
2. ReconcileSchema() ← repair legacy schema/create prerequisites
3a. SQLite: apply one migration, reconcile, repeat
3b. MariaDB: apply the complete migration chain
4. ReconcileSchema() ← create/repair everything skipped before migrations
5. Seed admin, start services
```
### Why the reconciler runs twice
### Why migration sequencing differs by provider
Neither position alone works:
Neither a single reconciliation position nor one shared provider sequence works:
- **Pass 1 must come first.** A legacy database has hand-added columns and Identity tables that
predate the migrations; without repairing them (and stamping the legacy migration id into
`__EFMigrationsHistory`) `Migrate()` collides with them. `AddCareerProfileRelationalChildren` also
adds children that reference `CareerProfiles`, a **reconciler-owned** table — so it must exist
before migrations run.
- **Pass 2 must come after.** On a brand-new database the migration-owned tables do not exist during
- **The final pass must come after.** On a brand-new database the migration-owned tables do not exist during
pass 1, so every reconciler table that references one (FK into `JobApplications`) is skipped, as
are the index and `AUTO_INCREMENT` repairs.
- **SQLite reconciles between migrations.** Historical SQLite table rebuilds read the current model
shape, including columns that were originally supplied by reconciliation. The per-migration pass
establishes that shape before a later rebuild reads it.
- **MariaDB does not reconcile between migrations.** Its ALTER operations do not use SQLite table
rebuilds, and an intermediate pass could create a later migration's column early and cause a
duplicate-column failure. It applies the chain first and uses the shared final repair pass.
Every statement in `ReconcileSchema` is existence-guarded, so the second pass is a no-op scan on an
already-correct database. Two consequences worth knowing:
@@ -59,7 +66,9 @@ already-correct database. Two consequences worth knowing:
Created by EF migrations, never by the reconciler:
`Companies`, `JobApplications`, `Jobs`, `Correspondences`, `Attachments`, `JobEvents`,
`RuleSettings`, and the ASP.NET Identity tables.
`RuleSettings`, and the ASP.NET Identity tables. Two compatibility migrations use guarded
`CREATE TABLE IF NOT EXISTS` bootstraps for `AspNetUsers` and `AiInteractions` so standalone EF
tooling can traverse the historical chain; normal application startup makes those statements no-ops.
The reconciler may **repair** these (add a missing column, add an index, fix a non-`AUTO_INCREMENT`
primary key) and may seed the default `RuleSettings` row — but it must never `CREATE TABLE` them.
@@ -133,6 +142,12 @@ dotnet run --project JobTrackerApi/JobTrackerApi.csproj
Create the empty schema/database itself (`CREATE DATABASE jobtracker;`); the application builds
everything inside it.
Standalone EF tooling is also supported for a blank SQLite database. The historical initial
migration now supplies the stable JobApplication columns required by later SQLite rebuilds, and
guarded compatibility bootstraps provide the reconciler-owned source tables used by later additive
migrations. Application startup may subsequently reconcile the remaining Identity and auxiliary
tables without losing rows.
## Production upgrade
Deploy and restart. The reconciler is idempotent and additive:
@@ -159,3 +174,11 @@ All four scenarios, 2026-07-19, against MariaDB 11 and SQLite:
Column types on MariaDB spot-checked: `int AUTO_INCREMENT` primary keys, `varchar(255)` owner keys,
`datetime(6)` timestamps, `tinyint(1)` booleans, and every composite index inside the key limit.
On 2026-08-15 the current 29-migration chain was additionally verified against a blank standalone
SQLite database, an older populated SQLite checkpoint, and a disposable MariaDB 11.8 database.
Standalone SQLite migration and retry both reached the latest migration; populated title/date and
reconciler-owned owner/summary data survived. Starting the application over that EF-only database
served `/health` successfully. Fresh MariaDB startup and restart both served `/health` with 29
migrations and 49 tables; provider-sensitive ID, owner, decimal and timestamp column types were
spot-checked. No production database was changed.
@@ -45,6 +45,6 @@ The exact isolated API process was stopped and port 5303 was confirmed closed. L
## Limitations
- Browser localhost access remains denied by the in-app browser administrator policy; no browser claim is made.
- No MariaDB server was available. Pomelo SQL generation passed, but execution awaits a disposable or production-safe MariaDB smoke.
- Direct `dotnet ef database update` against a completely blank SQLite file still fails in the pre-existing reconciler-owned schema gap at `AddJobEntityAndProspectStages`. The documented application startup path succeeds because the reconciler establishes those columns before migrations. This is JT-019 schema-ownership debt, not the JT-003 query defect, and historical migrations were not changed.
- Disposable MariaDB 11.8 execution now passes for a fresh application start and restart with all 29 migrations and 49 tables. Production MariaDB execution remains unverified.
- Direct `dotnet ef database update` against a blank SQLite file now reaches the latest migration and is idempotent. A populated older checkpoint preserves its job data through the same chain, and real application startup over the EF-only database serves `/health` (V-186). The broader migration/reconciler dual-ownership architecture remains JT-019 debt.
- Execution policy denied deletion of the exact disposable nested data directory; it is stopped and recorded in the session handoff.
+2 -2
View File
@@ -1,6 +1,6 @@
# MAIL-001 consolidated job-email hub
Updated: 2026-08-10
Updated: 2026-08-15
Status: `IN PROGRESS`. Canonical hub routing, provider-neutral reads, explicit-confirmed send API, persisted reply/new-message UI, interrupted-send recovery, legacy SMTP retirement, send-attempt/draft export coverage, shared application context and tenant-owned draft persistence/API are implemented and locally verified; remaining provider mailbox actions and full account-deletion lifecycle remain.
@@ -142,7 +142,7 @@ Status: `IN PROGRESS`. Canonical hub routing, provider-neutral reads, explicit-c
- The owning job has a cascade relationship; a real-SQLite two-owner test proves User A sees only User A's draft and deleting User A's job removes only that draft while preserving User B's data.
- The additive migration has explicit SQLite and MariaDB types plus reversible down SQL. EF reports the model current; backend passes 625/625 and both provider scripts generate successfully.
- No route, UI, provider call, token or content log was added. Export coverage and the complete SEC-009 deletion lifecycle remain prerequisites before private draft content becomes reachable.
- A disposable full migration-chain SQLite rehearsal is blocked in the older `AddJobEntityAndProspectStages` migration because it references `LastReminderEmailSentAt` before any migration creates it. The failure occurs before `AddEmailDrafts` and remains tracked as JT-019 schema-chain debt.
- The repaired historical chain now reaches this migration from a blank standalone SQLite database and remains idempotent. A populated older checkpoint also preserves job data through the chain (V-186); production migration remains gated.
## Implemented readable draft export coverage
+2 -2
View File
@@ -12,10 +12,10 @@ Updated: 2026-08-15
- **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages.
- **Immediate order:** the eight-item immediate queue is complete locally: admin version (`a6cffe0`), Career persistence (`f0b9b22`), CV contrast (`3b86ea2`), JOBS-002 (`deed948`), accessibility (`a7c2549`), PRODUCT-001 (`a25c31b`), VER-001 and tracking reconciliation. SEC-009 is complete at `842e793`; PROD-001 read-only evidence and the PROD-003 plan-only harness are complete pending this documentation commit. No further independent implementation remains.
- **Status counts:** 8 `VERIFIED LOCALLY`; 25 `IMPLEMENTED — NOT VERIFIED`; 0 `IN PROGRESS`; 1 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`.
- **Test status:** backend 657/657; frontend 58/58 suites and 237/237 tests; AI sidecar 22/22; Ollama benchmark harness 4/4 plus safe dry-run; optimized production build/TypeScript; EF model parity and MariaDB migration-script generation; Docker Compose config; safe-failure deployment preflight; and Playwright 9/9. Five real-SQLite deletion tests cover lockout, isolation, quarantine failure and restored-backup replay. npm audit 0 evidence remains current because the lockfile did not change. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded.
- **Test status:** backend 680/680; frontend 58/58 suites and 237/237 tests; AI sidecar 23/23; Ollama benchmark harness 5/5 plus safe dry-run; optimized production build/TypeScript; EF model parity; SQLite/MariaDB migration scripts; blank/idempotent/populated SQLite migration-chain tests; disposable fresh/restarted MariaDB 11.8 application startup; Docker Compose config; safe-failure deployment preflight; and Playwright 9/9. npm audit 0 evidence remains current because the lockfile did not change. Jest force-exit/open-handle behavior remains recorded.
- **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default.
- **Production status:** State unchanged. Sanitized read-only SSH inventory was performed; no logs, prompts, private rows/content or secret values were read, and no provider/model call, model pull, service restart, file/config change, backup, restore, migration or deployment occurred. It confirmed all-interface Ollama/frontend listeners and stale database-only backups as rollout blockers.
- **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before later migrations; normal startup owns reconciliation. Cross-feature monthly AI usage accounting remains a rollout gap.
- **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. The direct clean EF-only SQLite defect and synchronous AI accounting gap are closed; migration/reconciler dual ownership remains architectural debt.
- **Outstanding security findings:** JT-001 repository ownership remains High deployment risk until migration/inventory/provider checks; production portion of JT-002; JT-006 and SEC-009 production retention/restore plus JT-011/JT-012/JT-022 prerequisites. JT-005 foundations are implemented; AI worker activation awaits controlled rollout. Production still exposes ports contrary to the release-branch contract, and JT-007/JT-008/JT-010 lack provider/production verification.
## Current evidence
+14 -8
View File
@@ -62,6 +62,12 @@ This queue records the highest-value work that can proceed without production cr
| 6 | Honest Free/Pro homepage and upgrade surfaces | PRODUCT-001 | Locally complete. One catalogue drives exactly Free/Pro; retired tier/price/Free-AI/unlimited claims are gone; configured billing state controls the real upgrade action; contextual notices are reusable/dismissible. Frontend 232/232, policy/billing 30/30, build and Chromium 8/8 pass. |
| 7 | Complete application action matrix and full regression | VER-001 | Locally complete. Backend 647/647, frontend 232/232, sidecar 22/22, build, Compose configuration, safe-failure preflight and Chromium 9/9 pass; external provider/native-AT/production cells remain explicitly unverified. |
| 8 | Tracking and blocker reconciliation | All | Complete for this checkpoint. The plan, progress, handoff, verification log, action matrix and `BLOCKERS.md` distinguish repository work from external gates; continue updating them with each later package. |
| 9 | Complete live-account deletion cache/tombstone safety | SEC-009 | Locally complete. Sidecar-cache failure is retryable and fail-closed; tombstones use separate persistent storage; activation remains disabled pending retention and restore decisions. |
| 10 | Prove worker clocks and restart idempotency | BG-001 | Locally complete with injected clocks, exact-threshold tests, fresh worker instances and reminder/export deduplication. Production canary remains disabled. |
| 11 | Universal synchronous AI usage admission | POL-001 | Locally complete. Shared generation paths reject Free/exhausted users before provider I/O and avoid double-counting durable/workspace operations. |
| 12 | Prove email-token and Stripe downgrade lifecycles | SEC-005B, POL-001 | Locally complete with real Identity-token replay/expiry/custom-username tests and fake-gateway active/past-due/canceled Stripe transitions. External SMTP/Stripe journeys remain blocked. |
| 13 | Remove the Job email 100-application selector ceiling | MAIL-001 | Locally complete through bounded owner-filtered server search and tenant/UI regressions. |
| 14 | Repair the full historical migration chain | CORE-001, JT-019 | Locally complete for blank/idempotent/populated SQLite, EF-only-to-application startup, provider scripts and disposable MariaDB 11.8 fresh/restart. Production restore/rollout remains blocked. |
## Requirement coverage index
@@ -287,10 +293,10 @@ This queue records the highest-value work that can proceed without production cr
- **Required browser verification:** SQLite Career/Application workspace after API tests.
- **Required production verification:** MariaDB smoke after deployment.
- **Status:** `VERIFIED LOCALLY`.
- **Blocker:** production MariaDB execution and browser checks remain unavailable; direct blank-file EF-only migration is separate JT-019 schema-ownership debt while fresh application startup passes.
- **Evidence:** audit runtime reproduction JT-003; `docs/verification/core-001-sqlite-provider-parity.md`; 3/3 real-provider tests; 509/509 backend regression; isolated fresh-SQLite owner/empty/non-owner HTTP matrix.
- **Blocker:** production MariaDB execution and production browser checks remain unavailable. The direct blank-file EF-only defect is closed; historical dual schema ownership remains JT-019 architectural debt.
- **Evidence:** audit runtime reproduction JT-003; `docs/verification/core-001-sqlite-provider-parity.md`; V-186; 3/3 real-provider query tests; 3/3 migration-chain tests; 680/680 backend regression; isolated fresh-SQLite owner/empty/non-owner HTTP matrix; disposable MariaDB 11.8 fresh/restart smoke.
- **Commit:** none.
- **Remaining work:** browser Career/Application workspace verification and executable MariaDB smoke after safe provider/deployment access; address EF-only blank-chain drift under JT-019 rather than editing already-applied historical migrations here.
- **Remaining work:** production Career/Application browser verification and production MariaDB restore/rollout smoke. Keep the compatibility migration/reconciler contract covered until a later expand/verify/contract release can consolidate ownership safely.
### CORE-002 — Remove ambiguous application-workspace routes
@@ -341,10 +347,10 @@ This queue records the highest-value work that can proceed without production cr
- **Required browser verification:** not applicable until OPS-001C exposes owner APIs.
- **Required production verification:** executable MariaDB upgrade/down rehearsal and monitored schema rollout.
- **Status:** `IMPLEMENTED — NOT VERIFIED`.
- **Blocker:** no disposable MariaDB or production environment; application consumers deliberately not migrated yet.
- **Blocker:** disposable fresh/restart MariaDB now passes; production schema rollout and monitored consumer canary remain unavailable.
- **Evidence:** `docs/verification/ops-001a-durable-operations.md`; 7/7 focused and 539/539 full backend tests; SQLite upgrade/down/up and fresh startup; dual-provider scripts.
- **Commit:** none.
- **Remaining work:** MariaDB execution/production rollout; task-specific producers must validate references/policies and use OPS-001B/C rather than storing private payloads.
- **Remaining work:** production rollout; task-specific producers must validate references/policies and use OPS-001B/C rather than storing private payloads.
### OPS-001B — Persistent operation notifications and terminal outbox
@@ -359,10 +365,10 @@ This queue records the highest-value work that can proceed without production cr
- **Required browser verification:** deferred to OPS-001C.
- **Required production verification:** schema rollout and synthetic notification canary only.
- **Status:** `IMPLEMENTED — NOT VERIFIED`.
- **Blocker:** MariaDB execution unavailable; repository implementation can continue.
- **Blocker:** disposable fresh/restart MariaDB now passes; production migration/canary remains unavailable.
- **Evidence:** `docs/verification/ops-001b-notifications.md`; 9/9 focused and 541/541 full backend tests; forced transaction rollback; SQLite upgrade/down/up; current model snapshot; generated SQLite/MariaDB up/down SQL.
- **Commit:** none.
- **Remaining work:** execute the migration on MariaDB; expose owner APIs/UI in OPS-001C; complete browser and production canaries. No email delivery is part of this package.
- **Remaining work:** complete production schema/notification canaries. Owner APIs/UI are implemented in OPS-001C; no email delivery is part of this package.
### OPS-001C — Owner operation/notification APIs and frontend queue client
@@ -668,7 +674,7 @@ This queue records the highest-value work that can proceed without production cr
- **Blocker:** real provider/re-consent, full SEC-009 deletion, MariaDB, production and required 375/768/1440/theme/keyboard browser gates are unavailable or require new authority.
- **Evidence:** `docs/verification/mail-001-job-email-hub.md`; V-126V-153. Draft/new-message UI 13/13, API/idempotency/rotation 10/10, persistence 1/1 with dual-provider reversible SQL and readable export 4/4; Free send policy 7/7; provider states 9/9; hub unlink 8/8 UI and 2/2 API; shared application context focused 10/10; prior send export/cascade focused 16/16; recovery/send focused 10/10; legacy follow-up/worker 10/10; delivery/capability 18/18; provider/correspondence 5/5; hub detail 5/5; backend 630/630; frontend 50/50 suites and 198/198 tests plus build/audit; local empty/disconnected and compatibility-route browser smoke at 1280×720.
- **Commit:** `6008b4a` (hub), `536d403` (neutral reads), `a20775c` (safe detail), `653f011` (ledger), `e9937ac` (Gmail/Graph delivery adapters and consent), `123fc55` (explicit-confirmed send API), `449faeb` (confirmed reply composer), `ee5ef7e` (interrupted-send recovery), `8fe3903` (legacy SMTP retirement), `aff34cc` (content-free export and cascade evidence), `ff547df` (shared application context), `1dabbeb` (confirmed hub unlink), `f9e641c` (honest provider states), `7f41cb2` (Free email policy regression), `14b396a` (inert tenant draft persistence), `2fa4e38` (owner-isolated readable draft export), `a9bb22e` (tenant-safe revisioned draft API), `80b5532` (persisted draft send identity), `d3d2b67` (saved reply recovery/conflicts), `29de263` (definitive-failure identity rotation), `b735963` (new-message job/provider drafting).
- **Remaining work:** full account deletion remains SEC-009; provider mailbox category capabilities require separately authorized scopes/re-consent and remain absent; browser/production verification. Server-side owner-filtered job choice search now reaches applications beyond the old first-page ceiling (V-183). Existing connections need explicit re-consent; IMAP remains read-only. No real email; uncertain sends need manual reconciliation. JT-019 blocks a clean full-chain SQLite rehearsal before the new draft migration.
- **Remaining work:** SEC-009 repository deletion coverage is complete but production activation remains gated; provider mailbox category capabilities require separately authorized scopes/re-consent and remain absent; browser/production verification remains. Server-side owner-filtered job choice search now reaches applications beyond the old first-page ceiling (V-183). Existing connections need explicit re-consent; IMAP remains read-only. No real email; uncertain sends need manual reconciliation. The clean full-chain SQLite rehearsal now passes (V-186).
### JOBS-001 — Job-search source and assessment redesign
+2 -2
View File
@@ -3,7 +3,7 @@
Updated: 2026-08-15
- **Exact current task:** no independent implementation remains. SEC-009, PROD-001 read-only inventory/reporting and the PROD-003 plan-only benchmark harness are complete; continue only after a recorded blocker is authorized/resolved.
- **Last completed step:** measured production hardware/runtime/backups read-only, documented rollout stops, then added a synthetic-only benchmark harness that defaults to no network execution.
- **Last completed step:** repaired and regression-tested the full historical migration chain for standalone SQLite tooling and provider-aware application startup, including a disposable MariaDB 11.8 fresh/restart rehearsal.
- **Files currently modified:** master progress/work-plan/handoff/decisions/blockers/evidence; SEC-009 verification; production hardware/rollout/benchmark reports; production backup checkpoint; Ollama benchmark script/tests.
- **Commands already run:** SEC-009 focused backend 21/21, backend 657/657, frontend focused 8/8 and full 237/237, optimized build, EF parity, MariaDB script generation and Chromium 9/9; benchmark harness 4/4 plus plan-only dry run; sanitized read-only SSH inventory and gzip integrity across 21 existing dumps.
- **Test results:** all repository gates pass. PROD-001 is PASS/PARTIAL because measured all-interface ports and incomplete/stale backup/restore evidence fail its safety acceptance. No model inference was run. Jest retains the documented open-handle notice.
@@ -12,7 +12,7 @@ Updated: 2026-08-15
- **Production changes currently active:** none. Read-only SSH observed metadata/health/selected non-secret settings and backup integrity only. No log/private-row/content/secret read, deployment, migration, provider call, inference, model pull, restart, backup, restore or production file/config change occurred.
- **Rollback status:** SEC-009 is additive migration `20260815164027_AddAccountDeletionLifecycle`. Keep deletion disabled, reconcile any durable request, and retain tombstones before downgrade. `842e793` is pushed. Production still runs `de937d25dc5e` / version `157`; its checkout has a pre-existing mode-only `deploy/deploy.sh` change that must be preserved/reviewed.
- **Uncommitted changes:** documentation and the plan-only Ollama benchmark harness/tests following pushed SEC-009 commit `842e793`; no dependency, model, application runtime, schema or production state change in this checkpoint.
- **Known failures:** PR 28's newest remote CI is not yet confirmed. Production publishes frontend 3000 and JobTracker Ollama 11434 on all interfaces; latest observed database-only backup is 2026-08-02; no complete files/keys/tombstone restore proof; root is 83% used; deployed AI sidecar is old direct-Gemini behavior; production script mode is dirty. SEC-006 internet access, SEC-007 dependency, provider/re-consent, Stripe price, signup, retention/legal, backup/restore, model execution/deployment and legacy cutover decisions remain recorded blockers. Historical JT-019 and Jest open handles remain.
- **Known failures:** PR 28's newest remote CI is not yet confirmed. Production publishes frontend 3000 and JobTracker Ollama 11434 on all interfaces; latest observed database-only backup is 2026-08-02; no complete files/keys/tombstone restore proof; root is 83% used; deployed AI sidecar is old direct-Gemini behavior; production script mode is dirty. SEC-006 internet access, SEC-007 dependency, provider/re-consent, Stripe price, signup, retention/legal, backup/restore, model execution/deployment and legacy cutover decisions remain recorded blockers. JT-019's blank-chain defect is fixed, while dual ownership remains debt; Jest open handles remain.
- **Exact next action:** after this documentation/harness checkpoint is committed and pushed, stop. Resume from the highest-priority blocker the user authorizes: recommended first is production network plus complete backup/scratch-restore safety, then bounded synthetic model benchmarking.
- **Work that can continue independently:** none identified after the PROD-003 harness. Do not bypass blockers by pulling models, changing ports/firewalls, restoring data, contacting providers, or using package indexes without explicit authority.
- **Decisions still required from the user:** production network/port mutation; complete backup and scratch restore; retention/tombstone/legal policy; model pull/synthetic production inference; deployment/worker activation; parser package-index access; provider/Stripe/signup actions; legacy cutover timing.