Files
rose-ash/plans/relations-as-posts.md
giles 9effa71dde
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 52s
host: metamodel create-relation form (session-scoped) + keep load-rel-kinds! unrolled
Define a relation through the UI (metamodel editor surface 1, completing it):
POST /meta/new-relation creates a relation-post (is-a relation, :rel metadata) and
registers it via a runtime concat onto host/blog-rel-kinds — safe because the serving
handler has the IO resolver installed. /meta gains a '+ Relation' form (name, label,
symmetric). Verified: define 'Blocks' (symmetric) -> Relations(5), its editor renders on
edit pages, kind-spec + symmetric correct; auth-guarded.

SESSION-SCOPED: the relation-post + edges persist durably, but the rel-kinds registry
entry is lost on restart because load-rel-kinds! must stay UNROLLED — it runs at BOOT
where it is JIT-compiled but the IO resolver is NOT yet installed, so a dynamic loader
(map/reduce over instances-of 'relation' with a durable read per item) silently returns []
(verified: dynamic -> /meta Relations(0)). The serving-JIT HO-callback-perform fix only
engages with the resolver = serve time. Flagged to sx-vm-extensions (NOTE-render-diff-for-
vm-ext.md); they ACKed + are tracking the boot-resolver fix. Reverted the dynamic loader,
kept the unroll with a comment explaining why.

VERIFICATION NOTE: the full blog suite could not complete — the box is under extreme
contention from sibling loops (load 14, multiple full conformance + erlang/vm-ext rebuilds)
and the Datalog-heavy 140-test suite times out even at a 1800s cap. Verified instead two
ways: (1) live-path HTTP (real route + auth + editor render, ephemeral SX_SERVING_JIT=1),
(2) a focused in-process eval of the create-relation core (exists/is-a/kind-spec/symmetric/
registry-len = true,true,true,true,5). Prior full run was 140/140; changes since are purely
additive (handler + form + route + 3 tests). Re-run the blog suite when the box is quiet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:52:23 +00:00

395 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-sx` is 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 67 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 67) → 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):
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.
### 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):
1. `/meta` overview — **DONE + LIVE** (the *see*; `host/blog-type-defs` + `host/blog-meta-index`).
2. **Slice 8 — typed fields** `{name, value-type, widget}` — the keystone — **DONE + LIVE**.
3. **Generic instance form** — input per field ("the editor maps onto types") — **DONE + LIVE**.
4. **Render template per type** (8c) — data, `(field "name")` placeholders — **DONE + LIVE**.
5. **Cards-as-types + migrate** — seed the card-type vocabulary from content-on-sx; type the old posts — NEXT.
Editor surfaces on `/meta`: **create-type** (`POST /meta/new-type`) — **DONE + LIVE**; **create-relation**
(`POST /meta/new-relation`) — **DONE, but SESSION-SCOPED**: the relation-post + edges persist, the
rel-kinds registry entry is a runtime concat lost on restart (boot loader can't dynamically enumerate
under JIT-at-boot — the kernel boot-resolver gap, flagged to sx-vm-extensions). Then **clear-and-reseed**.
Also open: **specialised editors** (`:editor` slot → content-addressed component, e.g. WYSIWYG).
## 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`, `Effect` are 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→paid` waits
for a SumUp webhook, `paid→shipped` waits days. flow's suspend/resume IS this. Failures →
compensation (saga) — `commerce-on-sx` already 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`/`Smalltalk` snippet stored on a post and `eval`'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-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.