Status: Historical decision record. Option B, replayable Location-scoped Catalog transforms, was selected and implemented. All interfaces and flows below are historical option sketches, not current API reference; current behavior is owned by Core.
The decision compared where provider/model inputs live and how visible catalog state changes after boot. The designs below preserve that comparison.
Config merges its ordered documents and then runs ordered, replayable plugin transforms. Each transform is a callback receiving Draft<Config.Info> and may mutate any config field.
type ConfigTransform = (config: Draft<Config.Info>) => void
const transform = yield * Config.transform()
yield *
transform((config) => {
config.providers ??= {}
config.providers.acme = {/* ... */}
config.model = "acme/code"
config.permissions = [/* ... */]
})
Because a transform can mutate any part of config, a transform change cannot safely trigger only Catalog.reload() or any other granular subset. Every service derived from config must reload in place from the newly transformed config.
const transform = yield* Config.transform()
yield* transform((draft) => mutateAnyConfigField(draft))
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ Agent.reload()
→ MCP.reload()
→ other config-consuming services reload
Configured plugin installation/updates should not block location readiness. Build an initial snapshot from authored config and fast built-ins, then activate slow plugins in the background and coalesce their resulting reload requests.
LocationServiceMap.get(ref)
→ build location layer
→ Config.layer reads authored documents
→ merge authored documents
→ run currently active Config transforms
→ Policy.layer reads transformed Config
→ Catalog.layer reads transformed Config
→ materialize baseline provider/model catalog
→ PluginBoot baseline ready
→ Frontend.fetchCatalog()
PluginBoot background fiber
→ install/update plugin packages concurrently
→ activate completed plugins
→ Config.transform()
→ transform(updateConfig)
→ ReloadScheduler.request()
→ debounce short burst of completed activations
→ Reload.all()
→ Config.get()
→ run newly active Config transforms
→ Catalog.reload()
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
The initial layer build is not a reload. Reload.all() only runs after the live location changes, such as a background plugin becoming active or a config source changing. Debouncing reduces repeated full-service reloads when multiple plugins complete near each other; each batch still reloads every config-consuming service because a config transform may mutate any field.
config file loaded
→ config source/watch trigger records new documents
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
timer fires
→ ModelsDevPlugin.refresh()
→ ModelsDev.get()
→ transform(applyModelsDevToConfig)
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
Catalog does not know about ModelsDev; the plugin transforms config before catalog reads it.
Account.switched(providerID)
→ AuthPlugin.refresh(providerID)
→ Account.active(providerID)
→ transform(applyAuthToConfig)
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
Plugin.activate("acme-models")
→ Config.transform()
→ transform(applyAcmeConfig)
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
Plugin.disable("company-naming")
→ close plugin scope
→ Config internally unregisters transform in finalizer
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ sonnet.name = "Sonnet"
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
file watcher sees edit
→ config source/watch trigger records updated documents
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
policy config changes
→ config source/watch trigger records updated documents
→ Reload.all()
→ Policy.reload()
→ Catalog.reload()
→ apply updated policy
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
Draft<Config.Info>, can inspect preceding config state, and can mutate arbitrary config fields through a replayable transform.Config owns merge/order semantics for fields visible to transforms.Catalog depends on provider/model config semantics and is part of that full service reload.Catalog.Event.Updated notification.Plugins register replayable catalog transforms. Each transform receives a Catalog.Editor whose helper methods mutate a private catalog draft; Catalog rematerializes visible records from its active transforms.
interface Catalog {
transform(): Effect.Effect<(update: (catalog: Catalog.Editor) => void) => Effect.Effect<void>, never, Scope.Scope>
}
const transform = yield* Catalog.transform()
yield* transform(update)
→ replace this transform callback
→ apply active transforms in registration order
→ apply policy
→ commit diff
→ Event.publish(Catalog.Event.Updated)
→ Frontend.refetchCatalog()
Configured plugin installation/updates should not block location readiness. Build an initial catalog from immediately available sources, then activate slow plugins in the background and coalesce refresh requests.
LocationServiceMap.get(ref)
→ build location layer
→ Catalog.layer creates empty catalog state
→ PluginBoot.layer activates immediately available plugins
→ ConfigProviderPlugin installs Catalog.transform()
→ ModelsDevPlugin installs Catalog.transform()
→ AuthPlugin installs Catalog.transform()
→ Catalog.layer applies active transforms during boot
→ apply policy
→ materialize baseline provider/model catalog
→ PluginBoot baseline ready
→ Frontend.fetchCatalog()
PluginBoot background fiber
→ install/update plugin packages concurrently
→ activate completed plugins
→ Catalog.transform()
→ transform(updateCatalog)
→ Catalog internally rebuilds
→ Catalog.Event.Updated
→ Frontend.refetchCatalog()
Each completed plugin activation rebuilds catalog when it calls its transform. Debouncing plugin completions would require adding an explicit batch/suspend-rebuild mechanism; it does not arise from the transform interface itself.
config file loaded
→ ConfigProviderAdapter.load()
→ transform(applyConfigToCatalog)
→ Catalog internally rebuilds
timer fires
→ ModelsDevPlugin.refresh()
→ ModelsDev.get()
→ transform(applyModelsDevToCatalog)
→ Catalog internally rebuilds
→ commit diff
Account.switched(providerID)
→ AuthPlugin.refresh()
→ transform(applyAuthToCatalog)
→ Catalog internally rebuilds
→ replay active transforms including current auth
→ apply policy
→ commit diff
Plugin.activate("acme-models")
→ Catalog.transform()
→ transform(applyAcmeToCatalog)
→ Catalog internally rebuilds
→ commit diff
Plugin.disable("company-naming")
→ close plugin scope
→ Catalog internally unregisters transform in finalizer
→ Catalog internally rebuilds
→ sonnet.name = "Sonnet"
→ commit diff
file watcher sees edit
→ ConfigProviderAdapter.load()
→ transform(applyUpdatedConfigToCatalog)
→ Catalog internally rebuilds
policy changes
→ Catalog rebuild trigger
→ replay all active transforms
→ apply updated policy last
→ commit diff
Catalog.Event.Updated notification.