CX+AI

CX+AI Coercion Guide

Beginner → advanced: how a value becomes the type you asked for

Coercion in CX

You declare the type you want. The value arrives as that type. This one idea removes most of the conversion ceremony that config-driven and AI-driven code usually demands — and it stays literally C underneath.

Sections 1–5 are the beginner path and assume nothing but C basics. Sections 6–10 are the advanced path: the actual rules, the machinery, and the sharp edges. Every claim here was verified against the shipped compiler by running it.


1. The one idea

Most languages make you convert. You read something loosely typed — a config value, a JSON field, a reply from a model — then write the conversion by hand, once per use, and get it wrong somewhere.

CX inverts that. The destination decides. Whatever you are assigning into — a variable's declared type, a function's parameter, a return type, the other side of an operator — is called the sink, and the sink's type is what the value is read as.

json cfg;
cfg = parseDoc("{ \"port\": 8080, \"host\": \"localhost\", \"ratio\": 0.75 }");

port.i  = cfg["port"];    // read as int    -> 8080
host.s  = cfg["host"];    // read as string -> "localhost"
ratio.f = cfg["ratio"];   // read as float  -> 0.75

Three different reads of the same document, and not one conversion call. The .i, .s and .f suffixes you already write on every declaration do the work. There is no runtime guessing: the compiler knew the sink's type, so it picked the right read at compile time.


2. Numbers: int and float

Start with the part that will not surprise you. Between .i (64-bit int) and .f (double), CX follows C's usual arithmetic conversions:

n.i = 7;
f.f = 2.0;

a.f = n / 2;      // int / int = int division -> 3, then widened to 3.0
b.f = n / 2.0;    // one operand is float -> float division -> 3.5
c.f = n * f;      // mixed -> float -> 14.0
d.i = f;          // float into an int sink -> truncates -> 2
If you know C, you already know this section. Integer division stays integer division; promotion happens when an operand is float. CX did not invent a cleverer rule here on purpose — surprising arithmetic is not a feature.

3. Numbers into text

When a string is one side of a +, the other side is converted to text:

n.i   = 42;
ms.f  = 12.5;

msg.s  = "answer = " + n;        // "answer = 42"
line.s = "took " + ms + " ms";   // "took 12.50 ms"
This works, but it warns — and the warning is asking a fair question. Both lines raise CX-W1004: string '+' with a non-string: the value is auto-converted to text -- write str(...) to be explicit. The conversion happens and the output is correct; the compiler just wants you to say you meant it, because + could as easily have been arithmetic.

Wrap the value in str() and the warning goes away:

msg.s = "answer = " + str(n);            // no warning
println("count: " + str(q->count));

str(x) is also what you want when there is no string sibling to trigger the conversion at all: println(str(n));

Float formatting is a pragma, not a guess. #pragma decimals 2 sets how many decimal places a float renders with, which is why 12.5 printed as 12.50 above. Set it once at the top of the file.

4. JSON: where it pays off

A JSON value has no inherent scalar type until something asks for one. That is not a weakness — it is exactly why sink-driven reading works so well.

Most languages need a conversion at every read:

port = int(cfg["port"])
host = str(cfg["host"])
rate = float(cfg["rate"])

CX needs none, because you already declared what you wanted:

Every cx excerpt below that carries a region name is lifted from one program that runs. It ships as examples/reference/01_manual_fragments.cx; each excerpt names the region it comes from, and the doc gate refuses the pair if they ever drift apart.
port.i = cfg["port"];
host.s = cfg["host"];
rate.f = cfg["rate"];
hp.i   = cfg["hp"] + 10;

And it is not only declarations. Every typed destination behaves the same way — that consistency is the point, because a rule with exceptions is a rule you have to remember:

function takeDamage.v(dmg.i) { hp = hp - dmg; }

hp.i = 0;

