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 and its instances are validated on save — no code
(tested with a 'guide' type requiring a 'pre' block). article's schema is migrated
onto the article post at boot (host/blog--set-schema!, a single read+write).
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). schema-of reads the post (a durable read) — only the SAVE
path calls it (a write request, never a render that would VmSuspend).
conformance 299/299 (+4: article h1 enforced from the post, a new refinement type
validates its instances, schema read off the post, edit preserves :schema).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.8 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. - Live JIT gotcha (cost real time): the serving-mode JIT drops all-but-first when
map/for-each-ing a function-produced list — buildingrel-kindsthat way rendered only 1 of 4 editors live, while conformance + the ephemeral server passed. Sohost/blog-rel-kindsis 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-candidatescomputes 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 ~limitreads 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-getloop 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 bytype) require a type object;tagged(anchored bytag) a tag. A newwroterelation needs only aWork declares wroteedge — 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-submitnow 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 writeX is-a relationwhererelationisn't undertype. Validation is a handler boundary.
Slice 4 — type algebra — DONE (intersection ∧ union)
- An algebraic type is a post with operand edges:
conjedges (intersection members),disjedges (union members).host/blog-instances-of-exprcomputes 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?generalisesis-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 viahost/blog-out), NOT in lib/relations — feeding extra kinds into the Datalog graph blows up its per-query re-saturation;load-edges!skipsconj/disjon replay for the same reason. - Refinement
{x : T | φ(x)}(a type-post with a:constraintpredicate) → 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 — usetimeout 1200.)
Slice 5 — refinement types (schemas ON the type-post) — DONE
- A type-post carries its schema in a
:schemaslot (a list of{:block :msg}rules — a refinement{x : T | x has these blocks}).host/blog-schema-ofreads it off the post; the hardcodedhost/blog-type-schemastable 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 aguidetype requiring apreblock. - 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-ofreads 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/:relmetadata (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-asingle-valued?) as a declared constraint.
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.