Lemon Platform Split

August 14, 2026 · View on GitHub

Status: v2 — open questions resolved, execution plan set · Started 2026-08-09, decisions resolved 2026-08-09 with code-level investigation (see Evidence). Living document: append to the Decision Log as work lands; check off work items in place.

1. Goal

Reshape Lemon from a 22-app umbrella (~415k LOC) into a platform for building BEAM agents:

  • The lemon repo publishes a small set of hex packages with deliberate, semver'd public APIs, plus a batteries-included reference runtime.
  • Products (coding agent, sim arenas, tcg, showcase, TS clients) live in their own repos and consume hex releases exactly as a third party would.
  • Third-party builders get: documented extension behaviours, a contract-test kit, mix lemon.new, and getting-started docs written for their agent, not ours.

2. Target architecture

Published packages (from the lemon platform repo)

PackageContentsSource todayPublish order
lemon_aiProvider-agnostic LLM client: providers, registry, rate limiting, circuit breaker, compaction, tokens/textapps/lemon_ai (31k, zero umbrella deps)1
lemon_coreThe platform's shared language: Bus, Event envelope, Store (+backends), Secrets, Config loader, boundary contracts (RunRequest, ExecutionCommand, InboundMessage, DeliveryIntent, EngineRuntime, RouterBridge, SessionKey, ResumeToken, run phases), primitives (clock/id/retry/telemetry/idempotency), Extensions manifestslimmed apps/lemon_core2
lemon_agentAgent loop, tool registry, subagents, model runtime, workspace stores (goals/kanban/heartbeats)apps/lemon_agent + 3 stores from lemon_core3
lemon_cli_runnersVendor AI CLI wrappers (Claude Code, Codex, Kimi, OpenCode, Pi) as streaming subagents: JsonlRunner behaviour + per-vendor runner/schema/subagent triplescarved from apps/lemon_agent (D15, 2026-08-11)4
lemon_memoryDurable agent memory: document schema, store, provider behaviour + fan-out registry, ingest pipeline, search, task fingerprints8 modules from lemon_core (~1.9k LOC)4
lemon_mediaMedia job tracking: redacted job/artifact metadata store, supervised job workers, lifecycle broadcasts, retention cleanupapps/lemon_media (~1.1k LOC, lemon_core only)5 (before router/channels, which depend on it — D13)
lemon_routerRun lifecycle + session orchestration: single-flight, queue/steer, coalescing, policy, watchdog, delivery routingapps/lemon_router, facade hardened5
lemon_gatewayEngine execution runtime only: Engine behaviour, engine registry/scheduler/locks, EngineRuntime implapps/lemon_gateway minus transports/sms/voice5
lemon_channelsChannel core (Registry, Outbox, Dispatcher, PresentationState) + Plugin behaviour + built-in adapters (telegram, discord, whatsapp, xmtp, email, webhook)apps/lemon_channels + gateway's transports5
lemon_platform_testContract-test kit: behaviour compliance suites for Plugin/Engine/StoreBackend/MemoryProvider authorsnew6
lemon_browserBrowser capability driver + artifact storeapps/lemon_browser (818 LOC, lemon_core only)7 (D14, approved 2026-08-13)
lemon_skillsSkill registry, discovery, installation, assistant-platform toolsapps/lemon_skills (minus X tools, gone with D7)7, gated on the API-stabilization pass (D14)

Satellite (separate small repos/packages, the model for all vendor integrations): x_api (X client + its channel adapter + its 3 skills tools, self-registering).

Stays in the platform repo, unpublished initially

lemon_control_plane, lemon_cli, lemon_web, lemon_automation, lemon_lsp — these form the reference runtime ("lemon server") that wires the published packages together. Publish later if demand appears; being in-repo keeps their API churn cheap. (lemon_skills and lemon_browser left this list for the published table when D14 was approved 2026-08-13 — they are shared platform infrastructure the runtime also consumes, and unpublished they blocked the coding-agent extraction.)

Product repos (extracted, consume hex releases)

RepoTakesWhy grouped
coding-agentcoding_agent, coding_agent_ui, lemon_mcp, lemon_evalsmcp + evals compile-depend on coding_agent
lemon-simlemon_sim (incl. Bench), lemon_sim_ui, lemon_tcgtcg needs sim's Kernel/LLM engines; sim_ui needs everything. Flagship demo repo (D8)
showcaseshowcase/ static site
lemon-clientsclients/ TS packagesdifferent toolchain

Dependency rules (enforced, see Phase 3)

lemon_ai ← lemon_agent ← {router, gateway, channels, skills, products}
lemon_agent ← lemon_cli_runners ← {gateway, products}
lemon_core ← everything
lemon_memory ← {router (ingest hook), skills, products}
router ⇄ gateway: ONLY via LemonCore.EngineRuntime behaviour (config-injected)
channels → router: ONLY via LemonCore.RouterBridge
router → channels: Dispatcher/Outbox facade only (the one allowed compile-time edge)
products/satellites → platform: hex deps; platform NEVER depends on a product

3. Evidence (investigations of 2026-08-09)

Full details in agent reports; key facts the plan relies on:

E1 — Store/lemon_core publishability audit. LemonCore.Store is an application singleton, not a library: name: __MODULE__ hardcoded (store.ex:40-42), config read from :lemon_core app env ignoring start_link opts (store.ex:472-478), ReadCache uses fixed public named ETS tables that fail open on collision (store/read_cache.ex:38-49). Domain coupling in the hot path: finalize_run calls RunHistoryStore.put/4 and MemoryIngest.ingest/3 directly (store.ex:906,914); Telegram msg-id indexing at store.ex:1048; policy/session/telegram tables baked in (store.ex:28-38). Deps: sentry + finch + exqlite are non-optional (apps/lemon_core/mix.exs:27-42); uuid ~> 1.1 unmaintained. Secrets crypto is sound (AES-256-GCM + HKDF) but key provisioning is macOS-Keychain-first with no non-macOS init path (secrets/master_key.ex:47-61,216-224). Backend behaviour itself (store/backend.ex) is clean and genuinely pluggable.

E2 — Bench extraction assessment. Bench is only 2,857 LOC (~2.9% of lemon_sim), filesystem-only persistence, and near-zero coupling to sim internals — but its Domains registry hardcodes ~21 LemonSim.Examples.* module pairs (bench/domains.ex:48+), and no consumer wants Bench without the sim: lemon_tcg uses zero Bench (it uses LemonSim.Kernel.* + LLM.* engines), lemon_sim_ui uses ~9 League/Domains functions but also the kernel and five game engines. Extraction would add graph nodes for no consumer gain.

E3 — router/gateway/channels topology. The three apps already communicate almost entirely through indirection: router→gateway has zero compile-time references (behaviour injection via LemonCore.EngineRuntime, 4 callbacks, bound in config/config.exs:36); channels→router and gateway→router go through LemonCore.RouterBridge; the only compile-time edge is router→channels (Dispatcher/Outbox). CI already polices boundaries (lemon_core/quality/architecture_rules_check.ex:37-51). Blemishes: gateway hosts email/webhook transports + sms/voice (~6.7k LOC after the D12 farcaster delete) that duplicate the channels concept under a second transport behaviour (LemonGateway.Transport vs LemonChannels.Plugin); channels/control_plane reach back into gateway via dynamic atoms (lemon_channels/gateway_config.ex:4, engine_registry.ex:13, control_plane/methods/transports_status.ex:139); control_plane leaks router internals (RunRegistry, RunSupervisor, RunOrchestrator — 5 call sites). Gateway's coding_agent dep is 12 refs in 4 files: the in-process "lemon" engine shim (engines/lemon.ex, engines/lemon/session_runner.ex) plus CodingAgent.Config.workspace_dir/0 and CodingAgent.Security.ExternalContent.

