Skip to content

ADR-0004: Preserve CanonicalItem as the Bidirectional Typed Boundary

  • Status: Accepted
  • Date: 2026-05-19

Context

runex moves data in two directions:

  • inbound: external system -> graph
  • outbound: graph -> external system

The runtime currently uses one typed boundary on both paths:

  • CanonicalItem

Inbound:

  • source adapter translates raw external data into CanonicalItem
  • upsert_node_from_item writes that item into the graph

Outbound:

  • project_node_to_item projects a graph node into CanonicalItem
  • a sink adapter translates that item to an external write payload

A recurring alternative is to weaken this boundary by moving opaque JSON or dict payloads through kernels or DSL effects, especially when trying to "simplify" adapters or unify ingest and writeback at the function call level.

Decision

We preserve CanonicalItem as the single typed boundary in both directions.

That means:

  1. inbound channels produce CanonicalItem
  2. the graph ingest pipeline consumes CanonicalItem
  3. outbound projection produces CanonicalItem
  4. sinks consume CanonicalItem
  5. the DSL does not become a transport for opaque item payloads

The preferred framing is:

CanonicalItem is the doorframe between the graph and the external world, inbound and outbound alike

Why

The system needs one stable, typed surface where heterogenous external worlds become one internal semantic world.

If that surface weakens into ad hoc JSON conventions:

  • field typing becomes implicit
  • link structure becomes less disciplined
  • runtime introspection gets weaker
  • the DSL becomes payload plumbing rather than semantic description

Keeping one typed boundary makes the ingress and egress paths symmetric without collapsing them into unstructured transport.

Alternatives Considered

1. Kernel returns raw item JSON, DSL passes it onward

Rejected.

Reason:

  • it demotes the boundary from typed contract to convention
  • it pushes raw data plumbing into the DSL
  • it makes downstream guarantees depend on payload shape discipline rather than runtime types

2. Inbound uses CanonicalItem, outbound uses arbitrary sink-specific payloads

Rejected.

Reason:

  • it breaks the symmetry of the runtime boundary
  • it makes writeback a second-class path
  • sink behavior becomes harder to reason about and test uniformly

3. Each adapter defines its own private intermediate structure

Rejected.

Reason:

  • the runtime loses a shared semantic boundary
  • testing and projection become per-channel special cases
  • contract drift becomes more likely over time

Consequences

Positive

  • keeps ingress and egress conceptually symmetric
  • supports uniform testing of source and sink behavior
  • keeps typed fields, links, and identity structure explicit
  • prevents the DSL from turning into a general JSON transport layer

Negative / Tradeoffs

  • adapter and sink authors must learn one explicit shared structure
  • some channel-specific data may need careful projection rather than blind payload passthrough

We accept this because a shared typed boundary is cheaper than many implicit private ones.

Implications

  • source and sink APIs should continue to speak in CanonicalItem
  • proposals to move connector payloads directly through kernels or DSL should be treated as a regression risk
  • product and agent surfaces should reason about ingest/writeback in terms of typed items and graph projection, not opaque transport blobs