orbit-agent-runtime 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +750 -0
  2. package/LICENSE +201 -0
  3. package/README.md +492 -0
  4. package/README.zh-CN.md +389 -0
  5. package/benchmarks/README.md +24 -0
  6. package/benchmarks/gateway.mjs +68 -0
  7. package/benchmarks/pae.mjs +90 -0
  8. package/benchmarks/replay.mjs +51 -0
  9. package/benchmarks/run-all.mjs +24 -0
  10. package/benchmarks/wal.mjs +50 -0
  11. package/bin/orbit.mjs +523 -0
  12. package/dist/.tsbuildinfo +1 -0
  13. package/dist/demo-deepseek.d.ts +2 -0
  14. package/dist/demo-deepseek.d.ts.map +1 -0
  15. package/dist/demo-deepseek.js +66 -0
  16. package/dist/demo-deepseek.js.map +1 -0
  17. package/dist/demo-host.d.ts +2 -0
  18. package/dist/demo-host.d.ts.map +1 -0
  19. package/dist/demo-host.js +98 -0
  20. package/dist/demo-host.js.map +1 -0
  21. package/dist/demo-replay.d.ts +2 -0
  22. package/dist/demo-replay.d.ts.map +1 -0
  23. package/dist/demo-replay.js +68 -0
  24. package/dist/demo-replay.js.map +1 -0
  25. package/dist/src/core/orbitRuntimeHost.d.ts +349 -0
  26. package/dist/src/core/orbitRuntimeHost.d.ts.map +1 -0
  27. package/dist/src/core/orbitRuntimeHost.js +706 -0
  28. package/dist/src/core/orbitRuntimeHost.js.map +1 -0
  29. package/dist/src/index.d.ts +92 -0
  30. package/dist/src/index.d.ts.map +1 -0
  31. package/dist/src/index.js +226 -0
  32. package/dist/src/index.js.map +1 -0
  33. package/dist/test/audit_chain.test.d.ts +2 -0
  34. package/dist/test/audit_chain.test.d.ts.map +1 -0
  35. package/dist/test/audit_chain.test.js +283 -0
  36. package/dist/test/audit_chain.test.js.map +1 -0
  37. package/dist/test/channel_hub.test.d.ts +2 -0
  38. package/dist/test/channel_hub.test.d.ts.map +1 -0
  39. package/dist/test/channel_hub.test.js +66 -0
  40. package/dist/test/channel_hub.test.js.map +1 -0
  41. package/dist/test/cordis_adapter.test.d.ts +2 -0
  42. package/dist/test/cordis_adapter.test.d.ts.map +1 -0
  43. package/dist/test/cordis_adapter.test.js +280 -0
  44. package/dist/test/cordis_adapter.test.js.map +1 -0
  45. package/dist/test/cost_routing.test.d.ts +2 -0
  46. package/dist/test/cost_routing.test.d.ts.map +1 -0
  47. package/dist/test/cost_routing.test.js +31 -0
  48. package/dist/test/cost_routing.test.js.map +1 -0
  49. package/dist/test/domain_transaction.test.d.ts +2 -0
  50. package/dist/test/domain_transaction.test.d.ts.map +1 -0
  51. package/dist/test/domain_transaction.test.js +351 -0
  52. package/dist/test/domain_transaction.test.js.map +1 -0
  53. package/dist/test/file_channel.test.d.ts +2 -0
  54. package/dist/test/file_channel.test.d.ts.map +1 -0
  55. package/dist/test/file_channel.test.js +179 -0
  56. package/dist/test/file_channel.test.js.map +1 -0
  57. package/dist/test/gateway.test.d.ts +2 -0
  58. package/dist/test/gateway.test.d.ts.map +1 -0
  59. package/dist/test/gateway.test.js +548 -0
  60. package/dist/test/gateway.test.js.map +1 -0
  61. package/dist/test/governance_profile.test.d.ts +2 -0
  62. package/dist/test/governance_profile.test.d.ts.map +1 -0
  63. package/dist/test/governance_profile.test.js +214 -0
  64. package/dist/test/governance_profile.test.js.map +1 -0
  65. package/dist/test/governance_schema.test.d.ts +2 -0
  66. package/dist/test/governance_schema.test.d.ts.map +1 -0
  67. package/dist/test/governance_schema.test.js +200 -0
  68. package/dist/test/governance_schema.test.js.map +1 -0
  69. package/dist/test/host_integration.test.d.ts +2 -0
  70. package/dist/test/host_integration.test.d.ts.map +1 -0
  71. package/dist/test/host_integration.test.js +104 -0
  72. package/dist/test/host_integration.test.js.map +1 -0
  73. package/dist/test/host_journal_persistence.test.d.ts +2 -0
  74. package/dist/test/host_journal_persistence.test.d.ts.map +1 -0
  75. package/dist/test/host_journal_persistence.test.js +367 -0
  76. package/dist/test/host_journal_persistence.test.js.map +1 -0
  77. package/dist/test/impact_domain.test.d.ts +2 -0
  78. package/dist/test/impact_domain.test.d.ts.map +1 -0
  79. package/dist/test/impact_domain.test.js +48 -0
  80. package/dist/test/impact_domain.test.js.map +1 -0
  81. package/dist/test/injected_clock.test.d.ts +13 -0
  82. package/dist/test/injected_clock.test.d.ts.map +1 -0
  83. package/dist/test/injected_clock.test.js +200 -0
  84. package/dist/test/injected_clock.test.js.map +1 -0
  85. package/dist/test/isolation_domain.test.d.ts +2 -0
  86. package/dist/test/isolation_domain.test.d.ts.map +1 -0
  87. package/dist/test/isolation_domain.test.js +302 -0
  88. package/dist/test/isolation_domain.test.js.map +1 -0
  89. package/dist/test/journal_wal.test.d.ts +2 -0
  90. package/dist/test/journal_wal.test.d.ts.map +1 -0
  91. package/dist/test/journal_wal.test.js +622 -0
  92. package/dist/test/journal_wal.test.js.map +1 -0
  93. package/dist/test/mcp_adapter.test.d.ts +2 -0
  94. package/dist/test/mcp_adapter.test.d.ts.map +1 -0
  95. package/dist/test/mcp_adapter.test.js +440 -0
  96. package/dist/test/mcp_adapter.test.js.map +1 -0
  97. package/dist/test/openai_compat_channel.test.d.ts +2 -0
  98. package/dist/test/openai_compat_channel.test.d.ts.map +1 -0
  99. package/dist/test/openai_compat_channel.test.js +325 -0
  100. package/dist/test/openai_compat_channel.test.js.map +1 -0
  101. package/dist/test/openapi_adapter.test.d.ts +2 -0
  102. package/dist/test/openapi_adapter.test.d.ts.map +1 -0
  103. package/dist/test/openapi_adapter.test.js +270 -0
  104. package/dist/test/openapi_adapter.test.js.map +1 -0
  105. package/dist/test/orbit_cli.test.d.ts +2 -0
  106. package/dist/test/orbit_cli.test.d.ts.map +1 -0
  107. package/dist/test/orbit_cli.test.js +137 -0
  108. package/dist/test/orbit_cli.test.js.map +1 -0
  109. package/dist/test/pact_verifier.test.d.ts +2 -0
  110. package/dist/test/pact_verifier.test.d.ts.map +1 -0
  111. package/dist/test/pact_verifier.test.js +41 -0
  112. package/dist/test/pact_verifier.test.js.map +1 -0
  113. package/dist/test/pae_adapter.test.d.ts +2 -0
  114. package/dist/test/pae_adapter.test.d.ts.map +1 -0
  115. package/dist/test/pae_adapter.test.js +469 -0
  116. package/dist/test/pae_adapter.test.js.map +1 -0
  117. package/dist/test/plugin_sandbox_guard.test.d.ts +2 -0
  118. package/dist/test/plugin_sandbox_guard.test.d.ts.map +1 -0
  119. package/dist/test/plugin_sandbox_guard.test.js +57 -0
  120. package/dist/test/plugin_sandbox_guard.test.js.map +1 -0
  121. package/dist/test/rate_limit_behavior.test.d.ts +2 -0
  122. package/dist/test/rate_limit_behavior.test.d.ts.map +1 -0
  123. package/dist/test/rate_limit_behavior.test.js +85 -0
  124. package/dist/test/rate_limit_behavior.test.js.map +1 -0
  125. package/dist/test/replay_compat.test.d.ts +2 -0
  126. package/dist/test/replay_compat.test.d.ts.map +1 -0
  127. package/dist/test/replay_compat.test.js +1122 -0
  128. package/dist/test/replay_compat.test.js.map +1 -0
  129. package/dist/test/replay_engine.test.d.ts +2 -0
  130. package/dist/test/replay_engine.test.d.ts.map +1 -0
  131. package/dist/test/replay_engine.test.js +85 -0
  132. package/dist/test/replay_engine.test.js.map +1 -0
  133. package/dist/test/shell_channel.test.d.ts +2 -0
  134. package/dist/test/shell_channel.test.d.ts.map +1 -0
  135. package/dist/test/shell_channel.test.js +173 -0
  136. package/dist/test/shell_channel.test.js.map +1 -0
  137. package/dist/test/teardown_isolation.test.d.ts +10 -0
  138. package/dist/test/teardown_isolation.test.d.ts.map +1 -0
  139. package/dist/test/teardown_isolation.test.js +279 -0
  140. package/dist/test/teardown_isolation.test.js.map +1 -0
  141. package/dist/test/token_budget.test.d.ts +2 -0
  142. package/dist/test/token_budget.test.d.ts.map +1 -0
  143. package/dist/test/token_budget.test.js +113 -0
  144. package/dist/test/token_budget.test.js.map +1 -0
  145. package/dist/test/trace_journal.test.d.ts +2 -0
  146. package/dist/test/trace_journal.test.d.ts.map +1 -0
  147. package/dist/test/trace_journal.test.js +43 -0
  148. package/dist/test/trace_journal.test.js.map +1 -0
  149. package/dist/test/trace_journal_persistence.test.d.ts +2 -0
  150. package/dist/test/trace_journal_persistence.test.d.ts.map +1 -0
  151. package/dist/test/trace_journal_persistence.test.js +175 -0
  152. package/dist/test/trace_journal_persistence.test.js.map +1 -0
  153. package/dist/test/trace_persistence.test.d.ts +2 -0
  154. package/dist/test/trace_persistence.test.d.ts.map +1 -0
  155. package/dist/test/trace_persistence.test.js +216 -0
  156. package/dist/test/trace_persistence.test.js.map +1 -0
  157. package/dist/test/trip_protector.test.d.ts +2 -0
  158. package/dist/test/trip_protector.test.d.ts.map +1 -0
  159. package/dist/test/trip_protector.test.js +42 -0
  160. package/dist/test/trip_protector.test.js.map +1 -0
  161. package/examples/README.md +34 -0
  162. package/examples/cli-record-replay.mjs +74 -0
  163. package/examples/custom-channel.mjs +102 -0
  164. package/examples/js-pae-plugin.mjs +128 -0
  165. package/examples/mcp-adapter.mjs +102 -0
  166. package/package.json +60 -0
  167. package/src/core/orbitRuntimeHost.ts +853 -0
  168. package/src/index.ts +256 -0