hp = cfg["hp"];                  // assignment sink
hp += cfg["bonus"];              // compound-assign sink
takeDamage(cfg["dmg"]);          // parameter sink (the param's declared type)
listAdd(scores, cfg["score"]);   // container element sink (the list's element type)
queuePush(dmg, cfg["hit"]);      // same for a queue

function baseHp.i() {
   return cfg["hp"];             // return sink -> read as int
}

The same applies to lookup(). If you use the rules engine, an entity carries named values — its lanes (hp, speed, big) — and lookup(e, "big") reads one by name. The lane name must be a string literal (CX-E5030 if it is not), and that is the point of the function: the compiler resolves the literal to a slot once per call site, so after the first call it is a direct indexed read rather than a string search — the hot-path counterpart to cfg["big"]. If you have not met entities yet, all you need here is this: even this fast path is typed by its sink through the identical hook, not a parallel mechanism. In the examples below, e is an entity with an int lane big and a float lane flt.

The sink reaches it through a wrapper, too (v3.179.25). Unary minus, the arithmetic operators and a ternary's arms pass a number along unchanged, so they pass the sink along with it:

printf("%lld\n", lookup(e, "big") * 1);   // still the exact int64 lane
printf("%lld\n", -lookup(e, "big"));      // exact, negated

One rule decides where that stops: a statically-float sibling outranks the sink. (c ? lookup(e, "flt") : 1) * 100.0 reads 2.25 and multiplies, even inside an (int) cast — the multiply is float arithmetic, and the cast rounds the result, which is what a cast is for. Anything that is not one of those wrappers — a call, a comparison, a concatenation, a subscript — is a consumer with its own typing, and the sink stops there.


5. Value vs entity — the one distinction to learn

If you remember one thing beyond "the sink decides", make it this. A json expression can be two different kinds of thing:

ShapeWhat it isWhat happens
doc["key"] — a subscripta value inside the documentcoerced to whatever the sink wants
doc — a bare json variablean entity handle, the document itselfpassed through untouched
json d;

n.i = d["hp"];   // VALUE  -> coerced to int
d->count;        // ENTITY -> the document handle is passed as-is
arm(d, pre);     // ENTITY -> the rules engine wants the document

This is why the line is drawn at value vs entity rather than at json vs not-json: coercing a bare document to a number would turn every rules-engine and persistence call into nonsense. A document is a thing; a field is a value.

The distinction belongs to argument positions, where both readings are possible and only you know which one you meant. It does not apply at a sink — an assignment to a declared variable, or an operator like - — because nothing there can want a document. n.i = doc; reads the document as a number, and so does -doc; the entity reading is not on offer, so there is nothing to choose between.


6. The coercion table

There is exactly one table in the compiler that answers which conversion applies, and both backends read it. Per-site guards decide whether to coerce; this decides only how:

Source→ int→ float→ string
json valuecx_json_as_intcx_json_as_floatcx_json_as_str
variantcx_var_as_intcx_var_as_floatcx_var_as_str
int / floatC's usual arithmetic conversionsbi_str_int / bi_str_flt
Why one table matters. A second conversion path is how backends drift apart — the native build and the VM would answer differently for the same source, and only one of them would be tested. Everything that coerces routes through here, so native and VM agree by construction.

7. Two directions: sink-driven and sibling-driven

Everything in the beginner half was sink-driven: a declared destination existed, so its type won. Inside an operator there is no declaration to read, so a second rule takes over.

Sibling-driven: text or number, decided by the neighbour

n.i = 10;   f.f = 2.5;   s.s = "hp: ";

a.f = dj["hp"] + n;    // numeric sibling -> read as a NUMBER (lossless)
b.f = dj["hp"] + f;    // numeric sibling -> read as a NUMBER (lossless)
c.s = s + dj["hp"];    // string sibling  -> read as TEXT, concatenated
if (dj["hp"] > 50) { } // compared numerically

The sibling decides text versus number. It does not decide how precise the number is: a json field read numerically is always read losslessly, as a float.

