Plan: plans/typed-posts-and-relations.md. "Typing is just relating to a type",
types are posts. Phase 1 lifts the hard-coded kind:"related" into a parameter,
driven by one registry — the spine the later phases (type resolution, tags,
picker) build on. Zero user-visible change.
- host/blog-rel-kinds registry: {kind,label,symmetric,candidates[,inverse-label]}
for related (symmetric) / is-a / tagged (directed). One place knows each kind's
direction, label, and candidate set.
- host/blog-relate!/unrelate! take a kind; symmetric kinds write both directions,
directed kinds write one. host/blog-out/in read children/parents per kind;
host/blog-related = out(slug,"related") (back-compat).
- relate/unrelate routes carry a `kind` form field (default "related"), validated
against the registry. delete drops edges across ALL kinds + both directions.
6 tests: symmetric reads both sides, directed writes one (inverse via host/blog-in),
unrelate is kind-scoped, unknown kind rejected, default kind = related. 244/244;
Playwright picker 4/4 (related path unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5.1 KiB
5.1 KiB
Typed posts & relations — typing is just relating to a type
host-on-sx. Driving idea: classification is a relation to a type node, and types are posts. Everything (related, tag, category, series, type) becomes a typed edge in
lib/relationsoverblog:<slug>nodes. One primitive.
Decisions
- Types are posts. No new node namespace — content-posts and type/tag posts
are all
blog:<slug>. A "tag" is a post; tagging documents itself. is-ais the typing edge;taggedis membership. Kept distinct so a tag page can list members without conflating "ocaml is a tag" with "hello is tagged ocaml".- Hierarchy is core, not deferred.
is-a/subtype-oftransitive closure vialib/relationsreachability is what makes typing-as-relation more than flat labels. All typing helpers are transitive from the first line, or subtypes silently break candidate/is-a?checks later. - Validation is gradual, not deferred. A type-post optionally carries a
schema slot; validation runs only where one exists. Tags declare none (stay
folksonomy-free);
articlecan declare "needs a heading". The hook lands with the type phase (reusinghost/blog-content-ok?); only schema expressiveness grows over time. This closes the nominal/structural loop: the declaredis-aedge is a claim, the validator checks the content honors it. - Scalars stay fields.
status/title/sx_contentremain fields, not edges — listings filter on them constantly andlib/relationsre-saturates Datalog per query. Links-to-shared-nodes → edges; per-post hot scalars → fields.
The linchpin: a relation-kind registry
One data structure drives validation, the picker candidate sets, and rendering:
host/blog-rel-kinds =
({:kind "related" :label "Related posts" :symmetric true :candidates "all"}
{:kind "is-a" :label "Types" :symmetric false :candidates "types"
:inverse-label "Instances"}
{:kind "tagged" :label "Tags" :symmetric false :candidates "tags"
:inverse-label "Tagged with this"})
:symmetric → write both directions on relate. :candidates → what the picker
offers (all = every post; tags = is-a? blog:tag transitively; types =
is-a? blog:type). :label/:inverse-label → headings.
Phases
Phase 1 — Kind generalization + registry ← START HERE
Pure refactor; zero user-visible change (related keeps working).
host/blog-rel-kindsregistry +host/blog--kind-spec/--kind-symmetric?.host/blog-relate!(a,b,kind)/unrelate!(a,b,kind)— directed; symmetric kinds also write the reverse (today's "related" behavior = the symmetric case).host/blog-out(slug,kind)(children) /host/blog-in(slug,kind)(parents), existence-filtered.host/blog-related(slug)=out(slug,"related")(back-compat).- Routes carry
kind(form field, default"related"); validated against registry. deletecleanup drops edges across all kinds, both directions.
Phase 2 — Type resolution via reachability (the spine)
- Seed root type-posts:
blog:type("Type") andblog:tag is-a blog:type, each documenting itself. Idempotent seed inserve.sh. host/blog-types-of(slug)= directis-atargets ∪subtype-of-reach of each (SX-side composition overlib/relationsreach — no new Datalog rules).host/blog-is-a?(slug, type)— transitive.- Type-posts carry an optional
:schemaslot (designed now, mostly empty). - Validation hook:
host/blog-content-ok?extended to also run any schema(s) implied by the post's declared types. No schema → no-op (gradual).
Phase 3 — Tags as posts
- "is a tag" =
host/blog-is-a? slug "tag"(transitive). Helpershost/blog-tags(slug)=out(slug,"tagged"),host/blog-tagged-with(tag)=in(tag,"tagged"). - Edit page: a "This post is a tag" toggle = add/remove
is-a blog:tagedge.
Phase 4 — Render (data-driven from the registry)
- Post page iterates the registry → "Related posts" + "Tags" blocks, same code.
- Tag-post page: its own content (the tag's documentation) plus "Tagged with
this" (incoming
tagged). A tag page documents the tag AND lists its members. - Optional
/tagsindex = postsis-a? blog:tag.
Phase 5 — Generalize the picker
host/blog--relate-candidates(slug, q, kind)branches on the kind's:candidates(all / tags / types).relate-optionsendpoint takes&kind=; picker filter input carriesdata-kind;relate-picker.jsforwards it.- Edit page renders one picker section per kind from the registry.
Phase 6 — Schema expressiveness (ongoing)
- Grow the type
:schemalanguage: start minimal (required block kinds / a predicate over content), richer later. Enforcement already wired in Phase 2; only the language grows. Not a blocker — a gradient.
Notes
- Node model unchanged (
blog:<slug>); onlykindvaries. The relate machinery, picker, and post-page block all generalize by lifting the hard-codedkind: "related"into a parameter. - A type can be a post all the way up (
blog:tag is-a blog:type); meta-circular but bounded by seeding a small root set.