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_itemwrites that item into the graph
Outbound:
project_node_to_itemprojects a graph node intoCanonicalItem- 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:
- inbound channels produce
CanonicalItem - the graph ingest pipeline consumes
CanonicalItem - outbound projection produces
CanonicalItem - sinks consume
CanonicalItem - the DSL does not become a transport for opaque item payloads
The preferred framing is:
CanonicalItemis 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