| 1 | # Spatiotemporal Composability (Plugin/Runtime v2) |
| 2 | |
| 3 | ## Context |
| 4 | |
| 5 | Reasonix already has a solid extension kernel: `Contribution → RuntimeSnapshot`, |
| 6 | `RuntimeSet` reverse/idempotent cleanup with generation guards, Builder freeze |
| 7 | and activate stages, and `Rebuild` that keeps the old controller on failure. |
| 8 | |
| 9 | This design ports four established runtime-composition mechanisms into that |
| 10 | kernel without adopting an external runtime or changing the implementation |
| 11 | language: |
| 12 | |
| 13 | 1. Unified runtime effect scopes |
| 14 | 2. Typed, reactive dependency graph |
| 15 | 3. Generation / epoch component lifecycle |
| 16 | 4. Verifiable atomic reload (build → activate → publish → drain) |
| 17 | |
| 18 | ## Fixed decisions |
| 19 | |
| 20 | - Upgrade native plugins to `reasonix.io/plugin/v2` |
| 21 | - Reject v1 and legacy native manifests (no apiVersion) at install, doctor, boot |
| 22 | - Keep existing stdio/HTTP transport; do not rewrite the transport layer |
| 23 | - Do not introduce an external runtime dependency |
| 24 | - Irreversible external work uses effect receipts; never fake rollback |
| 25 | - Receipt recovery is process-local, bounded, and does not promise crash recovery |
| 26 | - Preserve system prompt, tool schema, and memory prefix cache-first constraints |
| 27 | - Claude compatibility plugin paths are out of scope for v2 native runtime changes |
| 28 | |
| 29 | ## Lifecycle |
| 30 | |
| 31 | Component states: |
| 32 | |
| 33 | ```text |
| 34 | Inactive → Preparing → Active → Draining → Inactive |
| 35 | Failed (activation or cleanup failure; retains error + receipts) |
| 36 | ``` |
| 37 | |
| 38 | Component granularity for v1 of this design: one native v2 sidecar runtime |
| 39 | package is one component node. Host-owned providers, interceptors, and UI |
| 40 | bindings are child contributions of that node. Individual tools are not |
| 41 | independent lifecycle nodes yet. |
| 42 | |
| 43 | ## EffectScope |
| 44 | |
| 45 | `RuntimeSnapshot` stays immutable configuration. Live handles live only in an |
| 46 | `EffectScope` (default implementation held by `RuntimeSet`): |
| 47 | |
| 48 | | Class | Meaning | |
| 49 | | --- | --- | |
| 50 | | Reversible | Dispose undoes the effect | |
| 51 | | Cancelable | Cancel stops further work; wait for completion on dispose | |
| 52 | | Compensatable | Dispose may run a compensate function | |
| 53 | | Irreversible | Record receipt only; never claim rollback success | |
| 54 | |
| 55 | Rules: |
| 56 | |
| 57 | - Each effect runs dispose at most once |
| 58 | - Within a component: reverse registration order |
| 59 | - Across components: reverse topological drain order |
| 60 | - Independent components may activate/clean in parallel |
| 61 | - Mid-activation failure auto-disposes already tracked effects |
| 62 | - Dispose waits for cancelable background work |
| 63 | - Cleanup errors become diagnostics; they never skip remaining dispose |
| 64 | |
| 65 | Resources that must track through EffectScope: sidecar process, MCP |
| 66 | client/transport, provider stream, watcher, event subscription, UI hub binding, |
| 67 | goroutine/background job, session temporary resources, controller cleanup |
| 68 | callback. |
| 69 | |
| 70 | ## Snapshot / plan / controller boundary |
| 71 | |
| 72 | ```text |
| 73 | RuntimeSnapshot = immutable configuration and dependency view |
| 74 | RuntimeSet = live resources owned by one generation (EffectScope) |
| 75 | RuntimePlan = transition from old snapshot to new snapshot |
| 76 | Controller = owner of the published generation |
| 77 | ``` |
| 78 | |
| 79 | Live handles (process, connection, watcher, callback, goroutine) must never |
| 80 | enter `RuntimeSnapshot`. |
| 81 | |
| 82 | ## Capability contract |
| 83 | |
| 84 | `internal/extensioncontract` is a leaf package shared by `pluginpkg` and |
| 85 | `internal/extension`: |
| 86 | |
| 87 | - `CapabilityKey{Namespace, Kind, ID}` — always namespaced |
| 88 | - `Capability{Key, Version, SchemaHash}` — stable schema hash required for |
| 89 | provider/tool/UI capabilities |
| 90 | - `Requirement` extends capability with `VersionRange` and `Optional` |
| 91 | |
| 92 | Versions use `golang.org/x/mod/semver`. Key, kind, version, and schema hash |
| 93 | participate in canonical hashing. |
| 94 | |
| 95 | ## Manifest v2 |
| 96 | |
| 97 | Native `reasonix-plugin.json` requires: |
| 98 | |
| 99 | ```json |
| 100 | { |
| 101 | "apiVersion": "reasonix.io/plugin/v2", |
| 102 | "name": "example", |
| 103 | "version": "2.0.0", |
| 104 | "requires": [...], |
| 105 | "provides": [...], |
| 106 | "runtime": { "command": "...", "intercepts": [], "replaces": [], "capabilities": [] } |
| 107 | } |
| 108 | ``` |
| 109 | |
| 110 | Validation: |
| 111 | |
| 112 | - Missing / v1 / unknown major apiVersion → hard reject |
| 113 | - `provides` is the capability ceiling for handshake |
| 114 | - Handshake must not declare undeclared capabilities |
| 115 | - Declared-but-missing capabilities become `Unavailable` (no forge) |
| 116 | - Non-optional missing requirements keep component `Inactive` |
| 117 | - Optional missing requirements allow activation with diagnostics |
| 118 | - Required dependency cycles fail with full cycle path |
| 119 | - Multiple providers for the same `(namespace, kind, id)` need explicit |
| 120 | selection or report conflict |
| 121 | - Replacement slots stay single-owner; interceptors stay additive by priority |
| 122 | |
| 123 | Claude/Codex compatibility manifests are unchanged. |
| 124 | |
| 125 | ## Dependency graph and RuntimePlan |
| 126 | |
| 127 | Build flow: |
| 128 | |
| 129 | ```text |
| 130 | Discover → Parse → Validate → Resolve capabilities → Validate versions/schema |
| 131 | → Detect cycles → Topological activate order → Reverse drain order |
| 132 | ``` |
| 133 | |
| 134 | Deterministic sort keys: dependency rank, scope rank, priority, canonical |
| 135 | component ID. |
| 136 | |
| 137 | Epoch identity for a consumer: |
| 138 | |
| 139 | ```text |
| 140 | epoch = [capability key, provider component ID, provider version, provider schema hash] |
| 141 | ``` |
| 142 | |
| 143 | Only epoch changes force consumer reload. |
| 144 | |
| 145 | `RuntimePlan` carries Added / Removed / Reloaded / Unchanged plus ActivateOrder |
| 146 | and DrainOrder. Diagnostics record `PrefixChanged` only after comparing the old |
| 147 | and new snapshot `CacheHash`, while `ProviderChanged` records provider capability |
| 148 | add/remove/reload independently. No-op plans must not change `CacheHash`. |
| 149 | Changes should affect only the relevant subgraph (provider, MCP server, |
| 150 | interceptor chain, UI hub). |
| 151 | |
| 152 | ## Atomic publish / drain |
| 153 | |
| 154 | ```text |
| 155 | Discover → ResolveGraph → CreatePlan → Preflight |
| 156 | → ActivateNewGeneration → AwaitReady → PublishController → DrainOldGeneration |
| 157 | ``` |
| 158 | |
| 159 | - Preflight failure: old runtime untouched |
| 160 | - New activation failure: dispose entire new generation scope |
| 161 | - Do not publish until new runtime is Active |
| 162 | - Publish is a single atomic pointer swap |
| 163 | - After publish: old controller Draining (no new turns; finish in-flight) |
| 164 | - Drain timeout cancels remainder and records receipts |
| 165 | - Stale generation UI / provider stream / event output is silently dropped |
| 166 | |
| 167 | ## Effect receipts |
| 168 | |
| 169 | Irreversible and compensatable effects record owner, generation, component, |
| 170 | class, timestamps, receipt id, and compensation status. Provider requests |
| 171 | already submitted must not be reported as rolled back. File writes need prior |
| 172 | state or compensate. Sent messages get receipts and duplicate-send protection. |
| 173 | Cancellation stops later work only. |
| 174 | |
| 175 | The ledger retains at most 32 generations and 256 receipts per generation. |
| 176 | Eviction marks recovery evidence incomplete, releases associated file priors, |
| 177 | and prevents a clean-rollback claim. The ledger is not persisted; process-crash |
| 178 | recovery is explicitly outside this design's scope. |
| 179 | |
| 180 | ## Permissions |
| 181 | |
| 182 | The dependency graph answers what capabilities a component may obtain. It does |
| 183 | not replace the sandbox: trusted host components, native sidecars (process |
| 184 | boundary), and untrusted plugins still face permission interceptors at call |
| 185 | time. |
| 186 | |
| 187 | ## Error reasons (extension protocol) |
| 188 | |
| 189 | In addition to the existing frozen table, v2 adds: |
| 190 | |
| 191 | - `dependency_unsatisfied` |
| 192 | - `dependency_cycle` |
| 193 | - `schema_mismatch` |
| 194 | - `activation_failed` |
| 195 | - `stale_generation` |
| 196 | - `cleanup_failed` |
| 197 | |
| 198 | (`unsupported_version` already exists.) |
| 199 | |
| 200 | ## Migration |
| 201 | |
| 202 | - `reasonix plugin migrate <name> --to-v2` rewrites only pre-extension native |
| 203 | manifests that omit `apiVersion`, backs up the original, and errors on |
| 204 | dependencies it cannot infer. It does not accept Manifest v1. |
| 205 | - `reasonix plugin doctor` reports dependency and protocol errors |
| 206 | - v1 / missing apiVersion native manifests fail install, doctor, and boot |
| 207 | |
| 208 | ## Acceptance |
| 209 | |
| 210 | 1. Every v2 component resource has a clear owner and EffectScope |
| 211 | 2. Activation failure never leaks new-generation resources |
| 212 | 3. Missing dependencies become diagnostic Inactive, never panics |
| 213 | 4. Dependency replacement reloads only the affected subgraph |
| 214 | 5. Publish/drain order is testable |
| 215 | 6. Irreversible work is never marked rollback-success |
| 216 | 7. v1 native manifests are rejected without dual-read or migration |
| 217 | 8. Prompt/tool cache stability guards pass |
| 218 | 9. Doctor explains why a component is inactive |
| 219 | 10. No external runtime or language dependency |
| 220 | |
| 221 | ## Implementation status (repo) |
| 222 | |
| 223 | | Area | Status | |
| 224 | | --- | --- | |
| 225 | | EffectScope / LiveScope / RuntimeSet | Done | |
| 226 | | extensioncontract + DependencyGraph + RuntimePlan | Done | |
| 227 | | Manifest `reasonix.io/plugin/v2` + reject v1 | Done | |
| 228 | | Extension Protocol major v2 + schema/SDK gen | Done | |
| 229 | | PublishGate + stale UI/provider chunk drop | Done | |
| 230 | | Sidecar incremental adopt (StartPackagesWithPlan) | Done | |
| 231 | | RebuildFrom + desktop/CLI lastBuildResult | Done | |
| 232 | | MCP Host ReplaceServerBackend stable proxy | Done | |
| 233 | | Provider liveClient re-resolve | Done | |
| 234 | | LifecycleRegistry + FormatRuntimeStatus | Done | |
| 235 | | Manifest provides ceiling on handshake | Done | |
| 236 | | Full subgraph-only BuildRuntime (skip tools/prompt) | Done: plan classify + ReuseAssembly rediscovery skip + CacheHash reuse | |
| 237 | | Controller admission on PublishGate | Done (`turnDroppedDraining`) | |
| 238 | | Lifecycle metrics + graph/plan benchmarks | Done (soft CI baselines) | |
| 239 | | EffectScope for every MCP/watcher/UI resource | Done (activator + session-resources fold + Track* helpers) | |
| 240 | | Receipt-driven recovery / checkpoint | Done (`DecideResume` + doctor/desktop + provider-submit receipts) | |
| 241 | | Drain timeout product path | Done (`ScheduleDrainWatch` / `SweepAndForceExpire`) | |
| 242 | | Doctor runtime + desktop RuntimeDoctor | Done | |
| 243 | | Integration matrix (rapid reload, provider classify, admission) | Done | |
| 244 |