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>
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>
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>
- 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>
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>
The 'see the system you've defined' page: every type-post (with its schema's required
blocks) and every relation-post (with its signature), each linking to the post that
defines it. The surface the metamodel editor hangs off (North Star UI surface 1 of 3).
- host/blog-type-defs: the type DEFINITIONS = the subtype-of hierarchy rooted at 'type'
(type + transitive subtypes). NOT host/blog-instances-of 'type' (that's the is-a
INSTANCES — typed content, not the definitions, which are linked by subtype-of).
- host/blog-meta-index (GET /meta, mounted before /:slug): pure read, all durable reads
pre-fetched into let bindings before the quasiquote (perform-in-tree = VmSuspend);
relations from the boot-populated host/blog-rel-kinds VALUE. Types + relations tables.
- Home footer links to /meta + /tags.
Verified live (ephemeral): Types (3: Type/Tag/Article, Article shows required block h1),
Relations (4: related symmetric, is-a/subtype-of/tagged directed). Blog suite 122/122.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- conformance.sh [suite] runs ONE suite (filters the SUITES array so result-parser
indices stay aligned; all MODULES still load). 'conformance.sh sxtp' = 0.3s vs ~8min.
- lib/host/live-check.sh: non-browser live smoke — boot ephemeral host, login, seed a
post (exercises form-ingest write), print status|content-type|body-head per path,
assert reads are text/sx + no JSON leak + no 5xx. The counterpart to run-picker-check.sh.
- plans/NOTE-render-diff-for-vm-ext.md: defer host_render_diff (JIT-vs-interpreter
regression oracle) to the sx-vm-extensions loop — it's their fix's oracle, not a host
feature; building it from loops/host would fork JIT-engine understanding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
build-request-body's POST-form branch now serialises the form fields to a text/sx
body via the serialize primitive (content-type text/sx), instead of FormData ->
URLSearchParams -> urlencoded. A hydrated page posts SX; the host reads it via
host/sx-body / host/field (the server already accepts both — urlencoded stays the
no-engine / login-bootstrap fallback). Recompiled the web stack -> .sxbc.
Verified client-agnostically (no DOM, the user's preference): a new sxtp suite test
proves the wire contract serialize(engine) <-> host/sx-body(server) round-trips a
field dict losslessly, INCLUDING sx_content full of quotes/parens that would break a
naive encoder, plus host/field's content-type discrimination + urlencoded fallback
(sxtp 43/43). The DOM field-read (dom-query-all + .value) is the one irreducibly-
browser bit — left to a targeted Playwright smoke.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greenfield SX-native pivot (NOT a strangler): the host speaks SX/SXTP end to end;
JSON only at the future ActivityPub federation edge.
- OUTPUT: host/json-status -> host/sx-status — every host/ok/host/error response is
text/sx via the serialize primitive (NOT application/json). Flips feed, relations,
blog reads. Tests assert the SX envelope ({:ok true :data ...}).
- DELETE the blog JSON CRUD /posts (POST/PUT/DELETE) + bearer-based host/blog--protect:
a pure old-contract REST mirror. Create/edit go through the HTML editor forms;
programmatic writes speak SXTP. FOLLOW-UP: no browser delete route yet (was JSON-only,
no UI) — add POST /:slug/delete + cascade edge cleanup when the metamodel UI needs it.
- INPUT: host/sx-body (sxtp.sx) parses a text/sx request body to a string-keyed dict
(parse-safe + sxtp/-normalize). feed POST + relations attach/detach read it.
- UNIFIED field reader host/fields / host/field: text/sx body OR urlencoded form by
content-type. The blog form handlers (new/edit/relate/unrelate) + login read through
it — additive, urlencoded still works (no-engine / bootstrap fallback).
Conformance 290/290 (11 suites). Retires the strangler framing in the plan; adds the
'SX all the way out' wire table. The engine half (browser posts text/sx) follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every object (content/type/relation post) now carries a stable :cid = hash of its
canonical, key-sorted content. The runtime has no hash primitive, so host/blog--canon
(recursive, sorts keys -> identical across processes regardless of dict insertion order)
and a tail-recursive double-hash (host/blog--hash-go / host/blog--cid-of) are built in SX.
The slug (a name) and any prior :cid are excluded -> the CID hashes content only.
git-shaped: slug = mutable name -> CID = immutable content identity.
Single choke point host/blog--write! stamps the CID on every record write; routed all
three write sites (put!, set-schema!, seed-rel!) through it. Accessors host/blog-cid and
host/blog-by-cid (reverse lookup). +6 conformance tests (blog suite 134/134). Plan: new
'Content-addressability is universal' section (CID model, git-shape, federation: types
flow across fed-sx as shared content-addressed vocabulary; structure/behaviour trust-split).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A type-post carries its schema in a :schema slot (a list of {:block :msg} rules — a
refinement {x : T | x has these blocks}). host/blog-schema-of reads it off the post;
the hardcoded host/blog-type-schemas table is gone. A NEW refinement type is pure
data: give a type-post a :schema and its instances are validated on save — no code
(tested with a 'guide' type requiring a 'pre' block). article's schema is migrated
onto the article post at boot (host/blog--set-schema!, a single read+write).
host/blog-put! now MERGES over the previous record, so editing a post's
title/content doesn't nuke its :schema/:rel metadata (also closes the Slice 2
'edit drops :rel' gap). schema-of reads the post (a durable read) — only the SAVE
path calls it (a write request, never a render that would VmSuspend).
conformance 299/299 (+4: article h1 enforced from the post, a new refinement type
validates its instances, schema read off the post, edit preserves :schema).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An algebraic type is a post with operand edges: conj edges (intersection members),
disj edges (union members). host/blog-instances-of-expr computes its extent from the
operands' extents by set intersection/union, RECURSIVELY — operands can themselves be
algebraic (meta-circular; tested with (tag ∧ article) ∧ tag). host/blog-is-a-expr?
generalises is-a? to type expressions; make-and!/make-or! build them. Binary today
(nth 0/1, no fold over operands — robust on the serving JIT).
Operand edges are KV-only (host/blog--add-edge-kv!, read via host/blog-out), NOT in
lib/relations — feeding extra kinds into the Datalog graph blows up its per-query
re-saturation; load-edges! skips conj/disj on replay too.
conformance 295/295 (+4: intersection/union membership, extent = set op, nested expr).
(NB: host conformance can EXIT 124 purely from a sibling loop's CPU contention — ran
with timeout 1200.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
relate-candidates computes the available candidate SLUGS (slug-sorted, no per-candidate
read), then reads titles only for the page it returns. On the unfiltered path (q="" —
the initial picker load AND every editor server-fill, the common case) that's ~limit
durable reads instead of one-per-post, cutting the http-listen suspend/resume churn. A
filter (q≠"") still resolves titles across the pool since it matches on the title.
(A boot slug→title cache would make the filter O(1)-perform too, but it's blocked: no
bulk KV read, and a per-post host/blog-get loop at boot hits the JIT 'durable read in a
boot loop drops all-but-first' bug — see plans/relations-as-posts.md.)
conformance 291/291, run-picker-check 3/3 (incl. the title filter + paging).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A relation's declares-anchor IS its target-type constraint: is-a/subtype-of (anchored
by type) require a type object; tagged (anchored by tag) a tag; related (no anchor) any
post. host/blog--valid-object?(kind, other) = other ∈ the relation's candidate pool — the
SAME set the picker offers — and relate-submit now enforces it (invalid target = silent
no-op). The picker never offers an invalid target, so this guards crafted/API requests:
the jump from candidate set to an enforced relation schema. A new typed relation needs
only a relation-post + a '<TargetType> declares <rel>' edge.
host/blog-relate! (direct/seed) stays unvalidated — validation is a handler boundary
(the seed writes 'X is-a relation', and relation isn't under type).
conformance 291/291 (+4: valid-object? accepts types/tags/any, relate-submit creates the
edge for a type object and no-ops for a non-type).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The serving-mode JIT dropped 3 of 4 relations when host/blog-rel-kinds map/for-each'd
a function-produced list (only the first survived) — so only one relation editor
rendered live. Restore slice 1's working shape: host/blog-rel-kinds is a VALUE the
boot populates (set! in load-rel-kinds!), and both the cache loads and the list build
are UNROLLED (no iteration over the relation list). Metadata still lives on the
relation-posts. conformance 287/287.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
host/blog-in "relation" "is-a" (a reduce over ALL edges) returned a partial set on
the live store (many edges), so only one relation editor rendered. Enumerate the
relations from a fixed slug list instead — deterministic; the metadata still lives
on the relation-posts (loaded into the cache). rel-kinds maps kind-spec over the
list and drops any uncached.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
is-a/subtype-of/tagged/related are now POSTS (each is-a a new `relation` root),
owning their metadata in a :rel slot {:symmetric :label :inverse-label}. The static
host/blog-rel-kinds registry is gone: kind-spec/rel-kinds/kind-symmetric? read the
relation-posts (via an in-memory cache), and the relation list derives from
host/blog-in "relation" "is-a".
Perform-budget fixes (a durable read inside the http-listen render VM raises
VmSuspended; too many per request 500s the page):
- relation metadata is loaded into a cache at boot (host/blog-load-rel-kinds!,
like load-edges!), so kind-spec is pure on render paths;
- the initial edit page renders its pickers EMPTY (the load trigger fills each) —
only the relate/unrelate FRAGMENT server-renders candidates (with-cands flag).
Previously every edit page render did candidate-get × 4 pickers and 500'd.
host conformance 287/287 (+4 slice-2: kind-spec reads :rel, kind-symmetric? off the
post, unknown kind has no spec, rel-kinds derived from the graph). run-picker-check
3/3 (edit page boots, relate/unrelate flow works, no client errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Types declare which relation they anchor (type declares is-a/subtype-of, tag
declares tagged) via a 'declares' edge; the picker's candidate set is the
down-closure of a relation's anchors through is-a ∪ subtype-of. So is-a/subtype-of
now offer the WHOLE type closure — the roots (type/tag/article) AND instances —
fixing the wrinkle where only instances showed and you could never pick 'tag' or
'article' as a type. 'related' has no anchor → every post.
Replaces the hardcoded :candidates "types"/"tags"/"all" with graph queries
(host/blog--reach-down + the declares edges). Design + roadmap (relations as
first-class posts, typed relations, type algebra, constraints) in
plans/relations-as-posts.md.
host conformance 283/283 (+5: is-a pool includes type roots, excludes plain posts,
tagged anchored by tag, related = all, is-a relate-options offers Article).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a top nav with a boosted Home link, inside the [sx-boost] wrapper but outside
#content, so it SPA-navigates to / and survives every content swap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Relating/removing re-renders the kind's editor (outerHTML); the swapped-in picker's
results <ul> was empty and only filled after its 'load' fetch, so the candidate list
briefly emptied (a visible flash). Render the first page of candidates INTO the
results <ul> server-side (host/blog--relation-editor builds it inline via cons, the
same splice pattern the current-relations list uses), so the re-rendered picker
arrives already populated; the 'load' trigger then re-fetches the same page and
morphs it in place — invisible. No empty state, no flash.
Rendered inline rather than via the ~relate-picker component because component args
are evaluated, so pre-built candidate li-trees can't be spliced through one (they'd
be applied as calls). The component is left in place but unused.
Server-side only — the client engine (orchestration.sxbc, last commit's re-bind fix)
is unchanged. host conformance 278/278 (new: editor server-renders candidates), web
engine suite 8/8, run-picker-check 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two reported bugs on the edit page's relation editor:
1. relating a candidate didn't add it to the current-relations list (the AJAX
relate just deleted the candidate row; the relation only showed after a reload);
2. removing a relation could blank the relate picker.
Fix (lib/host/blog.sx): both the candidate's relate form and a current relation's
remove form now target #rel-editor-<kind> with sx-swap=outerHTML, and the
relate/unrelate handlers return the re-rendered editor for that kind (current list +
a fresh picker). So one swap keeps BOTH lists in sync: the related post moves into
the current list and out of the (re-loaded) candidate pool; removing moves it back.
Gated on the SX-Target header, so a plain boosted form / no-JS POST (the is-a-tag
toggle) still redirects + re-renders #content.
Engine fix (web/orchestration.sx): handle-html-response's non-select branch called
post-swap on the OLD target, which an outerHTML swap has already REPLACED — so the
swapped-in content's triggers (here the re-rendered picker's "load") never bound and
the picker stayed empty. post-swap the swap result (the new node), mirroring the
sx-select branch. Recompiled orchestration.sxbc for the content-addressed client.
Tests:
- web/tests/test-relate-picker.sx: relating re-syncs the editor (post in current
list + picker re-loads); removing does likewise — both fail without the engine fix.
- lib/host/tests/blog.sx: relate/unrelate return the re-rendered editor fragment
(200, #rel-editor + picker), forms wire to #rel-editor-KIND/outerHTML, plain
boosted POST still 303.
- relate-picker.spec.js: the full in-page flow (relate adds to list, remove keeps
the picker, no reload) + persistence.
Verified: host conformance 277/277, web engine suite 8/8, run-picker-check 3/3,
run-spa-check 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug: the edit page's remove button (on a current relation) was a plain boosted
form — POST /unrelate -> 303 redirect -> the engine re-rendered #content, and the
freshly-swapped relate picker came back EMPTY ("the list of posts to relate" was
cleared).
Fix: make the remove button an AJAX in-place delete, exactly like the relate
candidate rows — each current-relation <li> gets an id and its form carries
sx-post + sx-target=#cur-<kind>-<other> + sx-swap=delete. unrelate-submit returns
an empty 200 for that request so the engine deletes just that one row; #content is
never re-rendered, so the picker is untouched. method+action stay for no-JS.
The empty-200 is gated on the SX-Target header (sent only by the sx-post form), so
a plain boosted form / no-JS POST still redirects + re-renders — the is-a-tag
toggle and graceful degradation are unaffected.
Tests (all red before the fix):
- lib/host/playwright/relate-picker.spec.js: the remove-button test now asserts
the picker still has candidates after a removal (the reproduction).
- web/tests/test-relate-picker.sx: an SX engine test — removing a current relation
deletes just that row and leaves the sibling picker's list intact.
- lib/host/tests/blog.sx: the relation-editor renders the AJAX delete attrs;
unrelate returns empty-200 with SX-Target and 303 without.
Verified: host conformance 275/275, web engine suite 8/8, run-picker-check 2/2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Fixes "the remove button does nothing — no network, no console". A plain form on
a boosted (sx-boost) page has no sx-get/sx-post, so the SPA engine boosts it and
binds submit -> execute-request. But bind-boost-form called
`(execute-request form nil nil)` — discarding the method+action it was handed —
and execute-request then asks get-verb-info for a verb, gets nil, and no-ops. So
EVERY plain boosted form silently did nothing: the related-posts "remove" button,
the editor Save button, the is-a-tag toggle.
Fix: pass the form's own method+action as the verbInfo
`(dict "method" method "url" action)`, so the request actually fires (body built
from the form fields). A latent web-engine bug surfaced by the host's edit page —
the first page with plain boosted POST forms.
Test: relate-picker.spec.js gains a remove-button case (relate, reload, click
remove, assert the relation is gone) — 7/7. WASM rebuilt (boot-helpers.sxbc).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Picking a candidate to relate it no longer does a full POST -> 303 -> reload.
The candidate <li> now carries an id and its relate form is an AJAX sx-post
(sx-target="#cand-<kind>-<other>", sx-swap="delete"): on success the engine
deletes just that one row — the item is now related, so it leaves the candidate
pool with no reload and no candidate-list refetch. host/blog-relate-submit returns
an empty 200 for an SX request (so the delete swap fires) and still 303s for a
plain POST (no-JS fallback via the form's method+action).
relate-picker.spec.js test 4 updated to assert the in-place row delete + no reload
+ the relation still persists (shows on the post page). 6/6 + conformance 272/272.
(Symmetric unrelate-in-place was prototyped but backed out: the current-links
form, bound via boot's process-elements rather than post-swap, didn't fire the
AJAX delete despite identical markup — a binding quirk to chase separately. Unrelate
keeps its plain POST -> reload for now, no regression.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The declarative picker markup is now a reusable SX component
(lib/host/sx/relate-picker.sx, defcomp ~relate-picker &key slug kind) instead of
inline markup in the editor. It is a CONTENT-ADDRESSED, CLIENT-EXPANDED component:
- Server: on a full page load render-page expands ~relate-picker server-side
(SEO / no-JS), exactly as before.
- Client: on a boosted SPA nav the edit body serialises to the compact
(~relate-picker :slug … :kind …), and the CLIENT expands it. The component
module is compiled to a content-addressed .sxbc, served immutably from
/sx/h/{hash}, and listed in the page's data-sx-manifest "boot" array so the
client eager-loads it after the web stack — registering its defcomp before any
boosted fragment references it.
Wiring:
- lib/host/sx/relate-picker.sx — the component.
- lib/host/blog.sx — editor emits (~relate-picker :slug s :kind k); the inline
form markup is gone.
- lib/host/static.sx — host/static-manifest-json emits boot:["relate-picker.sxbc"]
(the previously-empty boot array, now used as designed).
- hosts/ocaml/browser/sx-platform.js — loadWebStack eager-loads the page manifest's
boot[] modules (content-addressed) after the web stack.
- bundle.sh + compile-modules.js — copy/compile the component to .sxbc.
- serve.sh + conformance.sh — load the component module server-side.
This gives the host an app-component system: app defcomps shipped to the client by
hash, the same machinery as the kernel modules — the picker is the first, and it's
the model for publishing components externally.
Tests: conformance 272/272 (server expansion); relate-picker.spec.js 6/6 incl. the
boosted-nav populate (proves client-side component load + expansion) and the
error/retry case. WASM stack rebuilt (relate-picker.sxbc @ 6818110a).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two engine fixes in web/orchestration.sx (rebuilt into the WASM bytecode) plus the
blog CSS that surfaces them.
1. Retry on NETWORK failure, not just HTTP errors. The fetch error/catch path (the
real offline / DNS / connection-refused case) previously dispatched
sx:requestError and stopped — only a non-ok HTTP response with an empty body
ever reached handle-retry. So "no connection" never recovered. Now the catch
path calls handle-retry too, so an sx-retry element actually self-heals when the
connection returns (the cap bounds the backoff interval, not the attempt count —
it retries forever).
2. Visible failure state. On any failed/aborted fetch the engine adds an `.sx-error`
class to the element (cleared, with the retry backoff reset, on the next
success). Without it a stuck retry loop is invisible — the picker just sits
"Loading…". The blog shell ships CSS so the relate picker shows "Connection
problem — retrying…" / "offline, retrying…" on .sx-error.
Platform-wide: any sx-get/sx-post element benefits, not just the picker.
Tests: relate-picker.spec.js gains a 6th case — abort relate-options, assert
.sx-error appears, un-abort, assert it clears and the picker repopulates (proving
the retry loop is live). 6/6 browser + 272/272 conformance. WASM web stack rebuilt
(orchestration.sxbc + the static hs-* copies refreshed by the same build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three composing pieces that make the blog SPA correct and resilient.
Content-addressed module cache (lib/host/static.sx, serve.sh, blog.sx shell,
conformance.sh): index each web-stack .sxbc by the content hash in its head,
serve GET /sx/h/{hash} immutable text/sx, and emit <script data-sx-manifest>
{file->hash} so the WASM client loads modules content-addressed (localStorage +
immutable) instead of path + max-age. serve.sh builds the index at boot;
conformance.sh now loads static.sx before blog.sx (the shell calls
host/static-manifest-json).
Declarative relate picker (lib/host/blog.sx, lib/dream/form.sx): replace the
inline /relate-picker.js blob — which never ran on swapped-in content, so the
candidate list was empty after a boosted nav to /<slug>/edit — with a declarative
SX-htmx form: sx-get relate-options on "load" + debounced "input", innerHTML-swap
the results ul; infinite scroll via a server-emitted "load more" sentinel
(sx-trigger revealed, sx-swap outerHTML) that pages the rest, q preserved via a
new symmetric dr/url-encode. The engine re-binds these triggers on swapped
content, so the picker populates on full load AND boosted SPA nav. Candidate
relate forms get :sx-disable (plain POST->303->reload, their original behavior;
the engine would otherwise boost them and swap the redirect unreliably).
sx-retry "exponential:1000:30000" on the form+sentinel retries a dropped/offline
fetch forever (the cap bounds the interval, not the attempts).
SIGPIPE hardening (hosts/ocaml/bin/sx_server.ml): the native http-listen server
had no SIGPIPE handler, so a client aborting an in-flight fetch (the engine
cancels superseded requests on a debounced filter/fast nav) closed the socket
mid-write and killed the whole process (exit 141). Ignore SIGPIPE so the failed
write becomes a catchable Sys_error the per-connection handler already swallows.
Tests: host conformance 272/272; relate-picker.spec.js 5/5 incl. a boosted-nav
populate regression; spa-check 4/4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After a fragment swap, process-elements(target) -> process-boosted(target) only
boosted [sx-boost] containers that are DESCENDANTS of the swap target. But the
swap target (#content) is nested UNDER the boost wrapper (<div sx-boost="#content">
<div id="content">), so re-boosting scoped to the target found nothing — the
swapped-in links never got bound. Only the initial document-wide boot boost
worked, so: home->sub worked (home links boosted at boot), but Back restored the
home content unboosted, and the next click did a full page reload. (Post-page
links were unboosted too; Back just exposed it.)
process-boosted now ALSO boosts from the nearest [sx-boost] ANCESTOR of root
(dom-closest), so any swap target inside a boost scope gets its links rebound.
is-processed? guards keep it idempotent.
spa-check: the back-button test now clicks AGAIN after Back and asserts it's a
SPA nav (no full reload) — would have caught this. .sxbc regenerated.
Verified: spa-check 4/4 (incl. click-after-back).
dom-parse-html returned body.childNodes — a NodeList, not a Node — so the client
SX render did appendChild(NodeList) and threw "Argument 1 does not implement
interface Node", silently dropping every raw! HTML block (e.g. a post's <article>
body). It surfaced only now because the blog renders fragments client-side
(text/sx) since this session; before, fragments were server HTML so sx-render
never ran on raw!. The error is caught/non-fatal, and the spa-check suite only
asserted the footer + URL behaviour, so it passed through a dropped post body.
- dom-parse-html now returns a DocumentFragment (moves the parsed nodes in): a
real Node, appendChild-able as one unit, and queryable — which also fixes the
already-broken hs-htmx callers that did (dom-query doc ...) / (dom-first-child
doc) on what was a NodeList.
- spa-check: assert #content article is visible after a boosted nav, so a dropped
post body fails the suite (closes the test gap).
- .sxbc regenerated; bundle dom.sx synced to canonical web/lib/dom.sx.
Verified: spa-check 4/4 (incl. the new article assertion).
Boosted (SPA) requests now return the SX source of the content (serialize) with
content-type text/sx, so the engine's handle-sx-response parses + sx-renders it
client-side on the WASM OCaml kernel — instead of server-rendered HTML. Direct /
no-JS requests still get the full HTML shell (SEO + first paint).
- host/blog--page: fragment branch serializes the body tree to SX wire format
(was render-page -> HTML); full branch unchanged (HTML shell).
- host/blog--resp: new content-type-aware wrapper (text/sx for boosted, text/html
otherwise); replaced the 13 dream-html/dream-html-status call-site wrappers.
- listings built with (cons (quote ul) items) not (list (quote ul) items): the
list form nests children as one list and relied on render-to-html flattening
it; sx-render (client) treats (li ...) as a call -> 'Not callable'. cons splices
them into canonical (ul li1 li2 ...) that renders identically on both sides.
Verified: native host conformance 271/271; SX-Request returns text/sx SX source,
direct request text/html; lib/host/playwright/spa-check 4/4 (boot, boost, SX
fragment swap, back button) in chromium.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking a blog link now fragment-swaps #content with URL push + working back
button, no full reload — the SX-htmx engine driving the same OCaml kernel the
server runs. Six bugs in the source-load + boost path, found by bisecting in
chromium, all fixed:
1. Import double-apply (sx_server.ml x2, sx_browser.ml): the import suspension
handlers computed `key = library_name_key lib_spec` then called
`library_loaded_p key` — but library_loaded_p applies library_name_key
itself, so it ran sx_to_list on a string and crashed ("Expected list, got
string"). Only unloaded libs suspend, so it only bit lazy imports. Pass the
spec, not the key.
2. Unloaded-import crash (spec/evaluator.sx + sx_ref.ml library_exports): an
import of a not-yet-loaded library returned nil exports, and bind-import-set
did (keys nil) -> crash. Return an empty dict so the import is a graceful
no-op (lazy symbol resolution covers real usage).
3. value_to_js missing Integer (sx_browser.ml): integers passed to host methods
were mishandled, so dom-query-all's (host-call node-list "item" i) ignored i
and returned node 0 for every index — every element aliased the first, so
only one link ever boosted. Add the Integer -> JS number case.
4. browser-same-origin? rejected relative URLs (browser.sx x2): it only did
(starts-with? url origin), so "/alpha/" was treated as cross-origin and
should-boost-link? refused every relative link. Accept scheme-less,
non-protocol-relative URLs.
5. dom-query-in undefined (orchestration.sx x2): the swap path called a function
that exists nowhere; it's just dom-query with a container arg.
6. Lazy-deps never loaded under source fallback (sx-platform.js): lazy symbol
resolution only fires on the VM GLOBAL_GET path, but source-loaded swap
callbacks run on the CEK and raise instead of lazy-loading, so the post-swap
hs-boot-subtree!/htmx-boot-subtree! were undefined and aborted URL push.
Preload the manifest's lazy-deps.
Verified: native host conformance 271/271; lib/host/playwright/spa-check 4/4
(boot, boost, fragment swap + URL push, back button) in real chromium against an
ephemeral durable host server.
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>
The 100%-JIT experiment surfaced something worse than the 500s: the kernel
miscompile also returns WRONG RESULTS with no error — blank pages (render map
yields empty) and an empty relate picker (drop in relate-options yields []).
Conformance (CEK) passes these, so the code is correct; the JIT silently
produces garbage. Silent corruption is worse than a crash, so the request path
runs on CEK again (IO-bound — no perf loss). Datalog/relations JIT stays on
(/tags 0.16s). Restoring it brought back content + the 17-candidate picker.
Go 100% JIT again once sx-vm-extensions fixes the OP_PERFORM-resume bug.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per dev intent: don't mask JIT errors. Remove (jit-exclude! "host/*" "dream-*"
"dr/*") so ALL request-path SX runs under JIT. Host handlers miscompile on first
call in the http-listen path (map/rest arg bug → 500, self-heals on retry); that
surfacing is the point — it exercises the JIT against real durable-IO traffic and
gives the sx-vm-extensions loop the full miscompile list to fix (kernel bugs A/B
in plans/HANDOFF-jit-miscompile.md). Datalog JIT win stays (/tags fast).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Datalog/relations saturation is CPU-bound and JITs cleanly: host conformance
271/271 under JIT, 5.4x faster (1m43s -> 19s, same binary); live /tags 2.5s ->
0.76s. loops/host now carries the merged sx-vm-extensions kernel (the JIT engine
+ gate), built into the binary the container bind-mounts.
- docker-compose: SX_SERVING_JIT=1 (default-OFF gate; opt-in here).
- serve.sh: when JIT is on, (jit-exclude! "host/*" "dream-*" "dr/*"). The host app
+ Dream framework MISCOMPILE on first call in the http-listen + cek_run_with_io
path (map/rest emit wrong CALL_PRIM args -> 500; the JIT->CEK fallback marks the
fn failed but does NOT recover the failed call). They're IO-bound, so CEK is no
slower — but the miscompile is a real kernel-JIT bug to fix upstream (see
plans/HANDOFF-jit-miscompile.md), after which this exclude can be dropped.
Verified live: cold pages 200 (no first-hit 500), relate picker lists candidates,
relate round-trip works, /tags fast, datalog still JITs (78 dl-* compiles).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 6 — gradual schema validation made real:
- host/blog-type-schemas now carries a declarative schema (a list of
{:block :msg} required-element rules); "article" requires an h1.
- host/blog--all-tags / --schema-issues / host/blog-type-issues walk the parsed
content and report each missing required block; host/blog-type-valid? = no
issues. A type with no schema imposes nothing (gradual).
- seed an "article" type-post (article subtype-of type). edit-submit now lists
the specific schema issues on a 400 ("an article needs a heading"), so a post
that is-a article must satisfy it on save.
Post-page performance (the unresponsiveness): a post page was ~1s even with no
relations and no load — NOT CPU (render-page ~2ms, in-memory handler ~5ms) but
the DURABLE read path: host/blog--relation-blocks called host/blog-out/in, each
re-scanning the whole KV (host/blog-slugs + an all-edges scan), so a page did ~7
kv-keys performs deep in the call stack. Each durable perform routes through
cek_run_with_io and is costly there. Fixes:
- host/blog-out/in read DIRECT edges from the durable edge store (string scan),
not lib/relations (whose queries re-saturate the Datalog ruleset, ~seconds).
- host/blog--relation-blocks reads the KV key list ONCE and derives both the post
set and the edges in memory (host/blog--edges-for / --recs-slugs), one kv-keys
plus a host/blog-get per linked post. Post pages: ~1s -> ~0.02s (46x); live
11-135s -> ~0.15s. lib/relations stays for TRANSITIVE queries only.
- conformance timeout 300 -> 600s: the relations-heavy blog suite is CPU-bound
under shared-box contention and was tripping a false truncation at 300.
271/271 (blog 100). Verified live: post pages fast, Tags/Related/Tagged-with-this
render, schema rejection works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the hard-coded related/tagged blocks with iteration over the registry,
so adding a kind renders automatically — no handler edit.
- host/blog--relation-blocks: iterates host/blog-rel-kinds; each kind contributes
its outgoing block (label) and, if it has an inverse, its incoming block
(inverse-label, e.g. tagged -> "Tagged with this", is-a -> "Instances"). Empty
blocks dropped; one kv-keys read up front, relation lookups in-memory.
host/blog--relations-or-hint adds the logged-in "add some" hint when empty.
- host/blog--relation-editors: one editor per registry kind on the edit page
(Related / Types / Subtype of / Tags), replacing the hard-coded two.
- GET /tags: index of every tag (a post that is-a tag), each linking its own page.
- dropped host/blog--related-block / --kind-block / --tagged-with-block (folded
into host/blog--edges-block + the registry iteration).
- GOTCHA (4th time): host/blog-tags-index called host/blog-get INSIDE the item
quasiquote -> VmSuspended/500 live (conformance in-memory store can't see it);
pre-fetch records before the quasiquote.
5 tests (relations-section hint, registry render of Related+Tags, inverse block
for a tag, /tags lists + 200). 265/265; Playwright 4/4. Verified live: /tags,
post pages show registry blocks, tag page shows Types + Tagged-with-this, edit
page has a picker per kind.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A tag is just a post that is-a tag; tagging is a "tagged" edge to it. End to end:
mark a post a tag, tag posts with it, see a post's tags and a tag's members.
- helpers: host/blog-is-tag? (= is-a? slug "tag"), host/blog-tags (out tagged),
host/blog-tagged-with (in tagged), host/blog-instances-of (a type's members,
O(#subtypes) not O(#posts) — the efficient candidate source).
- picker generalised to be KIND-AWARE and MULTI-INSTANCE: relate-options takes
&kind=, candidates come from the kind's registry :candidates (all/tags/types);
/relate-picker.js wires every .relate-picker box by data-kind (a Related picker
and a Tags picker now coexist on the edit page).
- render: post page gains a "Tags" block; a tag post additionally lists "Tagged
with this" (its members). edit page: a Related editor + a Tags editor + an
"is this post a tag" toggle (reuses /relate kind=is-a — no new route).
- GOTCHA (again): host/blog--relation-editor read host/blog-out INSIDE its
quasiquote -> VmSuspended/500 under http-listen + durable edges; moved the read
to a let before the quasiquote (conformance can't see it — in-memory store;
the ephemeral Playwright run caught it).
6 conformance tests (is-tag?, instances-of, tag+tagged-with, tagged picker offers
only tags, related picker still all, is-a-tag toggle) -> 261/261. Playwright
multi-picker 4/4. Verified live: ocaml made a tag, welcome tagged ocaml, Tags
block + Tagged-with-this both render.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The spine: types ARE posts, and typing is transitive the right way. is-a
(instance-of) does NOT chain on its own, but subsumption does — an instance of a
subtype is an instance of the supertype.
- registry gains "subtype-of" (directed, transitive). host/blog-types-of(slug) =
declared is-a targets PLUS every subtype-of-ancestor of each (composed host-side
over relations/descendants — no new Datalog rules). host/blog-is-a?(slug,type)
is transitive through subtype-of.
- host/blog-seed-types! seeds the root type-posts "type" and "tag" (real posts
that document themselves) with tag subtype-of type, so anything is-a tag is
transitively a type. Idempotent; wired into serve.sh.
- gradual-validation seam: host/blog-type-schemas (empty) + host/blog-schema-of +
host/blog-type-valid? (vacuously true with no schemas) wired into edit-submit
alongside the parse check — enforcement is a one-line add later, not a retrofit.
6 tests: types-of = declared + all subtype-of supertypes; is-a? transitive
through subtype-of; is-a alone does NOT chain; instance of tag is transitively a
type; type-valid vacuous with no schemas. 255/255.
Verified live: /type/ + /tag/ render as posts, tag subtype-of type survived a
recreate (durable), ocaml is-a tag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sessions were in-memory, so a restart logged everyone out (same class as the
relation wipe). Move them to the durable store, but LAZILY so anonymous/crawler
traffic doesn't spam it: session/create mints a sid with no row; the row appears
on the first session/set (a login). A per-boot epoch (one durable write at
startup, host/session-init!) keeps sids unique across restarts without a write
per request.
- lib/host/session.sx: lazy backend (create = no row, set = create row,
exists = row written) + epoch/in-memory-counter sid generation.
- serve.sh: point the session store at the durable backend + host/session-init!.
- blog.sx: host/current-principal is now a durable read, so host/auth-footer
(home + post footers) had to move OUT of the quasiquote into let bindings —
a perform during page-tree build raises VmSuspended (the whole site 500'd for
a beat). Principal computed once per page.
- 2 session tests: create writes no row, set creates the row.
249/249. Verified live: site renders (anon + authed), login + footer survive a
container force-recreate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lib/relations holds the graph in memory only (a Datalog cache), so related/tags/
types were wiped on every restart while the posts (durable KV) survived — fatal
for a model where tags and types ARE relations. Make the host the durable source
of truth.
- every physical edge is also a KV row "edge:<src>|<kind>|<dst>" in the blog
store (host/blog--add-edge!/--del-edge! wrap relations/relate+unrelate with
kv-put/kv-delete). '|' is safe: slugs are [a-z0-9-], kinds are registry names.
- host/blog-load-edges! rebuilds the in-memory graph from edge:* keys; serve.sh
calls it on boot right after pointing the store at the durable backend.
- lib/relations stays an in-memory cache; the durable KV is the source of truth
(same shape as the blog pointing at the durable backend).
3 tests: KV row written on relate, replay rebuilds the graph after an in-memory
wipe (restart sim), unrelate deletes the row. 247/247.
Verified live: related welcome<->hello, force-recreated the container (wipes the
in-memory graph), the relation + its rendered block survived the restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plan: plans/typed-posts-and-relations.md. "Typing is just relating to a type",
types are posts. Phase 1 lifts the hard-coded kind:"related" into a parameter,
driven by one registry — the spine the later phases (type resolution, tags,
picker) build on. Zero user-visible change.
- host/blog-rel-kinds registry: {kind,label,symmetric,candidates[,inverse-label]}
for related (symmetric) / is-a / tagged (directed). One place knows each kind's
direction, label, and candidate set.
- host/blog-relate!/unrelate! take a kind; symmetric kinds write both directions,
directed kinds write one. host/blog-out/in read children/parents per kind;
host/blog-related = out(slug,"related") (back-compat).
- relate/unrelate routes carry a `kind` form field (default "related"), validated
against the registry. delete drops edges across ALL kinds + both directions.
6 tests: symmetric reads both sides, directed writes one (inverse via host/blog-in),
unrelate is kind-scoped, unknown kind rejected, default kind = related. 244/244;
Playwright picker 4/4 (related path unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire a browser check for the picker, run it against an ephemeral host server,
and fix the two real bugs it surfaced.
- lib/host/playwright/relate-picker.spec.js — drives login-redirect-return,
JS candidate load + infinite scroll, debounced filter, and click-to-relate
(asserting the relation shows on the post page).
- lib/host/playwright/run-picker-check.sh — spins up an ephemeral host server
(this worktree's binary + lib, temp persist), seeds a host post + 25
candidates, runs the spec in the main worktree's Playwright/chromium, tears
everything down. No live-site dependency, no live-data pollution. 4/4 pass.
Bugs the check caught:
1. Query params weren't %-decoded — dream's form parser decodes but its query
parser doesn't, so a filter "Item 13" arrived as "Item%2013" and matched
nothing. Fix: decode q with dream's own dr/url-decode in host/blog-relate-
options. (+ conformance test for a spaced filter.)
2. A filter typed while a load was in flight got dropped (busy guard returned
with no trailing fetch). Fix: a `pending` flag re-runs the load when the
in-flight one finishes, coalescing to the latest query.
239/239 conformance; JS node --check clean. Verified live: spaced filter
returns matches; served JS carries the pending-reload fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make relating discoverable and pleasant: a hint on posts with no relations, and
a real candidate picker on the edit page.
- post page: when a post has no relations AND the viewer is logged in, show a
subtle "No related posts yet — add some" hint linking to the edit page;
anonymous viewers still see nothing.
- GET /<slug>/relate-options?q=&offset= — SX endpoint returning one page of
candidate rows (HTML <li> fragment): every post except itself and ones already
related, narrowed by q (case-insensitive title/slug substring), title-sorted,
paginated by host/blog--picker-limit. Public read; the relate POST stays
guarded.
- GET /relate-picker.js — small vanilla glue (debounced live filter +
scroll-to-load-more) served from a route. The host serves static HTML (no SX
island hydration), so the interactive layer is a cached script, not an island;
data-slug on the input carries the post to it.
- edit page: the plain "slug to relate" box becomes a filter input + scrollable
results list (#relate-filter/#relate-results) populated by the script; each row
is a one-click relate form.
8 tests: endpoint lists/excludes-self/filters-by-q/excludes-already-related, JS
route content-type + glue, hint shown logged-in / hidden anonymous. 238/238.
Verified live: hint (logged-in only), candidate rows, q=filter, JS route
(node --check OK), edit picker UI with data-slug.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Compose two already-migrated domains: a post is a relations-graph node
"blog:<slug>", and a "related" link is a symmetric pair of edges
(lib/relations). The post page shows a "Related posts" block; the edit page
gets an editor to add (by slug) and remove relations.
- host/blog-relate!/unrelate!/related: symmetric edges under kind "related";
related slugs = blog children, existence-filtered against ONE kv-keys read.
- post page: "Related posts" links block; edit page: related editor (remove
buttons + add-by-slug box).
- POST /:slug/relate, /:slug/unrelate — guarded browser routes (redirect to
login like the other write routes); relate validates the other post exists.
- delete cleans up a post's related edges (no dangling links).
IO ORDERING (the live 500 that conformance missed): host/blog--related-block/
-editor do durable reads (perform). Performing inside the quasiquote, via
unquote, while the page tree renders raised Sx_vm.VmSuspended under http-listen;
the in-memory conformance store never performs, so it passed. Fix mirrors
host/blog-home: do the reads in the handler's let bindings BEFORE the
quasiquote, and check related-existence against a single host/blog-slugs read
rather than a perform per candidate inside filter.
9 relate tests (guard, symmetry, render, no-op on missing, unrelate both ways,
delete cleanup). Verified live: relate -> Related block both ways; unrelate
clears it; posts without relations and the whole site stay 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Login had no visible entry point — you could only reach it by hitting a guard.
Add an auth footer the pages splice in: "log in" when logged out, "signed in
as <user> · log out" when logged in.
- host/auth-footer: SX fragment reading the session principal; guards a
session-less request so it's safe to call anywhere.
- GET /logout added alongside POST so the footer link is a plain <a> (logout
is low-harm; GET is acceptable). Clears the session, redirects home.
- home and post pages splice (host/auth-footer req) into their footer.
Tests: home + post footers show a login link when anonymous; GET /logout ->
303. 221/221. Verified live: anonymous shows "log in"; logged in shows
"signed in as admin · log out"; /logout reverts it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking "edit" while logged out returned a raw JSON 401
{"ok":false,"error":"unauthorized"} — a dead end in the browser. HTML routes
now redirect to a usable login page and return you afterwards.
- host/require-login: browser-shaped guard. Same session-or-bearer check as
host/require-user, but on failure REDIRECTS to /login?next=<path> instead of
JSON 401. (host/require-user stays for JSON/API routes.)
- host/-principal-of: shared session-then-bearer resolution.
- login honours ?next=: GET /login renders a hidden next field; POST /login
redirects there on success and re-renders the form (with next) on failure.
- host/-safe-next: only same-site absolute paths are honoured — //evil.com and
http://… fall back to "/", closing the open-redirect.
- blog: host/blog--protect-html (require-login) guards the browser routes —
POST /new, GET/POST /:slug/edit; the JSON /posts routes keep host/require-user.
Do we need login? Yes — it's the write/edit auth boundary; without it anyone
could edit or delete posts. The bug was the dead-end 401, not the gate. Now
logged-out edit -> login -> back to edit is a clean flow.
Tests: blog no-auth write routes assert 303 + Location /login(+next); session
suite gains next round-trip + open-redirect-guard cases. 218/218.
Verified live: /welcome/edit logged out -> 303 /login?next=/welcome/edit;
login -> 303 back to /welcome/edit -> 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Posts ARE SX source, so expose it: a public raw-source view and a guarded
in-browser source editor.
- GET /<slug>/source — raw sx_content as text/plain (public; a published
post's source isn't secret).
- GET /<slug>/edit — edit form pre-filled with the post's title, raw source
(in a textarea, render-to-html-escaped so it shows verbatim), and status
(current value pre-selected). Guarded (editor only). Slug is preserved.
- POST /<slug>/edit — save the edited source; same write-time validation as
create (unparseable body -> 400, post left intact); 303 back to the post.
- post page gains "view source · edit · all posts" footer links.
Routing: /:slug/source + /:slug/edit are two-segment patterns; the router
consumes :param as exactly one segment and requires a full match, so /:slug
does not shadow them (asserted). 14 new blog tests cover view (200/text-plain/
raw body/404/no-shadow) and edit (401 unauth GET+POST, 200 form, source shown,
303 save, persisted, slug preserved, 400 malformed, 404 missing).
Verified live on blog.rose-ash.com: view source, guarded edit form, save
round-trip (rendered post + source both reflect the edit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>