Cordis → rust mapping (Phase 0, step 5)

Source: DeepSeek Cordis paper ("A Programming Paradigm for Spatiotemporal Composability", Aug 2026, cordiverse/cordis + cordiverse/paper) and DeepSeek Harness (deepseek-ai/deepseek-harness, TS, ~60 packages, 12 layers). Target: ARES /opt/ares (dirmacs/ares v0.7.3, 11 crates + ares-server root, Rust 1.98, Tokio/Axum). This doc is strategy only, no code changes. Spike crate crates/cordis (Phase 1) must prove the theorems before adoption.

Phase 2 crate graph (crates.io already has cordis 0.0.0, so the Cargo package is ares-cordis with [lib] name = "cordis"; workspace dep key stays cordis):

PathPackageRust crate
crates/cordisares-cordiscordis
crates/ares-agentares-agentares_agent
crates/ares-storeares-storeares_store

Root package stays ares-server with lib name ares. Domain config lives in plugin crates; Overlay (src/overlay.rs) owns ares.toml after Phase 4.


1. core equation

Cordis: Γ^∞ = μΓ. Γ × (Γ → Γ) × Σ, unified context that lifts effect systems (revertible mutations) and coeffect systems (typed dependency declarations) to runtime.

Rust mapping:

#![allow(unused)]
fn main() {
pub struct Context {
    // Γ — value environment (store)
    store: RwLock<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>, // Σ impl, see §3
    // Isolate table: TypeId → Symbol (scoped identity)
    isolate: RwLock<HashMap<TypeId, Symbol>>,
    // Intercept table: TypeId → override (prototype-chain)
    intercept: RwLock<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>,
    // Fiber that owns this context's lifecycle
    fiber: Arc<Fiber>,
    // Parent for hierarchical lookup (prototype chain)
    parent: Option<Arc<Context>>,
    // Root for epoch-computation reachability
    root: Weak<Context>,
}
}
  • Context::new_root() -> Arc<Context> creates Fiber::Inactive.
  • Context::extend(&self) -> Arc<Context> creates child with parent = Some(self) (lexical scope / request scope).
  • Context::provide::<T: Service>(&self, svc: T) inserts TypeId::of::<T>() → Arc<T> into store; witnessed by effect.
  • Context::get::<T: Service>(&self) -> Option<Arc<T>> walks store → intercept → parent.store (coeFFECT lookup).
  • Context::get_relaxed::<T: Service>(&self) -> Option<Arc<T>> — same walk, but a locally-owned provider whose owner fiber rests mid-transition (Active, Loading, Reloading, Unloading, reactive Pending) still resolves. Strict get refuses those so consumers never observe mid-transition values; disposed owners (undos already ran) and terminal Failed{error} owners stay refused even relaxed.
  • Context::isolate::<T>(&self, label: &str) -> Arc<Context> creates child whose isolate[TypeId::of::<T>()] = Symbol(label).
  • Context::intercept::<T>(&self, override: T) -> Arc<Context> creates child whose intercept[TypeId::of::<T>()] = override.

No unsafe. No libloading in spike (stubbed); YAGNI HMR deferred behind #[cfg(feature = "hmr")].


2. witnessed effects, temporal composability

Cordis witnessed effect function: (Γ → Γ) × (Γ → Γ) pair (do + undo) with LIFO accumulator for revertible mutations. Guarantees: if fiber disposes, all effects it applied are reverted in reverse order.

Rust:

#![allow(unused)]
fn main() {
pub trait Disposable: Send + 'static {
    fn dispose(self: Box<Self>);
}

pub struct EffectGuard {
    // LIFO accumulator — Box<dyn FnOnce() + Send>
    acc: Vec<Box<dyn FnOnce() + Send>>,
}

impl Drop for EffectGuard {
    fn drop(&mut self) {
        while let Some(undo) = self.acc.pop() { undo(); } // reverse order
    }
}

pub trait Effect: Send + Sync + 'static {
    fn apply(&self, ctx: &Context) -> Box<dyn Disposable>;
}

// Helper on Context — mirrors Cordis Context::effect
impl Context {
    pub fn effect<E: Effect>(&self, eff: E) -> Box<dyn Disposable> {
        let guard = eff.apply(self);
        self.fiber.accumulator.lock().push({
            let ptr = /* capture undo closure */;
            Box::new(move || { /* undo */ })
        });
        guard
    }
}
}

Spike verification (Phase 1, §8): temporal composability test

#![allow(unused)]
fn main() {
#[tokio::test]
async fn temporal_composability() {
    let ctx = Context::new_root();
    let fiber = ctx.plugin(FooService::new(), FooConfig::default()).await;
    ctx.provide(BarService(42));
    assert_eq!(ctx.get::<BarService>().unwrap().0, 42);
    fiber.dispose().await;
    assert!(ctx.get::<BarService>().is_none());
    assert_eq!(ctx.snapshot(), pre_plugin_snapshot);
}
}

Must hold before Phase 2.


3. coeffect table Σ, spatial composability

Cordis Σ is a TypeId-keyed table of dependency declarations (inject = ["foo"]). Fiber recomputes epoch from dependency UIDs; if epoch unchanged, no reload.

Rust anymap/typemap equivalent (hand-rolled to avoid extra dep in spike):

#![allow(unused)]
fn main() {
pub struct CoeffectTable {
    // TypeId → (TypeId, Symbol, UID)
    injects: HashMap<TypeId, (Symbol, String)>, // String = epoch fragment ":uid"
}

impl CoeffectTable {
    pub fn declare<T: Service>(&mut self, label: Symbol) {
        self.injects.insert(TypeId::of::<T>(), (label, uid_for::<T>(label)));
    }
    pub fn epoch(&self) -> String {
        // Monoid over concatenation, per Cordis: ":uid1:uid2:..."
        let mut frags: Vec<_> = self.injects.values().map(|(_, uid)| uid).cloned().collect();
        frags.sort();
        frags.join(":")
    }
}
}
  • aranymap crate is not needed; HashMap<TypeId, Box<dyn Any + Send + Sync>> with TypeId::of::<T>() suffices.
  • Symbol is Arc<str> or &'static str for isolate labels (e.g., tenant:abc).
  • Handlers currently take State<AppState> (17,22 fields, src/lib.rs:230). New handlers: State<Arc<Context>> + ctx.get::<T>() where T is declared as inject. Example:
#![allow(unused)]
fn main() {
 // Before (P0 god-struct):
 async fn chat(State(state): State<AppState>, ...) -> Response
 
 // After (decomposed Context):
 async fn chat(State(ctx): State<Arc<Context>>, ...) -> Response {
     let exec = ctx.get::<dyn AgentExecutionService>().expect("no execution service");
     exec.execute(req, &ctx).await
 }
}

