Unify sexp protocol and ActivityPub extension into single spec
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
Merges sexpr-activitypub-extension.md and sexpr-protocol-and-tiered-clients.md into sexpr-unified-protocol.md — recognising that browsing, federation, and real-time updates are all the same thing: peers exchanging s-expressions on a bidirectional stream. One format, one connection, one parser. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
731
docs/sexpr-unified-protocol.md
Normal file
731
docs/sexpr-unified-protocol.md
Normal file
@@ -0,0 +1,731 @@
|
||||
# The Sexp Protocol
|
||||
|
||||
**A unified protocol for documents, applications, and federation.**
|
||||
**One format. Every boundary.**
|
||||
|
||||
---
|
||||
|
||||
## Core Insight
|
||||
|
||||
The sexp protocol replaces HTTP, HTML, JSON-LD, WebSocket, and ActivityPub's inbox model with a single concept: **peers exchange s-expressions on a bidirectional stream.**
|
||||
|
||||
There is no distinction between:
|
||||
- A client requesting a page (what HTTP does)
|
||||
- A server pushing a real-time update (what WebSocket does)
|
||||
- A peer delivering a federated activity (what AP inbox POST does)
|
||||
- A server sending a DOM mutation (what HTMX does)
|
||||
|
||||
They are all sexp expressions sent between two peers:
|
||||
|
||||
```scheme
|
||||
;; Browsing (request → response)
|
||||
(GET "/markets/")
|
||||
(ok (page :title "Markets" ...))
|
||||
|
||||
;; Real-time push (server → client, unsolicited)
|
||||
(push! (swap! "#feed" :prepend (use "post-card" :title "New post")))
|
||||
|
||||
;; Federation delivery (peer → peer)
|
||||
(Create :actor "alice@rose-ash.com"
|
||||
(Note :id post-123 (p "Hello from the fediverse!")))
|
||||
|
||||
;; Client action (client → server)
|
||||
(POST "/like/" :body (:post-id 123))
|
||||
|
||||
;; Mutation response (server → client)
|
||||
(push! (swap! "#like-count-123" :inner "43"))
|
||||
```
|
||||
|
||||
Same parser. Same stream. Same connection. The "type" of interaction is determined by the head symbol of the expression, not by the protocol layer.
|
||||
|
||||
---
|
||||
|
||||
## Why Replace HTTP and JSON-LD
|
||||
|
||||
### HTTP's Problems
|
||||
|
||||
HTTP is a request-response protocol with text headers and a body. Strip away the historical baggage and what you actually need is:
|
||||
|
||||
```
|
||||
Peer A sends: verb + target + metadata + optional body
|
||||
Peer B sends: status + metadata + body
|
||||
```
|
||||
|
||||
That's just an s-expression. But HTTP adds:
|
||||
- **Header/body split** — two parsing phases, different formats
|
||||
- **Rigid request-response** — to work around this, we bolted on WebSocket (separate protocol, upgrade handshake), SSE (chunked encoding hack), HTTP/2 push (failed, removed), HTTP/3 QUIC streams (complex, still one-request-one-response per stream)
|
||||
- **Overcomplicated caching** — `Cache-Control`, `ETag`, `If-None-Match`, `If-Modified-Since`, `Vary`, `Age`, `Expires`, `Last-Modified`, `s-maxage`, `stale-while-revalidate` — a dozen headers with complex interaction rules
|
||||
- **Arbitrary status codes** — memorised numbers with no semantic meaning in the format
|
||||
- **Form encoding mess** — `application/x-www-form-urlencoded` or `multipart/form-data` with boundary strings, MIME parts, content-disposition headers
|
||||
|
||||
### JSON-LD's Problems
|
||||
|
||||
JSON-LD was chosen for ActivityPub because of its semantic web lineage, but in practice nobody uses the linked data features — servers just pattern-match on `type` fields and ignore the `@context`. The format is:
|
||||
|
||||
- **Verbose** — deeply nested objects with string keys, quoted values, commas, colons
|
||||
- **Ambiguous** — compaction/expansion rules mean the same activity can have many valid JSON representations
|
||||
- **Lossy** — post content is flattened to an HTML string inside a JSON string; structure destroyed
|
||||
- **Hostile to signing** — JSON-LD canonicalization is fragile and implementation-dependent
|
||||
- **Hostile to AI** — agents must parse JSON, then parse embedded HTML, then reason about structure
|
||||
|
||||
### The Sexp Solution
|
||||
|
||||
One format that is:
|
||||
- **Compact** — half the size of equivalent JSON-LD, no closing tags like HTML
|
||||
- **Unambiguous** — one canonical serialization, deterministic for signing
|
||||
- **Structural** — content *is* the tree, not a string embedded in a string
|
||||
- **Parseable** — trivial recursive descent, no backtracking, nanoseconds per node
|
||||
- **Bidirectional** — requests, responses, pushes, and activities all use the same syntax
|
||||
- **AI-native** — agents parse, generate, and reason about sexp as naturally as tool calls
|
||||
|
||||
---
|
||||
|
||||
## Protocol Specification
|
||||
|
||||
### Transport
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Application: sexp expressions │
|
||||
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
|
||||
│ Session: bidirectional sexp stream │
|
||||
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
|
||||
│ Transport: QUIC (or TCP+TLS fallback) │
|
||||
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
|
||||
│ Network: IP │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
QUIC is ideal — multiplexed streams, built-in TLS, connection migration. Each sexp expression gets its own QUIC stream. Requests and pushes multiplex without head-of-line blocking. Connection survives network changes (mobile → wifi).
|
||||
|
||||
**Framing:** Length-prefixed sexp expressions over QUIC streams. The parser knows when an expression ends (balanced parens), but length-prefixing enables efficient buffer allocation.
|
||||
|
||||
**URL scheme:** `sexpr://host:port/path`
|
||||
|
||||
### Expression Types
|
||||
|
||||
Every expression on the wire is one of these forms:
|
||||
|
||||
#### Requests (client → server)
|
||||
|
||||
```scheme
|
||||
(GET "/markets/"
|
||||
:auth "bearer tok_abc123"
|
||||
:have "sha3-a1b2c3"
|
||||
:have-components ("sha3-aaa" "sha3-bbb"))
|
||||
|
||||
(POST "/like/"
|
||||
:auth "bearer tok_abc123"
|
||||
:body (:post-id 123))
|
||||
|
||||
(POST "/submit/"
|
||||
:body (
|
||||
:username "alice"
|
||||
:email "alice@example.com"
|
||||
:avatar (file :name "photo.jpg" :type "image/jpeg" :data <binary>)))
|
||||
|
||||
;; Streaming request — keep connection open for pushes
|
||||
(GET "/feed/" :stream #t)
|
||||
```
|
||||
|
||||
#### Responses (server → client)
|
||||
|
||||
```scheme
|
||||
;; Success with content
|
||||
(ok :hash "sha3-d4e5f6"
|
||||
(page :title "Markets" :layout "main"
|
||||
(section (h1 "Markets")
|
||||
(each markets (lambda (m)
|
||||
(use "vendor-card" :name (get m "name")))))))
|
||||
|
||||
;; Not modified (client already has current version)
|
||||
(not-modified)
|
||||
|
||||
;; Redirect
|
||||
(redirect :to "/new-location/" :permanent #t)
|
||||
|
||||
;; Not found
|
||||
(not-found :message "Page does not exist")
|
||||
|
||||
;; Error
|
||||
(error :code "auth-required" :message "Please log in"
|
||||
:login-url "sexpr://rose-ash.com/login/")
|
||||
|
||||
;; Response with new components the client is missing
|
||||
(ok
|
||||
:new-components (
|
||||
(component "vendor-card" :hash "sha3-ddd" (params ...) (template ...)))
|
||||
(page ...))
|
||||
```
|
||||
|
||||
#### Pushes (server → client, unsolicited)
|
||||
|
||||
```scheme
|
||||
;; DOM mutation
|
||||
(push! (swap! "#feed" :prepend
|
||||
(use "post-card" :title "New post" :author "alice")))
|
||||
|
||||
;; Batch mutation
|
||||
(push! (batch!
|
||||
(swap! "#notifications" :append (div :class "toast" "Saved!"))
|
||||
(class! "#save-btn" :remove "loading")))
|
||||
|
||||
;; Component update (new version available)
|
||||
(push! (component-update "vendor-card" :hash "sha3-eee"))
|
||||
```
|
||||
|
||||
#### Activities (peer → peer, federation)
|
||||
|
||||
```scheme
|
||||
;; Create
|
||||
(Create
|
||||
:id "https://rose-ash.com/ap/activities/456"
|
||||
:actor "https://rose-ash.com/ap/users/alice"
|
||||
:published "2026-02-28T14:00:00Z"
|
||||
:to (:public)
|
||||
:cc ("https://rose-ash.com/ap/users/alice/followers")
|
||||
|
||||
(Note
|
||||
:id "https://rose-ash.com/ap/posts/123"
|
||||
:attributed-to "https://rose-ash.com/ap/users/alice"
|
||||
:in-reply-to "https://remote.social/posts/789"
|
||||
:summary "Weekend market update"
|
||||
|
||||
(section
|
||||
(p "Three new vendors joining this Saturday.")
|
||||
(use "vendor-list" :vendors vendors)
|
||||
(use "calendar-widget" :calendar-id 42))
|
||||
|
||||
(attachment
|
||||
(Image :url "https://rose-ash.com/media/market.jpg"
|
||||
:media-type "image/jpeg"
|
||||
:width 1200 :height 800
|
||||
:name "Saturday market stalls"))
|
||||
|
||||
(tag
|
||||
(Hashtag :name "#markets" :href "https://rose-ash.com/tags/markets")
|
||||
(Mention :name "@bob@remote.social" :href "https://remote.social/users/bob"))))
|
||||
|
||||
;; Follow
|
||||
(Follow :actor "https://rose-ash.com/ap/users/alice"
|
||||
:object "https://remote.social/users/bob")
|
||||
|
||||
;; Accept (response to Follow — on the same connection)
|
||||
(Accept :actor "https://remote.social/users/bob"
|
||||
(Follow :actor "https://rose-ash.com/ap/users/alice"
|
||||
:object "https://remote.social/users/bob"))
|
||||
|
||||
;; Like, Announce, Undo — all just expressions
|
||||
(Like :actor alice :object post-123)
|
||||
(Announce :actor bob (Note :id post-123))
|
||||
(Undo :actor alice (Like :actor alice :object post-123))
|
||||
```
|
||||
|
||||
**Key insight:** Activities are not "posted to an inbox" — they are expressions sent on the bidirectional stream. The peer-to-peer connection *is* the inbox. When you follow someone, their activities stream to you on the same connection you used to follow them. No inbox endpoint, no HTTP POST delivery, no polling.
|
||||
|
||||
#### Collections (queryable, paginated)
|
||||
|
||||
```scheme
|
||||
;; Request an actor's outbox
|
||||
(GET "/ap/users/alice/outbox" :page 1)
|
||||
|
||||
;; Response
|
||||
(ok
|
||||
(OrderedCollectionPage
|
||||
:part-of "https://rose-ash.com/ap/users/alice/outbox"
|
||||
:next "/ap/users/alice/outbox?page=2"
|
||||
:total-items 142
|
||||
|
||||
(Create :actor alice :published "2026-02-28T14:00:00Z"
|
||||
(Note :id post-3 (p "Latest post")))
|
||||
(Announce :actor alice :published "2026-02-27T09:00:00Z"
|
||||
(Note :id post-2 :attributed-to bob))
|
||||
(Create :actor alice :published "2026-02-26T18:00:00Z"
|
||||
(Note :id post-1 (p "Earlier post")))))
|
||||
```
|
||||
|
||||
#### Actor Profiles
|
||||
|
||||
```scheme
|
||||
(Person
|
||||
:id "https://rose-ash.com/ap/users/alice"
|
||||
:preferred-username "alice"
|
||||
:name "Alice"
|
||||
:summary (p "Co-op member, market organiser")
|
||||
:inbox "sexpr://rose-ash.com/ap/users/alice/inbox"
|
||||
:outbox "sexpr://rose-ash.com/ap/users/alice/outbox"
|
||||
:followers "sexpr://rose-ash.com/ap/users/alice/followers"
|
||||
:following "sexpr://rose-ash.com/ap/users/alice/following"
|
||||
:components "sexpr://rose-ash.com/ap/components/"
|
||||
:public-key (:id "https://rose-ash.com/ap/users/alice#main-key"
|
||||
:owner "https://rose-ash.com/ap/users/alice"
|
||||
:pem "-----BEGIN PUBLIC KEY-----\n..."))
|
||||
```
|
||||
|
||||
#### Schema Introspection
|
||||
|
||||
```scheme
|
||||
(GET "/__schema/")
|
||||
|
||||
(ok
|
||||
(schema
|
||||
(endpoint "/" :method GET
|
||||
:returns (page)
|
||||
:params (:stream bool))
|
||||
(endpoint "/markets/" :method GET
|
||||
:returns (page :contains (list (use "vendor-card"))))
|
||||
(endpoint "/like/" :method POST
|
||||
:params (:post-id int)
|
||||
:returns (mutation))
|
||||
(activity Create
|
||||
:object (Note)
|
||||
:delivers-to :followers)
|
||||
(activity Follow
|
||||
:object (Person)
|
||||
:expects (Accept))))
|
||||
```
|
||||
|
||||
An AI agent hitting `/__schema/` learns the entire surface — pages, actions, federation activities — as parseable sexp. No separate OpenAPI doc. The schema *is* the API.
|
||||
|
||||
### Caching
|
||||
|
||||
Content-addressed, hash-based. No expiry headers, no revalidation dance.
|
||||
|
||||
```scheme
|
||||
;; Client has a cached version
|
||||
(GET "/markets/" :have "sha3-a1b2c3")
|
||||
|
||||
;; Unchanged
|
||||
(not-modified)
|
||||
|
||||
;; Changed — new hash included
|
||||
(ok :hash "sha3-d4e5f6" (page ...))
|
||||
```
|
||||
|
||||
Component-level caching:
|
||||
|
||||
```scheme
|
||||
;; Client reports which components it has
|
||||
(GET "/markets/" :have-components ("sha3-aaa" "sha3-bbb"))
|
||||
|
||||
;; Server sends only missing components alongside the page
|
||||
(ok
|
||||
:new-components (
|
||||
(component "vendor-card" :hash "sha3-ddd" (params ...) (template ...)))
|
||||
(page ...))
|
||||
```
|
||||
|
||||
The hash *is* the cache key, the ETag, and the content address. One concept replaces twelve HTTP headers.
|
||||
|
||||
### Component Discovery
|
||||
|
||||
Actors advertise a `:components` endpoint in their profile. Peers fetch and cache component definitions by content hash:
|
||||
|
||||
```scheme
|
||||
;; Fetch component manifest
|
||||
(GET "/ap/components/")
|
||||
|
||||
(ok
|
||||
(component-manifest
|
||||
("cart-mini" :hash "sha3-aaa")
|
||||
("vendor-card" :hash "sha3-bbb")
|
||||
("calendar-widget" :hash "sha3-ccc")
|
||||
("post-card" :hash "sha3-ddd")))
|
||||
|
||||
;; Fetch a specific component by hash
|
||||
(GET "/ap/components/sha3-bbb")
|
||||
|
||||
(ok
|
||||
(component "vendor-card" :hash "sha3-bbb"
|
||||
(params name stall image)
|
||||
(div :class "vendor-card"
|
||||
(img :src image :alt name)
|
||||
(h3 name)
|
||||
(p :class "stall" stall))))
|
||||
```
|
||||
|
||||
When a federated activity includes `(use "vendor-card" :name "Sourdough" :stall "A12")`, the receiving peer resolves the component from its cache (by hash) or fetches it from the sender's manifest. Federated UI, not just federated data.
|
||||
|
||||
### Signatures
|
||||
|
||||
Sexp serialization has a canonical form:
|
||||
- Keywords sorted alphabetically
|
||||
- Single space between atoms
|
||||
- No trailing whitespace
|
||||
- UTF-8 encoding
|
||||
|
||||
This makes signatures deterministic without JSON-LD canonicalization:
|
||||
|
||||
```scheme
|
||||
(signed :sig "base64..." :key-id "https://rose-ash.com/ap/users/alice#main-key"
|
||||
(Create :actor "https://rose-ash.com/ap/users/alice"
|
||||
(Note :id post-123 (p "Hello"))))
|
||||
```
|
||||
|
||||
Sign the canonical serialization of the inner expression. Verify by re-serializing and checking. No compaction/expansion ambiguity.
|
||||
|
||||
---
|
||||
|
||||
## Bidirectional Stream: The Unified Model
|
||||
|
||||
A connection between two sexp-speaking peers carries everything on one stream:
|
||||
|
||||
```scheme
|
||||
;; === Connection opened ===
|
||||
|
||||
;; Client browses
|
||||
(GET "/")
|
||||
(ok (page :title "Home" ...))
|
||||
|
||||
;; Client navigates (same connection)
|
||||
(GET "/markets/")
|
||||
(ok (page :title "Markets" ...))
|
||||
|
||||
;; Client follows a user (federation)
|
||||
(Follow :actor alice :object bob)
|
||||
|
||||
;; Server confirms
|
||||
(Accept :actor bob (Follow :actor alice :object bob))
|
||||
|
||||
;; Bob posts something — server pushes activity
|
||||
(Create :actor bob :published "2026-02-28T15:00:00Z"
|
||||
(Note :id post-456 (p "New vendor announcement!")))
|
||||
|
||||
;; Client sees it rendered in real-time via mutation
|
||||
(push! (swap! "#feed" :prepend
|
||||
(use "post-card" :actor "bob" :content (p "New vendor announcement!"))))
|
||||
|
||||
;; Client likes the post
|
||||
(POST "/like/" :body (:post-id 456))
|
||||
|
||||
;; Server pushes mutation + delivers Like activity to bob
|
||||
(push! (swap! "#like-count-456" :inner "12"))
|
||||
|
||||
;; Client opens a streaming feed
|
||||
(GET "/feed/" :stream #t)
|
||||
(ok :stream #t (page :title "Feed" ...))
|
||||
|
||||
;; More activities arrive over time...
|
||||
(Create :actor charlie :published "2026-02-28T15:30:00Z"
|
||||
(Note :id post-789 (p "Market day tomorrow!")))
|
||||
(push! (swap! "#feed" :prepend
|
||||
(use "post-card" :actor "charlie" :content (p "Market day tomorrow!"))))
|
||||
|
||||
;; === Connection persists ===
|
||||
```
|
||||
|
||||
Browsing, federation, real-time updates, and user actions — all on one bidirectional stream. The distinction between "web server", "AP inbox", and "WebSocket" disappears. They were always the same thing — peers exchanging structured expressions.
|
||||
|
||||
---
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
### With HTTP (Tier 0 and Tier 1)
|
||||
|
||||
The sexp protocol runs alongside HTTPS. The same server handles both:
|
||||
|
||||
```python
|
||||
@bp.get("/markets/")
|
||||
async def markets():
|
||||
data = await get_markets(g.s)
|
||||
tree = sexp('(page :title "Markets" ...)', markets=data)
|
||||
|
||||
accept = request.headers.get("Accept", "")
|
||||
if "application/x-sexpr" in accept:
|
||||
return Response(serialize(tree), content_type="application/x-sexpr")
|
||||
|
||||
html = render_to_html(tree)
|
||||
return Response(html, content_type="text/html")
|
||||
```
|
||||
|
||||
### With ActivityPub (JSON-LD peers)
|
||||
|
||||
For Mastodon, Pleroma, and other JSON-LD AP servers:
|
||||
|
||||
| Peer Type | Outbound | Inbound |
|
||||
|---|---|---|
|
||||
| Standard AP (Mastodon, Pleroma) | Translate sexp → JSON-LD, include `rose:sexpr` extension field | Parse JSON-LD, translate to sexp internally |
|
||||
| Sexp-aware AP (other rose-ash instances) | Native sexp on bidirectional stream | Parse sexp directly |
|
||||
| Sexp-aware with shared components | Sexp with component references | Resolve from cache, render natively |
|
||||
|
||||
JSON-LD bridging for non-sexp peers:
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": ["https://www.w3.org/ns/activitystreams", "https://rose-ash.com/ns/sexpr"],
|
||||
"type": "Create",
|
||||
"object": {
|
||||
"type": "Note",
|
||||
"content": "<p>Hello world</p>",
|
||||
"rose:sexpr": "(p \"Hello world\")"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No existing AP implementation breaks. The `rose:sexpr` field is ignored by servers that don't understand it.
|
||||
|
||||
### Fallback Gateway
|
||||
|
||||
For browsers without extension or native client:
|
||||
|
||||
```
|
||||
sexpr://rose-ash.com/markets/
|
||||
→ Gateway fetches sexp from server
|
||||
→ Renders to HTML
|
||||
→ Serves to browser at https://rose-ash.com/markets/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Three Client Tiers
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ rose-ash server (Quart) │
|
||||
│ │
|
||||
│ Same sexp component tree │
|
||||
│ Same data, same logic │
|
||||
│ │
|
||||
├──────────┬──────────┬────────┤
|
||||
│ HTTPS │ HTTPS │ SEXPR │
|
||||
│ HTML out │ sexp out │ native │
|
||||
└────┬─────┴────┬─────┴───┬────┘
|
||||
│ │ │
|
||||
┌──────────▼──┐ ┌─────▼──────┐ ┌▼──────────────┐
|
||||
│ Browser │ │ Browser + │ │ Rust client │
|
||||
│ (vanilla) │ │ extension │ │ (native) │
|
||||
│ │ │ │ │ │
|
||||
│ HTML + HTMX │ │ sexpr.js │ │ sexp protocol │
|
||||
│ Full CSS │ │ over HTTPS │ │ over QUIC │
|
||||
│ ~200ms load │ │ ~80ms load │ │ ~20ms load │
|
||||
└─────────────┘ └────────────┘ └────────────────┘
|
||||
Tier 0 Tier 1 Tier 2
|
||||
```
|
||||
|
||||
| | Tier 0: Browser | Tier 1: Extension | Tier 2: Rust Client |
|
||||
|---|---|---|---|
|
||||
| URL | `https://rose-ash.com` | `https://rose-ash.com` | `sexpr://rose-ash.com` |
|
||||
| Protocol | HTTPS | HTTPS | sexpr:// over QUIC |
|
||||
| Wire format | HTML | sexp over HTTP | sexp native stream |
|
||||
| Rendering | Browser DOM | sexpr.js → DOM | Rust → GPU |
|
||||
| Component cache | Browser cache (URL-keyed) | IndexedDB (hash-keyed) | Disk (hash-keyed, pre-parsed AST) |
|
||||
| Real-time | HTMX polling / SSE | WebSocket sexp mutations | Native bidirectional stream |
|
||||
| Federation | N/A | AP via fetch | Native sexp stream |
|
||||
| Bundle size | HTMX 14KB + CSS | sexpr.js ~8KB | 5MB binary, zero runtime deps |
|
||||
| Page load | ~200ms | ~80ms | ~20ms |
|
||||
| Memory per page | ~200MB | ~150MB | ~20MB |
|
||||
| AI integration | Parse HTML (painful) | Parse sexp (easy) | Native sexp (trivial) |
|
||||
|
||||
### Rust Client Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ sexpr-client (Rust) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌─────────────────┐ │
|
||||
│ │ Parser │ │ Component │ │
|
||||
│ │ (zero- │ │ Cache │ │
|
||||
│ │ copy) │ │ (SHA3 → AST) │ │
|
||||
│ └──────────┘ └─────────────────┘ │
|
||||
│ ┌──────────┐ ┌─────────────────┐ │
|
||||
│ │ Layout │ │ Network │ │
|
||||
│ │ Engine │ │ (tokio + QUIC) │ │
|
||||
│ └──────────┘ └─────────────────┘ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ Renderer (wgpu / iced) │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Why it's fast:**
|
||||
- Parser: nanoseconds per node (trivial recursive descent in Rust)
|
||||
- No HTML parser, no CSS cascade, no DOM construction, no JS engine
|
||||
- Render directly to GPU surface — skip the entire browser rendering pipeline
|
||||
- Pre-parsed ASTs from disk cache — zero parse time on cache hit
|
||||
- 10-50MB memory vs 200-500MB per browser tab
|
||||
|
||||
Page load: network (~50ms) → parse (~0.1ms) → layout (~2ms) → paint (~3ms) = **under 60ms**.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Content Negotiation (Quart)
|
||||
|
||||
Add `Accept` header handling to existing Quart routes.
|
||||
|
||||
- Check for `application/x-sexpr` in `Accept` header
|
||||
- If present: serialize sexp tree, return directly
|
||||
- If absent: render to HTML as today
|
||||
- Add `Vary: Accept` header
|
||||
|
||||
**Files:**
|
||||
- `shared/infrastructure/factory.py` — content negotiation middleware
|
||||
- `shared/sexp/serialize.py` — canonical sexp serializer (deterministic for signing/caching)
|
||||
|
||||
**Result:** Existing routes serve sexp to any client that asks. Zero changes to route logic.
|
||||
|
||||
### Phase 2: Sexp ↔ JSON-LD Bridge
|
||||
|
||||
Bidirectional translation for AP federation with non-sexp peers:
|
||||
|
||||
**sexp → JSON-LD** (outbound):
|
||||
- Activity type symbol → `"type"` field
|
||||
- Keyword attributes → JSON object properties
|
||||
- Content sexp → rendered to HTML for `"content"` field
|
||||
- Include `rose:sexpr` extension field
|
||||
|
||||
**JSON-LD → sexp** (inbound):
|
||||
- `"type"` → head symbol
|
||||
- `"content"` HTML → parsed to sexp (best-effort)
|
||||
- Nested objects → child expressions
|
||||
|
||||
**Files:**
|
||||
- `shared/infrastructure/ap_sexpr.py` — serializer/deserializer
|
||||
- `shared/sexp/html_to_sexp.py` — HTML → sexp parser for inbound content
|
||||
- `shared/sexp/tests/test_ap_sexpr.py` — round-trip tests
|
||||
|
||||
### Phase 3: sexpr.js Client Library
|
||||
|
||||
Browser-side JS runtime (see `sexpr-js-runtime-plan.md`):
|
||||
|
||||
- Parser + renderer: `parse()` → AST → `renderToDOM()`
|
||||
- Mutation engine: `swap!`, `batch!`, `class!`, `request!`
|
||||
- Component registry with localStorage cache (content-addressed)
|
||||
- WebSocket connection for real-time pushes
|
||||
|
||||
### Phase 4: Browser Extension
|
||||
|
||||
Package sexpr.js as a WebExtension:
|
||||
|
||||
- Register `sexpr://` protocol handler
|
||||
- Intercept navigation → fetch via HTTPS with `Accept: application/x-sexpr`
|
||||
- Parse → render to DOM
|
||||
- Component cache in IndexedDB (content-addressed)
|
||||
|
||||
**Tech:** WebExtension API (Firefox + Chrome), JS/TS.
|
||||
|
||||
### Phase 5: Component Discovery
|
||||
|
||||
Enable peers to discover and cache shared components:
|
||||
|
||||
- Actor profiles include `:components` endpoint URL
|
||||
- `GET /ap/components/` → manifest (name → hash)
|
||||
- `GET /ap/components/{hash}` → component definition (sexp)
|
||||
- Content-addressed cache on client (hash-keyed)
|
||||
- Federated content with `(use "component" ...)` resolves from cache
|
||||
|
||||
**Files:**
|
||||
- `federation/bp/components/routes.py` — manifest + fetch endpoints
|
||||
- `shared/sexp/component_manifest.py` — hash generation
|
||||
|
||||
### Phase 6: Protocol Specification
|
||||
|
||||
Formal specification document:
|
||||
|
||||
- **Framing**: length-prefixed sexp over QUIC streams
|
||||
- **Expression types**: requests, responses, pushes, activities, collections
|
||||
- **Canonical serialization**: deterministic rules for signing
|
||||
- **Caching**: content-hash based
|
||||
- **Authentication**: token in request keywords
|
||||
- **Component discovery**: manifest protocol
|
||||
- **Bidirectional streaming**: lifecycle, multiplexing
|
||||
- **Schema introspection**: `/__schema/` endpoint
|
||||
- **JSON-LD bridging**: rules for non-sexp AP peers
|
||||
- **Security**: no eval, escaping, signature verification
|
||||
|
||||
Publish as a FEP (Fediverse Enhancement Proposal) and standalone specification.
|
||||
|
||||
### Phase 7: Rust Protocol Server
|
||||
|
||||
QUIC server alongside Hypercorn:
|
||||
|
||||
- Listen on separate port (e.g., 4433)
|
||||
- Parse sexp requests, route to same handler logic
|
||||
- Bidirectional stream: pushes, requests, responses, activities
|
||||
- Component manifest endpoint
|
||||
- Federation delivery on persistent connections (replaces HTTP POST to inbox)
|
||||
|
||||
**Tech:** Rust, `quinn` (QUIC), `tokio`, `rustls`.
|
||||
|
||||
**Files:**
|
||||
- `sexpr-server/src/protocol.rs` — framing, parsing, routing
|
||||
- `sexpr-server/src/quic.rs` — QUIC listener + stream management
|
||||
- `sexpr-server/src/federation.rs` — peer connection manager
|
||||
|
||||
### Phase 8: Rust Native Client
|
||||
|
||||
Standalone sexp document viewer:
|
||||
|
||||
- QUIC client for `sexpr://` URLs
|
||||
- Zero-copy sexp parser (arena-allocated AST)
|
||||
- Component cache on disk (SHA3-keyed)
|
||||
- Layout engine (flexbox subset)
|
||||
- GPU renderer via `wgpu` or `iced`
|
||||
- Text rendering via `cosmic-text`
|
||||
- Bidirectional stream: browsing + federation + real-time on one connection
|
||||
|
||||
**Tech:** Rust, `quinn`, `wgpu`/`iced`, `cosmic-text`, `tokio`.
|
||||
|
||||
**Files:**
|
||||
- `sexpr-client/src/parser.rs` — zero-copy parser
|
||||
- `sexpr-client/src/cache.rs` — content-addressed component cache
|
||||
- `sexpr-client/src/layout.rs` — layout engine
|
||||
- `sexpr-client/src/render.rs` — GPU renderer
|
||||
- `sexpr-client/src/stream.rs` — bidirectional connection manager
|
||||
|
||||
### Phase 9: Fallback Gateway
|
||||
|
||||
HTTP proxy for browsers without extension/client:
|
||||
|
||||
- Accept HTTPS requests at `https://rose-ash.com`
|
||||
- Fetch sexp from server internally
|
||||
- Render to HTML, serve to browser
|
||||
- Add `<link rel="alternate" type="application/x-sexpr" href="sexpr://...">` for discovery
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
Each phase builds on the last. No phase breaks existing functionality:
|
||||
|
||||
```
|
||||
Phase 1 (content negotiation) ── Tier 0 unchanged, sexp available
|
||||
Phase 2 (JSON-LD bridge) ── federation works with all AP peers
|
||||
Phase 3 (sexpr.js) ── Tier 1 client-side rendering
|
||||
Phase 4 (extension) ── sexpr:// URLs in browser
|
||||
Phase 5 (components) ── federated UI exchange
|
||||
Phase 6 (spec) ── formal protocol document
|
||||
Phase 7 (Rust server) ── native protocol alongside HTTPS
|
||||
Phase 8 (Rust client) ── Tier 2 native experience
|
||||
Phase 9 (gateway) ── sexpr:// accessible from any browser
|
||||
```
|
||||
|
||||
Depends on:
|
||||
- **Ghost removal** (see `ghost-removal-plan.md`) — posts must be sexp before Phases 2-3 add real value
|
||||
- **sexpr.js runtime** (see `sexpr-js-runtime-plan.md`) — the JS library that powers Phases 3-4
|
||||
|
||||
---
|
||||
|
||||
## Cooperative Angle
|
||||
|
||||
- Members install the Rust client → fast native experience, 5MB binary, no app store
|
||||
- Visitors browse `https://rose-ash.com` → standard HTML, no barrier
|
||||
- Federated co-ops connect via persistent sexp streams → rich UI exchange, not just text syndication
|
||||
- AI agents speak the protocol natively → components as tool calls, mutations as actions
|
||||
- Auto-updates via content-addressed components → no gatekeeping
|
||||
- The component registry is a shared vocabulary across the cooperative network
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Other Plans
|
||||
|
||||
| Document | Role |
|
||||
|---|---|
|
||||
| `sexpr-js-runtime-plan.md` | The JS library powering Tier 1 (Phases 3-4) |
|
||||
| `ghost-removal-plan.md` | Posts must be sexp before federation/client rendering adds value |
|
||||
| `sexpr-ai-integration.md` | AI agents benefit from all tiers and the self-describing schema |
|
||||
|
||||
---
|
||||
|
||||
*The document is the program. The program is the document. The protocol is both.*
|
||||
Reference in New Issue
Block a user