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:
211
plans/agent-briefings/host-fed-sx-adapter-loop.md
Normal file
211
plans/agent-briefings/host-fed-sx-adapter-loop.md
Normal 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.
|
||||
@@ -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 5–8):
|
||||
|
||||
- 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
112
plans/fed-sx-host-types.md
Normal 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).
|
||||
Reference in New Issue
Block a user