Vision document for migrating rose-ash to an s-expression-based architecture where pages, media renders, and LLM-generated content share a unified DAG execution model with content-addressed caching on IPFS/IPNS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
26 KiB
Rose-Ash S-Expression Architecture Transformation
Context
Rose-ash is a federated cooperative platform built as a Quart microservice monorepo (blog, market, cart, events, federation, account, likes, relations). It currently uses Jinja2 templates for rendering, ad-hoc Python code for fragment composition, and HTTP-based inter-service communication (data, actions, fragments, inbox).
Separately, the art-dag and art-dag-mono repos contain a DAG execution engine, s-expression parser/evaluator, Celery rendering pipeline, and 104k lines of media processing primitives — but are not integrated with rose-ash.
The transformation: Unify these into a single architecture where s-expressions are the application language. Pages and media renders become the same computation — DAG execution over content-addressed nodes. Python drops to the role of runtime primitives (DB, HTTP, IPFS, GPU). The app logic, layouts, components, routes, and data bindings are all expressed in s-expressions.
Key insight 1: Rendering a page and rendering a video are the same function — walk a DAG of content-addressed nodes, check cache, compute what's missing, assemble the result. The only difference is the leaf executor (Jinja/HTML vs JAX/pixels).
Key insight 2: S-expressions are the natural output language for an LLM. An LLM trained on the primitive vocabulary generates s-expressions in response to natural language prompts and HTTP requests. The primitives are the guardrails — the LLM can compose freely but can only invoke what's registered. The s-expressions are then resolved and rendered as HTML pages, media assets, or any other output format. The app becomes a conversation: user speaks natural language → LLM speaks s-expressions → resolver renders results. The LLM learns continually from the data flowing through the system — the s-expressions it generates, the content they resolve to, the user interactions they produce.
Architecture Overview
┌──────────────────────────────────────────────┐
│ Natural language (the interface) │
│ user prompts, HTTP requests, AP activities │
├──────────────────────────────────────────────┤
│ LLM (the compiler) │
│ natural language → s-expressions │
│ trained on primitives, learns from data │
├──────────────────────────────────────────────┤
│ S-expressions (the application) │
│ components, pages, routes, data bindings, │
│ media effects, composition, federation │
├──────────────────────────────────────────────┤
│ Resolver (the engine) │
│ parse → analyze → plan → execute → cache │
│ content-addressed at every node │
├──────────────────────────────────────────────┤
│ Python primitives (the runtime) │
│ HTTP/DB/Redis/IPFS/Celery/JAX/AP/OAuth │
└──────────────────────────────────────────────┘
Storage tiers:
Hot: Redis (seconds-minutes, user-specific, ephemeral)
Warm: IPFS (immutable, content-addressed, shared)
Pointers: IPNS (mutable "current version" references)
Feedback loop:
LLM generates s-expression → resolver executes → result cached
→ user interacts → interaction data feeds back to LLM training
→ LLM improves its s-expression generation
Phase 1: S-Expression Core Library
Goal: A standalone s-expression parser, evaluator, and primitive registry that every service can import. Pure data manipulation — no I/O, no rendering, no HTTP.
Source material: ~/art-dag-mono/core/artdag/sexp/ (parser.py, evaluator.py, compiler.py, primitives.py)
Deliverables:
shared/sexp/
__init__.py
parser.py # tokenize + parse s-expressions to AST
types.py # SExp, Symbol, Keyword, Atom types
evaluator.py # evaluate with environments, closures, let-bindings
primitives.py # register_primitive decorator + base primitives
env.py # environment/scope management
Base primitives (no I/O, pure transforms):
seq,list,map,filter,reducelet,lambda,defcomp,if/when/condstr,concat,formatslot(access keyword fields from data)- Arithmetic, comparison, logic
Tasks:
- Port parser from art-dag-mono, adapt to rose-ash conventions
- Port evaluator, add
defcomp(component definition) anddefrouteforms - Define type system (SExp, Symbol, Keyword, String, Number, List, Nil)
- Implement environment/scope chain
- Write primitive registry with
@register_primitivedecorator - Unit tests
Verification: Pure unit tests — parse → evaluate → assert result.
Phase 2: HTML Renderer
Goal: An HSX-style renderer that walks an s-expression tree and emits HTML strings. Handles elements, attributes, components, fragments, raw HTML, escaping.
Reference: HSX (Common Lisp) — s-expressions map directly to HTML elements.
Deliverables:
shared/sexp/
html.py # s-expression → HTML string renderer
escape.py # attribute and text escaping (XSS prevention)
components.py # defcomp registry, component resolution
Syntax conventions:
(div :class "foo" :id "bar" ;; HTML element with attributes
(h1 "Title") ;; text children
(p "Paragraph"))
(defcomp ~card (&key title children) ;; component (~ prefix)
(div :class "card"
(h2 title)
children))
(~card :title "Hello" ;; component invocation
(p "Body"))
(<> (li "One") (li "Two")) ;; fragment (no wrapper element)
(raw! "<b>trusted</b>") ;; unescaped HTML
(when condition (p "shown")) ;; conditional rendering
(map fn items) ;; list rendering
Tasks:
- Implement HTML element rendering (tag, attributes, children)
- Implement text escaping (prevent XSS — escape &, <, >, ", ')
- Implement
raw!for trusted HTML (existing Jinja| safeequivalent) - Implement fragment (
<>) rendering - Implement
defcomp/ component registry and invocation - Implement void elements (img, br, input, meta, link)
- Boolean attributes (disabled, checked, required)
- Unit tests — render s-expression, assert HTML output
Verification: Render existing fragment templates as s-expressions, diff against current Jinja output.
Phase 3: Async Resolver
Goal: Walk an s-expression tree, identify nodes that need I/O (service fragments, data queries), fetch them in parallel, substitute results. This is the DAG execution engine applied to page rendering.
Source material:
~/art-dag-mono/core/artdag/engine.py(analyze → plan → execute)~/rose-ash/shared/infrastructure/fragments.py(fetch_fragment, fetch_fragments, fetch_fragment_batch)
Deliverables:
shared/sexp/
resolver.py # async tree walker — identify, fetch, substitute
cache.py # content-addressed caching (SHA3-256 → Redis/IPFS)
primitives_io.py # I/O primitives (frag, query, action)
I/O primitives (async, registered separately from pure primitives):
(frag service type :key val ...)→ fetch_fragment(query service query-name :key val ...)→ fetch_data(action service action-name :key val ...)→ call_action(current-user)→ load user from request context(htmx-request?)→ check HX-Request header
Resolution strategy:
- Parse the s-expression tree
- Walk the tree, identify all
frag/querynodes - Group independent fetches, dispatch via
asyncio.gather() - Substitute results into the tree
- Render resolved tree to HTML
Content addressing:
- Hash each subtree (SHA3-256 of the s-expression text)
- Check Redis (hot cache) → check IPFS (warm cache) → compute
- Cache rendered subtrees at configurable granularity
Tasks:
- Implement async tree walker with parallel fetch grouping
- Port content-addressed caching from art-dag-mono/core/artdag/cache.py
- Implement
fragprimitive (wraps existing fetch_fragment) - Implement
queryprimitive (wraps existing fetch_data) - Implement
actionprimitive (wraps existing call_action) - Implement request-context primitives (current-user, htmx-request?)
- Integration tests against running services
Verification: Render a blog post page via resolver, compare output to current Jinja render.
Phase 4: Bridge — Coexistence with Jinja
Goal: Allow s-expression components and Jinja templates to coexist. Migrate incrementally — one component at a time, one page at a time.
Deliverables:
shared/sexp/
jinja_bridge.py # Jinja filter/global to render s-expressions in templates
# + helper to embed Jinja output in s-expressions via raw!
Bridge patterns:
# In Jinja: render an s-expression component
{{ sexp('(~link-card :slug "apple" :title "Apple")') | safe }}
# In s-expression: embed existing Jinja template output
(raw! (jinja "fragments/nav_tree.html" :items nav-items))
Migration order for fragments (leaf nodes first):
link-card(blog, market, events, federation) — simplest, self-containedcart-mini— small, user-specificauth-menu— small, user-specificnav-tree— recursive structure, good test of compositioncontainer-nav,container-cards— cross-service composites
Tasks:
- Implement
sexp()Jinja global function - Implement
jinjas-expression primitive - Rewrite
link-cardfragment as s-expression component (all services) - Rewrite
cart-miniandauth-menufragments - Rewrite
nav-treefragment - Rewrite
container-navandcontainer-cardsfragments - Verify each rewritten fragment produces identical HTML
Verification: A/B test — render via Jinja, render via s-expression, diff output.
Phase 5: Page Layouts as S-Expressions
Goal: Replace Jinja template inheritance ({% extends %}, {% block %}) with s-expression component composition. Layouts become components with slots.
Current template hierarchy:
_types/root/index.html → base HTML shell
_types/root/_index.html → layout with aside, filter, content slots
_types/blog/index.html → blog-specific layout
_types/post/index.html → post page
Becomes:
(defcomp ~base-layout (&key title user cart &rest content)
(html :lang "en"
(head (title title) ...)
(body :class "min-h-screen"
(~header :user user :cart cart)
(main :class "flex" content))))
(defcomp ~app-layout (&key title user cart aside filter content)
(~base-layout :title title :user user :cart cart
(when filter (div :id "filter" filter))
(aside :id "aside" aside)
(section :id "main-panel" content)))
(defcomp ~post-page (&key post nav-items user cart)
(~app-layout
:title (:slot post :title)
:user user :cart cart
:aside (~nav-tree :items nav-items)
:content
(article
(h1 (:slot post :title))
(div :class "prose" (raw! (:slot post :body))))))
OOB updates (HTMX partial renders):
(defcomp ~post-oob (&key post nav-items)
(<>
(div :id "filter" :hx-swap-oob "outerHTML"
(~post-filter :post post))
(aside :id "aside" :hx-swap-oob "outerHTML"
(~nav-tree :items nav-items))
(section :id "main-panel"
(article ...))))
Tasks:
- Define
~base-layoutcomponent (replaces_types/root/index.html) - Define
~app-layoutcomponent (replaces_types/root/_index.html) - Define
~headercomponent (replaces header block) - Define OOB rendering pattern (replaces
oob_elements.html) - Rewrite blog post page as s-expression
- Rewrite market product page
- Rewrite cart page
- Rewrite events calendar page
- Update route handlers to use resolver instead of render_template
- Remove migrated Jinja templates
Verification: Visual comparison — deploy both paths, screenshot diff.
Phase 6: Routes as S-Expressions
Goal: Route definitions move from Python decorators + handler functions to s-expression declarations. Python route handlers become thin dispatchers.
Current:
@post_bp.get("/<slug>/")
async def post_view(slug):
post = await services.blog.get_post_by_slug(g.s, slug)
ctx = await post_data(slug, g.s)
if is_htmx_request():
return render_template("_types/post/_oob_elements.html", **ctx)
return render_template("_types/post/index.html", **ctx)
Becomes:
(defroute "/blog/:slug/"
(let ((post (query blog post-by-slug :slug slug))
(nav (query blog nav-tree))
(user (current-user))
(cart (when user (query cart cart-summary :user_id (:slot user :id)))))
(if (htmx-request?)
(render (~post-oob :post post :nav-items nav))
(render (~post-page :post post :nav-items nav :user user :cart cart)))))
Python dispatcher:
# One generic route handler per service
@bp.route("/<path:path>")
async def dispatch(path):
route_expr = match_route(path) # find matching defroute
return await resolve_and_render(route_expr, request)
Tasks:
- Implement
defrouteform with path pattern matching - Implement route registry (load s-expression route files at startup)
- Implement request context binding (path params, query params, headers)
- Write generic Quart dispatcher
- Migrate blog routes
- Migrate market routes
- Migrate cart routes
- Migrate events routes
- Migrate account/federation routes
Verification: Full integration test — HTTP requests produce correct responses.
Phase 7: Content Addressing + IPFS + IPNS
Goal: Resolved fragment trees are content-addressed and cached on IPFS. IPNS provides mutable pointers to current versions. Cache invalidation becomes IPNS pointer updates.
Source material:
~/rose-ash/shared/utils/ipfs_client.py(already in rose-ash)~/rose-ash/shared/utils/anchoring.py(merkle trees, OTS)~/art-dag-mono/core/artdag/cache.py(content-addressed caching)
Two-tier caching:
Hot tier (Redis): user-specific, short TTL, ephemeral
key: sha3(s-expression) → rendered HTML
examples: cart-mini, auth-menu
Warm tier (IPFS): deterministic, immutable, shared
CID: sha3(s-expression) → rendered HTML on IPFS
IPNS name: stable identity → current CID
examples: post-body, nav-tree, link-card, full pages
Invalidation:
- Content changes → new s-expression → new hash → new CID
- Service publishes new CID to IPNS name
- ActivityPub
Updateactivity propagates to federated instances - No TTL-based expiry for warm tier — immutable content, versioned pointers
Tasks:
- Implement SHA3-256 hashing of s-expression subtrees
- Implement two-tier cache lookup (Redis → IPFS → compute)
- Implement IPNS name management per fragment type
- Implement cache warming (pre-render and pin stable content)
- Wire invalidation into event bus (content change → IPNS update)
- Wire IPNS updates into AP federation (Update activities)
Verification: Cache hit rates, IPFS pin counts, IPNS resolution latency.
Phase 8: Media Pipeline Integration
Goal: Bring art-dag's Celery rendering pipeline into rose-ash as the render service. Same s-expression language, same resolver, different leaf executors (JAX/FFmpeg instead of HTML).
Source material:
~/art-dag-mono/l1/(Celery app, tasks, sexp_effects, streaming)~/art-dag-mono/core/artdag/(engine, analysis, planning, nodes, effects)
Deliverables:
rose-ash/
render/ # new service
celery_app.py # from art-dag-mono/l1/celery_app.py
tasks/ # from art-dag-mono/l1/tasks/
sexp_effects/ # from art-dag-mono/l1/sexp_effects/
primitives.py # 104k lines of media primitives
interpreter.py
wgsl_compiler.py # GPU shaders
effects/ # effect plugins
streaming/ # video streaming output
Dockerfile
Dockerfile.gpu
docker-compose.yml
Integration points:
(render-media expr)primitive dispatches to Celery task- Results stored on IPFS, CID returned
- Event bus activity emitted on completion
- Same content-addressing — same s-expression → same output CID
Tasks:
- Move L1 codebase into
rose-ash/render/ - Adapt imports to use
shared/sexp/parser (replace local copy) - Register media primitives alongside web primitives
- Implement
render-mediaprimitive (dispatch to Celery) - Wire Celery task completion into event bus
- Integration tests — submit recipe, verify output on IPFS
Verification: Submit an s-expression media recipe via the resolver, get back an IPFS CID with the rendered output.
Phase 9: Unified DAG Executor
Goal: One execution engine that handles both page renders and media renders. The executor dispatches to different primitive sets based on node type, but the resolution, caching, and content-addressing logic is shared.
Deliverables:
shared/sexp/
executor.py # unified DAG executor
registry.py # executor registry (HTML executors, media executors)
Unified flow:
input s-expression
→ parse (shared/sexp/parser.py)
→ analyze (identify node types, owners, dependencies)
→ plan (check cache tiers, determine fetch/compute order)
→ execute (dispatch to registered executors in parallel)
→ cache (store results at appropriate tier)
→ return (HTML string, media CID, or composed result)
Tasks:
- Abstract the resolver into a generic DAG executor
- Implement executor registry (register by node type/prefix)
- Register HTML executors (frag, query, render, defcomp)
- Register media executors (transcode, filter, compose, source)
- Implement mixed-mode execution (page with embedded media)
- Provenance tracking (link executor output to AP activities)
Verification: A single resolve() call handles a page that contains both HTML components and embedded media references.
Phase 10: Federation of S-Expressions
Goal: S-expression components and pages are first-class ActivityPub objects. Remote instances can fetch, render, cache, and re-style federated content expressed as s-expressions.
Integration with existing AP infrastructure:
shared/infrastructure/activitypub.py— actor endpointsshared/events/bus.py— activity emissionshared/utils/ipfs_client.py— content storageshared/utils/anchoring.py— provenance
Tasks:
- Define AP object type for s-expression content (
rose:SExpression) - Publish component definitions as AP Create activities
- Federate page updates as AP Update activities with IPNS pointers
- Implement remote component resolution (fetch s-expr from remote instance)
- Implement content verification (signature on s-expression CID)
- Implement re-styling (apply local theme to remote s-expression)
Verification: Instance A publishes a post, Instance B resolves and renders it from the federated s-expression.
Phase 11: LLM as S-Expression Compiler
Goal: An LLM trained on the primitive vocabulary generates s-expressions from natural language. Users describe what they want in plain English. The LLM outputs valid s-expressions. The resolver renders them. Pages are generated on the fly from conversation.
The LLM speaks s-expressions because:
- The primitive vocabulary is small and well-defined (unlike HTML/CSS/JS)
- S-expressions are structurally simple — easy for an LLM to generate correctly
- The resolver validates and sandboxes — the LLM can't produce unsafe output
- Every generated s-expression is content-addressed — same prompt → cacheable result
- The primitives are the training data — the LLM learns what
~product-card,~nav-tree,(query market ...)do from examples
How it works:
User: "show me a page of seasonal vegetables under £5"
LLM generates:
(~app-layout :title "Seasonal Vegetables Under £5"
:content
(let ((products (query market products-search
:category "vegetables"
:seasonal true
:max_price 5.00
:sort "price-asc")))
(div :class "grid grid-cols-3 gap-4"
(if (empty? products)
(p :class "text-gray-500" "No vegetables found matching your criteria.")
(map (lambda (p) (~product-card :slug (:slot p :slug)))
products)))))
Resolver: parse → fetch products → render cards → HTML page
Three modes of LLM integration:
-
Generative pages: User prompt → LLM → s-expression → rendered page
- Conversational UI: user refines via follow-up prompts
- Each generated page is a CID on IPFS — shareable, cacheable
-
Adaptive layouts: LLM observes user behavior → generates personalized component arrangements
- Home page adapts: frequent buyer sees cart-heavy layout
- Event organizer sees calendar-first layout
- Same primitives, different composition
-
Content authoring: LLM assists in creating blog posts, product descriptions, event listings
- Author describes intent → LLM generates structured s-expression content
- Content is data (s-expression), not just text — queryable, composable, versionable
Training the LLM on primitives:
- Primitive catalog: every registered primitive with its signature, description, examples
- Component library: every
defcompwith usage examples - Query catalog: every
(query service name)with parameter schemas and return types - Interaction logs: successful s-expressions that produced good user outcomes
- Continuous learning: new primitives/components automatically extend the vocabulary
Safety model:
- S-expressions can only invoke registered primitives — no arbitrary code execution
- The resolver validates the tree before execution
- I/O primitives respect existing auth (HMAC, OAuth, user context)
- Rate limiting on LLM generation endpoint
- Content-addressed caching prevents regeneration of identical requests
- Generated s-expressions are logged as AP activities (provenance tracking)
Deliverables:
shared/sexp/
llm.py # LLM integration — prompt → s-expression generation
catalog.py # primitive/component catalog for LLM context
validation.py # validate generated s-expressions before execution
rose-ash/
llm/ # LLM service (or integration with external LLM API)
routes.py # conversational endpoint
training.py # continuous learning from interaction data
prompts/ # system prompts with primitive catalog
Tasks:
- Build primitive catalog generator (introspect registry → structured docs)
- Build component catalog generator (introspect defcomp registry → examples)
- Build query catalog generator (introspect data endpoints → schemas)
- Design system prompt that teaches LLM the s-expression grammar + primitives
- Implement generation endpoint (natural language → s-expression)
- Implement validation layer (parse + type-check generated expressions)
- Implement conversational refinement (user feedback → modified s-expression)
- Implement caching of generated s-expressions (prompt hash → CID)
- Wire into AP for provenance (LLM-generated content attributed to LLM actor)
- Implement feedback loop (interaction data → training signal)
Verification: User prompt → generated page → visual inspection + primitive coverage audit.
Summary of Phases
| Phase | What | Depends On | Scope |
|---|---|---|---|
| 1 | S-expression core library | — | shared/sexp/ |
| 2 | HTML renderer (HSX-style) | 1 | shared/sexp/html.py |
| 3 | Async resolver | 1, 2 | shared/sexp/resolver.py |
| 4 | Jinja bridge + fragment migration | 2, 3 | All services' fragment templates |
| 5 | Page layouts as s-expressions | 4 | All services' page templates |
| 6 | Routes as s-expressions | 5 | All services' route handlers |
| 7 | Content addressing + IPFS/IPNS | 3 | shared/sexp/cache.py |
| 8 | Media pipeline integration | 1, 7 | render/ service |
| 9 | Unified DAG executor | 3, 8 | shared/sexp/executor.py |
| 10 | Federation of s-expressions | 7, 9 | AP + IPFS integration |
| 11 | LLM as s-expression compiler | 1-6, 9 | shared/sexp/llm.py, llm/ service |
Foundation (Phases 1-3): The s-expression language, HTML rendering, and async resolver. Everything else builds on this.
Migration (Phases 4-6): Incremental replacement of Jinja templates and Python route handlers with s-expressions. The bridge ensures coexistence — the app never breaks during migration.
Infrastructure (Phases 7-9): Content addressing, IPFS/IPNS caching, media pipeline, and unified DAG execution. Pages and video renders become the same computation.
Intelligence (Phases 10-11): Federation makes s-expressions portable across instances. The LLM makes s-expressions accessible to non-programmers — natural language in, rendered pages out. The system learns from its own data, continuously improving the quality of generated s-expressions.
Each phase is independently deployable. The end state: a platform where the application logic is expressed in a small, composable, content-addressed language that humans author, LLMs generate, resolvers execute, IPFS stores, and ActivityPub federates.