Walk into any small factory and ask how calibration is tracked. Nine times out of ten, someone points at a spreadsheet. It lives on one person's desktop, gets updated when somebody remembers, and when the ISO 9001 auditor asks for proof, that's what gets waved at them. It doesn't hold up.
CalibKeep is my attempt to fix that: a fully offline, encrypted desktop app that manages calibration schedules, depreciation, and audit trails. I learned a few painful lessons building it. This post is the stack, the traps, and the fixes — in the order I wish I'd hit them.
The Stack at a Glance
- .NET 8 + Avalonia UI 11.2 (MVVM, CommunityToolkit.Mvvm)
- EF Core 8 + SQLCipher for an AES-256 encrypted local database
- QuestPDF for calibration/asset/depreciation reports
- xUnit + Moq, currently 105 tests
- DPAPI for local credential storage (auto-unlock hint only — never the master key)
Rule #0: No DbContext Singletons
// ❌ Never this
services.AddSingleton<AppDbContext>();
// ✅ Always this
using var ctx = _contextFactory.CreateDbContext();
Every repository and service opens its own short-lived context via IDbContextFactory. This keeps shared state out of the picture. No cross-thread context leaks, no weird collisions when the UI thread and a background export hit the database at the same time.
The SQLCipher Trap That Cost Me a Day
The scariest bug in this project: opening a SQLCipher database with the wrong passphrase permanently poisons the connection handle for the process lifetime. Not Close(). Not ClearAllPools(). Not even GC. The handle stays broken.
The fix was to never open the file to validate the password:
// metadata.json holds an HMAC marker computed from the passphrase.
// We verify the HMAC before ever touching the SQLite file.
Restore went through the same kind of ordeal. Instead of swapping files around, we moved to SQLite's online backup API (BackupDatabase) and write into the live DB without locking:
temp file ──► BackupDatabase ──► live SQLCipher DB
That eliminated both "file is not a database" and "file is being used" errors for good.
The Audit Trail: Write It in the Same Transaction
ISO 9001 wants proof of every change. So every service that does Create/Update/Delete writes an AuditLog row in the same transaction — no exceptions.
Table FieldName OldValue NewValue
Calibration NextDueDate 2026-08-01 2026-11-01
It's boring code, but it's the difference between "a nice app" and "a compliance tool."
Calibration Impact: The Feature That Adds Real Value
Changing a calibration interval isn't a single field update — it can invalidate dependent equipment. So when a calibration-critical field changes (manufacturer, model, measurement range, accuracy, reference standard...), a small rule engine flags the impact as HIGH, counts the open calibration tasks affected, and writes the reasoning into the audit trail. Interval changes on a calibration task get the same treatment. It's a hard-coded table of critical fields and two careful checks — no AI, no magic — but it's what makes the app feel like it understands the domain instead of just storing dates.
MSIX Packaging: Three Traps, One Script
Shipping to the Microsoft Store surfaced three gotchas:
-
makeappxdemandsAppxManifest.xml— notPackage.appxmanifest— and the schema requires aTargetDeviceFamily. -
Signing fails with
0x8007000Bwhen the manifestPublisher(CN=RuffStack) doesn't match the signing certificate subject. Event log ID 150 told us exactly this. -
Store licenses are real, local trials are fake. We now read the actual
StoreContextlicense in packaged builds and fall back to a local 15-day trial in dev. One interface:
public interface IStoreLicenseContext
{
Task<LicenseInfo?> GetLicenseAsync(); // null = not a Store build
}
Everything is now one command:
powershell -File CalibKeep.Packaging\build-msix.ps1 -Sign
Takeaways
- Encrypted local storage in .NET is doable, but budget time for SQLCipher handle poisoning and backup/restore edge cases.
- If you need compliance, bake the audit trail into the transaction boundary, not as a side effect.
- Test the packaging/licensing story early — it's the "works on my machine" trap with extra steps.
If you're building an offline-first desktop tool with .NET 8 + Avalonia, I hope this saves you a couple of rabbit holes.
ruffstack.dev
Top comments (0)