Drift Report Row Mapper
Translate drift reports to and from the property maps the Neo4j graph store reads and writes.
Neo4j properties are scalars and flat arrays, while a report's drifted type sets are collections, so both sets are serialized to JSON strings. JSON also handles names containing pipes, tabs, newlines and quotes, which these names routinely do: they come out of LLM extraction. Both sets are written sorted, so re-saving one observation writes byte-identical JSON and the MERGE is a no-op. Nothing reads the order back; the sets are read into a Set.
The capture instant is written three ways. capturedAt is the ISO-8601 string and is half the natural key, so it is what round trips. capturedAtEpochSecond and capturedAtNano let the database sort and range-filter on the instant at full precision. Epoch milliseconds, which a version stamp uses, truncate: two reports captured 500µs apart would compare equal, leaving "newest first" arbitrary between them, and a since bound falling inside a millisecond would sweep in reports captured just before it. Sorting on the ISO string has its own failure — Instant.toString() writes no fraction on a whole second and 'Z' outranks '.', so 12:00:00Z sorts after 12:00:00.500Z.
A fourth property, contextKey, encodes the report's scope; see GLOBAL_CONTEXT_KEY.
Reads are strict: a property this mapper wrote must be present when it is read again. contextId is the one exception, because its absence is how a global report is encoded. A node missing anything else is corrupt, so the accessor throws and the store's surrounding guard skips the row with a warning.