Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 23s
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>
233 lines
16 KiB
Markdown
233 lines
16 KiB
Markdown
# 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:
|
||
|
||
1. **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.
|
||
2. **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).
|
||
3. **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):
|
||
|
||
1. **Typed scalar ATTRIBUTES, not just entity relations.** A `Product` needs `price: 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.
|
||
2. **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.
|
||
3. **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-post `T` anchors relation `R` at
|
||
its **object** end ("you may point *at* `T` with `R`"). Seed: `type declares is-a`,
|
||
`type declares subtype-of`, `tag declares tagged`. `related` has no declaration.
|
||
- **Candidate set** for relating under `R` = the **down-closure** of `R`'s anchors through
|
||
`inverse(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 `declares` edges; add `host/blog--reach-down` (down-closure) and rewire
|
||
`host/blog--candidate-pool` to be declaration-driven. `:candidates` becomes vestigial.
|
||
- Wrinkle fixed: the type roots now appear as `is-a` candidates.
|
||
|
||
### Slice 2 — relations as first-class posts — DONE
|
||
- `relation` root + `is-a`/`subtype-of`/`tagged`/`related` seeded as posts (each is-a
|
||
relation) owning their metadata in a `:rel` slot (`: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!`,
|
||
like `load-edges!`) so `kind-spec` is pure; (2) the initial edit page renders its pickers
|
||
EMPTY (the load trigger fills each) — only the relate/unrelate FRAGMENT server-renders
|
||
candidates (`with-cands` flag), so one page render doesn't do `candidate-get × every
|
||
picker`. Benign single-perform suspend/resume still logs `VmSuspended` but 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 — building `rel-kinds` that way rendered
|
||
only 1 of 4 editors live, while conformance + the ephemeral server passed. So
|
||
`host/blog-rel-kinds` is 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-candidates` computes 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 ~`limit` reads
|
||
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-get` loop **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 by `type`) require a type object; `tagged` (anchored by `tag`) a tag. A new
|
||
`wrote` relation needs only a `Work declares wrote` edge — 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-submit` now
|
||
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 write
|
||
`X is-a relation` where `relation` isn't under `type`. Validation is a *handler* boundary.
|
||
|
||
### Slice 4 — type algebra — DONE (intersection ∧ union)
|
||
- An algebraic type is a post with operand edges: `conj` edges (intersection members),
|
||
`disj` edges (union members). `host/blog-instances-of-expr` computes 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?`
|
||
generalises `is-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 via `host/blog-out`), NOT in
|
||
lib/relations — feeding extra kinds into the Datalog graph blows up its per-query
|
||
re-saturation; `load-edges!` skips `conj`/`disj` on replay for the same reason.
|
||
- **Refinement** `{x : T | φ(x)}` (a type-post with a `:constraint` predicate) → 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 — use `timeout 1200`.)
|
||
|
||
### Slice 5 — refinement types (schemas ON the type-post) — DONE
|
||
- A type-post carries its schema in a `:schema` slot (a list of `{:block :msg}` rules —
|
||
a refinement `{x : T | x has these blocks}`). `host/blog-schema-of` reads it off the
|
||
post; the hardcoded `host/blog-type-schemas` table 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 a `guide` type requiring a `pre` block.
|
||
- 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-of` reads 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` / `:rel` metadata (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-a` single-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:
|
||
|
||
1. **The role SIGNATURE** (the *shape* of a tuple) — Slice 6 (a + b + c).
|
||
2. **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 `:type` is a type-expr (so it can be algebraic:
|
||
`Relation<Work ∧ Published>`). The object-role's type IS today's `declares`-anchor — make it
|
||
explicit. `valid-object?` becomes per-role `is-a-expr?` against `:type`.
|
||
- **(b) arity** = `(len roles)`. Binary stays the fast `src|kind|dst` edge 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** — `:card` per 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-of` becomes "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-label` display hint):
|
||
relating one auto-derives the converse, the way `:symmetric` writes both directions.
|
||
- **Sub-relations** — relations subtyping relations (`wrote subPropertyOf created`): X wrote Y
|
||
⟹ X created Y. Same `subtype-of` machinery, over the `relation` root — 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)
|
||
1. **Subject-end declarations** — who may be the *source* of a relation (a root `Thing`?).
|
||
2. **Inheritance path** — through `is-a` AND `subtype-of` downward (current choice); revisit
|
||
if instances-of-instances as candidates surprises.
|
||
3. **Bootstrap / meta-circularity** — `is-a` needs `is-a`; seed relation-posts + `Type is-a
|
||
Type`(?) idempotently, as the type seed already is.
|
||
4. **Cost** — `reach-down` is a BFS of direct-edge scans; fine for a small blog, revisit with
|
||
a `lib/relations` transitive query if the graph grows.
|