# RESOLVED — 0128: `[:0]u8` at FFI boundaries — conflicting symbol views, garbage string returns > **RESOLVED** (2026-06-12). Investigation corrected the filing: the > "silent `u8` return" and the "`?[:0]u8` unresolved panic" were BOTH > artifacts of the reproducers binding the C symbol `getenv`, which > std/process.sx already declares as `-> *u8` — the FIRST registration > of a C symbol silently won and every call through the later > declaration was typed by the older signature (`*u8`), cascading into > the panic. `?[:0]u8` itself resolves correctly (it is `?string`). > The two GENUINE defects, both fixed: > > 1. **Conflicting same-symbol redeclaration was silent.** > `dedupeExternSymbol` (src/ir/lower/decl.zig) now runs at extern > registration: an EQUAL signature shares the first registration's > FuncId; a CONFLICTING one is diagnosed ("extern symbol '' is > already bound with a different signature"). > 2. **Extern `-> string` / `-> ?string` returns read garbage.** The > C side returns ONE `char *`; the LLVM signature declared the fat > `{ptr,i64}` (len = register garbage; bus error on use), and > `?string` (24 B struct) was mis-declared SRET — the hidden > out-pointer landed in the C callee's first argument register. > Now: such returns are classified by `cstrRetKind` > (src/ir/emit_llvm.zig), declared as plain `ptr` returns (never > sret), and the call site synthesizes the sx value via > `cstrReturnToSx`: `{ptr, strlen(ptr)}` with the strlen call > branch-guarded (NULL → `{null,0}`), wrapped in `{string, i1}` > with `has = ptr != null` for the optional. > > Regression tests: `examples/1221-ffi-cstring-returns.sx` (plain + > optional non-null via strerror/strsignal + optional NULL via > dlerror) and `examples/1172-diagnostics-extern-symbol-conflict.sx` > (the getenv conflict); both FAIL on pre-fix master. The extern > dedupe changes IR snapshots (duplicate libc decls collapse), so the > affected `.ir` files were regenerated. Gates: zig build test > 426/426, tests/run_examples.sh 602/602, distribution repo 21/21. > Boundary: comptime-interp (`#run`) extern calls are untouched, and > indirect (fn-pointer) extern calls don't synthesize — both can > follow if ever needed. ## Design contract (Agra, 2026-06-12) `?[:0]u8` should lower to a `char *` at the FFI boundary — a single nullable thin pointer — NOT a fat pointer. This is the natural sx type for every libc/sqlite-style API that returns a nullable C string (`getenv`, `sqlite3_errmsg`, `sqlite3_column_text`, ...). ## Current state (verified at master 1bc60d3) `[:0]u8` is an ALIAS for `string` (src/types.zig:145 — the `'['` arm returns `.string_type`), i.e. a fat ptr+len value at the sx level. At a `extern` PARAM position the C-ABI lowering already thins it: sx `string`/slices coerce to a single pointer and the length is dropped (src/backend/llvm/abi.zig, the `is_extern_c_api` knob) — so `popen :: (cmd: [:0]u8, ...)` works and matches the design contract. The other two boundary positions are broken: ### Defect A — `-> [:0]u8` extern RETURN silently resolves to `u8` ```sx #import "modules/std.sx"; libc :: #library "c"; getenv_s :: (name: [:0]u8) -> [:0]u8 extern libc "getenv"; main :: () -> i32 { v := getenv_s("PATH"); print("len={}\n", v.len); // error: field 'len' not found on type 'u8' return 0; } ``` The declared return type `[:0]u8` resolves to plain `u8` — not the `string` alias, not an error. Whatever the return-position type-name path is, it disagrees with the table in types.zig and fails SILENTLY into a wrong type (the silent-fallback-default class this repo's CLAUDE.md forbids). ### Defect B — `?[:0]u8` does not resolve; panics LLVM emission ```sx #import "modules/std.sx"; libc :: #library "c"; getenv_opt :: (name: [:0]u8) -> ?[:0]u8 extern libc "getenv"; main :: () -> i32 { p := getenv_opt("PATH"); if p == null { return 1; } return 0; } ``` Panics: "unresolved type reached LLVM emission" (src/backend/llvm/types.zig:175). Meanwhile plain `?string` resolves and works at the sx level — the `'?'` arm (types.zig:141) stores `child_name = "[:0]u8"`, and whatever resolves the child later doesn't go through the same table that maps the bracket spelling to `string_type`. Minimum bar even without the feature: a diagnostic, not a panic. ## Fix shape 1. Make the bracket spelling resolve identically everywhere — return position and optional child position must hit the same alias table that param position does (Defect A and the resolution half of B). 2. Implement the boundary contract for returns: a extern `-> [:0]u8` / `-> ?[:0]u8` receives ONE pointer from C; the sx-side `string` is built by synthesizing the length (strlen) at the boundary, and for the optional a NULL pointer maps to `null`. If (2) is deferred, extern string/optional-string RETURNS must be rejected with a diagnostic naming the workaround (`?*u8`). ## Workaround in use /Users/agra/projects/distribution/src/db/sqlite.sx declares every nullable C-string return as `?*u8` (null-pointer niche verified working in both JIT and AOT) and copies via a manual strlen helper; its header comment cites this issue's gap. std's own `getenv :: -> *u8` + cast-check is the same dodge. Both can migrate to `?[:0]u8` once the contract lands. ## Verification Both reproducers behave: A prints a real PATH length; B answers null / non-null correctly for missing / present variables. `zig build test`, `tests/run_examples.sh` green; pin both as examples.