---
key: media.lastscan-null-boundary
title: 2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)
status: active
since: '2026-07-18'
supersedes: none
superseded-by: none
rule: A never-scanned `LastScan` surfaces as `null` at the API/MCP boundary, not the `0001-01-01` MinValue sentinel — enforced by an ongoing read-boundary coercion plus a one-time data migration cleanup.
signals: 'LastScan, MinValue sentinel, API-boundary honesty · paths: `GetAllMediaSourcesForApiHandler.NormalizeLastScan`, `MediaSourceRepository` · issues: #409, #460'
mechanics: '`NullOutNeverScannedLastScan` migration; `LibrariesScreen.tsx` `hasScanned` removal; #460 write-side `null`'
---

`Library.LastScan` / `LibraryPath.LastScan` are `DateTime?`; a never-scanned library is `null` at
runtime for a freshly-created row. But the `0001-01-01 00:00:00` MinValue sentinel appeared in the
DB from **two** sources — both now closed:
1. Old `Reset_*` migrations wrote it via raw SQL (`UPDATE Library SET LastScan = '0001-01-01 00:00:00'`).
2. Live code wrote it on the Plex/Jellyfin/Emby remove-and-recreate (disable-sync) flows in
   `MediaSourceRepository`. **Closed by #460**: those three writes now set `null`, so normal use no longer
   produces new sentinel rows. Safe because every remaining `LastScan` reader either coalesces
   (`LastScan ?? SystemTime.MinValueUtc`) for its own non-nullable scan-comparison, or is the read-boundary
   coercion below — scan-comparison behavior is unchanged.

`GetAllMediaSourcesForApiHandler` projected `l.LastScan` straight onto its `DateTime?` DTO, so that
sentinel leaked to API/MCP clients as a fake midnight timestamp — the SPA papered over it with a
client-side year<1900 heuristic (#409 first pass). Decision: the API is the right place to be honest, so
**never-scanned reports as null** for API/MCP parity with the UI, via two layers:

- **Read-boundary coercion — the load-bearing guard** (provider/history-independent):
  `GetAllMediaSourcesForApiHandler.NormalizeLastScan` maps any `< 2000-01-01` value to null. It stays
  *permanent* even after #460 closed source #2: it is what makes the contract hold for sentinel rows this
  codebase is not going to rewrite — a restored or hand-edited DB, and any row written by source #2
  between #409's cleanup migration and #460 — and a migration-only fix would have regressed the next time
  a user toggled a library's sync off.
- **Data migration** (`NullOutNeverScannedLastScan`, dual-provider): a one-time cleanup of the historical
  residue — `UPDATE Library/LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan <
  '2000-01-01'`. Data-only (empty `Up`/`Down` otherwise, both `TvContextModelSnapshot.cs` byte-identical).
  The `< '2000-01-01'` predicate matches the `0001-01-01` sentinel robustly on both providers (ISO-text
  compare on SQLite, whatever the out-of-range zero date stored on MySQL — where no `Reset_*LastScan`
  migration ever ran, so it's a safe no-op there) without depending on the exact stored bytes; no real
  scan predates ErsatzTV. `Down` is a no-op — the original sentinel is unrecoverable and worthless.

`GetAllMediaSourcesForApiHandler` is the **only** API/MCP-facing consumer of `LastScan` (grepped
`LastScan` under `ErsatzTV.Application/**/Queries` and `ErsatzTV/Controllers`); the other reads are
internal scanner code that coalesces to `SystemTime.MinValueUtc` for its own non-nullable
`DateTimeOffset` scan-comparison needs and never serializes it to a client. The DTO field was already
`DateTime?`, so the OpenAPI schema is unchanged (no regen). The SPA's `hasScanned` heuristic in
`LibrariesScreen.tsx` was removed — both call sites revert to a plain null/truthy check now that the API
is honest.

**#460 (write side, 2026-07-25):** the follow-up this record deferred is done — the three disable-sync
writes set `null` instead of `MinValue`, so **no new** sentinel rows are created. Deliberately ships
**no** second cleanup migration: rows written by source #2 between #409's `NullOutNeverScannedLastScan`
and this change still hold the sentinel at rest, and the read coercion above (not a migration) is what
keeps the contract honest for them. Regression-tested per provider in
`MediaSourceRepositoryDisableSyncTests`. `LibraryPath.LastScan` is deliberately left untouched — the
disable-sync flows re-add `Paths` with their original values, and the only readers of the path-level
column are the local-library scan handlers, so it has no API surface and no remote-scan effect. This
*extends* the rule rather than reversing it: the read-boundary coercion is unchanged and authoritative.
