One Context, One Registry, and Knowing When to Stop

Published: (March 4, 2026 at 09:28 AM EST)
6 min read
Source: Dev.to

Source: Dev.to

Last post, the first runtime and adapter were alive

The type system had been fighting back — declaration merging across packages, build‑tool swaps — but honestly? I kind of forgot about that problem. It is still there, just not blocking anything right now. Sometimes you move forward and the old fight waits. 😄

The units existed. They could register. But the plumbing between “units declared in a config file” and “units actually running” did not exist yet.

That changed. This round of work is quieter than the last, but it touches everything:

  • config resolution
  • a shared base context
  • a working expose‑and‑query system
  • a few small utilities to keep the registry sane

First Things First: Loading the Units

Before anything else can work, the Kernel needs to know what units it is dealing with. That is what config.resolve.units does — it takes the flat list of units from your config file and resolves them into something the Kernel can actually use.

The units go in as references — strings, factory functions, imported objects — and they come out as loaded, validated, ready‑to‑boot definitions that the Kernel knows how to classify and initialize.

This is the bridge between “I declared units in my config” and “the Kernel knows what to do with them.”
Without it, the unit array is just data sitting in a file. With it, the Kernel can:

  • classify units by kind,
  • topologically sort them by dependencies, and
  • start calling lifecycle hooks in the right order.

It is not glamorous work, but it is the plumbing that makes everything else possible.

One Context to Share Them All

With units resolved, the next question is: what do they get to work with? Every unit kind in Velnora gets a context object — the thing passed into lifecycle hooks like configure() and build(). The context is how a unit talks to the rest of the system: exposing APIs, querying other units, reading config.

Until now, each context was defined independently:

  • IntegrationConfigureContext had its own ctx.query().
  • IntegrationBuildContext had its own.
  • AdapterDevContext — same thing, redeclared from scratch.

That meant duplicated surface area everywhere, and any change to the shared behavior had to be mirrored across all of them. It was manageable with three unit kinds, but it would not be manageable at ten.

The fix is straightforward — create a BaseContext that carries everything shared, and let the specific contexts extend from it.

  • Integration contexts inherit the base and add integration‑specific capabilities.
  • Adapter contexts do the same.
  • Runtime contexts do the same.

The shared parts live in one place; the specialized parts stay where they belong. This isn’t a clever idea, it’s just the first time it is actually built.

Expose, Query, Done

The BaseContext now fully implements the exposing and querying mechanism — the two operations that every unit needs regardless of kind. This is ctx.expose() and ctx.query() actually working, not just typed, but wired up end‑to‑end.

  • A unit can expose an API under a key during configure().
  • Any other unit that declared a dependency on that key can query it and get the real implementation back.

No casting, no any, no manual lookups. The foundation that the entire Unit System was designed around is now real.

Backing the BaseContext is the GlobalRegistry — a central registry object that holds all exposed APIs at runtime.

  • When a unit calls ctx.expose("docker", dockerApi), it writes to the GlobalRegistry.
  • When another unit calls ctx.query("docker"), it reads from the same place.

Each registry entry is keyed, and the keys are properly typed and enforced. The same key that TypeScript checks at compile time is the one used to look things up at runtime. If the types say "docker" exists, the runtime registry has a slot for "docker". If they do not, the slot does not exist and the query fails loudly.

With these in place, the BaseContext is not just a shared interface anymore. It is the working layer that integrations, adapters, and runtimes all build on top of. The thing that was only types two posts ago is now running code.

A Helper for the Lazy

One small helper that came out of this work — makeRegistryObject. It is a convenience for lazy people. You give it a name and a shape, and it builds you a nested object where every level is also a usable string key.

makeRegistryObject("kernel", {
  config: {
    units: "units"
  }
})

The result:

  • result"kernel"
  • result.config"kernel:config"
  • result.config.units"kernel:config:units"

Each level works as a registry key. No manual string building, no typos, full autocomplete.

It is a tiny thing, but in a monorepo with a growing number of registry keys, it saves a lot of stupid mistakes.

The Pause

After wiring up the base context and the config resolution, I started pushing into the runtime implementation — the actual guts of how a runtime like Node or Bun boots its toolchain, resolves packages, and executes code. And then I stopped.

Not because something broke, but because I realized I was about to make decisions that would be very expensive to undo. The runtime layer touches everything:

  • how units get their toolchains,
  • how execution plans are built,
  • how the package‑manager abstraction plugs in,
  • how thread‑mode and process‑mode execution diverge.

One wrong abstraction here and every adapter, every integration, every future runtime inherits the mistake.

So I went back to research: reading through the earlier design notes, sketching alternatives, revisiting the Toolchain API and the Adapter Protocol to see if the boundaries still made sense after…

Everything that changed in the last few rounds. Sometimes the most productive thing you can do is stop coding and think. This is one of those times.

What Is Next

Once the runtime design solidifies, the next step is implementing it: a real Node runtime that boots its toolchain through the BaseContext, resolves packages through the registry, and produces execution plans that adapters can consume without knowing which runtime is behind them. That is the test that proves the whole stack works end‑to‑end.

0 views
Back to Blog

Related posts

Read more »