ADR-0008: Casting contract — lenient value-preserving fallback for locale-dependent and unrepresentable types
Copy Markdown View SourceStatus: Accepted Date: 2026-09-28 Deciders: replicant maintainer (1.3.0 audit remediation; ships in 1.3.0)
Context
Replicant.Casting.Types.cast_record/2 converts each decoded pgoutput text value into
the Elixir term a sink receives in %Change{}.record. Three defects forced a contract
decision rather than spot fixes:
- Multidimensional arrays crashed on ordinary data.
ArrayParser.parse/1returns nested lists for 2-D+ literals, but only the_int*/_float*array clauses recursed; every other clause fed a nested list straight into its scalar parser (Decimal.new/1,DateTime.from_iso8601/1,Jason.decode/1, …), which raised — a fail-closed halt (value-free, but a halt) on a plainnumeric[][]column. moneywas locale-fragile. Money output is governed by the server'slc_monetary. The regex strip (String.replace(~r/[^\d.-]/, "")) turned ade_DE"1.234,56"intoDecimal 1.23456— a silently wrong 100x value — and multi-dot forms raised.timetzsilently lost data.String.slice(record, 0..7)dropped both the fractional seconds and the offset; there is no Elixir type for time-with-offset.intervalalready delivers the raw string for exactly this reason.
Decision
cast_record/2is lenient by default, with a documented small set of raise-sites (Decimal.newon malformednumeric,Base.decode16!on non-hexbytea,DateTime.from_naive!ontimestamp) that the decode boundary scrubs value-free. This part is unchanged; ADR-0003 governs the boundary.- Every casted array type recurses element-wise through
cast_array_elements/2with a per-type scalar caster mirroring its scalar clause exactly — raise-sites stay raise-sites, lenient stays lenient. No array type halts on legitimate nested data. - Locale-dependent money parses only the strict C/en-US shapes (optional leading
minus and
$, digits with valid,###grouping or plain digits, optional cents) and delivers the original string otherwise — never a silently-wrongDecimal, never a raise. A forcedDecimalcannot be honest under an unknownlc_monetary. - Types with no faithful Elixir representation deliver the raw server string
(
timetz,interval). Information-preserving beats lossy convenience. - The array literal parser is strict where Postgres is strict (empty elements,
unbalanced framing) and quote-aware (braces inside quoted elements are element
data), because a silent mis-parse of
array_outoutput is a wrong-length or wrong-typedrecord— worse than a scrubbed error. Replicant.lsn_from_string/1returns{:ok, lsn} | {:error, :invalid_lsn}instead of raising. A public function of a published Hex package must never put a caller's input bytes into an exception message (a crash report is a log surface — Critical Rule 1). This is a public return-shape change shipped in 1.3.0 (a minor) by owner decision: the affected surface is narrow (LSN display-string parsing), the old shape could embed input in a raise, and the ecosystem is young; the CHANGELOG carries an explicit upgrade note.
Options considered
- Always deliver
moneyas a raw string — breaks correct C/en-US consumers for no gain; rejected. - Detect the server locale (
SHOW lc_monetary) and parse accordingly — connection coupling plus a locale-table this library would have to maintain forever; rejected. The raw-string fallback is locale-honest without a table. - A
{Time, offset}tuple fortimetz— invents a public shape no consumer asked for; the raw string matches theintervalprecedent; rejected (revisit on demand). - Widen
cast_record/2to swallow all raises — explicitly rejected: the moduledoc's raise-site contract predates this ADR; only genuinely-malformed input reaches the raising path, and the boundary (ADR-0003) is the correct scrub point.
Consequences
A sink receiving
money/timetzmust acceptDecimal | String.t/String.trespectively; downstream adapters (e.g.ash_replicant) classify these types themselves and were checked before release.- Multidimensional arrays of every casted type now deliver nested terms with the same per-element semantics as their scalar clauses.
type_modifieronRelation.Column/Change.Columndecodes as signed int32 (Postgresatttypmodis signed;-1is the ubiquitous "no modifier" marker and previously surfaced as4294967295).- The getting-started Livebook demonstrates the casting contract against a live server and its CI test asserts the observed values on every push.