Dashboard gains a per-route latency bar chart (nested p50/p95/p99 bars, tail
visible) + status-colored waterfall with ms duration labels + a real 3s
auto-refresh (replacing the non-functional data-on-load SSE attr). serve.sh
self-warms the serving JIT over /dev/tcp so the first visitor after a restart
gets ~78ms not the one-time ~2.5s compile. otel suite 125/125.
Dashboard: drop the non-functional data-on-load SSE attr; add <meta refresh 3s>
so it genuinely live-updates (the host serves single-body responses, no
server-push SSE). /otel/stream stays a snapshot for pollers.
serve.sh: replace the ineffective boot-time make-app warmup (wrong JIT context)
with a backgrounded self-warmer that GETs the hot pages over real HTTP (bash
/dev/tcp — no curl in the image) once /health is up, so the first real visitor
after a restart gets ~78ms instead of the one-time ~2.5s serving-JIT compile.
The blog render path (comp-fold + relations + typed-block) JIT-compiles on first
call, so the first visitor after a restart paid ~2.5s (vs ~78ms warm) — that was
the /:slug p99 tail. Define the route groups once, render / + welcome +
nt-live-encore + /otel through a throwaway app at boot to force compilation, then
reset the otel ring so warmup spans don't skew live metrics.
The blog post-detail route /:slug matches any single segment, so /otel was
being served as a missing blog slug (404). Order otel/routes ahead of the blog
routes so the literal /otel + /otel/stream match first.
The otel dashboard route (GET /otel) is single-segment, so blog-routes' /:slug catch-all
shadowed it (404 'no post: otel'); only /otel/stream (two segments) survived. Move otel/routes
ahead of the blog routes. Live-only wiring fix (route order); no test change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds lib/host/otel.sx to serve.sh MODULES and otel/routes to the host/serve
group list so GET /otel (+ /otel/stream) serve on the live host once merged.
Build-time wiring only; no container touched.
otel/format-traceparent + otel/current-traceparent emit '00-<32hex>-<16hex>-01';
otel/parse-traceparent round-trips it (nil on malformed/bad-width). otel/-timed
now guards the thunk: success spans get :status ok, a raised error records a
span with :status error + an exception event then propagates. Error propagation
uses a false-returning guard clause test (an explicit (raise e) in a guard
handler re-enters the guard and hangs).
otel/export-otlp folds spans → OTLP/JSON envelope (resourceSpans → scopeSpans →
spans) with hex traceId(32)/spanId(16)/parentSpanId, uint64-as-string nano
timestamps, typed attributes (stringValue/intValue), and span kind
(SERVER/INTERNAL). otel/export-otlp-json encodes via dream-json-encode;
otel/post-otlp POSTs through an injected transport (testable without a live
collector).
otel/dashboard SSRs the metrics strip + latest-trace waterfall + recent-traces
list as HTML carrying Datastar-style data-on-load subscribing to /otel/stream,
the SSE feed of SXTP otel.span events. Routes otel/dashboard-route +
otel/stream-route (otel/routes) mount via make-app. recent-traces/latest-trace
+ otel/span-event helpers.
otel/metrics folds spans → {:total-requests :routes}; each route carries a
request count and nearest-rank latency percentiles over its durations. Route
key is the http.route attr (falls back to span name). Includes a small
insertion sort (no sort primitive) and order-preserving distinct.
otel/waterfall-rects folds a trace's spans into rect geometry (x by start
offset, width by duration, y by depth via parent-link ancestor count);
otel/waterfall folds those into an inline <svg> (one <rect>+<text> per span).
Renders to real SVG markup via the html tag registry.
otel/instrument-routes wraps each flattened Dream route's handler in a timed
span named METHOD /route with {:http.method :http.route :http.status} attrs;
host/make-app applies it so every matched request becomes a trace. Refactored
with-span onto a shared otel/-timed core that takes a finalize fn for
result-derived attrs (the http.status only known post-handler).
Clamp against a high-water mark so the clock never steps backwards; span
durations stay non-negative. Real ns-scale timestamps replace the P1
placeholder counter.
host/blog-seed-nt-live-encore! now embeds the RAW Ghost HTML (from rose-ash.com/rss) and
imports via the "html" field, so host/html->sx converts it at boot — no more pre-converted
sx_content from the external Python script. Verified: the converter produces the identical 11
cards (card-image/text ×4 pairs + 3 card-embed), handling the real post's kg-card comments,
srcset, and nested figcaption markup. blog 197/197.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lib/host/htmlsx.sx — a pure-SX HTML → SX converter (char-level tokenizer + stack parser):
host/html->sx turns a post's HTML into an (article …) tree that host/blog--decompose! consumes
— img / p / figure+figcaption / iframe / headings / blockquote / lists, inline strong/em/a kept
nested (decompose flattens to text), entities decoded to UTF-8, comments+doctype skipped. This
replaces the one-off external Python converter used for the nt-live-encore import.
import-post! now accepts a raw "html" field (converted via html->sx, serialized to sx_content,
decomposed) alongside "sx_content" — so importing real Ghost HTML is first-class. Wired
htmlsx.sx into conformance.sh + serve.sh module lists (loads in conformance AND live).
New htmlsx suite 8/8 (text/entities/void/nested/figure/iframe/comments + an html→sx→decompose→
typed-cards round-trip); blog 197/197 (+ import-from-html test).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
host/blog-seed-landing-demo! (+ host/blog--seed-card! fixed-slug helper): a Landing TYPE with
TWO composition fields — :body (heading/text/image + cond/each) and :aside (text/callout, no
controls) — plus a populated landing-demo instance, wired into serve.sh (survives wipes),
idempotent (fixed card slugs, set-comp! overwrites). /landing-demo/ renders both fields; its
edit page shows two independent block editors (#comp-body, #comp-aside); /landing/ reads the
two-field definition. Demonstrates layer 2 end to end on the live site.
blog 196/196 (+ tests: idempotent 2-field seed, both fields render).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every type post reads as schema + extension. Added host/blog--type-population (host/blog--take
helper): a type's page shows its instances (posts is-a it, first 24 + count) and its subtypes
(is-a / subtype-of inverses), next to the read-only type definition. Injected in host/blog-post
when host/blog--is-type?. So /article/ shows what an article IS *and* which posts are articles;
/card/ shows its subtypes; every card type / tag / type reads its own definition (all are
is-type?).
blog 194/194 (+ tests: population lists instances + count, a parent type lists subtypes, GET
/article/ shows Population).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A type post's public page (/article/) now shows a read-only Type-definition panel: its fields,
each Composition field's block grammar ("may contain: heading, text, image, …; control blocks:
cond, each"), and the relations its instances may use — so anyone can read what a type IS, not
just admins on the edit page. host/blog--type-def-view (the read form of host/blog--type-def-
editor's data); injected in host/blog-post after the body when host/blog--is-type?.
blog 191/191, full conformance 420/420 (+ tests: the view renders fields/grammar/relations;
GET /article/ shows it, an instance's page doesn't).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
related / is-a / subtype-of / tagged are part of an object's composition (external — NOT in the
CID), and the TYPE declares which relation kinds its instances may use (:type-relations; absent
-> all kinds, so metamodel types keep full freedom). host/blog--{all-rel-kinds, type-relations,
set-type-relations!, allowed-relations, relation-allowed?}. The relation editors filter to the
permitted kinds; relate-submit ENFORCES it. article declares (related is-a tagged) — an article
instance can't be subtyped. The type-def editor (Part C) gains a relation CHECKLIST + POST
/<type>/relations, so the type's inline block-grammar AND external relations are edited in one
place: "it's just more composition."
blog 189/189 (+ Part B tests: allowed-relations excludes subtype-of for article, editors filter,
relate rejects a forbidden kind, checklist renders, POST /relations sets it). Full conformance
deferred — the sibling OTel loop is contending on the shared warm-conf dir; Part B touches only
blog.sx, so the other 7 suites are unaffected. Verifying live instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"It's just more composition": a type post's edit page now shows a Type-definition editor —
each field as name:type, and each Composition field with a GRAMMAR CHECKLIST (a checkbox per
card kind = permitted, + conditional/repeater toggles). Editing it changes what the type's
instances may contain. host/blog--{is-type?, set-field-grammar!, own-field, checkbox,
grammar-form, type-def-editor}; POST /<type>/grammar reads the checklist (uniquely-named
blk-<ct> / allow-<ctrl> boxes, since form fields are single-value) → set-field-grammar!.
Shown only when host/blog--is-type? (declares fields, or subtype-of type) — a type's page has
it, an instance's doesn't.
blog 184/184, full conformance 413/413 (+ Part C tests: is-type?, set-field-grammar!, the
checklist renders, POST /grammar sets it, appears on a type page not an instance's).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The type now GOVERNS the composition, not just declares the slot. A Composition field carries
its grammar: {:name "body" :type "Composition" :blocks (…card types…) :allow ("cond" "each")}.
:blocks absent -> any card subtype (back-compat); :allow absent -> both control blocks.
- host/blog--{field-decl, allowed-blocks, allows-control?, block-allowed?, comp-violations}.
- The editor PALETTE is the grammar: one <option> per allowed card type (spliced as direct
<select> children), and the conditional/repeater add-forms appear only if :allow permits.
- block-add-submit ENFORCES it (was a coarse "any card subtype" check) — the type governs writes.
- comp-violations flags a composition holding a forbidden block (the save/import gate).
- article declares its :body grammar (all 7 card kinds + cond/each).
blog 179/179, full conformance 408/408 (+ grammar tests: allowed-blocks/allows-control?,
palette shows only permitted kinds, add rejects a forbidden card, violations flags one).
Part B (relations as type-governed composition) + Part C (edit the type definition) next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
host/blog-seed-nt-live-encore! imports the real post (its HTML-derived sx_content embedded)
via host/blog-import-post!, decomposing it into the :body composition of typed cards; wired
into serve.sh next to the demo seeds. Verified: after a full store wipe + reboot it reseeds
(HTTP 200, 4 images, 3 video embeds, tagged nt-live/films). Idempotent. blog 175/175.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prep for importing a real blog post into the :body composition:
- article now DECLARES {:name "body" :type "Composition"} (layer 2 — the type defines that an
article's body is a composition). The edit FORM + submit read scalar-fields only, so the
Composition field never gets a stray text input (or gets nil'd on save).
- decompose handles real-post block kinds: <figure> → card-image WITH its <figcaption> as the
caption (host/blog--find-child digs out the inner <img>); <iframe>/<embed>/<video> →
card-embed with src as :url. card-embed's template now renders an actual <iframe> (videos
play) instead of the url as text.
blog 175/175, full host conformance 404/404 (+ test: figure→card-image(caption) & iframe→
card-embed via import). Next: wipe content (reseed types+demos), import nt-live-encore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
: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>
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>
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>
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>
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>
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>
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>