- live-check.sh + run-picker-check.sh now set SX_SERVING_JIT=1 to MATCH THE CONTAINER: that env gates the http-listen IO resolver, so without it perform-heavy paths (the is-a/ tags picker's reach-down BFS) falsely raise VmSuspended -> 500 in the harness while the live site is fine (confirmed live is-a picker = 200). Harness must mirror what the container runs. - conformance.sh: 600s -> 1200s cap (overridable via SX_CONF_TIMEOUT). A sibling loop at load ~6 pushed the Datalog-heavy blog suite past 600s -> false 'no suite results parsed'. - plan: types can specify SPECIALISED EDITORS — a type's :editor slot = a content-addressed editor component (WYSIWYG, map picker) shipped to the client like ~relate-picker. Generic form is the default, not the ceiling; spectrum = generic -> per-field widget -> :editor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
28 KiB
Relations as posts — declared, inherited, and eventually algebraic
Principle
Everything is a post in one graph: content-posts, type-posts, relation-posts, and
(later) constraint-posts. Nothing about typing is hardcoded — a type-post declares
which relations it anchors, declarations are inherited down the type closure, and
every candidate set / validation is a transitive graph query (lib/relations). This
closes the meta-circular loop the typing plan gestured at: the type system describes
itself in its own graph.
Supersedes the hardcoded :candidates "types"/"tags"/"all" field of host/blog-rel-kinds.
Content-addressability is universal (foundational)
Every object carries a content-address (CID) — content-posts, type-posts, relation-posts,
constraint-posts, all of them. A CID is the hash of the object's canonical form: a recursive,
key-sorted serialization (so insertion order, and any process-seed-dependent dict ordering, is
irrelevant — identical content always yields an identical CID). The runtime has no hash primitive,
so the canon serializer + a tail-recursive double-hash are built in SX (host/blog--canon,
host/blog--cid-of); the slug is excluded from the hash (it's a name, not content).
The model is git-shaped: the slug is a mutable name → CID (a branch pointing at a commit); the CID is the immutable content identity (the commit). Editing a post mints a new CID; the slug follows. Type evolution is the same — a type version is content-addressed, instances reference the version they were created against. Two objects with identical content are the same object (same CID) — correct content-addressing semantics.
Why it's foundational (federation). A CID is a global, location-independent identity, so:
- Types flow across
fed-sx. The same type definition on any node has the same CID → a shared, content-addressed vocabulary. Federated instances reference type CIDs, so a receiving node can interpret them. This is linked-data/RDF realised on the post graph, and it generalises ActivityPub itself: AP has a fixed type vocabulary (Note/Article/Person, Create/Follow/Like) — the metamodel makes that vocabulary extensible and user-defined. - Structure / behaviour trust-split (the federation boundary): type structure (schema,
relations, signatures) is declarative and federates freely — sharing a definition is sharing a
hash. Behaviour (Slice 9 lifecycles/effects) does not federate naively: you never run a
remote node's lifecycle with your effect primitives (their "ship" could
charge-card). Behaviour federates only under high trust, with the effects re-bound to local, audited primitives (their orchestration, your effects).fed-sxis already trust-gated — that's the lever.
Build order: stamp a stable CID on every object first (additive — slug-addressing stays the working
key), then a cid → slug index, then migrate references / type versioning, then federation.
North star — the metamodel as a system-construction kit
The destination this is all heading toward: the host stops being "a blog" and becomes a
self-describing metamodel. You define a domain — types (with schemas/refinements) and
relations (with role signatures + algebra) — and a working system falls out. The blog content
is one seeded configuration; clear it and define different types and you have a different system
on the same engine. Framework, not application (cf. [[feedback_runtime_control]],
[[project_zero_dependencies]]).
Most of the instance UI is already generic — the edit page's relation editors are generated
by iterating the relations; each picker's candidates come from the relation's declares-anchor /
role type; validation comes from the type's :schema. So once Slices 6–7 land, "define the
types" through a UI is mostly two surfaces, plus a reset:
- Metamodel editor — create a type-post (give it a schema/refinement); create a relation-post (give it a role signature + algebra). The thing that lets you construct a system.
- Generic instance form — create/edit any post of any type, driven entirely by the definitions above (the relation editors + pickers + save-time validation we already have).
- Clear-and-reseed — wipe instance data, seed only the metamodel roots (
type,relation, the core relations); start from a bare kit and build a domain up from nothing.
Sequence: finish the schema language (Slices 6–7) → the two UI surfaces + reset → clear the demo data and define a real domain through the UI. The slices below are the schema language; this is what it's for.
Endgame — the whole platform as a typed domain (greenfield, not a strangler)
Not just the blog: the entire rose-ash platform — store, events, orders, cart, … — is
expressible as type + relation definitions in this one metamodel. Product, Event, Order,
Ticket are types; "cart has line-items", "order for an event", "ticket of an event" are
relations with signatures (cardinality = a cart has many line-items, a ticket belongs to one
event). This is NOT a strangler off Quart ([[project_host_on_sx]]) — it's a greenfield,
SX-native system: define the domain schema as data from first principles, then port the data
once at the end (define-then-port), rather than reimplementing each service's bespoke models
endpoint-by-endpoint. The strangler's compatibility machinery (JSON mirrors, route/model parity,
incremental contracts) is dropped — it was tax, not value, for a system that doesn't correspond
to the old one.
SX all the way out — no JSON on the internal wire
The platform speaks SX/SXTP end to end, both directions, browser included — JSON survives only at the ActivityPub federation edge (JSON-LD, a published external standard).
| Layer | SX-native form |
|---|---|
| Page render | HTML (the document itself) |
| Data reads | text/sx via the serialize primitive (host/ok/host/error → host/sx-status) |
| Write bodies | text/sx parsed via sxtp/parse (was JSON / form-urlencoded) |
| Browser → server | the engine posts text/sx (boosted forms serialise fields to SX wire); form-urlencoded survives only as the no-engine / pre-hydration fallback + the login bootstrap handshake |
| Federation edge | JSON-LD (ActivityPub — the only JSON) |
The blog JSON CRUD /posts (POST/PUT/DELETE) is deleted, not converted: it was a pure
old-contract REST mirror; writes go through the HTML editor forms + SXTP.
Three honest additions store/events surface (the blog didn't need them):
- Typed scalar ATTRIBUTES, not just entity relations. A
Productneedsprice: Money,sku: String,stock: Int; these are values, not edges to posts. We've built RDF object properties (edges to resources); this needs datatype properties (literals with value-types + validation). So a type declares fields{field, value-type, card, required, validation}alongside relations; instances carry typed values; value-types (Money,Int,DateTime) are primitive types. Same shape as a role — a role points at a type, a field holds a value-type. This is a real addition to a/b/c+d and likely Slice 8. - Behaviour / lifecycle (order
pending→paid→shipped) is NOT structure — it's the substrate loops:[[project_flow_on_sx]](durable workflows),[[project_commerce_on_sx]],[[project_events_on_sx]]. The metamodel attaches behaviour to types by composing those, not reinventing them. - Integrations (SumUp payments, ActivityPub federation, artdag media) — types reference these services; they don't dissolve into posts.
So the complete picture: the metamodel expresses structure + validation of the whole platform's domain model uniformly; behaviour composes from the substrate loops; integrations stay referenced services. It's the convergence point of every loop in the repo.
Types define the UI — the editor maps onto the metamodel
The payoff of typed fields (Slice 8): a type drives both sides of the UI from one definition.
Beyond name + schema, a type carries fields {name, value-type, widget} and templates:
- Fields drive the edit UI — the editor renders one input per field, the widget chosen by the
field's
value-type(Date→date-picker,URL→link input,String→text,Image→uploader). - Fields drive the render — the type's render template (a parameterised SX template stored on the type-post, instantiated with the instance's field-values) references those fields by name.
- An instance is then just field-values on a post. Add a field to the type → it appears in the editor and the page, no code touched. Same definition, both surfaces.
"kg-cards become types." Each Koenig/Ghost card — image, gallery, callout, embed, bookmark,
heading — becomes a type-post with fields + a render template. We've already enumerated that
whole vocabulary: [[project_content_on_sx]] modelled heading/text/code/quote/image/embed/divider/
list/table/callout/media as block types — that list is the seed set of card-types. "The old
blog posts get typed" = migrate Ghost content into typed blocks, one type-post per block kind.
"The editor maps onto the types." The editor stops being hardcoded card handlers and becomes a generic field-editor: given a type, emit an input per field; on save, store the values; render through the type's template. A new card = a new type-post, zero editor code — the editor is defined by the metamodel. Proof the pattern works: the edit page's relation-editors are already generated from relation definitions, not hand-coded (one level up from fields).
Honest layer: the render template is data (editable, meta-circular); only the irreducible
widgets (the date-picker, the image-uploader) are platform pieces, and value-type is what
selects the widget — the same decidable-core / fenced-frontier line as everywhere else.
The generic form is the default, not the ceiling — types can specify specialised editors.
A UI doesn't just fall out of the types; it can be customised. A type may declare an
:editor slot — a registered, content-addressed editor component (a WYSIWYG for rich body,
a map picker for geo, a colour picker) that replaces or augments the input-per-field form, shipped
to the client by hash like ~relate-picker. So the editing spectrum per type is: generic
field-form (data, free) → per-field widget override (value-type/:widget) → whole
specialised editor component (the escape hatch, e.g. WYSIWYG). The metamodel picks the level per
type — :editor if set, else the generic form. Same decidable-core / fenced-frontier shape: the
declarative form covers the 95%, a code component handles the cases that need real interaction.
Refined build order (this is what /meta is the on-ramp to):
/metaoverview — DONE (the see;host/blog-type-defs+host/blog-meta-index).- Slice 8 — typed fields
{name, value-type, widget}on a type — the keystone (drives form + template). - Generic instance form — input per field ("the editor maps onto types").
- Render template per type — data, field-placeholders.
- Cards-as-types + migrate — seed the card-type vocabulary from content-on-sx; type the old posts.
Plus the editor surfaces on
/meta: create-type / create-relation forms, then clear-and-reseed.
Behaviour as data — lifecycles + ECA over an effect vocabulary (DESIGN — Slice 9)
Structure is inert; "place an order / ship goods" is the dynamic part. The principle:
behaviour is data-defined orchestration over a small fixed vocabulary of effects. Only two
layers stay code — the effect primitives (the irreducible ops that touch the world) and the
interpreter that runs the data. Everything between is editable posts. The system defines its
own behaviour down to the effect boundary ([[feedback_runtime_control]]).
Shape. A type declares a lifecycle (a state machine) as data, plus standalone ECA rules for reactions that aren't state transitions:
Order: cart --place--> placed [guard: stock-available ∧ total>0] [effects: reserve-stock]
placed --pay--> paid [guard: payment-ok] [effects: charge-card, confirm-stock]
paid --ship--> shipped [guard: address-valid] [effects: create-shipment, notify]
ECA: when stock(product) < threshold => notify(buyer:owner, "restock")
- States/transitions/rules/effect-invocations are all posts — meta-circular:
Lifecycle,Transition,Rule,Effectare themselves types in the metamodel; a behaviour is instances you edit in the same UI as the schema. A transition ={from, to, on-event, guard, [effects]}. - Guards are PURE — predicates over the instance's attributes/relations, i.e. type-system queries (Datalog). No side effects, analysable, you can diagram a lifecycle.
- Runs on
[[project_flow_on_sx]]because it's durable + long-running:placed→paidwaits for a SumUp webhook,paid→shippedwaits days. flow's suspend/resume IS this. Failures → compensation (saga) —commerce-on-sxalready does "refund as a flow". - "Place an order" / "ship" = attempt transition T; the button/webhook just fires the event.
The effect vocabulary (sketch — store + events)
An effect is a named, parameterised op (itself an Effect post: name + params + binding).
Behaviours reference effects by name with args bound to instance/context. Four tiers:
| Tier | Effect | Notes |
|---|---|---|
| Pure guard (read-only, not an effect) | is-a? / attr-cmp / count / relation-exists? |
type-system queries (Datalog); compose the transition guards |
| Data (internal, transactional on the graph) | create(type, attrs), set-attr, set-state, relate / unrelate, incr / decr, append-ledger(entry) |
the durable post-graph mutations; decr stock is atomic-with-check |
| Domain (composed from data, named for atomicity/meaning) | reserve-stock, release-stock, confirm-reservation, book-seat, issue-ticket |
small compositions the vocabulary blesses; events-on-sx has the capacity-safe versions |
| Integration (external services — the code edge) | charge-card, refund (SumUp), create-shipment / track, notify(recipient, template, data), federate(activity) (ActivityPub), process-media(asset) (artdag) |
the irreducible primitives; keep this list SMALL and composable (artdag's S-expression effects is the model) |
| Control (durable orchestration — flow primitives) | wait-for(event), wait-until(time) / after(dur), emit(event), transition(instance, state) |
wait-for = the SumUp webhook / shipment-delivered; after = reservation-expiry / event-reminder; emit chains ECA rules |
So place order = guard stock-available ∧ total>0 → effects reserve-stock, set-state placed,
emit order-placed; the webhook later fires pay → charge-card, confirm-reservation,
set-state paid. Events reuse the same machinery: ticket reserved →(after 15m, no pay)→ released,
event --remind(after)--> notify digests. Almost all of it is the same vocabulary.
The one fork (same shape as the type-system line)
- Declarative core — lifecycles + ECA + the effect vocabulary: safe, analysable, diagrammable, editable by non-programmers, verifiable. Covers ~95%.
- Guarded code escape-hatch — a
Scheme/Smalltalksnippet stored on a post andeval'd for the rare bespoke guard/effect ([[project_content_on_sx]]is Smalltalk message-passing,[[project_flow_on_sx]]is guest Scheme — the homoiconic door exists). Turing-complete, unsafe, fenced — exactly the decidable-core / fenced-frontier split we drew for types.
Where to start: pin down the effect vocabulary above (the real design artifact), build the
generic interpreter on flow-on-sx with pure (Datalog) guards, and lift commerce-on-sx /
events-on-sx from guest-code into lifecycle+effect DATA — they already implement exactly this,
just not editably.
Why (the wrinkle that started this)
Candidates for is-a/subtype-of were instances-of("type") — the instances that are
types, but NOT the type-defining posts themselves (type, tag, article are wired with
subtype-of, no is-a edge, so they're not instances of type). So the picker offered
tutorial (is-a tag) but never tag/article/type — the things you most want to say a
post is-a. The fix is to ask the right question: a candidate is anything that inherited
the relation's object-end declaration from the anchor, which includes the roots.
Model
- A declaration is an edge
T --declares--> R: type-postTanchors relationRat its object end ("you may point atTwithR"). Seed:type declares is-a,type declares subtype-of,tag declares tagged.relatedhas no declaration. - Candidate set for relating under
R= the down-closure ofR's anchors throughinverse(is-a) ∪ inverse(subtype-of)(a post is a candidate iff it is, transitively, an instance-or-subtype of an anchor — or IS one). No anchors ⇒ every post (related).is-a/subtype-of: anchors{type}⇒ the whole type closure (roots + subtypes + instances). Wrinkle fixed.tagged: anchors{tag}⇒ the tags.related: no anchor ⇒ all posts.
Roadmap
Slice 1 — declarations + candidate-by-inheritance — DONE
- Seed
declaresedges; addhost/blog--reach-down(down-closure) and rewirehost/blog--candidate-poolto be declaration-driven.:candidatesbecomes vestigial. - Wrinkle fixed: the type roots now appear as
is-acandidates.
Slice 2 — relations as first-class posts — DONE
relationroot +is-a/subtype-of/tagged/relatedseeded as posts (each is-a relation) owning their metadata in a:relslot (:symmetric :label :inverse-label).host/blog-rel-kinds/kind-spec/kind-symmetric?now read it; the static registry is gone.host/blog--rel-slugs=host/blog-in "relation" "is-a"(cheap, flat).- Perform budget under http-listen (the hard lesson): a durable read inside the
render VM raises
VmSuspended, and too many per request 500s the page. Two fixes: (1) relation metadata is loaded into an in-memory cache at boot (host/blog-load-rel-kinds!, likeload-edges!) sokind-specis pure; (2) the initial edit page renders its pickers EMPTY (the load trigger fills each) — only the relate/unrelate FRAGMENT server-renders candidates (with-candsflag), so one page render doesn't docandidate-get × every picker. Benign single-perform suspend/resume still logsVmSuspendedbut returns 200. - Live JIT gotcha (cost real time): the serving-mode JIT drops all-but-first when
map/for-each-ing a function-produced list — buildingrel-kindsthat way rendered only 1 of 4 editors live, while conformance + the ephemeral server passed. Sohost/blog-rel-kindsis a VALUE the boot populates and the cache loads are UNROLLED. Conformance green ≠ correct live — verify the rendered edit page. (Re-fold the enumeration once plans/jit-bytecode-correctness.md lands.)
Slice 2.5 — picker title reads are O(page), not O(pool) — DONE
relate-candidatescomputes the available candidate SLUGS (slug-sorted, no per-candidate read), then reads titles ONLY for the page it returns. On the unfiltered path (q="" — the initial picker load AND every editor server-fill, the common case) that's ~limitreads instead of one-per-post — killing the durable-read churn under http-listen. A filter (q≠"") still resolves titles across the pool (it matches on the title), but that's the interactive path.- A boot-time slug→title cache would make even the filter O(1)-perform, BUT it's blocked
for now: there's no bulk KV read, and a per-post
host/blog-getloop at boot hits the JIT bug (a durable read inside a boot loop drops all-but-first —load-edges!only works because its loop body is perform-free). Revisit with a bulk read or once the JIT lands.
Remaining follow-ups: subject-end declarations (who may be the source); a proper relation-subtype closure when relations get subtyped; the boot title cache above.
Slice 3 — typed relations (target-type constraints) — DONE
- The declaration's
declares-anchor IS the target-type constraint:is-a/subtype-of(anchored bytype) require a type object;tagged(anchored bytag) a tag. A newwroterelation needs only aWork declares wroteedge — fully data-driven. host/blog--valid-object?(kind, other)=other ∈ candidate-pool(kind)— the SAME set the picker offers, so picker and validation agree by construction.relate-submitnow enforces it (an invalid target is a silent no-op, like the other guards);related(no anchor) accepts any post. The picker never offers an invalid target, so this guards crafted/API requests — the jump from "candidate set" to an enforced relation schema.- NOTE:
host/blog-relate!(direct/seed) stays UNVALIDATED — the seed needs to writeX is-a relationwhererelationisn't undertype. Validation is a handler boundary.
Slice 4 — type algebra — DONE (intersection ∧ union)
- An algebraic type is a post with operand edges:
conjedges (intersection members),disjedges (union members).host/blog-instances-of-exprcomputes its EXTENT from the operands' extents by set intersection / union, RECURSIVELY — so operands can themselves be algebraic (meta-circular; tested with(tag ∧ article) ∧ tag).host/blog-is-a-expr?generalisesis-a?to type expressions.host/blog-make-and!/make-or!build them. - Binary today (
nth 0/1, no fold over operands — robust on the serving JIT); n-ary fold is a follow-up once iteration-with-perform is JIT-reliable. - Operand edges are KV-only (
host/blog--add-edge-kv!, read viahost/blog-out), NOT in lib/relations — feeding extra kinds into the Datalog graph blows up its per-query re-saturation;load-edges!skipsconj/disjon replay for the same reason. - Refinement
{x : T | φ(x)}(a type-post with a:constraintpredicate) → Slice 5, with constraints-as-posts. (Process note: a sibling loop running heavy conformance saturates the box; host conformance can EXIT 124 purely from CPU contention — usetimeout 1200.)
Slice 5 — refinement types (schemas ON the type-post) — DONE
- A type-post carries its schema in a
:schemaslot (a list of{:block :msg}rules — a refinement{x : T | x has these blocks}).host/blog-schema-ofreads it off the post; the hardcodedhost/blog-type-schemastable is gone. A NEW refinement type is pure data: give a type-post a:schema(host/blog--set-schema!) and its instances are validated on save against it — no code. Tested with aguidetype requiring apreblock. - Save-time validation (
type-issues/type-valid?, the only callers, in the SAVE request) unions the schemas of a post's full transitive type set — unchanged, just sourced from the posts.schema-ofreads the post (a durable read) — fine in the save request, never render. host/blog-put!now MERGES over the previous record, so editing a post's title/content doesn't nuke its:schema/:relmetadata (also closes the Slice 2 "edit drops :rel" gap).article's schema migrated onto the article post (set-schema!at boot — a single read+write, not a loop, so boot-JIT-safe; idempotent, handles the already-seeded article).- FUTURE: arbitrary predicate constraints (not just required blocks); constraints as their
own posts; relation cardinality (
is-asingle-valued?) as a declared constraint.
Parameterised relations (DESIGN — Slices 6 & 7)
The next axis: Relation<…>. The key reframe is that the obvious parameters aren't separate
<N>s — they split into two halves, and they compose into one coherent thing:
- The role SIGNATURE (the shape of a tuple) — Slice 6 (a + b + c).
- The relation's ALGEBRA (how it behaves) — Slice 7 (d).
A relation is Relation<signature>, where a signature is an ordered list of roles, each
role carrying a type and a cardinality; the signature's length is the arity.
Today's binary typed relations are the degenerate 2-role case — backward-compatible, nothing
gets thrown away. Prior art to borrow (and stay decidable within): Codd / ER reified
relationships (signature), OWL property characteristics (algebra), Datalog / relation algebra
(derived relations — the undecidable frontier; fence it). Decidability rule of thumb: concrete
- algebraic role-types and counts stay decidable; arbitrary predicates / recursive rules don't.
Slice 6 — the role signature (a + b + c)
Generalise the relation-post's :rel slot from {:symmetric :label} to a :roles list —
{:roles [{:name :type :card} …]} — driving picker candidates, validation, and arity per-role:
- (a) per-role type — each role's
:typeis a type-expr (so it can be algebraic:Relation<Work ∧ Published>). The object-role's type IS today'sdeclares-anchor — make it explicit.valid-object?becomes per-roleis-a-expr?against:type. - (b) arity =
(len roles). Binary stays the fastsrc|kind|dstedge path; n-ary needs reification: a relation instance becomes its own post with role edges (subject→X,object→Y,recipient→Z) — on-brand (we made relation kinds posts; now instances too), but a SECOND representation alongside the binary edges, not a tweak. Qualifiers (Wikidata- style) then come free as extra roles. - (c) cardinality —
:cardper role (min/max; functional = max 1, required = min 1), enforced on relate by counting. Composes with Slice 5 validation. No model change for binary. - Siblings: ordered roles (set vs list), keys/identity (which roles identify a tuple).
- Layering (cheapest → deepest): (c) cardinality on the binary object-role → (a) explicit role-type + the 2-role signature abstraction → (b) reified n-ary (the real lift).
- Variance: nominal, none initially — no structural subtyping of
Relation<…>(covariance of parameterised types is a research project). JIT caveat: 2-role signatures are unrollable; n-ary role-iteration with per-role reads needs the cache/unroll treatment (Slice 2/5 lesson).
Slice 7 — relation algebra / characteristics (d)
The behaviour half — and (d) transitivity is special because we ALREADY hardcode it
(is-a/subtype-of closure via lib/relations); declaring it generically removes code.
- Algebraic properties declared on the relation-post (
:transitive :symmetric :reflexive :antisymmetric :irreflexive), with the closure derived generically from them — OWL's property characteristics.subtype-ofbecomes "a declared transitive + antisymmetric relation" (a partial order), not a special case.:symmetric(already stored) folds in here. - Inverse relations — a real
:inverse(not just the:inverse-labeldisplay hint): relating one auto-derives the converse, the way:symmetricwrites both directions. - Sub-relations — relations subtyping relations (
wrote subPropertyOf created): X wrote Y ⟹ X created Y. Samesubtype-ofmachinery, over therelationroot — meta-circular. - Decidable core stops here. Beyond-d, FENCED: defined-by-rule relations (composition,
grandparent = parent ∘ parent— straight onto the Datalog substrate, but gate to stratified/bounded rules) and cross-role refinement predicates (start < end) — both need the predicate-language-vs-embedded-code decision first.
Open design questions (track as we go)
- Subject-end declarations — who may be the source of a relation (a root
Thing?). - Inheritance path — through
is-aANDsubtype-ofdownward (current choice); revisit if instances-of-instances as candidates surprises. - Bootstrap / meta-circularity —
is-aneedsis-a; seed relation-posts +Type is-a Type(?) idempotently, as the type seed already is. - Cost —
reach-downis a BFS of direct-edge scans; fine for a small blog, revisit with alib/relationstransitive query if the graph grows.