Commit Graph

98 Commits

Author SHA1 Message Date
616c3cf966 host: layer 2 — types declare composition fields (a block editor per field)
:body was hardwired; now a TYPE declares which of its fields are compositions
({:name "body" :type "Composition"}), and an object may carry several (:body, :aside, :body-1).
The edit page renders ONE block editor per declared field (host/blog--block-editors →
host/blog--composition-fields → the type's Composition fields, default ["body"]); each editor
is independent, targets #comp-<field>, and its cards get field-qualified slugs
(<container>__<field>__<name>). Every block op takes a `field` (threaded via a hidden "field"
input, so routes are unchanged); the response re-renders just that field's editor.

STORAGE: compositions moved into a STRING-KEYED sub-dict :comps (like :field-values) —
string keys round-trip through persist cleanly, whereas a mix of a keyword :body and a string
"body" top-level key does NOT survive serialization as one key (it splits the data). body-of/
set-body! delegate to comp-of/set-comp! with "body" + a legacy top-level :body read fallback,
so existing bodies still render (the demos reseed into :comps on boot).

blog 174/174, full host conformance 403/403 (+ tests: a Landing type with two Composition
fields → two independent #comp-body/#comp-aside editors; block-add! to a named field; default
[body]). Editor still renders any node kind (no "unknown block"); #block-editor wrapper kept
so the Playwright selectors hold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:07:21 +00:00
b056469be1 host: block editor renders ANY composition node (no more "(unknown block)")
The block editor assumed cards-as-objects leaves (ref/alt-with-refs/each-with-ref), so a
hand-authored composition (the compose-demo: text/row/alt-with-text/each-with-inline) fell
through to "(unknown block)" for every text/row node. Now every node kind gets a labelled row
+ preview + move/remove controls: card (✎ chip), text (its content), layout (row/grid + item
count), field, group, and a graceful "other". Conditionals/repeaters display each branch via
host/blog--node-display (a ref → ✎ chip, else the inline text/summary) instead of assuming a
ref. host/blog--node-kind extended (text/layout/field/group); +node-display/+branch-display.

TEST-FIRST: a mixed body (text + alt-with-text + row + each-with-inline) asserts the editor
has NO "unknown block" and labels text/layout/for-each. RED before, GREEN after. blog 171/171.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:15:30 +00:00
39c3def2e7 host: composition editor for and/or/each + relative-addressed refs (resolve-in-context)
The block editor now edits the object's ONE root composition (:body) as three block kinds —
CARD (a ref leaf, the "and"/content), CONDITIONAL (alt+when, the "or": render the first
branch whose live-context condition holds), and REPEATER (each: render a template per graph
query). The render-fold already interprets seq/alt/when/each/ref, so authored compositions
render for free; this adds the editing model + UI.

ADDRESSING (per the design discussion — refs are IPNS-like, not frozen CIDs): refs are
RELATIVE-STORED + RESOLVE-IN-CONTEXT. A :body stores (ref "body__b0") (field-relative); the
render context carries the CONTAINER (the object being rendered) and the resolver combines
them -> the card's storage slug <container>__<field>__<name>. So a body is portable (doesn't
pin the container's name), and editing a card updates everything that refs it for free (no
cascade). A cross-domain ref is absolute with an authority ("market:…"); the resolver
dispatches on the prefix (local today, fetch_data/AP later). A compat shim resolves an older
absolute ref directly. (Snapshot-to-absolute-CID stays a future on-demand op; the CID —
hash(record incl :body) — is the immutable layer over this naming layer.)

