Files
rose-ash/plans/relations-as-posts.md
giles 9c148e58dc
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
plan: note the live serving-JIT iteration gotcha (Slice 2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:17:04 +00:00

6.2 KiB
Raw Blame History

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.)
  • 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 Bis-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-circularityis-a needs is-a; seed relation-posts + Type is-a Type(?) idempotently, as the type seed already is.
  4. Costreach-down is a BFS of direct-edge scans; fine for a small blog, revisit with a lib/relations transitive query if the graph grows.