E4 — misc. License is MIT (hex-compatible). Hex names lemon, lemon_core, lemon_ai, lemon_agent, lemon_runtime, lemon_bench, lemon_memory, lemon_channels all unclaimed as of 2026-08-09. LemonChannels.Plugin (6 callbacks, worked example in moduledoc, runtime registration via Application.register_and_start_adapter/2) is the best extension point in the tree. control_plane uses 7 CodingAgent surfaces, all ops-introspection (TaskStore, SessionRegistry, Session.compact, Extensions, RunGraph, Wasm.SidecarSupervisor, skills paths).

4. Resolved decisions

#DecisionRationale
D1No merged lemon_runtime package. Router, gateway, channels stay separate packages; the name lemon_runtime is retired.E3: router↔gateway is already a published-package-quality boundary (behaviour-injected, zero compile refs). Merging destroys the cleanest seam in the tree.
D2 (amended 2026-08-10)Gateway sheds only what is actually a channel. Original D2 ("all five move to Plugin, Transport behaviour deleted") was not supportable: only email/farcaster/webhook implement LemonGateway.Transport (SMS/voice never did); Plugin.deliver/1 is fire-and-forget and cannot return a synchronous HTTP response into the originating request (webhook sync-mode, farcaster frames); channels has zero HTTP-server infrastructure; and all ~7.9k LOC is dead-by-default (:legacy_ingress_enabled false, :transports []). Amended: port email to Plugin (genuine fit, needs new LemonChannels.InboundHttp); webhook + SMS stay in gateway as non-channel ingress; voice deferred; farcaster deleted (D12, executed 2026-08-10). Prior art: docs/plans/gateway-channels-transport-migration.md (2026-07-07) reached the same conclusion; re-verified 2026-08-10. Full analysis: docs/platform/transport-unification.md.Enlarging Plugin (sync-response/idempotency/queue-override) to serve non-channel surfaces would bloat the platform's most third-party-facing extension point.
D3Bench stays inside lemon_sim and leaves with it. In the sim repo, move the wiring modules (bench/domains.ex, the three registries) out of bench/ into a LemonSim.BenchDomains namespace and inline stable_json, so bench/ has zero Examples.* references. Extract to lemon_bench only when a second consumer appears.E2: exactly one dependent, which needs full lemon_sim anyway. Extract on the second consumer, not the first.
D4lemon_memory becomes its own published package (memory_document, memory_store, memory_provider behaviour, memory_providers registry, memory_ingest, memory_safety, session_search, task_fingerprint).Coherent ~1.9k LOC domain, has a behaviour, 3+ consumers, MemoryStore is already :name-parameterized. Durable memory is a headline platform feature.
D5goal_store / kanban_store / heartbeat_store move to lemon_agent under an LemonAgent.Workspace.* namespace.They are multi-agent work coordination built purely on core primitives; every consumer (automation, channels, skills, control_plane) already depends on agent_core. Keeps slim core product-free without inventing a fourth package.
D6Store gets library-ified before publish (see Phase 1 items); sentry/finch/exqlite become optional deps; uuid replaced with a vendored UUIDv7 or uniq.E1. Non-negotiable for a package third parties embed.
D7x_api becomes the model satellite integration: its own repo/package containing the X client, the channels adapter (adapters/x_api* moves there), and the 3 X skills tools (get_x_mentions, x_search, post_to_x move out of lemon_skills). It self-registers via the Plugin/tool registration APIs. Platform loses all compile-time knowledge of X.Proves the extension story with a real integration; kills two wrong-direction deps at once.
D8lemon-sim is the flagship demo repo.Cleanest dependency profile (core+agent+ai only) = the best advertisement that the platform seam works; arenas are the most visually compelling artifact.
D9control_plane↔coding_agent inversion via method-provider registration: control_plane exposes a MethodProvider registration API; coding_agent registers its ops methods (tasks_, sessions_, extensions_status, skills_status, run_graph, wasm status) at boot. Same pattern as channel adapters.E4: all 7 usages are ops-introspection methods — a registry fits better than 7 behaviours.
D10Versioning: umbrella calver stays for the repo; each published package starts at 0.1.0 semver at first publish, 1.0.0 only after the extraction (Phase 5) has proven the APIs. Hex names reserved at Phase 0. MIT license confirmed.Freedom to break APIs while the only consumers are our own repos.

5. Execution plan

Phases are ordered by dependency; items within a phase are parallelizable unless noted. Sizes: S (≤1 day), M (days), L (week+).

Phase 0 — Groundwork (S)

  • 0.1 Reserve hex package names via 0.0.1 placeholders Superseded: the names were reserved by publishing the real 0.1.0 releases directly (see 4.3, 2026-08-11). (reconciled 2026-08-13)
  • 0.2 Add boundary or Extended architecture_rules_check.ex (AST-based @module_reference_rules, catches dynamic atoms) with 5 rules + 29-entry shrink-only @grandfathered allowlist grouped by the Phase 2 item that retires each group. Found+grandfathered one unknown violation: lemon_automation/cron_manager.ex:479 uses LemonRouter.RunRegistry (retire in 2.6). Runs in existing mix lemon.quality lane. (2026-08-10)
  • 0.3 docs/platform/ skeleton created (8 package stubs), cataloged in docs/catalog.exs. (2026-08-10)

Done when: names reserved; CI red on any new cross-boundary reference.

Phase 1 — Carve lemon_core (L; the critical path)

