Files
rose-ash/plans/relations-as-posts.md
giles 4e968426c1
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
plan: behaviour as data — lifecycles + ECA over an effect vocabulary (Slice 9)
Capture the behaviour layer. Principle: behaviour is data-defined orchestration over a
small fixed vocabulary of effects; only the effect primitives + the interpreter stay code,
everything between is editable posts (meta-circular — Lifecycle/Transition/Rule/Effect are
themselves types). Guards are pure type-system (Datalog) queries; runs on flow-on-sx
(durable: wait-for webhook, after timer; saga compensation). 'Place order'/'ship' = attempt
transition T.

Sketches the effect vocabulary in four tiers — pure guards / data (graph mutations) /
domain (reserve-stock, book-seat) / integration (charge-card, create-shipment, notify,
federate; the code edge, kept small per artdag's S-expr effects) / control (wait-for, after,
emit, transition; flow primitives) — worked through store + events. The fork: declarative
core + guarded code escape-hatch (Scheme/Smalltalk on a post). Start by pinning the
vocabulary + a generic interpreter, and lift commerce-on-sx/events-on-sx from guest-code
into lifecycle+effect DATA (they already implement exactly this, just not editably).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:43:13 +00:00

21 KiB
Raw Blame History

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.

North star — the metamodel as a system-construction kit

The destination this is all heading toward: the host stops being "a blog" and becomes a self-describing metamodel. You define a domain — types (with schemas/refinements) and relations (with role signatures + algebra) — and a working system falls out. The blog content is one seeded configuration; clear it and define different types and you have a different system on the same engine. Framework, not application (cf. [[feedback_runtime_control]], [[project_zero_dependencies]]).

Most of the instance UI is already generic — the edit page's relation editors are generated by iterating the relations; each picker's candidates come from the relation's declares-anchor / role type; validation comes from the type's :schema. So once Slices 67 land, "define the types" through a UI is mostly two surfaces, plus a reset:

  1. Metamodel editor — create a type-post (give it a schema/refinement); create a relation-post (give it a role signature + algebra). The thing that lets you construct a system.
  2. Generic instance form — create/edit any post of any type, driven entirely by the definitions above (the relation editors + pickers + save-time validation we already have).
  3. Clear-and-reseed — wipe instance data, seed only the metamodel roots (type, relation, the core relations); start from a bare kit and build a domain up from nothing.

Sequence: finish the schema language (Slices 67) → the two UI surfaces + reset → clear the demo data and define a real domain through the UI. The slices below are the schema language; this is what it's for.

