# Row Value Contract This document defines the runtime types that flow through [`ir.Row`](../internal/ir/change.go) — the dialect-neutral representation of a single row of data. While [docs/type-mapping.md](type-mapping.md) describes how engine-specific *DDL* types translate to and from the IR's `Type` hierarchy, this document describes the Go *values* that live in `Row` for each IR type, end to end through the read → translate → write pipeline. The contract is normative: every engine reader MUST produce values matching this table; every engine writer MUST accept values matching this table. The translator may rely on it. ## The `Row` type ```go type Row map[string]any ``` Rows are keyed by column name. Values are stored as `any` because the type set is small but heterogeneous; a typed sum would add ceremony without practical benefit. The contract below removes the looseness `any` would otherwise imply. ## NULL handling SQL `NULL` is represented by a Go `nil` value: ```go row["nullable_col"] == nil // true when the column was NULL in the source ``` This applies to every column type — there is no distinction between "Integer NULL" and "String NULL"; both are stored as a plain `nil` interface value. Engine readers must never store a typed nil pointer (`(*int64)(nil)`, `[]byte(nil)`, etc.) — only an untyped `nil`. Engine writers must treat any `nil` value as SQL `NULL` regardless of the column's IR type. Nullability itself is a property of the IR `Column` (`Column.Nullable`), not of the value. A non-nullable column whose `Row` value is `nil` is an error the writer should reject; readers will not produce such values from well-formed source data. ## Core IR types | IR type | Go value type in `Row` | Notes | |---|---|---| | `Boolean` | `bool` | | | `Integer` (signed, any width) | `int64` | Width is preserved on `Column.Type`, not on the value. | | `Integer` (unsigned ≤ `MaxInt64`) | `int64` | Numeric value is unambiguous; signedness lives on `Column.Type`. | | `Integer` (unsigned, may exceed `MaxInt64`) | `uint64` | Engine readers should choose `uint64` for unsigned columns whose values may exceed `MaxInt64` (typically `BIGINT UNSIGNED`). | | `Decimal` | `string` | Textual representation preserves precision losslessly. Avoid `float64` round-trips. | | `Float` (single or double) | `float64` | Single-precision values are widened to `float64` — no information loss in this direction. | | `Char`, `Varchar`, `Text` | `string` | Charset / collation are properties of the column, not the value. The bytes are interpreted in that charset to produce the Go string (engine readers handle the conversion). | | `Binary`, `Varbinary`, `Blob` | `[]byte` | See [memory ownership](#memory-ownership-of-byte-slices) below. | | `Date` | `time.Time` | Time portion is `00:00:00`, location is `UTC`. | | `Time` | `string` | A textual representation such as `"08:30:00"` or `"08:30:00.123456"`. Go's `time.Duration` is *not* used because some SQL `TIME` values fall outside its valid range. | | `DateTime` | `time.Time` | Location is `UTC` for transport. The semantics of "no time zone" are recorded on `Column.Type`, not on the value. | | `Timestamp` | `time.Time` | Always `UTC` regardless of the source's session timezone. Engine readers handle the conversion. | | `JSON` | `[]byte` | Raw JSON bytes. Whether the engine validated/normalised the bytes is recorded on `Column.Type` (`JSON{Binary: true}` for a parsed/normalised representation, `JSON{Binary: false}` for textual). | **Float NaN payload bits are outside the contract.** A Postgres `float8` NaN applied through slot-CDC lands as Go's canonical quiet NaN (`7ff8000000000001`) where the source held PG's (`7ff8000000000000`) — text-identical, invisible to every SQL comparison and to `verify`, detectable only via `float8send` bytes; cold copy and trigger-CDC preserve the source's bits (observed live on Cloud SQL PG 16, 2026-07-16, provider-independent). `-0.0` DOES round-trip exactly through slot-CDC apply (the trigger engine's `-0`→`+0` wart is documented in ADR-0066). If float-bit forensics ever become a stated guarantee, this is the line to revisit. ## Extension IR types | IR type | Go value type in `Row` | Notes | |---|---|---| | `Enum` | `string` | The enum value itself, not its ordinal. | | `Set` | `[]string` | The currently-selected members, in declaration order. An empty set is a non-nil empty slice (`[]string{}`), distinct from `nil` which would mean SQL `NULL`. | | `UUID` | `string` | Canonical hyphenated form (`"01234567-89ab-cdef-0123-456789abcdef"`). Lowercase. | | `Array` | `[]any` | Each element follows the contract for its `Element` IR type. Multidimensional arrays nest. | | `Geometry` | `[]byte` | Raw WKB (Well-Known Binary). The subtype is recorded on `Column.Type`. | | `Inet` | `string` | Canonical textual form (`"192.168.1.1"`, `"2001:db8::1"`). | | `Cidr` | `string` | Canonical textual form (`"192.168.1.0/24"`). | | `Macaddr` | `string` | Lower-case, colon-separated (`"08:00:2b:01:02:03"`). | ## Memory ownership of byte slices Engine readers MUST return byte slices that the caller owns. In particular, the slice MUST NOT alias the database driver's internal scan buffer — those buffers are typically reused across rows, and aliasing would silently corrupt earlier rows once a later row is read. Concretely: the MySQL reader copies bytes off the driver's `[]byte` before returning them. Other engine readers must do the same. The unit test [`TestDecodeBytesIsCopy`](../internal/engines/mysql/value_decode_test.go) enforces this property for the MySQL engine; equivalent tests should accompany every other engine reader. ## Time zone semantics | IR type | Stored timezone in `time.Time` | Semantic interpretation | |---|---|---| | `Date` | `UTC` | A wall-clock date with no time portion. The `UTC` location is a transport convention, not a meaningful timezone. | | `Time` (stored as string) | n/a | A wall-clock time of day, no timezone. | | `DateTime` | `UTC` | A wall-clock date+time with no timezone. The `UTC` location is, again, a transport convention; the value's semantic timezone is "unspecified" and recorded on `Column.Type` (`Timestamp{WithTimeZone: false}`). | | `Timestamp` | `UTC` | An instant in time. The instant is the same regardless of where the consumer reads it. | Engine readers must not return `time.Time` values in a non-UTC location. Engine writers must accept any `time.Time` location (calling `.UTC()` is cheap and idempotent), but should write in the engine's expected form: a literal date for `Date`, a UTC datetime literal for `DateTime`, and an instant for `Timestamp`. ### Zero and partial dates (MySQL legacy data) MySQL under a relaxed `sql_mode` can store dates with no valid calendar value: the all-zero `'0000-00-00'`, a zero month (`'2026-00-15'`), or a zero day (`'2026-06-00'`). These have **no faithful `time.Time` representation** — Go's `time.Date` would normalize a zero component into a neighbouring real date, silently corrupting the value (Vector A). The MySQL reader therefore reads `Date`/`DateTime`/`Timestamp` columns as their raw text (via `CAST(... AS CHAR)`) so the decode layer sees the literal, and resolves zero/partial dates per the operator's `--zero-date` policy **before** a `time.Time` is ever constructed: - `error` (default) — refuse loudly, naming the column. The IR never carries a guessed value. - `null` — emit SQL `NULL` (refused loudly for a `NOT NULL` column). - `epoch` — emit `1970-01-01` (`1970-01-01 00:00:01 UTC` for date+time types). The one-second offset past midnight is deliberate: MySQL's `TIMESTAMP` floor is `1970-01-01 00:00:01` UTC, so a midnight placeholder is unrepresentable on a MySQL `TIMESTAMP` target and a relaxed-`sql_mode` write would silently coerce it back to the `0000-00-00` zero sentinel. A single sentinel at the floor is representable by every temporal target (and the offset is meaningless on a synthetic placeholder for an invalid date), so the resolution stays target-agnostic in the source reader. A genuinely out-of-range but **non-zero** date (month 13, Feb 30) is not a zero date; it stays a hard decode error regardless of `--zero-date`, so the flag can never silently rescue malformed data. See [migrating-legacy-mysql.md](operator/migrating-legacy-mysql.md) for the operator-facing flow and its interaction with the write-side `--mysql-sql-mode`. ## Per-engine reader normalisation requirements Drivers vary in what they return for the same SQL value. Engine readers are responsible for normalising to the contract above. The MySQL reader, for example, must: - Coerce `int64` → `bool` for `Boolean` columns whose source is `TINYINT(1)`. MySQL stores the full signed 8-bit range in a `TINYINT(1)` (it is only a display width), so a column used as a real small integer can hold values outside `{0,1}` (2, 127, -1, …). The boolean convention collapses every non-zero value to `true`, losing the integer. The reader **WARNs loudly, once per column** (naming the column and an example value) when it sees such a value, and points at the data-preserving remedy: `--type-override