Sky.Spa de-reflection (prod web) — progress tracker
Resume: read this +
.claude/AUTONOMOUS_GOAL.md. Goal: de-reflect the Sky.Spa CLIENT core so a real Sky-emitted client compiles under TinyGo to a web-viable bundle, ISOLATED in a client runtime so Sky.Live's reflect path is untouched. Branchexp/spa. TinyGo at /opt/homebrew/bin (0.41.1).
Measured baseline (surrogates, prod-web.md)
- reflection-free counter: Go 579KB gz / TinyGo 65KB gz
- reflection-free todos-scale: Go 600KB gz / TinyGo 103KB gz
- current reflect-heavy real client: ~2.4MB gz => target: real de-reflected client ~100KB gz under TinyGo.
Phases
| Phase | State | Notes |
|---|---|---|
| D0 — Architecture-Consult | ✅ | map spa-counter client reflection surface + de-reflection mechanism + TinyGo constraints + phased plan |
| D1 — dispatch de-reflection | ✅ | typed-closure emission (rt.SpaFns) — the 4 dispatch sites, client-only |
| D2 — spa-counter → TinyGo | ✅ | real Sky-emitted counter compiles+renders under TinyGo; 1.59 MB raw / 521 KB gz |
| D3 — codec + ADT de-reflection | ⏳ | reflection-free client codec/adt for data-decoding (todos) |
| D4 — todos → TinyGo + e2e | ⏳ | real todos client TinyGo build + render + e2e; measure |
| D5 — Judge + isolation proof + full sweep | ⏳ | Sky.Live untouched; §0.2.1 green; DONE list |
Decisions / findings
- (D0) isolation is a hard requirement: de-reflection in a
//go:build js/spa client runtime; server reflect path byte-unchanged.
D0 findings (consult, verified on-machine)
- Isolation is the dominant blocker, NOT dispatch.
tinygo build -target wasmon the emitted counter fails FIRST onnet/http(roundtrip_js.go gap) — an IMPORT typecheck failure (not reachability), via untaggedrt.goimporting net/http(:45)/os(:47)/os/exec(:48)/crypto/rsa(:31)/crypto/x509(:37) + database/sql (rt_core_shims_js.go). DCE can't save it. Client must stop importing the server stdlib. - Dispatch: typed-closure emission (confirmed), NOT Stage-6. Codegen has the
concrete types at the
Spa_configsite (lower.rs:5901 lower_record, all-anyanon-struct branch ~5972/6079) → emitrt.SpaFns{Init,Update,View,Subs}adapters that callMain_update(m.(Msg),md.(int))directly + unpackT2.V0/.V1at concrete type → removesreflect.Value.Call(live_core.go:2425/2436/2516) + T2 tuple reflect. Stage 6 is the server wire-decode problem the client lacks + still routes through reflect.Call — wrong lever. - Counter's reflect surface = ONLY the 4 sky_call sites + T2. HtmlToVNode/adt_shape (SkyADT fast path) + codec_auto NOT reached by the counter — those are D3 (todos).
- Isolation options: (A) tag-split rt.go server funcs
//go:build !js(no dup, extends the P1 live_core/rt_server carve, driven by the tinygo build oracle) vs (B) separatert_spaclient package (perfect isolation, duplication tax). Default A (server files stay, just tagged → non-js server build byte-unchanged); fall to B only if rt.go won't split cleanly. - D-plan: D1 typed-closure dispatch → D2 isolation carve + counter TinyGo → D3 codec/adt → D4 todos TinyGo+e2e.
D1 findings (implemented)
- Codegen (
rust/crates/lower/src/lower.rs,kernel_callhook +try_lower_spa_fns/spa_adapter): thert.Spa_configkernel call now emitsrt.SpaFns{Init,Update,View,Subs}of typed adapter closures (func(a0 any, a1 any) rt.SkyTuple2 { p := Main_update(a0.(Main_Msg), a1.(int)); return rt.SkyTuple2{V0: p.V0, V1: p.V1} }) instead of the all-anyanon struct. Type assertions.(T), no reflect. Gated onrt.Spa_configonly ⇒ Sky.Live/Tui/Webview (Live_config, a different kernel) is byte-unchanged and still reflect-dispatches. - Runtime:
rt.SpaFns+asSpaFns(spa_core.go);Spa_configstores the SpaFns under "Fns"; the wasm driver (live_wasm.gospaRun/step/renderCurrent/reconcileSubs) invokes the closures directly, reads.V0/.V1— nosky_call/sky_call2/tupleFirston the counter path. - Verified: standard-Go
GOOS=jsbuild +run_headless.cjspass (0→+1×3→3→Reset→0→−1);cargo test -p sky/lower/codegen+go test ./rt/...green.
D2 findings (implemented — option A, tag-split, one rt package)
- os + net/url COMPILE under TinyGo 0.41.1 (verified with a probe) ⇒ only net/http, os/exec, crypto/rsa, crypto/x509, encoding/pem were true blockers. Much smaller carve than feared.
- Split to
//go:build !js:rt_server_kernels.go(Process_run, RSA/PKCS Crypto),stdlib_http_server.go(net/http outbound client); tagged console_inline / console_internal_token / email_kernel / email_mime!js; dropped net/http from rt_core_shims_js.go; inlinedhttp.TimeFormatliteral in rt.go. Client HTTP stays browser-fetch(http_wasm.go). - Task entry reflect:
AnyTaskRun(TaskCoerceT[Error,()](Spa_app(cfg)))hitreflect.Type.NumIn()(unimplemented in TinyGo → runtime panic). Fixed reflect-free withSkyTask[E,A].RunAny()+ an interface assertion inanyTaskInvoke(portable; identical result to the old reflect fallback, faster on the server too). - Result:
tinygo build -no-debug -opt=z -target wasmon the REAL emitted spa-counter SUCCEEDS and RENDERS/runs the TEA loop headless on TinyGo's wasm_exec.js. Bundle 1,623,321 B raw / 534,039 B gzip (1.59 MB / 521 KB). Larger than the hand-written surrogate (191 KB / 65 KB) because the real client links the wholertpackage (all pure kernels + reflect-based codec/ADT machinery DCE can't yet strip). Shrinking toward the surrogate is D3+ (codec/ADT de-reflection + DCE tuning). - Reflect status: the 4 DISPATCH sites are reflect-free (proven by render).
reflect.Value.Call/MakeFuncremain in js-compiled files (sky_call/sky_call2live_core.go,pipelineApply/SkyCall/MakeFunc rt.go) on COLD client paths the counter never executes (Cmd.perform, onNavigate, Sub.every timers, JSON-pipeline decode). TinyGo compiles them as panic-stubs; de-reflecting them (so a perform/router/timer Spa app also TinyGo-runs) is D3+. - Sky.Live untouched:
go test ./rt/...green; 09-live-counter + 19-skyforum build + serve HTTP 200; standard-Go spa-counter + spa-input still render.gates_measure_a_fresh_compiler(21), project suite,denominators --check,coverage-ledger --checkall PASS.
D1+D2 DONE (@e825d428, pushed) + D3 plan (consult)
- D1: typed-closure SPA dispatch (lower.rs try_lower_spa_fns; rt.SpaFns) — server byte-unchanged.
- D2: TinyGo-clean client carve (option A tag-split; rt_server_kernels.go + stdlib_http_server.go). REAL spa-counter TinyGo-compiles+renders. Bundle 521 KB gz. Sky.Live 09/19 HTTP 200.
- D3 consult: hand-written codecs DON'T touch codec_auto (no codec rewrite). D3 = ONE mechanism ("apply a boxed Sky func value without reflect" = func(any)any wrap-at-emission + client driver
.(func(any)any)), at dispatch cold-paths (perform/onInput/onNavigate/Sub.every/param-routes; live_wasm.go:430/431, dom_render_wasm.go:144) AND the codec applicative (JsonDec_map2→pipelineApply; stdlib_extra.go:1190). - SIZE IS A CLIFF: TinyGo keeps reflect metadata until ZERO reachable reflect.Value.Call/MakeFunc. Need dispatch (D3a)+codec applicative (D3b)+HtmlToVNode unwrapADTShape ref removed + codec_auto/adt_shape/adaptFuncValue tagged !js (D3c). Measure at D3c.
- Phases: D3a cold-path dispatch · D3b codec applicative · D3c render+reflect isolation (size unlock, "zero reachable reflect" gate) · D3d(=D4) todos TinyGo + e2e + measure.
D3 progress (coordinator, hands-on foreground)
- D3a DONE (@..): performTask + dispatchEvent reflection-free (typed asserts on SkyADT-aliased boundary: SkyTask[SkyADT,any], toMsg func(SkyResult[SkyADT,any])any, onInput func(string)any). Pure runtime, client-only, no codegen. Counter renders under TinyGo, no regression.
- D3b DONE (@ec930641): pipelineApply 2-arg fast-path (codec applicative func(func(any)any,any)any). Todos client TinyGo-COMPILES.
- D3c-partial (@..): ResultCoerce SkyResult[E,any] fast-path.
- BLOCKER MAPPED (honest): full functional todos-under-TinyGo needs the any→typed COERCION MACHINERY de-reflected, not just dispatch/codec. The runtime panic walks forward as each site is fixed: FieldByName (ResultCoerce ✓) → now (reflect.Type).ConvertibleTo() in coerceInner/Coerce/AsListT narrowing the decoded list to []Todo_R. Root cause: the client decode yields values that are then reflect-narrowed to typed structs/slices; the clean fix is either (a) make the client codec decode produce already-typed values (no post-decode coerce), or (b) client-only reflection-free variants of coerceInner/Coerce/AsListT/ narrowReflectValue/mapToRecordStruct. Multi-site, iterative — a bounded-but- substantial remaining effort (the "reflection-free coercion" piece).
- STATE: dispatch + codec-applicative + result-coerce de-reflected + committed; spa-counter FULLY works under TinyGo (D1+D2, CI-green); todos COMPILES under TinyGo (644 KB gz, size cliff not yet tripped) but panics at runtime on the next coercion-reflect site. Size (zero-reflect DCE) awaits the coercion de-reflection.
D3 DEFINITIVE finding — the coercion narrow needs CODEGEN, not runtime fast-paths
Chasing the runtime panic forward (FieldByName→ResultCoerce ✓ → ConvertibleTo→
coerceInner/Coerce) reaches a FUNDAMENTAL wall: coerceInner[[]Todo_R] /
Coerce[[]Todo_R] must narrow []any (each element a BOXED Todo_R) into a typed
[]Todo_R. In the generic runtime helper the element type is erased, so the narrow
is inherently reflect (ConvertibleTo/reflect.Convert) — TinyGo-unimplemented.
Narrowing it reflection-free requires the STATIC element type, which exists only at
CODEGEN (the emit site knows Todo_R). So the emit must either (a) produce
already-typed decode results (no post-decode coerce), or (b) emit the narrow using
the known element type (e.g. AsListT[Todo_R] directly on an A=any result rather
than coerceInner[[]Todo_R]). This is the pervasive "reflection-free coercion"
codegen work (prod-web.md's large lever), NOT a bounded runtime patch.
Achieved (committed, verified)
- spa-counter FULLY works under TinyGo (D1+D2, CI-green, 521 KB gz) — a real Sky-emitted client running client-side under TinyGo. SHIPPED.
- Dispatch (performTask/dispatchEvent), codec-applicative (pipelineApply), and
ResultCoerce de-reflected (client-only/safe short-circuits); todos COMPILES
under TinyGo (644 KB gz). Server byte-unchanged;
go build ./...=0.
Remaining for functional todos (record-app) under TinyGo + the size cliff
- Codegen: emit the client's any→typed coercion reflection-free using the static
element/field types (route slice narrows through
AsListT[Elem]onA=anyresults; struct narrows via typed field assigns) — gated on the SPA client target so the server reflect coercion is unchanged. - Then D3c isolation (client-only reflection-free variants of the residual reflect refs: sky_call cold-fallbacks, unwrapADTShape in HtmlToVNode, adaptFuncValue) so the client graph reaches ZERO reflect → DCE trips the size cliff (644 KB → target ~100 KB). This is a bounded-but-substantial codegen effort (multi-session), now precisely scoped. Resume here.
Compiler-wide de-reflection (post-mandate) — coercion, general
- DONE (@..):
ResultCoerce[E,[]Elem]→ResultCoerceOk[E,[]Elem](x, AsListT[Elem])(runtime helper + codegen render). Compiler-wide (server too). Result-of-list narrow reflection-free. Todos: 9 ResultCoerceOk; panic moves to the STRUCT narrow. - NEXT (found): the struct narrow.
Http_getreturnsrt.HttpResponse(runtime struct), client emitsCoerce[Sky_Core_Http_HttpResponse_R]— a CROSS-TYPE struct conversion (rt type → emitted_Rtype, same fields) that reflect (ConvertibleTo) handles but TinyGo cannot. General fixes to weigh: (a) make runtime types that cross into emitted code ALIAS the emitted type (like Error = SkyADT) so no conversion — bounded set (HttpResponse, ...); (b) codegen emits a field-wise struct converter for Coerce[StructR]; (c) runtime typed struct-copy helpers keyed on the rt source type. Await the compiler-wide strategy consult (a05aa819) for the general approach + inventory before choosing.
Re-classification (N-strikes on the dispatch lever) — first-class-function ABI
LANDED on exp/spa (general, compiler-wide reflection-free wins, committed):
- rt.ResultCoerceOk + codegen recursive
narrow_call(struct arm narrows a structural-struct Coerce reflection-free from the canonical all-any boxed form w/ reflect fallback; Result-of-a via ResultCoerceOk). Closes coercion narrows. - RuntimeAlias (Sky.Core.Http.HttpResponse = rt.HttpResponse) — cross-type struct narrow reflection-free.
- filterMap typed-twin routing allows B=any (List any → List_filterMapT).
- SkyCall func(any)any / func(any,any)any / func()any fast paths.
- lower boxed-closure CONSTRUCTORS: arity>=2, or arity-1 into a boxed slot (any / func(any...)any), emit curried func(any)any nests.
Todos client under TinyGo now: renders + routes + filters + persists to backend.
RESIDUAL (re-classified — NOT another per-site fix): first-class FUNCTION VALUES
and HOF PARAMS lower to DIVERSE typed Go func shapes. Root example:
Std.Spa.getJson's toMsg param lowers to func(rt.SkyResult[Error,any]) any
(typed param), a Msg ctor is func(any) Main_Msg; neither matches the runtime
fast paths, and the SPA/Cmd runtime SkyCalls it → reflect.NumIn panic. This is a
first-class-function ABI matter (make HOF params + fn values uniformly
func(any)any WITHOUT breaking the typed HOF twins that need func(A)B). An
Architecture-Consult (agent a1b267019) is designing the systematic lever
(erase-fn-param-types vs box-at-widen) + grilling regressions before implementing.
Verification of the landed batch (server regression) is running (/tmp/verify.log).
Function-value boxing landed (commit 1430e4c0) + the ABI residual
LANDED + verified server-safe (cargo test workspace, roundtrip 178/178, 09-live-counter build+run HTTP 200, 19-skyforum build):
Ctx::box_func_value+widen(lower.rs): a function value crossing into an EXPLICITanyslot (the 17 widen() sites) is boxed to a curriedfunc(any)anynest (callable; Go upcasts to any). Typed HOF twins pass callbacks un-widened.narrow_callFunc arm (codegen): func-typed Coerce narrows reflection-free (exact assert | boxed-func(any)any adapter | reflect fallback).sky_callfunc(any)any/func()any fast paths (rt) — SPA dispatch convergence.
Todos client under TinyGo: renders + routes + filters + persists to backend.
THE RESIDUAL — first-class-function ABI (needs a coordinated design, NOT a per-site patch):
- Boxing a func to a CURRIED
func(any)anyis right for pipelineApply (curried dispatch) but a MULTI-ARG func value applied FLAT as a Go call (decodeResponse(codec, resp)) then breaks (too many arguments, the boxed form is 1-arg). Boxing atcoerce_if_needed's Any path (tried, reverted) therefore emits invalid Go for flat multi-arg applications. - Root: the codec-applicative decode still coerces a typed
func(any)X(a codec record fieldenc : List x -> Valueloweredfunc([]any) any; a 2-argdecodeResponse) tofunc(any) any, hittingrt.Coerce→adaptFuncValue→reflect.MakeFunc(unimplemented on TinyGo). - To close it: make EVERY application of a boxed func value emit curried /
SkyCalldispatch (never a flat Go multi-arg call), OR box to a flatfunc(any,…) anyand give pipelineApply/apply-N matching fast paths. Either is a coordinated whole-program ABI change across the call-lowering path — a fresh Architecture-Consult + grill cycle, not another per-site fix.
ABI-design consult verdict (2026-08-22): RECOMMEND-STOP at checkpoint 1430e4c0
The residual is CLOSEABLE, not floor. The coherent fix is Option C (curried
boxing at coerce_if_needed's Any path @ lower.rs:2911 + curry-aware chained
application c(a)(b) of a boxed func VALUE at lower.rs:4526/4554 + curried
make_partial nests @ lower.rs:4653). It is NOT floor-touching (touches lowering
only; no §0.3-rule-5 site) and emits NO dispatch token (unlike routing through
SkyCall, which would trip the armed coerce-floor dispatch tripwire + brush the
§4.3 TEA-boundary floor).
WHY STOP NOW (not "impossible" — deliberately deferred):
- The verification leg does not exist:
examples/60-spa-todos/run.sh:34builds the client with STANDARDGOOS=js GOARCH=wasm go build(full reflect), so the residual is INERT on the shipping path and the TinyGo path is unbuilt/ungated. Can't ratchet a property nothing tests. A TinyGo build+run gate must land first. - The residual bites ONLY under TinyGo, which is not the current build path.
- Option C edits a central hot path (
coerce_if_needed) already reverted once — an N-strikes signal for a separate, scoped, gated effort, not an inline grind.
DISCIPLINED PATH FORWARD (when authorized): (a) land a TinyGo build+run gate for
the SPA client; (b) execute Option C with the exact loci above, re-blessing
coerce-floor (adapter down, dispatch stays 0, justified narrow movement);
(c) until then, codec-decode-under-TinyGo is a documented known limitation.
The app runs correctly on the standard-Go-wasm path (this is what run.sh serves).