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
lemonrepo 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)
| Package | Contents | Source today | Publish order |
|---|---|---|---|
lemon_ai | Provider-agnostic LLM client: providers, registry, rate limiting, circuit breaker, compaction, tokens/text | apps/lemon_ai (31k, zero umbrella deps) | 1 |
lemon_core | The 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 manifest | slimmed apps/lemon_core | 2 |
lemon_agent | Agent loop, tool registry, subagents, model runtime, workspace stores (goals/kanban/heartbeats) | apps/lemon_agent + 3 stores from lemon_core | 3 |
lemon_cli_runners | Vendor AI CLI wrappers (Claude Code, Codex, Kimi, OpenCode, Pi) as streaming subagents: JsonlRunner behaviour + per-vendor runner/schema/subagent triples | carved from apps/lemon_agent (D15, 2026-08-11) | 4 |
lemon_memory | Durable agent memory: document schema, store, provider behaviour + fan-out registry, ingest pipeline, search, task fingerprints | 8 modules from lemon_core (~1.9k LOC) | 4 |
lemon_media | Media job tracking: redacted job/artifact metadata store, supervised job workers, lifecycle broadcasts, retention cleanup | apps/lemon_media (~1.1k LOC, lemon_core only) | 5 (before router/channels, which depend on it — D13) |
lemon_router | Run lifecycle + session orchestration: single-flight, queue/steer, coalescing, policy, watchdog, delivery routing | apps/lemon_router, facade hardened | 5 |
lemon_gateway | Engine execution runtime only: Engine behaviour, engine registry/scheduler/locks, EngineRuntime impl | apps/lemon_gateway minus transports/sms/voice | 5 |
lemon_channels | Channel core (Registry, Outbox, Dispatcher, PresentationState) + Plugin behaviour + built-in adapters (telegram, discord, whatsapp, xmtp, email, webhook) | apps/lemon_channels + gateway's transports | 5 |
lemon_platform_test | Contract-test kit: behaviour compliance suites for Plugin/Engine/StoreBackend/MemoryProvider authors | new | 6 |
lemon_browser | Browser capability driver + artifact store | apps/lemon_browser (818 LOC, lemon_core only) | 7 (D14, approved 2026-08-13) |
lemon_skills | Skill registry, discovery, installation, assistant-platform tools | apps/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)
| Repo | Takes | Why grouped |
|---|---|---|
coding-agent | coding_agent, coding_agent_ui, lemon_mcp, lemon_evals | mcp + evals compile-depend on coding_agent |
lemon-sim | lemon_sim (incl. Bench), lemon_sim_ui, lemon_tcg | tcg needs sim's Kernel/LLM engines; sim_ui needs everything. Flagship demo repo (D8) |
showcase | showcase/ static site | |
lemon-clients | clients/ TS packages | different 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
| # | Decision | Rationale |
|---|---|---|
| D1 | No 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. |
| D3 | Bench 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. |
| D4 | lemon_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. |
| D5 | goal_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. |
| D6 | Store 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. |
| D7 | x_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. |
| D8 | lemon-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. |
| D9 | control_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. |
| D10 | Versioning: 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 viaSuperseded: the names were reserved by publishing the real0.0.1placeholders0.1.0releases directly (see 4.3, 2026-08-11). (reconciled 2026-08-13) - 0.2
AddExtendedboundaryorarchitecture_rules_check.ex(AST-based@module_reference_rules, catches dynamic atoms) with 5 rules + 29-entry shrink-only@grandfatheredallowlist grouped by the Phase 2 item that retires each group. Found+grandfathered one unknown violation:lemon_automation/cron_manager.ex:479usesLemonRouter.RunRegistry(retire in 2.6). Runs in existingmix lemon.qualitylane. (2026-08-10) - 0.3
docs/platform/skeleton created (8 package stubs), cataloged indocs/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_overridedeliberately 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-teststore_instance_test.exsproves 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_termkeyed by store name so they survive store restarts. RunHistoryStore + MemoryIngest register themselves via config (each owns itshandle_finalize_runadapter, so it moves with the module in 1.6). Read path inverted too:get_run_historyforwards 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_targetsremoved 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.exscarries exactly three hard deps —jason,toml,telemetry— withphoenix_pubsub,exqlite,file_system,sentryandfinchoptional: 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":Busstarts akeys: :duplicateRegistry(LemonCore.Bus.Registry, first child ofLemonCore.Application) and dispatches viaRegistry.dispatch+sendwhenPhoenix.PubSubis absent. Backend detection is at runtime —Code.ensure_loaded?memoized in:persistent_term, overridable withconfig :lemon_core, :bus_backend, :registry | :pubsub | :autoso tests can force the fallback, since optional deps are not inherited transitively (corrected from the commit's original compile-time module attribute in041f9b28). The fallback is local-node only and the moduledoc says so: distributed deployments must depend on phoenix_pubsub explicitly. The other four degrade throughCode.ensure_loaded?guards rather than crashing: Store defaults toEtsBackendwhileSqliteBackend.init/1raises with install guidance (andExqlite.Erroris classified by message, not by struct match, so the module isn't needed at compile time);RunHistoryStoreis simply not started (Application.sqlite_children/0— reads exit:noproc, finalize-run hooks no-op);ConfigReloader.Watcheralways schedules a 5s poll and only upgrades to native watching whenFileSystemloads;drop_unloadable_handlers/0strips the Sentry:loggerhandler at boot, because an unloadable handler module takes the boot down.finchis only Sentry's HTTP client (v12+) — nothing in core calls it. VendoredLemonCore.UUID(lib/lemon_core/uuid.ex:uuid4/0,uuid7/0,1,version/1,decode/1; RFC 9562 variant bits, canonical lowercase) replaced the unmaintaineduuidhex dep in five apps and inmix.lock, reached only throughLemonCore.Id(which gaineduuid7/0). Two extras the line didn't anticipate:lemon_routergained its ownexqlitedep (RoutingFeedbackStore was free-riding on core's hard one), and the release profiles now nameexqlite/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_fromunder the fallback) +uuid_test.exs, joined byuuid_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:
KeyProviderbehaviour (keychain/env/file built-ins, chain configurable viaconfig :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: trueescape 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_memorycreated (deps: lemon_core + exqlite as a direct, non-optional dep — durable memory is the app's reason to exist). All 8 modules moved withgit mv, renamedLemonCore.Memory*→LemonMemory.*(Document,Store,Provider,Providers(.Local),Ingest,Safety,SessionSearch,TaskFingerprint);mix lemon.memorymoved too. Supervision (Providers always; Store+Ingest behind the exqlite guard) now lives inLemonMemory.Application, and the finalize-run hook config points atLemonMemory.Ingest. App-env key moved from:lemon_core, LemonCore.MemoryStoreto:lemon_memory, LemonMemory.Store(config.exs, test.exs, runtime.exs). Doctor's memory diagnostics now go throughLemonCore.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_agentasLemonAgent.Workspace.*(D5); update automation/channels/skills/control_plane call sites. Clean break, no shims.lemon_automationgained anagent_coredep; core's support bundle now resolves the goal/kanban diagnostics modules fromconfig :lemon_core, :workspace_diagnosticsso 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 indoctor/support_bundle.ex(media, browser) anddoctor/lsp_diagnostics.ex(lsp), which now resolve those modules fromconfig :lemon_core, :doctor_runtime(LemonCore.Doctor.RuntimeModules);config :lemon_core, :doctor_checkslets 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_infois not single-consumer (core's own support-bundle manifest uses it atsupport_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_storeare used by lemon_gateway (run.ex,transports/farcaster/cast_handler.ex) as well as router/control_plane, and are baked intoStore.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.Envis now framework-only (258 LOC, down from 3,455); the 266 declarations live in 16 per-app registry modules aggregated throughconfig :lemon_core, :env_registries. Contract is structural (declarations/0), with ause LemonCore.Env.Registrymacro adding compile-time shape validation for apps that depend on lemon_core —aiimplements 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 sayLEMON_GATEWAY_*/LEMON_TELEGRAM_*/LEMON_WEB_CACHE_*stay in lemon_core becauseLemonCore.Config.*resolves them (viaEnv.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 — readingconfig :lemon_core, :pathswith precedence call-site option > app env > module default (.lemon,config.toml), and reading$HOME/TMPDIRat call time so a release never freezes the build machine's paths. The reference runtime states those defaults back explicitly inconfig/config.exs("so the values live with the runtime rather than inside the library"; the block actually landed alongside in 1.9'sd7ed1422), 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.9f845146finished the sweep for checkpoints —LemonCore.Checkpoint's@checkpoint_dirwas a compile-time attribute baking the build machine's tmp into the module;checkpoint_dir/0now delegates toPathsper call. §6's "checkpoint → verify owner during Phase 1" resolves to core:LemonCore.Checkpointstays, and checkpoints stay under system tmp by design (in-flight rollback material — moving them under the state dir would orphan every existing checkpoint), withcheckpoint_dir:as the opt-in for hosts that want durability. Deliberately outside the:pathslayout (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.Storeresolves its own:pathapp env (set byruntime.exs/test.exs) with a~/.lemon/storefallback, mirrored in core'sdoctor/checks/memory.ex. No data migration: the defaults are unchanged, config only relocates future reads and writes. Covered bytest/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 withPaths). (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.exmoved toapps/coding_agentasCodingAgent.GatewayEngine(.SessionRunner), self-registering via the newLemonGateway.EngineRegistry.register/1at coding_agent boot (registration also updates:lemon_gateway, :enginesso 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 ownLemonGateway.Workspacereadingconfig :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.ExternalContentwas NOT moved to lemon_core —CodingAgent.Security.ExternalContentis already a delegation shim overLemonAgent.Security.ExternalContent, which depends onLemonAgent.Types.AgentToolResultandLemonAi.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.AgentRuntimeresolves a single registered module implementing the 13-callbackAgentRuntime.Providerbehaviour, andAgentRuntime.call/3is 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 registersCodingAgent.ControlPlaneProviderat boot viaModule.concat+applyso 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 againstbehaviour_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 intoapps/x_apiwithgit mv; x_api gainedlemon_channels/agent_core/aideps 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.Toolscoding_tools/all_tools,LemonMCP.ToolAdapter.@builtin_tools), so moving them would have made the platform depend on the satellite. AddedLemonAgent.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.Applicationregisters the adapter viaLemonChannels.Application.register_and_start_adapter/2and the tools via the registry, both behindCode.ensure_loaded?guards, so x_api boots standalone.LemonChannels.Adapters.XAPIis out ofconfig :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 noXApi.*reference and its own test; folding capability lookup into the registered adapter'smetabelongs 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.Emailowns both halves,emailjoineddiscordinTransportRegistry's@channels_owned_transport_ids, andapps/lemon_gateway/lib/lemon_gateway/transports/email{,.ex}is deleted along withgen_smtp/mailfrom the gateway's deps. The load-bearing decision was thread state: the gateway's:email_message_threads/:email_thread_statetables were ported rather than dropped, becauseOutboundPayloadcarries noSubjectand noReferenceschain — without them every reply starts a new conversation in the recipient's client.thread_id/1stays 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] emailblock, so existing relays and tokens survive. Inbound stays off by default (InboundHttpdisabled + 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, andLemonPlatformTest.PluginCasenow 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 inLemonGateway.Application.start/2with three capabilities —engine_registry,transport_registry,gateway_config— and the three back-refs now ask core: channels'gateway_config.ex(via newLemonGateway.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'stransports_status.ex(the:transport_registry_moduleapp-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:
LemonRoutergainedavailable?/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/2already existed and just needed callers pointed at them). The facade owns the defensiveness each caller had reimplemented —Process.whereisprobes,Registry.select/lookup,DynamicSupervisor.count_children, rescue/catch ladders — so a router that is not running reports nothing-active instead of raising.counts/0deliberately returns the full zeroed shape rather than%{}: control_plane's status method reads.active/.queued/.completed_todayunguarded, and an empty map raised KeyError (caught in test). 9 call sites migrated across control_plane (7), automation (1, theProcess.whereis(RunRegistry)probe →available?/0) and the abort pair;RunSupervisor/RunOrchestratorare now@moduledoc false(there is no RunRegistry module — it's a plainRegistrystarted in the router's supervision tree). All 8 router entries retired from@grandfathered; the:router_internals_boundaryrule now has zero exemptions. Newfacade_test.exsproves 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.Eventsplus 24 payload structs underlib/lemon_core/events/(21 registered modules +Completion,Action,ApprovalPendingnested; the 25th file is the sharedEvents.Payloadmacro), all generated by that macro that supplies strictnew/1, lenientfrom_map/1(string keys, nested coercion, unknown-key drop),@derive Jason.Encoderand an@deprecatedAccessshim; dispatch is a runtimetopic atom → moduleregistry (30 type atoms) withcoerce/2(lenient) andcast/2(strict, for trust boundaries), not a typespec union. Coverage was deliberately narrowed from "every published topic" to the 7 contract topics —run:<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-4is 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/2stays an untyped pass-through. TheLemonCore.Eventenvelope was kept on purpose (five forwarders relay unknown types by matching%Event{}) andmetastays 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 throughfrom_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'sEventBridge(26payload[:key]clauses, now with awarn_unmapped_oncecatch-all) andRunCompletionWaiter's envelope-less-map clauses still work unchanged. So "consumers pattern-match on structs" is true of the pilots (routing_feedback_store, gatewayrun.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 indocs/platform/bus-events.md(cataloged indocs/catalog.exs, from781418f9). Coverage:LemonPlatformTest.EventsCase— registry completeness,from_mapround-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 byapps/lemon_platform_test/test/compliance/core_events_test.exs, plus control_plane'sevent_type_validation_test.exsfor 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.Payloadno longer implementsAccess, 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. Seedocs/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 inLemonAgent✓ (Types.AgentToolshape + execute/4 contract +tool/1,tool/2module convention;ToolRegistryprecedence, its one inconsistent merge site, and x_api as the satellite worked example). - 3.3 Done:
apps/lemon_platform_testships fourExUnit.CaseTemplates —BackendCase,PluginCase,EngineCase,ProviderCase— consumed asuse 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 (channelsRegistry, gatewayEngineRegistry, memoryProviders) — "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) andCodingAgent.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 throughBackendCaseto confirm the suites fail (7 failures from 3 injected violations) rather than passing vacuously. Contract gaps found:Store.Backendhad 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.Providerwas underspecified on return shapes and search-opts semantics (tightened in place; its missing error channel recorded). Left flagged for the apps another worker holds:Pluginneeds an id-format rule and a "must not raise" statement onnormalize_inbound/1;Engine.cancel/1has no agreed domain (CLI engines raise on a foreign context,CodingAgent.GatewayEnginetolerates it — hence the opt-in:cancel_tolerates_unknown_ctxtest). (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_resourceand embedded in the beam).mix lemon.new my_agentscaffolds a plain non-umbrella project on path deps tolemon_core+ai+agent_core, each with the eventual hex line commented beneath it (including thehex: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 onLemonAgent.subscribe/2, not aLemonChannels.Plugin— channels drags in ~23 deps including nostrum/bandit for a "hello agent" project, and the plan's dep triple deliberately excludes it.--channelgenerates a realLemonChannels.Plugin(a console adapter: delivers to stdout, needs no credentials, registers viaregister_and_start_adapter/2) plus aLemonPlatformTest.PluginCasesuite — 15 compliance tests, green.--memorydoes the same forlemon_memory. Generated.ex/.exsgo throughCode.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 --memory29 green, bothmix compile --warnings-as-errorsclean. 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'sread_filetool + 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--channeloutput, persist-memory's module is the--memoryoutput and itsask_with_context/2snippet was run. "Add an engine" was dropped from the original list —LemonGateway.Engineis the runtime's own extension point, not something amix lemon.newproject 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_mediaper D13) carrydescription,package/0(hex name, MIT licence, explicitfiles, GitHub + changelog links) anddocs/0(main: "readme", README + CHANGELOG extras, source_url/ref);ex_docadded where missing; MITLICENSEcopied into each app; a Keep-a-ChangelogCHANGELOG.mdper package written from that package'sgit logfor consumers;lemon_memorygained the README it never had. The dep mechanism (hex_package.exs):mix hex.builddoes not rejectin_umbrelladeps — it drops them (it keeps Hex-SCM deps only) and reports them as excluded, so an unguarded publish ships a tarball claiminglemon_agenthas no dependencies. Each publishable app's mix.exs doesCode.require_file("../../hex_package.exs", __DIR__)and wraps its list inLemon.HexPackage.deps/1; withLEMON_HEX_PUBLISH=1set,{app, in_umbrella: true}becomes{app, "~> 0.1", hex: :package_name}, which is also where the app-vs-hex name split lives (:lemon_ai→lemon_ai,:lemon_agent→lemon_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@packagesraises, naming the app, rather than being silently dropped. Verified:LEMON_HEX_PUBLISH=1 mix hex.buildgreen for all 9 (aiis also green without the flag, having no sibling deps). Router and channels were the two that raised, onlemon_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 at0.1.0, and D10's semver flip is the release script's job. Gitignore gainedapps/*/*.tarandapps/*/doc/. - 4.2 CI publish workflow + release script. Tags are
<package>-v<version>(ai-v0.1.0), not thevX.Y.Z-<package>this line originally guessed: the tag has to name a directory the workflow cancdinto, and it must not collide with release.yml'sv*.*.*CalVer pattern.scripts/release_packagedoes verify → bump → changelog roll → commit → tag → publish for one package, with a--dry-runthat changes nothing and a--verifymode for CI;.github/workflows/publish.ymlruns--verify+ the quality lane on a tag, thenmix hex.publish(package and docs, so hexdocs comes along). Publishing is gated onHEX_API_KEY: absent, the step no-ops with a notice, so the whole pipeline is exercisable before the key exists. Ordering is enforced fromhex_package.exs, not a second hardcoded list. Docs:docs/release/hex-packages.md. The publish-order gate is what surfaced thelemon_mediaproblem (lemon_channelsandlemon_routerboth 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_mediaper D13 +lemon_cli_runnersper D15, which this line predates) published to hex.pm at0.1.0on 2026-08-11 (19:16–19:28 UTC, in dependency order — verified via the hex.pm API:z80dev/lemonlinks, our descriptions). Provenance caveat: the publish bypassedrelease_package's tag flow — a first publish needs no version bump, so a baremix hex.publishleft no release commits and no*-v0.1.0tags 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 throughrelease_packagein dependency order — idempotent resume, one-time quality gate, sequential local publish (tags-then-CI cannot order a concurrent train) — so from0.1.1every release is tagged. Seedocs/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_entriesannotate any PR that changes a published package'slib/without touching itsCHANGELOG.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 group Blocker (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-group Extract 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-clients none (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-repopreserving 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_coreno longer references a sim module anywhere:LemonCore.Runtime.Env.apply_ports/1used to hardcode:lemon_sim_ui+LemonSimUi.Endpointinapply_sim_port/1, and now readsconfig :lemon_core, :runtime_endpoints— a list of{port_field, otp_app, endpoint_module}triples declared inconfig/config.exs. Both endpoints (web + sim) went through the merge semanticsapply_web_port/1already had, so extra:httpoptions survive a port apply for the sim endpoint too (they previously did not —apply_sim_port/1replaced:httpwholesale).LemonCore.Env.Registry's moduledoc example no longer namesLemonSimUi.Env.Every remaining sim touchpoint outside
apps/lemon_sim*andapps/lemon_tcgis 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:Location Kind On extraction config/runtime.exs(3 marked blocks)runtime env wiring; sim_ui_endpoint_enabled?crosses blocks 1→3, and everyparse_runtime_booleancall site is simmove blocks 1–3 and the helper config/config.exs(2 marked blocks):runtime_endpointssim triple, env registries, sim_ui defaultsdelete marked lines config/{dev,test,prod}.exs(1 block each)endpoint + hosted-room settings delete marked lines .github/workflows/release-smoke.yml(15 refs)werewolf smoke, sim Dockerfile, npm assets whole lane moves .github/workflows/release.yml, rootmix.exssim_ui.assets.*aliasesasset deploy move lemon_core/quality/architecture_{check,policy}.exlint inventory of this repo's apps — namespace/dep tables, no code dependency delete 3 + 3 rows lemon_core/lib/mix/tasks/lemon.help.extask-name catalog (3 groups + 9 @fallbackentries), reads@shortdocat runtimedelete marked block lemon_core/runtime/profile.ex:lemon_sim_uiin theruntime_fullrelease 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-porttooling edit in place Deliberately not done, as churn without boundary gain: the
sim_portfield on theRuntime.Envstruct stays (it is an integer, not a dependency, and renaming it toucheslemon_cli's setup wizard,bin/lemon,scripts/live_cron_runtime_restart_smoke.exsand 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
mainvia 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-integrationlabels (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).
| Destination | Modules |
|---|---|
| core — primitives | bus, 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 contracts | run_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). LemonAgent.Security.ExternalContent — depends on agent/AI types, so core can't host it; see 2.1 note) |
| router | run_store, run_history_store (post-1.3 hook split). chat_state/chat_state_store corrected to stay in core — see the boundary-contracts row. |
| channels | cwd, 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) |
| consumer | 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
| Risk | Mitigation |
|---|---|
| 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 assumptions | Catalog first, type incrementally topic-by-topic; keep struct + legacy-map acceptance for one cycle. |
| Extraction loses git history / breaks muscle memory | git filter-repo with path preservation; leave tombstone READMEs in the umbrella pointing to new repos for one release. |
| Cross-repo drift after Phase 5 | 5.7 nightly integration lane; contracts changes require changelog + version bump by CI check. |
| Scope creep: publishing skills/media/browser/etc. too early | Explicitly deferred (§2); revisit only on external demand. |
8. Decision log
| Date | Decision | Why |
|---|---|---|
| 2026-08-13 | Plan 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-11 | D15 (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-09 | Sequencing: carve core → invert deps → contracts → hex from monorepo → extract products | Extraction is cheap after boundaries are real; painful before. |
| 2026-08-10 | D11: 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-10 | D12 (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-13 | D14 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-09 | D1–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-10 | D14 (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 & 11 — lemon_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-10 | D13 (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_coreResolved 2026-08-13 (vendor-channel sweep):LemonCore.Config.Gateway's telegram/discord/xmtp fields, validators and defaults moved behind the newLemonCore.Config.Gateway.Channelbehaviour (config :lemon_core, :gateway_channels) implemented byLemonChannels.Adapters.{Telegram,Discord,Xmtp}.Config;ChannelDiagnostics/ChannelReadiness/Checks.Channelsmoved toLemonChannels.Doctor.*behind:doctor_runtime/:doctor_checks; proof classification goes through theLemonCore.Doctor.ChannelProofsbehaviour. Flat legacy keys (gateway[:telegram],gateway[:enable_telegram]) are still emitted byconvert_gateway/1, so zero reader call sites changed.InboundMessagefield names stay (published boundary contract — a rename is breaking for no structural gain); its moduledoc now documents the generic meaning. A:core_vendor_channel_referencequality 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'scompaction_trigger.ex:177on the same event, and the completion event already carries:resumeinto 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/0capability-vocabulary widening — deferred until a real consumer exists. Phase 2.4/E1 deletedLemonChannels.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:CapabilityQueryalready resolves through the plugin registry viaRegistry.get_capabilities_new/1→Capabilities.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 thanmeta/0's flag map can express —{:attachments, max_size:, features:},{:rich_blocks, features: [...]}, and{:rate_limit, value:}, none of whichfrom_legacy/1can produce (it only ever emitsfeatures: [:images]for attachments and cannot express rich_blocks features or rate_limit at all). If a consumer ever needs those, widenmeta/0to accept the typedCapability.spec()list thatCapabilities.new/1already consumes, keepingfrom_legacy/1for 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 atapps/lemon_channels/lib/lemon_channels/capabilities.exbefore the 2.4 deletion. -
LemonMemory.IngestResolved 2026-08-13::nameparameterization (currently always registers as__MODULE__) — small API change, do before publishing lemon_memory.Ingestnow mirrors the 1.1 Store shape —start_link/1registers under:name(defaultLemonMemory.Ingest),init/1merges start_link opts overconfig :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_storewas always called asmodule.put/1, so a registered store name silently failed, now classified once at init (:providersfor the defaultStoreatom,:module, or:server) instead of probing the code server on the hot path. 9-testingest_instance_test.exsproves 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 realProviders.Localprovider against a per-test seededStoreinstance instead of stubbing the layer it delegates to. What it pins down: theLEMON_FEATURE_SESSION_SEARCHkill switch and theopt-instate 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 noscope_keyfails closed rather than broadening to every document, an unavailable store degrades to[]rather than raising, andformat_results/1renders the exact numbered/timestamped shape consumers inject into agent context (includingunknownfor a missingingested_at_ms). The stale "no direct test coverage" gap is out ofapps/lemon_memory/CHANGELOG.md; the 60% coverage threshold inapps/lemon_memory/mix.exsstays as a floor, withmix lemon.memorynow 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_gateway→lemon_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.)