# 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 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 (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.