Endgame — the whole platform as a typed domain (the strangler's finish line)

Not just the blog: the entire rose-ash platform — store, events, orders, cart, … — is expressible as type + relation definitions in this one metamodel. Product, Event, Order, Ticket are types; "cart has line-items", "order for an event", "ticket of an event" are relations with signatures (cardinality = a cart has many line-items, a ticket belongs to one event). This is the completion of the host-on-sx strangler off Quart ([[project_host_on_sx]]): define the domain schema as data instead of porting each service's bespoke models.

Three honest additions store/events surface (the blog didn't need them):

  1. Typed scalar ATTRIBUTES, not just entity relations. A Product needs price: Money, sku: String, stock: Int; these are values, not edges to posts. We've built RDF object properties (edges to resources); this needs datatype properties (literals with value-types + validation). So a type declares fields {field, value-type, card, required, validation} alongside relations; instances carry typed values; value-types (Money, Int, DateTime) are primitive types. Same shape as a role — a role points at a type, a field holds a value-type. This is a real addition to a/b/c+d and likely Slice 8.
  2. Behaviour / lifecycle (order pending→paid→shipped) is NOT structure — it's the substrate loops: [[project_flow_on_sx]] (durable workflows), [[project_commerce_on_sx]], [[project_events_on_sx]]. The metamodel attaches behaviour to types by composing those, not reinventing them.
  3. Integrations (SumUp payments, ActivityPub federation, artdag media) — types reference these services; they don't dissolve into posts.

So the complete picture: the metamodel expresses structure + validation of the whole platform's domain model uniformly; behaviour composes from the substrate loops; integrations stay referenced services. It's the convergence point of every loop in the repo.

Behaviour as data — lifecycles + ECA over an effect vocabulary (DESIGN — Slice 9)

Structure is inert; "place an order / ship goods" is the dynamic part. The principle: behaviour is data-defined orchestration over a small fixed vocabulary of effects. Only two layers stay code — the effect primitives (the irreducible ops that touch the world) and the interpreter that runs the data. Everything between is editable posts. The system defines its own behaviour down to the effect boundary ([[feedback_runtime_control]]).

Shape. A type declares a lifecycle (a state machine) as data, plus standalone ECA rules for reactions that aren't state transitions:

Order: cart --place--> placed [guard: stock-available ∧ total>0] [effects: reserve-stock]
       placed --pay--> paid   [guard: payment-ok]               [effects: charge-card, confirm-stock]
       paid --ship--> shipped [guard: address-valid]            [effects: create-shipment, notify]
ECA:   when stock(product) < threshold  =>  notify(buyer:owner, "restock")
  • States/transitions/rules/effect-invocations are all posts — meta-circular: Lifecycle, Transition, Rule, Effect are themselves types in the metamodel; a behaviour is instances you edit in the same UI as the schema. A transition = {from, to, on-event, guard, [effects]}.
  • Guards are PURE — predicates over the instance's attributes/relations, i.e. type-system queries (Datalog). No side effects, analysable, you can diagram a lifecycle.
  • Runs on [[project_flow_on_sx]] because it's durable + long-running: placed→paid waits for a SumUp webhook, paid→shipped waits days. flow's suspend/resume IS this. Failures → compensation (saga) — commerce-on-sx already does "refund as a flow".
  • "Place an order" / "ship" = attempt transition T; the button/webhook just fires the event.

The effect vocabulary (sketch — store + events)

An effect is a named, parameterised op (itself an Effect post: name + params + binding). Behaviours reference effects by name with args bound to instance/context. Four tiers:

Tier Effect Notes
Pure guard (read-only, not an effect) is-a? / attr-cmp / count / relation-exists? type-system queries (Datalog); compose the transition guards
Data (internal, transactional on the graph) create(type, attrs), set-attr, set-state, relate / unrelate, incr / decr, append-ledger(entry) the durable post-graph mutations; decr stock is atomic-with-check
Domain (composed from data, named for atomicity/meaning) reserve-stock, release-stock, confirm-reservation, book-seat, issue-ticket small compositions the vocabulary blesses; events-on-sx has the capacity-safe versions
Integration (external services — the code edge) charge-card, refund (SumUp), create-shipment / track, notify(recipient, template, data), federate(activity) (ActivityPub), process-media(asset) (artdag) the irreducible primitives; keep this list SMALL and composable (artdag's S-expression effects is the model)
Control (durable orchestration — flow primitives) wait-for(event), wait-until(time) / after(dur), emit(event), transition(instance, state) wait-for = the SumUp webhook / shipment-delivered; after = reservation-expiry / event-reminder; emit chains ECA rules

So place order = guard stock-available ∧ total>0 → effects reserve-stock, set-state placed, emit order-placed; the webhook later fires paycharge-card, confirm-reservation, set-state paid. Events reuse the same machinery: ticket reserved →(after 15m, no pay)→ released, event --remind(after)--> notify digests. Almost all of it is the same vocabulary.

The one fork (same shape as the type-system line)

  • Declarative core — lifecycles + ECA + the effect vocabulary: safe, analysable, diagrammable, editable by non-programmers, verifiable. Covers ~95%.
  • Guarded code escape-hatch — a Scheme/Smalltalk snippet stored on a post and eval'd for the rare bespoke guard/effect ([[project_content_on_sx]] is Smalltalk message-passing, [[project_flow_on_sx]] is guest Scheme — the homoiconic door exists). Turing-complete, unsafe, fenced — exactly the decidable-core / fenced-frontier split we drew for types.

Where to start: pin down the effect vocabulary above (the real design artifact), build the generic interpreter on flow-on-sx with pure (Datalog) guards, and lift commerce-on-sx / events-on-sx from guest-code into lifecycle+effect DATA — they already implement exactly this, just not editably.

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.)

Slice 2.5 — picker title reads are O(page), not O(pool) — DONE

  • relate-candidates computes 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 ~limit reads 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-get loop 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 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 — DONE (intersection ∧ union)

  • An algebraic type is a post with operand edges: conj edges (intersection members), disj edges (union members). host/blog-instances-of-expr computes 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? generalises is-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 via host/blog-out), NOT in lib/relations — feeding extra kinds into the Datalog graph blows up its per-query re-saturation; load-edges! skips conj/disj on replay for the same reason.
  • Refinement {x : T | φ(x)} (a type-post with a :constraint predicate) → 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 — use timeout 1200.)