Spike verification: spatial composability

#![allow(unused)]
fn main() {
#[tokio::test]
async fn spatial_composability() {
    let ctx = Context::new_root();
    let consumer = ConsumerService::new(inject: vec![TypeId::of::<FooService>()]);
    let fid = ctx.plugin(consumer, Config::default()).await;
    assert_eq!(ctx.fiber_state(fid), FiberState::Inactive); // dep missing
    ctx.provide(FooService);
    assert_eq!(ctx.fiber_state(fid), FiberState::Active); // auto-reload
    ctx.provide(FooService::v2()); // re-provide
    assert_eq!(ctx.fiber_epoch(fid).prev, ":foo_v1");
    assert_eq!(ctx.fiber_epoch(fid).current, ":foo_v2"); // reload triggered
}
}

4. isolate & intercept

Isolate (spatial scoping)

Cordis isolate("name", label) creates realm where provide/inject are scoped.

Rust:

#![allow(unused)]
fn main() {
impl Context {
    pub fn isolate<T: Service>(&self, label: impl Into<Symbol>) -> Arc<Context> {
        let child = self.extend();
        child.isolate.write().insert(TypeId::of::<T>(), label.into());
        child
    }
}

// Usage: per-tenant tool isolation (P10, Phase 3)
let tenant_ctx = root_ctx.isolate::<dyn ToolService>("tenant:acme");
tenant_ctx.provide(TenantToolService::new(tenant_id));
// tenant_ctx.get::<dyn ToolService>() returns tenant-scoped service
// root_ctx.get::<dyn ToolService>() still returns fleet service
}

Intercept (prototype-chain override)

Cordis intercept("key", config) overrides a coeffect without mutating the provider.

Rust:

#![allow(unused)]
fn main() {
impl Context {
    pub fn intercept<T: Service>(&self, override_val: T) -> Arc<Context> {
        let child = self.extend();
        child.intercept.write().insert(TypeId::of::<T>(), Arc::new(override_val) as Arc<dyn Any + Send + Sync>);
        child
    }
}

// Usage: per-request model pinning (P10, Phase 5)
let req_ctx = root_ctx.intercept(ModelOverride { model: "gpt-4o-mini".into() });
let llm = req_ctx.get::<dyn LlmService>().unwrap(); // sees override via prototype walk
}

Kernel intercept meta-events (beyond the prototype chain)

Five kernel operations expose listener-driven veto points on reserved events (internal/get, internal/set, internal/config, internal/update, internal/listener), plus an internal/dispatch observer. These sit outside the product event catalog on purpose.

  • EventsService::intercept_get/set/config/update/listener implement the semantics; internal/dispatch reports every NON-internal dispatch as (mode, name, args) and exempts itself from observation.
  • internal/get consults a Bail chain on every strict Context::get: a non-null terminal replaces the returned value, {"refuse": true} fails the lookup, a redirect verdict continues at the parent frame, null passes through, and a chain error refuses the read.
  • internal/set errors veto the provider write; the previous binding stays fully intact.
  • internal/config's non-null terminal IS the effective configuration for one apply pass (staged on the fiber and consumed by the registry runner); a chain error rests the fiber terminal Failed.
  • internal/update bails skip the scheduled restart entirely — the fiber keeps serving its current application and the deferred config stays readable via Fiber::vetoed_config.
  • internal/listener bails cancel the registration; the caller receives an inert handle and neither registry sees the listener.

Zero-cost gating: every helper short-circuits on listener_count == 0 before doing anything else, so the default path is two map lookups. Synchronous call sites bridge through block_in_place on multi-thread tokio runtimes; single-thread flavors fall OPEN (warning + allow), matching historical no-listener behavior. A thread-local re-entrancy fence keeps operations made inside a chain un-intercepted. The *_from dispatch family — bail_from, waterfall_from, waterfall_async_from — exposes the same chains for product code, adding an optional per-dispatch ListenerFilter; filtered listeners skip one dispatch and remain registered.


5. fiber lifecycle (with inertial lock)

Cordis Fiber states: Inactive → Reloading → Active → Unloading plus Inertia lock to serialize transitions (Thm 63, guarded withdrawal: provider does not withdraw until dependents deactivate).

Rust:

#![allow(unused)]
fn main() {
pub enum FiberState {
    Inactive { error: Option<AppError> },
    Reloading { iter: Box<dyn EffectIterator>, acc: EffectAcc, committed: CommittedView },
    Active { acc: EffectAcc, committed: CommittedView },
    Unloading { acc: EffectAcc, committed: CommittedView, outcome: Option<AppError> },
}

pub struct Fiber {
    state: RwLock<FiberState>,
    inertia: Arc<tokio::sync::Mutex<()>>, // serialize transitions
    acc: Mutex<EffectAcc>, // Vec<Box<dyn FnOnce() + Send>>
    epoch: RwLock<String>, // computed epoch
    injects: CoeffectTable,
    committed: CommittedView, // snapshot for rollback
}

impl Fiber {
    pub async fn refresh(&self) {
        let _guard = self.inertia.lock().await; // Thm 63
        let new_epoch = compute_epoch(&self.injects);
        if *self.epoch.read() == new_epoch { return; } // no change
        self.reload().await;
    }

    async fn reload(&self) { /* iterate effects, recompute, commit or rollback */ }
    async fn dispose(self: Arc<Self>) { /* LIFO undo, state → Inactive */ }
}
}
  • EffectIter is Box<dyn Iterator<Item = Box<dyn Effect>> + Send>, each Service::init yields effects.
  • CommittedView is HashMap<TypeId, Arc<dyn Any>> snapshot taken at Active entry; used for rollback on failure.
  • notify (see §7) triggers Fiber::refresh() via BFS over dependent fibers.

File placement: crates/cordis/src/fiber.rs (spike) → later crates/ares-context/src/fiber.rs.

The fiber lifecycle adds Loading and Failed: Loading marks a fiber mid-instantiation and Failed records a terminal error from a plugin activation. A failed fiber remains observable via the registry so a loader or admin tool can report why a registration did not become Active; a later successful re-registration starts a fresh fiber that reaches Active. In 0.9.0, Fiber::refresh still compares epochs, then undoes prior effects and reruns the registered plugin apply when a reload is required.

Pending rest state (reversible withdrawal)

