Files
rose-ash/plans/relations-as-posts.md
giles 90190346aa
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
host: relations-as-posts slice 3 — typed relations (target-type constraint enforced)
A relation's declares-anchor IS its target-type constraint: is-a/subtype-of (anchored
by type) require a type object; tagged (anchored by tag) a tag; related (no anchor) any
post. host/blog--valid-object?(kind, other) = other ∈ the relation's candidate pool — the
SAME set the picker offers — and relate-submit now enforces it (invalid target = silent
no-op). The picker never offers an invalid target, so this guards crafted/API requests:
the jump from candidate set to an enforced relation schema. A new typed relation needs
only a relation-post + a '<TargetType> declares <rel>' edge.

host/blog-relate! (direct/seed) stays unvalidated — validation is a handler boundary
(the seed writes 'X is-a relation', and relation isn't under type).

conformance 291/291 (+4: valid-object? accepts types/tags/any, relate-submit creates the
edge for a type object and no-ops for a non-type).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:25:49 +00:00

103 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) — DONE
- The declaration's `declares`-anchor IS the target-type constraint: `is-a`/`subtype-of`
(anchored by `type`) require a type object; `tagged` (anchored by `tag`) a tag. A new
`wrote` relation needs only a `Work declares wrote` edge — 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-submit` now
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 write
`X is-a relation` where `relation` isn't under `type`. Validation is a *handler* boundary.
### 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 B``is-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-circularity**`is-a` needs `is-a`; seed relation-posts + `Type is-a
Type`(?) idempotently, as the type seed already is.
4. **Cost** — `reach-down` is a BFS of direct-edge scans; fine for a small blog, revisit with
a `lib/relations` transitive query if the graph grows.