Story 21.8 — conversion defects: CAST to BOOLEAN, temporal formats, and the DDL ingest path - #313
Conversation
Story 21.8, scoped by the lead to Parts A + B + E + G; C, D and F deferred.
Part A - CAST(x AS BOOLEAN) was a silent no-op. `coerce` had seven arms FROM
boolean and none TO it, so every `(_, Boolean)` pair fell to the identity
fallback: `CAST(1 AS BOOLEAN)` emitted `1`. Lead ruling PD-1 is C-style -
zero is false, `Boolean.parseBoolean` for strings. Sources are ENUMERATED so
`(Boolean, Boolean)` still reaches the identity arm. `canConvert` untouched:
it gates schema evolution, not casts, and now deliberately disagrees.
Part B - the four `<string> -> <temporal>` arms hard-coded one format per
target, so `CAST('2025-01-10 14:30:00' AS TIMESTAMP)` raised while #276 had
already taught the WHERE path to accept that same literal. Lead ruling OQ-3
is an ordered format set; Painless has no expression-level try/catch and
`ofPattern`'s optional sections cannot express ISO's variable-length
fractional seconds, so it is realised as a separator normalisation in front
of a WIDER ISO formatter - one parse, a strict superset of what parsed
before. TIMESTAMP moves to `ISO_DATE_TIME.withZone(UTC)`, which fills a
MISSING zone only: an explicit offset still wins.
Also fixed, found by re-measuring record 6 on the production path and
confirmed on real ES: `includeTimeZone` appended " XXX" to the CALLER'S
pattern, so `DATETIME_FORMAT(ts, 'yyyy')` returned "2025 Z" and
`DATETIME_PARSE` threw on any input without a trailing offset.
Part G - the DDL path built ingest processors with NO schema, so a computed
column stored the UNCONVERTED operand into a correctly-mapped field: the
#205 silent-wrong-value family, invisible to every mapping-level check. The
spec expected two seams because `ALTER ... SET SCRIPT AS` carries no column
list - true of the statement, false of the fix. `Table.merge` applies the
ALTER to the LIVE table and `CreateTable.schema` builds its own, and both
end at `Table.update()`, so ONE seam covers both with no I/O and nothing
added to the parse path. An operand the schema cannot resolve stays silent
and emits the identity (lead ruling OQ-4).
Part E - records 3 (epoch millis) and 4 (DECIMAL scale) confirmed CORRECT and
closed with written reasoning and no code, because "the doc was wrong so the
engine must be wrong" is the obvious and incorrect inference.
Verification: every emission was EXECUTED on Elasticsearch 8.18.3 before
being pinned - a Painless claim is only provable by Elasticsearch. Part G is
asserted on the STORED _source, never on the mapping and never on a read
path. Green on real ES 6.8 rest+jest / 7.17 / 8.18 / 9.0; sql 943, core 924,
macrosTests 21, bridge 197, es6bridge 197, both Scala legs. Seven mutations
each produce the predicted RED.
Release notes (all user-visible):
- CAST(<n>|<string> AS BOOLEAN) now converts. `CAST('1' AS BOOLEAN)` is
FALSE - the numeric rule applies to a numeric operand, not to a numeric
string. TRY_CAST to BOOLEAN can no longer yield NULL.
- CAST to DATE also accepts `yyyy/MM/dd`; to TIMESTAMP/DATETIME also the
space-separated spelling, defaulting a missing zone to UTC; to TIME now
ISO_LOCAL_TIME, so `14:30` and fractional seconds parse.
- DATETIME_FORMAT no longer appends a zone to the requested pattern, and
DATETIME_PARSE no longer demands one. Values change for anyone relying on
the old output.
- A DDL computed column now CONVERTS on ingest. Existing indices keep their
stored pipeline; the first ALTER after upgrading re-renders it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`%f` was mapped to the pattern letters `SSS` under a comment reading "microseconds", so the engine contradicted its own comment, its published documentation and the MySQL dialect it imitates. MEASURED on real ES 8.18.3, `S` is FIXED WIDTH in BOTH directions, so no letter substitution can be right: SSS formats .123456789 as .123 parses .123 only SSSSSS formats .123456789 as .123456 parses .123456 only [.SSSSSS][.SSS] formats as .123456.123 Mapping it to `SSSSSS` would have made the documentation true by BREAKING the parse of a 3-digit fraction - narrowing one direction to widen the other, which is an "except" inside the rule this same PR's temporal CAST arms are justified by. `DateTimeFormatterBuilder` is whitelisted in Painless (measured), so `%f` leaves the substitution map and `param` emits a variable-width `appendFraction` instead. A decimal point written immediately before `%f` is absorbed INTO the fraction, which is what lets a value with no fractional part format as `12:00:00` rather than `12:00:00.` and still parse. Verified with the emitted script on ES 8.18.3: parses `.123` (as before), `.123456`, `.123456789` and NO fraction at all; formats the value's real precision. A strict SUPERSET in both directions - nothing that worked before stopped working. The published annotation `'2025-01-10 12:00:00.123456'` is now literally correct. The builder is emitted only when a fraction is present, so every other format stays on the plain `ofPattern` path and no other emission moves - pinned. Green: sql 945, core 924, bridge 197, es6bridge 197, macrosTests 21; real ES 6.8 rest+jest / 7.17 / 8.18 / 9.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI caught the one defect in this story that reached a real cluster:
`CASE WHEN <comparison>` failed on all five clients with
search_phase_execution_exception: all shards failed; compile error
caused_by class_cast_exception:
Cannot cast from [boolean] to [java.lang.Object].
`Criteria` fixed `out` to BOOLEAN but left `baseType` inherited from
`FunctionChain`, which walks the IDENTIFIER's chain -- so `1 = 1`
reported BIGINT and `descr = 'x'` reported the column's type. That has
always been wrong and always been inert: with no `(_, BOOLEAN)` arm in
`SQLTypeUtils.coerce`, every such pair fell to the identity fallback and
the comparison was emitted untouched.
Part A of this story added the arms, so `CASE WHEN 1 = 1` coerced an
already-boolean comparison FROM BIGINT and emitted
def param1 = (1 == 1 != null ? (def)((1 == 1 != 0)) : null); ...
It cannot be fixed inside `coerce`: once an arm matches, a genuine
`CAST(1 AS BOOLEAN)` and this comparison are the same (BIGINT, BOOLEAN)
pair. The source type has to be right before `coerce` is called, and
`Criteria` is where both halves of that fact belong.
Emission is byte-for-byte restored to origin/main for every shape --
literal comparison, numeric column, varchar column -- so this pins a
restoration, not a new shape.
The whole unit estate stayed green while every live client failed: 945
sql + 924 core + 2 x 197 bridge tests, none of which renders a CASE
through a PainlessContext. `BooleanCastSpec` grows the production
renderer it was missing (`painless(None)` cannot see this branch at all)
plus the invariant itself, so the next `(_, BOOLEAN)` arm cannot re-open
the hole silently. Falsified: reverting the one-line fix turns exactly
those two tests red with the CI script byte-for-byte.
Verified on real Elasticsearch 6.8 (rest + jest), 7.17, 8.18.3 and 9.0:
GatewayApiSpec 72/72, 72/72, 73/73, 73/73, 73/73.
Story 21.8 / issue #312.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI caught a regression from part A — fixed in
|
| ES | suite |
|---|---|
| 6.8 rest | 72/72 (1 canceled) |
| 6.8 jest | 72/72 (1 canceled) |
| 7.17 | 73/73 |
| 8.18.3 | 73/73 |
| 9.0 | 73/73 |
sql 947 · core 924 · bridge 197 · es6bridge 197 · scalafmtCheck · headerCheck · 2.12.20 sql/core Test/compile.
🤖 Generated with Claude Code
Story 21.8 of epic 21 (Core SQL front door). A conversion that cannot be performed is a bug, not a
no-op.
Closes #312
Lead-scoped to Parts A + B + E + G; C, D and F deferred. Four rulings were taken before any
code was written: scope, PD-1 (boolean semantics), OQ-3 (temporal formats), OQ-4 (unresolvable DDL
operand).
🔴 T0 refuted the story's own centrepiece
The spec shipped
status: draftand said not to dev from it as written. Re-measuring every recordon current
mainmoved two rows, and both reshaped the work.Record 6 claimed four date functions emit uncompilable Painless — "the most serious" of the six —
and honestly flagged its blast-radius half as INFERRED. Discharging that inference first was the
highest-value hour of the story. The malformed
e0defform is produced only by the context-freepainless(None). Every production surface renders these throughpainless(Some(ctx)), whose arm iscorrect; the
painless(None)sites in shipped code (bucket_selector,bucket_script, geodistance, top_hits script fields) either carry aggregates or drop the field. No measured
production surface emits it ⇒ Part C deferred.
A different, LIVE defect was found in the same four functions and folded in.
includeTimeZoneappended
" XXX"to the CALLER'S pattern — measured on real ES 8.18.3:DATETIME_FORMAT(ts, 'yyyy')"2025 Z"— silent wrong answer"2025"DATETIME_PARSE('2025-01-10 10:00:00', 'yyyy-MM-dd HH:mm:ss')2025-01-10T10:00ZWhat changed
Part A —
CAST(x AS BOOLEAN). Seven arms FROM boolean, none TO it, so every(_, Boolean)pairfell to the identity fallback. C-style per PD-1: zero is false,
Boolean.parseBooleanfor strings.Sources are ENUMERATED, not wildcarded, so
(Boolean, Boolean)still reaches the identity arm.canConvertis byte-identical and now pinned as deliberately disagreeing — it gates schemaevolution, a different contract.
Part B — the temporal format set. Painless has no expression-level
try/catch(coercereturns an expression embedded anywhere) and
ofPattern's optional sections cannot express ISO'svariable-length fractional seconds, so a hand-written pattern would NARROW what parses. The set is
realised as a separator normalisation in front of a WIDER ISO formatter — one parse, strict
superset.
TIMESTAMPmoves toISO_DATE_TIME.withZone(UTC), which fills a MISSING zone only: anexplicit
+01:00still wins (→ 13:30Z, measured).Part G — the DDL ingest path. A computed column stored the UNCONVERTED operand into a correctly
mapped field. The spec designed two seams because
ALTER … SET SCRIPT AScarries no column list —true of the statement, false of the fix:
Table.mergeapplies the ALTER to the table alreadyloaded from Elasticsearch, and
CreateTable.schemabuilds its own, and both end atTable.update(). One seam, both sites, no I/O, nothing added to the cost-probed parse path. SoAC-G2 is fixed rather than excluded.
Part E — no code. Records 3 (epoch millis) and 4 (DECIMAL scale) confirmed CORRECT and closed
with written reasoning, because "the doc was wrong so the engine must be wrong" is the obvious and
incorrect inference.
%f(second commit). Mapped toSSSunder a comment saying "microseconds". The obvious fixwould have been a regression:
Sis FIXED WIDTH in both directions, soSSSSSSmakes the docs trueby breaking the parse of
.123, and[.SSSSSS][.SSS]formats as.123456.123. That is an "except"inside the rule Part B is justified by.
DateTimeFormatterBuilderis whitelisted (measured), so%femits a variable-widthappendFraction: parses.123,.123456,.123456789and nofraction at all, and formats the value's real precision.
Verification
Every emission was EXECUTED on Elasticsearch before being pinned — a Painless claim is only
provable by Elasticsearch, and this project has paid for that rule twice. Part G is asserted on the
stored
_source, never on the mapping and never on a read path: a correct mapping and a wrong_sourcecoexist silently.sql945 ·core924 ·macrosTests21 · bridge 197 · es6bridge 197, bothScala legs
Release notes
CAST(<n>|<string> AS BOOLEAN)now converts.CAST('1' AS BOOLEAN)is false — the numericrule applies to a numeric operand, not to a numeric string.
TRY_CASTto BOOLEAN can no longeryield
NULL.CASTto DATE also acceptsyyyy/MM/dd; to TIMESTAMP/DATETIME also the space-separated spelling,defaulting a missing zone to UTC; to TIME now
ISO_LOCAL_TIME.DATETIME_FORMATno longer appends a zone to the requested pattern andDATETIME_PARSEno longerdemands one — values change for anyone relying on the old output.
%fis variable width: it formats a value's real precision and parses any number of digits.first
ALTERafter upgrading re-renders it.Deferred, with local records (no new issues filed)
Parts C, D and F remain open in the spec. Two residuals were found and recorded locally:⚠️ Not the same defect though: Part C is the context-free
date/time functions are unusable in a computed column (pre-existing, byte-identical after this
PR — that is AC-G3 holding — and on a published example), and it is routed to Part C, whose
family-wide guard should catch it.
assembly joining operand and call; the residual is the operand's runtime shape in processor context.
Part C's fix alone will not close it.
🤖 Generated with Claude Code