Changed in v3.179.20 / v3.179.21 — this supersedes the old int bias. Previously an int sibling made the field read as an int, which threw the fraction away before the operator ever ran. With "scale": 2.5, doc["scale"] * 2 answered 4, and if (doc["scale"] > 2) answered false — a silent wrong boolean, not a rounding difference. The sibling had no way of knowing what was in the field, so it was never the right thing to ask.

The visible cost of that fix is that json arithmetic now types float, even when the stored value is whole:

println(str(dj["level"] * 2));   // "14.000", not "14", for level = 7

Use a typed sink or a cast when you want the integer shape back — both land exactly, in either direction:

lv.i = dj["level"] * 2;          // 14
println(str((int)(dj["level"] * 2)));  // "14"

Bitwise and shift operators (&, |, ^, <<, >>) are integral and are not affected. lookup() on a rules/persist lane is also unaffected: those lanes are declared with a type, so the declaration names the read and an int lane keeps its full 64-bit exactness.

When both sides are json

With no concrete sibling to take a type from, the pair defaults to numeric — and because + could plausibly have meant concatenation, the compiler warns rather than choosing in silence:

x = doc["a"] + doc["b"];        // numeric addition + a warning: say which you meant
x = doc["a"] + str(doc["b"]);   // explicit concatenation, no warning

Ternaries coerce per arm

v.f = flag ? dj["hp"] : 100;   // the json arm is coerced; the literal arm is left alone

The coercion is applied to the json arm only, not wrapped around the whole expression — wrapping the result would misread the arm that was already an int.

The scalar arm names text versus number for the json arm, but not its precision — same rule as a binop, for the same reason. Before v3.179.21 an int arm made flag ? doc["hp"] : 100 read 2.5 as 2.

Unary minus reads the value, not the handle

d.f  = -dj["delta"];           // -2.5 for a stored 2.5
dh.f = -held;                   // -2.5 -- a json HANDLE under the sign, too
bn.i = ~dj["level"];           // -8 for a stored 7: `~` reads the INT lane

There is no sibling here at all, so nothing named a type — and before v3.179.21 that meant the node handle was negated rather than the value. Worth knowing as a shape: whenever a json value sits somewhere with no neighbour and no declaration, the lossless numeric read is the default that applies.

A unary operator is a sink, and every json shape reaches it. Until 2026-08-26 only the subscript form did. A json variable under the sign, or a cursor read (-jsonGet(nums) inside a foreach), slipped past the check, because the compiler was asking the argument question of §5 in a place where there is no argument — so the handle went to the operator uncoerced. Native answered 0; the register VM answered the negated handle. Both wrong, and wrong differently. The operand is now read as a value wherever it comes from, and both backends print the same digits.

- and ~ read different lanes, deliberately. A minus keeps the fraction, so it takes the float reader. C has no bitwise complement of a floating-point number, so ~ takes the int reader — which is the only read that can reach that operator at all.


8. Builtin arguments: json-aware vs json-blind

Some builtins want the raw node handle because they dispatch on the node's kind themselves — jsonLen(), str(), and the whole json* family. Others have no idea JSON exists: abs(), max(), sqrt() take numbers. Hand a node handle to one of those and, since a handle is a valid integer, C would happily compute on the node index — a silently wrong answer.

The fix avoids keeping a list of json-aware builtins, because a second list drifts from the first. Instead the compiler asks its own builtin resolver twice: once pretending the first argument is json, once pretending it is a number. If the resolver picks a different C function, the builtin is json-aware and gets the handle untouched. If it picks the same one, the builtin is blind to json and the argument is coerced to a number first.

strlen(dj["items"]);   // json-aware -> cx_json_len    -> handle passed through
str(dj["name"]);    // json-aware -> cx_json_as_str -> handle passed through
abs(dj["delta"]);   // json-blind -> same fn either way -> argument coerced
sqrt(dj["area"]);   // json-blind -> coerced

Why this design is worth copying: it self-maintains. Teach some builtin to handle json later and the coercion steps aside automatically, with nothing else to update — because the fact lives in one resolver rather than being duplicated into a table someone has to remember to edit.

