Step 3 — federation, live-verified with TWO real host instances.
- host/ta.sx: host/ta--post/make-http-wire/federate (POST a serialized activity to a peer's /inbox
over real HTTP). host/blog.sx: POST /inbox (host/blog-inbox → receive! → process locally, does NOT
re-federate — no loops).
- DURABLE OUTBOX (fed-sx reliability, after the user asked 'if B is down does it still work?'):
emit! processes locally (always succeeds), QUEUES per-peer to a persisted outbox, delivers
best-effort. A peer being DOWN no longer fails the publish — delivery is GUARDED (SX guard catches
the http-request connection error), failed items stay queued and retry on next emit / on boot /
manual /flows?flush=1. /flows shows the outbox depth.
- serve.sh: SX_PEERS → peers; boot load+flush of the outbox. docker-compose: a 2nd host sx_host_b
(peer B, own store, no peers).
LIVE PROOF: (1) a peer POSTs create/article to blog.rose-ash.com/inbox → A fires validate+notify.
(2) publish on A → federates to B → B fires ITS behaviors on A's activity (B's /flows + /activities).
(3) RESILIENCE: publish with B DOWN → A returns 303 (was 500) + queues; start B + flush → B receives
the backlog + fires. blog 218/218 (+TA receive test), full host conformance green.
A = blog.rose-ash.com (public/Caddy); B = sx_host_b (internal docker DNS only, no public domain).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Steps 1+2 of RA-live/TA-live, live-verified end-to-end on blog.rose-ash.com.
(1) DEPLOY: docker-compose.dev-sx-host.yml gains an sx_kernel service running next/kernel/serve.sh
(the durable-execution kernel), SX_HTTP_HOST=0.0.0.0 so the host container reaches it at
http://sx_kernel:8930.
(2) HOST AS CLIENT: lib/host/ra.sx gains a KERNEL runner — host/ra--make-kernel-runner drives the
kernel over HTTP (http-request, native primitive; returns {status headers body}). It advertises
{effect,branch,each,suspend}, so select-runner routes a durable DAG to it. host/blog.sx: the DAG
registry + runner fleet are now mutable (register-dag!/add-runner!); emit! records SUSPENSIONS in a
durable pending log; /flows shows suspended instances with a resume link (?resume=<id>) driving
host/ra--kernel-resume. serve.sh wires it: set kernel-base, add the kernel runner, register the
durable 'blog-digest' DAG, declare a DURABLE behavior on article (create→publish SYNC, update→
blog-digest DURABLE), add a 'category' field.
LIVE PROOF: editing a published newsletter article → Update → routes to the kernel runner → POST
/flow/start/newsletter → kernel SUSPENDS (instance 5, shown pending on /flows) → /flows?resume=5 →
host re-drives the kernel → DONE → digest-sent effect + pending cleared. Durable suspend/resume
across separate HTTP requests, on a deployed persistent kernel. urgent edits complete immediately
(digest). http-request works in the serving context. blog 217/217, full conformance green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lib/host/ta.sx — a seam transport {:emit :deliver} over a DIRECTIONAL wire (out = outbox→followers,
in = inbox←follows). The transport is the SERIALIZATION boundary: activities cross the wire as
SX-source strings (host/ta--serialize/deserialize map the keyword-keyed activity ↔ a flat
string-keyed wire form of the P2 activity fields). host/ta--make-transport(out-wire, in-wire) +
host/ta--make-mem-wire (an in-memory directional queue for tests).
Proven (ta 5/5): content + relation activities round-trip through the wire; the FEDERATION LOOP —
instance A emits an activity → the wire carries it → instance B's behavior/pump delivers + processes
it → B's engine fires ITS behavior on A's activity; DIRECTIONAL (B re-emits to its own outbox, not
back into the inbox — no loop). 'Everything works over fed-sx', proven at the seam.
TA-live (deferred, same shape as RA-live): swap the mem-wire for the real next/ delivery wire —
needs a PERSISTENT next/ kernel (gen_servers don't survive across erlang-eval-ast calls) + the ACTOR
MODEL (peer_actors/follower_graph decide who the out-wire delivers to) + pushing /activities onto it.
Full host conformance green (+ta 5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generalizes emission beyond publish to the full event source. TWO ActivityPub-faithful classes:
- CONTENT (host/blog--content-activity): Create on first publish, Update on a subsequent published
edit. object-type is DERIVED from the post's is-a (host/blog--post-type), not hardcoded 'article'.
- RELATION (host/blog--relation-activity): Add/Remove, carrying :relation + :target (the edge).
host/blog--emit! runs any activity through behavior/process (logged + matched). emit-content-change!
(create/update) wired into form-submit + edit-submit; emit-relation! (add/remove) wired into
relate-submit + unrelate-submit.
DEBT #1 FIXED — per-EVENT :id (not the bare CID): content = create:/update:+cid; relation =
add:/remove:+src:kind:dst (EDGE-based, because a relation change doesn't shift the CID, so a
CID-based id would false-dedup different edges on one object).
The activity log is now the DURABLE EVENT SOURCE (string-keyed records under 'activitylog',
boot-loaded), surfaced at /activities — what TA will push to peers.
LIVE PROOF (blog.rose-ash.com): publish → /activities 'create article <cid>'; relate → 'add article
p2-events — add welcome related'; unrelate → 'remove …'. blog 217/217 (+4 P2, reframed P0.3 fire
tests for Update semantics), full host conformance 614/614.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generalizes the hardcoded publish trigger into declared, capability-routed behavior.
- Types carry :behavior — flat string-keyed bindings {"verb" "type" "dag"} on the type-post
(persist-safe, like :type-relations). The "article" type declares on-create → the "publish" DAG.
- host/blog--load-behaviors! gathers ALL posts' declarations into a registry at boot (serve.sh); the
trigger match (host/blog--triggers :match = host/blog--match-behaviors) consults it. Hardcoded
create+article trigger removed.
- Runner DERIVED (DEBT #2 fixed): match resolves :dag via host/blog--dag-registry and picks the
runner via host/flow--select-runner over host/blog--runner-fleet ([exec-runner]; RA joins at
RA-live). Each binding carries its :runner; behavior/-run-binding now uses the binding's runner
(else the engine default) — so the capability model drives the LIVE engine.
- The type-def view shows each behavior + its derived runner (host/blog--behavior-lines).
LIVE PROOF: /article shows 'on create → publish DAG · needs {effect, branch} · runner: synchronous
(exec-fold)'; publishing on blog.rose-ash.com fired /flows validate+notify via the DECLARED path.
blog 213/213 (+3 P1), full host conformance 610/610. FINDING: load-behaviors! scans all posts, not
is-type?-filtered (article failed is-type? on the durable store though it passed in-memory).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lib/host/ra.sx — a PURE-SX seam runner (advertises {effect,branch,each,suspend}) with an INJECTED
erl-eval (real = er-to-sx-deep ∘ erlang-eval-ast; mock in unit tests), so it loads in the plain host
(Erlang refs resolve lazily inside lambdas) and is unit-testable without the Erlang runtime.
host/ra--{atom,bin,erl-src,start-expr,resume-expr,parse,make-runner,resume,real-eval}: marshals our
canonical activity → Erlang source (CID as <<"…">> binary, atoms single-quoted), starts a named
next/ flow via flow_store, parses (ok Id (flow_done V))→{:status done :effects V :flow-id} /
(ok Id (flow_suspended T))→{:status suspended :resume {:id :tag}}.
DUAL-RUNNER ROUTING (flows.sx): host/flow--required-caps now handles a {:erl-flow :needs} DAG
(declared caps, since a foreign flow can't be introspected); host/flow--select-runner picks the
cheapest runner whose capabilities cover the DAG's needs. The capability model is now REAL with two
runners — an {effect,branch} composition lands on exec-runner; a {suspend} DAG routes to RA.
Verified: ra 9/9 (mock erl-eval) + plans/ra-integration.sh 4/4 (the REAL module driving live
flow_store: urgent→done, newsletter→suspended with a resume handle, digest_sent effect-as-data).
Full host conformance 607/607; next/tests/triggers_e2e.sh 10/10 baseline intact.
FINDING → RA-LIVE deferred: gen_servers don't persist across separate erlang-eval-ast calls (flow
README), so true cross-call suspend/resume needs a PERSISTENT next/ kernel process. The runner +
marshalling + suspend/resume mechanics are proven; RA-live is process lifecycle + wiring, documented.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
REVIEW at the P0-complete milestone found one live bug and several forward prerequisites.
FIX (was live): edit-submit ran maybe-publish! BEFORE set-field-values!, so an edit that set a
category and published in one submit fired the publish activity on the STALE category (wrong branch).
Reordered — fields land before the transition fires. Regression test added (fields-first →
newsletter→digest, not stale→notify). blog 210/210.
Recorded carried-forward debt in the plan: activity identity (DEBT #1, blocks P2 — :id=CID false-
dedups relation events), capability bind not wired into the live engine (DEBT #2, P1), synchronous-
in-request dispatch (DEBT #3, RA needs the async boundary + background pump), the 'urgent' default
smell (DEBT #4). Sequencing note: P1's runner-derivation is vacuous until RA adds a 2nd runner, and
RA is the load-bearing risk — recommend a narrow RA spike next to de-risk the durable/federated half.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
host/blog--publish-activity now emits the CANONICAL seam shape {:verb :actor :object <cid>
:object-type :slug :category :delta :id}: :object is a content-addressed REFERENCE (the CID, not an
inlined dict), :id the dedup identity, :slug+:category the domain fields the DAG reads. Consumers
reconciled — the on-publish trigger matches :verb+:object-type; publish-ctx reads top-level
:category+:slug. Added host/blog--activity->erl: marshals the canonical activity → next/'s Erlang
proplist for the Erlang runner adapter (RA) — defined + tested, unused until RA so the reconcile is
complete and RA's bridge is ready. (:ts/:prev omitted — no clock primitive in the host; deferred.)
LIVE PROOF: published on blog.rose-ash.com → /flows fired validate+notify with the canonical
activity. blog 209/209, full host conformance 597/597.
P0 COMPLETE: the synchronous publish workflow runs end-to-end on the live host through the
substrate-agnostic seam, durably, in the canonical shape, with the RA marshaller staged. RA (Erlang
runner) + TA (fed-sx transport) plug in next without touching the DAG or the wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The driver now persists each effect record to the blog store (string-keyed to dodge the keyword/
persist top-level split), and host/blog-load-flowlog! rebuilds the in-memory log on boot (wired into
serve.sh after load-edges!). So /flows survives a restart — closing the P0.3 gap.
LIVE PROOF: published a post on blog.rose-ash.com → /flows showed validate+notify → RESTARTED the
container (in-memory log lost) → /flows STILL showed them, reloaded from the durable store.
Round-trip also covered by a conformance test (persist → clear → reload → identical). blog 208/208,
full host conformance 599/599. Note: whole-list rewrite per effect — fine at P0 volume, cap/rotate later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Publishing a post now fires the on-publish behavior DAG through the seam. host/blog--{transport
(activity log), triggers (on-publish: create+article → publish-DAG), driver (records each effect in
the flow log), publish-engine (behavior/make-engine over the four adapters + the execute-fold runner
+ publish-ctx), fire-publish!, maybe-publish!}. Both write handlers (form-submit POST /new,
edit-submit POST /:slug/edit) detect the draft→published TRANSITION (fire-once) in the handler body
and run behavior/process. GET /flows renders the flow log (the effect-as-data the driver dispatched).
LIVE PROOF: logged in + POST /new on blog.rose-ash.com → /flows shows 'validate' + 'notify' (the
publish-DAG branched on the default urgent category), driven end-to-end by the real behavior engine.
Every piece is a seam adapter — swapping the runner for Erlang (RA) or the transport for fed-sx (TA)
federates this same wiring unchanged.
blog 207/207 (+4 P0.3), full host conformance 595/595. GAP: flow log is in-memory (P0.3b = persist).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the user: the execute-fold-vs-artdag split from P0.2 is a capability SNAPSHOT, not a permanent
boundary. artdag MAY grow +{effect,branch,each} node-kinds; business logic then migrates onto it to
inherit the DAG-engine superpowers — content-addressed memoization (recompute only on input-CID
change), optimize (fuse/dedup/dce), schedule, and above all FEDERATION (a flow result reused across
peers by content-id — the federation vision, for free). The capability model makes the migration
seamless (same DAGs + seam; the runner just advertises more). Named the real design work: dynamic
control in a static DAG (branch prunes a path); effect nodes non-cacheable vs pure nodes memoized.
Demand-driven (phase AX); execute-fold stays the lean default for cheap synchronous flows. Annotated
the P0.2 finding + flows.sx header so the finding doesn't harden into dogma.
Doc/comment-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hypothesis test. FINDING: a synchronous business flow expresses NATURALLY as an EXECUTE-FOLD
composition (host/execute.sx: seq/effect/alt — the category branch IS 'alt'), NOT an artdag
DATAFLOW DAG (which has no control flow). So 'business logic = art-dag' holds at the ABSTRACTION
(both content-addressed op-DAGs) and is REFINED at the vocabulary: the synchronous control-flow
runner is the execute-fold (caps {effect,branch,each}); artdag is the dataflow sibling. Two
instances of one thing, run very differently — exactly the framing.
lib/host/flows.sx: capability typing (host/flow--node-cap/required-caps derive a DAG's capability
set from its node vocabulary; effect→effect, alt→branch, each→each, wait→suspend), the execute-fold
seam runner (advertises {effect,branch,each}), and host/flow--bind (required ⊆ advertised → derive
the runner, else fail-fast). host/blog--publish-dag (the publish workflow) + publish-ctx.
Verified: publish-DAG required-caps = {effect,branch} → binds to the sync runner; runs →
newsletter→[validate,digest] / urgent→[validate,notify] / other→[validate,skip]; a node →
{suspend} → binds FAIL-FAST against the exec-runner (would need the Erlang runner, RA). Runner is
DERIVED, not chosen. flows 7/7, blog 203/203, full host conformance 591/591.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second review of the (core) seam caught a subtle one — and that my first 'fix' was itself wrong.
The async completion of a SUSPENDED durable flow happens AFTER the synchronous process call has
returned, so an :emit captured in the run env would be stale. The correct seam is construction-
wiring: a durable runner is wired to the transport's INBOUND channel at construction and injects
its completion activity there, out-of-band; a later behavior/pump drains it → effects flow. So the
engine code was already right (pump is the async re-entry seam); only the contract comment was
wrong — corrected. New test proves the loop: process(wait) suspends (no effect), then pump drains
the out-of-band completion → the flow's digest effect flows. Also clarified: dedup is per-
invocation (global idempotency = emitter fire-once + durable inbox); retry is flow-level; the
engine-facing runner result is {:status :effects :resume :error} (:results is runner-internal).
behavior 10/10 (+ async-completion). No engine change — comment + test only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After review, the seam was only synchronous-complete; the durable/celery-sx runners couldn't
plug in cleanly. Additive fixes (pipeline unchanged): (1) :status branch in run-binding — 'done'
dispatches effects, 'suspended' records the flow + :resume (a durable runner holds it; completion
re-enters as a new activity via pump), 'failed' records + :error for retry/dead-letter. (2) richer
runner env — :ctx (per-activity, via engine :ctx-of) + injected :effects (external-read interfaces,
e.g. a deterministic fetch_followers). (3) dedup by content :id — a cycle is caught by identity,
not just the depth guard. (4) behavior/pump — drain transport.deliver for inbound (peer activities
+ async runner completions), sharing one trace so dedup spans the batch.
behavior 9/9 (+ suspended/failed/dedup/env/pump); full host conformance 580/580.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lib/host/behavior.sx — the substrate-independent seam every runner/transport/registry/driver
plugs into. An engine bundles four dict-of-functions adapters (trigger-registry, runner,
transport, driver); behavior/process folds an ACTIVITY through the pipeline: emit → match
triggers → run each behavior DAG → dispatch each effect-as-data → recurse on new activities
(loop closure, depth-guarded at 8). Every stage injected, so the same DAG + engine run over the
synchronous op-table runner / Erlang durable / celery-sx / fed-sx transport unchanged.
Reference tests (mock adapters) prove the contract: publish→trigger→runner→effect flows; a
non-matching activity fires nothing (log complete, execution precise); an effect that emits a new
activity re-triggers (loop closes); an unbounded loop is depth-guarded (terminates). Wired into
conformance.sh + serve.sh MODULES. behavior 4/4; full host conformance 575/575.
Next: P0 supplies the REAL adapters (publish activity ← host/blog--publish-activity, local-SX
trigger, sync op-table runner over a publish-DAG, host driver) — same engine.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Business logic as federated composition-flows (plans/business-logic-fed-flows.md). P0.1: the
host describes a published post as a fed-sx activity — host/blog--publish-activity(slug) →
{:type "create" :actor "site" :id <CID> :object {:type "article" :slug :category}} — the
exact shape next/'s trigger machinery consumes (verified: next/tests/triggers_e2e.sh 10/10).
category (drives the flow branch: newsletter suspends / urgent fires / else skip) comes from
the "category" field-value, else the first tag, else "urgent". + host/blog--post-category.
Design decided: activity log = every CID delta (event source); triggers = declared subscriptions
(DefineTrigger); flows hybrid (SX composition for simple via the execute-fold, named Erlang flows
for complex); federated execution = Erlang (next/); the type carries content+relations+behavior.
blog 200/200 (+3: contract, category fallback, missing-post nil).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
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>