The serving-mode JIT dropped 3 of 4 relations when host/blog-rel-kinds map/for-each'd
a function-produced list (only the first survived) — so only one relation editor
rendered live. Restore slice 1's working shape: host/blog-rel-kinds is a VALUE the
boot populates (set! in load-rel-kinds!), and both the cache loads and the list build
are UNROLLED (no iteration over the relation list). Metadata still lives on the
relation-posts. conformance 287/287.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
host/blog-in "relation" "is-a" (a reduce over ALL edges) returned a partial set on
the live store (many edges), so only one relation editor rendered. Enumerate the
relations from a fixed slug list instead — deterministic; the metadata still lives
on the relation-posts (loaded into the cache). rel-kinds maps kind-spec over the
list and drops any uncached.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
is-a/subtype-of/tagged/related are now POSTS (each is-a a new `relation` root),
owning their metadata in a :rel slot {:symmetric :label :inverse-label}. The static
host/blog-rel-kinds registry is gone: kind-spec/rel-kinds/kind-symmetric? read the
relation-posts (via an in-memory cache), and the relation list derives from
host/blog-in "relation" "is-a".
Perform-budget fixes (a durable read inside the http-listen render VM raises
VmSuspended; too many per request 500s the page):
- relation metadata is loaded into a cache at boot (host/blog-load-rel-kinds!,
like load-edges!), so kind-spec is pure on render paths;
- the initial edit page renders its pickers EMPTY (the load trigger fills each) —
only the relate/unrelate FRAGMENT server-renders candidates (with-cands flag).
Previously every edit page render did candidate-get × 4 pickers and 500'd.
host conformance 287/287 (+4 slice-2: kind-spec reads :rel, kind-symmetric? off the
post, unknown kind has no spec, rel-kinds derived from the graph). run-picker-check
3/3 (edit page boots, relate/unrelate flow works, no client errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Types declare which relation they anchor (type declares is-a/subtype-of, tag
declares tagged) via a 'declares' edge; the picker's candidate set is the
down-closure of a relation's anchors through is-a ∪ subtype-of. So is-a/subtype-of
now offer the WHOLE type closure — the roots (type/tag/article) AND instances —
fixing the wrinkle where only instances showed and you could never pick 'tag' or
'article' as a type. 'related' has no anchor → every post.
Replaces the hardcoded :candidates "types"/"tags"/"all" with graph queries
(host/blog--reach-down + the declares edges). Design + roadmap (relations as
first-class posts, typed relations, type algebra, constraints) in
plans/relations-as-posts.md.
host conformance 283/283 (+5: is-a pool includes type roots, excludes plain posts,
tagged anchored by tag, related = all, is-a relate-options offers Article).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a top nav with a boosted Home link, inside the [sx-boost] wrapper but outside
#content, so it SPA-navigates to / and survives every content swap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Relating/removing re-renders the kind's editor (outerHTML); the swapped-in picker's
results <ul> was empty and only filled after its 'load' fetch, so the candidate list
briefly emptied (a visible flash). Render the first page of candidates INTO the
results <ul> server-side (host/blog--relation-editor builds it inline via cons, the
same splice pattern the current-relations list uses), so the re-rendered picker
arrives already populated; the 'load' trigger then re-fetches the same page and
morphs it in place — invisible. No empty state, no flash.
Rendered inline rather than via the ~relate-picker component because component args
are evaluated, so pre-built candidate li-trees can't be spliced through one (they'd
be applied as calls). The component is left in place but unused.
Server-side only — the client engine (orchestration.sxbc, last commit's re-bind fix)
is unchanged. host conformance 278/278 (new: editor server-renders candidates), web
engine suite 8/8, run-picker-check 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two reported bugs on the edit page's relation editor:
1. relating a candidate didn't add it to the current-relations list (the AJAX
relate just deleted the candidate row; the relation only showed after a reload);
2. removing a relation could blank the relate picker.
Fix (lib/host/blog.sx): both the candidate's relate form and a current relation's
remove form now target #rel-editor-<kind> with sx-swap=outerHTML, and the
relate/unrelate handlers return the re-rendered editor for that kind (current list +
a fresh picker). So one swap keeps BOTH lists in sync: the related post moves into
the current list and out of the (re-loaded) candidate pool; removing moves it back.
Gated on the SX-Target header, so a plain boosted form / no-JS POST (the is-a-tag
toggle) still redirects + re-renders #content.
Engine fix (web/orchestration.sx): handle-html-response's non-select branch called
post-swap on the OLD target, which an outerHTML swap has already REPLACED — so the
swapped-in content's triggers (here the re-rendered picker's "load") never bound and
the picker stayed empty. post-swap the swap result (the new node), mirroring the
sx-select branch. Recompiled orchestration.sxbc for the content-addressed client.
Tests:
- web/tests/test-relate-picker.sx: relating re-syncs the editor (post in current
list + picker re-loads); removing does likewise — both fail without the engine fix.
- lib/host/tests/blog.sx: relate/unrelate return the re-rendered editor fragment
(200, #rel-editor + picker), forms wire to #rel-editor-KIND/outerHTML, plain
boosted POST still 303.
- relate-picker.spec.js: the full in-page flow (relate adds to list, remove keeps
the picker, no reload) + persistence.
Verified: host conformance 277/277, web engine suite 8/8, run-picker-check 3/3,
run-spa-check 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug: the edit page's remove button (on a current relation) was a plain boosted
form — POST /unrelate -> 303 redirect -> the engine re-rendered #content, and the
freshly-swapped relate picker came back EMPTY ("the list of posts to relate" was
cleared).
Fix: make the remove button an AJAX in-place delete, exactly like the relate
candidate rows — each current-relation <li> gets an id and its form carries
sx-post + sx-target=#cur-<kind>-<other> + sx-swap=delete. unrelate-submit returns
an empty 200 for that request so the engine deletes just that one row; #content is
never re-rendered, so the picker is untouched. method+action stay for no-JS.
The empty-200 is gated on the SX-Target header (sent only by the sx-post form), so
a plain boosted form / no-JS POST still redirects + re-renders — the is-a-tag
toggle and graceful degradation are unaffected.
Tests (all red before the fix):
- lib/host/playwright/relate-picker.spec.js: the remove-button test now asserts
the picker still has candidates after a removal (the reproduction).
- web/tests/test-relate-picker.sx: an SX engine test — removing a current relation
deletes just that row and leaves the sibling picker's list intact.
- lib/host/tests/blog.sx: the relation-editor renders the AJAX delete attrs;
unrelate returns empty-200 with SX-Target and 303 without.
Verified: host conformance 275/275, web engine suite 8/8, run-picker-check 2/2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
web/console-render.sx: render-to-console walks a live DOM element tree through the
engine's own dom-* accessors and prints it as terminal text — the results <ul>
becomes a bulleted list, the filter <input> a text field, the load-more sentinel a
"…" line, an .sx-error element a flagged line. It's the console platform's draw
step: the browser PAINTS the engine's tree, the harness ASSERTS it, this PRINTS it
— one tree, three bindings, the proof the engine is a general runtime not a browser
library.
Wired into the picker's SX engine tests (web/tests/test-relate-picker.sx): the load
and error tests now ALSO assert their console rendering — the same tree the engine
built drives both the DOM assertion and the terminal output, so Phase 1's suite is
the console renderer's regression suite for free. Plus a relate-picker:console suite
for the field/bullet/sentinel/error shapes. 7/7 green, no web-suite regressions.
(Class membership reads the live classList via dom-has-class?, not the static class
attribute — the engine adds .sx-error through classList.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The picker's per-behaviour browser tests are now SX engine tests
(web/tests/test-relate-picker.sx) + SX conformance (lib/host/tests/blog.sx), so
delete them from Playwright and keep only what needs a real boosted-SPA browser:
spa-check.spec.js (3): WASM kernel boots + loads modules CONTENT-ADDRESSED
(/sx/h/{hash} fetches, zero path-.sxbc fallback — new assertion) + marks
ready; a boosted nav fragment-swaps #content (raw! HTML path); back/re-boost.
relate-picker.spec.js (2): the bind-boost-form remove button; the picker
re-binds its load trigger on content brought in by a boosted SPA nav.
Net: 11 browser tests -> 5. Both ephemeral-host suites verified green
(run-spa-check.sh 3/3, run-picker-check.sh 2/2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the rest of the relate-picker's interactive behaviours from Playwright into
the SX harness, driving the real engine against the mock DOM:
- load: the form's "load" trigger populates the results on first render
- filter: a debounced "input" re-fetches and narrows the candidates
- paging: revealing the load-more sentinel pages in the next page (outerHTML
swap replaces the sentinel)
- error-retry: a dropped fetch marks .sx-error, and the next request clears it
Models two browser natives the OCaml runner lacks: observe-intersection (a
recording stub the test fires to simulate the sentinel scrolling into view) and
the synchronous-timer retry (stripped in the error test — backoff timing is a
test-engine.sx concern; here we assert the visible state).
Mock-DOM completeness (run_tests.ml): firstChild/lastChild on elements, so
children-to-fragment can drain a parsed fragment into an innerHTML/outerHTML swap
target. (Also repairs one pre-existing web test that needed firstChild.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the relate-picker's relate-delete behaviour from Playwright into an SX
harness test that drives the real engine (web/engine.sx + web/orchestration.sx)
against the OCaml runner's in-memory mock DOM. Builds the candidate row, runs
process-elements to bind the form's submit, mocks fetch-request to return the
host's empty 200, fires submit, and asserts the row is deleted in place — the
full fetch→swap→DOM-mutation loop in pure SX.
Mock-DOM completeness (run_tests.ml): NodeList.item(i) so dom-query-all can
iterate querySelectorAll results, and a DOMParser mock so the empty-body
sx-swap=delete path (handle-html-response → parseFromString) works as in a
browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The non-browser platform is a console/TUI renderer: the engine's platform ops map
to a text-node tree (harness-web's mock DOM is ~90% there), render-to-console
prints it, a raw-stdin input loop drives simulate-click/input. The same
~relate-picker runs unchanged in a terminal — browser is one platform binding,
console another, test harness a third.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Durable plan for the next step: drive the engine against the mock platform
(spec/harness.sx :fetch + web/harness-web.sx simulate-click/DOM asserts), so
fetch->swap->DOM behavior is tested without a browser — the same engine could
drive a non-browser target. Phases: PoC (relate-delete), port the rest, trim
Playwright to WASM-boot + content-addressed-load, stretch = non-browser renderer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes "the remove button does nothing — no network, no console". A plain form on
a boosted (sx-boost) page has no sx-get/sx-post, so the SPA engine boosts it and
binds submit -> execute-request. But bind-boost-form called
`(execute-request form nil nil)` — discarding the method+action it was handed —
and execute-request then asks get-verb-info for a verb, gets nil, and no-ops. So
EVERY plain boosted form silently did nothing: the related-posts "remove" button,
the editor Save button, the is-a-tag toggle.
Fix: pass the form's own method+action as the verbInfo
`(dict "method" method "url" action)`, so the request actually fires (body built
from the form fields). A latent web-engine bug surfaced by the host's edit page —
the first page with plain boosted POST forms.
Test: relate-picker.spec.js gains a remove-button case (relate, reload, click
remove, assert the relation is gone) — 7/7. WASM rebuilt (boot-helpers.sxbc).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Picking a candidate to relate it no longer does a full POST -> 303 -> reload.
The candidate <li> now carries an id and its relate form is an AJAX sx-post
(sx-target="#cand-<kind>-<other>", sx-swap="delete"): on success the engine
deletes just that one row — the item is now related, so it leaves the candidate
pool with no reload and no candidate-list refetch. host/blog-relate-submit returns
an empty 200 for an SX request (so the delete swap fires) and still 303s for a
plain POST (no-JS fallback via the form's method+action).
relate-picker.spec.js test 4 updated to assert the in-place row delete + no reload
+ the relation still persists (shows on the post page). 6/6 + conformance 272/272.
(Symmetric unrelate-in-place was prototyped but backed out: the current-links
form, bound via boot's process-elements rather than post-swap, didn't fire the
AJAX delete despite identical markup — a binding quirk to chase separately. Unrelate
keeps its plain POST -> reload for now, no regression.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The declarative picker markup is now a reusable SX component
(lib/host/sx/relate-picker.sx, defcomp ~relate-picker &key slug kind) instead of
inline markup in the editor. It is a CONTENT-ADDRESSED, CLIENT-EXPANDED component:
- Server: on a full page load render-page expands ~relate-picker server-side
(SEO / no-JS), exactly as before.
- Client: on a boosted SPA nav the edit body serialises to the compact
(~relate-picker :slug … :kind …), and the CLIENT expands it. The component
module is compiled to a content-addressed .sxbc, served immutably from
/sx/h/{hash}, and listed in the page's data-sx-manifest "boot" array so the
client eager-loads it after the web stack — registering its defcomp before any
boosted fragment references it.
Wiring:
- lib/host/sx/relate-picker.sx — the component.
- lib/host/blog.sx — editor emits (~relate-picker :slug s :kind k); the inline
form markup is gone.
- lib/host/static.sx — host/static-manifest-json emits boot:["relate-picker.sxbc"]
(the previously-empty boot array, now used as designed).
- hosts/ocaml/browser/sx-platform.js — loadWebStack eager-loads the page manifest's
boot[] modules (content-addressed) after the web stack.
- bundle.sh + compile-modules.js — copy/compile the component to .sxbc.
- serve.sh + conformance.sh — load the component module server-side.
This gives the host an app-component system: app defcomps shipped to the client by
hash, the same machinery as the kernel modules — the picker is the first, and it's
the model for publishing components externally.
Tests: conformance 272/272 (server expansion); relate-picker.spec.js 6/6 incl. the
boosted-nav populate (proves client-side component load + expansion) and the
error/retry case. WASM stack rebuilt (relate-picker.sxbc @ 6818110a).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two engine fixes in web/orchestration.sx (rebuilt into the WASM bytecode) plus the
blog CSS that surfaces them.
1. Retry on NETWORK failure, not just HTTP errors. The fetch error/catch path (the
real offline / DNS / connection-refused case) previously dispatched
sx:requestError and stopped — only a non-ok HTTP response with an empty body
ever reached handle-retry. So "no connection" never recovered. Now the catch
path calls handle-retry too, so an sx-retry element actually self-heals when the
connection returns (the cap bounds the backoff interval, not the attempt count —
it retries forever).
2. Visible failure state. On any failed/aborted fetch the engine adds an `.sx-error`
class to the element (cleared, with the retry backoff reset, on the next
success). Without it a stuck retry loop is invisible — the picker just sits
"Loading…". The blog shell ships CSS so the relate picker shows "Connection
problem — retrying…" / "offline, retrying…" on .sx-error.
Platform-wide: any sx-get/sx-post element benefits, not just the picker.
Tests: relate-picker.spec.js gains a 6th case — abort relate-options, assert
.sx-error appears, un-abort, assert it clears and the picker repopulates (proving
the retry loop is live). 6/6 browser + 272/272 conformance. WASM web stack rebuilt
(orchestration.sxbc + the static hs-* copies refreshed by the same build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three composing pieces that make the blog SPA correct and resilient.
Content-addressed module cache (lib/host/static.sx, serve.sh, blog.sx shell,
conformance.sh): index each web-stack .sxbc by the content hash in its head,
serve GET /sx/h/{hash} immutable text/sx, and emit <script data-sx-manifest>
{file->hash} so the WASM client loads modules content-addressed (localStorage +
immutable) instead of path + max-age. serve.sh builds the index at boot;
conformance.sh now loads static.sx before blog.sx (the shell calls
host/static-manifest-json).
Declarative relate picker (lib/host/blog.sx, lib/dream/form.sx): replace the
inline /relate-picker.js blob — which never ran on swapped-in content, so the
candidate list was empty after a boosted nav to /<slug>/edit — with a declarative
SX-htmx form: sx-get relate-options on "load" + debounced "input", innerHTML-swap
the results ul; infinite scroll via a server-emitted "load more" sentinel
(sx-trigger revealed, sx-swap outerHTML) that pages the rest, q preserved via a
new symmetric dr/url-encode. The engine re-binds these triggers on swapped
content, so the picker populates on full load AND boosted SPA nav. Candidate
relate forms get :sx-disable (plain POST->303->reload, their original behavior;
the engine would otherwise boost them and swap the redirect unreliably).
sx-retry "exponential:1000:30000" on the form+sentinel retries a dropped/offline
fetch forever (the cap bounds the interval, not the attempts).
SIGPIPE hardening (hosts/ocaml/bin/sx_server.ml): the native http-listen server
had no SIGPIPE handler, so a client aborting an in-flight fetch (the engine
cancels superseded requests on a debounced filter/fast nav) closed the socket
mid-write and killed the whole process (exit 141). Ignore SIGPIPE so the failed
write becomes a catchable Sys_error the per-connection handler already swallows.
Tests: host conformance 272/272; relate-picker.spec.js 5/5 incl. a boosted-nav
populate regression; spa-check 4/4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After a fragment swap, process-elements(target) -> process-boosted(target) only
boosted [sx-boost] containers that are DESCENDANTS of the swap target. But the
swap target (#content) is nested UNDER the boost wrapper (<div sx-boost="#content">
<div id="content">), so re-boosting scoped to the target found nothing — the
swapped-in links never got bound. Only the initial document-wide boot boost
worked, so: home->sub worked (home links boosted at boot), but Back restored the
home content unboosted, and the next click did a full page reload. (Post-page
links were unboosted too; Back just exposed it.)
process-boosted now ALSO boosts from the nearest [sx-boost] ANCESTOR of root
(dom-closest), so any swap target inside a boost scope gets its links rebound.
is-processed? guards keep it idempotent.
spa-check: the back-button test now clicks AGAIN after Back and asserts it's a
SPA nav (no full reload) — would have caught this. .sxbc regenerated.
Verified: spa-check 4/4 (incl. click-after-back).
dom-parse-html returned body.childNodes — a NodeList, not a Node — so the client
SX render did appendChild(NodeList) and threw "Argument 1 does not implement
interface Node", silently dropping every raw! HTML block (e.g. a post's <article>
body). It surfaced only now because the blog renders fragments client-side
(text/sx) since this session; before, fragments were server HTML so sx-render
never ran on raw!. The error is caught/non-fatal, and the spa-check suite only
asserted the footer + URL behaviour, so it passed through a dropped post body.
- dom-parse-html now returns a DocumentFragment (moves the parsed nodes in): a
real Node, appendChild-able as one unit, and queryable — which also fixes the
already-broken hs-htmx callers that did (dom-query doc ...) / (dom-first-child
doc) on what was a NodeList.
- spa-check: assert #content article is visible after a boosted nav, so a dropped
post body fails the suite (closes the test gap).
- .sxbc regenerated; bundle dom.sx synced to canonical web/lib/dom.sx.
Verified: spa-check 4/4 (incl. the new article assertion).
Every .sxbc shipped with `:bytecode (nil nil ...)` and `:arity nil`, so the WASM
kernel's vm.sx hit "VM: unknown opcode 0" on every module and fell back to .sx
source (slower, noisy console). Root cause: `raw_serialize` in the `compile-blob`
command (sx_server.ml) handles `Number` but not `Integer`, and bytecode opcodes +
arity/upvalue-count are `Integer`s — so they fell through to the `_ -> "nil"`
catch-all and serialized as nil. Same class of bug as the value_to_js Integer gap
(689dae7d). It went unnoticed because source-fallback masks it. Add the Integer
case and regenerate: the web stack now loads entirely from bytecode (0 unknown-
opcode warnings, 0 source fallbacks), boost + SPA unchanged. compiler.sx in the
bundle was also stale — re-synced to the canonical lib/compiler.sx.
Verified: native host conformance 271/271; chromium boots with 0 unknown-opcode
warnings + 0 source-fallback loads; spa-check still passes (boost 6/6, fragment
swap). Prereq for content-addressing the assets (caching real bytecode, not nil).
Boosted (SPA) requests now return the SX source of the content (serialize) with
content-type text/sx, so the engine's handle-sx-response parses + sx-renders it
client-side on the WASM OCaml kernel — instead of server-rendered HTML. Direct /
no-JS requests still get the full HTML shell (SEO + first paint).
- host/blog--page: fragment branch serializes the body tree to SX wire format
(was render-page -> HTML); full branch unchanged (HTML shell).
- host/blog--resp: new content-type-aware wrapper (text/sx for boosted, text/html
otherwise); replaced the 13 dream-html/dream-html-status call-site wrappers.
- listings built with (cons (quote ul) items) not (list (quote ul) items): the
list form nests children as one list and relied on render-to-html flattening
it; sx-render (client) treats (li ...) as a call -> 'Not callable'. cons splices
them into canonical (ul li1 li2 ...) that renders identically on both sides.
Verified: native host conformance 271/271; SX-Request returns text/sx SX source,
direct request text/html; lib/host/playwright/spa-check 4/4 (boot, boost, SX
fragment swap, back button) in chromium.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking a blog link now fragment-swaps #content with URL push + working back
button, no full reload — the SX-htmx engine driving the same OCaml kernel the
server runs. Six bugs in the source-load + boost path, found by bisecting in
chromium, all fixed:
1. Import double-apply (sx_server.ml x2, sx_browser.ml): the import suspension
handlers computed `key = library_name_key lib_spec` then called
`library_loaded_p key` — but library_loaded_p applies library_name_key
itself, so it ran sx_to_list on a string and crashed ("Expected list, got
string"). Only unloaded libs suspend, so it only bit lazy imports. Pass the
spec, not the key.
2. Unloaded-import crash (spec/evaluator.sx + sx_ref.ml library_exports): an
import of a not-yet-loaded library returned nil exports, and bind-import-set
did (keys nil) -> crash. Return an empty dict so the import is a graceful
no-op (lazy symbol resolution covers real usage).
3. value_to_js missing Integer (sx_browser.ml): integers passed to host methods
were mishandled, so dom-query-all's (host-call node-list "item" i) ignored i
and returned node 0 for every index — every element aliased the first, so
only one link ever boosted. Add the Integer -> JS number case.
4. browser-same-origin? rejected relative URLs (browser.sx x2): it only did
(starts-with? url origin), so "/alpha/" was treated as cross-origin and
should-boost-link? refused every relative link. Accept scheme-less,
non-protocol-relative URLs.
5. dom-query-in undefined (orchestration.sx x2): the swap path called a function
that exists nowhere; it's just dom-query with a container arg.
6. Lazy-deps never loaded under source fallback (sx-platform.js): lazy symbol
resolution only fires on the VM GLOBAL_GET path, but source-loaded swap
callbacks run on the CEK and raise instead of lazy-loading, so the post-swap
hs-boot-subtree!/htmx-boot-subtree! were undefined and aborted URL push.
Preload the manifest's lazy-deps.
Verified: native host conformance 271/271; lib/host/playwright/spa-check 4/4
(boot, boost, fragment swap + URL push, back button) in real chromium against an
ephemeral durable host server.
The kernel's sha2/cbor/cid/ed25519 modules were labelled 'WASM-safe' but assumed
63-bit native int. On the web targets — js_of_ocaml (32-bit int) and
wasm_of_ocaml (31-bit int) — they truncated, producing wrong digests/CIDs and a
Char.chr crash at kernel INIT (ed25519 precomputes sqrtm1 + base_point at module
load, driving the base-2^26 bignum). This is why a freshly-built browser kernel
crashed on boot while the stale committed artifact (older toolchain) still ran.
Fixes (all verified bit-identical to the 63-bit native build, conformance 271/271):
- sx_sha2: SHA-256 round words via Int32 (were native int + land 0xFFFFFFFF,
which is a no-op on 31-bit and overflows the constants); both SHA-256/512
length-encoding via Int64 shifts (native "lsr 32" is shift-mod-32 on js, which
leaked the length byte into a higher word). NIST vectors pass native/js/wasm.
- sx_cbor: write_head width selection + byte emission via Int64 (the 0x100000000
literal truncated to 0 on js, sending small ints to the 8-byte branch; and
"v lsr (8*i)" with i>=4 was shift-mod-32).
- sx_cid: base32_lower keeps acc bounded to the unconsumed low bits (it grew 8
bits/byte and overflowed). cid_from_sx now matches native<->js exactly.
- sx_ed25519: bignum mul accumulates in Int64 (26x26=52-bit products overflow);
div_small running remainder in Int64 (rem<<26 ~= 2^34). This was the boot gate
— the browser kernel now boots (SxKernel live, crypto-sha256 correct on js).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn the blog into a SPA using the SX-htmx engine (web/engine.sx) booting the
WASM OCaml kernel (same evaluator as the server) in-browser, with sx-boost
fragment-swapping every link into #content.
Server side DONE + verified:
- lib/host/static.sx: GET /static/** serves shared/static via the file-read
primitive (ctype by ext, traversal-guarded, 404 on missing). Wired into
serve.sh (module + route group). Tested: kernel JS + .wasm binary-exact.
- host/blog--page is now the SPA shell: full page = WASM boot scripts +
sx-boost=#content wrapper + #content; on SX-Request:true returns ONLY the
inner content fragment for the engine to swap. All 13 handlers thread req.
- docker-compose mounts ./shared/static.
- lib/host/playwright/spa-check.{spec.js,run-spa-check.sh}: boot/boost/swap/back.
Client side: the WASM kernel BOOTS (SxKernel object, data-sx-ready=true, web
stack loads). BLOCKER: the bundled .sxbc throw 'VM: unknown opcode 0' vs this
worktree's kernel -> .sx source fallback -> boot.sx source fails 'Expected
list, got string' -> process-boosted never binds links (boosted 0/N). Fix =
rebuild a consistent WASM bundle (recompile .sxbc against the kernel via
scripts/sx-build-all.sh); the browser wasm target isn't built here yet. See
plans/host-spa.md. Live NOT redeployed (stays on pre-SPA process).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The serving-JIT perform-in-HO-callback miscompile (map/rest/drop wrong
CALL_PRIM args → blank pages, empty picker) is now fully fixed, so the host
runs 100% serving JIT with NO jit-exclude.
sx-vm-extensions 81177d0e resolves a suspended HO-callback's IO inline
(instead of unwinding the native map/filter loop and corrupting the stack),
but ONLY when a synchronous resolver is installed (!_cek_io_resolver = Some).
The host serves via the http-listen primitive, whose handler drove durable IO
through cek_run_with_io with the resolver = None — so it hit the unwinding
path the fix doesn't cover. (The vm-ext repro installed a resolver, so it
never exercised the host's real no-resolver path.)
Fix: extract cek_run_with_io's IO resolution into resolve_io_request, and have
http-listen install _cek_io_resolver := Some (fun req _ -> resolve_io_request
req) — byte-identical resolution, so the inline path resolves durable reads
exactly as the CEK loop would.
Verified: host conformance 271/271; ephemeral durable server at 100% JIT (no
exclude) zero fallbacks + real content + related shown + picker 12 candidates;
live blog.rose-ash.com home/post/tags 200 with related posts, zero error-log
lines; relate-picker Playwright 4/4 (infinite-scroll + filter + relate).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause (found via bin/repro_jit_resume.ml, 9 surgical cases): when a
`perform` (durable kv read) fires inside a native HO-primitive callback
(map/filter/reduce/for-each/some/every?), the VmSuspended unwound through
the primitive's native OCaml loop (List.map etc.), destroying the loop's
iteration state. The remaining elements were dropped and the stack left
misaligned, so the NEXT CALL_PRIM (map/rest/drop) read wrong args —
"map: expected (fn list)", "rest: 1 list arg", "drop: list and number".
Only triggers in the http-listen + cek_run_with_io serving path (epoch
eval has no synchronous resolver, so conformance was 271/271).
(A) lib/sx_vm.ml call_closure_reuse: when a callback suspends AND a
synchronous IO resolver is installed (serving mode), resolve the
callback's IO inline and run it to completion right there, returning its
value to the native loop — so the loop is never unwound. Scoped to the
resolver-set path; the CEK-driven path (flow/reactive/async tests) keeps
its existing reuse_stack behaviour, so nothing else changes. reuse_stack
is isolated across the nested resume.
(A') lib/sx_vm.ml resume_vm: re-assert _active_vm := Some vm for the
duration of the resumed run (mirrors call_closure). call_closure restored
_active_vm to the caller when VmSuspended unwound, so HO callbacks during
a resume could land on the wrong VM. Latent-bug fix.
(B) bin/sx_server.ml register_jit_hook: the resolve_loop runs inside the
VmSuspended handler, so a non-VmSuspended exception from resume_vm escaped
to the http handler (→ 500). Catch it and fall back to CEK for THIS call
(mark jit_failed, return None → interpreter re-runs it). Self-heals on the
first hit, not a retry. Defense-in-depth; with (A) it shouldn't trigger.
Verification: repro 9/9 (incl. host shape: map[cb→interpreted-helper
perform]→drop = (7 8); reduce; nested map). Standard + --full OCaml
conformance unchanged at 4834/1110 (baseline identical — the 1110 are
pre-existing environmental: host-call-fn/browser-platform symbols,
rational display, tw/regex). Host loop to re-verify 271/271 serving and
drop its (jit-exclude! "host/*" "dream-*" "dr/*") band-aid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 100%-JIT experiment surfaced something worse than the 500s: the kernel
miscompile also returns WRONG RESULTS with no error — blank pages (render map
yields empty) and an empty relate picker (drop in relate-options yields []).
Conformance (CEK) passes these, so the code is correct; the JIT silently
produces garbage. Silent corruption is worse than a crash, so the request path
runs on CEK again (IO-bound — no perf loss). Datalog/relations JIT stays on
(/tags 0.16s). Restoring it brought back content + the 17-candidate picker.
Go 100% JIT again once sx-vm-extensions fixes the OP_PERFORM-resume bug.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per dev intent: don't mask JIT errors. Remove (jit-exclude! "host/*" "dream-*"
"dr/*") so ALL request-path SX runs under JIT. Host handlers miscompile on first
call in the http-listen path (map/rest arg bug → 500, self-heals on retry); that
surfacing is the point — it exercises the JIT against real durable-IO traffic and
gives the sx-vm-extensions loop the full miscompile list to fix (kernel bugs A/B
in plans/HANDOFF-jit-miscompile.md). Datalog JIT win stays (/tags fast).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Datalog/relations saturation is CPU-bound and JITs cleanly: host conformance
271/271 under JIT, 5.4x faster (1m43s -> 19s, same binary); live /tags 2.5s ->
0.76s. loops/host now carries the merged sx-vm-extensions kernel (the JIT engine
+ gate), built into the binary the container bind-mounts.
- docker-compose: SX_SERVING_JIT=1 (default-OFF gate; opt-in here).
- serve.sh: when JIT is on, (jit-exclude! "host/*" "dream-*" "dr/*"). The host app
+ Dream framework MISCOMPILE on first call in the http-listen + cek_run_with_io
path (map/rest emit wrong CALL_PRIM args -> 500; the JIT->CEK fallback marks the
fn failed but does NOT recover the failed call). They're IO-bound, so CEK is no
slower — but the miscompile is a real kernel-JIT bug to fix upstream (see
plans/HANDOFF-jit-miscompile.md), after which this exclude can be dropped.
Verified live: cold pages 200 (no first-hit 500), relate picker lists candidates,
relate round-trip works, /tags fast, datalog still JITs (78 dl-* compiles).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Carry the sx-vm-extensions loop's serving-JIT handoff notes, and add a
correction: the post-page slowness was the durable read count (fixed in
0a2f1a61), not the (long-gone) Smalltalk render path — so SX_SERVING_JIT is an
optional general speedup, not the perf blocker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 6 — gradual schema validation made real:
- host/blog-type-schemas now carries a declarative schema (a list of
{:block :msg} required-element rules); "article" requires an h1.
- host/blog--all-tags / --schema-issues / host/blog-type-issues walk the parsed
content and report each missing required block; host/blog-type-valid? = no
issues. A type with no schema imposes nothing (gradual).
- seed an "article" type-post (article subtype-of type). edit-submit now lists
the specific schema issues on a 400 ("an article needs a heading"), so a post
that is-a article must satisfy it on save.
Post-page performance (the unresponsiveness): a post page was ~1s even with no
relations and no load — NOT CPU (render-page ~2ms, in-memory handler ~5ms) but
the DURABLE read path: host/blog--relation-blocks called host/blog-out/in, each
re-scanning the whole KV (host/blog-slugs + an all-edges scan), so a page did ~7
kv-keys performs deep in the call stack. Each durable perform routes through
cek_run_with_io and is costly there. Fixes:
- host/blog-out/in read DIRECT edges from the durable edge store (string scan),
not lib/relations (whose queries re-saturate the Datalog ruleset, ~seconds).
- host/blog--relation-blocks reads the KV key list ONCE and derives both the post
set and the edges in memory (host/blog--edges-for / --recs-slugs), one kv-keys
plus a host/blog-get per linked post. Post pages: ~1s -> ~0.02s (46x); live
11-135s -> ~0.15s. lib/relations stays for TRANSITIVE queries only.
- conformance timeout 300 -> 600s: the relations-heavy blog suite is CPU-bound
under shared-box contention and was tripping a false truncation at 300.
271/271 (blog 100). Verified live: post pages fast, Tags/Related/Tagged-with-this
render, schema rejection works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the hard-coded related/tagged blocks with iteration over the registry,
so adding a kind renders automatically — no handler edit.
- host/blog--relation-blocks: iterates host/blog-rel-kinds; each kind contributes
its outgoing block (label) and, if it has an inverse, its incoming block
(inverse-label, e.g. tagged -> "Tagged with this", is-a -> "Instances"). Empty
blocks dropped; one kv-keys read up front, relation lookups in-memory.
host/blog--relations-or-hint adds the logged-in "add some" hint when empty.
- host/blog--relation-editors: one editor per registry kind on the edit page
(Related / Types / Subtype of / Tags), replacing the hard-coded two.
- GET /tags: index of every tag (a post that is-a tag), each linking its own page.
- dropped host/blog--related-block / --kind-block / --tagged-with-block (folded
into host/blog--edges-block + the registry iteration).
- GOTCHA (4th time): host/blog-tags-index called host/blog-get INSIDE the item
quasiquote -> VmSuspended/500 live (conformance in-memory store can't see it);
pre-fetch records before the quasiquote.
5 tests (relations-section hint, registry render of Related+Tags, inverse block
for a tag, /tags lists + 200). 265/265; Playwright 4/4. Verified live: /tags,
post pages show registry blocks, tag page shows Types + Tagged-with-this, edit
page has a picker per kind.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A tag is just a post that is-a tag; tagging is a "tagged" edge to it. End to end:
mark a post a tag, tag posts with it, see a post's tags and a tag's members.
- helpers: host/blog-is-tag? (= is-a? slug "tag"), host/blog-tags (out tagged),
host/blog-tagged-with (in tagged), host/blog-instances-of (a type's members,
O(#subtypes) not O(#posts) — the efficient candidate source).
- picker generalised to be KIND-AWARE and MULTI-INSTANCE: relate-options takes
&kind=, candidates come from the kind's registry :candidates (all/tags/types);
/relate-picker.js wires every .relate-picker box by data-kind (a Related picker
and a Tags picker now coexist on the edit page).
- render: post page gains a "Tags" block; a tag post additionally lists "Tagged
with this" (its members). edit page: a Related editor + a Tags editor + an
"is this post a tag" toggle (reuses /relate kind=is-a — no new route).
- GOTCHA (again): host/blog--relation-editor read host/blog-out INSIDE its
quasiquote -> VmSuspended/500 under http-listen + durable edges; moved the read
to a let before the quasiquote (conformance can't see it — in-memory store;
the ephemeral Playwright run caught it).
6 conformance tests (is-tag?, instances-of, tag+tagged-with, tagged picker offers
only tags, related picker still all, is-a-tag toggle) -> 261/261. Playwright
multi-picker 4/4. Verified live: ocaml made a tag, welcome tagged ocaml, Tags
block + Tagged-with-this both render.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The spine: types ARE posts, and typing is transitive the right way. is-a
(instance-of) does NOT chain on its own, but subsumption does — an instance of a
subtype is an instance of the supertype.
- registry gains "subtype-of" (directed, transitive). host/blog-types-of(slug) =
declared is-a targets PLUS every subtype-of-ancestor of each (composed host-side
over relations/descendants — no new Datalog rules). host/blog-is-a?(slug,type)
is transitive through subtype-of.
- host/blog-seed-types! seeds the root type-posts "type" and "tag" (real posts
that document themselves) with tag subtype-of type, so anything is-a tag is
transitively a type. Idempotent; wired into serve.sh.
- gradual-validation seam: host/blog-type-schemas (empty) + host/blog-schema-of +
host/blog-type-valid? (vacuously true with no schemas) wired into edit-submit
alongside the parse check — enforcement is a one-line add later, not a retrofit.
6 tests: types-of = declared + all subtype-of supertypes; is-a? transitive
through subtype-of; is-a alone does NOT chain; instance of tag is transitively a
type; type-valid vacuous with no schemas. 255/255.
Verified live: /type/ + /tag/ render as posts, tag subtype-of type survived a
recreate (durable), ocaml is-a tag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lone js opt-in-JIT residual was async/await_in_loop, which failed to PARSE
under JIT ("Unexpected token: op '<'" on `i < 5`) while passing on CEK. The js
exclusion was "js-*", but the recursive-descent parser is the jp-* namespace
(75 functions in lib/js/parser.sx) — only the lexer/transpile/runtime are js-*.
So the parser was left JIT-eligible and a jp-* function miscompiled this
construct (the long-standing parser-miscompile class).
Fix: extend the js exclusion to "js-* jp-*" so the parser is interpret-only too,
matching how every other guest's front-end is handled. js conformance under
SX_SERVING_JIT=1 is now 148/148, == CEK.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sessions were in-memory, so a restart logged everyone out (same class as the
relation wipe). Move them to the durable store, but LAZILY so anonymous/crawler
traffic doesn't spam it: session/create mints a sid with no row; the row appears
on the first session/set (a login). A per-boot epoch (one durable write at
startup, host/session-init!) keeps sids unique across restarts without a write
per request.
- lib/host/session.sx: lazy backend (create = no row, set = create row,
exists = row written) + epoch/in-memory-counter sid generation.
- serve.sh: point the session store at the durable backend + host/session-init!.
- blog.sx: host/current-principal is now a durable read, so host/auth-footer
(home + post footers) had to move OUT of the quasiquote into let bindings —
a perform during page-tree build raises VmSuspended (the whole site 500'd for
a beat). Principal computed once per page.
- 2 session tests: create writes no row, set creates the row.
249/249. Verified live: site renders (anon + authed), login + footer survive a
container force-recreate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 6 common-lisp opt-in-JIT failures were all condition-system continuation
escape: cl-restart-case/cl-handler-case/cl-handler-bind wrap their body in
call/cc (restarts + non-local handler exit). When an SX function that drives
the condition system (the parse-recover / interactive-debugger fixtures, e.g.
parse-numbers, make-policy-debugger) is JIT-compiled, the call/cc form runs in
a NESTED cek-run where invoking the captured continuation
runs-to-completion-and-returns instead of escaping — so a restart fails to
abort and the body falls through. Observed as result accumulation
(got (1 3 0 3) vs (1 3)) and no-abort (restart returns the 999 sentinel).
These callers are arbitrary user/fixture code, not a fixed namespace, so they
can't be prefix-excluded. New data-driven mechanism:
- jit-exclude-callers-of! registers call/cc-establishing form names in
Sx_types.jit_excluded_caller_names.
- jit_compile_lambda skips any function whose constant pool (recursively,
incl. nested closures) references a registered name — code_refs_escaping_caller.
Guarded by Hashtbl.length > 0 so it's a no-op for every guest that doesn't
register (zero effect outside CL).
- lib/common-lisp/runtime.sx registers the establish side (cl-restart-case,
cl-handler-case, cl-handler-bind) and the invoke side (cl-invoke-restart,
cl-invoke-debugger, cl-signal, cl-error-with-debugger).
Result: CL conformance under SX_SERVING_JIT=1 = 487/0, EXACTLY matching the CEK
baseline (was 484/6 with a +3 double-execution over-count). parse-recover
3/4 -> 6/0, interactive-debugger 7/2 -> 7/0.
Note: the geometry/mop-trace suites report 0/0 on BOTH CEK and JIT — they error
"Undefined symbol: refl-class-chain-depth-with" (the CLOS suites don't preload
lib/guest/reflective/class-chain.sx). Pre-existing conformance-harness gap, not
a JIT issue; left as-is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>