@@ -0,0 +1,853 @@
1
+ import { ChannelHub } from "@orbit/core-hub";
2
+ import { MemoryKvChannel } from "@orbit/core-hub";
3
+ import { LlmMockChannel } from "@orbit/core-hub";
4
+ import { TraceJournal, PersistedTraceJournal } from "@orbit/core-hub";
5
+ import { PluginSandboxGuard } from "@orbit/core-hub";
6
+ import { PluginPactVerifier } from "@orbit/core-hub";
7
+ import { SandboxPool } from "@orbit/sandbox-runtime";
8
+ import { ImpactDomainGraph } from "@orbit/sandbox-runtime";
9
+ import { CostRouter } from "@orbit/core-hub";
10
+ import { RecordJournal, PersistedRecordJournal } from "@orbit/core-hub";
11
+ import { ReplayEngine } from "@orbit/core-hub";
12
+ import { makeUniqueMark } from "@orbit/infra-common";
13
+ import { KERNEL_VERSION } from "@orbit/infra-common";
14
+ import { CapabilityGateway } from "@orbit/core-hub";
15
+ import { TokenBudgetEngine, DEFAULT_TOKEN_BUDGET_CONFIG } from "@orbit/core-hub";
16
+ import { RateLimiter, DEFAULT_RATE_LIMIT_CONFIG } from "@orbit/core-hub";
17
+ import { BehaviorCollector } from "@orbit/core-hub";
18
+ import { PaeAdapterRegistry } from "@orbit/pae-engine";
19
+ import { PaeChannel } from "@orbit/pae-engine";
20
+ import type { IPaeAdapter, PaeFidelity, PaeToolDescriptor } from "@orbit/pae-engine";
21
+ import type { GatewayInvokeParams } from "@orbit/core-hub";
22
+ import type { GatewayCheckers } from "@orbit/core-hub";
23
+ import { tripThresholdForProfile, tokenBudgetConfigForProfile } from "@orbit/core-hub";
24
+ import { verifyAuditChain } from "@orbit/core-hub";
25
+ import { validateArgsAgainstSchema } from "@orbit/infra-common";
26
+ import type { AuditChainReport } from "@orbit/core-hub";
27
+ import type { AgentSandbox } from "@orbit/sandbox-runtime";
28
+ import {
29
+ ChannelKind, ChannelCallCtx, PluginUnitPact, AgentBoxConfig, CapabilityKey, ReplayMode
30
+ } from "@orbit/infra-common";
31
+ import {
32
+ resolveGovernanceProfile,
33
+ governanceProfileHash,
34
+ type GovernanceProfile,
35
+ type GovernanceProfileName
36
+ } from "@orbit/infra-common";
37
+ import type { RunVersionFingerprint } from "@orbit/infra-common";
38
+ import type { ClockSource } from "@orbit/infra-common";
39
+ // W20: the physical layer becomes host state.
40
+ import { IsolationDomainManager } from "@orbit/sandbox-runtime";
41
+ import { DomainChannel } from "@orbit/sandbox-runtime";
42
+ import type { DomainTransportFactory } from "@orbit/sandbox-runtime";
43
+ import type { IsolationDomainPlan } from "@orbit/sandbox-runtime";
44
+ import type { DomainInvokeCtx } from "@orbit/sandbox-runtime";
45
+ import type { DomainReconciliation, DomainTransaction } from "@orbit/sandbox-runtime";
46
+
47
+ const HOST_DEFAULT_TIMEOUT_MS = 10_000;
48
+
49
+ /**
50
+ * Durability options. When a path is supplied the corresponding journal is
51
+ * mirrored to an append-only write-ahead log on disk and recovered on the next
52
+ * boot, so a restart does not lose the audit trail or the recorded run. Omitting
53
+ * a path (the default) keeps the journal purely in-memory — the original
54
+ * behavior, and what every existing test relies on.
55
+ */
56
+ /** W31: PAE isolation rank for the trust-assumption cap (L0 < L1 < L2). */
57
+ function rankIsolation(level: string): number {
58
+ return level === "L2" ? 2 : level === "L1" ? 1 : 0;
59
+ }
60
+
61
+ export interface OrbitRuntimeHostOptions {
62
+ /** Durable audit/behavior journal (trace) WAL path. */
63
+ traceJournalPath?: string;
64
+ /** Durable recording-window journal WAL path (recovered on boot). */
65
+ recordJournalPath?: string;
66
+ /**
67
+ * Wall-clock source for every time-dependent decision that reaches a recorded
68
+ * value (trip cooldown, channel TTLs). Injecting a frozen clock makes a
69
+ * recording window reproducible; omitting it keeps the real clock, which is
70
+ * the previous behaviour.
71
+ */
72
+ clock?: ClockSource;
73
+ /**
74
+ * Retention bound for the durable audit journal: keep at most this many
75
+ * newest entries. An append-only audit log that grows without limit
76
+ * eventually fills the disk, and a full disk is an outage — so the bound is
77
+ * explicit and operator-chosen rather than an implicit default. Applied at
78
+ * boot (after recovery) and at shutdown. Omit for unbounded retention.
79
+ */
80
+ auditRetention?: number;
81
+ /**
82
+ * W29: governance tier (VISION §3.1). `standard` is the default and resolves
83
+ * to the kernel's pre-W29 numbers verbatim. `sandbox` disables token
84
+ * compression, widens rate limits, admits every PAE adapter kind and keeps
85
+ * the trace in memory. `strict` narrows rate limits, trips earlier, compresses
86
+ * aggressively, admits NO foreign adapters and REQUIRES a durable trace path
87
+ * (construction fails without one). The profile name is hashed into the run
88
+ * fingerprint, so a trace recorded under one tier refuses to replay under
89
+ * another (`RunFingerprintDriftError`).
90
+ */
91
+ governanceProfile?: GovernanceProfileName;
92
+ /**
93
+ * W30: HMAC-SHA256 signing key for the audit hash chain (VISION §3.1's
94
+ * "落盘 + 签名"). When provided, every audit entry is linked with
95
+ * `prevHash`/`chainHash` and `host.verifyAuditChain()` can prove the trail
96
+ * has not been tampered with. Without a key the journal records no chain
97
+ * fields at all (pre-W30 behaviour). The `strict` governance tier REQUIRES
98
+ * a signing key — a compliance tier must be able to prove its audit trail.
99
+ */
100
+ auditSigningKey?: string;
101
+ }
102
+
103
+ /**
104
+ * Top-level assembly: wires every component bottom-up and owns the host
105
+ * lifecycle. Components stay public for advanced scenarios (custom channels,
106
+ * audit), but day-to-day use should go through the facade methods below.
107
+ */
108
+ export class OrbitRuntimeHost {
109
+ public readonly channelHub: ChannelHub;
110
+ public readonly traceJournal: TraceJournal;
111
+ public readonly pluginSandboxGuard: PluginSandboxGuard;
112
+ public readonly pluginPactVerifier: PluginPactVerifier;
113
+ public readonly sandboxPool: SandboxPool;
114
+ /** M3: dependency graph feeding isolation decisions. */
115
+ public readonly impactGraph: ImpactDomainGraph;
116
+ /** M4: cost profiles and budget routing. */
117
+ public readonly costRouter: CostRouter;
118
+ /** W8: pure-function token budget + context compressor (single source of truth). */
119
+ public readonly tokenBudget: TokenBudgetEngine;
120
+ /** W11: pure-function rate limiter (call-count budget, replay-safe). */
121
+ public readonly rateLimiter: RateLimiter;
122
+ /** W11: behavior collector (record / live-proposal / replay-bypass). */
123
+ public readonly behaviorCollector: BehaviorCollector;
124
+ /** W7: unified gateway entry — the determinism boundary (capabilityInvoke). */
125
+ public readonly gateway: CapabilityGateway;
126
+ /** W15: registry of foreign-runtime adapters (the adaptation surface). */
127
+ public readonly paeRegistry: PaeAdapterRegistry;
128
+ /** W15: the channel that publishes the adaptation surface to the hub. */
129
+ public readonly paeChannel: PaeChannel;
130
+ /** W8: kinds served by a PAE adapter; routing flips to "pae" when non-empty. */
131
+ private readonly paeAdapterKinds = new Set<ChannelKind>();
132
+ /**
133
+ * W20: the physical layer — isolation domains allocated from the impact
134
+ * graph. Created lazily: a host that never allocates domains keeps its
135
+ * previous hub surface and fingerprint byte for byte.
136
+ */
137
+ private domainManager: IsolationDomainManager | null = null;
138
+ /** W20: the gateway surface over the domain manager's units. */
139
+ private domainChannel: DomainChannel | null = null;
140
+ /** W20: the graph changed since the last allocation (plan needs a re-sync). */
141
+ private domainsStaleFlag = false;
142
+ /** W27: durable recording-window journal WAL path, if configured. */
143
+ private readonly recordJournalPath?: string;
144
+ /** Injected clock, threaded to every time-dependent decision that is recorded. */
145
+ private readonly clock?: ClockSource;
146
+ /** W27: the recording window currently attached to the gateway (for flush). */
147
+ private activeRecordJournal: RecordJournal | null = null;
148
+ /**
149
+ * W27: the same instance as {@link traceJournal}, held at its durable type.
150
+ * `traceJournal` stays declared as the base class on purpose — call sites must
151
+ * not depend on durability — so retention/compaction needs its own handle.
152
+ */
153
+ private readonly durableTraceJournal: PersistedTraceJournal;
154
+ /** W27: audit retention bound; undefined means unbounded. */
155
+ private readonly auditRetention?: number;
156
+ /** W29: resolved governance tier; drives limiter / trip / compression / PAE / durability. */
157
+ private readonly governanceProfile: GovernanceProfile;
158
+ /** W30: HMAC key for the audit hash chain; undefined = unsigned journal. */
159
+ private readonly auditSigningKey?: string;
160
+
161
+ public constructor(opts?: OrbitRuntimeHostOptions) {
162
+ this.recordJournalPath = opts?.recordJournalPath;
163
+ this.clock = opts?.clock;
164
+ this.governanceProfile = resolveGovernanceProfile(opts?.governanceProfile);
165
+ // A compliance tier with an ephemeral audit trail is a contradiction in
166
+ // terms: fail at construction, not at the first reboot.
167
+ if (
168
+ this.governanceProfile.traceDurability === "required" &&
169
+ !opts?.traceJournalPath
170
+ ) {
171
+ throw new Error(
172
+ "governance profile 'strict' requires traceJournalPath — a compliance tier must have a durable audit trail"
173
+ );
174
+ }
175
+ // W30: strict also requires the ability to PROVE the trail. The signing
176
+ // key is what turns an append-only file into a tamper-evident one.
177
+ if (this.governanceProfile.name === "strict" && !opts?.auditSigningKey) {
178
+ throw new Error(
179
+ "governance profile 'strict' requires auditSigningKey — a compliance tier must sign its audit chain"
180
+ );
181
+ }
182
+ this.auditSigningKey = opts?.auditSigningKey;
183
+ if (opts?.auditRetention !== undefined) {
184
+ if (!Number.isInteger(opts.auditRetention) || opts.auditRetention < 0) {
185
+ throw new RangeError(
186
+ `auditRetention expects a non-negative integer, received ${String(opts.auditRetention)}`
187
+ );
188
+ }
189
+ this.auditRetention = opts.auditRetention;
190
+ }
191
+ this.costRouter = new CostRouter();
192
+ this.tokenBudget = new TokenBudgetEngine(tokenBudgetConfigForProfile(this.governanceProfile));
193
+ this.rateLimiter = new RateLimiter({
194
+ maxCallsPerWindow: this.governanceProfile.limiter.maxCallsPerWindow,
195
+ windowSizeCalls: this.governanceProfile.limiter.windowSizeCalls
196
+ });
197
+ this.behaviorCollector = new BehaviorCollector();
198
+ this.paeRegistry = new PaeAdapterRegistry();
199
+ this.paeChannel = new PaeChannel(this.paeRegistry);
200
+ this.channelHub = new ChannelHub();
201
+ this.durableTraceJournal = new PersistedTraceJournal(opts?.traceJournalPath, this.auditSigningKey);
202
+ this.traceJournal = this.durableTraceJournal;
203
+ this.impactGraph = new ImpactDomainGraph();
204
+ this.pluginPactVerifier = new PluginPactVerifier();
205
+ this.pluginSandboxGuard = new PluginSandboxGuard(
206
+ this.traceJournal,
207
+ // W29: the trip threshold comes from the profile, softened by the
208
+ // plugin's dependency out-degree (see tripThresholdForProfile).
209
+ (pluginId) => tripThresholdForProfile(this.governanceProfile, this.impactGraph.outDegree(pluginId)),
210
+ opts?.clock
211
+ );
212
+ this.sandboxPool = new SandboxPool(this.channelHub, this.traceJournal, this.costRouter);
213
+
214
+ // Close the capability loop: plugin-originated channel calls must pass the
215
+ // declared-capability check. Injected as a function so the channel layer
216
+ // never depends on the pact layer above it.
217
+ this.channelHub.attachCapabilityGate((pluginUnitId, kind, funcName) =>
218
+ this.pluginPactVerifier.hasCapability(pluginUnitId, this.requiredCapabilityFor(kind, funcName))
219
+ );
220
+
221
+ // W7: wire the gateway. Checkers are injected (not concrete components) so
222
+ // the gateway stays above the pact/safeguard layers. tripAllowed routes
223
+ // through the gateway's own per-plugin trip map. W8: budget / compression /
224
+ // route / tokenConfigHash are now derived from the real TokenBudgetEngine
225
+ // and channel registry instead of literal stubs.
226
+ const checkers: GatewayCheckers = {
227
+ tripAllowed: (pluginId) => this.tripPreCheckFor(pluginId),
228
+ pactPass: (pluginId, kind, funcName) =>
229
+ this.pluginPactVerifier.hasCapability(pluginId, this.requiredCapabilityFor(kind, funcName)),
230
+ budgetDecision: (pluginId) => this.tokenBudget.budgetPolicy(pluginId),
231
+ rateLimited: (pluginId) => this.rateLimiter.isLimited(pluginId),
232
+ // W15: a call on the adaptation channel is by definition PAE-routed; the
233
+ // legacy "any adapter kind declared" rule is kept for callers that only
234
+ // announce a routing intent without registering a real adapter.
235
+ route: (_pluginId, kind) =>
236
+ kind === ChannelKind.PAE_TOOL || this.paeAdapterKinds.size > 0 ? "pae" : "native",
237
+ compression: (output) => this.tokenBudget.decideCompression(output),
238
+ fingerprint: () => this.runFingerprint(),
239
+ accountTokens: (pluginId, output) => {
240
+ if (typeof output === "string") {
241
+ this.tokenBudget.account(pluginId, this.tokenBudget.estimateTokens(output));
242
+ }
243
+ },
244
+ estimateTokens: (output) => (typeof output === "string" ? this.tokenBudget.estimateTokens(output) : 0),
245
+ consumeRateLimit: (pluginId) => {
246
+ this.rateLimiter.acquire(pluginId);
247
+ }
248
+ };
249
+ this.gateway = new CapabilityGateway(this.channelHub, checkers, opts?.clock);
250
+ // W11: collectors/hooks live inside the gateway; attach the host-owned one.
251
+ this.gateway.attachCollector(this.behaviorCollector);
252
+
253
+ this.channelHub.registerBuiltInChannel(ChannelKind.MEM_KV_STORE, new MemoryKvChannel(this.clock));
254
+ this.channelHub.registerBuiltInChannel(ChannelKind.LLM_ACCESS, new LlmMockChannel());
255
+ // W15: the adaptation surface is a built-in channel with no tools until an
256
+ // adapter is registered — foreign runtimes get no private path in.
257
+ this.channelHub.registerBuiltInChannel(ChannelKind.PAE_TOOL, this.paeChannel);
258
+ this.costRouter.register(ChannelKind.MEM_KV_STORE, { costPerCall: 0, latencyMs: 1, quality: 1 });
259
+ this.costRouter.register(ChannelKind.LLM_ACCESS, { costPerCall: 1, latencyMs: 320, quality: 1 });
260
+ // Adaptation costs more than a native call (an extra hop, possibly a
261
+ // process boundary) and is priced accordingly for budget routing.
262
+ this.costRouter.register(ChannelKind.PAE_TOOL, { costPerCall: 2, latencyMs: 40, quality: 1 });
263
+ }
264
+
265
+ /**
266
+ * Capability required by a channel method. Native channels map statically;
267
+ * PAE tools are resolved through the registry so a foreign tool is governed
268
+ * at the same granularity as a native method (read vs write), never wholesale.
269
+ * An unknown tool is treated as a write — the conservative default — so a
270
+ * mis-registered surface fails closed.
271
+ */
272
+ private requiredCapabilityFor(kind: ChannelKind, funcName: string): CapabilityKey {
273
+ if (kind === ChannelKind.PAE_TOOL) {
274
+ return this.paeRegistry.capabilityOf(funcName) ?? "channel:write";
275
+ }
276
+ if (kind === ChannelKind.DOMAIN_TOOL) {
277
+ // W20: a domain hop is a cross-process call into another unit's process.
278
+ // The capability is not yet declared per unit tool, so the conservative
279
+ // default governs — the same fail-closed rule the adapter surface uses.
280
+ return "channel:write";
281
+ }
282
+ return requiredCapability(kind, funcName);
283
+ }
284
+
285
+ /** Host-private trip pre-check delegating to the gateway's per-plugin map. */
286
+ private tripPreCheckFor(pluginId: string): boolean {
287
+ return this.gateway.tripPreCheck(pluginId);
288
+ }
289
+
290
+ /**
291
+ * The run-version fingerprint a recorded trace carries (W7, extended W15/W20).
292
+ *
293
+ * Both optional fields are **omitted rather than empty**: a host that never
294
+ * registers an adapter or allocates a domain produces exactly the fingerprint
295
+ * it produced before those layers existed, so a trace recorded on an older
296
+ * kernel is reported as configuration drift — never as a mystery digest
297
+ * mismatch.
298
+ */
299
+ public runFingerprint(): RunVersionFingerprint {
300
+ return {
301
+ kernelVersion: KERNEL_VERSION,
302
+ pactVersions: {},
303
+ tokenConfigHash: this.tokenBudget.configHash(),
304
+ paeEnabled: this.paeAdapterKinds.size > 0,
305
+ ...(this.paeRegistry.isEmpty() ? {} : { paeAdaptersHash: this.paeRegistry.configHash() }),
306
+ ...(this.domainManager?.planOf() ? { domainPlanHash: this.domainManager.planHash() } : {}),
307
+ // W29: only a NON-default tier is a fingerprint surface. `standard` is
308
+ // omitted so a default host keeps its pre-W29 fingerprint byte for byte
309
+ // (backward-compat rule: new hash fields are omitted, never empty).
310
+ ...(this.governanceProfile.name === "standard"
311
+ ? {}
312
+ : { governanceProfileHash: governanceProfileHash(this.governanceProfile) })
313
+ };
314
+ }
315
+
316
+ /**
317
+ * W29: the resolved governance tier and its concrete numbers. Read-only —
318
+ * the tier is a construction-time decision; switching it means constructing
319
+ * a host with `governanceProfile`.
320
+ */
321
+ public get currentGovernanceProfile(): GovernanceProfile {
322
+ return this.governanceProfile;
323
+ }
324
+
325
+ public async bootHost(): Promise<void> {
326
+ // W27: recover durable journals *before* anything can append. A missing file
327
+ // is normal (first boot) and leaves the journal empty; an existing file
328
+ // replays its entries so the audit trail survives the restart. Recovery
329
+ // rebuilds the whole chain, so it must precede channel setup — otherwise
330
+ // setup-time audit entries would be overwritten by the recovered snapshot.
331
+ await this.traceJournal.load();
332
+ // W30: a compliance tier starts only against a PROVABLE audit trail. A
333
+ // chain that fails verification after recovery means the log was edited
334
+ // while the host was down — a strict tier refuses to run on it.
335
+ if (this.governanceProfile.name === "strict" && this.auditSigningKey) {
336
+ const report = this.verifyAuditChain();
337
+ if (!report.consistent) {
338
+ throw new Error(
339
+ `governance profile 'strict' refuses to boot: audit chain broken at entry #${report.brokenAt} ` +
340
+ `(${report.brokenReason})`
341
+ );
342
+ }
343
+ }
344
+ // Apply the retention bound to whatever the previous run left behind, before
345
+ // this run starts appending. Compaction also physically removes a tail that
346
+ // a crash truncated (recovery merely ignores it), so the log is well-formed
347
+ // again from the first append of the new run.
348
+ await this.pruneAuditLog();
349
+ await this.channelHub.setupAllBuiltInChannels(this.newHostCtx());
350
+ if (this.recordJournalPath) {
351
+ await this.resumeRecording();
352
+ }
353
+ }
354
+
355
+ /**
356
+ * W30: prove the audit trail has not been tampered with. Recomputes the
357
+ * hash chain from the genesis seed and reports the first broken entry.
358
+ * An unsigned journal (no key configured) reports `signed: false` and is
359
+ * vacuously consistent.
360
+ */
361
+ public verifyAuditChain(): AuditChainReport {
362
+ if (!this.auditSigningKey) {
363
+ return { consistent: true, total: this.traceJournal.entries().length, signed: false };
364
+ }
365
+ return verifyAuditChain(this.traceJournal.entries(), this.auditSigningKey);
366
+ }
367
+
368
+ /**
369
+ * W27: enforce the configured audit-retention bound and compact the WAL.
370
+ *
371
+ * Called automatically at boot and shutdown; exposed so an operator can prune
372
+ * a long-running host on demand without a restart.
373
+ *
374
+ * @returns the number of audit entries retained.
375
+ */
376
+ public async pruneAuditLog(): Promise<number> {
377
+ if (this.auditRetention === undefined) {
378
+ return this.traceJournal.entries().length;
379
+ }
380
+ return this.durableTraceJournal.retainLast(this.auditRetention);
381
+ }
382
+
383
+ /** Reverse-order teardown: pool -> pact -> guards -> channels -> journal -> graph. */
384
+ public async shutdownHost(): Promise<void> {
385
+ // W27: drain any pending WAL writes before tearing components down, so the
386
+ // last recorded calls/audit entries are not lost on a clean shutdown.
387
+ //
388
+ // A drain that loses a write now reports it (see PersistedRecordJournal),
389
+ // but the teardown below must still run to completion: aborting here would
390
+ // leak every child process and foreign adapter the host owns. So the flush
391
+ // failure is carried and re-raised only after the release is done — the
392
+ // caller gets the signal *and* a clean process.
393
+ let drainError: unknown = null;
394
+ try {
395
+ await this.activeRecordJournal?.flush();
396
+ } catch (err) {
397
+ drainError = err;
398
+ }
399
+ try {
400
+ await this.traceJournal.flush();
401
+ } catch (err) {
402
+ drainError ??= err;
403
+ }
404
+ // Bound the log *at rest*: flush first so nothing pending is lost, then
405
+ // apply retention. Order matters — pruning before the flush would let the
406
+ // drained writes push the file back over the bound.
407
+ await this.pruneAuditLog();
408
+ this.sandboxPool.clear();
409
+ this.pluginPactVerifier.clear();
410
+ this.pluginSandboxGuard.releaseAllGuard();
411
+ // W20: release the physical layer before the hubs — every domain host is a
412
+ // real child process and must not outlive the kernel that spawned it.
413
+ await this.releaseIsolationDomains();
414
+ await this.channelHub.teardown();
415
+ // Channel teardown already released every adapter (PaeChannel.teardown);
416
+ // dropping the registry afterwards leaves no dangling foreign surface.
417
+ this.paeRegistry.clear();
418
+ this.traceJournal.clear();
419
+ this.impactGraph.clear();
420
+ if (drainError !== null) throw drainError;
421
+ }
422
+
423
+ // Facade -----------------------------------------------------------
424
+
425
+ /** Register a plugin; its declared channel deps feed the impact graph (M3). */
426
+ public registerPlugin(pact: PluginUnitPact): void {
427
+ // W31: the strict tier demands a declared parameter contract for every
428
+ // plugin — a compliance tier must state what it accepts.
429
+ if (this.governanceProfile.schemaMode === "required" && !pact.schema) {
430
+ throw new Error(
431
+ `governance profile 'strict' requires a schema on plugin '${pact.id}' — a compliance tier must declare its parameter contract`
432
+ );
433
+ }
434
+ this.pluginPactVerifier.registerPluginUnit(pact, makeUniqueMark());
435
+ this.impactGraph.addNode(pact.id);
436
+ for (const dep of pact.declareChannelDeps ?? []) {
437
+ this.impactGraph.addEdge(pact.id, dep);
438
+ }
439
+ // W20: the graph feeds domain allocation — a change invalidates the plan.
440
+ this.domainsStaleFlag = true;
441
+ }
442
+
443
+ /** Spawn an agent sandbox; its channel deps feed the impact graph (M3). */
444
+ public spawnAgentBox(cfg: AgentBoxConfig): AgentSandbox {
445
+ const box = this.sandboxPool.spawnSandbox(cfg);
446
+ this.impactGraph.addNode(cfg.agentBoxId);
447
+ for (const dep of cfg.channelDeps ?? []) {
448
+ this.impactGraph.addEdge(cfg.agentBoxId, dep);
449
+ }
450
+ // W20: same invalidation rule as `registerPlugin`.
451
+ this.domainsStaleFlag = true;
452
+ return box;
453
+ }
454
+
455
+ /**
456
+ * W8: declare a PAE adapter for a channel kind. Once any adapter is
457
+ * registered the gateway's `route` decision becomes "pae" for governed
458
+ * calls, and the run fingerprint's `paeEnabled` flips — so a trace recorded
459
+ * before PAE existed is detected as config drift, never as a digest mismatch.
460
+ */
461
+ public registerPaeAdapter(kind: ChannelKind): void {
462
+ this.paeAdapterKinds.add(kind);
463
+ }
464
+
465
+ /**
466
+ * W15: attach a real foreign-runtime adapter.
467
+ *
468
+ * The adapter is validated, indexed and published as tools on the PAE
469
+ * channel, and a **dynamic pact** is derived from its declared surface and
470
+ * registered like any hand-written plugin manifest. That is what keeps the
471
+ * promise of "MCP-grade ecosystem reach without a governance downgrade": the
472
+ * foreign tool now passes the same capability check, trip protection, budget
473
+ * accounting and recording as a native channel method, because it *is* one.
474
+ *
475
+ * @returns the derived pact (already registered unless `registerPact: false`).
476
+ */
477
+ public registerPaeToolAdapter(
478
+ adapter: IPaeAdapter,
479
+ opts: { registerPact?: boolean; requireHostMinEdition?: string } = {}
480
+ ): PluginUnitPact {
481
+ // W29: the governance tier admits adapter kinds explicitly. `strict`
482
+ // admits none — a compliance tier has no foreign-runtime surface at all.
483
+ this.assertPaeAdmitted(adapter.meta.kind, adapter.meta.isolation);
484
+ this.paeRegistry.register(adapter, makeUniqueMark());
485
+ this.paeChannel.syncTools();
486
+ this.paeAdapterKinds.add(ChannelKind.PAE_TOOL);
487
+ const pact = this.paeRegistry.derivePact(adapter.meta.adapterId, {
488
+ requireHostMinEdition: opts.requireHostMinEdition
489
+ });
490
+ if (opts.registerPact !== false) {
491
+ this.registerPlugin(pact);
492
+ }
493
+ return pact;
494
+ }
495
+
496
+ /**
497
+ * W29+W31: the governance gate for foreign adapters — kind admission
498
+ * (`paeAdmission`) and isolation cap (`maxIsolationLevel`, the trust
499
+ * assumption). `strict` admits no kind at all and caps isolation at L1; a
500
+ * denied adapter is rejected outright rather than half-connected.
501
+ */
502
+ private assertPaeAdmitted(kind: string, isolation: string): void {
503
+ const admission = this.governanceProfile.paeAdmission;
504
+ if (admission !== "all" && !admission.includes(kind)) {
505
+ throw new Error(
506
+ `governance profile '${this.governanceProfile.name}' does not admit PAE adapter kind '${kind}' ` +
507
+ `(admitted: ${admission.length === 0 ? "none" : admission.join(", ")})`
508
+ );
509
+ }
510
+ const cap = this.governanceProfile.maxIsolationLevel;
511
+ if (rankIsolation(isolation) > rankIsolation(cap)) {
512
+ throw new Error(
513
+ `governance profile '${this.governanceProfile.name}' caps PAE isolation at ${cap}; ` +
514
+ `adapter '${kind}' claims ${isolation} (trust assumption)`
515
+ );
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Register an adapter whose surface is only knowable *after* a handshake.
521
+ *
522
+ * `registerPaeToolAdapter` resolves the capability surface at registration
523
+ * time, which presupposes that the surface already exists. An MCP server
524
+ * announces its tools over the wire, so there is nothing to resolve until the
525
+ * connection is up. This variant connects first, lets the adapter discover
526
+ * what the peer actually exposes, and then registers exactly that.
527
+ *
528
+ * The handshake is not a governance bypass: registration still runs the same
529
+ * static validation and still derives a dynamic pact from the *discovered*
530
+ * surface, so a server that later announces a new tool is configuration drift
531
+ * on the next replay rather than a silently expanded capability.
532
+ */
533
+ public async connectPaeToolAdapter(
534
+ adapter: IPaeAdapter,
535
+ opts: { registerPact?: boolean; requireHostMinEdition?: string; maxWaitMs?: number } = {}
536
+ ): Promise<PluginUnitPact> {
537
+ // W29+W31: gate BEFORE the handshake — a denied kind or isolation must
538
+ // not spawn a child process just to be rejected (register re-checks).
539
+ this.assertPaeAdmitted(adapter.meta.kind, adapter.meta.isolation);
540
+ if (adapter.setup) {
541
+ await adapter.setup({
542
+ traceMarkId: makeUniqueMark(),
543
+ maxWaitMs: opts.maxWaitMs ?? HOST_DEFAULT_TIMEOUT_MS
544
+ });
545
+ }
546
+ return this.registerPaeToolAdapter(adapter, {
547
+ registerPact: opts.registerPact,
548
+ requireHostMinEdition: opts.requireHostMinEdition
549
+ });
550
+ }
551
+
552
+ /**
553
+ * Detach an adapter: its tools stop being dispatchable, its derived pact is
554
+ * revoked, and the routing flag clears once the surface is empty. A trace
555
+ * recorded while it was present then replays as configuration drift, not as
556
+ * a mysterious digest mismatch.
557
+ *
558
+ * Resources held by the adapter are released in the background; use
559
+ * `releasePaeToolAdapter` when you need to wait for that to complete (for
560
+ * example before the process exits, or in a test that asserts a subprocess
561
+ * is gone).
562
+ */
563
+ public unregisterPaeToolAdapter(adapterId: string): void {
564
+ this.paeRegistry.unregister(adapterId);
565
+ this.paeChannel.syncTools();
566
+ this.pluginPactVerifier.unregisterPluginUnit(adapterId);
567
+ this.impactGraph.removeNode(adapterId);
568
+ // W20: removing a node can shrink closures, so the plan must be recomputed.
569
+ this.domainsStaleFlag = true;
570
+ if (this.paeRegistry.isEmpty()) {
571
+ this.paeAdapterKinds.delete(ChannelKind.PAE_TOOL);
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Detach an adapter and await its release.
577
+ *
578
+ * Same as `unregisterPaeToolAdapter`, plus a wait for `teardown` to finish —
579
+ * the difference matters for adapters that own an OS process (MCP), where
580
+ * "unregistered" and "peer actually shut down" are separated by real time.
581
+ */
582
+ public async releasePaeToolAdapter(adapterId: string): Promise<void> {
583
+ this.unregisterPaeToolAdapter(adapterId);
584
+ await this.paeRegistry.drainReleases();
585
+ }
586
+
587
+ /**
588
+ * Connect adapters registered after `bootHost`. Idempotent — adapters that
589
+ * are already connected are skipped, so it is safe to call after every batch
590
+ * of registrations.
591
+ */
592
+ public async bootPaeAdapters(): Promise<void> {
593
+ await this.paeRegistry.setupAll({
594
+ traceMarkId: makeUniqueMark(),
595
+ maxWaitMs: HOST_DEFAULT_TIMEOUT_MS
596
+ });
597
+ this.paeChannel.syncTools();
598
+ }
599
+
600
+ /**
601
+ * Capability negotiation for a foreign tool: returns the descriptor when the
602
+ * mapping is at least as faithful as required, otherwise throws. Callers make
603
+ * an informed choice instead of silently receiving a degraded result.
604
+ */
605
+ public negotiatePaeTool(toolName: string, minFidelity: PaeFidelity = "full"): PaeToolDescriptor {
606
+ return this.paeRegistry.negotiate(toolName, minFidelity, makeUniqueMark());
607
+ }
608
+
609
+ /**
610
+ * M2: open a recording window; sandboxes running in "record" mode fill it.
611
+ *
612
+ * W27: when the host was constructed with `recordJournalPath`, the window is
613
+ * durable — every recorded call is mirrored to the WAL. Opening a *new* window
614
+ * truncates that WAL first (a fresh window must not inherit the previous
615
+ * run's calls); use {@link resumeRecording} to continue a prior window instead.
616
+ */
617
+ public beginRecording(): RecordJournal {
618
+ const journal = this.recordJournalPath
619
+ ? new PersistedRecordJournal(this.recordJournalPath, { truncate: true })
620
+ : new RecordJournal();
621
+ this.activeRecordJournal = journal;
622
+ this.channelHub.attachRecordJournal(journal);
623
+ this.gateway.attachJournal(journal);
624
+ return journal;
625
+ }
626
+
627
+ /**
628
+ * W27: reopen the durable recording window persisted by a previous process.
629
+ *
630
+ * Recovers the WAL (crash-safe: a truncated trailing line is dropped) and
631
+ * re-attaches it, so `orderIndex` continues from the recovered length and the
632
+ * combined journal replays as one uninterrupted run. Without a configured
633
+ * `recordJournalPath` this degrades to a plain in-memory window.
634
+ */
635
+ public async resumeRecording(): Promise<RecordJournal> {
636
+ if (!this.recordJournalPath) return this.beginRecording();
637
+ const journal = await PersistedRecordJournal.recover(this.recordJournalPath);
638
+ // Heal the file before the resumed window appends to it: a crash-truncated
639
+ // tail would otherwise sit in the *interior* once the next line lands, and an
640
+ // invalid interior line is a hard fault. No-op when the log is healthy.
641
+ await journal.healIfNeeded();
642
+ this.activeRecordJournal = journal;
643
+ this.channelHub.attachRecordJournal(journal);
644
+ this.gateway.attachJournal(journal);
645
+ return journal;
646
+ }
647
+
648
+ /**
649
+ * W27: the recording window currently attached to the gateway, or `null` if
650
+ * none was ever opened. After a boot with `recordJournalPath` configured this
651
+ * is the journal recovered from the WAL, so callers can inspect or replay a
652
+ * window persisted by a previous process without reopening it.
653
+ */
654
+ public currentRecordJournal(): RecordJournal | null {
655
+ return this.activeRecordJournal;
656
+ }
657
+
658
+ /** M2: attach a replay engine over a previously recorded journal. */
659
+ public attachReplayEngine(journal: RecordJournal): ReplayEngine {
660
+ const engine = new ReplayEngine(journal);
661
+ this.channelHub.attachReplayEngine(engine);
662
+ this.gateway.attachReplayEngine(engine);
663
+ return engine;
664
+ }
665
+
666
+ /** W7: the unified gateway entry — deterministic boundary for governed calls. */
667
+ public async capabilityInvoke<T>(params: {
668
+ kind: ChannelKind;
669
+ pluginId?: string;
670
+ funcName: string;
671
+ args: unknown[];
672
+ mode: ReplayMode;
673
+ ctx?: Partial<ChannelCallCtx>;
674
+ }): Promise<T> {
675
+ // W31: progressive contractification — when the tier checks schemas and
676
+ // the target tool declares one, reject non-conforming arguments BEFORE
677
+ // the call executes. Replay bypasses the check: arguments were already
678
+ // validated at record time, and injection must stay a pure replay. The
679
+ // function is async so the rejection is a rejected promise, never a
680
+ // synchronous throw (callers use await / assert.rejects uniformly).
681
+ if (params.mode !== "replay" && this.governanceProfile.schemaMode !== "optional") {
682
+ this.assertArgsAgainstSchema(params.funcName, params.args);
683
+ }
684
+ return this.gateway.capabilityInvoke<T>(params as GatewayInvokeParams);
685
+ }
686
+
687
+ /**
688
+ * W31: gateway-side parameter validation for PAE tools that declare a
689
+ * schema. The error is raised as a plain Error (user-classified 400 by the
690
+ * console bridge); the recorded call never happens, so nothing to replay.
691
+ */
692
+ private assertArgsAgainstSchema(funcName: string, args: unknown[]): void {
693
+ const binding = this.paeRegistry.lookup(funcName);
694
+ const schema = binding?.tool.schema;
695
+ if (!schema) return; // no contract declared -> nothing to check
696
+ const result = validateArgsAgainstSchema(schema, args);
697
+ if (!result.ok) {
698
+ throw new Error(
699
+ `parameter contract violated for tool '${funcName}' at ${result.path}: ${result.error}`
700
+ );
701
+ }
702
+ }
703
+
704
+ /** M3: nodes that would be affected if the given node fails (reachability closure). */
705
+ public isolationDomain(nodeId: string): string[] {
706
+ return [...this.impactGraph.closure(nodeId)];
707
+ }
708
+
709
+ /** M3: isolation theorem query — two nodes are provably independent. */
710
+ public areIsolated(a: string, b: string): boolean {
711
+ return this.impactGraph.areIndependent(a, b);
712
+ }
713
+
714
+ /** M4: choose the cheapest channel that fits the budget and latency target. */
715
+ public routeChannel(kinds: ChannelKind[], budget: number, maxLatencyMs: number): ChannelKind | undefined {
716
+ return this.costRouter.choose(kinds, budget, maxLatencyMs);
717
+ }
718
+
719
+ /* ------------------------------------------------------------------ */
720
+ /* W20 · 图驱动域分配与域间事务化调用 */
721
+ /* ------------------------------------------------------------------ */
722
+
723
+ /** Whether the impact graph changed since the last domain allocation. */
724
+ public domainsStale(): boolean {
725
+ return this.domainsStaleFlag;
726
+ }
727
+
728
+ /**
729
+ * Allocate isolation domains from the current impact graph and publish their
730
+ * unit surface on the gateway (W19 channel, W20 host state).
731
+ *
732
+ * Idempotent for a given graph: the sync is a diff, so re-running after a
733
+ * no-op graph change neither restarts child processes nor perturbs the plan
734
+ * hash. Registering the channel happens here and only here — a host that
735
+ * never allocates domains keeps its previous hub surface and fingerprint.
736
+ */
737
+ public async allocateIsolationDomains(
738
+ opts: {
739
+ transportFactory?: DomainTransportFactory;
740
+ clock?: ClockSource;
741
+ maxImpactClosure?: number;
742
+ maxDomainSize?: number;
743
+ defaultTimeoutMs?: number;
744
+ } = {}
745
+ ): Promise<IsolationDomainPlan> {
746
+ let manager = this.domainManager;
747
+ if (!manager) {
748
+ manager = new IsolationDomainManager({
749
+ transportFactory: opts.transportFactory,
750
+ clock: opts.clock,
751
+ defaultTimeoutMs: opts.defaultTimeoutMs,
752
+ maxImpactClosure: opts.maxImpactClosure,
753
+ maxDomainSize: opts.maxDomainSize
754
+ });
755
+ const channel = new DomainChannel(manager);
756
+ this.domainManager = manager;
757
+ this.domainChannel = channel;
758
+ this.channelHub.registerBuiltInChannel(ChannelKind.DOMAIN_TOOL, channel);
759
+ // A domain hop is a cross-process call: priced and timed like the
760
+ // adaptation surface, so budget routing sees the real cost.
761
+ this.costRouter.register(ChannelKind.DOMAIN_TOOL, { costPerCall: 2, latencyMs: 40, quality: 1 });
762
+ }
763
+ const plan = await manager.syncDomains(this.impactGraph, this.domainCtx(), opts);
764
+ this.domainChannel?.syncTools();
765
+ this.domainsStaleFlag = false;
766
+ return plan;
767
+ }
768
+
769
+ /** The current domain plan, or `null` before the first allocation. */
770
+ public domainPlan(): IsolationDomainPlan | null {
771
+ return this.domainManager?.planOf() ?? null;
772
+ }
773
+
774
+ /** Running domains: id, units and isolation level, sorted. */
775
+ public domains(): Array<{ domainId: string; units: string[]; isolation: string }> {
776
+ return this.domainManager?.domainsOf() ?? [];
777
+ }
778
+
779
+ /**
780
+ * Invoke a unit in its domain through the gateway — the cross-domain hop.
781
+ * Recorded and replayed like any other governed call; settled in the domain
782
+ * transaction ledger either way.
783
+ */
784
+ public invokeDomainUnit<T>(
785
+ unitId: string,
786
+ tool: string,
787
+ args: unknown[] = [],
788
+ opts: { pluginUnitId?: string; mode?: ReplayMode; ctx?: Partial<ChannelCallCtx> } = {}
789
+ ): Promise<T> {
790
+ return this.gateway.capabilityInvoke<T>({
791
+ kind: ChannelKind.DOMAIN_TOOL,
792
+ pluginId: opts.pluginUnitId,
793
+ funcName: `${unitId}:${tool}`,
794
+ args,
795
+ mode: opts.mode ?? "live",
796
+ ctx: opts.ctx
797
+ } as GatewayInvokeParams);
798
+ }
799
+
800
+ /** The cross-domain transaction ledger (decision → execution → result). */
801
+ public domainLedger(): readonly DomainTransaction[] {
802
+ return this.domainManager?.txnLedger() ?? [];
803
+ }
804
+
805
+ /** Reconcile the ledger — cross-domain events must balance (VISION 2.2). */
806
+ public reconcileDomainTransactions(): DomainReconciliation {
807
+ return this.domainManager
808
+ ? this.domainManager.reconcile()
809
+ : {
810
+ balanced: true,
811
+ pairs: [],
812
+ orphans: [],
813
+ rejected: [],
814
+ totals: { transactions: 0, settled: 0, failed: 0, rejected: 0 }
815
+ };
816
+ }
817
+
818
+ /** Tear down every domain and release its host process. */
819
+ public async releaseIsolationDomains(): Promise<void> {
820
+ if (!this.domainManager) return;
821
+ const manager = this.domainManager;
822
+ this.domainManager = null;
823
+ this.domainChannel = null;
824
+ this.domainsStaleFlag = false;
825
+ await manager.teardownAll();
826
+ }
827
+
828
+ /** Context for domain synchronization calls. */
829
+ private domainCtx(): DomainInvokeCtx {
830
+ const ctx = this.newHostCtx();
831
+ return { traceMarkId: ctx.traceMarkId, maxWaitMs: ctx.maxWaitMs };
832
+ }
833
+
834
+ private newHostCtx(): ChannelCallCtx {
835
+ return { traceMarkId: makeUniqueMark(), maxWaitMs: HOST_DEFAULT_TIMEOUT_MS };
836
+ }
837
+ }
838
+
839
+ /** Minimal mapping from a channel method to its required capability. */
840
+ function requiredCapability(kind: ChannelKind, funcName: string): CapabilityKey {
841
+ if (kind === ChannelKind.MEM_KV_STORE) {
842
+ return funcName === "writeEntry" || funcName === "removeEntry" ? "channel:write" : "channel:read";
843
+ }
844
+ if (kind === ChannelKind.FILE_SYSTEM) {
845
+ const writes = new Set(["writeTextFile", "appendTextFile", "removePath", "makeDir"]);
846
+ return writes.has(funcName) ? "channel:write" : "channel:read";
847
+ }
848
+ if (kind === ChannelKind.SHELL_EXEC) {
849
+ // Executing commands is treated as a mutating capability by default.
850
+ return "channel:write";
851
+ }
852
+ return "channel:read";
853
+ }