A json value here is read as a float

A blind builtin's argument is read with the float reader, because a json member's type is not knowable when the code is compiled and a float is the lossless choice for both of JSON's number lanes. That read also decides the builtin's own shape: for the arg-polymorphic family (abs, min, max, clamp), a json argument selects the float variant, exactly as a literal 2.5 would.

cfg = parseDoc("{ \"area\": 2.25, \"delta\": -4.5, \"hp\": 7 }");

sqrt(cfg["area"])    // -> 1.5    the fraction survives
abs(cfg["delta"])    // -> 4.5    float variant, because the json arg is read as one
max(cfg["area"], 1)  // -> 2.25

n.i = abs(cfg["hp"]);  // -> 7    an int member is exact; the sink puts it back on the int lane

So str(abs(cfg["delta"])) prints 4.500, not 4 — the call's type follows the same vote, not just its value. If you want the integer, ask for it: str((int)abs(...)), or read through an .i sink.

Fixed in v3.179.15. Before that, a json argument did not count as float when the int-vs-float variant was chosen, so the int variant won — and the argument was then read as an int to match that choice. Two halves agreeing on the same wrong premise: sqrt(cfg["area"]) with 2.25 answered 1.4142 (that is sqrt(2)) and abs(cfg["delta"]) with −4.5 answered 4. Wrong answers, not rounding. On v3.179.0 and earlier, read the field into an .f sink first.

json-aware builtins (len, str, the json* family) are unaffected — they never take the coerced path at all — and neither is a bare json variable, which is an entity (§5).

⚠ The int64 lane does not survive this position. A json integer above 253 (a large id) read through a blind builtin goes through a double and loses exactness — the one case the float read cannot serve, because the argument's C type has to be decided before the value is known. Measured: ``cx js = parseDoc("{ \"big\": 9007199254740993 }"); b.i = js["big"]; // -> 9007199254740993 exact, the int lane max(js["big"], 1) // -> 9007199254740992 off by one ` Keep such a field on the int lane: read it into an .i sink and pass *that*, rather than subscripting inside the call. **Since v3.179.27 this loss announces itself** — CX-W5005, once per program run, naming the value and the .i escape. It is a warning, never a fatal: the answer is the honest one available to a single static argument type, and you are the one who knows whether this particular field needs the int lane. An .i sink or (int)` cast is silent because it reads through a different (exact) path, not because the warning was suppressed.

A json string at a builtin that wants a string

Since v3.179.26 the argument position asks what the builtin's parameter is, so a json string lands correctly in the string family:

strstr(js["s"], "cd");       // 2
toupper(js["s"]);              // "ABCDEF"
replacestring(js["s"], "ab", "Z");
(int)(js["n"]);             // 42 -- strInt's parameter is DECLARED text

