When an ADR has eight problems


Writing the ADR took an afternoon. Finding out it was wrong took two days. Rewriting it took one more. This is what happens when you ship architecture before you’ve stress-tested the words.

Context

How wrong can an ADR be after one afternoon of writing? Apparently, eight-different-ways wrong. But let’s set the scene first.

By April, moitumi had:

  • A graph-based domain with brain-inspired naming (post: From Nodes to Neurons).
  • A daemon process (moitumid) with surfaces as clients (post: Extracting the daemon).
  • Several plugin-shaped subsystems: mcp-stdio for MCP subprocess plugins, claude-code for agent runners, a manifest schema, an audit log, scope-glob write authorization.
  • A pull request landing a native plugin loader with a C ABI (PR #198), letting .so/.dylib files be loaded into the daemon.

That last one provoked the right question:

Is this one plugin model, or three parallel ones?

The first answer — ADR-035 (Microkernel + Plugin Architecture), landed 2026-04-08 — said “three executor types: mcp-stdio, claude-code, native,” and split inter-plugin communication into signals through synapses and synchronous queries that bypass synapses. It also introduced a separate Neurite / NeuronContext / IPlugin type to give plugin authors a cleaner surface than raw Neuron.

It was reasonable. It was internally consistent. It was wrong on eight axes.

The eight problems

A long design session on 2026-04-10 went question by question (Q1 through Q20 in the rewrite spec). Eight load-bearing problems came out:

  1. The executor-type taxonomy leaked implementation detail into the user model. mcp-stdio / claude-code / native were presented as first-class executor types in the manifest. They’re actually loader backends — how an instance of the plugin is produced. Plugin authors write a signal handler; whether their code runs as a subprocess, a .so, or a C++ class compiled into the daemon is the loader’s problem, not the author’s.

  2. “The plugin architecture” and “pluggable in-process services” were treated as separate problems. They are the same problem. Every current core service (Controller, AgentLifecycle, FileSystemCortexStore, GitDataSource, ConsolidationCycle, BoostCortexAnalytics, …) is a latent plugin. Treating them as different created two architectures.

  3. No explicit kernel boundary. The ADR said “core is the microkernel” but never enumerated what is in the kernel. That made the word “microkernel” aspirational.

  4. Neurons were treated as passive data that plugins access directly. A biological neuron is not passive. And different kinds of neurons hold fundamentally different content — a calendar event is not shaped like a code symbol is not shaped like a journal entry. A single cortex-wide content struct is a relational-database fallacy.

  5. Storage was implicitly kernel-owned. It shouldn’t be. The kernel owns identity and topology; everything about content, metadata, activation, weights, timestamps, archival is plugin-owned.

  6. can_call as a distinct permission synapse kind created a self-authorization hole. Once plugins can create their own outgoing synapses (synaptogenesis), a dedicated permission kind can be trivially forged by the creator — A creates a can_call synapse from A to any target, granting itself permission.

  7. Introducing a separate Neurite / NeuronContext / IPlugin type fragmented the mental model. Biological neurons are single cells. Software should match — one Neuron class plugins subclass, not multiple types.

  8. Splitting inter-plugin communication into “signals through synapses” and “synchronous queries that bypass synapses” was architectural hedging. The split existed to give plugins ergonomic synchronous reads. It forced plugin authors to remember two communication styles and the kernel to run two routers.

The rewrite, in nine decisions

So what falls out the other side? The rewrite collapses the architecture. In order:

1. Drop the executor-type taxonomy

Plugins write one interface: subclass Neuron. Loader backends are internal. The manifest field gets renamed executorloader. There are four backends; plugin authors never pick:

BackendImpliesUse case
builtinC++ class compiled into moitumidEvery in-tree service. Hot-path default.
subprocessJSON-over-stdio (real MCP)External integrations, any language
native.so via ADR-036 C ABIThird-party in-process plugins
claude_code_runnerPTY + Claude Code binaryAgents

The manifest shape implies the backend. [driver] section means native. command means subprocess. claude_code means claude_code_runner. Nothing means builtin. Authors never type “backend = native” — they describe what their plugin is. Refreshing, once you see it.

2. Explicit kernel boundary

What counts as kernel? The test is now strict: if it could be removed and the system would still route signals and load plugins, it is a plugin; otherwise it is kernel.

In-kernel:

  • Neuron registry — id → handler hash map.
  • Synapse topology table — (source, target, kind) triples.
  • Signal router — dispatches signals; authorization check is topology only.
  • Actor scheduler — per-neuron single-writer (ADR-019).
  • Plugin loader — manifest → live Neuron subclass.
  • Audit log shell — primitive.
  • Timer primitive — “wake me in N ms, deliver this signal.”
  • Bootstrap — ~500-1000 lines.

That is the entire kernel. Three tables, one dispatch loop, no governance engine, no query router.

What the kernel does not know:

  • Content. What a neuron contains (text, code, calendar data, embeddings) is not a kernel concept.
  • Metadata. Open-world key/value lives in each plugin’s private store.
  • Activation levels, synapse weights, timestamps, archival flags — all plugin-owned.
  • Governance rules. Scope-glob write restrictions don’t run at the kernel level.
graph TB
    subgraph Kernel["Kernel (irreducible)"]
        Reg[Neuron registry<br/>id → handler]
        Topo[Synapse topology<br/>source, target, kind]
        Route[Signal router]
        Sched[Actor scheduler]
        Load[Plugin loader]
        Time[Timer primitive]
        Audit[Audit shell]
    end

    subgraph Plugins["Plugins (everything else)"]
        Store[plugin.cortex_default_store]
        Plast[plugin.plasticity]
        Attn[plugin.attention]
        Tools[plugin.cortex_tools]
        Tick[plugin.tick_source]
        Agent[plugin.agent_lifecycle_driver]
        Etc[...every current service]
    end

    Plugins -.signals.-> Route
    Route --> Plugins
    Plugins -.create synapses.-> Topo
    Plugins -.register timers.-> Time

    style Kernel fill:#fff4e6,stroke:#d97706
    style Plugins fill:#dcfce7,stroke:#16a34a

3. Every current service is a plugin (migration as the target)

This is the move that makes “microkernel” honest. Needless to say, the migration is the target architecture, not a nice-to-have. Every service in moitumi-core becomes a Neuron subclass loaded via the builtin backend. The Controller god-object decomposes into:

  • plugin.tick_source
  • plugin.agent_lifecycle_driver
  • plugin.consolidation_driver
  • plugin.reflection_driver
  • plugin.scheduled_plugin_spawner
  • plugin.ingestion_pipeline
  • plugin.cortex_tools (replaces the tool bridge, hosts governance)

At migration end, no Controller.cpp exists. (By 2026-04-13 the Controller dissolution was complete in production — Phase 10 of the migration. Tests still use a Controller as a test helper for now.)

4. One Neuron class

Plugins subclass one thing. That’s it. No IPlugin interface, no Neurite port, no ports::X split. The base provides identity, topology queries, create_synapse / remove_synapse, send, scheduling, logging, audit. Subclasses override handle_signal, create, destroy, list_tools.

namespace moitumi {

class Neuron {
public:
    virtual ~Neuron() = default;

    [[nodiscard]] auto id() const -> NeuronId;
    [[nodiscard]] auto path() const -> NeuronPath;

    [[nodiscard]] auto outgoing_synapses() const -> std::vector<Synapse>;
    [[nodiscard]] auto incoming_synapses() const -> std::vector<Synapse>;

    auto create_synapse(NeuronId target, RelationshipKind kind) -> SynapseId;
    auto remove_synapse(SynapseId id) -> bool;

    // The ONLY inter-plugin communication mechanism. Fire-and-forget. Void.
    auto send(const Synapse& s, std::string tool, std::string payload) -> void;

    auto schedule(std::chrono::milliseconds delay, std::string self_tool) -> TimerHandle;

    // Overridable
    virtual void handle_signal(const Signal& sig) = 0;
    virtual auto create(const PluginManifest& m) -> std::expected<void, PluginError> { return {}; }
    virtual void destroy() noexcept {}
};

} // namespace moitumi

Notice what is not there: no get_content(path), no get_metadata(path, key), no get_activation(path). There is no cross-plugin read API. None.

5. Remove can_call

Topology is the permission for every synapse kind. Effectively, the kernel’s signal routing authorization is “does source have at least one outgoing synapse to target?” — nothing else. Amendment to ADR-033 captures this.

6. Remove queries entirely

This is the painful one.

Plugins that need data from other plugins subscribe via incoming synapses, maintain local caches, and handle incoming update signals. Pure event-sourced / CQRS / actor model. Matches Erlang/BEAM exactly.

class ContextAssembly : public moitumi::Neuron {
public:
    void handle_signal(const Signal& sig) override {
        if (sig.tool == "content_changed") {
            local_content_[extract_path(sig.payload)] = extract_content(sig.payload);
            return;
        }
        if (sig.tool == "assemble_context") {
            auto bundle = build_bundle_from_local_cache(sig.payload);
            auto reply = find_outgoing_to(sig.source);
            if (reply) send(*reply, "context_ready", serialize(bundle));
        }
    }
private:
    std::unordered_map<NeuronPath, std::string> local_content_;
};

To be honest, I made at least four attempts to preserve a synchronous read mechanism. NeuronContext. Neurite with fetchable views. get_view virtual. handle_signal -> optional<Response>. Every time, pushing the model through surfaced another hole. The honest answer was always “pure event-sourced, subscribe and maintain local state, accept the plugin boilerplate cost.” I got there via a long loop… and a lot of resistance along the way.

7. handle_signal returns void

No Async<T>. No Task<T>. No coroutines in the kernel or plugin interface. Request/reply, if a plugin wants it, is a plugin-level protocol built on top of fire-and-forget signals + reciprocal synapses + correlation IDs.

Phase A (single-threaded synchronous dispatch) and Phase B (per-neuron mailboxes + worker pool) are both void-handler. That being said, plugin code does not change across the phase transition. Top notch.

8. Controller does not exist in the target

Already covered in (3). The God-object decomposes into independent builtin plugins, each handling one responsibility.

9. Governance moves into plugins

Scope-glob write rules from ActionValidation move from kernel pre-checks into plugin.cortex_tools’s own handle_signal. The kernel is governance-blind. The receiving plugin enforces its own policy.

The validation-discipline lesson

The DEVLOG entry from 2026-04-10 captures it precisely. Quoting myself:

I made at least four attempts to preserve a separate “query” or “synchronous read” mechanism. Each time, pushing the model through and asking “is this consistent with the other commitments?” surfaced another hole. The pattern I should have noticed earlier: if the user’s biological intuition pushes toward “one mechanism, signals via synapses, nothing else”, and I keep proposing “two mechanisms because reads are ergonomically hard”, the intuition is load-bearing and the ergonomic hedge is the wrong move.

The pattern is general:

When you keep proposing the same compromise, the compromise is what’s wrong. Not the principle that keeps rejecting it.

Three times in moitumi’s history I’ve caught myself doing exactly this:

  1. Tick scheduling for plugins. Invented a tick-participation interface when the existing actor + continuation neuron model already solved it.
  2. Typed synapses for provenance and capability binding. Drifted toward Neo4j-style property graphs when ADR-030 (open-world metadata) + the audit log already covered it.
  3. Synchronous reads. The story of this post.

Each time I caught it by stepping back, re-reading prior commitments, and applying CLAUDE.md’s validation-discipline rule: “re-read before committing, test from a different angle, question the approach.”

Anyway, the cost of not catching it on first writing is a clean-slate rewrite. The cost of catching it is one careful read of what you just wrote, before pushing the PR. Which would you rather pay?

What this earns

So what did all this churn buy? The new architecture is smaller. The kernel is ~500-1000 lines. The contract for a plugin author is one virtual function (handle_signal) plus optional create/destroy/list_tools. There is no CapabilityRegistry. There is no governance engine. There is no query router. The phases (A, B, C) preserve void handlers — plugin code does not change across the concurrency transition.

The biological metaphor doesn’t require this shape, but it points straight at it. Neurons fire signals at neighbors over synapses. They don’t query each other. They subscribe and integrate. The actor model is the software shape of that intuition. Once I stopped fighting it, the architecture got out of its own way.

Overall… eight problems, nine decisions, one rewrite. The uncomfortable part isn’t the work — it’s noticing that the principle was correct on day one, and I spent two days arguing with it.

Source pointers

  • DEVLOG.md — entries of 2026-04-08 (original ADR-035/036), 2026-04-10 (clean-slate rewrite), 2026-04-13 (Controller dissolution complete)
  • PLAN.md — ADR-033 (Neural Signaling, amended), ADR-035 (Microkernel + Plugin Architecture, revised), ADR-036 (Native Plugin C ABI)
  • docs/superpowers/specs/2026-04-10-adr-035-rewrite.md — the full Q1-Q20 exploration log

Cross-posted from the moitumi dev blog.