Notable user-visible changes. Keep this file additive — never rewrite history.
Release-notes source. When a version is tagged, its section here is copied into the GitHub Release body, and
sky upgradeprints those notes for every version between the user's current binary and the one they upgrade to (sky upgrade --notespreviews without upgrading). To make a release surface a ⚠ breaking-change / migration banner on upgrade, give the relevant subsection a heading containing the word "Breaking" or "Migration" (e.g.### ⚠ Breaking changes,### Migration). Keep migration steps concrete and copy-pasteable — this is the text a user sees the moment they upgrade.
No compiler, stdlib or runtime change. Every entry here is CI enforcement, and
it is tagged so the line has a clean marker rather than because a user gains a
feature: sky upgrade from v0.20.1 changes nothing you can observe in a build.
Recorded because the omissions were real, and each is now something that goes RED rather than something someone remembers.
- The editor-parity corpus could shrink for a whole cycle.
LSP_EXPECTEDpins it at 49 cases, but that assertion lived in a harness body that only--tier t1executes — a RELEASE invocation. Per-push CI callsxtask lspdirectly and checked only that the cases which RAN passed, so deleting cases stayed green until tag day. Now enforced per-push; zero cases and a missing report are failures too. - A killed build reported itself as a rejected program. The corpus runner
kept only a boolean status and discarded the exit code and signal, so four
go builds killed on a constrained runner read as a codegen regression in therecord_updatefamily and took three CI rounds to tell apart. It now names the signal and says NO DIAGNOSTIC when the build emitted no error of its own. - The required-check fan-in could only see one workflow file. A second push-triggered workflow with a failing job would not have blocked a merge.
- An ordinary
cargo testrewrote a tracked proof ledger. The falsifier proof timestamp refreshed on any test run, so it measured when someone last ran the tests rather than when the mutation was last proven — andgit statusafter testing was never clean.SKY_PROOF_LEDGERredirects it; the suite now leaves the tree clean. - A reject witness was red for two reasons.
ambiguous_type_name.skycalled a name that does not exist, so it carried an unknown-name defect alongside the ambiguous-type defect it exists to pin. The declared-codes-subset rule kept it green either way.
-
elinsideparagraphemitted a<div>, and the browser hoisted it out of the<p>. The highlight-a-phrase patternparagraph's own docstring recommends —Ui.paragraph [] [ Ui.el [ Font.bold ] (Ui.text "Sky.Live"), Ui.text " — web UI" ]— produced flow content inside a paragraph, which the HTML parser closes the paragraph before. So the rendered document was a paragraph, then a sibling block, then a bare text run outside any paragraph: the highlighted label broke onto its own line and the text after it lost the paragraph's formatting. Visible on any bullet list built this way.Two independent faults produced it, and fixing either alone still rendered wrong: the TAG (a generic node fell back to
divregardless of context) and the DISPLAY (flexinside a text run). Inside a paragraph,elis now a<span>withdisplay: inline, androw/columnbecomeinline-flex, so a highlighted phrase wraps with its sentence instead of splitting it. Nothing outside a paragraph changes — five accept-parity assertions pin that, because keying on parent context is exactly how a fix like this flattens every layout in every app.The regression test asserts on the EMITTED MARKUP, because nothing else could tell the two renderings apart: the broken version compiled, type-checked, ran, and passed every existing gate.
- Blockquotes and images render.
> quoteconsumes its marker and renders with a left rule (typedBorderattributes, so it follows the theme);renders a real<img>. Both were previously "deliberately not supported in v1", with the marker or a literal!left in the text. An image'ssrcgoes through the same URL guard as a link'shref, sobecomesabout:blankanddata:image/…still works.
sky check/sky buildno longer compile a runtime file the compiler deleted. The build materialisedruntime-go/rtintosky-out/rtand never pruned it, so upgrading across a release that REMOVES a runtime source left the old file behind andgo buildfailed on helpers that were correctly gone — an error naming a file you never wrote, surviving every rebuild until you thought to wipesky-out/. Nothing caught it because the examples are built from a wiped slate, and the wipe is what hides this class.
Not user-visible, recorded because the mandate for this cycle was that a gate which cannot fail is worse than no gate.
- The harness T2 tier ran in no workflow at all — 383 behavioural
assertions, including the
Dictkey-type × access-shape crossing built after #174'sDict.foldlpanic reached a release. It now runs nightly. Per-push was tried first and reverted: it costs 25+ minutes on a runner against a 900s per-push ceiling, and raising that ceiling to fit it would be the budget drift the ceiling exists to catch. - Three release-only gates were red on
mainwhile per-push CI was green — the conformance census, the coverage denominator ratchet, and the coverage ledger. All three now pass, and the denominator's shrink is accounted for by name rather than re-baselined. - The kernel-signature surface went 93 → 69 unsigned members across the v0.20 line, and the remainder now carries a dated review (2027-02-12) enforced by a test, plus a test pinning the count so the hand-maintained number cannot drift again.
- A missing Neovim reported a green editor-parity gate in CI. It now fails there and still skips locally.
Std.Markdownrenders blockquotes and images.> quoteconsumes its marker and renders with a left rule (typedBorderattributes, so it follows the theme);renders a real<img>. Both were previously "deliberately not supported in v1", with the marker or a literal!left in the text. An image'ssrcgoes through the same URL guard as a link'shref, sobecomesabout:blankanddata:image/…still works.
-
Std.Markdowndocumented a hard line break it never implemented. The module anddocs/stdlib.mdboth listed "trailing-double-space →<br>" as supported. Nothing in the parser ever looked for it, so the break was silently dropped. This is the same class as the tables defect fixed below, inverted — so the unsupported set is now a machine-checked declaration with a dated expiry (rust/crates/project/tests/declared_stdlib_gaps.rs) rather than a comment, and CI goes red on its own when a date arrives. -
Std.Markdownordered lists stopped working at the eleventh item. The marker test was equality against the hard-coded prefixes"1. "…"10. ", so11. ksilently became a paragraph in the middle of a list. The digit run is now scanned, and the marker is stripped by that same scan — previously it split on the first". "anywhere in the line, so1. See fig. 2 herelost everything up tofig.. -
Std.Markdownhorizontal rules were a list of five literals.------(six dashes) rendered as a paragraph while-----was a rule, and"----"appeared in the list twice. Any run of three or more-,*or_is now a rule, which is what the docstring already claimed. -
Std.Markdownheadings did not parse inline markup.# **Bold**rendered the asterisks literally. -
List.sortandList.sortBysorted the RENDERING, not the value.List.sort [ 10, 9, 2 ]returned10, 2, 9;[ -1, -20, 3 ]came back unchanged;List FloatandList Charwere wrong the same way (aCharis a rune, and its rendered form is its decimal code point, so[ 'a', '~', 'B' ]sorted as~ B a).List Stringwas correct, which is why it survived. Both now share the ordering dispatch</>/comparealready used, so the four cannot disagree.List.sortWithwas always correct — it takes your comparator. -
Std.Markdowndid not filter link URLs, against its own promise.Markdown.render "[x](javascript:alert(1))"emitted<a href="javascript:alert(1)">verbatim, while the module documents itself as "safe to feed UNTRUSTED markdown … no bluemonday-equivalent sanitiser needed". HTML-escaping does not help: the payload contains no metacharacter. Fixed inStd.Ui, at the single site every URL-bearing attribute passes through, soUi.link,Ui.image, a formactionand a hand-writtenUi.htmlAttribute "href" …are all covered.Behaviour change: an
href/src/actionwhose scheme isjavascript:,vbscript:, ordata:other thandata:image/now renders asabout:blank. Every other URL — relative,https:,mailto:,tel:,blob:,#fragment, custom app schemes — is unchanged. -
Std.Markdowndocumented tables as unsupported while rendering them. The docstring now matches the parser, and says what the genuinely unsupported constructs do instead (a blockquote keeps its>marker; anrenders as!plus a link).
Upgrade note. Two classes of program that used to compile are now rejected, and both were accepting genuinely broken code — see ⚠ Breaking changes below. Everything else is additive.
This release is mostly about the test suite, which sounds like an internal matter and is not: overhauling it surfaced ~30 defects in the compiler, runtime, stdlib and tooling, several of which produced wrong answers or runtime panics from code that compiled clean.
-
An ambiguous unqualified name is now an error
[E1012]. Two modules that bothexposing (..)the same name, referenced without a qualifier, used to compile and silently resolve to whichever module was imported LAST — so reordering two import lines changed what your program computed. Sky now refuses to pick.Migration: qualify the reference (
Alpha.label), or narrow one import'sexposing (…)list. The compiler names both modules and both qualified forms.The implicit prelude is deliberately exempt:
Sky.Core.Prelude exposing (..)alongsideSky.Core.Math exposing (..)still compiles, becausesky initemits the prelude import unconditionally and its presence is not a choice you made. Verified across every example and six real applications: no working program changes meaning. -
An over-applied kernel-qualifier call is now an error
[E2007].Path.join "a" "b"type-checked and then failedgo buildwith a raw Go message (too many arguments in call to rt.Path_join). It now reportsjoin is declared as 1-arg, called with 2 arg(s)with source context.Migration:
Path.join [ "a", "b" ]— it takes a list of segments. -
A failing
main : Task Error ()now exits non-zero. It previously exited 0 and printed nothing, so a failed job looked like a successful one. Scripts that relied on the old exit status will now correctly see a failure. -
Emitted Go changed. Record field reads and tuple projections on named aliases no longer round-trip through runtime coercion. This is a correctness/performance improvement with no source change required; it is listed here because generated output is not byte-identical to v0.19.
-
A misspelled import could panic at run time.
import Std.NoSuchModule as Nopetype-checked, emitted Go, passedgo build, and panicked withrt.AsInt: expected numeric value, got <nil>. Unrecognised paths fell through to the Go-FFI classification, whose references resolve leniently tonil— but aStd.*/Sky.*path can never be a Go package. Now rejected[E1001]at the import, for all four shapes (call, value reference, type reference, and an import that is never used). The un-aliased spelling was always rejected; only the alias path was open. -
Std.Jobssilently degraded to an in-process memory queue on all four store-failure paths — enqueued jobs lost on restart, never shared between replicas, no error. Now a hard startup failure underENV=production, withmemorystill available as a deliberate opt-in. -
A failed job's
last_errorwas a Go struct dump ({0 Error [7 {msg <nil>}]}) — the operator's only record of why a job dead-lettered. -
Store.insertreturned rows-affected instead of the assigned id. -
Postgres was broken in three field-builder kernels which never rebound
?to$n. -
Input.checkboxnever emitted thecheckedattribute, andStd.Ui.Events.onInputhad the wrong signature. -
Live.withPortlost to the compiler-injectedsky.tomlport default.
Each of these read as configured and was wired to nothing:
[jobs]was parsed by no one, while the runtime's own error told operators to "set sky.toml[jobs]store_path" and, underENV=production, refused to start. Now parsed (store,storePath) and documented.[live] input— the runtime hardcoded"debounce"behind an// or "blur"comment. Now selectable, with an unrecognised value falling back loudly rather than being served to a client that would ignore it.[auth] session_ttland three siblings, shipped in two examples: three are not keys at all andsession_ttlistokenTtlmisspelled, so both examples advertised a 24-hour session and got the default.
Any unrecognised key in a runtime config section now produces a build warning naming the accepted keys — the parser used to drop them in silence.
sky testreported0 passed, exit 0, on a file full of tests.sky dbverbs destroyed the project's built binary.sky check ≡ sky buildis restored for the kernel-qualifier surface: 94 advertised members across 15 pseudo-modules had no Sky signature, so the arity gate self-disabled and a raw Go error could reach the user. A ratcheting census freezes the remaining 93, and a lowering backstop guarantees none of them reachesgo buildunchecked.[database] driverinsky.tomlwas decorative.- The bundled Sky Console had not compiled for ten days.
install.shacceptsINSTALL_DIR(alongsideSKY_INSTALL_DIR), creates the target directory when it does not exist, falls back to~/.local/binwhen/usr/local/binis not writable, and warns when the chosen directory is not onPATH.
Not user-visible, but it is why this release exists:
- ~23 gates that could not fail were found and fixed.
xtaskexited 0 on an unknown subcommand;grep "0 fail"matched inside"10 fail"; SKIP counted as PASS; a golden encoded the output of an example whose database call was dead code. Every gate now declares a mutation that must turn it red, and an empty mutation list fails the build. - A combinatorial corpus of 432 cases across five families — stdlib behaviour at empty/boundary/unicode/failure edges, rejection by diagnostic code with a paired accepted twin, and properties of the emitted Go.
- A coverage ledger that is generated, ratcheted, and names what is still uncovered: 127 of 141 surfaces at "asserted or better".
- CI wall-clock cut from ~2200s to under the 15-minute tier budget by parallelising and splitting, never by raising the ceiling.
Security release. Upgrade and redeploy every Sky.Live app. A session-fixation flaw let one client act as another. Rebuild with
sky upgradeand redeploy — no app code changes are required.
-
/_sky/eventaccepted a session id from the request body without binding it to the caller's session cookie (session hijack). Every Sky.Live dispatch posts asessionIdin its JSON body; the server looked the session up by that value alone.handleSSEhad always required the matching cookie —handleEventnever did. Anyone who learned another user's session id could therefore drive that session: dispatch messages into it, mutate its Model, fire its handlers, and read whatever the resulting view rendered. On a multi-tenant deployment that reaches another tenant's data, because the session carries the identity.CSRF did not protect this. Sky's CSRF check is a double-submit comparison of header to cookie and was never bound to the session id, so a request carrying the attacker's own valid CSRF pair passed the check and still drove the victim's session. Requests under
/_sky/console/…skipped CSRF entirely.The session id is not a secret in practice: while the cookie is
HttpOnly, the same value is rendered into page JavaScript and echoed in request bodies, so it is exposed to XSS, browser extensions, shared screenshots, and proxy/access logs.Fixed:
/_sky/eventnow resolves the session from the request cookie and refuses a body id that does not match it. A request with no session cookie is refused rather than falling back to the body value (a mismatch-only rule is defeated by simply omitting the cookie). The refusal is byte-identical to the existing "session not found" response, so it cannot be used to probe which session ids exist.handleSSEnow resolves through the same helper, so the cookie-name derivation has one definition and cannot drift — including for console sub-apps, which use their own cookie name and correctly reject a decoy under the default name. The session cookie is re-issued on dispatch, so a page left open past the cookie TTL keeps working instead of failing every click.
- An app served cross-origin in an iframe now fails cleanly instead of
limping.
/_sky/eventrequires the session cookie, so a deployment where the browser blocks third-party cookies — a cross-origin iframe withoutSKY_LIVE_FRAME_ANCESTORSset — now returns a session-lost reload rather than dispatching. Such a deployment was already broken: SSE could not connect and the Model was wiped on every page load. The part that appeared to work was the flaw itself. Migration: setSKY_LIVE_FRAME_ANCESTORSto the embedding origin (which also setsSameSite=None; Secureon the session cookie), or serve the app same-origin.
-
curl … | shfailed when/usr/local/bindid not exist. The installer never created its target directory, so on a fresh macOS, a slim container image or a CI runner without that path the writability test failed, the script fell through tosudo mvinto a directory that was not there, and the install died with a bare "No such file or directory". The directory is now created — plainly if possible, withsudoif the parent needs it, and falling back to~/.local/binwhen there is no writable system location and nosudorather than failing. A second defect on the same path:chmod +xran unprivileged even when themvhad neededsudo, so it failed on the root-owned binary andset -eaborted after the file had landed, leaving a non-executablesky. Both now go through the same privilege path. -
INSTALL_DIRis accepted as well asSKY_INSTALL_DIR(and the existing--dirflag);SKY_INSTALL_DIRwins if both are set. The installer also warns, with the exactexport PATH=…line, when the install directory is not on yourPATH— previously a install to~/.local/binreported success and thenskywas not found. -
The release preflight could not stamp its own success from a git worktree, so
scripts/preflight-tag.shpassed every gate and the pre-push hook still refused the tag. Inside a worktree.gitis a file, not a directory, so the literal$REPO_ROOT/.git/last-preflight-passpath named nothing. Both the script and the hook installer now resolve it viagit rev-parse --git-common-dir. -
The
sendBeaconunload flush was rejected on every CSRF-enabled app, losing debounced input on tab close.sendBeaconcannot set request headers, so the batch could not carryX-Sky-Csrfand was refused withcsrf_missing— the batch path was effectively dead for default-configured browser apps. The beacon now carries its token in the request body; the middleware accepts a body-borne token only for the Live event path, only forPOSTwithapplication/json, and only when no header was supplied, comparing it constant-time against the__sky_csrfcookie. This is the same double-submit binding, not an exemption — a cross-origin page still cannot read the cookie. (The body is deliberately kept as JSON: switching it to a CORS-safelisted content type would have let a cross-origin beacon fire with no preflight.)
docs/skylive/architecture.mdno longer advertises a session id passed "cookie or query param", nor an/_sky/sse?session=<id>form that never existed. The wire-protocol spec now states that the session cookie is the authority for/_sky/event.docs/skylive/production-resilience.mdclaimed the CSRF token wasHMAC(key, sid)— session-bound. It is not: the token is 32 random bytes with no session binding. Corrected to describe what the code actually does. A genuinely session-bound token remains future work.
-
Tuple/record pattern destructuring on an erased subject (#170, #172). A tuple pattern whose subject arrived type-erased — a callback param a higher-order function erases (
List.foldr (\x (a, b) -> …)), alet/caseover anany-typed value — emitted_t0.V0 undefined (type any has no field V0)and failedgo build. Same class for a record pattern on an erased ADT payload (case status of Loaded { x, y } -> …). The tuple and record pattern arms now read their fields reflectively (rt.TupleField/rt.Field) when the subject is erased, matching theCons/List/fst/sndarms that already did. -
Nested constructor pattern on a generic ADT payload (#172). Matching
Loaded (Just xs)whereLoaded's payload is a genericanarrowed the payload to its nominal before testing the inner pattern, closing_v.V0.Tag undefined. -
Row-polymorphic record update through
foldl/foldrdropped fields (#171). A record updated inside a fold callback (\item acc -> { acc | value = … }) was lowered to a subset struct that dropped the fields the callback didn't touch, so a fold accumulator came back with those fields zeroed. Now preserved. -
Dict k (List Record)correctness (#173). Three defects, all fixed:- an annotated
Dict k (List Record)returned an empty Dict at runtime (the typed-map narrow dropped every entry whose value needed a reflect narrow); Dict.keys/Dict.toListon aDict Int _returned the stringified keys narrowed to0([0, 0, …]) —Dict.keysnow reconstructs the typed keys likeDict.toListalready did;- a record stored in the Dict's value list dropped the fields the builder
lambda didn't access (
item.namecame back""). More broadly, a record held as an open-row subset view no longer silently loses its un-accessed fields when stored in any container and read back.
The workaround from the issue (dropping the annotation) is no longer needed.
- an annotated
- Durable session stores now bound RAM to the active working set instead of
every session held for the TTL. Previously the sqlite / postgres / redis
session stores kept the live
liveSessionpointer (~tens of KB each — Model + rendered tree + handlers) of EVERY session in an in-RAMmemCacheuntil its full TTL expired. Under sustained cookie-less traffic — crawlers and bots each minting a session — that accumulatedrate × TTLsessions in RAM, enough to OOM a small VM over hours-to-days even with little human traffic (it wedged a 1 GB instance on a 30-minute TTL, and was far worse on a 30-DAY TTL). The stores now evict an idle session's live pointer frommemCacheafter a short window (default 5 min) when it has no active SSE connection — persisting a fresh blob first, then tearing down its goroutines — and keep the blob on disk / in the external store until the full TTL, resurrecting it from disk on the next request (single-flight; the reconnecting SSE re-establishes its loops). RAM then tracks SSE-connected + recently-active sessions rather than everything-within-TTL. On-by-default; no app changes required — an abandoned tab / bot session evicts and a returning user resurrects transparently (one full re-render on wake). Sessions with a non-gob-encodable Model (the memCache-only fallback) are never evicted, so nothing is lost. The in-memory store is unaffected (no disk backing). Rebuild orsky upgradeto pick it up.
SKY_LIVE_IDLE_EVICT(andStd.Live.withIdleEvict) — the idle-evict window for the tiered session cache above. Default5m; set0/offor a value>= ttlto disable (falls back to the previous all-within-TTL behaviour). Only the durable stores (sqlite / postgres / redis) honour it.
- Sky.Live navigation now scrolls to the top of the new page, like a normal
browser navigation. Previously the runtime restored the pre-patch scroll
position on every full-body/patch cycle — correct for in-place updates, but it
meant navigating to a new page (a
sky-navlink click, a programmaticNavigate, or any page change) landed you at the old page's scroll offset, so the new page often appeared anchored mid-page or at the bottom. The runtime now distinguishes a real page navigation (the URL pathname changed → scroll to top) from an in-place update (SSE tick, same-page event, a filter change that keeps the path → leave the scroll exactly where the user had it). Rebuild orsky upgradeto pick it up; no app changes required.
- The
/_sky/consoleread APIs are no longer reachable unauthenticated behind a reverse proxy. The console auth gate (consoleAccessAllowed) previously treated any request from a loopbackRemoteAddras trusted. Behind a reverse proxy — the app on127.0.0.1, the proxy terminating TLS — every request'sRemoteAddris loopback, so the consoleoverview/logs/traces/metrics-summary/errors/analyticsendpoints (telemetry that can carry PII and secrets) were served without authentication in production, and an app-side SSRF could reach them too. The gate now authenticates the in-process console sub-app by a per-boot internal token (and still accepts the operator'sSKY_ADMIN_TOKEN), falling through to the existing cookie /SKY_CONSOLE_AUTHgate otherwise, and never trusts a source IP. No configuration change is required — rebuild orsky upgradeto pick up the fix. Anyone running a Sky.Live or Sky.Http.Server app behind a proxy withSKY_CONSOLE_AUTHset should upgrade.
A ground-up test-coverage pass across the compiler, standard library, Sky.Live, LSP, and tooling — driven by the observation that the existing gates prove "compiles + matches the oracle", not "behaves correctly at runtime". Adversarial behavioral tests (driving the real Sky-source API through the compiled binary, with boundary / malformed / platform-dependent inputs) surfaced 11 real "compiles-clean, behaves-wrong" bugs, all fixed at root cause and each now guarded by a permanent regression test. Rebuild with this version to pick up the runtime fixes; no code changes required.
Json.Decode.int/Codec.intcorrupted large integers and was platform-dependent. JSON numbers were parsed viafloat64, so any integer beyond 2^53 (a Snowflake ID, a nanosecond timestamp, a large counter) lost precision on round-trip — andmax int64decoded correctly on macOS but errored on Linux (an out-of-rangefloat64→int64conversion is implementation-defined in Go). Now parsed viajson.Number: the full int64 range round-trips losslessly on every platform.Money.allocatedropped a cent when splitting a NEGATIVE total.allocate 3of-$100.00returned[-33.33, -33.33, -33.33](sums to -99.99), violating the documented "parts sum to the input exactly" contract — a real defect for refunds / chargebacks. The residue is now distributed by sign + magnitude.Std.Time.addMonthsdropped the year when going BACKWARD across a year boundary.addMonths -1of Jan 2023 gave Dec 2023 (should be Dec 2022) — the month floored correctly but the year used truncating integer division. Fixed via a single floored total-month index.Sky.Core.Time.timeStringwas host-timezone-dependent. A function documented as "pure formatting" returned different output on different machines; now pinned to UTC like every other formatter.Sky.Core.Bytes.length/slicewere rune-based on a byte buffer.Bytes.length "世界"returned 2 (runes) instead of 6 (bytes), andsliceused rune indices — silently corrupting binary payloads. Now byte-accurate.Std.Auth.passwordStrengthpanicked on a valid password. The kernel returnedOk ()where the type promisedResult Error String, crashing on the success path. Now returns the documented"weak"/"fair"/"strong"category. (The auth-BYPASS surface — tampered-signature /alg:none/ expired / wrong-secret — was already correctly rejected; that's now covered by an adversarial suite too.)Sky.Core.Uuid.parsenever returnedJust. It returned aResultwhere the type isMaybe String, so every outcome read asNothing(a valid UUID couldn't be parsed). Fixed to returnJust/Nothing.
- Idle sessions disconnected after ~20-30 minutes ("reconnecting… refresh fixes
it"). The CSRF cookie's lifetime was keyed to the session TTL, so with
SKY_LIVE_TTLset (the documented production pattern) it expired during idle while the server session kept sliding on the SSE heartbeat — the next event POST then 403'd and the tab stranded until a manual refresh. The CSRF cookie now outlives an idle-sliding session (30-day floor). This is the root cause behind the resilience work in v0.19.4-7; the earlier passes fixed adjacent modes but missed this one. Now covered by a browser end-to-end test. - A misconfigured session store silently became in-memory. An unrecognised
storevalue (a typo, or a documented-but-unimplemented backend) fell through to the memory store — losing every session on restart, never shared across replicas. An explicitly-configured unknown store now fails loud in production (warns + memory in dev), matching the v0.19.4 fail-loud policy for known stores.
sky db migratesilently droppedUNIQUE, serialAUTOINCREMENT/BIGSERIAL, andDEFAULTconstraints thatsky db pushpreserved. The committed-migration path (the recommended production flow) rendered weaker DDL than the direct-create path — so aUNIQUEcolumn accepted duplicates on SQLite, and on Postgres a serial primary key rendered as a plainBIGINTwith no sequence, breaking every insert. Both paths now render through one shared renderer, so they cannot diverge.- Bare
Mathconstants (Math.pi,Math.e,Math.inf,Math.nan, …) failedgo buildwhen used directly. Passing one straight to aFloat -> Floatfunction, or using it in a comparison, type-checked but didn't compile (the constant lowered toany). Now lowered to its typed value.
- Firestore removed from the documented Sky.Live session-store options. It was
listed (
CLAUDE.md,sky.toml, docs) but never implemented, and is a poor fit for a session store (per-request latency + cost, and it doesn't provide the cross-instance broadcast broker Redis does). Usememory/sqlite/postgres/redis. (Firestore as an application database via the Go SDK / FFI is unaffected — that's a separate, working capability.) An explicitstore = "firestore"now fails loud rather than silently degrading. Time.timeStringandBytes.length/slicechange output for non-ASCII / large inputs (see Fixed above) — these correct clearly-documented behavior; code that depended on the buggy output should adopt the corrected semantics.
Behavioral hardening so the above class of bug is caught going forward: 12 new
adversarial stdlib conformance suites (Decimal, Money, Jwt/Auth, Encoding, Csv,
Compression, Time, Random, Math, Dict/Set, Regex, Uuid) driving the real API
through the compiled binary; CORS/BasicAuth + real-Postgres + cross-process-gob
integration tests; browser end-to-end tests for the Sky.Live idle-survival and
handler-desync-recovery paths; LSP diagnostics + sky verb (watch/doctor/doc/
profile/add) coverage; compiler codegen/lower/inference snapshot tests; a nightly
full example sweep; and xtask welltyped, a type-directed differential fuzzer that
diffs the compiler against the reference implementation on generated well-typed
programs. macOS CI now runs the behavioral conformance + golden gates too, so a
platform-dependent regression (like the int64 one) is caught on both platforms.
The final two fixes in the Sky.Live resilience pass — the two hardest silent- failure modes, each designed test-first and adversarially grilled before landing. Pure-runtime + codegen; rebuild with this version to pick them up.
Under backpressure (a slow tab, or a burst of ticker/pub-sub frames), the server could drop a frame from an over-full per-connection buffer. If the next patch's targets happened to still exist in the now-stale DOM, it applied over the wrong base with nothing detecting it — the page silently diverged from the server until the next detectable miss. The server already knows about every such drop, so it now flags the affected connection and ships an inline full-body resync on the same stream (fresh sequence number, so it supersedes any stale buffered frame). A healthy connection never triggers one; verified race-free.
Sky.Live persists a session's Model with gob. A concrete type that only ever
lived in an any-typed Model field (nil at init, set later by a Msg) was
invisible to the boot-time registration, and gob's type registry is per-process —
so after a restart the new process couldn't decode it, and the session was
silently dropped and lost. The compiler now emits a whole-binary registration of
every record and ADT type at boot, so every process (including the one that
restarts and decodes) can round-trip any any-field value. Deterministic
(byte-stable) output — no effect on any program that wasn't hitting this.
Two more runtime fixes from the resilience pass (the parts of the deep follow-up that were sound to ship; two larger items — an SSE drop-resync and compiler- emitted gob registration — remain designed for a dedicated change). Pure-runtime.
The type-registration used to persist a session's Model marked a type as
"registered" before the registration call that can fail (recovering the
panic). A failed registration was then remembered as done, so it was never
retried, every later save of that session failed, and the session silently
dropped to an in-memory-only fallback — lost on the next restart. Registration
now caches the success flag only when it actually succeeds. The per-session
"couldn't persist, using memory" fallback also increments a
sky_live_session_encode_fail_total{store} metric, so a "looks-persisted-but-
isn't" session in a durable deploy is now visible instead of a buried log line.
view(model) must be a pure function of the model — handler IDs and the SSE diff
both depend on the same model rendering to the same tree. A view that reads
Time.now / Random / Uuid.v4, or iterates a raw Go map instead of
Dict.toList, drifts the tree between renders (stale clicks, dropped patches).
Set SKY_LIVE_VIEW_DETERMINISM_CHECK=1 in dev to have the runtime render view
a second time and warn if the tree shape differs. Opt-in (never on in production,
off by default) because the second render doubles the side effects of an impure
view — move nondeterminism into update/Cmd and keep view pure.
Five more runtime fixes for hidden Sky.Live production-failure modes (the second batch after v0.19.4). All pure-runtime — rebuild with this version to pick them up.
The CSRF cookie was a session cookie (no expiry). Browsers that clear session cookies on tab-discard / sleep-wake (Safari/ITP, Chrome tab discard) dropped it while a Sky.Live SPA stayed open; the next POST regenerated a new cookie but the page still sent the old token → 403 on every click until a manual reload. The cookie is now persistent and re-issued on each request (sliding), keyed to the session TTL, so it survives those evictions.
A route whose Page constructor takes a non-String parameter (e.g.
AppDetailPage Int) panicked at request time (reflect: Call using string as type int) — compile-clean, then aborting every visit to that URL. Route
parameters are now coerced to the constructor's parameter type (int/float/bool),
and an unconvertible value (/product/abc for ProductPage Int) degrades to a
warning instead of crashing the request.
A panic in a session's update/view was recovered but logged only to stderr —
so a deterministic panic for a given Msg turned that control into a permanent
silent no-op with nothing to grep. It now emits a structured Error log (visible
in Std.Log + the console + metrics) with a correlation id and sets a
user-visible notification.
The inline /_sky/console mounts as a sub-app that ran its own store selection
and inherited the host's SKY_LIVE_STORE, opening a second pool against your
DB (SQLite writer contention; a redundant Postgres pool) — and, with v0.19.4's
fail-loud store policy, a console store-connect failure could take down the host.
Sub-app sessions are ephemeral, so a sub-app with no explicit store now uses an
in-process memory store.
In production, if the pub/sub broker is in-process (any non-Redis store with no
SKY_LIVE_BROKER_URL), the runtime now logs a one-time note that cross-replica
broadcasts (Cmd.publish, cross-instance multi-tab fan-out) won't reach other
replicas — so a multi-replica deploy doesn't silently drop them. Single-instance
deploys can ignore it.
A set of runtime fixes for a class of Sky.Live bugs that passed sky check +
go build + tests, looked healthy in production, then stranded or silently
degraded real users in ways that were very hard to debug. All pure-runtime — your
app picks them up by rebuilding with this version. No app-code or schema change.
After a deploy changed your view (or an SSE connection dropped and the DOM went
stale), a click could hit a handler ID the server's current render no longer had.
The server returned a bare 404 "handler not found" the client couldn't recover
from — it showed a "reconnecting/disconnected" banner and only a manual page
refresh brought it back. This was the most common cause of the "idle for a while,
then it's disconnected, refresh fixes it" reports.
Now the server re-renders the current view and returns it with a typed
X-Sky-Status: desync signal; the client applies it, refreshing the DOM and its
handler IDs so the next click works — self-heals in one round-trip, no manual
refresh. Session-loss is a separate X-Sky-Status: session-lost signal that
reloads deterministically (no more sniffing the response body).
Fixed — an explicitly-configured session store now fails loud instead of silently becoming in-memory
[live] store = "postgres" (or sqlite/redis) that couldn't connect at boot
used to silently fall back to an in-memory store — sessions then vanished on
every restart ("sessions randomly die"), while every health signal stayed green.
Now the runtime retries with backoff (to ride out the database-not-ready boot
race), then, if still unreachable, fails loud in production (refuses to start
so your orchestrator restarts it and you see the cause). Dev keeps a loud-warning
memory fallback so a DB-less sky run still works. Set SKY_LIVE_STORE=memory
to opt in to in-memory sessions deliberately.
The readiness endpoint returned 200 even when the session store / DB was
unreachable, so orchestrators kept routing to a broken replica. It now pings the
session store and the app DB, returning 503 when either is down.
db = Task.run (Db.connect ()) is evaluated once and cached. A first-connect
failure (a boot race, a momentary blip) used to freeze that handle to an error
for the whole process lifetime — every query failed until a manual restart. The
connection is now a self-healing pool: a boot-time failure logs a warning and the
next query reconnects transparently once the database is available.
- The
sky_sidcookie now slides: it's re-issued with a fresh lifetime on each page load, tracking the server-side TTL, so an actively-used session past the original cookie window is no longer silently logged out mid-use. - An open SSE connection now keeps its session alive (and tears the connection down when the session is evicted) — a connected-but-idle tab is no longer evicted under a live connection.
- Reads slide the TTL on Postgres/SQLite/Redis stores too (previously only writes did), matching the in-memory store.
v0.19.3 — onNavigate crash fix, cross-module case fix, conformance suite, sky db reset/drop (2026-08-01)
Two destructive dev-DB verbs, both accepting an optional [table] (default: all of
the project's declared db : Store.Project tables) and a --yes/-y to skip the
confirmation prompt:
sky db reset [table]— empties data (keeps the schema +_sky_migrationsledger, resets autoincrement). PostgresTRUNCATE … RESTART IDENTITY CASCADE; SQLiteDELETE+sqlite_sequencereset (FK-safe).sky db drop [table]— drops the declared tables plus_sky_migrations(a fresh "never ran migrate/push" state); a singledrop <table>leaves the ledger. PostgresDROP … CASCADE; SQLiteDROP(FK-safe).
Both prompt for confirmation on a TTY, refuse on a non-TTY (and in production)
without --yes, and scope to the app's declared tables — for a total wipe
(sessions/analytics/unrelated tables) use the database's own DROP SCHEMA. New
stdlib surface: Store.resetProject/dropProject/resetTable/dropTable.
A [live]/[auth]/[log]/[database] value carrying a trailing # comment
(e.g. store = "postgres" # sessions in the shared Postgres) was baked into the
emitted init() as SetSkyDefault("LIVE_STORE", "postgres\" # …") — the value
kept the closing quote and the comment, so it never matched case "postgres"
in the runtime and the setting silently fell back (e.g. sessions to the in-memory
store) on a raw-binary deploy (running the compiled sky-out/app directly,
not via sky run). The scalar parser now drops the inline comment and strips the
surrounding quotes (a # inside a quoted value is preserved), and section
headers tolerate a trailing comment. (read_sky_toml_config /
parse_toml_scalar in crates/project/src/build.rs; regression
scalar_values_strip_inline_comments_and_quotes.)
withOnNavigate was typed (String -> msg), but the runtime hands the callback
the Page value (it dispatches model.Page after each route change). A user
callback therefore lowered to func(string), and the runtime's reflect.Call
passed it a Page — panicking with reflect: Call using <Page>_V as type string on every page load (onNavigate fires on the initial mount too, so
every GET 500'd). sky check and go build both passed; the failure was
reflect-dynamic and only surfaced at runtime. The signature is now
(page -> msg) — a \_ -> Msg callback stays fully polymorphic, and
\page -> case page of … pins to your Page union. Regression:
ty test withonnavigate_page_callback.
If you wrote withOnNavigate (\path -> …) expecting a URL string, the value is
the destination Page, not the path — read the URL from the model instead.
The embedded Sky Console (mounted inside a user app) now shows a Sign out
link that clears the __Host-sky_console login cookie via a new
/_sky/console/_logout route. It appears only in embedded mode — a
standalone sky console-serve hub / aggregator has no login cookie, so it
renders no sign-out (driven by SKY_CONSOLE_LOGOUT_URL, which the embedded
mount injects and the hub does not).
orderAsc/orderDesc prepend each term, so a chained
q |> orderAsc "sort_pos" |> orderAsc "id" rendered ORDER BY id, sort_pos —
the last call became the primary sort key, silently corrupting any
multi-column sort. It surfaced as a broken image-reorder gallery: the rows
displayed in id order (random UUIDs) instead of sort_pos order, so the
up/down arrows became no-ops. orderTail now reverses the accumulated terms so
the first call is the primary key. Single-column ordering is unchanged.
A <button> with no type defaults to type="submit", so a Ui.button inside
a Ui.form submitted the form on click (firing the form's onSubmit, or
double-firing alongside the button's own onPress) — e.g. a "Cancel" action
button would SAVE the form. Ui.button now defaults to type="button"; a real
submit control overrides with Ui.htmlAttribute "type" "submit".
Log.warnWith/infoWith/errorWith/with/debugWith take a k-v list, but the
runtime only handled []any. A homogeneous Sky list (all strings — the common
case) lowers to Go []string, so every structured field was silently dropped
(logs showed just the message, no fields — in both plain and JSON output). The
attrs are now reflected into the field bag regardless of slice type.
A zero-arg top-level binding is a memoised CAF (evaluated once, cached for the
process). If it forces a fresh-value effect (Time.now/Uuid.v4/Random.*)
or a mutable-store read — even laundered through a helper (listActive = withConnList (\c -> Store.query …)) — the value freezes and never reflects later
writes/clock ticks. The compiler now warns and suggests the name () = …
function form, while suppressing the blessed memoised-handle contract
(db = Task.run (Db.connect())).
Two reflective-codec bugs the new conformance suite surfaced:
Codec.fromJsonon aCodec.enum/taggedUnionPANICKED (process abort,CoerceFailure) on a decode failure instead of returningErr— a runtime crash from well-typed code.JsonDec_failreturned a bare string rather than a properErrorADT, which theResultCoerce[Error, a]wrap onfromJson's result couldn't narrow; now returnsErrDecode, plus a defensive guard incoerceInnerso no decode path can crash the runtime.Codec.auto/autoCamel/autoWithdecoded permissively — a missing required field or a wrong-typed field silently became the zero-value default (Ok, silent data corruption). Now STRICT: errors on an absent required (non-Maybe) field, a type mismatch, a fractional number where anIntis expected, and an unknown enum value — matching the explicitobject/field/buildObjectdecoder. AMaybefield absent/null still decodes toNothing.
Every list-BUILDING op (map/filter/reverse/append/concat/range/zip/
indexedMap/take/drop) was a pure-Sky CPS loop that cons'd per element, and
immutable prepend on the []any list is O(n) — so each op was O(n²) in time
(200k elements ≈ 7.5 min). The v0.17 CPS rewrites fixed constant stack but not
time. These ops are now O(n) runtime kernels (Go append-loops, same []any
representation, constant stack) — a 1,000,000-element fold now runs in a fraction
of a second. isEmpty/length are kernel aliases too.
String.toInt " 42 " returned Nothing while String.toFloat and the typed
String_toIntT both trim — trimming silently depended on the codegen path.
String.toInt now trims, consistently.
Two modules that each declared a same-named ADT with the same variant names
(type Prim = Leaf String | Node Int in both) miscompiled every case on one
module's value: the pattern lowerer resolved the bare constructor name through a
last-writer-wins map, so case alphaValue of Leaf s -> … emitted its variant
type-assertions against the other module's variant struct. The value never
matched, so the exhaustiveness-checked case fell through to a runtime
panic (and, through the reflective Codec.taggedUnion decode path, an
interface conversion panic). Constructor construction already resolved the
correct module; only the pattern side didn't. Now every case arm asserts
against its own module's variant struct. Byte-identical output for any program
that wasn't hitting the collision.
A sky test suite layer (scripts/conformance.sh, wired into CI + the release
gate) that asserts documented stdlib semantics with ADVERSARIAL inputs — the
behavioral layer the corpus gates + differential oracle don't cover (they prove
"builds" + "matches oracle", not "behaves correctly at runtime"). It already
caught several compiles-clean-behaves-wrong bugs (Store multi-column order,
memoised-CAF stale reads, Log dropped attrs, fromJson-ADT panic, list
stack-safety, cross-module same-named ADT case) — each is now a permanent
assertion.
-
Analytics.recentEventsnow returnsList AnalyticsEvent, notList String. The built-in analytics aggregates moved ontoStd.Db.Store(see below), sorecentEventshands back typed rows instead of JSON-object strings. If you render it, read fields off the record instead of treating each item as a string:-- before (each item was a JSON string): List.map (\s -> Ui.text s) (Analytics.recentEvents 20) -- after (typed AnalyticsEvent rows — read .event / .ts / .userId): List.map (\e -> Ui.text (e.event ++ " · " ++ String.fromInt e.ts)) rows
.props/.contextremain JSON text on the record. No other analytics API changed shape.
Std.Analytics events are now queryable, aggregatable and patchable with the
same typed Std.Db.Store API as any other table — so building custom insights
(and our own internal tooling) gets the "if it compiles it works" guarantee
instead of bespoke query kernels.
Analytics.eventsStore : Store AnalyticsEvent— a Store over theanalytics_eventstable.AnalyticsEventis the stdlib envelope (typed columns:id/ts/event/userId/anonymousId) plus the open metadata bag (propsJSON — any keys yourevent/trackEventemit — and devicecontextJSON).Analytics.openStore : () -> Task Error Db— a connection to the analytics store (the console DB or the[analytics] dbPathoverride), for use witheventsStore. Query the envelope columns directly; reach forStore.selectRaw+json_extract/->>for the JSON props — same on SQLite and Postgres. The consent-gated WRITE (track) stays in the runtime; this is the read / query / update / aggregate side.- The built-in aggregates
totalEvents/uniqueUsers/eventCounts/recentEventsare now plainStd.Db.Storequeries overeventsStore(Sky, not Go kernels) — so a schema change is caught at compile time and they're a worked example of the Store API. Breaking:recentEventsnow returnsList AnalyticsEvent(typed rows — read.event/.ts/.userId) instead ofList String(JSON-object strings);.props/.contextstay JSON text. - Console recent-event stream shows the page path. A
page_viewrow in the Sky Console's Analytics tab now renders theprops.pathnext to the event name (page_view /shop/necklaces) instead of a bare, un-actionablepage_view. The path is lifted from the props JSON in Go (dialect-agnostic — same on SQLite and Postgres); any event tagged with apathprop shows it. (analyticsRecentEventsinruntime-go/rt/console_analytics.go; regressionTestAnalyticsRecentEventsPath.)
Closes the one basic single-table op Store couldn't do: a partial-column
UPDATE (SET a subset of columns and leave the rest untouched). update /
updateWhere are codec-driven and rewrite the WHOLE record — so patching one
column meant either dropping to raw SQL or a racy read-modify-write, and you
couldn't even name a column absent from the codec's read shape.
Store.setFields conn store pkValue [ ( "col", SqlValue ) … ]— PATCH by primary key.Store.updateFields conn store cond [ … ]— PATCH byCond. Only the named columns are written; column names accept the record field or the snake column; values bind asSqlValueparams (injection-safe).Store.adjust conn store cond [ ( "col", delta ) … ]— atomic relative change (SET col = col + delta), the one write whose value depends on the column's CURRENT value (counters / stock / balances) without reading first.- See
examples/55-store-partial-update.
-
Std.Analytics— signing out now un-attributes the session. WithLive.withAnalyticsIdentify, the identify resolver is the session's identity authority:Just ididentifies, butNothingwas a no-op that left the previous user id stamped on the session — so after sign-out (model.session→Nothing→ resolver returnsNothing) every subsequent auto page-view (and the persisted session blob) kept attributing events to the signed-out user. The resolver is now symmetric:Nothing/Just ""clears the user id, reverting the session to anonymous, and the cleared state persists on the next render. Signed-in and explicit-identify-only apps are unaffected. (runtime-go/rt/analytics_kernel.goanalyticsApplyIdentity; regressionTestAnalyticsApplyIdentityClearsOnSignOut.) -
sky fmt— multi-line record field values now break onto their own line, aligned. A record field whose value spanned multiple lines (a list, nested record, orcase) was left inline afterfield =, with its continuation,/]indented to a fixed depth that aligned with nothing — e.g.routes = [ route "/" Homefollowed by,items two columns in. The formatter now breaks a multi-line (or over-wide) value onto the next line, indented one step, so[/,/](and{/,/}) line up:, routes = [ route "/" Home , route "/about" AboutPage ]
Single-line values that fit stay inline. Idempotent + comment-preserving across the corpus (fmt gate); reformatted the stdlib + examples to match.
- Codegen — record field-set collision (
sky checkpassed,go buildfailed). Two record aliases that share a field-name set but differ in field types — e.g. a user'sEnvForm {key, value : String}and the newStd.Analytics.EventProp {key, value : PropValue}, whichStd.Livepulls in transitively — collided in the structural-record resolver. It keyed records by field name only, so the first-registered alias arbitrarily won and the other's function parameters were emitted with the WRONG Go struct (form.valuetyped asPropValueinstead ofstring), producing ago buildfailure thatsky checknever caught. The resolver now keeps every candidate per field-name set and selects the one whose field types match; parametric aliases (Cfg msg) are unaffected (their type-var slots stay wildcards, with the concrete arg recovered as before). This surfaced in v0.19.0 becauseStd.Analytics.EventPropwas new — any Sky.Live app with a{key, value}-ish record could hit it. Regression:examples/54-record-fieldset-collision. - Docs carried over from the v0.19.0 line: the README quick-start sample + a sharp
v0.18→v0.19
Live.appbreaking-change callout, and the raw-api-handler change (Dict String any -> Response→Request -> Task Error Response, now inroutes) documented in the migration guide.
Live.app / Tui.app / Tui.program / Cli.program no longer take a row-open
record literal. The six required fields go inside config { … } (which
produces an opaque, hover-able AppConfig), and optional fields
(head / guard / consoleAuth / analytics / status / …) attach with
withX builders in a pipe. Webview.app keeps its closed record (no optional
fields).
- Why: the row-open record was untyped — it hovered as
?and drifted from the docs. The builder makes the config a real checkable type and unifies the kernel-module docs onto one source (sky doc, LSP hover, and the type-checker now all read the module's.skyfile). New optional attributes likewithAnalyticsIdentifyare only reachable through it. - Migration (mechanical):
Full field→builder table for every app shape:
-- before main = Live.app { init = init, update = update, view = view , subscriptions = subscriptions, routes = [...], notFound = Home , head = headFor, analytics = { pageViews = True } } -- after main = Live.app (Live.config { init = init, update = update, view = view , subscriptions = subscriptions, routes = [...], notFound = Home } |> Live.withHead headFor |> Live.withAnalytics { pageViews = True })
docs/v0.19/migration-builder-cfg.md. - Raw
apiendpoints changed shape too. The old separateapicfg field is gone;api "METHOD /path" handlernow returns aRouteand lives in therouteslist next toroute. The handler signature isRequest -> Task Error Response—Requestis a typed record (.method/.path/.headers/.params/.query/.cookies/ …), and the return isTask Error Response(wrap a plainResponseinTask.succeed). Pre-v0.19Dict String any -> Responsehandlers must migrate to the record + Task shape.
Define your schema with Std.Db.Store + Std.Codec and expose
db : Store.Project; Sky generates and applies committed migration files —
no live database needed to diff. One committed file is dialect-correct on
SQLite and PostgreSQL (verified end-to-end on both).
sky db init— scaffolddb/migrations/+db/schema.json.sky db migrate --gen [name]— diff the type-derived schema against the committed snapshot and write a migration file. New required columns get a safeNOT NULL DEFAULT <zero>backfill;Maybefields become nullable; dropped/retyped columns are quarantined (never silently applied). On a TTY, gen asks whether a dropped column was renamed (rewritten to onerenameColumn, data preserved), dropped for good, or skipped, and lets you set a custom backfill default.sky db status— ✓ applied / ○ pending per committed file vs the live_sky_migrationsledger; exits non-zero while anything is pending (a ready-made deploy gate).sky db migrate— apply the committed files through the checksummed ledger, at most once each, dialect-correct for the connection.sky db seed— run your entry module'sseed : Db -> Task Error ().sky db push— the no-migration-files dev loop: sync the live DB to your types (create missing tables, add new columns).sky rungains--db-push/--db-migrate/--db-seedflags to run those steps before serving.- Self-migrating binaries.
sky buildembedsdb/migrations/into the app, so a deployedSKY_DB_OP=migrate ./appapplies them with no source tree and noskytoolchain on the host. Run it once as a deploy step; replicas booting withoutSKY_DB_OPnever migrate (safe to scale out).
Walkthrough: docs/tooling/cli.md.
Write one Codec per type, reused for JSON and dialect-safe DB
(schema + read + write) — no hand-written row mappers, no SqlValue lists.
The recommended default for record-shaped tables. See
docs/skydb/overview.md.
Std.Codec—Codec.auto blankreflection-derives a codec from a zero-value witness (scalars → columns,Maybe→ nullable, list/nested/ADT → JSON blob, nullary enum → readable name). Columns/JSON keys are snake_case by default (priceMinor→price_minor);Codec.autoCamelkeeps camelCase;Codec.autoWith [ ("col", codec) ] blankoverrides specific fields while auto-deriving the rest (aBoolstored 0/1, a custom enum format) with no full hand-written codec.toJson/fromJson/fromJsonSafe.Std.Db.Store— codec-driven CRUD. Schema builders pipe onto the store (each takes the record field or snake column; a typo fails fast):serial(auto-increment PK),unique,defaultNow/defaultText/defaultInt,touchOnUpdate(a timestamp DB-stamped on insert and auto-bumped tonow()on every update — no raw SQL),defaultWith(app-side computed default, e.g. a UUID PK),defaultBool(TRUE/FALSEon Postgres,1/0on SQLite),generated.Std.Db.Schema.toProject— bridge explicitSchema.Tabledefinitions into aStore.Projectso aSchema.Table-based app reaches the migration tooling (sky db push/sky db migrate --gen) with one line —db = Schema.toProject allTables— no rewrite into codec stores. Table name / PK /UNIQUE/NOT NULL/DEFAULT/ autoincrement carry through; secondary indexes stay oncreateSchema(which renderswithIndex).- Writes:
insert,insertMany(one multi-row INSERT for bulk/time-series),update(by PK),updateWhere(byCond),upsert(INSERT … ON CONFLICT DO UPDATE),delete,deleteWhere. - Reads:
all/findBy+ a composable, injection-safe query builder (where_/and_/or_/not_/eq…inList/orderAsc/limit/toList/toMaybe/count),sqlOf(filter by a typed value via its codec), andselectRaw codec sql params— run any SQL (JOIN /GROUP BY/ aggregate) and decode each row into a typed projection record (the sqlx split; deliberately not an ORM).
Typed product analytics for Sky apps — open payload builder with typed prop
values (Money lossless, Pii a distinct redactable type), pluggable sinks
(stderr / JSONL / custom POST), a SQLite store, and a Sky Console Analytics
tab. See examples/52-blog-analytics.
- Consent defaults to
Granted— enabling analytics captures fully andidentifyattaches the user, which is what most apps want. Privacy-conscious apps show a consent banner and downgrade withsetConsent Anonymous/Denied. Consent + identity are session-scoped. Only an explicitsetConsentis persisted as a choice — a session that merely rode the framework default follows the current default on restore, so theGranteddefault reaches sessions already stored in a DB-backed store (they don't stay stuck on a previous default), while an app's explicitAnonymous/Deniedis respected verbatim across restart / replica reshuffle. - Sky.Live auto page-views —
Live.withAnalytics { pageViews = True }; addLive.withAnalyticsIdentify (\model -> Maybe String)to attribute an already-authenticated session from the first render.
- Codegen (subset-record). A function that read only some fields of a record
parameter and returned the whole record via
Ok/Justnarrowed the constructor's type-arg to an anonymous subset struct, failinggo build.Ok/Justnow take the payload type from the argument's own type.
After a successful upgrade, Sky prints the notes for every version between the
old and new binary, flagging any release that carries a breaking-change /
migration section. sky upgrade --notes previews the notes without upgrading.
Release plan: docs/v0.17/release-plan.md.
Judge re-verdict: REFRAMED 100% ACHIEVED + VERIFIED.
-
Typed-emit wrap-target gate.
resolveWrapParams+resolveWrapParamsCtxnow gate HM-override on enclosing-scope T-var presence. Closes the wrong-typed wrap class (8 go-build errors onexamples/00-standard-libs→ 0; 131/131 runtime). Symbol-level diagnosis atdocs/v0.17/session-2026-06-28-diagnosis.md. -
rt.Coerce residual surface — documented sound. All Coerce-family sites on the canonical
examples/26-ui-showcasebenchmark enumerated across 8 safety classes with explicit soundness proofs atdocs/v0.17/rt-coerce-residual-surface.md. Zero "unknown / unsafe" remainders. Closes the rock-solid soundness claim under the reframed v0.17.0 goal. -
scopeStateRefIORef contract + audit spec. Per CLAUDE.md §0.3 criterion #3 locked wording. Compile.hs:496-595 documents the bracket-scoped (Class A) + monotonic-accumulating (Class B) write semantics;Sky.Build.ScopeStateRefAuditSpecmachine-verifies the writer counts (25 + 17) + the layering invariant. Pattern mirrorsSky.Build.AnonRecordWriterAuditSpec. -
Per-panic-class emission-time regression locks.
Sky.Build.PanicClassGateSpecadds the emission leg of the three-leg soundness stool (runtime classification atruntime-go/rt/panic_recover_test.go+ example sweep / verify-cli / WellTypedFuzzer real-world leg + this emission-time leg). 11 tests covering C1-C7 panic classes.
See docs/KNOWN_LIMITATIONS.md for the
full current-state catalog. Items closed since v0.16.x:
- Negative literal arguments (
f -1parses asf (-1)) - Multi-line function signatures (both
: Tand-> Tcontinuation) - Zero-arg call shape arity gate (
[E2007]StrictHmArityGate) Css.*keyword constants are bare values (Css.zero)Dict.toListtyped-key inference works inline AND let-boundsky checkvalidates Go interface satisfaction empirically- All list ops on constant Go stack (CPS / accumulator rewrites)
- 3-tuple literals at top-level
- Sky.Live
initreceives fullRequest - URL-driven route matches fire
NavigateMsg
- Cleanup pass. 22 v0.17 design notes + 2 v0.16.13 handoffs moved
to
docs/archive/. 39 MB of build artifacts underdocs/v0.16.x-console/parametric-cfg-repro/sky-out/deleted. docs/session-protocol.mdfolded intoCLAUDE.md§0.4 as a durable Session Methodology section (phase pattern, agent + grilling, three-leg soundness stool, N-strikes circuit-breaker, reframed-vs-literal goal handling, push discipline, context discipline).docs/KNOWN_LIMITATIONS.mdrefreshed to v0.17.0 state.
- Closed
interface conversion: Cfg_R[Msg] vs Cfg_R[any]panic in the REVERSE direction from v0.15.2. Surface: a library module (sky-editor'sEditor.view) definesview : Cfg msg -> Element msgwhose body forwardscfgto sibling polymorphic helpers (editorBody cfg,toolbar cfg onCheck,diagnostics cfg onDismissCheck). v0.15.2 closed the literal-into-typed- slot direction (Cfg_R[any]{...}→Cfg_R[Msg]); v0.15.3 closes the typed-source-into-erased-slot direction (Cfg_R[Msg]arg →Cfg_R[any]callee param at the sibling call site, plusCfg_R[T1]arg →Cfg_R[any]sibling call inside the generic body itself).- Symptom in production: clicking the Source tab in the
skydeploy file editor panicked at every render with
Cfg_R[State_Msg] vs Cfg_R[any]. - Fix mechanism (4 surgical changes, all in
Sky.Build.Compile):letBindingType— types the RHS of a zero-param let- binding from the source region or HM solver, gated oncanRouteTyped(only record literals, lambdas, and control flow get typed routing — Can.Call/Access pass through untyped so FFI return wrappers likert.AsListTdon't strip Result-Ok wrappers).Can.Accesstyped-field-access path — now also fires wheninferExprTypereturns an ambig TVar butlookupLambdaTypecarries the concreteTAlias(function param viawithScopedLambdaTypesfrom the dep-emission registration). Includes a secondary check vialookupLambdaGoStrto catch the lazy-rendering race where the Go-string registry is active but the Sky-type registry isn't yet populated.coerceArg— short-circuitsany(arg).(Foo_R[any])nominal cast when source's static Go type is the SAME parametric record alias base. Lets Go's call-site type inference pin the callee's T from the source's instantiation, which is the only correct behaviour across Go's nominal generic typing.- Param registration in dep-emission (
goStringBindings+inferredArgTys) now includes parametric record alias params, not just func-typed ones — so the call-arg short- circuit has the info to fire.
- Regression test:
test-files/v0.15-stress/src/Widget/ Form.skyis a synthetic library mirroring sky-editor'sEditor.skyshape (top-level polymorphicview cfg, sibling helpers, mixed_ -> msg+ baremsgfields, Std.Ui body, let-extracted polymorphic fields). The L1-L7 assertion inexamples/00-standard-libs-styleMain.skyfails on v0.15.2, passes on v0.15.3.
- Symptom in production: clicking the Source tab in the
skydeploy file editor panicked at every render with
Can.Def name [] bodynow consults the sameletBindingTypehelper before lowering, somain's top-level let-bindings of record literals emit asSetup_R[Msg]{...}instead of the type-erasedSetup_R[any]{...}shape that propagated the panic at downstream call sites.
- Passing a let-bound func-typed field-access (
let submit = cfg.wfSubmit in submitProbe cfg submit) to a SAME-MODULE generic helper still emitsrt.Coerce[func(P) any](submit), which fails Go's call-site inference against the callee'sfunc(P) T1slot. Workaround in user code: pass the field directly (submitProbe cfg cfg.wfSubmit). Sky-editor's actual code does NOT hit this — it passes such fields to Std.Ui kernels (Ui.onSubmit cfg.onSubmit) where the kernel's reflect-adapter handles the conversion. The syntheticsubmitProbecase is commented out in the regression test with a forward-looking note for the next iteration.
- Cabal test: 306 examples, 0 failures, 1 pending (matches v0.15.2).
- 27/27 examples build clean from wiped slate.
examples/00-standard-libsstdlib smoke test: 120/120 assertions pass.sky checkclean onexamples/{12-skyvote, 13-skyshop, 19-skyforum, 26-ui-showcase, 00-standard-libs}+ synthetic stress test + skydeploy control plane.scripts/verify-cli.sh: 13 pass / 0 fail / 1 skip.scripts/verify-all-web.sh: 10 pass / 0 fail + console-e2e green.scripts/lsp-test-nvim.sh: 17/17 LSP requests pass (hover, completion, goto-def across kernel calls, field access, let- bindings, lambda params, case patterns).- Skydeploy control plane: generated Go for
Editor_view/Editor_view__Msg_...no longer emits the panic-causingany(cfg).(Editor_Cfg_R[any])cast at sibling helper calls.
- Closed
interface conversion: Cfg_R[any] vs Cfg_R[Msg]runtime panic at every place aCan.Recordliteral sits in a typed call-arg slot whose Go target is a parametric record alias instantiation. Surfaced by skydeploy's Editor (Editor.view editorCfgat AppDetail.sky:Source tab) on every mount — Go generic types are nominal, soany(Cfg_R[any]{...}).(Cfg_R[Msg])fails at runtime even though Go's type checker accepts it.- Fix: call-arg lowering at every site (
zipWithDefault coerceArg exprToGo,coerceCallArgsAt'scoerceOne,kernelCoerceArg, bare ctor-call zip) now routesCan.Recordliterals targeting parametric record slots throughexprToGoExpectGo→lowerRecordLiteralTo, which emits the literal with the target's concrete type args directly (no nominal-type-assert wrapper). - Symmetry: the same pipeline also routes
Can.Lambdaat typedfunc(...) ...slots throughlowerTypedLambda(was already happening at some call sites; now uniform across all five). - Edge cases handled: the new arms are uniformly gated on
not (containsGenericTypeParam ty)so call sites where σ hasn't pinned the callee's TVar (Cfg_R[T1]) fall back to the legacycoerceArgpath — emittingCfg_R[T1]{...}at the caller would triggerundefined: T1since T1 names the callee's type variable, not in scope here. The existingexprToGoExpectGoarms (record-field-init, list-elem) are unchanged because they're only reached from contexts where σ is already concrete. - Stage E shipped the parametric record alias struct generation
- Stage E.2 routed the record-field-init context; v0.15.2 closes the call-arg context that Stage E missed.
- Fix: call-arg lowering at every site (
sky buildnow injects-ldflags "-X sky-app/rt.skyVersion=<compiler version>"into the underlyinggo build. Every Sky-built app's/_sky/buildinfonow reports the actual Sky version that built it instead of the default"dev". No deploy-script ceremony — a tagged Sky binary built withcabal install -ldflags="-X main.skyBuildVersion=0.15.2"propagates that string to every app it compiles.- Why: pre-v0.15.2, the
rt.skyVersionpackage-level var defaulted to"dev"and was only populated by the Sky compiler's own release CI (-X main.skyBuildVersion=...). The compiler's own version never reached the apps it built — every deployed Sky app reported"skyVersion":"dev"regardless of which tagged compiler had built it. - Migration: none. Existing apps rebuild → buildinfo flips from
"dev"to the real version on nextsky build. Deploy scripts that previously injected the ldflag manually (none in the public examples) can remove that step.
- Why: pre-v0.15.2, the
- Docs:
SKY_ADMIN_TOKENis the canonical env var for gating/_sky/metricsand/_sky/consolein production. The v0.15.0 doc refresh accidentally keptSKY_METRICS_TOKEN(a v0.14.21 legacy alias) as the recommended name inREADME.md+CLAUDE.md+templates/CLAUDE.md. Runtime behaviour unchanged — bothSKY_METRICS_TOKEN(v0.14.21) andSKY_CONSOLE_TOKEN_SECRET(v0.14.20) are still honoured byadminTokenSecret()inruntime-go/rt/subapp.go.
- Type-directed lowering throughout. Sub-expressions at lambda
bodies, record-field inits, list elements, and call args lower with
the slot's typed Go form propagated. The solver writes a per-region
type map (
globalRegionTypes);LowerCtxthreads the expected type down throughexprToGoExpectGo. Closes the long-standing parametric-record-alias bug class (every Surface 1/2/3 is now shipped). Architecture:docs/v1-rfc/type-soundness-deep-analysis.md. - Go generics on parametric record aliases.
type alias Cfg msg = { onSubmit : msg, label : String, ... }now emitstype Cfg_R[T1 any] struct { OnSubmit T1; Label string; ... }with per-instance type args (Cfg_R[Msg],Cfg_R[Int]). Callback fields keep their typed callee parameter — no morefunc(any) anyfallback at parametric-record slots. - Inline lambdas keep their typed shape at record-field slots.
{ onSubmit = \s -> Tag ("L:" ++ s), ... }againstCfg Msgnow emitsfunc(string) Msgfor the lambda, notfunc(any) any. - Cross-alias call without the alias-chain workaround. Structurally-
equal records can be passed across module boundaries without the
type alias State.FileForm = Editor.Formredirect. The redirect remains a valid idiom but is no longer required. - Same-module polymorphic call re-instantiation. Annotated
f : Cfg msg -> msgcalled withmsg=IntANDmsg=Boolin the SAME module both work — sibling references alpha-rename per call site. Previously the first call pinnedmsg. - Wildcard-
anysoundness gate.view : Model -> anyreturning a String against an expectedModel -> Html msgslot now correctly surfaces as a type error. Mid-development the v0.15 same-mod CForeign change wrongly treated wildcard-only sigs as polymorphic; the final gate requires at least one non-anyfreeVar before routing through CForeign. The pairCanonicalise.Type.freeTypeVars(collects wildcards) +Instantiate.fromAnnotation(filters them- per-occurrence fresh UF var) is documented in CLAUDE.md as load-bearing.
- TAlias type-args propagate through readback + showType +
typeStructEq. Errors like
Cfg Msg vs Cfg Intare now shown with their type args instead of the unhelpfulCfg vs Cfg. - Unify.hs App1 ↔ Alias same-name bridge. Recursive parametric
alias bodies (
type alias Tree a = { value : a, kids : List (Tree a) }) unify with externalTAliasreferences correctly. - Canonicaliser parametric-alias var substitution (Surface 1).
Sky source can now access fields on
Cfg msg-typed function parameters without dropping to structural inference.
Let bindings with parameters after multi-line caseZero-arity functions reading env vars memoised at init()exposing (Type(..))for user-module ADT constructorsimport X as Aliasleaks the alias into codegenletbindings don't support forward referencesParametric record alias bugs (Surfaces 1, 2, 3)
- 27/27 examples clean-build from a wiped slate
- 120/120 stdlib Sky.Test assertions (
examples/00-standard-libs) - 21/21 v0.15 parametric-record-alias stress test sections
- 306/306 cabal tests (0 failures, 1 pending) — including the LSP
DiagnosticsSpec"TEA with Live.app: wrong view return type surfaces as a real diagnostic" case scripts/verify-all-web.sh— 10/10 Sky.Live + Sky.Http.Server Playwright runs + console-e2escripts/verify-cli.sh— 13/13 CLI / Tui / Cli (Fyne X11 skipped)- Skydeploy clean rebuild + runtime probe (
/,/_sky/healthz,/_sky/buildinfo, console mounted)
- Background:
image url,linearGradient angle stops,gradient css(raw CSS escape). - Border:
widthEach { top, right, bottom, left },solid/dashed/dotted,shadow { offsetX, offsetY, blur, spread, color },glow blur color,innerShadow {…}(rendered with CSSinset). - Font:
italic,underline,letterSpacing em,wordSpacing em, plus weight helperssemiBold/extraBold/black. - Region (new + wired through): semantic landmarks now route to real HTML tags via the renderer —
mainContent→<main>,navigation→<nav>,footer→<footer>,aside→<aside>,heading n→<h1>..<h6>. Pluslabel text→aria-label="...",announce→aria-live="polite",announceUrgently→aria-live="assertive". Previously these helpers existed but the renderer didn't dispatch — they all rendered as<div>. - Nearby positioning:
above/below/onLeft/onRight/inFront/behind— wraps the parent withposition: relativeand the nearby element withposition: absolute+ matching offsets. Use for tooltips, popovers, dropdown menus, badges. - Input: typed wrappers for
email,username,search,currentPassword {show: Bool},newPassword {show: Bool}. Newradio/radioRow/slidercontrols (radio uses string-valuedRadioOptionto sidestep deeply-polymorphic-record HM friction).placeholdertext now actually renders as the HTMLplaceholder=attribute.LabelHiddenemitsaria-labelfor screen-reader access. - Overflow (new):
clip/clipX/clipY/scrollbars/scrollbarX/scrollbarY. Ui.htmlescape hatch: now wraps an arbitrary Std.Html VNode via the newRaw anyElement variant. Previously collapsed toText ""(placeholder).- Compiler-side:
Html.asideregistered in the kernel registry so the renderer's<aside>dispatch resolves tort.Html_aside.Html.mainwas already registered. - Limitation #14 doc clarification: the documented "use
Ui.text ""instead ofUi.none" workaround was misleading.Ui.noneworks fine when annotations use bareElement Msg(viaimport Std.Ui exposing (Element)) rather than the qualifiedUi.Element Msg. Updateddocs/skyui/overview.mdaccordingly.
- Relicensed to Apache License 2.0 (was MIT). Existing MIT releases (v0.10.0 and earlier) keep their original MIT terms; v0.10.1 onwards ships under Apache 2.0. The relicense brings:
- Patent grant from contributors (Apache 2.0 §3) — perpetual, irrevocable patent licence for what their contribution covers.
- Patent-retaliation clause — anyone initiating patent litigation against Sky users for the contribution loses their grant.
- Trademark clause (§6) — the licence does not grant rights to use the "Sky" name / trademarks.
- NOTICE file mechanism (§4(d)) — a structured way to propagate prior-art attribution through forks.
NOTICE.mdat the repo root. Same permissive philosophy as MIT (commercial use, modify, fork, sublicense all allowed). See CONTRIBUTING.md for what this means for contributors. Same week, the Std.Ui — Sky.Live polish + 4 compiler reliability fixes PR also lands.
- Per-file derivative-work attribution strengthened on the ten files in
src/Sky/adapted from elm/compiler (BSD-3-Clause, © Evan Czaplicki). Each file's header now names the upstream module + licence + copyright, andNOTICE.mdlists every adapted file with its origin and reproduces the full BSD-3-Clause licence text. This satisfies BSD-3-Clause clauses 1 + 2 (source-form + binary-form attribution). - Defensive endorsement-clause cleanup: removed promotional uses of "Elm" (and the prior promotional uses of "elm-ui") from user-facing docs / READMEs / runtime comments. Factual technical references — "Elm-compatible syntax", "matches Elm's behaviour", "Elm convention", per-file derivative-work attribution — stay because they are descriptive, not promotional.
-
Breaking —
Std.Db.*migrated fromResult Error atoTask Error a.Db.connect,Db.open,Db.exec,Db.execRaw, andDb.querynow returnTask Error a. Their runtime helpers (runtime-go/rt/db_auth.go) wrap their bodies infunc() any { ... }thunks so the actual SQL defers to the goroutine spawned byCmd.performinstead of blocking Sky.Live'supdate().- Why: DB ops can take hundreds of milliseconds, can fail meaningfully, and compose naturally with
Task.parallel/Task.andThen/Cmd.perform. Typing them as Result was a pre-Sky.Live legacy that forced every effectful pipeline to either bridge throughTask.fromResultor block the dispatcher. - Migration in this branch: every
Lib/Db.sky(08-notes-app, 12-skyvote, 17-skymon) andLib/Games.sky(16-skychess) wrapper kept its Result-shaped public API by bridging throughTask.runinternally — consumers (Main.sky, Page/*.sky) need no changes.examples/07-todo-cli/src/Main.skywas rewritten as a proper Task-chained CLI demonstrating the canonical error-propagation pattern.examples/18-job-queue/src/Main.skywas simplified to drop the now-unnecessary bridge helpers insaveSnapshot/loadHistory.examples/13-skyshopis unaffected (it uses Firestore, not Std.Db). - For new app code: prefer composing Task-returning Db calls directly (
Db.exec db "INSERT..." [...] |> Task.andThen ...) and dispatch viaCmd.perform. Use the Lib-layerTask.runbridge only when wrapping a singleton conn for synchronous case-pattern matching inside an existing update branch.
- Why: DB ops can take hundreds of milliseconds, can fail meaningfully, and compose naturally with
-
Added —
Task.onErrorandTask.mapError. Mirror their Result counterparts.Task.onError : (e -> Task e2 a) -> Task e a -> Task e2 arecovers from a Task error by producing a new Task — the canonical primitive for converting DB / FFI errors into 4xx/5xx HTTP responses, Sky.Live notifications, or CLI exit codes.Task.mapError : (e -> e2) -> Task e a -> Task e2 aadds context to an error before propagation. -
Added — kernel sigs for
File.*,Process.*,Io.*,Crypto.randomBytes,Crypto.randomToken(Bucket A2 of the audit). Type-only addition: the runtime helpers already returned Task thunks, the docs/stdlib tables already promised Task; HM now enforces what the runtime had silently delivered. Net-zero migration. -
Codegen fix —
coerceArgnow handlesSkyTaskparams. Previously, passing a value to a function expecting a typedrt.SkyTask[E, A]param emittedany(arg).(rt.SkyTask[E, A])direct assertion, which panicked at runtime againstfunc() anyfrom runtime helpers and againstSkyTask[any, any]from cross-instantiation pass-through (Go generics are nominal). Fixed by routing parametric SkyTask param targets throughrt.TaskCoerceT, mirroring the existingSkyResult/SkyMaybehandling. Also extended the same wrap to theVarLocalcall-result path. This unblocked the entire Db.* migration. -
Doctrine clarification in CLAUDE.md ("Effect Boundary: Task — two-tier in practice"). The audit considered migrating every effectful op to Task (println / Slog / Os.getenv / Os.getcwd / Time.now / Time.unixMillis) and concluded these stay sync. Reasons documented in CLAUDE.md under "Why theory ≠ practical here" —
let _ = println …discard pattern, module-levelapiKey = Os.getenv "X" |> Result.withDefault ""config reads, "stamp this row" timestamp use sites. Sky picks the Elm-pragmatic position over the Haskell-purist one: real I/O that benefits from composition goes through Task; sync convenience effects that don't benefit stay sync.
- Breaking — default HTML template no longer loads Inter from Google Fonts. The shell document emitted by
Live.apppreviously preconnected tofonts.googleapis.com/fonts.gstatic.com, fetched the Inter family, and forcedfont-family: 'Inter' … !importantonbodyand.font-sans. All four lines have been removed.- Why: third-party request on every cold page load (offline dev, GDPR, every visitor's IP logged with Google), plus an
!importantrule that fought app-level typography. There was no opt-out. - Behaviour now: the
<head>ships only<meta charset>and<meta viewport>. Headings and body inherit the browser default (Times/Arial) until the app sets typography itself. - Migration: apps that want a webfont add it explicitly — e.g. a
Html.styleNodein the view's head fragment, a self-hosted@font-facein aCss.stylesheet, or a<link>served fromServer.static. Apps that were silently relying on the default Inter will look unstyled until they set their own font. - Privacy/a11y wins: no third-party network request from the runtime, and no
!importantoverride blocking accessibility-first apps that self-host (e.g. Atkinson Hyperlegible).
- Why: third-party request on every cold page load (offline dev, GDPR, every visitor's IP logged with Google), plus an