The roadmap's capstone: now that two folds exist (render, execute), extract the machinery
they share. host/comp-fold (compose.sx) is the reusable core — the seq/alt/each combinator
dispatch + the `when` predicate set (host/comp--pred?) + the context-environment + the `each`
source (host/comp--source) + recursion + the depth guard, ALL in one place. A domain plugs in
via a small dict {:empty :combine :leaf :overflow}; only its leaves and how results combine
differ:
render = {:empty "" :combine str …} leaf -> markup (+ row/grid layout combinators)
execute = {:empty (list) :combine concat …} leaf -> effect
host/comp-render and host/exec-run are now one-liners over host/comp-fold with their domain.
execute.sx shed its own seq/alt/each dispatch — it's just a dict + a leaf. A THIRD domain
(eval/reduce/extent over the same algebra) is now only a new dict + leaf, no new control flow.
Both folds went through the core with ZERO behaviour change: new tests/compose.sx exercises
the core + render domain directly (17/17 — leaves, seq, row, alt+when (has/eq/not), each
(items/query/empty), tmpl recursion over a (children) tree + depth guard, ref transclude, one
object two contexts); execute 13/13; blog 162/164 (2 pre-existing relate-picker fails). Full
host conformance 388/390. Wired tests/compose.sx into conformance.
plans/composition-objects.md roadmap steps 1-8 COMPLETE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The keystone validation of the universal-algebra thesis. lib/host/execute.sx is a SECOND
interpreter over the SAME seq/alt/each composition algebra as the render-fold — but a
different fold: leaves are EFFECTS, seq = steps in order, alt+when = branch, each =
for-each, and the accumulator is an effect log instead of an HTML string. It REUSES
compose.sx's shared machinery verbatim — host/comp--pred? (when), host/comp--field
(field/value), host/comp--source (each source) — so the predicate set, context-environment,
and iteration source are domain-agnostic; only the leaf semantics + accumulator are new.
KEYSTONE (tested): ONE (alt (when (has "auth") …) …) skeleton + ONE context folds two ways
— render picks the branch → "<b>in</b>", execute picks the SAME branch → {:verb "enter"}.
A publish workflow (validate → branch-on-status → notify-each) runs as one execute-fold over
a composition object. So the behaviour model (Slice 9) is "an execute-fold over a composition
object", not a separate system — the way the recursive tree proved recursion, this proves the
algebra is domain-agnostic. host/exec-run; 13/13 (new execute suite); wired into conformance
+ serve. Full host conformance 371/373 in 42s (warm); the 2 fails are the pre-existing
relate-picker pair.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The post body is now editable as a composition. Model ops over the :body ref-seq (and the
ordered `contains` edges): host/blog-block-add! (create a card object is-a a card-type +
fields, contains edge, append a ref), -remove! (drop ref + edge), -move! (swap adjacent).
host/blog--block-editor renders a row per block — type + a content preview + ↑/↓/remove
controls + a "fields" link — plus an add-block form, injected into the edit page. Routes
POST /:slug/blocks/{add, :cslug/remove, :cslug/move} (guarded; SX-htmx sx-post + outerHTML
swap of #block-editor, redirect fallback for no-JS).
Cards-as-objects pays off: per-block FIELD editing is free — a card IS an object, so its
fields are edited via its own /<cslug>/edit page; the block editor only owns structure.
Guard fix: a card type is a SUBTYPE-OF card (not is-a), so the add validates ctype against
the down-closure of "card", not host/blog-is-a?. Verified via the warm server (162/164; the
2 fails are the pre-existing relate-picker pair). Deferred: Playwright live-swap check;
alt/each block insertion (the core editor handles the seq of refs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
STEP 5 (cards-as-objects). The importer no longer carries a Ghost body as one opaque
sx_content string: host/blog--decompose! splits an (article …) into one stored card OBJECT
per top-level block (is-a the mapped card-type + its field-values), links each by an ordered
`contains` edge, and sets the post :body = (seq (ref c0) (ref c1) …). Card types now carry a
render :template, so the new `ref` combinator (compose.sx) transcludes each card via the
SAME typed-block path articles use. /import wired to decompose; the home index filtered to
published so the "block"-status card objects stay hidden. Added the `val` leaf (raw field
value, no <span>) for attribute interpolation in templates (href/src). The post page renders
the transcluded cards — verified end-to-end (conformance 157/159; the 2 fails are the
pre-existing relate-picker pagination pair, unrelated).
PERF (the conformance-speed fix). host/blog typing — types-of / instances-of / type-defs —
computed the subtype closure via lib/relations descendants/ancestors, and EVERY such call
re-saturates the whole CEK-interpreted Datalog ruleset (~seconds each). Typing is the hottest
path (is-a?/types-of/instances-of run per post, per picker, per render), so this dominated
both the blog suite and live page latency. Now the closure is a host-side BFS over the DIRECT
subtype-of edges (the edge:* KV rows, via host/blog--subtype-closure) — one snapshot per
closure, O(edges), cycle-safe, Datalog-free. Same transitive set (KV == relations for direct
edges, host/blog-relate! writes both), so exact, not approximate. Drops Datalog out of the
typing hot path entirely — speeds conformance AND the live site (/tags etc.).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An object's `each` source can now be a GRAPH QUERY: `(query is-a TYPE)` resolves to
whatever is-a TYPE *right now* — the list isn't baked into the body, it's the live graph.
The object's `each` IS the query; the render is the run over current data (the unifying
property, now over real data).
compose.sx stays self-contained: the `query` source delegates to a resolver bound in the
render context under "query" — it asks the context for data, never reaching into the graph
itself. The host supplies graph access via host/blog--comp-query (`(query is-a TYPE)` ->
host/blog-instances-of -> full records) injected by host/blog--comp-ctx (auth + resolver);
the post handler renders :body against that context.
Added a `val` leaf — the raw field value with no markup wrapper, for use inside attributes
(href/src). `field` stays span-wrapped for display; `(val :slug)` makes a real link in the
each template. /compose-demo's each is now a live (query is-a compose-item) over two seeded
instances instead of a baked literal list.
Verified end-to-end via a focused harness eval over the full relations+persist+blog stack
(query iterates real instances; clean href via val; empty query -> empty, not an error).
Blog suite 151/153 — the 2 fails ("relate-options load-more sentinel", "related picker
offers all posts") are PRE-EXISTING (clean HEAD is 149/151 with the identical 2 fails, a
relate-picker pagination-boundary issue) and unrelated to composition; my 2 new tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The composition DAG is not a content mechanism; render is fold #1. The same structure
(content-addressed objects + ordered labelled forks + seq/par/alt/each) is interpreted by a
different fold per domain: content=render, behaviour=execute (flow-on-sx), query=eval
(Datalog), pipeline=reduce (artdag, literally a content-addressed composition DAG),
types=extent (and/or = intersection/union). "Relations just a fork" generalises: relation
kind + fold = domain. The X-on-sx loops already ARE these folds — the composition DAG is the
fleet convergence point. Payoff: build composition once, reuse per domain via interpreters;
the block editor + metamodel UI generalise to every fold (author a workflow like a document).
System collapses to four ideas: content-addressed objects + composition algebra + per-domain
folds + decidable-core predicates. Roadmap +2: prove universality with a second (execute)
fold over the same seq/alt/each; then factor out the shared compose core.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cards-as-OBJECTS model (plans/composition-objects.md): an object's :body is a tiny UI
language over content-addressed object refs; the render-fold is its interpreter. Four
combinators — seq (sequence) / row,grid (layout/par) / alt+when (conditional/or) / each
(iteration/loop) — plus field/text/card leaves, ref (transclude), and tmpl (recursion).
The two fundamentals designed IN: (1) recursion via self-referential named templates
(tmpl) + each over (children) + a depth guard — renders trees (verified: a nested type
hierarchy -> [Types[Article][Card[Image][Callout]]]); (2) the context is an extensible
ENVIRONMENT — reads it, extends it (:item, :depth) — so behaviour (Slice 9)
and reactivity (signals) plug in via the context with no new combinators.
and/or/choice fall out of one axis ( on forks) x the container strategy (render-all
vs render-first), so Alt isn't a new node — it's 'first'. The unifying property, proven:
the object's CID is its DEFINITION (query/template/every when-variant); render is the
EXECUTION (which items/branch/context). One object renders two ways by context (anon ->
'Please log in', authed -> 'Members area'). Render-fold and the Slice-9 behaviour interpreter
are the same shape — interpreters over content-addressed objects.
lib/host/compose.sx is self-contained (no blog deps); verified via sx_eval (every combinator
+ a recursive tree + a full composed doc across two contexts). Roadmap: wire :body into
host/blog-render, each-source=graph-query, live context, Lexical->card-objects import, block
editor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ran build-all.sh with wasm_of_ocaml 6.3.2: output .wasm units came out byte-identical to
the Jun-29 backup (same hashes, diff -rq clean), so 6.3.2 still emits legacy 'try'. A plain
rebuild is a dead end; the fix needs a newer wasm_of_ocaml (or flag) that emits try_table.
No harm done — deployed artifacts unchanged, live SPA intact. apt wabt/wasm2wat can't read
these wasm-GC binaries (0x5e); need wasm-tools or a real-browser check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Real browser console on blog.rose-ash.com shows the WASM kernel (Jun-29 artifact, built
with an older wasm_of_ocaml) emits the legacy 'try' exception instruction (deprecated; use
try_table) + loadManifest does a sync XHR. Not breaking yet (SPA boots; the day's symptom
was a stale cached loader, cleared by hard refresh) but will break when browsers drop 'try'.
Fix = rebuild the kernel with the current 6.3.2 toolchain (may emit try_table) + verify in a
real browser + make loadManifest async. hosts/ocaml/browser toolchain; schedule when the box
is quiet with a rollback path, don't rush.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Staged cross-loop hand-off (not started here): when the cards-as-types work lands, swap
host/blog-lookup's in-memory registry for content/head over content:<id> streams
populated by lib/blogimport (merged to local architecture a746b6ab, 76/76). Adds a
Phase 4 checklist item + plans/blogimport-pickup.md with concrete steps (merge
architecture, apply blog-side published-posts draft, inject fetch_data as fetch-fn,
backfill, swap lookup, sync-verify parity gate).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seed the kg-card / content-on-sx block kinds as types: a 'card' root (subtype-of type) +
card-heading/text/image/quote/code/embed/callout as subtypes, each with its own fields
(host/blog--seed-card-type!). They appear in /meta (Types 11) and define (a) the editor's
future card palette and (b) the radar migrator's target vocabulary. Instances-as-blocks vs
instances-as-posts is a later decision — this is the vocabulary.
plans/NOTE-blog-types-for-radar.md: the TYPE CONTRACT for the loops/radar migration — a
blog post -> is-a article + typed field-values; body Ghost/Koenig cards -> these card-types.
Two paths mapped onto radar's duplicate->cutover->diverge (type-at-import vs type-in-diverge),
plus the open cards-as-blocks-vs-posts question for them to inform from the Ghost corpus.
Verified live-path (/meta Types 11, card-types with fields) + focused eval (type-defs has
card-image; fields src/alt/caption, heading level/text). Full blog conformance still blocked
by box contention; test added for a quiet re-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes m2's last open box. The delivery_worker now wires its retry
loop on erlang:send_after / cancel_timer self-casts: failing flush
arms the per-Cid backoff timer; handle_info({retry, Cid}) redrives
one Cid through deliver_one_pure; success clears state, failure
schedules next slot or dead-letters on attempt 6.
m2 carries three cherry-picks of the send_after substrate work
(originally landed on loops/erlang via 3709460d/98b0104c/b10e55f0).
Those same commits are already on this architecture via the earlier
loops/erlang merge (154681a4); merging m2's duplicates is a
mechanical conflict-resolve to whichever copy git picks first.
Highlights since the previous m2 merge (2bafb4f7):
- 8b-timer wiring + 5 new tests in delivery_retry_timer.sh
- :timers state field tracks live refs; cancel_timer_for before
re-arming so stale timers don't keep the scheduler alive
- state_srv/1 + timer_ref_for/2 for test introspection
- merge-prep note documenting the duplicate-fix rebase strategy
m2 is now feature-complete. Conformance gate 771/771.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
# Conflicts:
# lib/erlang/conformance.sh
# lib/erlang/scoreboard.md
Wires the delivery_worker's retry loop on top of the
erlang:send_after / cancel_timer primitives just landed on
loops/erlang (3709460d, 98b0104c, 779e53b2 — cherry-picked here
since origin/architecture hasn't caught up yet).
Surface:
- new :timers [{Cid, Ref}] state field tracks live timer refs
- handle_call(flush): drain (existing semantics) + arm_retry_timer
per retried Cid (computes backoff slot from the now-bumped attempt
count, sets next_retry_at, send_after self-cast). Reply shape
unchanged.
- handle_info({retry, Cid}, S): redrives that one Cid through
deliver_one_pure. Success → record_success_pure + clear pending.
Failure → schedule_retry_for (which bumps attempts, dead-letters on
slot 6, or arms next slot).
- cancel_timer_for/2 before arming a new timer so stale timers don't
keep the scheduler's run loop alive after the work is done.
- state_srv/1 + timer_ref_for/2 for test introspection.
5/5 in new delivery_retry_timer.sh; existing delivery_worker.sh
17/17 and delivery_retry.sh 11/11 still green. Conformance gate
771/771 (was 761/761; the +10 is the cherry-picked send_after
suite).
Closes Blockers #3. m2 is now feature-complete.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Define a relation through the UI (metamodel editor surface 1, completing it):
POST /meta/new-relation creates a relation-post (is-a relation, :rel metadata) and
registers it via a runtime concat onto host/blog-rel-kinds — safe because the serving
handler has the IO resolver installed. /meta gains a '+ Relation' form (name, label,
symmetric). Verified: define 'Blocks' (symmetric) -> Relations(5), its editor renders on
edit pages, kind-spec + symmetric correct; auth-guarded.
SESSION-SCOPED: the relation-post + edges persist durably, but the rel-kinds registry
entry is lost on restart because load-rel-kinds! must stay UNROLLED — it runs at BOOT
where it is JIT-compiled but the IO resolver is NOT yet installed, so a dynamic loader
(map/reduce over instances-of 'relation' with a durable read per item) silently returns []
(verified: dynamic -> /meta Relations(0)). The serving-JIT HO-callback-perform fix only
engages with the resolver = serve time. Flagged to sx-vm-extensions (NOTE-render-diff-for-
vm-ext.md); they ACKed + are tracking the boot-resolver fix. Reverted the dynamic loader,
kept the unroll with a comment explaining why.
VERIFICATION NOTE: the full blog suite could not complete — the box is under extreme
contention from sibling loops (load 14, multiple full conformance + erlang/vm-ext rebuilds)
and the Datalog-heavy 140-test suite times out even at a 1800s cap. Verified instead two
ways: (1) live-path HTTP (real route + auth + editor render, ephemeral SX_SERVING_JIT=1),
(2) a focused in-process eval of the create-relation core (exists/is-a/kind-spec/symmetric/
registry-len = true,true,true,true,5). Prior full run was 140/140; changes since are purely
additive (handler + form + route + 3 tests). Re-run the blog suite when the box is quiet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- live-check.sh + run-picker-check.sh now set SX_SERVING_JIT=1 to MATCH THE CONTAINER:
that env gates the http-listen IO resolver, so without it perform-heavy paths (the is-a/
tags picker's reach-down BFS) falsely raise VmSuspended -> 500 in the harness while the
live site is fine (confirmed live is-a picker = 200). Harness must mirror what the
container runs.
- conformance.sh: 600s -> 1200s cap (overridable via SX_CONF_TIMEOUT). A sibling loop at
load ~6 pushed the Datalog-heavy blog suite past 600s -> false 'no suite results parsed'.
- plan: types can specify SPECIALISED EDITORS — a type's :editor slot = a content-addressed
editor component (WYSIWYG, map picker) shipped to the client like ~relate-picker. Generic
form is the default, not the ceiling; spectrum = generic -> per-field widget -> :editor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capture the vision refinement: a type drives BOTH sides of the UI from one definition —
fields {name, value-type, widget} drive the edit form (widget per value-type) AND the
render template (parameterised SX on the type-post, instantiated with field-values). An
instance is just field-values; add a field -> editor + page update, no code. kg-cards
become type-posts (the content-on-sx block vocabulary is the seed set); the editor becomes
a generic field-editor defined by the metamodel (the relation-editors already prove the
pattern). Render template = data (meta-circular); only widgets are platform pieces, selected
by value-type. Refined build order: /meta DONE -> Slice 8 typed fields (KEYSTONE) -> generic
instance form -> render template -> cards-as-types + migrate; plus create-type/create-relation
on /meta + clear-and-reseed.
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>
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>
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>
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>
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>