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