Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
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>
103 lines
6.7 KiB
Markdown
103 lines
6.7 KiB
Markdown
# 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.
|