ADR 0072 — numFmt coercion failure degrades to a warned text fallback (amends ADR-0003)
- Status: proposed
- Date: 2026-06-28
- Spec target: XTL 1.x, POST-1.0 (per ROADMAP, #56 is post-1.0 additive). Amends ADR-0003: relaxes a fatal error to a warned text fallback. The behavior relaxation is more permissive, but removing the
xl3/cell/numfmt-coercionerror code is a breaking catalog change — so implementation is gated to after the 1.0 freeze (thisproposeddoc does not reset the G24 clock). - Affects: evaluation.md ("Cell Evaluation / Single-Expression Cells"); language.md (numFmt coercion paragraph, L253); ADR-0003 (failure-mode clause); impl (
renderer.tsrenderCellValue/coerceDateValue/coerceNumberValue);types.ts(XtlWarningCode); error-codes catalog; conformance fixtures 021/022 - Amends: ADR-0003
- Issue: #56
Context
ADR-0003 promoted single-expression-cell numFmt coercion from MAY to
MUST and added the clause "Failing to coerce is an error," with the
explicit rationale that "silent string-pass-through is no longer
conformant." The reference impl honors this in renderer.ts:
coerceDateValue / coerceNumberValue throw xl3/cell/numfmt-coercion
when a string value will not parse under a date/number format.
That throw aborts the entire conversion, not just the offending
cell. Real operator data routinely mixes a typed value with placeholder
text in the same column — -, N/A, blank-as-dash — because the source
sheet inherited a date/number numFmt on a column that is only
sometimes a date. A single - in a 취소일 (cancel-date) column under
an mm-dd-yy format kills the whole file:
Error: Value cannot be coerced to a date for cell format "mm-dd-yy": -
code: 'xl3/cell/numfmt-coercion'
Three problems:
- Stricter than Excel. Excel shows non-conforming text in a date/number cell as text and moves on. xl3 makes it fatal.
- Whole-file abort. One bad cell out of thousands fails the entire output. Common with placeholder-mixed real data.
- Inconsistent with the sibling failure path. In the same
renderCellValue, a formula error (__xl3_error__) is recorded per cell ({ error }, render continues), while coercion failure alone aborts globally. - Poor diagnostics. The message carries only the format and value
(
-) — no sheet / cell address / column — so locating the offending column is slow.
The author currently works around this by clearing the block cell's
numFmt to General at template-build time for every mixed column — a
non-obvious trap repeated per template. That is exactly the toil xl3
exists to remove.
ADR-0003's reproducibility argument, however, is real and must be preserved: if coercion failure silently passed through as text, two conformant impls could diverge (one errors, one passes through) with no observable signal. The fix must keep that signal.
Considered Options
A. Silent text fallback. On failure, write the value as text and continue. Fixes the abort and matches Excel. Rejected — this is precisely the "silent string-pass-through" ADR-0003 outlawed: no observable signal, so a non-parsing impl and a parsing impl diverge invisibly. Breaks reproducibility.
B. Warned text fallback (recommended). On failure, emit a
conformance warning xl3w/cell/numfmt-coercion and write the value
via the text path; conversion continues. The warning is part of the
conformance contract (expected_warnings), so every conformant impl
emits the same warning and the same text output — the divergence
ADR-0003 feared cannot occur silently.
C. Per-cell hard error. Record the failure as a cell-level
{ error } (mirroring the formula-error path) instead of aborting.
Fixes the abort and is internally consistent, but writes an Excel error
cell (#…) where Excel itself would show plain text — surprising for
placeholder data, and loses the original value. Structurally available
but not free: the { error } cell-writing path already exists
(renderer.ts formula-error branch), yet XtlErrorCell.__xl3_error__ is
currently hardcoded to the single token '#DIV/0!' (functions.ts), so
C would need a new token (#VALUE!) added to that type — itself a
catalog-class change. B reuses the existing preserveValue path with
zero new machinery. C is not foreclosed: because both output shapes
already exist in the engine, a future ADR can add C as a config-selected
mode without revisiting this decision.
D. Location in the diagnostic (parallel, not exclusive). Whatever
the failure mode, include sheet "X" cell A5 (and column name when
available) in the message/location. Addresses problem 4 and composes
with any of A–C.
E. Keep the throw. Status quo. Rejected — the abort is the reported defect.
Decision
Adopt B + D. Coercion failure on a single-expression date/number cell:
- does not throw — conversion of the file continues;
- emits a warning
xl3w/cell/numfmt-coercionwhosecodeand Englishmessageare normative (corpus-matched), withlocation=sheet "<name>" cell <address>(plus the source column name when the cell is a data-block[col]reference and the name is known); - writes the original value as text via the existing text/General
path (
preserveValue) — the cell shows the literal value (-), exactly as Excel would.
ADR-0003's MUST is narrowed, not revoked: coercion is still
mandatory and a successful coercion still MUST produce the typed
value. Only the failure mode changes from error to
warning + text fallback. The clause "silent string-pass-through is no
longer conformant" is upheld — the fallback is warned, never silent.
Scope (unchanged paths)
- Only single-expression cells with a date or number numFmt take
this path (the cells that previously threw). Mixed-text cells and
@text-format cells already stringify viacanonicalStringand never threw — untouched. - Empty / null / already-typed values (number, Date, boolean) coerce as before — no warning.
- The change is per cell: N non-coercible cells emit N warnings, in
deterministic render order (row-major, then cell-major), so
expected_warningslists are stable and ordered.
Consequences
- Impl (
renderer.ts):coerceDateValue/coerceNumberValueno longerthrow; on parse failure they push anXtlWarningto a per-file sink and returnpreserveValue(v).renderCellValueand its call sites thread the sink + alocation(sheet/address already in scope at every call site viaevalCellAt(sheet.name, cell.address, …)). The collected warnings merge intoOutputFile.warningsalongside the existing filename warnings. - New warning code
xl3w/cell/numfmt-coercionadded toXtlWarningCode(types.ts) and the error/warning catalog.@stablewarning list is additive (G3 non-reset preserved). - Existing error code
xl3/cell/numfmt-coercionis removed from theXtlErrorCodecatalog — once the failure path warns instead of throwing, the code is no longer emitted anywhere and carrying dead surface into 1.0 is undesirable.- Timing gate (G24): removal is a breaking error-code change. The
catalog is append-only pre-1.0 (
error-codes.test.tsfails CI on removal by design) and the G24 quarter clock restarts on any breaking spec/API/error-code change (ROADMAP: 1.0 earliest ≈ 2026-09-21 absent such a change). This matches ROADMAP already classifying #54/#56/#57 as POST-1.0 additive — so the removal (and this ADR's whole implementation) lands after the 1.0 freeze, not during the G24 window. ThisproposedADR is doc-only and does not touch behavior/codes, so it does not reset the clock.
- Timing gate (G24): removal is a breaking error-code change. The
catalog is append-only pre-1.0 (
- Conformance fixtures
021-numfmt-number-coercion-errorand022-numfmt-date-coercion-errorconvert fromexpected_error_codetoexpected_warnings+ a rendered output pinning the text fallback. ADR-0015's error-code coverage list drops 021/022 (they no longer assert an error); a new warning-path fixture is added under ADR-0072. - Spec text: evaluation.md "Single-Expression Cells" failure
sentence changes from "Failing to coerce is an error" to "Failing to
coerce emits
xl3w/cell/numfmt-coercionand falls back to the cell's text rendering"; language.md L253 mirrors it. ADR-0003 gains an "Amended by ADR-0072" note on its failure-mode clause. - Reproducibility is preserved: the warning is normative, so a
parsing impl and a non-parsing impl still diverge observably (one
emits the warning, one does not) — never silently. This is strictly
stronger than the pre-ADR-0003
MAYand consistent with ADR-0003's stated reason for existing. - Migration: templates that previously aborted now produce output
plus warnings. Hosts that treated any non-empty
warningsas failure should review their policy. No public API shape change (OutputFile.warningsalready exists).
References
- Amends: ADR-0003 — numFmt coercion MUST
- ADR-0015 — structured error/warning reporting (warning shape, corpus matching)
- Sibling per-cell failure path: ADR-0025 (error-cell marker) in
renderer.tsrenderCellValue spec/evaluation.md"Cell Evaluation / Single-Expression Cells";spec/language.mdnumFmt coercion paragraph- Issue: #56