A fifth state completes the machine: an Active runner whose dependency is genuinely withdrawn disposes its effects LIFO under Unloading, then rests Pending instead of dying or going Inactive. While Pending, the fiber keeps its registry key (it survives prune_disposed) and reactivates through Loading when the provider returns.

  • Pending is reserved for reactive waiting only: apply errors still rest terminal Failed{error}, and a peer-version constraint refusal over a live provider rests Inactive (the provider is still available; the refusal is policy, not loss).
  • Eligibility needs one fully-satisfied refresh pass first — registration alone cannot mark a fiber eligible because its declares may still be unserved.

Readiness barriers vs availability predicates

RegistryService::register_with_readiness(ctx, plugin, config, ready_when) installs a ReadinessBarrier consulted before every activation pass. While the gate reports not-ready the fiber rests inspectable Pending — quiet waiting that never becomes Failed — with the factory run once up front and strict get refusing the non-Active owner, so the service stays out of consumer reach until the observed environment turns ready.

  • ReadinessBarrier::new(pred) wraps one Fn(&Arc<Context>) -> bool; .and(other) AND-composes; with_readiness([a, b, c]) folds any number of barriers (an empty list is vacuously ready).
  • .watching([TypeId]) unions the provider keys whose settlements re-kick the gated fiber through the ReflectService fan-out: an external provide or withdrawal re-evaluates the gate without anyone touching the fiber.

This complements rather than replaces availability predicates (Service::check): a rejected availability predicate rests Failed{error: "availability predicate rejected service"} — loud and terminal per the rules above — while a closed readiness gate is quiet, reversible waiting. Use the predicate when the factory cannot produce a valid service; use the barrier when production succeeds but serving should hold.


6. epoch, hash of dependency UIDs

Cordis epoch is monoid ":uid1:uid2:..." (concatenation). Fiber skips reload if epoch unchanged.

Rust:

#![allow(unused)]
fn main() {
pub fn compute_epoch(injects: &CoeffectTable) -> String {
    // Monoid over concatenation per paper §4.3
    let mut frags: Vec<String> = injects.uids_sorted();
    if frags.is_empty() { return ":".into(); }
    format!(":{}", frags.join(":"))
}

// uid_for<T> = format!("{}:{}", std::any::type_name::<T>(), label)
// Example: epoch = ":ares_llm::LlmService:tenant_acme:ares_tools::ToolService:tenant_acme"
}
  • The epoch type is String, not a hash. The paper uses concatenation for debuggability; if perf matters, switch to sha2 hash later.
  • Fiber::refresh compares self.epoch.read() vs compute_epoch(&self.injects); logs diff via tracing::debug!.

7. notify, reactive recomputation (tokio::sync::watch)

Cordis notify is fan-out to dependent fibers. In the TS Harness, notify uses EventEmitter; in Rust, it uses tokio::sync::watch.

#![allow(unused)]
fn main() {
pub struct ReflectService {
    // TypeId of changed service → watch channel sender
    notifiers: RwLock<HashMap<TypeId, watch::Sender<()>>>,
    // dependency graph: provider TypeId → [dependent FiberId]
    dependents: RwLock<HashMap<TypeId, Vec<FiberId>>>,
}

impl ReflectService {
    pub fn notify(&self, changed: TypeId) {
        // BFS walk dependents
        let deps = self.dependents.read().get(&changed).cloned().unwrap_or_default();
        for fid in deps {
            if let Some(sender) = self.notifiers.read().get(&changed) {
                let _ = sender.send(()); // fan-out, ignore closed receivers
            }
            // also trigger Fiber::refresh via task
            tokio::spawn({
                let fiber = self.fiber_for(fid);
                async move { fiber.refresh().await }
            });
        }
    }
}

// DB-backed source example (replaces 60s poll in runtime_registry.rs etc.):
// On Postgres NOTIFY (or polling fallback every 60s if no NOTIFY), call:
// ctx.get::<ReflectService>().unwrap().notify(TypeId::of::<RuntimeToolService>());
}

Replaces: RuntimeToolRegistry::start_background_reload (60s poll, crates/ares-tools/src/runtime_registry.rs), ProviderRegistry poll (crates/ares-llm/src/provider_registry.rs), NvidiaCatalogCache::start_background_refresh (crates/ares-llm/src/nvidia_catalog.rs).


8. events, 5 dispatch modes

Cordis Events: emit / parallel / serial / bail / waterfall typed bus.

Rust:

#![allow(unused)]
fn main() {
#[derive(Clone, Copy, Debug)]
pub enum Dispatch {
    Emit,      // fire-and-forget, no return, no error propagation
    Parallel,  // tokio::JoinSet, collect all, fail-open (one handler error doesn't cancel others)
    Serial,    // sequential, fail-open
    Bail,      // sequential, fail-fast (first error aborts)
    Waterfall, // sequential, each handler receives previous handler's output (chained)
}

pub struct EventsService {
    handlers: RwLock<HashMap<EventId, Vec<Handler>>>, // Handler = Box<dyn Fn(Value) -> Future<Output=Result<Value>> + Send>
    bus: broadcast::Sender<EventEnvelope>, // tokio::sync::broadcast for cross-task fan-out
}

impl EventsService {
    pub fn on(&self, event: EventId, handler: Handler) -> Box<dyn Disposable> {
        self.handlers.write().entry(event).or_default().push(handler);
        // return Disposable that removes handler on dispose (LIFO undo)
        Box::new(RemoveHandler { event, idx: len - 1 })
    }

    pub async fn dispatch(&self, event: EventId, payload: Value, mode: Dispatch) -> Result<Value> {
        match mode {
            Dispatch::Emit => { self.bus.send(envelope(payload)); Ok(Value::Null) }
            Dispatch::Parallel => { /* JoinSet */ }
            Dispatch::Serial => { /* loop */ }
            Dispatch::Bail => { /* loop with bail */ }
            Dispatch::Waterfall => { /* chain payload through handlers */ }
        }
    }
}
}

Mapping from TS Harness (12 layers, ~60 packages), in Rust, one crate suffices; do not replicate layering ceremony. EventsService is a Service itself (ctx.provide(EventsService::new())), so any fiber can ctx.get::<EventsService>().unwrap().on(...).

Dispatcher parity (shipped)

