is-a/subtype-of/tagged/related are now POSTS (each is-a a new `relation` root),
owning their metadata in a :rel slot {:symmetric :label :inverse-label}. The static
host/blog-rel-kinds registry is gone: kind-spec/rel-kinds/kind-symmetric? read the
relation-posts (via an in-memory cache), and the relation list derives from
host/blog-in "relation" "is-a".
Perform-budget fixes (a durable read inside the http-listen render VM raises
VmSuspended; too many per request 500s the page):
- relation metadata is loaded into a cache at boot (host/blog-load-rel-kinds!,
like load-edges!), so kind-spec is pure on render paths;
- the initial edit page renders its pickers EMPTY (the load trigger fills each) —
only the relate/unrelate FRAGMENT server-renders candidates (with-cands flag).
Previously every edit page render did candidate-get × 4 pickers and 500'd.
host conformance 287/287 (+4 slice-2: kind-spec reads :rel, kind-symmetric? off the
post, unknown kind has no spec, rel-kinds derived from the graph). run-picker-check
3/3 (edit page boots, relate/unrelate flow works, no client errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5.7 KiB
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-postTanchors relationRat its object end ("you may point atTwithR"). Seed:type declares is-a,type declares subtype-of,tag declares tagged.relatedhas no declaration. - Candidate set for relating under
R= the down-closure ofR's anchors throughinverse(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
declaresedges; addhost/blog--reach-down(down-closure) and rewirehost/blog--candidate-poolto be declaration-driven.:candidatesbecomes vestigial. - Wrinkle fixed: the type roots now appear as
is-acandidates.
Slice 2 — relations as first-class posts — DONE
relationroot +is-a/subtype-of/tagged/relatedseeded as posts (each is-a relation) owning their metadata in a:relslot (: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!, likeload-edges!) sokind-specis pure; (2) the initial edit page renders its pickers EMPTY (the load trigger fills each) — only the relate/unrelate FRAGMENT server-renders candidates (with-candsflag), so one page render doesn't docandidate-get × every picker. Benign single-perform suspend/resume still logsVmSuspendedbut returns 200. - Follow-up (Slice 2.5):
relate-candidatesdoes ahost/blog-getper pool member (O(posts) forrelated). 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 hypotheticalwrote's object must be aWork. 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 isis-a? A ∧ is-a? B(meet / GLB in the lattice). UnionA ∨ B—is-a? A ∨ is-a? B(join / LUB). - Refinement
{x : T | φ(x)}— a type-post with a:constraintpredicate over a post (generalises today'sarticleschema "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/
:constraintslot 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-asingle-valued?taggedmany?) becomes a declared constraint too.
Open design questions (track as we go)
- Subject-end declarations — who may be the source of a relation (a root
Thing?). - Inheritance path — through
is-aANDsubtype-ofdownward (current choice); revisit if instances-of-instances as candidates surprises. - Bootstrap / meta-circularity —
is-aneedsis-a; seed relation-posts +Type is-a Type(?) idempotently, as the type seed already is. - Cost —
reach-downis a BFS of direct-edge scans; fine for a small blog, revisit with alib/relationstransitive query if the graph grows.