10 KiB
P8-03 backend persistence evidence
Verified locally on 21 October 2026 against the Phase 7 WAL baseline. No production database or external service was used.
Contract and ownership
GET and PUT /api/auth/preferences/dashboard require the existing local authentication scheme. The principal supplies the account ID; query/body account IDs have no authority. Missing/unknown subjects return 401. DTO: {version:1, revision, widgets:[{id,visible,size}]}. No saved layout returns revision 0 and an empty widget array. Successful PUT returns the canonical DTO with revision incremented by one; a stale revision returns 409 with error: dashboard_revision_conflict.
Writes accept at most 16,384 actual UTF-8 bytes, including chunked requests; larger bodies return 413. Invalid JSON, unsupported/missing version, non-integer/negative/exhausted revision, absent widgets, more than 32 entries, unknown/duplicate IDs, absent/non-boolean visibility, and invalid sizes return 400 without saving. Incoming revision must be below 9,007,199,254,740,991 so the incremented revision remains an exact JavaScript integer. Supported IDs are the nine in the Phase 8 catalogue. cards accepts only full; every other widget accepts standard or full. Unsupported sizes are rejected on writes. Only validated canonical fields are stored; extra request fields are discarded. An empty saved list is valid, but has a positive revision and therefore remains distinct from an unsaved account. The frontend owns missing-widget defaults and explicit reset behaviour.
Reads retain the database revision. Unknown, duplicate or malformed saved widgets are omitted; unsupported saved sizes become full for cards and standard otherwise. Corrupt or oversized stored JSON becomes an empty list without resetting the revision. This permits a deliberate revision-guarded repair save and prevents corrupt storage from rendering arbitrary content. A bounded JSON document declaring an integer schema version greater than 1 instead exposes that actual version and an empty widget list. PUT rejects such stored future schemas with 409 dashboard_version_unsupported, even when a version-1 request supplies the matching revision; newer settings remain intact and the frontend can disable editing.
ApplicationUser owns nullable DashboardLayoutJson and default-zero DashboardLayoutRevision. The endpoint uses one SQL ExecuteUpdateAsync guarded by owner ID and expected revision; it updates exactly these two columns. Simultaneous first writes cannot both win. No layout write invokes UserManager.UpdateAsync.
The future-schema guard and expected revision come from the same initial row snapshot. After the bounded body is read, its supplied revision must match that snapshot before the conditional SQL update runs. This closes a reproduced race where a paused chunked request could guess a newer revision installed with a future schema after the earlier schema check. The deterministic HTTP regression observes EF's completed initial row read, installs version 2/revision 1 on another connection while the body is held, then releases a version-1/revision-1 body. Before repair it returned 200 and overwrote the future settings; after repair it must return 409 and preserve the exact JSON/revision. The existing SQL revision predicate also rejects an intervening write after the snapshot comparison.
A reproduced additional race showed that an unrelated stale Identity whole-user update could otherwise replay the old layout/revision. Both layout properties ignore ordinary EF tracked updates after insertion. Their conditional endpoint update remains effective. The regression marks every property modified, matching Identity's store behaviour, then proves the newer layout survives. Profile, language, notification settings and Identity stamps remain unchanged by layout saves.
Migration and startup
Additive migration 20261021090000_AddDashboardLayoutPreference adds nullable TEXT/longtext JSON and non-null INTEGER/bigint revision default 0. No new table or backfill of layout choices. The snapshot includes both properties. SQLite and MariaDB startup reconciliation intentionally leave these columns to the migration, matching the existing language preference owner; adding them before the per-migration loop would collide on fresh startup. Actual fresh and legacy file startup plus existing complete migration-chain and repeated-startup tests pass.
The explicit SQLite downgrade drops only these independent columns using native SQLite DROP COLUMN (requires SQLite 3.35+, satisfied by the tested bundled runtime). MariaDB uses normal EF DropColumn operations. Migration-chain downgrade/upgrade tests pass; downgrade necessarily removes saved layouts. No downgrade was performed outside disposable tests.
Test evidence
All commands used the pinned executable C:\Users\Connor\.dotnet\dotnet.exe through rtk, project JobTrackerApi.Tests/JobTrackerApi.Tests.csproj, flags --configuration Release --no-restore, and a TRX logger/results directory below .artifacts/phase8/.
| Run | Filter | Actual result | TRX |
|---|---|---|---|
| Meaningful pre-implementation RED | FullyQualifiedName~DashboardPreferencesTests |
0 pass / 13 fail / 0 skipped; absent routes/model | backend-red/dashboard-red.trx |
| Initial implementation | same | 13 pass / 0 fail / 0 skipped | backend-green/dashboard-green.trx |
| Stale Identity race RED | FullyQualifiedName~Stale_identity_profile_update |
0 pass / 1 fail; saved revision 1 overwritten by 0 | backend-stale-red/dashboard-stale-red.trx |
| First expanded run | dashboard + migration chain + SQLite startup + schema ownership | 25 pass / 16 fail; 15 downgrade SQL generation failures and one malformed fixture SQL | backend-focused/dashboard-focused.trx |
| Downgrade fix successor | same | 40 pass / 1 fail; remaining malformed fixture SQL | backend-focused-successor/dashboard-focused.trx |
| Final expanded run | same, plus two further dashboard HTTP cases | 43 pass / 0 fail / 0 skipped | backend-focused-final/dashboard-focused.trx |
| Complete backend | no filter | 1081 pass / 0 fail / 0 skipped, 29 seconds | backend-full/dashboard-full.trx |
| Future-schema / JS-safe revision RED | FullyQualifiedName~Future_schema|FullyQualifiedName~Invalid_payload_does_not_save |
12 pass / 2 fail; future version hidden and unsafe exhausted revision accepted | backend-future-red/dashboard-future-red.trx |
| Complete backend after future-schema repair | no filter | 1083 pass / 0 fail / 0 skipped, 32 seconds | backend-full-final/dashboard-full.trx |
| Future-schema paused-body race RED | FullyQualifiedName~Future_schema_installed |
0 pass / 1 fail; returned 200 instead of 409 | backend-toctou-red/dashboard-toctou-red.trx |
| Paused-body repair focused | FullyQualifiedName~DashboardPreferencesTests |
24 pass / 0 fail / 0 skipped, 24 seconds | backend-toctou-focused/dashboard-toctou-focused.trx |
| Complete backend after paused-body repair | no filter | 1084 pass / 0 fail / 0 skipped, 33 seconds | backend-toctou-full/dashboard-full.trx |
The initial test-harness attempt omitted historical startup reconciliation and failed before reaching endpoints (missing historical AvatarImageDataUrl column). The meaningful RED above reran after wiring the actual startup owner. The subsequent malformed fixture SQL used a literal brace through EF's formatting API; parameterising it corrected the fixture without changing the product expectation.
Final focused command:
rtk C:\Users\Connor\.dotnet\dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --filter "FullyQualifiedName~DashboardPreferencesTests|FullyQualifiedName~MigrationChainTests|FullyQualifiedName~SqliteStartupConcurrencyTests|FullyQualifiedName~StartupSchemaOwnershipTests" --logger "trx;LogFileName=dashboard-focused.trx" --results-directory .artifacts/phase8/backend-focused-final
Final complete command:
rtk C:\Users\Connor\.dotnet\dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=dashboard-full.trx" --results-directory .artifacts/phase8/backend-toctou-full
The 24 new dashboard cases use an actual local Kestrel HTTP listener, real EF SQLite files, the complete migrations and actual startup reconciliation. They verify 401, owner isolation despite an attempted query account override, GET/PUT acknowledgement, 409, concurrent first saves, unchanged unrelated account fields/stamps, stale Identity preservation, invalid/oversized/chunked payloads, all catalogue IDs/order, empty saved lists, corrupt reads, future-schema downgrade protection including a deterministic paused-body race, exact JavaScript revision bounds, legacy defaults and provider SQL generation. They also hold a SELECT reader open while saving the actual layout against the WAL file.
Limits
The HTTP fixture substitutes a test-only local authentication handler; it proves endpoint scheme/owner enforcement, not production cookie issuance, CSRF/session expiry or proxy behaviour. It uses the existing minimal EF host conventions; TestHostFactory has no reusable real HTTP host, so the fixture adds only the services needed by AuthController/startup. Existing auth tests remain in the passing complete suite. Browser/session integration belongs to the UI acceptance lane.
MariaDB migration SQL is generated with the installed Pomelo provider and explicitly checks longtext NULL and bigint NOT NULL DEFAULT 0. This is SQL inspection, not MariaDB runtime evidence. rtk docker ps --format "{{.Names}} {{.Image}} {{.Ports}}" failed because the Docker Desktop Linux engine pipe was unavailable; no disposable MariaDB instance was exercised or created.
The held-reader and simultaneous-save tests establish finite regression evidence. WAL does not eliminate every form of writer contention; no retries, swallowed provider failures or permanent reliability claim were introduced. Human/device, deployed configuration, backup/restore and production gates remain outstanding; production remains NO-GO.