Value Representations and Boundary Bridges (internals)

This guide is the map of every representation a Turmeric value can travel in between its producer and its consumer, the boundaries where the compiler must convert between them, and the failure shapes when a conversion (a "bridge") is missing. It exists because the bugs in this area all share one anatomy and kept being rediscovered one boundary at a time -- see the Maintenance section at the bottom before changing anything it describes.

Audience: compiler contributors and anyone triaging a cc invocation failed, link error, or segfault on code that passed tur check.

The invariant

At any given boundary crossing, a value has exactly one correct representation on arrival: the one the destination type demands. Everything else must be bridged away at the crossing. Every bug in this family is a producer and a consumer disagreeing about which representation crosses -- the checker accepts (the types agree), and then either cc rejects the emitted C, the linker misses a symbol, or the binary reinterprets memory and dies. Wrong-output without a crash is the rarest and worst form (see the float bit-reinterpret note below).

Representations: ordinary data

  1. The int64 carrier -- the universal int64_t slot. Typeclass method results, generic specializations before the bridge, Vec element slots, cons cells, and every inline-C body traffic in this. It is an erasure: the bits are the value for scalars and heap pointers, and a spilled pointer (or garbage) for anything by-value.

  2. By-value concrete aggregate -- tur_adt_Result__int__int, tur_adt_FzB, ... passed and returned as real C structs at typed defn boundaries. The invalid-C errors make the mismatch visible: tur_adt_FzB b = __ps_158; initializing an aggregate from an int64_t, or aggregate value used where an integer was expected going the other way.

  3. Heap pointer -- :heap structs and parametric heap containers (Option, Vec, Cons). The carrier bits ARE the pointer, so the erasure round trip is lossless. This is why Option escaped the bind miscompile while by-value Result did not (result-monad-bind-typed-boundary-miscompiles), and why :heap was the workaround for the by-value Vec-element shape (vec-byvalue-struct-element-invalid-c, resolved by increment 3).

Container element slots follow one width-independent rule (increment 3) per element class: scalar bits inline, heap pointer as-is, by-value ADT product (ANY width) heap-boxed on insert and deref-unboxed on read, fn value as a fat handle. The decision lives in type_is_boxed_container_elem (src/compiler/types.c), consulted by the push-side bridges, the read-back recovery, AND the ownership folds (tur-wide-byval? / tur-vec-elem-wide?) so boxing and freeing cannot drift. The old fork -- wide boxed, narrow stack-spilled with no reader -- was the missing-cell generator here. Width still matters where a paired inline layout exists (parametric-carrier monomorph fields, B4 closure params); those positions keep type_is_wide_byval_adt.

  1. Concrete scalar -- plain int64_t / double / bool / char*. One trap: a float crossing the carrier needs a bit reinterpret, not a numeric conversion. Picking the wrong one turns 1.5 into 4.6e18 silently -- pinned by tests/fixtures/constrained-generic-dispatch-float-element. This is the documented wrong-output case; treat any new float divergence as this family first.

In source, the two are spelled apart and :: spells neither: an int/float :: is a hard error, because the operator cannot tell a carrier slot holding float bits from a genuine integer. Reading a float back out of an :int slot -- a cons cell, a variadic rest list, a HAMT value -- is (bits->float x), and pushing one in is (float->bits x), both from stdlib/bits.tur; converting a real number is int->float / float->int from stdlib/math.tur. See docs/archive/ascribe-int-to-float-expression-ambiguity.md.

Representations: fn-typed values