Before that, only the numeric question was asked, and a cx_float was handed to a cx_str_handle parameter: native failed the generated C build (loud, but at the wrong layer), and the register VM answered silently wrong — the search returned 0 and the case fold "" (in that era's spellings, instr and ucase; both are gone as of 3.3.006.0). If you are on an older build, read the field into an .s sink and pass that.

Which positions are strings comes from the builtin's own C prototype, so the numeric positions of a mixed builtin are unaffected — substr(js["s"], 1, 3) reads the subscript as text and 1, 3 as numbers, and stringsplit(s, sep, n) treats only arguments 1 and 2 as strings.

And since 2026-08-26 that is literally, not approximately, where it comes from. The prototypes used to be read by a person and copied into a list of 74 builtin names — which is a list two things can disagree about, and 103 builtins with a declared text parameter were never on it. Writing a document's field straight into a spreadsheet cell, gpu_grid_set(g, r, c, line["name"]), was one of them: it failed to compile natively and wrote nothing on the VM, so data-driven programs carried a declared local per value to get around it. The compiler now reads the declaration itself, through the same derivation that already tells it which builtins return a string.

What stays hand-listed is the handful whose parameter types no declaration states: the variadic builtins, and those reached only through an arity-overload macro, where the preprocessor picks a branch and no single answer is true of the name. Everything else is read off the prototype the C compiler checks on every build.

A container's element type is a sink too

A builtin's parameter has one type, whatever the program. A container's element type is per-variable — list names.s and list counts.i are the same verb over different element types — so the declaration that answers the question is the container's, not the builtin's. It answers it the same way:

listAdd(names, rec["name"]);          // "alpha" -- a .s list takes the text
mapPut(labels, "first", rec["name"]); // "alpha" -- so does a .s map
listAdd(counts, rec["qty"]);          // 7       -- an .i list takes the integer
queuePush(pending, rec["name"]);      // "alpha" -- every family, not just lists

It reaches every element position of every family, not just the append: the value of listSet and listInsert, the key of search / find / contains (it is compared against the stored elements, so it owes the same type they do), the value of fill, the value side of the ordered-map promotion (mapPut(list, key, value)), arrAdd and fill on a grown array, queuePush, and sortndxAdd. An argument that is not an element — an index, a key, a count, a sort direction — is untouched.

Since 2026-08-26. Before that, listAdd(names, rec["name"]) into a .s list failed the generated C build natively and wrote the numeric reading of the node on the register VM: names[0] printed 0.000 and strlen(names[0]) answered 0, because what landed there was not a string at all. The numeric lane split the other way — listAdd(counts, rec["qty"]) was right natively and stored 7.000 in an integer list on the VM, while queuePush pushed the raw node handle natively and was right on the VM. On an older build, read the field into a typed local and add that.

Where it stops. A struct-element container has no reading of a json value — a struct element is a byte-for-byte copy of a value of that struct, and a node is not one. That is CX-E1112, named at your line with both types, rather than a C compiler message about generated code or an element quietly built from nothing:

CX-E1112: 'ps' holds pt elements, and this argument is a json value -- a json
node has no reading as a pt. Read the fields you need out of the node into a pt
value first, and add that

A json container is a different rule and is left alone here: its elements have no declared type, so mapPut(doc, key, value) and arrAdd(doc, value) decide by the value, not by a declaration (§4).


9. variant — the runtime-typed lane

variant is a value whose type is only known at runtime. It appears where CX genuinely cannot know in advance: values crossing an FFI boundary, and values produced by a model. It coerces through the same table as json, so an AI-produced value works in arithmetic, concatenation and comparison exactly like a json field does.

Two things to know:

It is also the slow path, and the compiler will tell you so — a note naming the variable, because a value on the tagged path is coerced at every single use rather than once at compile time. If you see that note on something you expected to be resolved, it is a hint worth chasing.

9b. Why a language against variants has one

"I have always been against variants as good coding requires planning. But when you are asking an AI to plug a hole in your code in mid flight — how is it going to get context? Especially if like me your prompts can be malformed? So coercion has to work both ways. For the human who wants strongly typed variables with no hassles. And for the AI to produce good code on the fly — without breaking anything." — Terence Agius, on why CX+AI works the way it does

Two people are writing your program now, and they do not know the same things.

You know the shape. You declared the struct, you named the fields, you chose int over double for a reason you could explain. A model asked to add a function at three in the afternoon knows the twenty lines it was shown and the sentence you typed — which, if you write prompts the way most of us do, was not a specification.

Most languages answer this by making one of you lose. Static typing makes the model guess and fail the build. Dynamic typing lets the model succeed and moves the failure to a customer.

Coercion is the answer, and it runs in both directions.

Toward you, it means you declare what you want and the value arrives as that type — the whole of sections 1 to 5.

Toward the model, the same rule means the code it writes does not have to know your types to be correct. It writes the obvious thing; the sink decides. Code that would be a crash in a dynamic language and a compile error in a static one is simply right here.

And that is exactly where a variant would normally arrive — so CX+AI refuses to let you declare one.

variant x;
CX-E0034: 'variant' is not a declarable type; it arises only at FFI/AI
          boundaries and is coerced at the typed sink

This is what makes the objection at the top survivable. A variant is not a convenience you can reach for: it exists only where the world is genuinely unknowable — a value crossing an FFI boundary, a value a model produced — and it stops existing the moment it meets a typed sink. Looseness is confined to the seam it came in through. It cannot spread into your program, because there is no way to write it down.

So the planning is not abandoned. It is enforced at exactly one place — the line where you said what you wanted — and everything upstream of that line is allowed to be as vague as an afternoon prompt.

The honest cost is the note §9 describes: a value still on the tagged path is coerced at every use rather than once at compile time, and the compiler names the variable. That note is the thing to chase, because it means a seam reached further into your program than you meant it to. Fixing it is a declaration on one line rather than a refactor — which is the whole point of pinning the looseness to the sink.


10. Sharp edges worth knowing

A json handle is an address; a dereference reads its truth

A json variable is a pointer, and C does not look through a pointer unless you tell it to. That is the whole rule, and it is his: "C's way" (2026-09-11).

json d = parseDoc("{\"n\": 42, \"z\": 0, \"f\": false, \"e\": {}}");
json z = d["z"];               // a member worth 0

if (z)        { }              // TRUE  — there IS a node; its contents are not asked
if (z == 0)   { }              // FALSE — the same question
if (z != 0)   { }              // TRUE  — and the same answer

if (d["z"])   { }              // FALSE — a DEREFERENCE reads the member, and it is 0
if (d["f"])   { }              // FALSE — the boolean decides
if (d["e"])   { }              // TRUE  — empty is still valid
if (d)        { }              // TRUE  — a handle again

The three handle spellings agree with each other, which is what makes the rule learnable: there is no position where if (z) and z != 0 disagree.

Three ways to reach the value, all explicit:

json zero;
zero = dj["missing"];          // a member worth 0
int zcast = (int)zero;          // 0 -- a cast
int zsink = zero;               // 0 -- an assignment into a typed sink
if (dj["missing"]) { }         // the member, reached in place

if, while, a for condition, a ternary condition, !, &&, || and the one-argument assert all ask the same question, and both backends answer it from one place in the runtime.

For a dereference, a scalar is its value and anything that is not a scalar is its validity — 42 is true, 0 is false, null is false, {a:1} is true, and {} is true.

Empty is not absent. {} is true, because validity says the parse succeeded and emptiness is a different question with its own answer: d->doc->count is 0 on {}. Conflating the two is what CX-W1021 was created to stop; making {} false would be the same conflation running the other way.

⚠ If you wrote CX against v3.3.015.0, one line changed and it changed silently. if (d["flag"]) ran the then-branch for a member worth 0, while assert(d["flag"]) read the value — one expression, two answers. The dereference now reads the value everywhere. The handle spelling did not change.

A string is true, empty or not — "same as C would do" (his ruling, 2026-09-10). char *s = ""; if (s) is true in C because a string is a pointer and the pointer is valid, and CX answers the same way. The falsy set is exactly null, 0 and false.

json d = parseDoc("{\"name\": \"orbital\", \"blank\": \"\"}");

if (d["name"])  { }          // TRUE  — a string
if (d["blank"]) { }          // TRUE  — even when it is ""

if (d["blank"] == "") { }    // TRUE  — emptiness, asked directly
string v = d["blank"];       // ...or through a string sink,
if (v->len == 0) { }           //       where `->len` is available
⚠ The asymmetry is deliberate: the number 0 is false while the empty string "" is true. In C a number IS its value and a string is a pointer to one. Emptiness is a different question and truth does not answer it — ask it by comparing, or through a string sink. <node>->len does not exist (CX-E1045: the json metadata fields are count type valid id format key options). Until 3.3.016.1 both this guide and the compiler's own refusal message recommended it.

Comparing a handle to zero

A zero test on a declared handle is a liveness test. doc == 0, doc != 0, doc < 0, doc > 0 read as doc->valid, and the compiler says so with CX-W1021 every time, because the correction is otherwise invisible: the program does what you meant, so nothing would ever bring you back to the line.

All six spellings behave this way, and json is no exception. if (doc), while (doc) and !doc read the handle exactly as doc != 0 does — which is why the three agree. The other five handle families (list, map, queue, sortndx, mempool) read the same way and always did.

A json member worth 0 is true as a handle and false as a value, and the spelling says which you asked for. if (z) runs — there is a node. if (d["z"]) does not — the member is 0. Nothing is ambiguous and nothing is filed: this is the ruled behaviour, not a collision.

Spelled out, the two questions look like this:

if (dj->doc->valid) { }            // the handle question, spelled out
if (dj["missing"] == 0) { }   // null / number / bool / string compare as before

The second line is unchanged and always was: a member is a value, and values compare as values. Only the bare handle moved.

This is worth reading twice if you have written CX before v3.1.045.0, because the old behaviour was a silent wrong answer rather than a stylistic quibble. A document has no numeric reading, so it coerced to 0 — which made doc <= 0 true for a perfectly good object, and a program that guarded its parse that way took the failure branch every time, with no diagnostic anywhere. Three spellings lowered three different ways, so the one that appeared to work (if (doc), testing the raw handle) was the one with no diagnosis at all.

doc->valid is what the comparison spellings mean — 1 while live, 0 after a jsonFree. Note what that buys: the old doc != 0 could not see a freed handle, because a freed handle is still a non-zero number. (It catches use-after-free, not use-after-free-then-reuse — a pool slot can be recycled by a later parse.) The truthiness rule reads the same liveness, so if (doc) after a jsonFree is false too.

If you meant the content, say so. doc->count is the number of members or elements; a parse that failed is reported on the parse's own error channel. The warning names both, because neither is discoverable from the wrong spelling.

An ordered comparison against a non-zero number is refused outright:

if (d <= 5) { }                // CX-E1114 -- no reading of this is meaningful

The handle is a number and the content is a number, they give different answers, and nothing in the source says which was meant. Resolving it either way would be a coin toss you could not see, so CX declines to toss.

An int-held handle is not affected. int h = parseDoc(...) is the pre-typed idiom: an int is an int, h <= 0 is ordinary integer arithmetic, and nothing here reaches it.

JSON has two number lanes

A value that is statically an integer is stored in an int64 lane rather than round-tripped through a double, because a double loses exactness above 253. This matters if you carry large ids in JSON — they survive. Read such a field into an .i sink to keep that exactness; reading it into .f converts, as you asked it to.

Reading a container consumes it

Not coercion, but the same class of surprise: queueTake() and queuePop() remove the element they return. Guard with q->count > 0 rather than probing by failing — an empty read is a loud error, not a zero.

What is never coerced

``cx int n; n = "42"; // CX-E1074 -- refused, not silently 0 n = (int)"42"; // 42 -- the explicit door float f; f = (float)"2.5"; // 2.5 ``

The refusal is deliberate rather than a missing feature. The reverse direction is lossless and does convert silently: a number reaching a declared string sink becomes text through bi_str_int / bi_str_flt (§3). Conversion where the data speaks losslessly, a refusal where it does not — that asymmetry is the whole of the rule.


11. Recap

SituationWhat decides the type
Declaration, assignment, compound-assignthe declared type of the destination
Function argumentthe parameter's declared type
returnthe function's return suffix
Container add / pushthe container's declared element type
Operator with a concrete siblingthe sibling's type
Operator, both sides jsonnumeric, and + warns
Builtin argumentjson-aware builtins get the handle; blind ones get a float — which also picks the builtin's float variant (§8)
Bare json / container variablenothing — it is an entity, passed through
The rule underneath all of it: a loosely-typed value has no type until something asks for one, and in CX the thing asking is almost always a type you already had to write. That is why the ceremony disappears without any magic being introduced — the information was already in your source.