The metamodel targets the entire rose-ash domain model, not just the blog — the finish line of the host-on-sx strangler off Quart: define the domain schema as data instead of porting each service's bespoke models. Records the three honest additions store/events surface beyond a/b/c+d: (1) typed scalar ATTRIBUTES (datatype properties: price:Money, stock:Int) alongside entity relations — a real addition, likely Slice 8; (2) behaviour/ lifecycle composes from the substrate loops (flow/commerce/events), not reinvented; (3) integrations (payments/federation/media) stay referenced services. Structure+validation from the metamodel, behaviour from substrates, integrations as services. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 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.
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 (the strangler's finish line)
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 the completion of the host-on-sx strangler off Quart ([[project_host_on_sx]]):
define the domain schema as data instead of porting each service's bespoke models.
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.
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.