Store library-ification (from E1, all in apps/lemon_core):

  • 1.1 Done: server-first optional arg (def get(server \\ __MODULE__, ...); explicit higher-arity clauses where trailing opts made defaults ambiguous). Opts-first config with app-env fallback; :store_runtime_override deliberately applies only to the default instance; per-instance RunHistoryStore sqlite filenames. (2026-08-10)
  • 1.2 Done: ReadCache rewritten — per-store table sets, table refs (no hot-path atom derivation), ownership-based collision rule (claim only if owner is self() or the store's registered process; else raise CollisionError). 14-test store_instance_test.exs proves two stores + caches isolated in one node. (2026-08-10)
  • 1.3 Done: Store.Hooks (store/hooks.ex) — finalize_run invokes registered {m,f,args} hooks with failure isolation; runtime registrations live in :persistent_term keyed by store name so they survive store restarts. RunHistoryStore + MemoryIngest register themselves via config (each owns its handle_finalize_run adapter, so it moves with the module in 1.6). Read path inverted too: get_run_history forwards to a configured :run_history_provider. Telegram msg-id indexing at store.ex:1048 turned out to be a stale comment (real code removed in 77d68e58, guarded by :core_telegram_resume_index_leak); :telegram_known_targets removed from core defaults — cached tables are per-instance opts + register_cached_table/1, channels registers its own at boot. sessions_index staleness bug fixed (write-through, regression test). A source-scan test now asserts store.ex contains no RunHistoryStore/MemoryIngest/telegram references. Policy wrappers deliberately untouched (§6). (2026-08-10)
  • 1.4 Done: apps/lemon_core/mix.exs carries exactly three hard deps — jason, toml, telemetry — with phoenix_pubsub, exqlite, file_system, sentry and finch optional: true, each with an inline comment naming its degradation path ("a host application can depend on lemon_core without inheriting a SQLite NIF, an HTTP client and a pubsub server it may not use"). The open question resolved as Registry fallback, not "require phoenix_pubsub explicitly": Bus starts a keys: :duplicate Registry (LemonCore.Bus.Registry, first child of LemonCore.Application) and dispatches via Registry.dispatch + send when Phoenix.PubSub is absent. Backend detection is at runtimeCode.ensure_loaded? memoized in :persistent_term, overridable with config :lemon_core, :bus_backend, :registry | :pubsub | :auto so tests can force the fallback, since optional deps are not inherited transitively (corrected from the commit's original compile-time module attribute in 041f9b28). The fallback is local-node only and the moduledoc says so: distributed deployments must depend on phoenix_pubsub explicitly. The other four degrade through Code.ensure_loaded? guards rather than crashing: Store defaults to EtsBackend while SqliteBackend.init/1 raises with install guidance (and Exqlite.Error is classified by message, not by struct match, so the module isn't needed at compile time); RunHistoryStore is simply not started (Application.sqlite_children/0 — reads exit :noproc, finalize-run hooks no-op); ConfigReloader.Watcher always schedules a 5s poll and only upgrades to native watching when FileSystem loads; drop_unloadable_handlers/0 strips the Sentry :logger handler at boot, because an unloadable handler module takes the boot down. finch is only Sentry's HTTP client (v12+) — nothing in core calls it. Vendored LemonCore.UUID (lib/lemon_core/uuid.ex: uuid4/0, uuid7/0,1, version/1, decode/1; RFC 9562 variant bits, canonical lowercase) replaced the unmaintained uuid hex dep in five apps and in mix.lock, reached only through LemonCore.Id (which gained uuid7/0). Two extras the line didn't anticipate: lemon_router gained its own exqlite dep (RoutingFeedbackStore was free-riding on core's hard one), and the release profiles now name exqlite/sentry/finch :permanent — optional for embedders, shipped by the reference runtime. Coverage: test/lemon_core/bus_registry_fallback_test.exs (fanout, unsubscribe, topic isolation, broadcast_from under the fallback) + uuid_test.exs, joined by uuid_property_test.exs (StreamData: bit layout, exact 48-bit timestamp for any ms, lexicographic ordering, decode round-trip). (e4e63bc2, 2026-08-10; checked off 2026-08-13)
  • 1.5 Done: KeyProvider behaviour (keychain/env/file built-ins, chain configurable via config :lemon_core, LemonCore.Secrets, key_providers:), portable non-macOS provisioning (0600 key file; mix lemon.secrets.init --target --force), weak raw keys rejected loudly (:weak_master_key) rather than HKDF-stretched — stretching would silently break existing ciphertexts; allow_legacy_raw_keys: true escape hatch with deprecation warning. Rotation gap documented in moduledoc. (2026-08-10)

Module moves (destinations per the disposition table in §6):

  • 1.6 Done: apps/lemon_memory created (deps: lemon_core + exqlite as a direct, non-optional dep — durable memory is the app's reason to exist). All 8 modules moved with git mv, renamed LemonCore.Memory*LemonMemory.* (Document, Store, Provider, Providers(.Local), Ingest, Safety, SessionSearch, TaskFingerprint); mix lemon.memory moved too. Supervision (Providers always; Store+Ingest behind the exqlite guard) now lives in LemonMemory.Application, and the finalize-run hook config points at LemonMemory.Ingest. App-env key moved from :lemon_core, LemonCore.MemoryStore to :lemon_memory, LemonMemory.Store (config.exs, test.exs, runtime.exs). Doctor's memory diagnostics now go through LemonCore.Doctor.RuntimeModules (:memory_providers) instead of naming the module — so lemon_core has zero memory references left. 5 consumers updated (coding_agent, lemon_cli, lemon_control_plane, lemon_router, lemon_skills). Clean break, no shims. (2026-08-10)
  • 1.7 Move goal/kanban/heartbeat stores → apps/lemon_agent as LemonAgent.Workspace.* (D5); update automation/channels/skills/control_plane call sites. Clean break, no shims. lemon_automation gained an agent_core dep; core's support bundle now resolves the goal/kanban diagnostics modules from config :lemon_core, :workspace_diagnostics so core keeps zero references to agent_core.
  • 1.8 Move single-consumer modules out. Done: provider_pool_rotator→coding_agent (CodingAgent.ProviderPoolRotator, now supervised by coding_agent), provider_config_resolver→agent_core (LemonAgent.ProviderConfigResolver). Doctor: the 17 check modules turned out to reference no foreign app — the cross-app reach was in doctor/support_bundle.ex (media, browser) and doctor/lsp_diagnostics.ex (lsp), which now resolve those modules from config :lemon_core, :doctor_runtime (LemonCore.Doctor.RuntimeModules); config :lemon_core, :doctor_checks lets any app register its own checks. lemon_core's doctor code and tests now name zero foreign modules (guarded by a test). Not moved, blocked on boundaries: build_info is not single-consumer (core's own support-bundle manifest uses it at support_bundle.ex:130, and it reports lemon_core's version) — moving it to lemon_sim_ui would make core depend on sim_ui; chat_state/chat_state_store are used by lemon_gateway (run.ex, transports/farcaster/cast_handler.ex) as well as router/control_plane, and are baked into Store.put_chat_state/get_chat_state — moving them to router would force a gateway→router dep, which §2 forbids. Both need a decision recorded before they can move.
  • 1.9 Done: LemonCore.Env is now framework-only (258 LOC, down from 3,455); the 266 declarations live in 16 per-app registry modules aggregated through config :lemon_core, :env_registries. Contract is structural (declarations/0), with a use LemonCore.Env.Registry macro adding compile-time shape validation for apps that depend on lemon_core — ai implements it by hand precisely because it must not depend on core. Unloaded registries are skipped, so the aggregate always describes what the build can actually read. Ownership is by reader, not by name: 25 variables whose names say LEMON_GATEWAY_*/LEMON_TELEGRAM_*/LEMON_WEB_CACHE_* stay in lemon_core because LemonCore.Config.* resolves them (via Env.get/2, often through atoms passed as arguments); moving them by namespace made lemon_core raise standalone. They follow their readers out in a later pass. lemon_core's env_test now tests the framework against a test-local registry + core-owned vars, so it passes standalone and in the umbrella. (2026-08-10)
  • 1.10 Done: LemonCore.Paths (lib/lemon_core/paths.ex) is the single resolver for every filesystem convention core owns — state dir name, config file name, home/project state dirs, the store default and the checkpoint dir — reading config :lemon_core, :paths with precedence call-site option > app env > module default (.lemon, config.toml), and reading $HOME/TMPDIR at call time so a release never freezes the build machine's paths. The reference runtime states those defaults back explicitly in config/config.exs ("so the values live with the runtime rather than inside the library"; the block actually landed alongside in 1.9's d7ed1422), which makes it declarative duplication rather than divergence — the library still has working defaults for a host that configures nothing. Migrated consumers, all inside lemon_core: Config.global_path/0 + project_path/1, Config.Modular, ConfigReloader.Watcher.watched_paths/1, RunHistoryStore's default dir, four doctor diagnostics and the jsonl→sqlite mix task. The bundled bug fix: the global config path had three independent implementations, so the reloader could watch a different file than the loader read. 9f845146 finished the sweep for checkpoints — LemonCore.Checkpoint's @checkpoint_dir was a compile-time attribute baking the build machine's tmp into the module; checkpoint_dir/0 now delegates to Paths per call. §6's "checkpoint → verify owner during Phase 1" resolves to core: LemonCore.Checkpoint stays, and checkpoints stay under system tmp by design (in-flight rollback material — moving them under the state dir would orphan every existing checkpoint), with checkpoint_dir: as the opt-in for hosts that want durability. Deliberately outside the :paths layout (documented in core's README): the secrets master key (own :key_file; home-scoping is load-bearing) and the store backend path — so the line's "MemoryStore" leg was answered by per-store config, not by Paths: LemonMemory.Store resolves its own :path app env (set by runtime.exs/test.exs) with a ~/.lemon/store fallback, mirrored in core's doctor/checks/memory.ex. No data migration: the defaults are unchanged, config only relocates future reads and writes. Covered by test/lemon_core/paths_test.exs (defaults, app-env renames propagating to every derived path, option-beats-env precedence, TMPDIR honored at call time, and Config/Modular/Checkpoint/doctor agreeing with Paths). (095c6215 + 9f845146, 2026-08-10; checked off 2026-08-13)

Done when: apps/lemon_core has no modules referencing telegram/run-history/memory/kanban concepts; two differently-named Stores can run in one node (add a test); mix deps.tree for lemon_core shows sentry/finch/exqlite optional; umbrella CI + smoke green. — All criteria met as of 2026-08-13: store_instance_test.exs proves two named Stores + caches in one node (1.2); sentry/finch/exqlite carry optional: true (1.4); and the vendor-channel sweep removed every telegram/discord/xmtp/whatsapp concept from core's lib (gateway-channel config via the new LemonCore.Config.Gateway.Channel behaviour + :gateway_channels registry, doctor surfaces via :doctor_runtime/:doctor_checks, proof classification via LemonCore.Doctor.ChannelProofs), CI-enforced by the :core_vendor_channel_reference quality rule.

Phase 2 — Invert wrong-direction deps (M)

  • 2.1 Gateway ⊘ coding_agent: engines/lemon.ex + engines/lemon/session_runner.ex moved to apps/coding_agent as CodingAgent.GatewayEngine(.SessionRunner), self-registering via the new LemonGateway.EngineRegistry.register/1 at coding_agent boot (registration also updates :lemon_gateway, :engines so a registry restart keeps it; configured engines whose module is absent are skipped instead of crashing the registry). Gateway's workspace dir is now its own LemonGateway.Workspace reading config :lemon_gateway, :workspace_dir, which the reference runtime points at {CodingAgent.Config, :workspace_dir, []}. mix.exs dep deleted and the edge inverted (coding_agent → lemon_gateway); the four grandfathered entries are retired. Correction to this item: Security.ExternalContent was NOT moved to lemon_core — CodingAgent.Security.ExternalContent is already a delegation shim over LemonAgent.Security.ExternalContent, which depends on LemonAgent.Types.AgentToolResult and LemonAi.Types.TextContent, so core cannot host it without taking agent/AI types. Gateway now calls the agent_core module directly (an edge it already had). §6 should move Security.ExternalContent from the core row to the lemon_agent row.
  • 2.2 Control_plane ⊘ coding_agent (D9). Shape chosen: one capability provider, not per-method registration. All 7 surfaces turned out to be backends behind existing methods (TaskStore inside tasks.*, SessionRegistry+Session.compact inside sessions.compact, Extensions/ToolRegistry/Config/Wasm inside extensions.status and skills.status, RunGraph inside run_graph.get, Progress inside agent.progress) — no method is wholly owned by the agent, so registering method modules would have meant splitting 8 handlers in half. Instead LemonControlPlane.AgentRuntime resolves a single registered module implementing the 13-callback AgentRuntime.Provider behaviour, and AgentRuntime.call/3 is the only path to it: missing provider, unimplemented optional callback, raise and exit all yield the caller's fallback, which is what preserves every method's existing empty/unavailable payload. coding_agent registers CodingAgent.ControlPlaneProvider at boot via Module.concat + apply so the product keeps zero compile-time reference to the unpublished reference runtime (it does not declare the behaviour either; a coding_agent test checks the module against behaviour_info(:callbacks) at runtime instead). mix dep deleted, 8 grandfathered entries retired, policy row updated. Bonus: extensions.status and agent.progress were previously unguarded — they would have crashed without coding_agent, and now degrade.
  • 2.3 Done (D7): the adapter (XApi.ChannelAdapter + .GatewayMethods) and the three tools (XApi.Tools.{XSearch,PostToX,GetXMentions}) moved into apps/x_api with git mv; x_api gained lemon_channels/agent_core/ai deps and channels+skills dropped theirs, so the arrow now points satellite → platform. The tools were the real work: they were never registered from lemon_skills — three platform lists named them (CodingAgent.ToolRegistry.@builtin_tools, CodingAgent.Tools coding_tools/all_tools, LemonMCP.ToolAdapter.@builtin_tools), so moving them would have made the platform depend on the satellite. Added LemonAgent.ToolRegistry (persistent_term, built-ins win on name collision) — the tool-side analogue of the engine/adapter/check registries — and all three consumers merge it. XApi.Application registers the adapter via LemonChannels.Application.register_and_start_adapter/2 and the tools via the registry, both behind Code.ensure_loaded? guards, so x_api boots standalone. LemonChannels.Adapters.XAPI is out of config :lemon_channels, :adapters — the runtime's config no longer names X either. All 5 XApi allowlist entries retired. Left in place deliberately: LemonChannels.Capabilities.lookup("x_api"), a string-keyed data table with no XApi.* reference and its own test; folding capability lookup into the registered adapter's meta belongs with 2.4. (2026-08-10)
  • 2.4 Done (rescoped per amended D2 — see docs/platform/transport-unification.md). A1–A3, B1 (characterization tests written before the move), B2 (LemonChannels.InboundHttp) and B3 (the port) all landed, and email cut over: LemonChannels.Adapters.Email owns both halves, email joined discord in TransportRegistry's @channels_owned_transport_ids, and apps/lemon_gateway/lib/lemon_gateway/transports/email{,.ex} is deleted along with gen_smtp/mail from the gateway's deps. The load-bearing decision was thread state: the gateway's :email_message_threads/:email_thread_state tables were ported rather than dropped, because OutboundPayload carries no Subject and no References chain — without them every reply starts a new conversation in the recipient's client. thread_id/1 stays pure as the seed and the store layers a lookup in front, so a runtime without a store behaves exactly as the stateless version did. Continuity is deliberate: same table names, and the adapter still reads the TOML [gateway] email block, so existing relays and tokens survive. Inbound stays off by default (InboundHttp disabled + token required), matching the dead-by-default gateway transport it replaced — this was a move, not a feature launch. Webhook/SMS remain gateway ingress; voice deferred; farcaster deleted (D12, C1 done). Fallout worth noting: the port's hostile-input hardening exposed the same raise-on-non-binary bug in the Telegram adapter, and LemonPlatformTest.PluginCase now probes every adapter for that class. (2026-08-10)
  • 2.5 Kill dynamic-atom back-refs. New LemonCore.EngineInfoBridge (RouterBridge's pattern pointed the other way: configured implementation module per capability, runtime dispatch, documented degraded answer). The engine runtime registers itself in LemonGateway.Application.start/2 with three capabilities — engine_registry, transport_registry, gateway_config — and the three back-refs now ask core: channels' gateway_config.ex (via new LemonGateway.Config.replacement_config/0, so the app-env peek lives in the app that owns the env), channels' engine_registry.ex (extract_resume/1), and control_plane's transports_status.ex (the :transport_registry_module app-env override still wins, which is how its tests substitute a stub). The @grandfathered allowlist is now empty — every authorized cross-boundary reference is gone.
  • 2.6 Done: LemonRouter gained available?/0, active_runs/0, run_active?/1, active_run_count/0, counts/0, designed from the 13 call sites rather than speculatively (submit/1, abort/2, abort_run/2 already existed and just needed callers pointed at them). The facade owns the defensiveness each caller had reimplemented — Process.whereis probes, Registry.select/lookup, DynamicSupervisor.count_children, rescue/catch ladders — so a router that is not running reports nothing-active instead of raising. counts/0 deliberately returns the full zeroed shape rather than %{}: control_plane's status method reads .active/.queued/.completed_today unguarded, and an empty map raised KeyError (caught in test). 9 call sites migrated across control_plane (7), automation (1, the Process.whereis(RunRegistry) probe → available?/0) and the abort pair; RunSupervisor/RunOrchestrator are now @moduledoc false (there is no RunRegistry module — it's a plain Registry started in the router's supervision tree). All 8 router entries retired from @grandfathered; the :router_internals_boundary rule now has zero exemptions. New facade_test.exs proves the contract with the router both running and stopped. (2026-08-10)

Done when: mix xref graph shows no gateway→coding_agent, control_plane→coding_agent, channels→x_api, skills→x_api edges; only one transport behaviour exists; boundary CI allowlist from 0.2 shrinks accordingly.

Phase 3 — Contracts, docs, test kit (M)

  • 3.1 Done: LemonCore.Events plus 24 payload structs under lib/lemon_core/events/ (21 registered modules + Completion, Action, ApprovalPending nested; the 25th file is the shared Events.Payload macro), all generated by that macro that supplies strict new/1, lenient from_map/1 (string keys, nested coercion, unknown-key drop), @derive Jason.Encoder and an @deprecated Access shim; dispatch is a runtime topic atom → module registry (30 type atoms) with coerce/2 (lenient) and cast/2 (strict, for trust boundaries), not a typespec union. Coverage was deliberately narrowed from "every published topic" to the 7 contract topicsrun:<id>, session:<key>, cron, exec_approvals, system, goals, routing_feedback — leaving app-internal ones (nodes, presence, run_graph:<id>, parent_question:<id>, the sim/arena family) untyped: giving them core structs would re-domain the app Phase 1 just spent itself emptying. Bus.broadcast_event/3-4 is the typed path — check_payload! raises when a registered type's payload is not its struct in :dev/:test (config :lemon_core, :enforce_event_payloads) and passes through in :prod; Bus.broadcast/2 stays an untyped pass-through. The LemonCore.Event envelope was kept on purpose (five forwarders relay unknown types by matching %Event{}) and meta stays a free map: the structs type the payload, not the frame. §7's "struct + legacy-map acceptance for one cycle" was implemented, and in both directions — old publishers reach new consumers through from_map/1/coerce/2 (a malformed injected payload is returned untouched instead of crashing the subscriber), new publishers reach old consumers through the Access shim, which is exactly why control_plane's EventBridge (26 payload[:key] clauses, now with a warn_unmapped_once catch-all) and RunCompletionWaiter's envelope-less-map clauses still work unchanged. So "consumers pattern-match on structs" is true of the pilots (routing_feedback_store, gateway run.ex) and not yet of the two highest-volume consumers; converting them and removing the shim is booked as the next major. Catalog (78 publish sites), envelope-vs-payload design, the semver table (add optional field = minor; require/remove/retype/rename a field or drop the shim = major) and the staging plan are in docs/platform/bus-events.md (cataloged in docs/catalog.exs, from 781418f9). Coverage: LemonPlatformTest.EventsCase — registry completeness, from_map round-trip incl. flattened nesting and falsified booleans, string-key acceptance, unknown-key drop, Jason encodability, Access-deprecation hygiene, envelope discipline and mismatch rejection under both bus backends — run against core's own registry by apps/lemon_platform_test/test/compliance/core_events_test.exs, plus control_plane's event_type_validation_test.exs for typed injection accept/reject. (b7b69e72, 2026-08-10; checked off 2026-08-13) Update 2026-08-13: the one-cycle compatibility layer is retired — Events.Payload no longer implements Access, and every consumer pattern-matches the structs (EventBridge's 25 registered types, RunCompletionWaiter, heartbeat/cron/telegram approval paths); coerce/2-at-entry remains the tolerated path for wire-injected legacy maps. See docs/platform/bus-events.md.
  • 3.2 (6 of 6 done 2026-08-10 via 3.3's case-template moduledocs + in-place tightening) Store.Backend ✓ (state threading, get totality, put_new semantics, backend-specific errors), Memory.Provider ✓ (return shapes, search opts), Plugin ✓ (id format/purity, must-not-raise normalize, no-crash deliver, meta keys "omit rather than invent"), Engine ✓ (cancel/1 total+idempotent — fixing a latent FunctionClauseError in all six CLI engines; steer invariant), LemonCore.EngineRuntime ✓ (config injection, async submit, unavailability as a retry state not a failure, cancel totality, gateway impl as the worked example), agent tool contract in LemonAgent ✓ (Types.AgentTool shape + execute/4 contract + tool/1,tool/2 module convention; ToolRegistry precedence, its one inconsistent merge site, and x_api as the satellite worked example).
  • 3.3 Done: apps/lemon_platform_test ships four ExUnit.CaseTemplates — BackendCase, PluginCase, EngineCase, ProviderCase — consumed as use LemonPlatformTest.PluginCase, adapter: MyAdapter. Each moduledoc is the behaviour's guide (contract in prose, worked minimal implementation, option reference, known gaps), so 3.2 is covered for four of its six behaviours. The suites are safe by default: nothing delivers a message, starts a run or opens a socket unless the consumer passes an explicit :deliver_probe/:run_probe, because a compliance suite that posts to a live Telegram bot is worse than none. Registration round-trips are included (channels Registry, gateway EngineRegistry, memory Providers) — "works standalone, invisible to the platform" is the common third-party failure. Self-validation, 156 tests green: 3 store backends + 2 channel adapters + echo engine + local memory provider in-app; XApi.ChannelAdapter (apps/x_api) and CodingAgent.GatewayEngine (apps/coding_agent) from their own apps with a test-only dep on the kit, which is the dependency direction a third party has. A deliberately-broken backend was run through BackendCase to confirm the suites fail (7 failures from 3 injected violations) rather than passing vacuously. Contract gaps found: Store.Backend had no documented state-threading/dynamic-table/{:exists, _} rules and no statement that {:error, reason} reasons are unmatched-on convention (tightened in place, plus the missing-teardown gap recorded); LemonMemory.Provider was underspecified on return shapes and search-opts semantics (tightened in place; its missing error channel recorded). Left flagged for the apps another worker holds: Plugin needs an id-format rule and a "must not raise" statement on normalize_inbound/1; Engine.cancel/1 has no agreed domain (CLI engines raise on a foreign context, CodingAgent.GatewayEngine tolerates it — hence the opt-in :cancel_tolerates_unknown_ctx test). (2026-08-10)
  • 3.4 Done: installer/ is a standalone mix project (phx_new's shape — app :lemon_new, zero deps because a mix archive cannot carry any, templates read at compile time via @external_resource and embedded in the beam). mix lemon.new my_agent scaffolds a plain non-umbrella project on path deps to lemon_core + ai + agent_core, each with the eventual hex line commented beneath it (including the hex: rename for the two whose package name differs from the app name). The channel decision: the default project's channel is a console loop built on LemonAgent.subscribe/2, not a LemonChannels.Plugin — channels drags in ~23 deps including nostrum/bandit for a "hello agent" project, and the plan's dep triple deliberately excludes it. --channel generates a real LemonChannels.Plugin (a console adapter: delivers to stdout, needs no credentials, registers via register_and_start_adapter/2) plus a LemonPlatformTest.PluginCase suite — 15 compliance tests, green. --memory does the same for lemon_memory. Generated .ex/.exs go through Code.format_string!/1, so an unparseable template fails generation rather than shipping. Verified by generating both shapes into a tmpdir against this repo: base 10 tests green, --channel --memory 29 green, both mix compile --warnings-as-errors clean. Installer has its own 18-test suite (generation is asserted on content; compiling a generated project per test would mean fetching the platform tree). (2026-08-10)
  • 3.5 Done: docs/getting-started/{build-your-first-agent,add-a-tool,add-a-channel,persist-memory}.md, all four written against generator output and cataloged. Every code block was executed: add-a-tool's read_file tool + its tests were applied to a freshly generated project (14 tests green), add-a-channel's adapter and compliance suite are byte-for-byte the --channel output, persist-memory's module is the --memory output and its ask_with_context/2 snippet was run. "Add an engine" was dropped from the original list — LemonGateway.Engine is the runtime's own extension point, not something a mix lemon.new project has any use for; LemonPlatformTest.EngineCase's moduledoc already carries that contract. (2026-08-10)

Done when: a fresh project from mix lemon.new compiles against path deps and passes the contract kit; every behaviour has a hexdocs page. — All five items checked as of 2026-08-13; both criteria evidenced by 3.5 (every getting-started code block executed against generator output, including the --channel compliance suite byte-for-byte) and 3.2/3.3 (all six behaviours documented, four as case-template moduledocs).

Phase 4 — Publish from the monorepo (S–M)

  • 4.1 Done: all 9 packages (8 + lemon_media per D13) carry description, package/0 (hex name, MIT licence, explicit files, GitHub + changelog links) and docs/0 (main: "readme", README + CHANGELOG extras, source_url/ref); ex_doc added where missing; MIT LICENSE copied into each app; a Keep-a-Changelog CHANGELOG.md per package written from that package's git log for consumers; lemon_memory gained the README it never had. The dep mechanism (hex_package.exs): mix hex.build does not reject in_umbrella deps — it drops them (it keeps Hex-SCM deps only) and reports them as excluded, so an unguarded publish ships a tarball claiming lemon_agent has no dependencies. Each publishable app's mix.exs does Code.require_file("../../hex_package.exs", __DIR__) and wraps its list in Lemon.HexPackage.deps/1; with LEMON_HEX_PUBLISH=1 set, {app, in_umbrella: true} becomes {app, "~> 0.1", hex: :package_name}, which is also where the app-vs-hex name split lives (:lemon_ailemon_ai, :lemon_agentlemon_agent), so no module or app name has to move. Unset, the list is returned untouched — the umbrella dev workflow never sees the hex form. An umbrella dep that is not in @packages raises, naming the app, rather than being silently dropped. Verified: LEMON_HEX_PUBLISH=1 mix hex.build green for all 9 (ai is also green without the flag, having no sibling deps). Router and channels were the two that raised, on lemon_media, until D13 promoted it — which is the mechanism doing its job: the raise is what turned a dep hex would have silently dropped into a decision. Versions deliberately untouched: every app is already at 0.1.0, and D10's semver flip is the release script's job. Gitignore gained apps/*/*.tar and apps/*/doc/.
  • 4.2 CI publish workflow + release script. Tags are <package>-v<version> (ai-v0.1.0), not the vX.Y.Z-<package> this line originally guessed: the tag has to name a directory the workflow can cd into, and it must not collide with release.yml's v*.*.* CalVer pattern. scripts/release_package does verify → bump → changelog roll → commit → tag → publish for one package, with a --dry-run that changes nothing and a --verify mode for CI; .github/workflows/publish.yml runs --verify + the quality lane on a tag, then mix hex.publish (package and docs, so hexdocs comes along). Publishing is gated on HEX_API_KEY: absent, the step no-ops with a notice, so the whole pipeline is exercisable before the key exists. Ordering is enforced from hex_package.exs, not a second hardcoded list. Docs: docs/release/hex-packages.md. The publish-order gate is what surfaced the lemon_media problem (lemon_channels and lemon_router both depend on it while it was unpublished, and hex drops rather than rejects such a dep), now resolved by D13 promoting it to the 9th package.
  • 4.3 Done: all 10 packages (the 8 + lemon_media per D13 + lemon_cli_runners per D15, which this line predates) published to hex.pm at 0.1.0 on 2026-08-11 (19:16–19:28 UTC, in dependency order — verified via the hex.pm API: z80dev/lemon links, our descriptions). Provenance caveat: the publish bypassed release_package's tag flow — a first publish needs no version bump, so a bare mix hex.publish left no release commits and no *-v0.1.0 tags locally or on origin; the exact build commit is unrecorded. Not retro-tagged for that reason. scripts/publish_train (2026-08-13) now drives the whole train through release_package in dependency order — idempotent resume, one-time quality gate, sequential local publish (tags-then-CI cannot order a concurrent train) — so from 0.1.1 every release is tagged. See docs/release/hex-packages.md. (reconciled 2026-08-13)
  • 4.4 Soak: one full dev cycle where product apps inside the umbrella are switched to consume the published contracts conceptually (no mechanical change, but any API change now requires a changelog entry). Fix what chafes while changes are still one-repo atomic. Advisory enforcement is live: .github/workflows/changelog-check.yml + scripts/check_changelog_entries annotate any PR that changes a published package's lib/ without touching its CHANGELOG.md.

Done when: all 8 on hex with docs; CHANGELOG discipline in place. — All 10 on hex with docs as of 2026-08-11; changelog discipline advisory-enforced. 4.4's soak clock effectively started with the 0.1.0 publish.

Phase 5 — Extract product repos (M per repo)

Per repo (lemon-sim, showcase, lemon-clients, coding-agent), in that order — flipped from the original (coding-agent first) when D14 was approved 2026-08-13: lemon-sim extracts first (unblocked, D8 flagship); coding-agent is deferred until lemon_browser + lemon_skills are published (packages 10 & 11).

Readiness (2026-08-10, investigation — full analysis in docs/platform/phase-5-extraction.md): the product groups are not equally ready. Per-group blocker table:

Product groupBlocker (unpublished dep)Recommended action
lemon-sim (lemon_sim, lemon_sim_ui, lemon_tcg)none — all platform deps published (core/agent/ai); sim_ui/tcg depend on sim intra-groupExtract first (D8 flagship, unblocked)
coding-agent (coding_agent, coding_agent_ui, lemon_mcp, lemon_evals)lemon_skills (mcp/evals inherit it) + lemon_browser (coding_agent only) — both reference-runtime apps that stay behindPublish browser (leaf, 818 LOC, clean); publish skills gated on an API-stabilization pass; defer coding-agent extraction until then (D14, approved 2026-08-13; stabilization pass + packaging landed 2026-08-13)
showcase / lemon-clientsnone (static site / separate toolchain)Extract any time

Both blocker apps have published-only deps (skills: core/memory/media/agent/ai; browser: core) — they are shared platform infrastructure the runtime also consumes (control_plane, automation), which rules out "they leave with coding-agent" and, for skills' 20-module tool-heavy surface, rules out inverting behind a behaviour. See D14.

  • 5.1 git filter-repo preserving history for the moved paths; swap {:x, in_umbrella: true}{:lemon_x, "~> 0.1"}.

  • 5.2 Port the umbrella's CI (test/credo/dialyzer/smoke) from templates; per-repo README/CONTRIBUTING/SECURITY.

  • 5.3 lemon-sim specifics: D3 Bench boundary hardening (BenchDomains wiring namespace, inline stable_json); take the LEMON_ARENA_* env registrations with it (from 1.9).

    Boundary pre-work landed 2026-08-12 (branch sim-boundary). lemon_core no longer references a sim module anywhere: LemonCore.Runtime.Env.apply_ports/1 used to hardcode :lemon_sim_ui + LemonSimUi.Endpoint in apply_sim_port/1, and now reads config :lemon_core, :runtime_endpoints — a list of {port_field, otp_app, endpoint_module} triples declared in config/config.exs. Both endpoints (web + sim) went through the merge semantics apply_web_port/1 already had, so extra :http options survive a port apply for the sim endpoint too (they previously did not — apply_sim_port/1 replaced :http wholesale). LemonCore.Env.Registry's moduledoc example no longer names LemonSimUi.Env.

    Every remaining sim touchpoint outside apps/lemon_sim* and apps/lemon_tcg is now delimited by a greppable marker — grep -rn "lemon-sim product block" — so 5.1/5.6 is a delete, not a hunt. Full inventory:

    LocationKindOn extraction
    config/runtime.exs (3 marked blocks)runtime env wiring; sim_ui_endpoint_enabled? crosses blocks 1→3, and every parse_runtime_boolean call site is simmove blocks 1–3 and the helper
    config/config.exs (2 marked blocks):runtime_endpoints sim triple, env registries, sim_ui defaultsdelete marked lines
    config/{dev,test,prod}.exs (1 block each)endpoint + hosted-room settingsdelete marked lines
    .github/workflows/release-smoke.yml (15 refs)werewolf smoke, sim Dockerfile, npm assetswhole lane moves
    .github/workflows/release.yml, root mix.exs sim_ui.assets.* aliasesasset deploymove
    lemon_core/quality/architecture_{check,policy}.exlint inventory of this repo's apps — namespace/dep tables, no code dependencydelete 3 + 3 rows
    lemon_core/lib/mix/tasks/lemon.help.extask-name catalog (3 groups + 9 @fallback entries), reads @shortdoc at runtimedelete marked block
    lemon_core/runtime/profile.ex:lemon_sim_ui in the runtime_full release profiledelete marked line
    lemon_core/env/declarations.ex (2), env.ex, bus.exapps: metadata on standard vars (PHX_SERVER, SHELL) + doc proseedit in place
    scripts/lint_ci_docs.sh, bin/lemon --sim-porttoolingedit in place

    Deliberately not done, as churn without boundary gain: the sim_port field on the Runtime.Env struct stays (it is an integer, not a dependency, and renaming it touches lemon_cli's setup wizard, bin/lemon, scripts/live_cron_runtime_restart_smoke.exs and four tests), and the two lint tables stay hardcoded rather than becoming config — they are supposed to enumerate the apps in the repo they police.

  • 5.4 coding-agent specifics: takes lemon_mcp + lemon_evals + coding_agent_ui; its gateway engine registers via 2.1's mechanism.

  • 5.5 x_api leaves to its satellite repo (D7 completion).

  • 5.6 Delete moved apps from the umbrella; platform repo keeps the reference runtime + published packages only.

  • 5.7 Cross-repo integration check: a nightly CI job in the platform repo that builds coding-agent and lemon-sim against hex releases (and optionally against main via git deps) — the early-warning system for accidental breakage.

Done when: umbrella contains only platform apps; products build green in their repos from hex releases; nightly integration lane green.

Phase 6 — Launch polish (S–M)

  • 6.1 Platform README rewritten for external builders; showcase site points at packages + generator + lemon-sim arenas as the demo.
  • 6.2 Position lemon-sim as flagship (D8): its README leads with the arena leaderboards; link from platform docs.
  • 6.3 Issue templates, good-first-integration labels (a new channel adapter is the ideal first PR), release announcement.

6. lemon_core module disposition (final)

Updated for D1–D9. Buckets: core (stays in published lemon_core), router/channels/gateway/agent/memory (moves to that package), consumer (moves to the named app), runtime (stays in platform repo's reference-runtime layer, unpublished).

DestinationModules
core — primitivesbus, event, store (+backends, post-1.1–1.4), secrets (post-1.5), config + config_cache(+error), clock, id, retry, telemetry, map_helpers, dedupe_ets, idempotency(+store), httpc, dotenv, logging + logger_setup, testing, env framework (registrations leave, 1.9), extensions/ (manifest, registry_audit)
core — boundary contractsrun_request, run_phase, run_phase_event, run_phase_graph, run_outcome, execution_command, inbound_message, delivery_intent, delivery_route, engine_runtime, engine_catalog, router_bridge, event_bridge, session_key, resume_token, chat_state + chat_state_store (a resume-token cache keyed by session — same family; corrected from the router row, see D11), exec_approvals (approval contract; storage wrapper moves w/ control_plane), terminal_backend (behaviour), introspection, + new: EngineInfoBridge (2.5), Events.* structs (3.1). Security.ExternalContent corrected 2026-08-10: lives in lemon_agent (LemonAgent.Security.ExternalContent — depends on agent/AI types, so core can't host it; see 2.1 note)
routerrun_store, run_history_store (post-1.3 hook split). chat_state/chat_state_store corrected to stay in core — see the boundary-contracts row.
channelscwd, binding, binding_resolver, gateway_config, project_binding_store, telegram-flavored store pieces from 1.3
gateway(none — gateway consumes core contracts; sheds transports per D2)
agent (lemon_agent)goal_store, kanban_store, heartbeat_store → LemonAgent.Workspace.* (D5); provider_config_resolver
memory (lemon_memory)✅ moved (1.6): LemonMemory.Document, .Store, .Provider, .Providers(+.Local), .Ingest, .Safety, .SessionSearch, .TaskFingerprint, plus mix lemon.memory (D4)
consumerbuild_info → lemon_sim_ui corrected 2026-08-10: build_info stays core — core's own support-bundle manifest uses it and it reports core's version/release metadata; the single-consumer premise was wrong. provider_pool_rotator → coding_agent ✓ (done, supervised child moved too); doctor checks → audit found none reach foreign apps; the real cross-app reach was support_bundle/lsp_diagnostics helpers, now behind LemonCore.Doctor.RuntimeModules (:doctor_runtime config) + app-owned checks registrable via :doctor_checks ✓; checkpoint → verify owner during Phase 1 (unchanged)
runtime (platform repo, unpublished)config_reloader(+dir), reload, terminal_backends registry + terminal_backend_policy, usage_store, usage_diagnostics, exec_approval_store, policy_store, progress_store, heartbeat wiring

7. Risks & mitigations

RiskMitigation
Phase 1 destabilizes everything at once (Store is in every hot path)Land 1.1–1.4 as compatibility-preserving refactors (default name = LemonCore.Store, app-env fallback kept); umbrella behavior unchanged until consumers opt into instances. Smoke suite after every item.
Bus payload typing (3.1) reveals undocumented consumer assumptionsCatalog first, type incrementally topic-by-topic; keep struct + legacy-map acceptance for one cycle.
Extraction loses git history / breaks muscle memorygit filter-repo with path preservation; leave tombstone READMEs in the umbrella pointing to new repos for one release.
Cross-repo drift after Phase 55.7 nightly integration lane; contracts changes require changelog + version bump by CI check.
Scope creep: publishing skills/media/browser/etc. too earlyExplicitly deferred (§2); revisit only on external demand.

8. Decision log

DateDecisionWhy
2026-08-13Plan reconciliation (no new code): 1.4 (e4e63bc2), 1.10 (095c6215 + 9f845146, runtime :paths block in d7ed1422) and 3.1 (b7b69e72, design doc 781418f9) all landed 2026-08-10 — the "08-10 to 08-12" range guessed elsewhere does not hold, every commit is same-day — but were never checked off. Verified against the working tree and marked done today, along with the follow-ups they pulled in (041f9b28 runtime bus-backend detection, 2ef2d0cf property tests, 8e0fa999 EventsFixtures). Phase 1 and Phase 3 are now item-complete; Phase 1's Done when is annotated as criteria-incomplete rather than silently claimed.A living plan is only a status document if landed work is visible in it. Three items reading as open invited redoing them, and 1.4 in particular still posed an open question ("Registry fallback vs require explicitly") whose answer had been sitting in bus.ex for three days.
2026-08-11D15 (user decision): CLI runners leave lemon_agent — the LemonAgent.CliRunners.* namespace (~8.5k LOC: JsonlRunner + six vendor runner/schema/subagent triples) becomes the lemon_cli_runners package as LemonCliRunners.*, depping lemon_agent + lemon_ai + lemon_core. §2's table originally assigned CLI runners to lemon_agent. App-env keys :cli_timeout_ms/:cli_cancel_grace_ms/:cli_session_lock_max_age_ms and the :cli_runners-area env declarations moved with it (LemonCliRunners.Env).Release cadence: the wrappers churn with six proprietary vendor CLIs, and vendor breakage shouldn't force releases of the stable agent framework. The code was already a leaf — zero inbound references from the rest of lemon_agent (the loop doesn't know it exists); consumers are gateway's CLI engines and coding_agent. Extraction does not free gateway of its lemon_agent dep (cli_runners itself needs EventStream, and gateway's tools use AgentTool/ExternalContent) — the win is package scope, not the dep graph.
2026-08-09Sequencing: carve core → invert deps → contracts → hex from monorepo → extract productsExtraction is cheap after boundaries are real; painful before.
2026-08-10D11: chat_state/chat_state_store stay in lemon_core as boundary contracts, and the router becomes their single writer (gateway's writes deleted).Third plan-corrected-by-evidence outcome after build_info and ExternalContent. §6 assigned them to router on the assumption router was the only reader; lemon_channels is a legitimate reader+writer, so router ownership would force channels→router. Chat state is a resume-token cache keyed by session — the same family as ResumeToken/SessionKey, already core boundary contracts. Moving it to channels (the one legal alternative, since router→channels is allowed) would encode an accident as architecture.
2026-08-10D12 (user decision): delete the Farcaster transport (~1.2k LOC, lemon_gateway/transports/farcaster/), which was frozen pending this call. Executed 2026-08-10: transport + tests, the enable_farcaster/farcaster config keys in LemonGateway.Config/LemonCore.Config.Gateway, the farcaster validators, 6 env declarations (5 FARCASTER_* + LEMON_GATEWAY_ENABLE_FARCASTER), and the doc/README/example-config mentions all removed; ~1.44k LOC of transport+tests plus ~300 LOC of config/validator/doc surface.Unused by its owner; off-by-default; it was the main source of the synchronous-frame-response complexity in the transport design. Deleting removes a whole interaction-model problem rather than porting it.
2026-08-13D14 approved (user decision): the 2026-08-10 proposal below stands as written — §5 order flipped (lemon-sim first), lemon_browser + lemon_skills move to the published table as packages 10 & 11, coding-agent extraction deferred until both are on hex. Executed same day: skills API-stabilization pass + packaging and browser packaging landed in one batch (see §2 table); publish via scripts/publish_train/release_package when ready.Unblocks Phase 5 for the coding-agent group without inventing a fake seam; sim extraction can proceed independently.
2026-08-09D1–D10 resolved (see §4) after code-level investigation (E1–E4)Store singleton audit; Bench coupling measurement; router/gateway/channels topology mapping; license + hex-name checks.
2026-08-10D14 (proposed — needs user sign-off): resolve the coding_agent Phase 5 blocker by (1) extracting lemon-sim before coding-agent (flip §5's order — sim is unblocked, D8 flagship), and (2) unblocking coding-agent by publishing lemon_browser and lemon_skills as packages 10 & 11lemon_browser outright (818-LOC clean leaf, same rationale as D13), lemon_skills gated on an API-stabilization pass, with coding-agent extraction deferred until that lands. Reject invert-behind-behaviour for skills (coding_agent uses ~20 modules incl. a dozen concrete agent tools — a fronting behaviour would be a re-export, not a seam) and reject leave-together (control_plane+automation also depend on skills/browser, so the reference runtime would break). Full analysis + per-group readiness table: docs/platform/phase-5-extraction.md. Note (2026-08-12): CodingAgent.SettingsManager's public shape already changed pre-extraction — the five per-vendor fields collapsed into one cli map (60a99c8a), which is the vendor-free shape we'd want to publish.Both blocker apps have published-only deps and are shared platform infrastructure the reference runtime also consumes, not product code — so the consistent move is publish (as with lemon_media in D13), not invert. Named here so the Phase 5 order isn't an undocumented cliff where sim is ready and coding-agent silently isn't. Marked proposed because extracting a repo and enlarging the published surface from 9 to 11 packages is a larger commitment than the Phase 1–4 boundary work.
2026-08-10D13 (user decision): lemon_media becomes the 9th published package, moving out of §2's unpublished reference-runtime list and into the published table ahead of router/channels. Hex name verified free 2026-08-10 (hex.pm/api/packages/lemon_media → 404).4.1 found the blocker: lemon_router and lemon_channels both depend on lemon_media, and a hex release cannot depend on an unpublished umbrella app — mix hex.build drops such deps silently, so neither could publish. §2's unpublished list was a default that never accounted for published packages depending on media, not a principle. media is a ~1.1k-LOC library on lemon_core alone with zero product coupling; publishing it costs one map entry and its metadata, whereas the alternative (inverting media_job_recorder.ex:56 and media_status_message.ex through a configured runtime module) adds indirection to two apps to avoid publishing a small clean library.

9. Deferred (not open — parked with owners)

  • Telegram config/diagnostics surfaces still in lemon_core Resolved 2026-08-13 (vendor-channel sweep): LemonCore.Config.Gateway's telegram/discord/xmtp fields, validators and defaults moved behind the new LemonCore.Config.Gateway.Channel behaviour (config :lemon_core, :gateway_channels) implemented by LemonChannels.Adapters.{Telegram,Discord,Xmtp}.Config; ChannelDiagnostics/ChannelReadiness/Checks.Channels moved to LemonChannels.Doctor.* behind :doctor_runtime/:doctor_checks; proof classification goes through the LemonCore.Doctor.ChannelProofs behaviour. Flat legacy keys (gateway[:telegram], gateway[:enable_telegram]) are still emitted by convert_gateway/1, so zero reader call sites changed. InboundMessage field names stay (published boundary contract — a rename is breaking for no structural gain); its moduledoc now documents the generic meaning. A :core_vendor_channel_reference quality rule now holds the line in CI.

  • chat_state/chat_state_store → router move. Resolved 2026-08-10 (D11): they stay in core. Two premises were wrong. Router is not the only reader — lemon_channels reads (telegram/transport/per_chat_state.ex:17) and writes (:125, :138), and channels may not depend on router (§2), so the move would have created a forbidden edge. And gateway's writes were redundant rather than load-bearing: the overflow delete was duplicated by router's compaction_trigger.ex:177 on the same event, and the completion event already carries :resume into router (extract_completed_resume/1), so router could always have done the write. Gateway's chat-state coupling is now zero and router is the single writer.

  • LemonChannels.Plugin.meta/0 capability-vocabulary widening — deferred until a real consumer exists. Phase 2.4/E1 deleted LemonChannels.Capabilities.Registry.lookup/1, a static string-keyed table that hard-coded capabilities for telegram/discord/x_api/xmtp/whatsapp. It had zero production callers: CapabilityQuery already resolves through the plugin registry via Registry.get_capabilities_new/1Capabilities.from_legacy/1, returning nil/false for unregistered channels. Deleting it (rather than delegating to it) also fixed a D7 violation — the platform was asserting facts about the x_api satellite. The knowledge worth preserving: the deleted table carried a strictly richer vocabulary than meta/0's flag map can express — {:attachments, max_size:, features:}, {:rich_blocks, features: [...]}, and {:rate_limit, value:}, none of which from_legacy/1 can produce (it only ever emits features: [:images] for attachments and cannot express rich_blocks features or rate_limit at all). If a consumer ever needs those, widen meta/0 to accept the typed Capability.spec() list that Capabilities.new/1 already consumes, keeping from_legacy/1 for one release. Adding that typed form speculatively was rejected: it would grow the platform's most third-party-facing extension point for data nobody reads. Old values recoverable from git history at apps/lemon_channels/lib/lemon_channels/capabilities.ex before the 2.4 deletion.

  • LemonMemory.Ingest :name parameterization (currently always registers as __MODULE__) — small API change, do before publishing lemon_memory. Resolved 2026-08-13: Ingest now mirrors the 1.1 Store shape — start_link/1 registers under :name (default LemonMemory.Ingest), init/1 merges start_link opts over config :lemon_memory, LemonMemory.Ingest (the public functions were already server-first), and a non-default worker binds its name into the hook as {LemonMemory.Ingest, :handle_finalize_run, [name]}; the parameterization also surfaced a real dispatch bug — an atom :memory_store was always called as module.put/1, so a registered store name silently failed, now classified once at init (:providers for the default Store atom, :module, or :server) instead of probing the code server on the hot path. 9-test ingest_instance_test.exs proves two named workers coexist with isolated stores, per-instance flag caches and app-env/opts precedence.

  • LemonMemory.SessionSearch has 0% direct coverage (consumers test callers) — main thing holding lemon_memory at 60%. Resolved 2026-08-13: apps/lemon_memory/test/lemon_memory/session_search_test.exs (19 tests) covers the façade directly, driving the real Providers.Local provider against a per-test seeded Store instance instead of stubbing the layer it delegates to. What it pins down: the LEMON_FEATURE_SESSION_SEARCH kill switch and the opt-in state both return [] without any provider running (the "callers never need to check" promise), blank/whitespace queries short-circuit the same way, the default limit is 5 and a caller limit is clamped at 20 while a smaller one is honoured, results come back newest-first, scope isolation holds for :session/:agent/:workspace/:all, a scoped search with no scope_key fails closed rather than broadening to every document, an unavailable store degrades to [] rather than raising, and format_results/1 renders the exact numbered/timestamped shape consumers inject into agent context (including unknown for a missing ingested_at_ms). The stale "no direct test coverage" gap is out of apps/lemon_memory/CHANGELOG.md; the 60% coverage threshold in apps/lemon_memory/mix.exs stays as a floor, with mix lemon.memory now the remaining untested surface.

  • Adapter satellite packages (lemon_channels_telegram, lemon_channels_discord): after Plugin behaviour is documented and x_api (D7) proves the pattern.

  • Renaming lemon_gatewaylemon_engines: revisit at 4.1; name describes the post-D2 shrunk scope better, but rename churn during the carve is not worth it.

  • Secrets key rotation / re-encrypt path: known-missing, documented in 1.5; schedule when a second key provider lands.

  • Publishing lsp/automation: on external demand only. (skills/browser joined the published set with D14; media with D13.)