Slice 5 — refinement types (schemas ON the type-post) — DONE

  • 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 (host/blog--set-schema!) and its instances are validated on save against it — no code. Tested with a guide type requiring a pre block.
  • 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-of reads 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 / :rel metadata (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-a single-valued?) as a declared constraint.

Parameterised relations (DESIGN — Slices 6 & 7)

The next axis: Relation<…>. The key reframe is that the obvious parameters aren't separate <N>s — they split into two halves, and they compose into one coherent thing:

  1. The role SIGNATURE (the shape of a tuple) — Slice 6 (a + b + c).
  2. The relation's ALGEBRA (how it behaves) — Slice 7 (d).

A relation is Relation<signature>, where a signature is an ordered list of roles, each role carrying a type and a cardinality; the signature's length is the arity. Today's binary typed relations are the degenerate 2-role case — backward-compatible, nothing gets thrown away. Prior art to borrow (and stay decidable within): Codd / ER reified relationships (signature), OWL property characteristics (algebra), Datalog / relation algebra (derived relations — the undecidable frontier; fence it). Decidability rule of thumb: concrete

  • algebraic role-types and counts stay decidable; arbitrary predicates / recursive rules don't.

Slice 6 — the role signature (a + b + c)

Generalise the relation-post's :rel slot from {:symmetric :label} to a :roles list — {:roles [{:name :type :card} …]} — driving picker candidates, validation, and arity per-role:

  • (a) per-role type — each role's :type is a type-expr (so it can be algebraic: Relation<Work ∧ Published>). The object-role's type IS today's declares-anchor — make it explicit. valid-object? becomes per-role is-a-expr? against :type.
  • (b) arity = (len roles). Binary stays the fast src|kind|dst edge path; n-ary needs reification: a relation instance becomes its own post with role edges (subject→X, object→Y, recipient→Z) — on-brand (we made relation kinds posts; now instances too), but a SECOND representation alongside the binary edges, not a tweak. Qualifiers (Wikidata- style) then come free as extra roles.
  • (c) cardinality:card per role (min/max; functional = max 1, required = min 1), enforced on relate by counting. Composes with Slice 5 validation. No model change for binary.
  • Siblings: ordered roles (set vs list), keys/identity (which roles identify a tuple).
  • Layering (cheapest → deepest): (c) cardinality on the binary object-role → (a) explicit role-type + the 2-role signature abstraction → (b) reified n-ary (the real lift).
  • Variance: nominal, none initially — no structural subtyping of Relation<…> (covariance of parameterised types is a research project). JIT caveat: 2-role signatures are unrollable; n-ary role-iteration with per-role reads needs the cache/unroll treatment (Slice 2/5 lesson).

Slice 7 — relation algebra / characteristics (d)

The behaviour half — and (d) transitivity is special because we ALREADY hardcode it (is-a/subtype-of closure via lib/relations); declaring it generically removes code.

  • Algebraic properties declared on the relation-post (:transitive :symmetric :reflexive :antisymmetric :irreflexive), with the closure derived generically from them — OWL's property characteristics. subtype-of becomes "a declared transitive + antisymmetric relation" (a partial order), not a special case. :symmetric (already stored) folds in here.
  • Inverse relations — a real :inverse (not just the :inverse-label display hint): relating one auto-derives the converse, the way :symmetric writes both directions.
  • Sub-relations — relations subtyping relations (wrote subPropertyOf created): X wrote Y ⟹ X created Y. Same subtype-of machinery, over the relation root — meta-circular.
  • Decidable core stops here. Beyond-d, FENCED: defined-by-rule relations (composition, grandparent = parent ∘ parent — straight onto the Datalog substrate, but gate to stratified/bounded rules) and cross-role refinement predicates (start < end) — both need the predicate-language-vs-embedded-code decision first.

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-circularityis-a needs is-a; seed relation-posts + Type is-a Type(?) idempotently, as the type seed already is.
  4. Costreach-down is a BFS of direct-edge scans; fine for a small blog, revisit with a lib/relations transitive query if the graph grows.