ares-server 0.10.0

ARES agent server with multi-provider LLM support, tool calling, RAG, and MCP integration
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
# 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`):

| Path | Package | Rust crate |
|------|---------|------------|
| `crates/cordis` | `ares-cordis` | `cordis` |
| `crates/ares-agent` | `ares-agent` | `ares_agent` |
| `crates/ares-store` | `ares-store` | `ares_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:

```rust
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:

```rust
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
```rust
#[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):

```rust
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:
 ```rust
  // 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
```rust
#[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:
```rust
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:
```rust
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:

```rust
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:

```rust
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`.

```rust
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:

```rust
#[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`):

```rust
#[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`):

```rust
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 `dlopen`s `.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:

```toml
[[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 `Message`s; 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<major>@<floor>:<version>", 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`.