MODEL: host/blog--{card-slug,resolve-ref,slug->ref,new-card!,node-kind,node-refs,node-pred,
node-each-type,cond->pred,pred->ckey}; block-add!/add-cond!/add-each!; index-addressed
block-move-idx!/remove-idx!/set-cond! (alt/each aren't single refs). UI: host/blog--block-row
renders by kind (card / "if <cond> → … else → …" / "for each <type> → …") with a condition
<select> + ✎ links to each card's own /<cslug>/edit (external object, CID-neutral). Routes:
POST /:slug/blocks/{add, add-cond, add-each, :idx/{move,remove,cond}}.

Types-define-structure is the next layer (a type declares its composition field(s) + block
grammar). Full host conformance 399/399 (blog 170, incl. 5 new and/or/each tests: add-cond/
add-each/set-cond, a conditional rendering the context-chosen branch, the 3-form editor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:08:12 +00:00
01e0b5db41 host: block-editor card-type <select> options are direct children (populate on boosted nav)
The add-block dropdown wrapped its <option>s in a <span> — (select :name "ctype" (span
(option…)…)) — to splice a dynamic list. A <select> only renders <option>/<optgroup> direct
children, so the dropdown was empty. A full-page load hid it (the browser's HTML parser hoists
mis-nested options out of the select), but on a BOOSTED nav the DOM is built programmatically
(no parser error-recovery), so the span stayed and the dropdown was empty. The card types are
a fixed set — inline the options directly as <select> children.

TEST-FIRST: 4th boost-nav.spec.js case (LOGGED IN: boosted nav to edit → assert
select[name=ctype] > option count is 5, incl card-heading). RED before (0 direct-child
options — span-wrapped), GREEN after. All 4 boost-nav tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:06:57 +00:00
7bec86289c web: morph preserves the boost's injected sx-* attrs — fix edit-page swap clobbering #content
Reported: logged in, go to an edit page, then press Home — nothing happens.

Root cause (browser-DOM trace): a boosted nav home→post morphs a home footer <a> into the
post's "edit" link (morph reuses nodes positionally). morph-node's sync-attrs then STRIPS any
attribute the old node has but the SERVER node lacks — which removes the boost's
client-injected sx-swap="innerHTML" (the server never sends it). With sx-swap gone the swap
defaults to outerHTML, so clicking edit REPLACES #content (the <div id=content>) with the edit
fragment's <div> (no id) — DOM trace: "sx-boost children [NAV, DIV#content]" → "[NAV, DIV]".
#content is destroyed, so every later boosted nav (Home) fetches but has no swap target
("post-swap: root=nil") → nothing updates.

Fix: sync-attrs no longer removes the boost's injected navigation attributes (sx-target /
sx-swap / sx-push-url / sx-get / sx-select) when the new (server) node lacks them — they're
identical across all boosted links, so a reused node keeps sx-swap="innerHTML" and the swap
morphs #content's children instead of replacing #content. Recompiled the web stack. Pairs
with a511b21d (fresh href) + 88f8b427 (SX-Redirect) — three facets of the morph-node-reuse
problem (stale href, lost swap attr, guarded-redirect clobber).

TEST-FIRST: added a 3rd boost-nav.spec.js case (LOGGED IN: home→post→edit, assert #content
survives + Home works). Reproduced RED via DOM traces (#content count 0), GREEN after — on the
ephemeral server AND live. No regressions: picker 3/3 + block-editor 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:17:59 +00:00
88f8b427c5 host: guarded route via boost → SX-Redirect (full nav to /login), not a #content-clobbering 303
Reported: go to an edit page, then press Home — nothing happens; navigation stops updating.

Root cause (found via a browser trace): a guarded route (host/require-login) answered a
BOOSTED (SX-Request) request with a 303 to /login. The browser's fetch follows the redirect
but DROPS the SX-Request header on the way, so /login returned the full HTML shell (<!doctype
html>…), not a text/sx fragment. Morphing that whole document into #content DESTROYS the
#content swap target (diagnostic: "#content count: 0"), so every later boosted nav fetches
but has nowhere to swap ("post-swap: root=nil") — the persistent nav Home appears to do
nothing.

Fix (host-side, no engine change — the engine already supports SX-Redirect): for a boosted
request require-login now returns 200 + an `SX-Redirect: /login?next=…` header. The engine
does a FULL navigation (browser-navigate) to a real /login page — #content is never
clobbered. Non-boosted requests still get a plain 303. Also added a "← Home" link to the
login shell (it's a standalone page with no persistent nav, so a logged-out user who followed
a guarded link was otherwise stranded — the literal "press Home" case).

TEST-FIRST: added a second boost-nav.spec.js case (home → post → click edit → assert clean
full-nav to /login, NOT a clobbered SPA, and Home works from there). Confirmed RED before
(Home did nothing on the clobbered page), GREEN after — verified on the ephemeral server AND
live. No regressions: picker 3/3 + block-editor 1/1 (login flow intact).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:55:32 +00:00
a511b21dd2 web: boosted links read href FRESH at click time — fix stale nav after a morph swap
Reported: on blog.rose-ash.com, home --boosted nav--> a post --click "edit"--> lands on
/tags (a HOME footer link), not /<slug>/edit; subsequent navs stop updating.

Root cause: an innerHTML boost swap uses morph-children, which REUSES DOM nodes in place
(matched positionally when links have no id). The home footer's <a href="/tags"> element is
re-purposed as the post's <a href="/compose-demo/edit"> — its href attribute is rewritten,
but bind-client-route-click had captured the OLD href in its click closure, and the element's
is-processed? mark survived the morph (so boost-descendants skipped re-binding it). Clicking
the reused "edit" link fired the stale /tags closure.

Fix: bind-client-route-click now reads the href FRESH from the element at click time
(dom-get-attr link "href", falling back to the captured value) instead of trusting the
closure. A reused node then always follows its CURRENT href — robust to morph reuse without
needing to clear marks or remove listeners. Recompiled the web stack (.sxbc + manifest).

TEST-FIRST: lib/host/playwright/{boost-nav.spec.js, run-boost-nav-check.sh} reproduces the
exact flow (home -> boosted nav -> click edit -> assert URL is /compose-demo/edit, NOT /tags)
against an ephemeral server. Confirmed RED before the fix (landed on /tags), GREEN after. No
regressions: relate-picker 3/3 (incl. boosted-nav populate) + block-editor 1/1 still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 05:55:46 +00:00
f804a71726 host: block editor live-swap — :sx-post (not sx-disable) + a Playwright check
The block-editor move/remove controls used :sx-disable "true" (the OLD relate-picker pattern
= plain POST → 303 → full reload). Switched to :sx-post + :sx-target #block-editor + :sx-swap
outerHTML (the current pattern): the click is a text/sx form round-trip through the WASM
engine, the handler returns the re-rendered #block-editor, and it swaps IN PLACE — no reload.

Added lib/host/playwright/{block-editor.spec.js, run-block-check.sh} (the run-picker-check
harness pattern: ephemeral host server + one editable post + the main worktree's chromium).
Verifies the irreducibly-browser behaviour the SX conformance can't see: adding, reordering
(↑), and removing blocks re-render #block-editor live, and the controls RE-BIND on the
content each swap brings in. PASSES (1/1, 16s). blog conformance still 165/165.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 05:20:52 +00:00
af3d81d108 host: polish — a third fold domain (deps) + a live execute-fold demo (/workflow-demo)
Two concrete demonstrations of the composition architecture:

THIRD DOMAIN (proves step 8's "a new domain is just a dict + leaf, no new control flow").
host/comp-deps folds a composition to the object ids it TRANSCLUDES — the static contains
DAG of a body. It reuses host/comp-fold's seq/alt/each dispatch verbatim; only the leaf
(collect `(ref ID)`) + accumulator (concat) are new. Useful in its own right (what a
(seq (ref c0) (each … (ref …))) body pulls in; context-specific — alt picks the taken
branch). compose suite 20/20.

LIVE EXECUTE-FOLD DEMO (makes step 7 tangible, parallel to /compose-demo for render).
/workflow-demo runs ONE composition object's :body through host/exec-run — the SAME structure
the render-fold would turn into HTML, folded by execute into a plan of effects (validate →
branch on status → notify each recipient). host/blog-seed-workflow-demo! + host/blog-workflow-
demo + route + serve.sh seed. Shows the behaviour model IS an execute-fold over a composition
object — the same object the block editor authors. blog suite 165/165.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 05:16:56 +00:00
10bc091890 host: fix the 2 brittle relate-picker tests — robust to pool size (blog 164/164)
Both tests pre-dated the metamodel growth (types/cards/relations are now posts), so the
`related` candidate pool — which by design offers EVERY post (a relation with no declaration
is unrestricted; plans/relations-as-posts.md) — grew past one 20-item page, and the tests
asserted single-page behaviour:
 - "omits the load-more sentinel on a short last page" assumed alpha-post's pool < 20;
 - "offers all posts" checked P Doc (pdoc, itself a type-def) was on page 1.
Both now test the actual behaviour without depending on absolute counts: the sentinel test
pages past the end (offset=100000 → empty page → no sentinel), and the unrestricted-pool test
filters (?q=doc → finds the pdoc type-def regardless of pagination — confirming `related` is
unrestricted, unlike `tagged`). Behaviour unchanged; the design ("related offers all") stands.
blog suite now 164/164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 05:12:40 +00:00
07dfad5919 host: warm-conf.sh — add eval/reload modes (the profiler that found the perf bug)
`eval <expr>` evals an SX expression against the warm image and reports round-trip time —
the profiling primitive that isolated relations/relate at 6s/call (super-linear). `reload
<files>` hot-reloads specific modules into the warm image. GOTCHAS baked in: the epoch
protocol rejects bare exprs ("Unknown command") so eval wraps in (eval "<src>") with quote/
backslash escaping; an (eval …) acks as (ok-len N C) with the result on its own line (NOT
(ok N R), which is the LOAD ack), errors as (error N …).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:55:20 +00:00
e12e314bc3 host: factor the shared composition CORE — one fold, N domains (composition step 8)
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>
2026-06-30 23:53:56 +00:00
ed68b9883d host: execute-fold — universality proven with a second fold (composition step 7)
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>
2026-06-30 23:49:41 +00:00
b78491a5a1 host: block editor — edit the :body composition (composition roadmap step 6)
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>
2026-06-30 23:45:20 +00:00
498ec006fe host: blog edge graph is KV-only — drop the per-write Datalog re-saturation (major perf)
A REAL production perf bug, surfaced while profiling slow conformance. host/blog--add-edge!
mirrored every edge into lib/relations via relations/relate, which RE-SATURATES the whole
CEK-interpreted Datalog ruleset on every single write — super-linear in the fact base
(profiled: 1.1s → 3.5s → 6.1s per edge as the graph grows 10→20→30 facts; O(graph) per
write, O(edges²) to build). This hit the LIVE SITE on every content op: importing a Ghost
post (decompose! = ~4 edges/block), tagging, relating, is-a, the metamodel editor — all
getting slower as the site grows.

Since typing now reads direct KV edges (host/blog--subtype-closure et al.), NOTHING in the
blog domain reads lib/relations anymore — the mirror was pure, very expensive dead weight.
So edges are now KV-only: add/del-edge! just kv-put/kv-delete (~20ms FLAT, O(1)); reads
already walk the edge:* rows directly. host/blog-load-edges! (which replayed every edge into
lib/relations on boot — O(edges²)) is now a no-op. conj/disj operands were already KV-only,
proving the whole graph can be. host/relations.sx (the relations DOMAIN service, its own
type:id nodes) is separate and untouched.

Result: blog-relate! 6.1s→20ms/call (and now FLAT, not growing); full blog suite ~23min→19s;
all 11 host suites 353/355 in 36s (the 2 fails are the pre-existing relate-picker pair). Live
writes drop from seconds to ~20ms. Pairs with the typing-reads-from-KV fix (prev commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:40:11 +00:00
14a6bd6411 host: cards-as-objects import + typing reads direct KV edges (composition step 5 + perf)
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>
2026-06-30 22:20:38 +00:00
a25427cb79 host: warm-conf.sh — persistent conformance server for fast iteration
conformance.sh cold-loads all ~57 modules every run (a multi-minute tax, worst under box
contention). warm-conf.sh keeps a long-lived sx_server with the 44 heavy dependency modules
(datalog/acl/relations/persist/dream) loaded ONCE, and per run reloads only the 16 lib/host/*
modules + the suite's test file — the things you actually edit — then evals the runner.

Reads the MODULES + SUITES arrays straight from conformance.sh (no duplication/drift). Safe
across runs: each test file re-opens a fresh persist store, and (since blog typing now reads
direct KV edges, not lib/relations) the warm Datalog DB no longer feeds blog results, so
stale facts can't pollute a re-run. Usage: warm-conf.sh start | run [suite] | stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:20:25 +00:00
5ead6e73c7 host: live context — device/locale routed into the render-fold (composition roadmap step 4)
The render context is now the live EXECUTION environment: host/blog--comp-ctx reads device
(mobile/desktop from User-Agent) and locale (from Accept-Language) PURELY from the request
headers — no perform — alongside auth + the graph-query resolver. So the SAME composition
object renders responsively/personalised: `(alt (when (eq "device" "mobile") …) …)` is a
responsive layout, `(when (eq "locale" "fr") …)` a localised variant. The object (its
when-variants) is the definition; the context picks which path renders.

host/blog--device-of / host/blog--locale-of; comp-ctx now (principal req) — post handler
passes req; /compose-demo gains a device-variant block. Reactive/live values plug into the
same context later with no new combinators (the plan's "make the context live" axis).

Verified via focused harness eval (mobile+fr vs desktop+en contexts render M/D variants;
no-req ctx omits device). Tests added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:43:06 +00:00
29aa7cd70f host: each-source = graph query — the data-driven each (composition roadmap step 3)
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>
2026-06-30 20:10:49 +00:00
bfb91819d9 host: wire :body into live rendering — composition fold is fold #1, live (roadmap step 2)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
A record may carry a :body (a composition node); host/blog-post renders it via the
render-fold (host/comp-render) against a context built from the principal (auth), else the
legacy sx_content path. compose.sx loaded into the host (serve.sh + conformance.sh module
lists). host/blog-body-of / host/blog--set-body!.

Seeded /compose-demo: ONE composition object that shows seq + alt(when auth) + row(par) +
each, and renders DIFFERENTLY by context. Verified live-path (ephemeral SX_SERVING_JIT=1):
anon -> login-prompt (else) + columns + event list; authed -> member block (when auth),
login-prompt gone. The object is the program; the render is the execution -- now live.
Focused eval confirms the in-process render matches the test (ANON<span>..> vs MEMBER<..>).
Tests added; full blog suite still box-contended.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:29 +00:00
cdbb5bb4ba host: composition-objects render-fold — seq/par/alt/each + recursion + context (keystone)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
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>
2026-06-30 17:11:17 +00:00
7f87054ec3 host: load kg-cards components so imported Ghost posts render fully
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m22s
Imported Ghost posts' sx_content holds (~kg_cards/kg-*) (from the lexical_to_sx converter);
the host's render-page resolves components, but the kg-cards weren't loaded so they
degraded to '(unsupported block)' placeholders. Copied blog/sx/kg_cards.sx ->
lib/host/sx/kg-cards.sx (host self-contained, not coupled to the legacy blog/ Quart dir)
+ added the one host-local dep ~rich-text (was only a test fixture) + registered it in
serve.sh + conformance.sh module lists. Verified: the real 'Free DVD Box Sets!' post now
renders <figure class=kg-card kg-image-card> for all images, zero placeholders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:25:01 +00:00
1d02afb64a sxtp: patch + signals primitives (Datastar-borrowed)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
Adds two new top-level SXTP message types alongside
request/response/condition/event, modelled on Datastar's
datastar-patch-elements and datastar-patch-signals SSE events:

  (patch :target "#x" :mode outer :body (~card)) - DOM fragment
    morph. Subsumes HTMX swap modes. Mode is outer (default) |
    inner | replace | prepend | append | before | after | remove.

  (signals :values {:n 3} :only-if-missing false) - reactive
    state patch. nil value removes the signal. only-if-missing
    skips existing signals (lazy init).

A server response stream can mix both freely; clients dispatch
by head symbol, ordering preserved. Cleaner than HTMX's
swap-mode-per-trigger because the patch shape is decoupled from
the triggering element/attribute.

Spec at applications/sxtp/spec.sx (patch-fields, signals-fields,
patch-modes, example-patch-stream). Constructors / predicates /
accessors / serialise / parse in lib/host/sxtp.sx. 25 new tests
in lib/host/tests/sxtp.sx (predicates, mode normalisation, fixed
field order, remove-without-body, signals round-trip). Host
conformance 129/129 (was 104/104).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 15:22:37 +00:00
fac15d6140 host: typed Ghost import — POST /import lands old posts as first-class Articles
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m0s
The genesis-import seam for the loops/radar migration (NOTE-blog-types-for-radar.md):
an old Ghost post lands not as bare sx_content but as a TYPED Article.

- host/blog-import-post!(ghost-dict): put! the {slug,title,sx_content,status} record +
  is-a article + Ghost columns -> article :field-values (custom_excerpt->subtitle,
  feature_image->hero) + tags -> tag-posts with tagged edges. Idempotent. The Ghost body
  is already sx_content ((~kg_cards/kg-*) from the Python lexical_to_sx migration), so we
  carry it as-is. host/blog-import-all! for batches.
- POST /import (guarded): body = a text/sx LIST of Ghost column dicts (radar's Postgres
  reader serialises rows to this); imports each typed; -> {:ok true :data {:imported N
  :slugs (...)}}. Runs in the serving handler (IO resolver installed) so the per-post/
  per-tag loops are JIT-safe.

Verified live-path end-to-end (ephemeral SX_SERVING_JIT=1): POST a fixture Ghost post ->
imported 1; the post's edit form is pre-filled (subtitle='An imported standfirst',
hero=the feature image), its page renders the subtitle standfirst via the article template
+ the body, and its tags (News/SX) land in the graph. Tests added; full blog suite still
blocked by box contention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:05:02 +00:00
a88ceda9d6 host: cards-as-types — the blog content block vocabulary as metamodel types
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 50s
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>
2026-06-30 14:18:29 +00:00
9effa71dde host: metamodel create-relation form (session-scoped) + keep load-rel-kinds! unrolled
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 52s
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>
2026-06-30 13:52:23 +00:00
536bb8b76b host: Slice 8c render-template-per-type + metamodel create-type form
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 24s
Closes the 'types define the UI' loop and adds the editor's create half.

8c (render template): a type declares a :template — a parameterised SX tree (stored as
source) with (field "name") placeholders that resolve to the instance's field-values at
render. host/blog-template-of / --set-template! / --instantiate (pure tree-walk) /
--typed-block (per the post's types, parse+instantiate, pre-fetched in the handler).
host/blog-post renders it above the body. Article seeded a subtitle standfirst template.
So ONE field definition now drives BOTH the edit form AND the rendered page.

create-type (metamodel editor surface 1): POST /meta/new-type creates a published post
subtype-of "type" -> appears in host/blog-type-defs / the /meta Types list, ready to be
given fields/schema/template. Guarded (unauthed -> login, not created). /meta gains a
'+ Type' form. You can now DEFINE A TYPE THROUGH THE UI.

Verified live-path: typed post's subtitle renders on its page; create 'Recipe' via the
form -> Types(4). Blog suite 140/140.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:40:27 +00:00
bbb8528352 tooling+plan: harness SX_SERVING_JIT=1 fix, conformance timeout bump, specialised editors
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 19s
- 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>
2026-06-30 12:18:34 +00:00
f5f4e93dcf host: Slice 8 — typed scalar fields on types + the generic, type-driven form
The keystone: a type declares :fields [{name, value-type, widget}], an instance carries
:field-values, and the SAME edit form is generated from the type definitions — no per-type
code. 'The editor maps onto the types.'

8a (field model): host/blog-value-types (String/Text/URL/Int/Date/Bool -> default widget),
host/blog--widget-for (explicit > value-type default > text), host/blog-fields-of +
--set-fields! (on the type-post, like schema), --fields-summary. Article seeded with
subtitle:String + hero:URL. /meta gains a Fields column. host/blog-type-defs (the subtype-of
hierarchy = type DEFINITIONS, vs instances-of = is-a instances).

8b (instance form): host/blog-field-values-of + --set-field-values!; host/blog--fields-for-post
(union of the post's transitive types' fields, deduped); host/blog--field-inputs (one labelled
input per field, widget per value-type, pre-filled). edit-form injects the Fields section
(durable reads pre-fetched); edit-submit reads field-* inputs via host/field and stores them.

Verified live-path (ephemeral, SX_SERVING_JIT=1): relate is-a article -> field inputs appear
-> save -> values persist. Blog suite 132/132.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:18:34 +00:00
7b9aece52d host: metamodel overview page (GET /meta) — the first editor surface
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 25s
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>
2026-06-30 11:38:58 +00:00
bd108ae7dd tooling: per-suite conformance filter + live-check.sh; note render-diff to vm-extensions
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 25s
- 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>
2026-06-30 11:24:29 +00:00
9293366cb4 engine: boosted forms post text/sx, not urlencoded (SX-native write wire)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
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>
2026-06-30 11:14:21 +00:00
999249b944 host: SX-native wire — reads + write bodies are text/sx, JSON CRUD deleted
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>
2026-06-30 11:07:30 +00:00
ad86f3051e host: universal content-address (CID) on every post
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>
2026-06-30 10:14:44 +00:00
d8e951ed27 host: relations-as-posts slice 5 — refinement types (schemas on the type-post)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 22s
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>
2026-06-30 09:13:30 +00:00
d45da81b80 host: relations-as-posts slice 4 — type ALGEBRA (intersection ∧ union)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 25s
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>
2026-06-30 08:41:41 +00:00
f94b9d0b93 host: relations-as-posts slice 2.5 — picker title reads are O(page), not O(pool)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 45s
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>
2026-06-30 07:50:31 +00:00
90190346aa host: relations-as-posts slice 3 — typed relations (target-type constraint enforced)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
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>
2026-06-30 07:25:49 +00:00
97f07cf40f host: rel-kinds is a boot-populated VALUE, loads unrolled (live JIT iteration bug)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 48s
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>
2026-06-29 23:15:12 +00:00
a9df9f4e99 host: relation enumeration via a static slug list (graph scan was fragile on live)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 39s
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>
2026-06-29 23:03:37 +00:00
c6627f4954 host: relations-as-posts slice 2 — relation metadata lives on relation-posts
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 48s
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>
2026-06-29 22:49:59 +00:00
b3804ce712 host: relations-as-posts slice 1 — declaration-driven candidate pools
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
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>
2026-06-29 21:40:27 +00:00
ad556c3e31 host: persistent Home link in the top nav
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
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>
2026-06-29 21:17:43 +00:00
339235a2b5 host: no flash on relate/unrelate — server-render the picker's first page
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
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>
2026-06-29 20:50:40 +00:00
268e91cd5d host: relate/unrelate keep both lists in sync (add to current list, never blank the picker)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
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>
2026-06-29 19:53:20 +00:00
09465f4483 host: removing a related post no longer clears the relate picker
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
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>
2026-06-29 19:15:11 +00:00
98ff7a350a host/tests: Phase 2 — trim Playwright to a boot smoke
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>
2026-06-29 17:56:07 +00:00
f1bd6f1557 engine: boosted forms now submit (bind-boost-form was discarding method/action)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
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>
2026-06-29 17:07:07 +00:00
c0007740e7 host: relate removes just the picked candidate row in place (no reload)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 33s
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>
2026-06-29 15:49:03 +00:00
b21ae05e8f host: extract the relate picker into a content-addressed ~relate-picker component
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
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>
2026-06-29 15:17:30 +00:00