The dispatch implementation follows the five modes exactly:

  • Emit: every handler is spawned and not awaited (fire-and-forget); dispatch returns JSON null after broadcasting the event and payload on the bus. A caller that needs completion can listen on the bus or use a oneshot channel, not await this call.
  • Parallel: handlers run concurrently via tokio::task::JoinSet; successful dispatch returns JSON null (handler values are discarded). The first error observed is propagated (a joined panic surfaces as CordisError::Fiber).
  • Serial: handlers run in registration order with the original payload; the first non-null result bails and is returned. An all-null chain returns the original payload. Serial and Bail share this path.
  • Bail: stops at the first handler that returns a non-null result and returns that value without running later handlers; a null result means not bailing and the chain continues with the original payload.
  • Waterfall: each handler transforms the payload and passes the result to the next; a handler short-circuits by returning an object whose waterfall_stop field is true. This is the Rust static-dispatch analogue of the TS next() closure: instead of passing a next function, a handler opts out by returning the sentinel.

Dispatch participation knobs (EventOptions / emit_filtered)

Flat listeners can register through on_with / once_with with EventOptions { prepend, global }:

  • prepend: true inserts the listener at the FRONT of the dispatch-order list, so it runs before previously registered listeners of the same event.
  • global: true marks the listener realm-agnostic: emit_filtered(event, args, filter) offers every non-global listener to the filter predicate first and excludes it from that one dispatch on a false verdict — without unregistering it. Global listeners bypass the filter entirely.
  • The historical on / once / emit signatures delegate with default options (false/false), so existing registrations are byte-compatible. The broadcast bus fan-out is not filtered; only registered handlers participate.

Event-first skill execution (0.9.0)

Each skill receives the request Context. Tool steps run on a tenant-scoped context created with ctx.isolate::<Tools>(tenant_id) and invoke Tools::execute through tools.execute. Every Skill LlmCall step uses strict Llm::complete through llm.complete; SkillEngine and SkillsService do not call providers directly or fall back to generate_with_history. When EventsService is present, waterfall_around wraps these capability calls. Tools, Llm, and Execute public methods stay on the same event-first path.


9. loader & config reconciliation (Declarative)

Cordis Loader: Entry { id, plugin, config, disabled, isolate, intercept } + EntryTree(Vec<Entry>) persisted to config/entries.json (or config/cordis-entries.toon via toon-format 0.4.1). Loader::reconcile(current, desired) diffs incrementally.

Rust (Phase 3, crates/cordis/src/loader.rs):

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone)]
pub struct Entry {
    pub id: String,               // fiber id, e.g. "tool:calculator"
    pub plugin: PluginId,         // e.g. "ares_tools::CalculatorService"
    pub config: serde_json::Value,// Plugin::Config serialized
    pub disabled: bool,
    pub isolate: Option<String>,  // e.g. Some("tenant:acme")
    pub intercept: HashMap<String, Value>,
}
pub struct EntryTree(pub Vec<Entry>);

impl Loader {
    pub fn reconcile(&self, current: &EntryTree, desired: &EntryTree) {
        // per-field dispatch (paper §5):
        // id/plugin change → rebuild fiber (dispose + new)
        // config change → fiber.update(new_config)
        // disabled toggle → fiber.retire() / fiber.begin()
    }
}
}

Persistence: config/entries.json (or config/cordis-entries.toon) separate from ares.toml symlink (/opt/ares-config/ares.toml), do not conflict (see Assumptions in plan). Reuse toon-format serialization.

Loader journal (shipped)

LoaderJournal makes the UpdateConfig and Retire arms real. It stores a JournalRecord per entry: the plugin label owning the entry, the last applied config, the live fiber id when known, and a monotonically increasing generation counter (every mutation bumps it). It is the single source of truth for "is this entry live, with which fiber, at what config/version".

  • Loader::instantiate and the RebuildFiber arm call journal.upsert(id, plugin, config, Some(fid)) after a successful factory invocation.
  • UpdateConfig reads the recorded fiber id, resolves it via RegistryService::get_fiber, and calls Fiber::update when a live fiber is known (running block_in_place on a multi-thread runtime, journal-only on a current-thread runtime or no runtime); it then calls journal.update_config(id, new_config, recorded), bumping generation.
  • Retire calls journal.retire(id), clearing the record.

It is provided as a service with ctx.provide(LoaderJournal::new()), so Context::get::<LoaderJournal> returns the shared handle. When absent, Loader::execute_action and Loader::instantiate degrade to log-only.


10. Plugin & RegistryService

Cordis plugins are FnOnce(&Context, Config) -> Result<Disposable> or struct with apply. Registry enforces single-source discipline.

Rust (Phase 2, crates/cordis/src/registry.rs):

#![allow(unused)]
fn main() {
pub trait Plugin: Send + Sync + 'static {
    type Config: Serialize + DeserializeOwned + Send + Sync;
    fn apply(&self, ctx: &Context, config: Self::Config) -> Result<Box<dyn Disposable>>;
}

pub struct RegistryService {
    fibers: RwLock<HashMap<FiberId, Arc<Fiber>>>,
}

impl RegistryService {
    pub fn plugin<P: Plugin>(&self, plugin: P, config: P::Config) -> Result<FiberId> {
        // check duplicate provider: no two fibers may provide same TypeId in same isolate realm
        // if violation: return Err(AppError::Configuration("duplicate provider for TypeId"))
        // else: create Fiber, store, return FiberId
    }
}
}

