Typed Payloads and the Erased Seam
Decided in ADR-0002, from the karpal-discovery session's feedback.
The principle
Typing matters in-process; across a process/JSON boundary it's always JSON anyway. So maximize in-process typing, and erase only where erasure is unavoidable — a subprocess.
What it means concretely
Block is generic over its payload:
#![allow(unused)] fn main() { pub trait BlockPayload: Serialize + DeserializeOwned + Send + Sync + 'static { fn kind_name(&self) -> &str; fn schema_id(&self) -> String; fn render_human(&self) -> String; } }
A vertical defines its own payload enum — karpal-discovery's
KarpalPayload — and gets a fully-typed Block<KarpalPayload>,
Tool<KarpalPayload>, and ToolRegistry<KarpalPayload>. Zero erasure
anywhere the vertical reaches.
Erasure reappears exactly once: at the umbrella host, where blocks arrive
across a subprocess JSON channel and are parsed as SeedBlock
(Block<BlockKind>). Unknown kinds land in BlockKind::Extension
losslessly — the erased seam. Erasure is topological: on the boundary
of the system, nowhere in the interior.
The derive
#[derive(BlockPayload)] makes the seam's two easy-to-get-wrong rules
compile-time guarantees (ADR-0004):
#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq, lonis_schema::BlockPayload)] #[lonis_payload(namespace = "karpal", render_fn = "render_search")] enum KarpalPayload { Search { query: String, results: Vec<ItemSummary> }, Ready, } // serde tag == kind_name() == "karpal.search" — from one declaration. }
- Payloads serialize adjacently tagged (
{"kind", "data"}) — an internally-tagged enum hard-fails at the seam, and the derive makes that shape automatic. - Kind tags are namespaced (
<vertical>.<kind>) so kinds never collide across verticals. render_fnkeeps a custom human render without giving up the derived wire safety.