Merge loops/fed-sx-types into architecture

Substrate for host-type federation + activity-driven flow triggers
(next/** only; clean/additive — zero file overlap with architecture).

Host-type federation (Phases 1-4):
- DefineType / SubtypeOf genesis verbs
- peer_types.erl receiver-side type cache
- GET /types/<cid> route + discovery_type_fetch.erl
- pipeline object-schema validation stage

flow-on-erlang + triggers (Phases 5-8):
- next/flow/ — native Erlang-on-SX durable workflow engine
  (deterministic-replay suspend/resume, combinator algebra, durable store)
- DefineTrigger genesis verb + trigger_registry.erl
- pipeline:apply_triggers/3 post-append fan-out + flow_dispatch.erl
- blog-publish-digest e2e; design §13.10 documents the fan-out convention

Gates at merge: lib/erlang 771/771, next/flow 36/36, all next/tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 12:06:50 +00:00
35 changed files with 3689 additions and 12 deletions

View File

@@ -0,0 +1,211 @@
; -*- mode: markdown -*-
# loops/host — fed-sx adapter slice (host side of host-type federation)
Scoped briefing for the follow-up that wires `loops/host`'s SX/dream
front door to the fed-sx kernel substrate landed by
`loops/fed-sx-types`. Companion to `plans/fed-sx-host-types.md` (the
substrate design + public surface). This is the build sheet for the
host-side adapters the substrate loop deliberately deferred.
```
description: loops/host — fed-sx adapter (publish/serve/ingest typed posts)
subagent_type: general-purpose
run_in_background: true
isolation: worktree # worktree at /root/rose-ash-loops/host
```
## Why this is small now
The substrate is done and tested (`origin/loops/fed-sx-types`, 4
phases). And the host already has *most of a type system*:
`lib/host/blog.sx` models a **type as a post** with a content-address
`:cid`, a `:schema` (`{:required [...]}`), `:fields`, `:template`, and a
**`subtype-of` graph over lib/relations**. So this loop is not building
a type model — it is **projecting the host's existing one onto fed-sx**
and ingesting peers' types back. The pieces line up almost 1:1:
| host (lib/host/blog.sx) | fed-sx (next/kernel) |
|--------------------------------------------|------------------------------------------|
| a type-post + `:schema {:required [...]}` | `DefineType` activity + refinement `{required, [...]}` |
| `subtype-of` edge (lib/relations) | `SubtypeOf` activity |
| `host/blog-by-cid` / `host/blog-type-defs` | `peer_types` cache + `GET /types/<cid>` |
| `host/blog-type-issues` (local validate) | `pipeline:apply_object_schema/2` (inbound) |
The host's `{:required [...]}` schema maps **directly** onto the
term_codec-safe `{required, [Field,...]}` refinement form the substrate
already validates — so schema translation is nearly trivial. Derive
both validators from the *same* schema data to avoid drift.
## Scope
**In scope**`lib/host/**` only (the mirror of fed-sx-types'
`next/**` only). New: `lib/host/fed_sx_outbox.sx`,
`lib/host/fed_sx_inbox.sx` (and a small shared `lib/host/fed_sx.sx`
bridge + a `host/types-routes`), plus `serve.sh` bring-up wiring and
`lib/host/tests/`.
**Out of scope**`next/**`. The substrate is frozen public surface;
do **not** edit `next/kernel/**` or the genesis verbs from here. If a
gap is found, file it against fed-sx-types, don't patch across the
boundary. **Hard line: do not edit `next/`.**
## Branch base
Start from `loops/host`, then bring in the substrate (clean, additive —
disjoint paths, no conflicts):
```bash
cd /root/rose-ash-loops/host
git fetch origin
git merge origin/loops/fed-sx-types # adds next/kernel + genesis + plan doc
```
After the merge the worktree has both `lib/host/**` (host) and
`next/kernel/**` (fed-sx substrate) on one branch.
## Phase 0 — settle the runtime boundary (DECIDE FIRST, blocks all else)
`lib/host` is **pure same-runtime SX** (one `sx_server.exe`: stdlib →
R7RS → APL → Datalog → ACL → Relations → Feed → Persist → Dream →
Host, per `serve.sh`). The fed-sx kernel is **Erlang-on-SX** on the
er-scheduler (`erlang-load-module` + gen_servers: `peer_types`,
`nx_kernel`). The host's dream handlers run on the native `http-listen`
accept loop — **outside** the er-scheduler. Calling a kernel
`gen_server:call` synchronously from a native-thread handler hits the
known scheduler-context deadlock (see
`plans/fed-sx-design.md` §, and the fed-prims http-listen note: a
handler on `Thread.create` outside er-sched can't complete a
`gen_server:call → receive`).
Two architectures; **the loop's first deliverable is choosing one with
a tiny spike**:
- **Option A — in-process Erlang bridge.** Host's sx_server also
`erlang-load-module`s the kernel and calls it directly. Pro: one
process, no serialization. Con: the deadlock above — kernel calls
must be marshalled onto the er-scheduler or restricted to pure
(non-gen_server) functions. Fragile; not recommended.
- **Option B — HTTP boundary (RECOMMENDED).** Run the fed-sx kernel
with its own `http_server`/`http-listen` loop (it already has the
whole route surface, and the m2 two-instance smoke test proves
HTTP federation between fed-sx nodes). The host talks to its local
fed-sx node **over localhost HTTP using the wire it already speaks**
(term_codec / activity+json / type-doc). This is literally what
federation is — the host is just another peer to its own node. An
`httpc`/localhost call from a native-thread host handler does **not**
touch the er-scheduler, so the deadlock never arises; the kernel's
own listen handler runs the gen_server calls within er-sched context.
Works whether the kernel is a sidecar process or spawned on an
er-scheduler process inside the host's sx_server (two ports, one
process). Pro: clean, reuses the fully-tested surface, no deadlock.
Con: serialization + lifecycle coordination.
**Recommendation: Option B.** Spike: host handler → `httpc` POST to the
local kernel's `/activity` → 200/cid back, with no hang. Lock the
decision before Phase 1.
## Phase 1 — outbox: project host types → DefineType / SubtypeOf
`lib/host/fed_sx_outbox.sx`. When a host type-post is created/updated
(`host/blog-put!` path), project it and publish to the local fed-sx
node:
- type-post → `DefineType` activity: `:object` = `{name: slug,
fields: (host/blog-fields-of slug), refinement-schema:
(host/blog-schema-of slug), instance-type: <base>}`. The host
`{:required [...]}` becomes the substrate `{required, [...]}` form
verbatim.
- each `subtype-of` edge (`relations/parents` over `"subtype-of"`) →
a `SubtypeOf` activity `{child-type-cid, parent-type-cid}`.
- publish via Phase 0's transport (POST `/activity` to the local node,
authed with the node's publish token).
**Key open decision — the type CID.** The host computes `:cid` via
`host/blog--cid-of` (double-hash over the canonical record); fed-sx
keys `peer_types` by a `TypeCid`. Either:
(a) **adopt the host `:cid` as the fed-sx TypeCid** — one identity,
no reconciliation, but peers can't content-verify it from the
wire bytes; or
(b) **let the kernel content-address the TypeRecord** — verifiable,
but the host must keep a `slug → fed-sx-cid` map (and
`SubtypeOf` edges must reference fed-sx CIDs, not host CIDs).
Pick one and document it in `plans/fed-sx-host-types.md`. (a) is
simpler and probably right for v1; revisit when cross-node verification
matters.
## Phase 2 — inbox: ingest peers' types + validate typed objects
`lib/host/fed_sx_inbox.sx`. Inbound from the local node's inbox:
- inbound `DefineType` → `peer_types:put` (cache it). Decide whether to
also **materialize** it as a host post (`host/blog-put!` +
`host/blog--set-schema!`) or keep federation-only types out of the
local blog (recommended for v1: cache-only, materialize on demand).
- inbound `SubtypeOf` → record the edge (peer_types hierarchy and/or
`host/blog-relate! child parent "subtype-of"` if materialized).
- inbound typed `Create` (a post that `is-a` some refinement type) →
the kernel inbound pipeline runs `pipeline:apply_object_schema/2`
(configured with a `type_index` + `{peer_types, peer_types}` +
`type_fetch_fn`), so a typed object is validated against its declared
type **before** the host sees it. Choose `strict_object_schema`
per-node (default false = open-world).
**Avoid double-validation drift:** the host already has
`host/blog-type-issues`. Let the **kernel validate federation inbound**
and the **host validate local writes**, both deriving from the same
`{:required [...]}` schema data — don't fork the rules.
## Phase 3 — serve + bring-up wiring
- **Serve `GET /types/<cid>`** on the host front door. Either proxy to
the kernel's `/types/<cid>` (Option B keeps one source of truth), or
serve directly from `host/blog-by-cid` + the projected TypeRecord.
Hook as `(dream-get "/types/:cid" host/types-by-cid)` and add
`host/types-routes` to the `host/serve` list (per `router.sx` /
`serve.sh` pattern).
- **Bring-up** in `serve.sh`: start the fed-sx node (Phase 0 transport),
start `peer_types`, configure `type_fetch_fn =
discovery_type_fetch:make_fetch_fn()` + a `type_url` resolver, and on
startup project existing host types (Phase 1) so the node is
type-aware from boot. Gate writes behind the existing
`host/require-auth` / `host/require-permission` middleware, same as
the relations write routes.
## Phase 4 — end-to-end round-trip test
Two nodes (host A + host B, or host + a sidecar fed-sx node): A defines
a refinement type → B fetches the type-doc via `GET /types/<cid>` → B
ingests an inbound typed object and `apply_object_schema` accepts the
valid one / rejects a refinement-failing one. Mirror the m2
two-instance smoke test style. Plus per-phase suites in
`lib/host/tests/` (the host runs its own `conformance.sh`).
## Tests discipline
- The host's `lib/host/conformance.sh` green before AND after every
commit. `lib/host` is **LIVE at blog.rose-ash.com** — pushing
`loops/host` reloads dev, so treat pushes as deliberate.
- Commits scoped to `lib/host/**` (+ `plans/fed-sx-host-types.md` as
decisions ratify). Do **not** edit `next/**`.
- One commit per phase; smaller intermediate commits fine if each
leaves the gate green. The Phase-0 spike can be its own commit.
## Done when
- A host type round-trips: defined locally → published as
`DefineType`/`SubtypeOf` → fetchable at `GET /types/<cid>` → a peer
validates an inbound typed object against it.
- `peer_types` is populated from inbound `DefineType`, and the inbound
pipeline rejects refinement-failing typed objects (strict node).
- The runtime-boundary decision and the type-CID decision are recorded
in `plans/fed-sx-host-types.md`.
- Host `conformance.sh` + the new fed-sx adapter suites green.
## Parallel-safety with loops/fed-sx-types
That loop owns `next/**` and is feature-complete; this loop owns
`lib/host/**`. Disjoint surfaces — they meet only at the merge that
brings the substrate in (Branch base, above). If this loop needs a
substrate change, file it against fed-sx-types rather than editing
`next/` here.

View File

@@ -1296,6 +1296,32 @@ inbox + pull from outbox. SSE is convenience, not protocol.
unknown verbs are stored-but-not-projected — safe by default, with explicit
operator control over what extensions load.
### 13.10 Activity-driven flow triggers (kernel convention)
Beyond projections (which fold an activity into read-model state), the kernel
supports firing **durable business flows** off arriving activities — the
"something happened → here is what we DO about it" half of the model. The
convention (substrate landed in `loops/fed-sx-types`, Phases 58):
- A `DefineTrigger{activity-type, flow-name, guard?, actor-scope?}` activity binds
an activity-type to a named flow. `trigger_registry` hydrates from a fold over
these (restart-safe, same content-addressing as `define-registry`).
- Fan-out runs **after** the kernel append, as the last pipeline step (§14):
`envelope → signature → activity-type schema → object schema → append → trigger
fan-out`. Only accepted activities fire flows; rejected ones never trigger.
- Fan-out is deduped per `{activity-cid, trigger-cid}` (federation can deliver the
same activity twice via different peers) using the actor's `:triggers_fired`
field, and is failure-isolated: one flow's failure never blocks the append or
the other flows.
- Flows run on **flow-on-erlang** (`next/flow/`), a native Erlang-on-SX durable
workflow engine (deterministic-replay suspend/resume; combinator algebra
mirrored from the Scheme `lib/flow`). It runs in the kernel's own runtime, so
the fan-out is a direct call — no cross-guest bridge. Because a flow runs inside
the engine's drive (where a blocking kernel call would deadlock the cooperative
scheduler), flows are **pure and describe effects as data** (their output, or a
`suspend`); a driver outside the flow performs IO and appends any follow-up
activity — which can in turn trigger further flows.
## 14. Validation pipeline
Every activity entering the substrate (whether published locally or received from a

112
plans/fed-sx-host-types.md Normal file
View File

@@ -0,0 +1,112 @@
; -*- mode: markdown -*-
# fed-sx host-type federation — substrate design + build log
How a host's typed-post graph (refinement types declared in
`lib/host`'s metamodel) flows across fed-sx nodes: a type is published
as a content-addressed `DefineType` activity, peers cache its record,
serve it over the wire, and validate inbound objects against the
declared refinement schema before appending them.
This document is both the design and the running build log for
`loops/fed-sx-types`. The companion build sheet is
`plans/agent-briefings/fed-sx-types-loop.md`.
## Vocabulary
- **Type record** — `{name, fields, refinement-schema, instance-type}`.
The parsed `:object` payload of a `DefineType` activity. Immutable
per CID: an updated type is a new CID (no in-place evolution).
- **Type CID** — content-address of the type record's wire form. The
stable handle a `SubtypeOf` edge or an object's `{type, _}` field
references.
- **Refinement schema** — a predicate over an object's field-values;
the extra constraint a refinement type adds on top of its base
`instance-type` (e.g. a `Post` is a `Note` whose `:title` is a
non-empty string).
## Scope
Substrate side only — everything under `next/**`. The host-side
adapters (`lib/host/fed_sx_outbox.sx`, `lib/host/fed_sx_inbox.sx`)
are a deliberate follow-up that consumes this branch's public surface
(`DefineType` / `SubtypeOf` verbs, `peer_types`, the `/types/<cid>`
route) once `loops/host`'s metamodel settles. **This loop does not
touch `lib/host/`.**
## Steps
### Step 1 — `DefineType` + `SubtypeOf` genesis activity-types — DONE
New `DefineActivity`-form genesis files, parsed as data by
`bootstrap.erl` at startup (no kernel change yet):
- `next/genesis/activity-types/define_type.sx` — declares the
`DefineType` verb. `:schema` accepts an activity whose `:object`
carries a string `:name` and an optional list `:fields`.
- `next/genesis/activity-types/subtype_of.sx` — declares the
`SubtypeOf` verb. `:schema` accepts an `:object` carrying both
`:child-type-cid` and `:parent-type-cid` as strings.
Schema bodies are SX source written with nested `get` (not
keyword-threading) so they are directly evaluatable: keywords are not
callable getters in the kernel and `(-> d :k)` does not get. Both are
registered in `next/genesis/manifest.sx` (activity-types now 7) and the
bundle counts in the bootstrap suites were bumped accordingly.
Tests: `next/tests/define_type.sh`, `next/tests/subtype_of.sh` — parse
shape, schema accept/reject, and a `term_codec` envelope round-trip.
### Step 2 — `peer_types.erl` receiver-side cache — DONE
`next/kernel/peer_types.erl`, a mirror of `peer_actors.erl` keyed by
type CID. State `[{TypeCidBytes, TypeRecord}, ...]`. Pure API
(`new/2`-threaded `lookup`/`store`/`evict`/`types`/`lookup_or_fetch`)
plus a registered gen_server (`put`, `lookup`, `state_for`,
`known_types`, `lookup_or_fetch`). On a miss `lookup_or_fetch` pulls a
Cfg-supplied `type_fetch_fn :: fun ((TypeCid, Cfg) -> {ok, Bytes} |
{error, _})`, decodes the wire bytes via `term_codec`, and caches the
record. No fn → `{error, no_fetch_fn}`; fetch error or bad bytes do not
poison the cache. Test: `next/tests/peer_types.sh`.
### Step 3 — `/types/<cid>` route + `discovery_type_fetch.erl` — DONE
`http_server.erl` serves `GET /types/<cid>` with
`Accept: application/vnd.fed-sx.type-doc`: the cached TypeRecord
`term_codec`-encoded, 404 if not cached. `discovery_type_fetch.erl`
holds the live-HTTP closure that `peer_types:lookup_or_fetch` calls.
Tests: `next/tests/peer_types_route.sh`, `next/tests/discovery_type_fetch.sh`.
### Step 4 — object-schema validation stage in `pipeline.erl` — DONE
`pipeline:apply_object_schema/2` (+ `stage_object_schema/1` factory)
sits between activity-type validation and the kernel append. When an
inbound object carries `{type, TypeName}`, resolve the TypeRecord
(Cfg `type_index`: TypeName → TypeCid; then
`peer_types:lookup_or_fetch/2`) and apply its refinement schema to the
object's `:field_values`. The schema is either a 1-arity Erlang
predicate (the substrate stand-in, for locally-defined types) or a
term_codec-safe `{required, [Field, ...]}` data constraint (so a
wire-fetched record validates too). Default `strict_object_schema =
false`: an unresolvable type is let through (the non-strict skip is
where a `validation_skipped` log belongs); opt-in strict rejects.
Objects with no declared type, and type names absent from the local
index, are skipped (open-world). Test: `next/tests/object_schema.sh`.
## Out of scope (deliberately)
- Host-side outbox/inbox adapters (`lib/host/**`).
- Type evolution / version migration — schemas are immutable per CID;
the "name → currently-valid CID" routing layer is a separate problem.
- Subtype-of unification / rendering across nodes — the graph data
lands via `SubtypeOf` activities; dedup/display is a consumer concern.
## What the host-side adapter loop gets
Once all four steps land, the follow-up `loops/host` adapter work can
treat the following as stable public surface:
- `DefineType` / `SubtypeOf` activity verbs (publish a type, link two).
- `peer_types` gen_server (cache a peer's type, look it up).
- `GET /types/<cid>` (serve a type the node knows).
- `pipeline`'s object-schema stage (inbound objects validated against
their declared refinement type when resolvable).