Static registration (preferred production): inventory/linkme (compile-time plugin set), real surface is RegistryService::plugin (single-source discipline); inventory::submit! / linkme::distributed_slice of fn(&Arc<Context>) -> Result<FiberId, CordisError> is the future shortcut once crate count stabilizes (see crates/cordis/src/lib.rs HMR section and Wiring task). Dynamic HMR (dev only, behind #[cfg(feature = "hmr")] off by default): libloading path that dlopens .so and calls Plugin::apply via extern "C"; if libloading ABI fragility blocks (Rust 1.98 toolchain coupling, unsafe soundness), fall back to file-watch + full fiber reload (re-read config/TOON), 90% of value per plan, see watcher fallback below.

HMR YAGNI decision (plan Assumptions §Contingencies)

Decision: DEFER libloading HMR, keep file-watch + Fiber::reload as production path.

  • Rationale: libloading::Library::new + Symbol<extern "C"> requires unsafe, a stable repr(C) ABI boundary, and the .so to be built with the exact same Rust toolchain (1.98). ABI drift across patches, rust-doctor soundness flags, and Box::leak ownership hazards make libloading too brittle for a generic runtime. As plan contingency states: "If libloading HMR proves too complex for Rust (dynamic library ABI fragility, unsafe surface), fall back to file-watch + full fiber reload without dynamic code swapping … file watcher still triggers Fiber::reload() by re-reading config/TOON, which already covers 90% of self-evolution value. Dynamic code HMR can be deferred to a later phase behind #[cfg(feature = "hmr")] without blocking the core redesign."
  • Fallback implemented: crates/cordis/src/watcher.rs (watch_many / watch_cordis_entries) uses notify::RecommendedWatcher (debounced 500 ms + 100 ms settle, same as AresConfigManager::start_watching) to watch config/agents/*.toon (recursive) and config/entries.json (or config/cordis-entries.toon parent dir). On Modify/Create it calls ReflectService::notify(tid) which BFS-walks dependents and spawns Fiber::refresh (epoch recompute via compute_epoch). No restart, no libloading. Logs Configuration hot-reloaded successfully via Cordis watch (generalizes AresConfigManager's Configuration hot-reloaded successfully which is already proven on random-port E2E 39476/39120, see docs/cordis-redesign.md §9/9b).
  • Stub preserved: crates/cordis/src/hmr.rs is #[cfg(feature = "hmr")] (Cargo feature hmr = ["dep:libloading"], off by default). It shows libloading::Library::new + get::<HmrEntryFn> + owned HmrLibrary holder (RAII, no Box::leak) calling cordis_plugin_apply (extern "C"). Enable with cargo build --features hmr and a .so built with the same toolchain. Not invoked by src/main.rs or ReflectService, watcher is the production path.
  • Cargo.toml: [features] hmr = ["dep:libloading"] (libloading 0.8 optional, notify 8.2.0 always for watcher), default = [].

11. what is explicitly not ported in spike (updated)

Per YAGNI (Phase 1, §8) + HMR deferral above:

  • ❌ libloading HMR DEFERRED, file-watch fallback crates/cordis/src/watcher.rs (notify → ReflectService::notify → Fiber::reload via epoch) covers 90% value without dynamic code. Dynamic code swap remains as crates/cordis/src/hmr.rs stub behind #[cfg(feature = "hmr")] (off by default, libloading 0.8 optional). See HMR decision above and lib.rs HMR section.
  • ❌ WASM, deferred.
  • ❌ Visual layer package (~60 TS packages, 12 layers), in Rust, one crate; do not replicate ceremony.
  • ❌ ares.toml symlink handling, keep AresConfigManager::start_watching() as-is for Phase 2; Loader is additive. watcher generalizes it to Cordis entries/TOON without touching ares.toml symlink (/opt/ares-config/ares.toml).

12. critical anchors (Reread before phases 2/4/5)

  • /opt/ares/src/main.rs run_server (lines 296,889, 17 steps) → becomes root_ctx.plugin(...).plugin(...).await (5,8 lines). Every registry/pool/cache must migrate to a Service.
  • /opt/ares/src/lib.rs AppState (230,274, 17,22 fields) + base_router() → Arc<Context>, build_router(ctx: Arc<Context>).
  • /opt/ares/crates/ares-tools/src/runtime_registry.rs start_background_reload (60s poll, ArcSwap) → epoch-driven notify.
  • /opt/ares/crates/ares-llm/src/provider_registry.rs ArcSwap<HashMap> + NvidiaCatalogCache → LlmService with circuit breaker.
  • /opt/ares/src/api/handlers/admin.rs 190 KB, 5,946 lines, split by domain in Phase 6.

13. consequences & alternatives

  • If async fn in trait causes dyn issues, use async_trait only for that trait and document why (Rust 1.98 floor, 1.75+ stable for async fn in trait, but a dyn Service can require async_trait; prefer an impl Future return).
  • If TypeId + HashMap proves too coarse (downcasting ergonomics), evaluate anymap/typemap crates, but hand-rolled HashMap<TypeId, Box<dyn Any>> is sufficient for spike.
  • If epoch String concatenation bloats logs, switch to sha2 digest and keep :uid1:uid2 only in tracing::debug!.

14. Current architecture (0.9.0)

This section describes the shipped 0.9.0 runtime. Earlier sections remain the Phase 0 mapping and 0.8 spike record.

Fiber refresh

Fiber::refresh recomputes the dependency epoch. When the epoch changes, or the fiber is not already Active with dependencies satisfied, it undoes prior effects and reruns the registered plugin apply. Dispose still LIFO-undoes and passes through Unloading.

Wave-1 refinements: a working runner whose dependency genuinely vanishes rests reversible Pending (see §5) instead of dying, and reactivates when the provider returns; Context::get_relaxed lets lifecycle code read locally-owned values during those transitions while strict get stays conservative; Fiber::subscribe_state exposes every transition to panic-contained synchronous observers.

EventsService dispatch

Shipped behavior in crates/cordis/src/events.rs:

  • Emit: fire-and-forget; dispatch returns JSON null.
  • Parallel: handlers run on a JoinSet; successful dispatch returns JSON null. Handler return values are not collected. The first join or handler error is propagated.
  • Serial: handlers run in registration order with the original payload. The first non-null result bails and is returned. An all-null chain returns the original payload. Serial and Bail share this path.
  • Waterfall: around-middleware with next; skipping next skips later handlers and core.

Event-first product path

Tools, Llm, Execute, and skills stay event-first. Public methods go through EventsService::waterfall_around when the bus is on ctx (tools.execute, llm.complete, agent.run). Skill tool and LLM steps use those same events on a tenant isolate. They do not call providers or the tool registry directly.

agent.admit

agent.admit (Dispatch::Bail) is the shared quota gate. Execute::run, JWT chat, API-key middleware, and MCP all dispatch it before work. The default handler denies monthly or daily quota for non-enterprise tenants. HTTP maps deny to 429; MCP maps deny to a tool error.

Store, Overlay, TOON

The Store loader factory connects, runs sqlx migrations, and seeds default agent templates, then provides Store. Overlay fills empty loader entry.config from ares.toml sections and leaves non-empty configs unchanged. TOON file changes notify TypeId::of::<Tools>() and TypeId::of::<Execute>() so those fibers refresh.

Isolate vs intercept

For the same TypeId, an isolate label wins: get skips intercept and walks the isolated store/parent. Unlabeled types still intercept, so request TenantContext and ModelOverride keep working on a Tools/Execute realm.

TenantRealms

Request paths open the tenant realm then intercept TenantContext (open then with_intercept), including JWT middleware (tenant claims), JWT chat/research, and v1 chat/stream/agents. JWT user: isolate does not invent a dummy Free tenant. Background jobs open or isolate only and do not attach a request intercept. Admin tenant delete calls TenantRealms::dispose before SQL delete.

Facade

The default ares crate has no axum. Context, Execute, Tools, Llm, and register_plugins are enough to run an agent. Enable feature http to pull ares-http. ProviderRegistry is not re-exported from ares; construction tests import it from ares-llm.

Honest residuals

  • ProviderRegistry still exists on ares-llm because Llm::new / AgentRegistry::from_config still take one during construction.
  • run_server still instantiates Overlay first, fills empty loader configs, then instantiates remaining config/cordis-entries.toml entries.
  • Scheduler, pipeline, and trigger domain loops remain native ARES engines. They inject Execute and run behind it; they are not a second public agent API.
  • Root ares-server is a binary. Overlay lives in crates/ares-http/src/overlay.rs; the server still registers the Overlay factory.
  • Overlay / optional ServerRuntime provide host extras (ActiveRuns, SkillEngine, MCP). Execute is registered once, by ares-agent.

15. Round 4 (0.9.x): policies, HMR finish, static factories, metrics, atomic saves

Typed event payloads

All 22 catalog events in crates/cordis/src/events_catalog.rs (CONTRACTS, contract_for) carry typed payload structs in crates/cordis/src/events_payload.rs. Each implements the TypedEvent trait (type Payload: Serialize + DeserializeOwned + Clone + Send + Sync, plus NAME/MODE/AROUND const-linked to the catalog). EventsService::dispatch_typed<E> serializes and dispatches with the declared mode; on_typed / on_typed_waterfall deliver deserialized payloads to listeners, skipping malformed ones with a warning. A consistency test asserts every binding matches CONTRACTS and that no event is unbound. The raw Value API remains for kernel and dynamic cases.

Engine choreography

Scheduler admission is scriptable through two waterfall events: scheduler.before_run output overrides agent_name/message on the executed request (audit identity stays schedule-tied); a Bail denial emits {ok:false, denied:true} and returns without running — due-pass callers solely advance next_run. Boundary events SCHEDULER_TICK, SCHEDULER_SCHEDULE_DISPATCHED, PIPELINE_STEP_STARTED/FINISHED, PIPELINE_FANOUT_COMPLETED, and TRIGGER_FIRED are emitted from scheduler/pipeline/trigger engines, completing full-catalog adoption.

RhaiPolicy scripting (default-on)

RhaiServiceConfig.listen: Vec<RhaiListenerConfig { event, fn_name }> lets a declarative entry attach sandboxed Rhai functions to any catalog event:

[[entry]]
id = "policy-example"
plugin = "RhaiPolicy"
[entry.config]
script = "fn gate(p) { if p.tenant_id == \"banned\" { #{deny: \"banned\"} } else { () } }"
[[entry.config.listen]]
event = "scheduler.admit"
fn_name = "gate"

Semantics: the payload arrives as an object map (plain property access). Returning ()/null passes through (Bail) or delegates via next (waterfall); any other value becomes the dispatch result — deny marker or short-circuit. Script runtime errors log a warning and pass through/delegate. Init validates each event against the catalog (unknown ⇒ Configuration error, recorded per-entry at boot). All listener disposables combine with the init guard so fiber dispose unregisters them. Enabled by default through root feature rhai-policy; factory key "RhaiPolicy" registered under the rhai feature.

Static registration (inventory-collected factories)

cordis::CordisPluginFactory { name, make } (make is a plain fn(&Arc<Context>, &Value) -> Result<FiberId, CordisError>) is inventory-collected; cordis::register_inventory_factories(reg) installs every submission and is the server's primary boot path (src/main.rs). The hand-written register_plugins chains remain compiled as the --no-default-features fallback and for tests. Each capability crate declares an optional inventory feature forwarded from root/facade; because inventory nodes live in linker sections, a crate with no otherwise-referenced code loses its submissions — tests force linkage by calling the manual chains before collecting. Parity proofs: crates/ares/tests/inventory_parity.rs (library crates) and tests/server_inventory_probe.…

Admin surface

  • POST /admin/cordis/services/{name}/retire / provide — runtime retire/re-provide.

  • GET|PUT /admin/cordis/entries, DELETE /admin/cordis/entries/{id}, POST /admin/cordis/entries/{id}/toggle — declarative entries management (Null configs normalize to {}), 503 when loader state is absent.

  • PATCH /admin/cordis/entries/{id} — typed partial update via cordis::loader::EntryUpdate (config, disabled, isolate, intercept; id/plugin deliberately not patchable). Only present fields change, {} is a validated no-op that still persists and re-applies; replies with the post-patch entry and per-action outcomes, 404 on unknown ids. A failed config pre-flight attaches a machine-readable issues array ([{message, path}, …]) beside the legacy error string.

  • POST /admin/cordis/entries/reload — reload from disk through the shared apply flow.

  • GET /admin/cordis/events — per-event dispatch counters {total_dispatched, by_event} from EventsService::dispatch_snapshot() (counts every mode via the single dispatch choke point).

Atomic entries persistence

EntryTree::save_to_toml_file writes <name>.tmp-<pid> then renames over the target (atomic on POSIX), preserving leading comment headers verbatim; the temp file is removed on failure so crashes mid-write leave either the previous or the new file, never a truncated one.

HMR resolution

The §10/§11 "defer" decision above is superseded. The dylib path is finished and correct, still opt-in: apply_plugin_so copies the library to a process-unique sibling (<stem>.<pid>.<seq>.hmr-load) before dlopen — glibc caches handles by path, so a rebuilt .so never swaps without this copy — and HmrLibrary::drop removes the copy after dlclose (best effort). Watcher hook apply_plugin_so_if_dylib benefits automatically. Production reload stays watcher + TOML reconcile; enable dylib loading with --features hmr only with same-toolchain cdylibs.


16. Round 5–6 (0.9.x): policy activation, effect removal, lifecycle hardening, composition

Round 5 — RhaiPolicy production activation

RhaiListenerConfig gained on_error: passthrough | deny (serde default = passthrough, byte-compatible with round-4 configs). deny is fail-closed: a flat-listener script error returns the built-in deny-marker shape instead of the payload; a waterfall-listener error vetoes without calling next. Multi-instance policies must use isolate realms (a pass-through handler on a shared Bail chain terminates every dispatch); each realm keeps its own EventsService. config/cordis-entries.toml ships an ACTIVE policy-admission-audit entry logging scheduler.admit agent names. The paper's witnessed-effect primitive (Effect, EffectGuard, Context::effect) was DELETED in round 5 — zero users ever; revertibility lives entirely in Disposable + Fiber LIFO a…

Round 6 — Guarded withdrawal (§4.3.1 reliedₙ)

RegistryService::reliance_count(&(TypeId, label)) derives (never seeds) how many ACTIVE fibers other than the provider resolve a key at its isolate label and declare an inject on it — always current under late declare_inject, no stale rows. A per-fiber realms ledger (FiberId -> Weak<Context>) captured at registration supplies context for label resolution. Context::remove<T> refuses while consumers remain ("guarded withdrawal: N active consumer(s)…"); internal rollback uses remove_forced (undo never blocks). Admin retire maps refusal to 409 {retired:false, reason:"guarded", consumers:N}.

Round 6 — Verified hot-swap

LoaderAction::RebuildFiber applies the new plugin OUT-OF-BAND on a scratch child context first: failure ⇒ old provider untouched. Success bridges via intercept (new values win instantly), disposes old while the bridge covers stale store rows, promotes peeked values store-first, then drops the bridge — get::<T> resolves at every instant (test probes from a concurrent task). Falls back to classic dispose+instantiate (reported verified=false) for untracked old fibers, isolated entries, or side-effectful factories (Store migrations run twice across trial+promotion). AppliedAction carries verified through admin PUT responses.

Round 6 — Typed listeners adopted

Scheduler's three prod listeners (agent.completed observability, agent.failed runtime-control, legacy shim) use on_typed::<AgentCompletedEvent/AgentFailedEvent>. rhai_service intentionally stays on the raw Value API (dynamic script maps are its purpose).

Round 6 — Dependency-cycle detection

cordis::cycles::{find_dependency_cycle, DependencyGraph} — colored-DFS over the fiber inject graph with deterministic node ordering; CycleLedger maps (TypeId,label)→provider-fiber and entry-id↔fiber so loader-side callers can reconstruct edges without registry internals. Unit-tested for self-loops, 2/3-cycles, nested-behind-prefix, disconnected components, cross-edge false positives.

Round 6 — Intercept meta-events, readiness barriers, cascade batching, logger, timers

Intercept meta-events + *_from dispatch. Five veto points (internal/get, internal/set, internal/config, internal/update, internal/listener) plus the internal/dispatch observer (see §4) ride EventsService::bail_from; product code gets the same chains through bail_from / waterfall_from / waterfall_async_from with an optional per-dispatch ListenerFilter. Veto semantics: a config-rewrite terminal becomes the effective config for that apply pass; an erroring config chain rests the fiber Failed; an update veto skips the restart and parks the deferred config in Fiber::vetoed_config; a listener bail cancels registration and returns an inert handle. Every gate is zero-cost when unregistered, and the synchronous bridges fall open on single-thread tokio flavors.

ReadinessBarrier. ReadinessBarrier::new(pred) + .and(..) / with_readiness([...]) + .watching([TypeId]), installed via RegistryService::register_with_readiness (see §5): closed gates hold fibers at inspectable Pending and re-kick through the ReflectService fan-out; availability predicates stay the loud Failed path.

Cascade batching. An in-flight ledger around loader re-applies collapses concurrent provider config updates to ONE dependent convergence wave per settled batch (CASCADE_INFLIGHT, consulted from the kernel refresh path).

LoggerService (cordis::logger). Bounded ring (default 1000) of Messages; effect-owned Exporter sinks (registration returns the removing Disposable) with per-sink ExporterConfig (per-name levels, max_length truncation); per-name thresholds via set_level with set_default_level fallback; zero-cost enabled gate before argument assembly; printf rendering %s %d %i %f %o %O %c %C %% with ANSI16 name-hashed colors (%c) and bold (%C); hyphenate/derived_name kebab-case logger names; LoggerIntercept per-fiber threshold overrides resolved through the relaxed read channel; Context::log/info/warn/debug/error facade no-ops when no logger is provided.

Timers (cordis::timer). Six primitives — timeout, sleep, interval, interval_stream, debounce, throttle — std-only, fiber-scoped via with_current_fiber labeled undos, running on one shared wheel thread; dispose yields exactly one final Err(InactiveEffect) on streams then closes them; dropping handles never cancels.


17. Round 7 (0.9.x): wiring composition and detection, drain-and-shift

Cycle detection wired

Loader::apply now reconstructs the post-apply inject graph via the CycleLedger (fresh-provide diff at instantiate_entry records providers; fiber.injected_type_ids() + ctx.isolate_label() resolve edges) and runs find_dependency_cycle. Detection never fails the apply — a found ring logs a warning naming entry ids; Loader::detect_cycle_entry_ids(&ctx) queries it, and GET /admin/cordis/entries carries an additive dependency_cycles key (entry-id rings, empty when healthy).

Composition wired at boot + reload

Both entry-load sites (boot_loader_program parse, watcher/poll reload via reload_current(ctx, path, &mut current, desired_composed, &journal) — callers own parsing+composition now) run cordis::compose_all (@include splice → @group flatten → ${rhai: …} interpolation) with the entries file's parent as base dir. Fail-open: composition errors log loudly naming path+error and proceed with the RAW entries. Interpolation scope variable is entry (rhai reserves $). Commented examples live in config/cordis-entries.toml.

Drain-and-shift provider replacement

Loader::replace_provider(ctx, plugin_name, config, journal): trial new provider out-of-band → intercept bridge → dispose old → promote store-first → fresh Active fiber journaled. Zero absence window (concurrent-get probe test). Shared tail extracted into SwapPromotion used by both verified rebuild and replace; fixing it closed a latent double-swap bug (promoted values had no undo, blocking any second swap). replace_provider bypasses Context::remove's guard legitimately — the bridge guarantees continuous resolution, which is exactly what the guard protects; genuine retire keeps the guarded path. Root-realm only; no dispose-then-rebuild fallback.


18. Round 8 (0.9.x): composed program, operational replace, metatheory smoke

Entries program split (composition load-bearing)

The production config/cordis-entries.toml now exercises the round-6 compose pipeline for real: events/overlay/store inline at the head, tools..probe spliced verbatim from config/cordis-entries-shared.toml via @include, http inline. Parity-proven: simulated composition of the new layout deep-equals the old flat 15-entry program, boot order unchanged. A second ACTIVE policy joined the program: policy-emergency-gate (isolate policy-emergency) denies any scheduled agent named EMERGENCY-HALT with on_error="deny" — a fail-closed kill-switch; policy-admission-audit remains last.

Operational provider replacement

POST /admin/cordis/services/{name}/replace (body {"config": <Value>}) exposes round-7's Loader::replace_provider: trial out-of-band → bridge → dispose old → promote → fresh fiber journaled. 200 {replaced:true, plugin, fiber_id} on success; 409 {replaced:false, reason} when replacement is refused (unknown label, untracked/isolated/failing trial — old provider untouched by design); 400 malformed body; 503 without journal. Providers can now be swapped under live traffic from outside the process.

Metatheory property smoke tests

cordis::metatheory encodes paper guarantees as executable checks over the public API:

  • quiescence_after_every_op (Thm 66 progress): 24-op deterministic schedule; no fiber rests in transitional states; Active fibers always have injects available. HELD — inertia keeps transitions unobservable at rest.
  • order_confluence_of_registrations (Cor 21 / Thm 73): consumer-first vs providers-first converge to identical epochs and projections. HELD.
  • dependent_never_active_without_provider (§spatial reactive invariant): HELD with two documented deltas — (1) a declare_inject can land on an Active fiber outside the state machine until refresh; (2) factories whose apply() errors become Failed without ReflectService wiring, where the paper expects permanently-Inactive dependents. Both are future-round deliverables.
  • lifo_dispose_restores_store (Thm 16): exact LIFO undo order, disposables fire exactly once, store restored.

19. Round 9 (0.9.x): eager reconciliation, failed-factory wiring, peer versioning

Metatheory deltas resolved (round-8 follow-ups)

Both documented deltas are now resolved behavior:

  1. Eager declaration: declare_inject on a fiber resting Active reconciles immediately — inertia try-lock fast path updates epoch+state in place (satisfied) or drives the refresh transition to Inactive{missing-dependency} (unsatisfied). A pending_declare flag folded into refresh's recompute loop makes declare-vs-refresh races lossless.
  2. Failed-factory wiring: both RegistryService::register failure paths wire the fiber into ReflectService (register_fiber + notify on the type key), so dependents observe provider-loss and rest Inactive at quiescence. Failed{error} remains the terminal visible state (deliberate divergence from the paper's permanently-Inactive expectation — operationally more useful); the provided slot stays vacant and re-registration supersedes with a fresh fiber id.

Bonus fix: a latent deadlock in register itself — an if-let scrutinee held the provided-map read guard across stale-slot cleanup that took the write lock. Exposed by metatheory leg F; fixed by deciding staleness under the read guard before any write.

Peer-dependency versioning

Addresses the paper's open problem (§discussion):

  • Provide side: Context::provide_versioned<T>(value, version); legacy provide() = version 0. Versions live in a parallel map with LIFO-undo restore (disposal restores the prior version exactly).
  • Inject side: Fiber::declare_inject_versioned<T>(min_compatible: Option<u64>). Satisfaction rule: provider exists AND major(provider) == major(requirement) AND provider >= requirement, where major(v) = v / 100_000. Mismatch ⇒ dependent stays Inactive — never binds incompatible versions.
  • Reactivity: constrained epoch fragments fold in "v@:", so same-major upgrades flip the epoch and reactively reactivate dependents (test: v200_001 → Inactive → compatible v100_005 re-provide → Active with new projection). Unconstrained fragments byte-identical to the prior scheme.
  • Deliberate open point kept: structural interface compatibility (paper's full problem) is NOT attempted — majors-only buckets with explicit floors cover the practical case; deeper structural checks remain future work.

Status vs paper guarantees

Quiescence (Thm 66): held. Order confluence (Cor 21/Thm 73): held. LIFO disposal (Thm 16): held. Reactive invariant (§spatial): now fully held — eager declarations close the last observable gap. Terminal-state divergence (Failed vs Inactive) is documented and deliberate.

20. Round 10 (0.9.x): accessors, layered chains, lifecycle riders, module graph, entry moves

Accessor registry (name-keyed computed properties)

Context::register_accessor(name, Accessor::{read_only, read_write, setter_only}) installs a computed property beside the TypeId service store and returns an EffectHandle whose disposal removes the declaration AND every alias. Context::alias(alias, target) binds an alternate name through the same registration slot; duplicates (including alias collisions) are rejected with DuplicateProvider. Typed reads surface PropertyTypeMismatch instead of a silent None; writes against a read-only property are refused with ReadOnlyProperty. Crucially, accessor traffic BYPASSES the internal/get / internal/set intercept waterfalls entirely — resolving an accessor never consults or re-enters a veto chain (accessors are policy plumbing, not provider state). Anchors: crates/cordis/src/context.rs.

Layered intercept chains

Intercept layers per TypeId are an ordered outermost..innermost sequence; NEW registrations APPEND rather than replace, so the innermost layer stays effective for all existing getters — no caller breakage when another policy layer joins. Context::intercept_chain(tid) returns every layer in dispatch order; Context::chains_structurally_equal(a, b) compares two chains by shared-instance identity (Arc::ptr_eq per layer pair) — erased values carry no comparable contract, so freshly-built values compare unequal by design, which is the honest test for restart decisions.

Lifecycle riders

Fiber::update now returns Result<(), CordisError>: a restart-path error propagates to the caller while the fiber stays Active serving its OLD configuration (effects never unwind on a failed restart). An internal/update veto parks the deferred config in Fiber::vetoed_config() and returns Ok — the skip is observable without being destructive. The internal/config waterfall consult now also covers the ACTIVATION path in the registry, so config rewrites apply on first activation identically to re-applies.

Module graph fan-out (opt-in)

cordis::module_graph::ModuleGraph maps module keys to their dependencies. With a ModuleReload implementation installed (with_reloader / set_reloader), change_many(ctx, keys) runs in two phases: phase 1 computes the TRANSITIVE affected plugin set READ-ONLY across the dependency closure; phase 2 reloads each affected plugin EXACTLY ONCE per transaction through ModuleReload, classified into a ChangeOutcome. A failing reload rolls back that plugin only — successfully reloaded siblings stay Active. The file watcher's debounced batch fans through a registered ModuleGraph when one is provided on the context; WITHOUT one, watcher behavior is byte-identical to before. Anchors: crates/cordis/src/module_graph.rs, wiring in crates/cordis/src/watcher.rs.

Entry moves (move_entry surface)

Entries gain hierarchy: EntryPosition { parent, position } rides on Entry. PATCH /admin/cordis/entries/{id} accepts optional parent / position applied move-THEN-update — an invalid placement (e.g. moving an entry into its own descendant) answers 409 before ANY mutation of file or live tree. POST /admin/cordis/entries/{id}/move relocates an entry together with its whole {id}:* subtree in one rename cascade. A valid move preserves fiber identity: the live instance refreshes IN PLACE under the new key, so consumers never observe a dispose/recreate window. Disabled groups move suppressed-then-restored. Anchors: EntryPosition, EntryTree::move_entry, subtree_ids, Loader::move_entry in crates/cordis/src/loader.rs; handlers in crates/ares-http/src/api/handlers/admin/cordis.rs.