The 'see the system you've defined' page: every type-post (with its schema's required
blocks) and every relation-post (with its signature), each linking to the post that
defines it. The surface the metamodel editor hangs off (North Star UI surface 1 of 3).
- host/blog-type-defs: the type DEFINITIONS = the subtype-of hierarchy rooted at 'type'
(type + transitive subtypes). NOT host/blog-instances-of 'type' (that's the is-a
INSTANCES — typed content, not the definitions, which are linked by subtype-of).
- host/blog-meta-index (GET /meta, mounted before /:slug): pure read, all durable reads
pre-fetched into let bindings before the quasiquote (perform-in-tree = VmSuspend);
relations from the boot-populated host/blog-rel-kinds VALUE. Types + relations tables.
- Home footer links to /meta + /tags.
Verified live (ephemeral): Types (3: Type/Tag/Article, Article shows required block h1),
Relations (4: related symmetric, is-a/subtype-of/tagged directed). Blog suite 122/122.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- conformance.sh [suite] runs ONE suite (filters the SUITES array so result-parser
indices stay aligned; all MODULES still load). 'conformance.sh sxtp' = 0.3s vs ~8min.
- lib/host/live-check.sh: non-browser live smoke — boot ephemeral host, login, seed a
post (exercises form-ingest write), print status|content-type|body-head per path,
assert reads are text/sx + no JSON leak + no 5xx. The counterpart to run-picker-check.sh.
- plans/NOTE-render-diff-for-vm-ext.md: defer host_render_diff (JIT-vs-interpreter
regression oracle) to the sx-vm-extensions loop — it's their fix's oracle, not a host
feature; building it from loops/host would fork JIT-engine understanding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
build-request-body's POST-form branch now serialises the form fields to a text/sx
body via the serialize primitive (content-type text/sx), instead of FormData ->
URLSearchParams -> urlencoded. A hydrated page posts SX; the host reads it via
host/sx-body / host/field (the server already accepts both — urlencoded stays the
no-engine / login-bootstrap fallback). Recompiled the web stack -> .sxbc.
Verified client-agnostically (no DOM, the user's preference): a new sxtp suite test
proves the wire contract serialize(engine) <-> host/sx-body(server) round-trips a
field dict losslessly, INCLUDING sx_content full of quotes/parens that would break a
naive encoder, plus host/field's content-type discrimination + urlencoded fallback
(sxtp 43/43). The DOM field-read (dom-query-all + .value) is the one irreducibly-
browser bit — left to a targeted Playwright smoke.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greenfield SX-native pivot (NOT a strangler): the host speaks SX/SXTP end to end;
JSON only at the future ActivityPub federation edge.
- OUTPUT: host/json-status -> host/sx-status — every host/ok/host/error response is
text/sx via the serialize primitive (NOT application/json). Flips feed, relations,
blog reads. Tests assert the SX envelope ({:ok true :data ...}).
- DELETE the blog JSON CRUD /posts (POST/PUT/DELETE) + bearer-based host/blog--protect:
a pure old-contract REST mirror. Create/edit go through the HTML editor forms;
programmatic writes speak SXTP. FOLLOW-UP: no browser delete route yet (was JSON-only,
no UI) — add POST /:slug/delete + cascade edge cleanup when the metamodel UI needs it.
- INPUT: host/sx-body (sxtp.sx) parses a text/sx request body to a string-keyed dict
(parse-safe + sxtp/-normalize). feed POST + relations attach/detach read it.
- UNIFIED field reader host/fields / host/field: text/sx body OR urlencoded form by
content-type. The blog form handlers (new/edit/relate/unrelate) + login read through
it — additive, urlencoded still works (no-engine / bootstrap fallback).
Conformance 290/290 (11 suites). Retires the strangler framing in the plan; adds the
'SX all the way out' wire table. The engine half (browser posts text/sx) follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every object (content/type/relation post) now carries a stable :cid = hash of its
canonical, key-sorted content. The runtime has no hash primitive, so host/blog--canon
(recursive, sorts keys -> identical across processes regardless of dict insertion order)
and a tail-recursive double-hash (host/blog--hash-go / host/blog--cid-of) are built in SX.
The slug (a name) and any prior :cid are excluded -> the CID hashes content only.
git-shaped: slug = mutable name -> CID = immutable content identity.
Single choke point host/blog--write! stamps the CID on every record write; routed all
three write sites (put!, set-schema!, seed-rel!) through it. Accessors host/blog-cid and
host/blog-by-cid (reverse lookup). +6 conformance tests (blog suite 134/134). Plan: new
'Content-addressability is universal' section (CID model, git-shape, federation: types
flow across fed-sx as shared content-addressed vocabulary; structure/behaviour trust-split).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capture the tooling that pays for itself across the remaining slices, ranked by
ROI-per-effort: (1) host_conformance(suite) per-suite fast runner — trivial bash arg,
done by hand this session; (2) host_live_check — boot ephemeral server, authed request
sequence, return rendered HTML (generalizes run-picker-check.sh; the pre-deploy check that
catches serving-JIT divergence conformance misses); (3) host_render_diff — render a route
JIT-vs-interpreter and flag divergence (the precise detector that ends the bug class;
builds on sx_render_trace; regression oracle for the jit-bytecode-correctness loop); (4)
surface deps-check/prim-check as MCP. Plus: file the sx-tree worktree write/validate bug.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capture the behaviour layer. Principle: behaviour is data-defined orchestration over a
small fixed vocabulary of effects; only the effect primitives + the interpreter stay code,
everything between is editable posts (meta-circular — Lifecycle/Transition/Rule/Effect are
themselves types). Guards are pure type-system (Datalog) queries; runs on flow-on-sx
(durable: wait-for webhook, after timer; saga compensation). 'Place order'/'ship' = attempt
transition T.
Sketches the effect vocabulary in four tiers — pure guards / data (graph mutations) /
domain (reserve-stock, book-seat) / integration (charge-card, create-shipment, notify,
federate; the code edge, kept small per artdag's S-expr effects) / control (wait-for, after,
emit, transition; flow primitives) — worked through store + events. The fork: declarative
core + guarded code escape-hatch (Scheme/Smalltalk on a post). Start by pinning the
vocabulary + a generic interpreter, and lift commerce-on-sx/events-on-sx from guest-code
into lifecycle+effect DATA (they already implement exactly this, just not editably).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The metamodel targets the entire rose-ash domain model, not just the blog — the finish
line of the host-on-sx strangler off Quart: define the domain schema as data instead of
porting each service's bespoke models. Records the three honest additions store/events
surface beyond a/b/c+d: (1) typed scalar ATTRIBUTES (datatype properties: price:Money,
stock:Int) alongside entity relations — a real addition, likely Slice 8; (2) behaviour/
lifecycle composes from the substrate loops (flow/commerce/events), not reinvented;
(3) integrations (payments/federation/media) stay referenced services. Structure+validation
from the metamodel, behaviour from substrates, integrations as services.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Name the destination: the host becomes a self-describing metamodel where you define a
domain (types + relations with signatures/algebra) and a working system falls out — the
blog is one seeded configuration. Most instance UI is already generic (relation editors
iterate the relations, pickers come from declares-anchors, validation from :schema), so
'define the types' is mostly a metamodel editor + a generic instance form + a
clear-and-reseed. Frames Slices 6-7 as the schema language this is for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capture the Relation<…> design from the discussion. The reframe: the parameters split
into two halves — the role SIGNATURE (shape of a tuple: per-role type a, arity b,
cardinality c) and the relation's ALGEBRA (behaviour: transitivity/symmetry/inverse/
sub-relations d). A relation is Relation<signature>; today's binary typed relations are
the degenerate 2-role case.
Slice 6: generalise :rel to a :roles signature; (a) per-role type = the declares-anchor
made explicit, (b) arity needs reification (instance-posts) for n-ary, (c) cardinality by
counting. Nominal variance, JIT caveat for n-ary role iteration.
Slice 7: declared algebraic properties with GENERIC closure (retires the hardcoded
is-a/subtype closure — OWL property characteristics); real inverse relations; sub-relations.
Decidable core stops here; defined-by-rule + cross-role predicates fenced behind the
predicate-language decision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A type-post carries its schema in a :schema slot (a list of {:block :msg} rules — a
refinement {x : T | x has these blocks}). host/blog-schema-of reads it off the post;
the hardcoded host/blog-type-schemas table is gone. A NEW refinement type is pure
data: give a type-post a :schema and its instances are validated on save — no code
(tested with a 'guide' type requiring a 'pre' block). article's schema is migrated
onto the article post at boot (host/blog--set-schema!, a single read+write).
host/blog-put! now MERGES over the previous record, so editing a post's
title/content doesn't nuke its :schema/:rel metadata (also closes the Slice 2
'edit drops :rel' gap). schema-of reads the post (a durable read) — only the SAVE
path calls it (a write request, never a render that would VmSuspend).
conformance 299/299 (+4: article h1 enforced from the post, a new refinement type
validates its instances, schema read off the post, edit preserves :schema).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An algebraic type is a post with operand edges: conj edges (intersection members),
disj edges (union members). host/blog-instances-of-expr computes its extent from the
operands' extents by set intersection/union, RECURSIVELY — operands can themselves be
algebraic (meta-circular; tested with (tag ∧ article) ∧ tag). host/blog-is-a-expr?
generalises is-a? to type expressions; make-and!/make-or! build them. Binary today
(nth 0/1, no fold over operands — robust on the serving JIT).
Operand edges are KV-only (host/blog--add-edge-kv!, read via host/blog-out), NOT in
lib/relations — feeding extra kinds into the Datalog graph blows up its per-query
re-saturation; load-edges! skips conj/disj on replay too.
conformance 295/295 (+4: intersection/union membership, extent = set op, nested expr).
(NB: host conformance can EXIT 124 purely from a sibling loop's CPU contention — ran
with timeout 1200.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
relate-candidates computes the available candidate SLUGS (slug-sorted, no per-candidate
read), then reads titles only for the page it returns. On the unfiltered path (q="" —
the initial picker load AND every editor server-fill, the common case) that's ~limit
durable reads instead of one-per-post, cutting the http-listen suspend/resume churn. A
filter (q≠"") still resolves titles across the pool since it matches on the title.
(A boot slug→title cache would make the filter O(1)-perform too, but it's blocked: no
bulk KV read, and a per-post host/blog-get loop at boot hits the JIT 'durable read in a
boot loop drops all-but-first' bug — see plans/relations-as-posts.md.)
conformance 291/291, run-picker-check 3/3 (incl. the title filter + paging).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A relation's declares-anchor IS its target-type constraint: is-a/subtype-of (anchored
by type) require a type object; tagged (anchored by tag) a tag; related (no anchor) any
post. host/blog--valid-object?(kind, other) = other ∈ the relation's candidate pool — the
SAME set the picker offers — and relate-submit now enforces it (invalid target = silent
no-op). The picker never offers an invalid target, so this guards crafted/API requests:
the jump from candidate set to an enforced relation schema. A new typed relation needs
only a relation-post + a '<TargetType> declares <rel>' edge.
host/blog-relate! (direct/seed) stays unvalidated — validation is a handler boundary
(the seed writes 'X is-a relation', and relation isn't under type).
conformance 291/291 (+4: valid-object? accepts types/tags/any, relate-submit creates the
edge for a type object and no-ops for a non-type).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>