Function values are their own zoo. The per-boundary decision today spans (at least) these forms -- see the investigation in docs/archive/poly-result-hof-capturing-closure-sigbus.md for where each is chosen (carrier_ok, src/compiler/elab_fns.c ~3600):

  1. tur_poly_fn_t {env, fn} carrier -- for plain, non-effectful, carrier-safe signatures with no named tyvar. The carrier<->fat seam is alias- and join-aware: the stage-2 tail walkers resolve a let-ALIAS of a carrier param to its origin (converting via poly-to-fat like the direct leaf), the if unifier admits a carrier-param arm against a boxed fn result by inserting the conversion at the join, and ascribing a carrier param to its own fn type is a no-op assertion (fn-value-carrier-fat-seam-residuals, archived).
  2. ^fat parameter -- explicit fat {thunk, env} handle.
  3. :ptr<void>-fat sink -- carries an is_fat flag disambiguating thin-vs-fat dispatch at the invoke (src/compiler/emit_expr.c ~4246).
  4. Nominal bare TY_FN pointer -- a thin code pointer with nowhere to put an environment. Fat-normalization has retired it in PARAM position for concrete, tyvar, and effect-annotated signatures alike; the two carrier-side feeds -- a call THROUGH a rank-2/forall param (elab_poly_call) and the make-struct fn-field store -- are shimmed so an already-normalized param is not boxed a second time. The thin form survives only for cfnptr, variadic, and arity>5 signatures; passing a capturing closure into an effect-annotated such param is a call-site TUR-E0007. Effect-row call sites dispatch fat through the E2a twin registry (slot 0 = a registered capturing-lambda entry whose __cps twin takes the env, slot 1 = the fatshim box's stashed bare-fn direct entry), threadable capturing lambdas are CPS-admitted with the direct thunk's env-unpack preamble, and effect-row checking peels the EX_FN_TO_FAT shim (poly-result-hof-capturing-closure-sigbus, archived -- every row).

The { shim, orig } box a bare fn is shimmed into is malloc'd per execution of the bridge. At a normalized nominal param nothing frees it -- a leak per call if allocated there -- but such a box is a constant when the boxed value is a global fn, so it is allocated once at file scope and filled from __tur_static_init.

A ^fat sink takes the same hoist when the callee provably neither retains nor drops the argument -- nonretain_param_mask bit set for that parameter. This was thought impossible ("a ^fat callee may drop its argument and the call site cannot tell"), and the missing fact was going to need an ownership annotation on the parameter. It does not: dropping goes through TUR_CLOSURE_DROP, a C macro reachable only from an inline-C body, and a body containing any inline-C has nonretain_param_mask == 0 by construction. So the bit already means "neither retains nor drops". A ^fat sink WITH inline-C -- the shape that can drop -- has the bit clear and keeps its heap box, which is what tests/fixtures/closure-drop-glue-fatshim pins. (fat-sink-shim-box-leaks-per-call, gated by tests/run-fat-shim-leak.sh.)

Two predicates, both in src/compiler/types.c, and they deliberately disagree: fn_param_type_is_fat_normalized (param position, tyvars included) and fn_result_type_is_fat_normalized (declared result and nested result annotations, concrete only -- widening it double-boxes against the hrt-curried-result poly-call protocol, which boxes returned closures itself). repr_of routes on REPR_POS_PARAM, so the --emit-abi-trace output reports the same split. 5. Struct-field-fat -- defstruct fn-typed fields, normalized to the fat representation uniformly after the same bug was fixed there (tests/fixtures/capturing-closure-struct-field/).

Plus one in-flight form the minimization matrix in docs/archive/history/fn-typed-value-return-ascribe-miscompiles.md exposed: the by-value fat struct sitting in a parameter slot, whose return path used to cast it thin (return (int64_t)(intptr_t)v; on an aggregate -- fixed by fat-normalization stage 2's poly-to-fat tail conversion).

Boundaries

Each of these is a crossing where a bridge may be needed:

The pairing is the problem: ~4 data representations (6 for closures) times each boundary kind, where every pair needs its own bridge and the bridges are implemented point-by-point, not derived from one convention. Each open report is one missing cell.

Open cells. These are the crossings that still have no working bridge; each has a live report in docs/reported/. This table is the campaign's index -- a repr cell with a filed report belongs here, so if you file one, add the row.

Open cell (producer -> boundary) Report
^Class-constrained parameter (carrier int64_t) -> generated instance method taking a defdata ADT by value; instantiating at int is a no-op erasure and works typeclass-constrained-param-erases-adt-to-int64

The table was empty between 2026-08-21 and these two filings. The pair that emptied it closed on the same day, and the let merge-temp one is worth a note because its filing had the arrow backwards. It was recorded here as "CAPTURELESS closure (bare fn pointer) -> let merge temp decided fat-handle", i.e. a thin producer reaching a fat boundary -- the same shape as the archived let-bound-noncapturing-lambda-segfaults-as-fn-arg, which really was that and was closed with the signature-keyed ensure_bare_fnptr_poly_shim adapter. It was the opposite: the tail already emitted a proper fat box and the merge TEMP was declared thin, so reusing that adapter would have boxed an already-boxed value. The lesson for this table is that a cell's direction is worth confirming against the emitted C before pairing it with a neighbour's bridge -- the shadow line names the two forms but not which side is the producer.

It was also not benign under TUR_REPR_NO_SHADOW_ICE=1, which is how it read at filing time: that repro exits 0, but a variant that returns and CALLS the closure emits -Wint-conversion on the temp assignment -- a hard error under GCC >= 14. A loud-but-benign shadow is worth re-testing with the value actually consumed before trusting the "benign".

File a new repr cell in this table as well as in docs/reported/.

Closed cells (paper trail). Bridges that now exist. Kept here because the resolution notes say which bridge was added and what it is paired against -- the next cell in this family is usually adjacent to one of them.

Closed cell (producer -> boundary) Resolution Report
fn value reaching a let merge temp in RESULT position -- the TAIL emitted the fat { thunk, env... } box while the TEMP was declared thin R (*)(A...), so the assignment was -Wint-conversion (hard error under GCC >= 14) and the R3 shadow ICE'd on it the two sites keyed fat-vs-thin off different facts -- emit_temp_decl off type.as.fn.boxed (a TYPE fact), stage-2 tail normalization off fn_result_type_is_fat_normalized (a POSITION fact). merge_temp_fn_is_fat() asks repr_of in RESULT position instead, and the decl and its ctype mirror both spell the temp from it let-returning-noncapturing-lambda-ices-at-merge-temp
bare-var tail of a NON-parametric by-value product -> emit_if merge temp (the parametric half was already bridged) position-sensitive, not type-sensitive: emit_arm_is_recorded_byval_agg() asks the localvar side table what representation the arm's value actually has HERE, and suppresses the carrier->concrete bridge when it is already the aggregate. A TYPE-level widening regressed ten fixtures because the same type rides the carrier at the vec/map element and assoc-type seams; the recorded type differs there, so those keep their bridge. SR1 (2026-08-26) added the case that side table cannot answer: it records LOCALS, and a by-value aggregate PARAMETER is not one, so a sum-typed param returned from an arm (re-repeat-n's atom : Regex) was bridged as though it were a carrier. emit_arm_is_byval_agg_var() is the second suppressor, and it IS the type-level test this row warns about -- kept safe only by being narrowed to by-value SUMS (the vec/map and assoc-type seams this row names are products) and gated on the seam. Widen it past sums and you should expect the ten fixtures back byvalue-product-tail-var-double-unboxed-nonparametric
four carrier->concrete bridging sites each asking "does this value already HAVE the aggregate representation?" their own way -- the arm sites through a shared predicate, TWO let-binding init sites through separate inline copies of the same three comparisons, and the CPS letraw mirror not at all, while its comment claimed "same gate as the direct site" one copy: emit_value_is_recorded_as(v, want_ctype), taking the wanted C type as a STRING because that is what the binder sites hold; emit_arm_is_recorded_byval_agg is a thin Type-taking wrapper for the arm sites. The CPS mirror's missing term was added and is provably inert -- across all 2131 fixtures only 33 reach that bridge with a by-value init type, and in every one either the Expr-level predicate already suppresses it or the init is recorded as int64_t/pointer/nothing, never the aggregate. Emitted C byte-identical. A consistency repair, not a fix for an observed miscompile cps-let-binder-bridge-lacks-position-check
a control form (let/do) wrapping an if whose arms are carrier producers -- each arm was bridged carrier->concrete by emit_if, then the ENCLOSING form bridged the already-concrete merge temp again (operand of type 'tur_adt_...' where arithmetic or pointer type is required). The same if as the whole function body was always fine two halves. bridge_control_value_to_byvalue_temp (the do/let companion its own comment already named) gained emit_arm_is_recorded_byval_agg, the same one-predicate change that fixed the emit_if arms. But the precondition that fix assumed did NOT hold: emit_if declares its by-value merge temp with emit_temp_decl DIRECTLY, bypassing emit_control_result_temp_decl, which is the wrapper that records the temp's emitted C type -- so the temp was by-value but invisible to the side table the predicate consults. Recording it is the other half, and without it the predicate answers false and nothing changes control-form-around-if-double-unboxes-carrier-arms
^mut rebinding of a concrete heap container (merge-temp position) -- carrier where chokepoint 1 says typed pointer, travelling with a spec-materialization hole (a generic call in a set! RHS never interned its spec: LINK error past tur check) chokepoint 1's concrete-heap rule extracted to emit_repr_concrete_heap_ptr_c_name and shared by the let-bind decl, the merge-temp decl, and its ctype mirror (the existing int<->ptr bridge reconciles a carrier tail); emit_abi_scan_expr gains its missing EX_SET case mut-map-reassign-missing-spec-link-error
capturing closure -> nominal thin TY_FN param whose signature carries an effect row (the report's LAST row; concrete and tyvar signatures were already fat-normalized) the CPS increment (2026-08-16): effect-annotated fn params join fn_param_type_is_fat_normalized; the E2a registry call sites dispatch fat (slot 0 = a registered capturing-lambda entry with an env-taking __cps twin, slot 1 = the fatshim's stashed bare-fn entry); threadable capturing lambdas are CPS-admitted with the direct thunk's env-unpack preamble; the effect_check walkers peel the shim. Capturing PERFORMING callbacks -- previously no working spelling -- thread the handler chain too. Thin remainder (cfnptr/variadic/arity>5 effectful) keeps a call-site TUR-E0007 poly-result-hof-capturing-closure-sigbus
generic closure return over a type application (struct Cons) -- the (type-app ? ?) shell at the checker AND the never-emitted ctor_Cons at link Defect A: result-graft recovery at the thunk-type clobber in elab_call.c (the binding's own ground result_full_type survives the swap; the elab_fns.c grounding gate is untouched). Defect B: inner_app clone trigger + body-type-derived clone result + head-keyed clone resolution at the thunk direct-call, so the per-spec inner-closure clone is both emitted and the one actually invoked generic-closure-return-type-app
typeclass method result at float (any width) -> generic (carrier) call argument producer bit-cast keyed on the method's DECLARED result kind (the same type the consumer keys its reinterpret on -- paired by construction); an int-declared method keeps its value conversion method-result-float-spec-return-value-converts
float32-ascribed literal -> any mixed C expression ascribe elaborator retypes a float literal in place ((:: 7.1 float32) == 7.1f32), so it emits at single precision instead of as the double literal the promotion rules then dominated float32-ascribed-literal-compares-as-double
float32 generic (carrier) call result -> concrete consumer elaboration: call_wrap_reinterpret_owning admits the carrier<->float32 pair (its silent mixed-size bail dropped the requested reinterpret, typing the call int); emit's size-mismatch reinterpret arm reads float pairs through the union overlay float32-generic-call-result-printed-as-carrier
TY_CONTRACT in type-ARGUMENT position -- the payload kept a live contract type at every downstream boundary peeled to its base in BOTH type-application loops (rt_peel_type_arg_contract), warning TUR-W0380 that the payload predicate is not enforced; TY_CONTRACT then joined type_has_concrete_codegen_layout by delegating to its base contract-type-arg-not-peeled-to-base
method result (carrier) -> typed (Result A B) defn boundary increment 2: continuation-wrapper ABI paired with the entry point dispatch actually selects result-monad-bind-typed-boundary-miscompiles
by-value aggregate returned by a CAPTURING continuation -> int64 tur_poly_fn_t.fn carrier sink (nested bind / multi-step do-m) signature-keyed fat spill shim: reads the real entry point out of the closure env's __fn slot and boxes the aggregate, the fat twin of the row above (which only covered named wrappers) nested-bind-over-result-typed-boundary-segfaults
method result (carrier) -> generic call argument increment 2 class-method-result-into-generic-invalid-c
by-value struct -> Vec / Map element slot increment 3: width-independent boxed element protocol vec-byvalue-struct-element-invalid-c
closure VALUE -> pass-through return / ascribe-around-let / nested fat HOF fat-normalization stage 2 fn-typed-value-return-ascribe-miscompiles
fn value read out of a container element, then called fat-normalization stage 2 fn-payload-in-container-undeclared-temp
let-ALIASED carrier fn param in tail position; carrier vs boxed-result if unification alias provenance in the tail walkers + poly-to-fat at the if join fn-value-carrier-fat-seam-residuals
closure handle -> double-typed element slot two-types-one-C-name collision, resolved upstream (both findings) concrete-codegen-layout-kind-enumerations-drift
carrier fn value -> monomorphized-ctor arg slot; carrier value -> pointer-typed fn return stop re-deriving the emitted C type from a Type. The ctor's real param C type is recorded at ADT-app registration and looked up at the call site; the return site asks the typed AST whether the tail emits the carrier instead of sniffing the emitted string macos-int-conversion-carrier-pointer-straddles

A structural note the last closed row exposes: the representation decision today is not one function but (at least) three hand-maintained TypeKind switches in src/compiler/types.c -- type_c_name, type_has_concrete_codegen_layout (fails closed: a missing kind silently falls back to the carrier), and append_type_mangle -- and codegen is correct only when all three agree. The triplication is removed (increment 4 stage 1) for payload-free kinds: the TY_SIMPLE_REPR_ROWS table in types.c carries one row per simple kind with all three answers, and each switch expands the rows with its own projection -- adding a kind without all three answers is a build failure, not a silent drift. Payload-carrying kinds keep per-switch arms; two CI guards ratchet the whole arrangement (tests/check-typekind-mangle-exhaustive.sh parses the table + residual arms and now also checks type_c_name exhaustiveness; tests/check-monomorph-name-collision.sh reads what they emit). The position axis -- one repr-of(type, position) routine for the per-SITE choices -- is staged in docs/archive/repr-decision-function-plan.md.

A strong diagnostic signal that a bridge exists but is not consulted: an intervening let fixing the repro (verified for class-method-result-into-generic-invalid-c -- the binding applies the carrier->concrete bridge the direct composition skips).

C-name accessors: which result may I hold?

Every representation in this guide has a C spelling, and the emitter reaches it through an accessor returning const char *. Whether you may HOLD that pointer across another call is part of the accessor's contract, and it is invisible at the call site -- two accessors that look identical can differ.

The rule now, and it is uniform: every C-name accessor returns a string that is stable for the whole compilation.

Why this is worth a section rather than a comment: emitters routinely gather one name per field or per parameter into an array and only then write the declaration. Against a shared buffer every entry aliases it, so every name is the LAST name -- and the result is well-formed C with the wrong type in it. No crash, no ASan report, no compiler diagnostic. It surfaces downstream as a -Wincompatible-pointer-types at some unrelated call site, or not at all.

The tree has been bitten twice: EmitSigEntry.ret_ctype handed out an interior pointer callers held across further emission (43 fixtures, caught by ASan), and adt_field_c_type mistyped a (Result Rational ArithError) monomorph's ok_val as the error arm. The second was found by a representation change, not by a test -- which is the point. A by-value sum makes "two pointer-boxed fields on one constructor" ordinary, so consolidating a representation is exactly what takes one of these latent and makes it live.

Two guards:

Resolved report: docs/archive/c-name-accessors-share-static-buffers.md.

Finding more missing cells

tests/type-fuzz-src.py walks this matrix mechanically: it generates correct-by-construction programs routing known values through random wrapper x boundary compositions and asserts check-accepted implies compiles + links + runs + prints the predicted output. Shapes reproducing the open cells above are excluded via its known_bug_slug table and pinned by --known-probes instead, so a red run means a NEW cell.

The convention-level fix for the closure rows -- normalize every non-carrier fn boundary onto the fat protocol instead of deciding representation per-boundary -- is complete; see docs/archive/fn-value-fat-normalization-plan.md. The campaign-level strategy governing that plan and its successors (which seams consolidate in which order, the probe/blast-radius discipline, and the performance guardrails) is docs/archive/representation-consolidation-meta-plan.md; this guide's open-cells table is that campaign's live scoreboard, and the closed-cells table below it is the record of what the campaign has already consolidated.

Maintenance -- keep this guide truthful

This guide is load-bearing for triage; a stale representation inventory is worse than none. Every report in the table above carries a closing task pointing back here. If you:

then update the corresponding section here in the same PR: fix the inventory, move the cell from the open-cells table down into the closed-cells table with a one-line resolution note, and note the new invariant. When a report is archived, update its row's link to docs/archive/.