Files
rose-ash/plans/typed-posts-and-relations.md
giles dc0cf0b4cc host: typed relations — Phase 1, generalize edges to carry a kind
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>
2026-06-28 16:21:14 +00:00

5.1 KiB
Raw Blame History

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/relations over blog:<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-a is the typing edge; tagged is 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-of transitive closure via lib/relations reachability 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); article can declare "needs a heading". The hook lands with the type phase (reusing host/blog-content-ok?); only schema expressiveness grows over time. This closes the nominal/structural loop: the declared is-a edge is a claim, the validator checks the content honors it.
  • Scalars stay fields. status/title/sx_content remain fields, not edges — listings filter on them constantly and lib/relations re-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-kinds registry + 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.
  • delete cleanup drops edges across all kinds, both directions.

Phase 2 — Type resolution via reachability (the spine)

  • Seed root type-posts: blog:type ("Type") and blog:tag is-a blog:type, each documenting itself. Idempotent seed in serve.sh.
  • host/blog-types-of(slug) = direct is-a targets subtype-of-reach of each (SX-side composition over lib/relations reach — no new Datalog rules).
  • host/blog-is-a?(slug, type)transitive.
  • Type-posts carry an optional :schema slot (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). Helpers host/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:tag edge.

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 /tags index = posts is-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-options endpoint takes &kind=; picker filter input carries data-kind; relate-picker.js forwards it.
  • Edit page renders one picker section per kind from the registry.

Phase 6 — Schema expressiveness (ongoing)

  • Grow the type :schema language: 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>); only kind varies. The relate machinery, picker, and post-page block all generalize by lifting the hard-coded kind: "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.