# 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`. ## 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. - **Follow-up (Slice 2.5):** `relate-candidates` does a `host/blog-get` per pool member (O(posts) for `related`). A boot-time **title cache** (updated on put!/delete!) would make the picker O(1)-perform and cut the suspend/resume churn. Subject-end declarations + a proper relation-subtype closure (when relations get subtyped) also belong here. ### Slice 3 — typed relations (target-type constraints) - A declaration carries a **target-type constraint**: the *other* end must be (an instance of) some type. `is-a`'s object must be a type; a hypothetical `wrote`'s object must be a `Work`. Validation on relate (and on save) = `is-a?` against the constraint. This is the jump from "candidate set" to a real relation schema. Picker candidates and validation read the *same* constraint. ### Slice 4 — type algebra Types are posts + `subtype-of` is a partial order ⇒ a **lattice**, and `is-a?` is transitive set-membership ⇒ extents have set semantics. So algebra is expressible as posts: - **Intersection** `A ∧ B` — a type-post whose membership predicate is `is-a? A ∧ is-a? B` (meet / GLB in the lattice). **Union** `A ∨ B` — `is-a? A ∨ is-a? B` (join / LUB). - **Refinement** `{x : T | φ(x)}` — a type-post with a `:constraint` predicate over a post (generalises today's `article` schema "must have a heading"). Gradual: declaring the type adds the obligation; the next save must satisfy it. - Algebraic types are *themselves posts* with edges to their operands — `is-a?` recurses on the expression. Meta-circular: the algebra lives in the graph it describes. ### Slice 5 — constraints as posts + validation - Promote the schema/`:constraint` slot to **constraint-posts** (a predicate expr + message), attachable to any type. Save-time validation evaluates the constraints of a post's full (transitive) type set. Relation cardinality (`is-a` single-valued? `tagged` many?) becomes a declared constraint too. ## 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.