# SX RULINGS — normative decisions on every ambiguity surfaced by the 2026-07-03 review DRAFT for ratification. Each ruling: STATUS `PROPOSED` → flip to `RATIFIED` / `REJECTED` / `AMENDED: `. Once ratified, this file moves to `spec/RULINGS.md` and becomes the authority the conformance batteries pin against. Evidence citations: core.md finding names, hosts.md J/C/JS/P/S codes, conformance.md F codes. **Default posture used for recommendations** (override per-ruling as you see fit): 1. Prefer an ERROR over any silent behavior (silent drop/no-op/misparse caused the worst findings). 2. Prefer R7RS/standard semantics where churn is low; prefer current-behavior-plus-documentation where churn is high and behavior is defensible. 3. Every ruling lands with conformance rows that run on BOTH production kernels (native + WASM). **Companion decisions (not language rulings, restated for context):** - D1 host lineup — recommended: kernel family (native OCaml + WASM) are the only evaluator targets; hosts/javascript and hosts/python standalone retired; shared/sx/parser.py shrunk to a wire-subset with a parity suite. Rulings below marked [D1] simplify to kernel-only if ratified. - D3 gate — recommended: native corpus green (hs-upstream skip-listed) + same corpus on WASM + cross-kernel differential battery + CEK-vs-JIT differential (when JIT on) + sx_ref.ml regen diff. --- ## A. Bindings & scope ### R1. `set!` on an unbound name - Current: silently creates a root binding (tested intent, test-scope.sx:196) — but BOTH spec docs say error (eval-rules.sx:112, special-forms.sx:141), and under JIT it writes a different global table than the interpreter (split brain). - RECOMMENDATION: **ERROR** ("set!: is not bound — use define"). Typo'd set! is a bug-hider; the docs already promise this. Flip test-scope.sx:196; sweep the corpus for reliance (expected small — the idiom is define-then-set!). Either way the JIT/interpreter split MUST die. - Churn: low-medium. Findings: core set!-unbound; hosts J-globals split. STATUS: PROPOSED ### R2. The ~60 special-form/HO names (`map`, `filter`, `bind`, `match`, `do`, `case`, `->`, …) - Current: `define`/`let`/`defmacro` of these names is silently accepted but ignored in call position (CEK); the VM honors them (J8) — worst of both worlds. - RECOMMENDATION: **reserved words** — `define`/`let`/`set!`/`defmacro` of any dispatch-table name is a load-time ERROR. Publish the list in spec. Align the VM. (Full lexical honoring is more Schemely but taxes every list-head dispatch and rescues little real code.) - Churn: low (error surfaces existing dead definitions). Findings: core unshadowable-names; J8. STATUS: PROPOSED ### R3. `let` semantics - Current: sequential (`let*`), body = implicit begin, on BOTH engines (tested intent). CLAUDE.md island rules claim the opposite (describes a dead evaluator). - RECOMMENDATION: **ratify current behavior**: `let` ≡ `let*`; body sequences. Fix CLAUDE.md. Document (or forbid) the observed letrec-ish quirk that binding-init lambdas capture the shared frame (`(let ((f (fn () a)) (a 5)) (f))` → 5). - Churn: zero (docs only). Findings: core let-docs; hosts handoff let-sequential. STATUS: PROPOSED ### R4. `letrec` - Current: parallel (all inits evaluated, then bound); read-before-init yields nil silently; PLUS two outright bugs (names injected into foreign lambdas' closures = global contamination; named-let loop name leaks into and clobbers the enclosing frame). - RECOMMENDATION: **letrec\* semantics** (sequential init) with ERROR on read-before-init (pre-bind to an "uninitialized" sentinel that faults on read). Named-let binds its loop name in a fresh frame, invisible after the form. The closure-injection and frame-leak are bugs to fix regardless of ruling. - Churn: low. Findings: core letrec-parallel/-injection/named-let. STATUS: PROPOSED ### R5. Component `&key` conventions - Current: `:flag false` is coerced to nil (indistinguishable from omitted); trailing keyword with no value silently binds nil; `&key` on plain fn/defmacro silently misbinds. - RECOMMENDATION: `false` is a legal &key value (bind via has-key, not `(or …)`); trailing keyword without a value = ERROR; `&key` in fn/defmacro either implemented identically to components or ERROR at definition (recommend: implement — one binding path for all three). - Churn: low. Findings: core &key-false / trailing-kw / defmacro-&key. STATUS: PROPOSED ## B. Errors & conditions ### R6. Handler installation semantics - Current: a handler runs with ITSELF still installed → any raise/error inside a guard clause or handler-bind handler loops forever (crit 1; WASM-verified). - RECOMMENDATION: **R7RS/CL semantics** — handlers run with the OUTER handler set; guard clause bodies evaluate after the escape (the no-match auto-reraise path already does this correctly — make it the only path). - Churn: zero for correct code (only un-hangs broken cases). Findings: crit 1, guard family. STATUS: PROPOSED ### R7. What is catchable - Current: only guest `(raise …)` reaches guard; host primitive errors, undefined-symbol, arity errors, and strict type errors all blow through every handler. - RECOMMENDATION: **everything is a condition.** Host/primitive/strict/undefined-symbol errors are raised as structured condition dicts ({:type :message :op …}) through the same channel guard sees. Reserve a non-catchable class only for kernel panics. - Churn: low-medium (code that "relied" on uncatchability is unlikely). Findings: core host-errors-uncatchable, strict-uncatchable; enables sane server error pages. STATUS: PROPOSED ### R8. `raise-continuable` / `signal-condition` - RECOMMENDATION: ratify R7RS: handler's value returns to the signal site (the current whole-program-result behavior is crit 2's frame-key bug, not a semantic choice). STATUS: PROPOSED ## C. Special forms ### R9. `cond` grammar — kill the dual-mode heuristic - Current: flat pairs documented; undocumented Scheme clause mode auto-detected iff every arg is a 2-element list → silent side-effect drops, mode flips, wrong values (core cond-ambiguity). - RECOMMENDATION: **flat pairs only**: `(cond t1 r1 t2 r2 … :else d)`. Multi-expression results use explicit `(do …)`. Support arrow as a flat triple `t => receiver`. A clause-shaped arg list as a test position is just evaluated — no mode detection ever. Migrate the cond-arrow suite (test-r7rs.sx:135-145) and any clause-mode usage (sweep needed). - Churn: medium (sweep + migrate clause-mode call sites). Findings: core cond-ambiguity, guard-multi-expr (inherits). STATUS: PROPOSED ### R10. `case` - RECOMMENDATION: ratify the flat evaluated-datums form and document it (vals ARE evaluated, first-match, structural `=`); `:else`/`else` legal ONLY in final position (else ERROR); Scheme datum-list clause syntax → clear parse-time ERROR ("use flat pairs"). Keyword/string punning follows R21 and gets documented. - Churn: low. Findings: core case-else-position / case-dialect. STATUS: PROPOSED ### R11. `do` - Current: `do` is a begin-alias EXCEPT when its first form's head is a list — then it's a Scheme do-loop → IIFE misparse. - RECOMMENDATION: **`do` = begin alias, always.** Scheme do-loop moves to a distinct name (`do-loop`) or is dropped (named let covers it). Kills the heuristic. - Churn: low (sweep for real do-loop usage; expected rare). Findings: core do-IIFE. STATUS: PROPOSED ### R12. Quasiquote - RECOMMENDATION, four sub-rulings: a. `unquote-splicing` becomes an alias of `splice-unquote` (one-line; kills the silent zero-splice trap; rename the misleadingly-named tests). b. Implement standard **depth tracking** (nested quasiquote raises quote depth; `,,x` works). Hosts agree current shallow behavior is consistent-but-nonstandard — fix at spec level. c. Quasiquote **traverses dict literals** (`{:k ,v}` works). d. Splicing a non-list and malformed splice arity → ERROR. - Churn: low (b is the only subtle one). Findings: core qq-longhand/-depth/-dicts/-splice-nonlist. STATUS: PROPOSED ### R13. Threading `->` / `->>` - RECOMMENDATION: (a) steps evaluate in CEK frames (bug: guard/IO broken through threading); (b) a lambda literal as a step = expand-time ERROR; (c) keyword step sugar: `(-> x :k :j)` ≡ `(-> x (get :k) (get :j))` — cheap, expected, kills the `Not callable: nil` trap; (d) remove the dead `|>` dispatch branch (parser rejects `|` anyway); (e) fix reduce-seeding via R15. - Churn: low. Findings: core threading-nested-CEK/-lambda-literal/|>-dead/keywords-as-getters. STATUS: PROPOSED ### R14. `match` - RECOMMENDATION: `(pattern (when cond))` guard clauses either implemented or ERROR — never silently read as a structural pattern (current). Recommend: implement (small, high value). Document let-match as dict-destructuring-only with a clear error for list patterns. - Churn: low. Findings: core match-guards. STATUS: PROPOSED ## D. Calling convention ### R15. Higher-order forms - RECOMMENDATION, six sub-rulings: a. Arg-order swap happens ONLY when exactly one argument is callable (components count as callable); both-callable or neither → ERROR "map: cannot determine function/collection". b. `(reduce f coll)` (2-arg) = Clojure-style fold (first element as init, empty coll → error unless f has identity? keep simple: empty → ERROR); `(reduce init f coll)` and threaded `(-> init (reduce f coll))` work via the one-callable rule in (a). c. Data-first with extra args = ERROR (today silently dropped). d. Multi-collection map coerces every collection with seq-to-list (strings/vectors), zips to shortest (already); map over a dict iterates `(k v)` pairs. e. HO names are first-class: `map` etc. in value position resolve to real closures so `(define f map)` / `(apply map …)` work. f. Zero/one-arg HO calls = arity ERROR (today silently `()`). Also fix the O(n²) accumulation (implementation, not semantics). - Churn: medium — (a) changes behavior for ambiguous calls, sweep needed. Findings: core reduce-2arg / reduce-swap / swap-drops-args / HO-not-first-class / ho-cryptic-errors / multi-coll / zero-arg; J7 (VM parity). STATUS: PROPOSED ### R16. `apply` - Current: native never spreads; WASM spreads 2-arg; test runner has a third behavior (three-way divergence, F-3 + core corrected finding). - RECOMMENDATION: **R7RS**: `(apply f a b … rest-list)` spreads, leading args prepended. All surfaces align; strict checks fire through apply (R25). - Churn: low (today it mostly errors). STATUS: PROPOSED ### R17. Arity checking (too-few args) - Current: missing params silently nil-fill (this is load-bearing: 1-arg `(assert x)` works only via nil-fill); too-many errors. - RECOMMENDATION: **ERROR on too-few** as well, with `&optional`/`&key`/`&rest` as the explicit mechanisms. Sweep required (harness `assert`, any nil-fill reliance). If the sweep turns up heavy reliance, fallback position: keep nil-fill but document it loudly and make strict mode error. Primary recommendation stands: error. - Churn: **high** — flagged as the riskiest ruling; do the sweep before ratifying. Findings: core strict-too-few / harness-assert nit. STATUS: PROPOSED ## E. Keywords, equality, types ### R18 (=R21 referenced above). Keywords - RECOMMENDATION: ratify current model — keywords self-evaluate to their string name; keyword-ness exists only in unevaluated AST. Consequences made explicit: `(keyword-name :k)` needs a quote; `"keyword"` is REMOVED from the strict type system; case/dict punning documented. NOT callable (R13c covers the getter idiom). - Churn: zero (docs + removing a dead type branch). STATUS: PROPOSED ### R19. Equality - RECOMMENDATION (low-churn variant, chosen deliberately over full R7RS split): a. `=` stays deep structural equality (alias equal?) — ubiquitous in the corpus; add the missing **Char arm** (today `(= #\a #\a)` → false) and any other missing type arms; document that `(= 1 1.0)` → true (numeric value equality inside =). b. Add real `eqv?` (identity + exact numeric/char equality) and `eq?` (alias identical?) as kernel primitives — they are spec-declared today but implemented NOWHERE. c. Comparisons `< > <= >=` become n-ary chained (R7RS); `=` stays 2+-ary deep. d. If content-addressing ever needs exactness-distinguishing equality, that's `eqv?`, not `=`. - Churn: low. Findings: core eq?/eqv?-missing, =-binary, char-equality; P5. STATUS: PROPOSED ### R20. Strict typing - RECOMMENDATION: (a) checks move to the continue-with-call/vm_call chokepoints → HO callbacks, apply, components, => receivers all covered; (b) unknown type name at declaration = ERROR; (c) `"component"` becomes a real type branch; `"keyword"` removed (R18); (d) `(:as type)` param annotations become the declaration channel (deprecate the name-keyed global dict, which is trivially evaded and inherited by shadowers); (e) strict errors are catchable conditions (R7); (f) set-prim-param-types! merges and validates; (g) return types: explicitly out of scope now. - Churn: low-medium. Findings: core strict-* family (8 findings). STATUS: PROPOSED ## F. Numbers ### R21. Integer model & overflow - Current: native = int63 with overflow-promote-to-float on + and * but silent WRAP on expt; WASM = 32-bit silent wrap (F-1 — production browsers!); JS bundle = float64. - RECOMMENDATION: spec defines SX integers as **exact within ±2^53** (the portable range); arithmetic that exceeds the host's exact range **promotes to float** (never wraps) — `expt` included. WASM must be fixed to match (js_of_ocaml int64/boxed or explicit overflow checks) — hosts lane feasibility-checks the mechanism; silent 32-bit wrap is a bug under any ruling. Values beyond 2^53 must not be trusted exact across the wire. - Churn: low at spec level; WASM fix is real hosts work. Findings: F-1, P4, core expt. STATUS: PROPOSED ### R22. Division & zero - RECOMMENDATION: integer `/`, `mod`, `quotient`, `remainder` by zero = catchable SX condition (today: raw OCaml Division_by_zero for mod/quotient, silent `inf` for /); float ops keep IEEE (inf/nan). `/` doc fixed: returns int when exact, float otherwise (current behavior ratified). - Churn: low. Findings: core div-by-zero, /-doc. STATUS: PROPOSED ### R23. Float text & wire - RECOMMENDATION: **shortest-round-trip printing everywhere** (native `%g` 6-sig-digit printing is a wire-corruption bug — P1); `inf`/`-inf`/`nan` are THE wire tokens on all hosts (P10); `round` stays half-away-from-zero, documented (R7RS banker's rejected: churn without benefit); `inexact->exact` rounding behavior kept + documented; `str 1.0` → keep `"1"` but canonical/wire serializers must preserve the float/int distinction (`1.0` serializes as `1.0`). - Churn: low. Findings: P1, P10, core round/float-rendering; canonical CID determinism. STATUS: PROPOSED ### R24. Rationals - RECOMMENDATION: `string->number` parses `"1/2"`; `(/ 1 3)` stays float (rationals remain opt-in via make-rational) — documented; radix arg restored by fixing the r7rs.sx shadow (C2). - Churn: low. STATUS: PROPOSED ## G. Strings ### R25. Unit semantics - Current: native counts UTF-8 bytes (substring can split codepoints → invalid UTF-8); JS counts UTF-16 units; constructors are codepoint-aware. Project style mandates UTF-8 text everywhere. - RECOMMENDATION: **codepoint semantics** for length/substring/index/ref at the spec level; kernel implements UTF-8-aware ops. Accept the perf cost (or add byte-* variants for hot paths later). - Churn: medium (kernel work + any code relying on byte counts). Findings: core UTF-8 family, P6. STATUS: PROPOSED ### R26. Case mapping - RECOMMENDATION: kernel `upcase`/`downcase`/`upper`/`lower` are **ASCII-only, documented** (full Unicode case tables deferred; JS's full-Unicode behavior dies with D1). Aliases exist on all surfaces (P11). - Churn: zero. STATUS: PROPOSED ### R27. `split` and escapes - RECOMMENDATION: `split` = literal substring separator, keeps empties, empty separator → chars (ratifies native; pin with the multi-char test that history shows is needed). String escape table is normative: `\n \t \r \\ \" \uXXXX(validated: 4 hex digits, scalar value, else ERROR)`; **unknown escape = parse ERROR** (kills the native-keeps-backslash vs guest-drops-it silent divergence, C25 direction fight). - Churn: low. Findings: core split note, \u family, unknown-escape divergence; C25. STATUS: PROPOSED ## H. Collections, nil, dicts ### R28. nil vs empty list - Current: distinct values in the reader/serializer; `(cons 1 nil)` → `(1)` on native (nil-as- empty in constructors); read ops inconsistent (`first nil` → nil but `reverse nil` → error). - RECOMMENDATION: nil and `()` remain **distinct values**; collection READ ops uniformly **nil-pun** (treat nil as empty: first/rest/nth/last/reverse/len/empty? all accept nil); constructors keep nil-as-empty seeding (cons/append onto nil). `nil?` ≠ `empty?` preserved. - Churn: low (only un-errors cases). Findings: core nil-tolerance; P7/P8 arms. STATUS: PROPOSED ### R29. Dict ordering - RECOMMENDATION: **insertion order preserved** — iteration, keys/vals, and serialization (OCaml Hashtbl replaced with an insertion-indexed structure; keys-reversed bug dies). CANONICAL form always sorts keys independently (already true in the CBOR/CID layer). Duplicate literal keys: last-wins, documented. - EMPIRICAL NOTE (quick-wins batch, 2026-07-03): an interim sorted-keys change broke 4 render tests — attr emission order flows through dict_keys and the tests PIN source-order attributes (`width` before `height` etc.). So the current reverse-ish order is load-bearing for render; any change here must land together with the render-attr ordering contract. Reverted; do not change keys order except via this ruling. - Churn: medium (kernel dict rework) but pays across wire/golden/cache findings C27/P9/core-keys. STATUS: PROPOSED ### R30. Small-primitive contract fixes (spec already says; hosts violate) - RECOMMENDATION: ratify the spec text and fix: `contains?` on dicts = key check; `merge` skips nil; `into` native on the kernel; `sort` takes an optional comparator, compares int/float numerically, stable; `get` returns a STORED nil (default only when key absent); `zip-pairs` = sliding window per spec (kernel currently chunks); `(max)`/`(min)` zero-arg = ERROR. - Churn: low each. Findings: core contains?/sort/keys; P2/P3/P8/P12; JS `get` arm. STATUS: PROPOSED ### R31. `append!` and mutation - Current: silently no-ops on ANY derived list (map/filter/rest/reverse output) — worst silent- data-loss finding in the primitives sweep. - RECOMMENDATION: `append!` **ERRORS on non-mutable lists** immediately (honest), and is deprecated in favor of persistent `append` + a real mutable vector/buffer for accumulator idioms. Sweep the corpus (it's a known accumulator idiom in loops). - Churn: medium (idiom sweep). Findings: core append!. STATUS: PROPOSED ## I. Parser & wire ### R32. One token grammar - RECOMMENDATION: publish the normative ident/number classifier in spec/parser.sx and make every surface bind THE SAME table (today: four divergent tables → same source, different ASTs). Specific token rulings: maximal-munch then classify (`1+`, `a,b` are symbols — ratifies native); hex/binary/octal `#x/#o/#b`-style and `0x10` accepted, documented; `inf`/`nan`/`-inf` are number literals (reserved, not idents); `1e` and other malformed numbers = parse ERROR (never nil); unicode identifiers **allowed** (UTF-8 letters — the docs mandate UTF-8 text; native reader extends its charset); `$`/`|` NOT ident chars; `.` IS a valid symbol (ratifies native; JS4 dies with D1); `#t`/`#f` = boolean literals on all surfaces. - Churn: medium (native reader charset + guest table sync). Findings: core parser-divergence family; C1b (unicode symbol kills server — fixed by charset + C1 try-wrap); JS4. STATUS: PROPOSED ### R33. Reader extensibility & comments - RECOMMENDATION: implement the `#name` reader-macro registry on the kernel (spec documents it; only JS has it today) — small, and sx-pub extensibility wants it. `#;` datum comment valid before `)` and at EOF (standard). `#|…|` stays a RAW STRING (documented loudly as not-a-block- comment); no block comments. - Churn: low. Findings: core reader-macro/datum-comment/raw-string. STATUS: PROPOSED ### R34. Dict literals & serializer round-trip - RECOMMENDATION: dict literal keys must be keyword/string/symbol — anything else is a parse ERROR on every parser (guest currently stringifies `{1 2}` silently); odd form count gets a "dict needs key-value pairs" error. Serializer: dict keys escaped/round-trippable (today unparseable output for non-ident keys — also a CID hazard); chars serialize by codepoint (`#\é` readable back once R25 lands); PROPERTY TEST: `parse(serialize(x)) = x` for the full value lattice, run on both kernels. - Churn: low. Findings: core serializer-dict-keys / multibyte-chars / dict-edges. STATUS: PROPOSED ### R35. Canonical form & CIDs (sx-pub-critical) - RECOMMENDATION: the **native CBOR/CID path is normative** (key-sorted, verified native==WASM, F-3). The canonical TEXT form is defined as: sorted keys, shortest-round-trip floats with preserved int/float distinction, fully-escaped strings, and is a fixed point (canonical(parse(canonical(x))) = canonical(x)) — property-tested cross-kernel. spec/canonical.sx either becomes a tested mirror of the native path (fix its runner-only helpers) or is deleted; two silently-diverging implementations is the one unacceptable state. - Churn: low-medium. Findings: core canonical family; F-3; P9/C27 (via R29). STATUS: PROPOSED ### R40. Primitive naming & small-default unification (answers the hosts handoff list) - RECOMMENDATION: one canonical name registry in spec/primitives.sx; per-host aliases die (with D1 most of these resolve to "make it native on the kernel"): `json-encode`/`json-parse` are KERNEL primitives (not IO-bridge helpers — today unavailable sandboxed); `regex-*` is the canonical family name; `parse`/`sx-parse` — `sx-parse` canonical, `parse` alias documented; 1-arg `(range n)` = 0..n-1 (ratifies native); `parse-int`/`string->number` on failure → nil (ratifies native, never 0); `format` and the stdlib move for real (the primitives.sx header claims a stdlib migration that never happened — make the header true or revert it) and spec/stdlib.sx loads in production (today `format` is unresolved on the server). - Churn: low. Findings: F-9 naming splits, P7 arms, core spec-drift / stdlib-header. STATUS: PROPOSED ## J. Render contracts ### R36. Attribute contract (all four adapters) - RECOMMENDATION: one contract, HTML-mode's as base: boolean-registry attrs — false/nil omit, anything else emits bare name (SX truthiness, documented footgun stands); non-boolean attrs — value stringified INCLUDING `"true"`/`"false"` (DOM adapter aligns — C19/core, found by both lanes); attribute NAMES validated `[A-Za-z_:][A-Za-z0-9_:.-]*` else ERROR (kills spread-dict injection); nil attr value omits the attribute. - Churn: low. Findings: core attr-name-injection / bool-footguns / dom-html-parity; C19. STATUS: PROPOSED ### R37. Raw-text elements & voids - RECOMMENDATION: `