@opetope/runtime 0.1.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 (314) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +345 -0
  4. package/README.ru.md +344 -0
  5. package/dist/application-compiler-edges.d.ts +3 -0
  6. package/dist/application-compiler-edges.js +2 -0
  7. package/dist/application-compiler-edges.js.map +1 -0
  8. package/dist/application-compiler-graph.d.ts +8 -0
  9. package/dist/application-compiler-graph.js +2 -0
  10. package/dist/application-compiler-graph.js.map +1 -0
  11. package/dist/application-compiler.d.ts +116 -0
  12. package/dist/application-compiler.js +2 -0
  13. package/dist/application-compiler.js.map +1 -0
  14. package/dist/application-conditions.d.ts +18 -0
  15. package/dist/application-conditions.js +2 -0
  16. package/dist/application-conditions.js.map +1 -0
  17. package/dist/application-definition.d.ts +30 -0
  18. package/dist/application-definition.js +2 -0
  19. package/dist/application-definition.js.map +1 -0
  20. package/dist/application-error.d.ts +10 -0
  21. package/dist/application-error.js +2 -0
  22. package/dist/application-error.js.map +1 -0
  23. package/dist/application-execution.d.ts +43 -0
  24. package/dist/application-execution.js +2 -0
  25. package/dist/application-execution.js.map +1 -0
  26. package/dist/application-feature-bindings.d.ts +11 -0
  27. package/dist/application-feature-bindings.js +2 -0
  28. package/dist/application-feature-bindings.js.map +1 -0
  29. package/dist/application-feature-instance.d.ts +5 -0
  30. package/dist/application-feature-instance.js +2 -0
  31. package/dist/application-feature-instance.js.map +1 -0
  32. package/dist/application-group-order.d.ts +29 -0
  33. package/dist/application-group-order.js +2 -0
  34. package/dist/application-group-order.js.map +1 -0
  35. package/dist/application-instance-retirement.d.ts +36 -0
  36. package/dist/application-instance-retirement.js +2 -0
  37. package/dist/application-instance-retirement.js.map +1 -0
  38. package/dist/application-open-options.d.ts +29 -0
  39. package/dist/application-open-options.js +2 -0
  40. package/dist/application-open-options.js.map +1 -0
  41. package/dist/application-port-compiler.d.ts +28 -0
  42. package/dist/application-port-compiler.js +2 -0
  43. package/dist/application-port-compiler.js.map +1 -0
  44. package/dist/attachment-call-declaration.d.ts +43 -0
  45. package/dist/attachment-call-declaration.js +2 -0
  46. package/dist/attachment-call-declaration.js.map +1 -0
  47. package/dist/attachment-declaration.d.ts +68 -0
  48. package/dist/attachment-declaration.js +2 -0
  49. package/dist/attachment-declaration.js.map +1 -0
  50. package/dist/attachment-execution.d.ts +9 -0
  51. package/dist/attachment-execution.js +2 -0
  52. package/dist/attachment-execution.js.map +1 -0
  53. package/dist/attachment-retirement-scheduler.d.ts +14 -0
  54. package/dist/attachment-retirement-scheduler.js +2 -0
  55. package/dist/attachment-retirement-scheduler.js.map +1 -0
  56. package/dist/call-option-snapshot.d.ts +20 -0
  57. package/dist/call-option-snapshot.js +2 -0
  58. package/dist/call-option-snapshot.js.map +1 -0
  59. package/dist/compile-call-target-bindings.d.ts +14 -0
  60. package/dist/compile-call-target-bindings.js +2 -0
  61. package/dist/compile-call-target-bindings.js.map +1 -0
  62. package/dist/compile-module-template.d.ts +38 -0
  63. package/dist/compile-module-template.js +2 -0
  64. package/dist/compile-module-template.js.map +1 -0
  65. package/dist/condition-group-execution.d.ts +44 -0
  66. package/dist/condition-group-execution.js +2 -0
  67. package/dist/condition-group-execution.js.map +1 -0
  68. package/dist/condition-override.d.ts +28 -0
  69. package/dist/condition-override.js +2 -0
  70. package/dist/condition-override.js.map +1 -0
  71. package/dist/condition-source.d.ts +10 -0
  72. package/dist/condition-source.js +2 -0
  73. package/dist/condition-source.js.map +1 -0
  74. package/dist/condition-types.d.ts +14 -0
  75. package/dist/condition.d.ts +28 -0
  76. package/dist/condition.js +2 -0
  77. package/dist/condition.js.map +1 -0
  78. package/dist/control-registry.d.ts +56 -0
  79. package/dist/control-registry.js +2 -0
  80. package/dist/control-registry.js.map +1 -0
  81. package/dist/dynamic-scope-child.d.ts +23 -0
  82. package/dist/dynamic-scope-child.js +2 -0
  83. package/dist/dynamic-scope-child.js.map +1 -0
  84. package/dist/dynamic-scope-controller.d.ts +16 -0
  85. package/dist/dynamic-scope-controller.js +2 -0
  86. package/dist/dynamic-scope-controller.js.map +1 -0
  87. package/dist/feature-attachment-authoring-types.d.ts +51 -0
  88. package/dist/feature-attachment-lowering.d.ts +18 -0
  89. package/dist/feature-attachment-lowering.js +2 -0
  90. package/dist/feature-attachment-lowering.js.map +1 -0
  91. package/dist/feature-attachment.d.ts +45 -0
  92. package/dist/feature-attachment.js +2 -0
  93. package/dist/feature-attachment.js.map +1 -0
  94. package/dist/feature-authoring-types.d.ts +196 -0
  95. package/dist/feature-authoring.d.ts +15 -0
  96. package/dist/feature-authoring.js +2 -0
  97. package/dist/feature-authoring.js.map +1 -0
  98. package/dist/feature-body.d.ts +53 -0
  99. package/dist/feature-body.js +2 -0
  100. package/dist/feature-body.js.map +1 -0
  101. package/dist/feature-call-authority.d.ts +8 -0
  102. package/dist/feature-call-authority.js +2 -0
  103. package/dist/feature-call-authority.js.map +1 -0
  104. package/dist/feature-call-types.d.ts +39 -0
  105. package/dist/feature-call.d.ts +18 -0
  106. package/dist/feature-call.js +2 -0
  107. package/dist/feature-call.js.map +1 -0
  108. package/dist/feature-calls.d.ts +6 -0
  109. package/dist/feature-calls.js +2 -0
  110. package/dist/feature-calls.js.map +1 -0
  111. package/dist/feature-contract.d.ts +70 -0
  112. package/dist/feature-contract.js +2 -0
  113. package/dist/feature-contract.js.map +1 -0
  114. package/dist/feature-contribution-model.d.ts +38 -0
  115. package/dist/feature-contribution-model.js +2 -0
  116. package/dist/feature-contribution-model.js.map +1 -0
  117. package/dist/feature-contribution.d.ts +126 -0
  118. package/dist/feature-contribution.js +2 -0
  119. package/dist/feature-contribution.js.map +1 -0
  120. package/dist/feature-definition-api.d.ts +53 -0
  121. package/dist/feature-definition-support.d.ts +21 -0
  122. package/dist/feature-definition-support.js +2 -0
  123. package/dist/feature-definition-support.js.map +1 -0
  124. package/dist/feature-effect.d.ts +26 -0
  125. package/dist/feature-effect.js +2 -0
  126. package/dist/feature-effect.js.map +1 -0
  127. package/dist/feature-event.d.ts +32 -0
  128. package/dist/feature-event.js +2 -0
  129. package/dist/feature-event.js.map +1 -0
  130. package/dist/feature-generation.d.ts +34 -0
  131. package/dist/feature-generation.js +2 -0
  132. package/dist/feature-generation.js.map +1 -0
  133. package/dist/feature-lazy-generation.d.ts +6 -0
  134. package/dist/feature-lazy-generation.js +2 -0
  135. package/dist/feature-lazy-generation.js.map +1 -0
  136. package/dist/feature-lazy.d.ts +44 -0
  137. package/dist/feature-lazy.js +2 -0
  138. package/dist/feature-lazy.js.map +1 -0
  139. package/dist/feature-materialization-binding.d.ts +31 -0
  140. package/dist/feature-materialization-binding.js +2 -0
  141. package/dist/feature-materialization-binding.js.map +1 -0
  142. package/dist/feature-materialization-types.d.ts +45 -0
  143. package/dist/feature-model-dependencies.d.ts +23 -0
  144. package/dist/feature-model-dependencies.js +2 -0
  145. package/dist/feature-model-dependencies.js.map +1 -0
  146. package/dist/feature-model.d.ts +60 -0
  147. package/dist/feature-model.js +2 -0
  148. package/dist/feature-model.js.map +1 -0
  149. package/dist/feature-optional.d.ts +15 -0
  150. package/dist/feature-optional.js +2 -0
  151. package/dist/feature-optional.js.map +1 -0
  152. package/dist/feature-own-lowering.d.ts +22 -0
  153. package/dist/feature-own-lowering.js +2 -0
  154. package/dist/feature-own-lowering.js.map +1 -0
  155. package/dist/feature-port-binding.d.ts +6 -0
  156. package/dist/feature-port-binding.js +2 -0
  157. package/dist/feature-port-binding.js.map +1 -0
  158. package/dist/feature-port.d.ts +66 -0
  159. package/dist/feature-port.js +2 -0
  160. package/dist/feature-port.js.map +1 -0
  161. package/dist/feature-record.d.ts +6 -0
  162. package/dist/feature-record.js +2 -0
  163. package/dist/feature-record.js.map +1 -0
  164. package/dist/feature-resource.d.ts +29 -0
  165. package/dist/feature-resource.js +2 -0
  166. package/dist/feature-resource.js.map +1 -0
  167. package/dist/feature-scope-types.d.ts +35 -0
  168. package/dist/feature-scope.d.ts +29 -0
  169. package/dist/feature-scope.js +2 -0
  170. package/dist/feature-scope.js.map +1 -0
  171. package/dist/feature-stream.d.ts +37 -0
  172. package/dist/feature-stream.js +2 -0
  173. package/dist/feature-stream.js.map +1 -0
  174. package/dist/feature-timers.d.ts +14 -0
  175. package/dist/feature-timers.js +2 -0
  176. package/dist/feature-timers.js.map +1 -0
  177. package/dist/index.d.ts +19 -0
  178. package/dist/index.js +2 -0
  179. package/dist/index.js.map +1 -0
  180. package/dist/inspection-activity-protocol.d.ts +63 -0
  181. package/dist/inspection-activity.d.ts +54 -0
  182. package/dist/inspection-activity.js +2 -0
  183. package/dist/inspection-activity.js.map +1 -0
  184. package/dist/inspection-diff.d.ts +10 -0
  185. package/dist/inspection-diff.js +2 -0
  186. package/dist/inspection-diff.js.map +1 -0
  187. package/dist/inspection-module-activity.d.ts +4 -0
  188. package/dist/inspection-module-activity.js +2 -0
  189. package/dist/inspection-module-activity.js.map +1 -0
  190. package/dist/inspection-observer.d.ts +38 -0
  191. package/dist/inspection-observer.js +2 -0
  192. package/dist/inspection-observer.js.map +1 -0
  193. package/dist/inspection-plan.d.ts +33 -0
  194. package/dist/inspection-plan.js +2 -0
  195. package/dist/inspection-plan.js.map +1 -0
  196. package/dist/inspection-protocol.d.ts +333 -0
  197. package/dist/inspection-protocol.js +2 -0
  198. package/dist/inspection-protocol.js.map +1 -0
  199. package/dist/inspection-registry.d.ts +41 -0
  200. package/dist/inspection-registry.js +2 -0
  201. package/dist/inspection-registry.js.map +1 -0
  202. package/dist/inspection-session.d.ts +92 -0
  203. package/dist/inspection-session.js +2 -0
  204. package/dist/inspection-session.js.map +1 -0
  205. package/dist/inspection-snapshot.d.ts +4 -0
  206. package/dist/inspection-snapshot.js +2 -0
  207. package/dist/inspection-snapshot.js.map +1 -0
  208. package/dist/inspection-state.d.ts +91 -0
  209. package/dist/inspection-state.js +2 -0
  210. package/dist/inspection-state.js.map +1 -0
  211. package/dist/instance-demand.d.ts +31 -0
  212. package/dist/instance-demand.js +2 -0
  213. package/dist/instance-demand.js.map +1 -0
  214. package/dist/internal.d.ts +41 -0
  215. package/dist/internal.js +2 -0
  216. package/dist/internal.js.map +1 -0
  217. package/dist/keyed-scope-controller.d.ts +11 -0
  218. package/dist/keyed-scope-controller.js +2 -0
  219. package/dist/keyed-scope-controller.js.map +1 -0
  220. package/dist/model-kernel.d.ts +18 -0
  221. package/dist/model-kernel.js +2 -0
  222. package/dist/model-kernel.js.map +1 -0
  223. package/dist/module-call-context.d.ts +7 -0
  224. package/dist/module-call-context.js +2 -0
  225. package/dist/module-call-context.js.map +1 -0
  226. package/dist/module-call-runtime.d.ts +21 -0
  227. package/dist/module-call-runtime.js +2 -0
  228. package/dist/module-call-runtime.js.map +1 -0
  229. package/dist/module-generation.d.ts +57 -0
  230. package/dist/module-generation.js +2 -0
  231. package/dist/module-generation.js.map +1 -0
  232. package/dist/module-instance-types.d.ts +156 -0
  233. package/dist/module-instance.d.ts +19 -0
  234. package/dist/module-instance.js +2 -0
  235. package/dist/module-instance.js.map +1 -0
  236. package/dist/module-runtime-identity.d.ts +4 -0
  237. package/dist/module-runtime-identity.js +2 -0
  238. package/dist/module-runtime-identity.js.map +1 -0
  239. package/dist/module-scope-open.d.ts +4 -0
  240. package/dist/module-scope-open.js +2 -0
  241. package/dist/module-scope-open.js.map +1 -0
  242. package/dist/module-scope-retirement.d.ts +3 -0
  243. package/dist/module-scope-retirement.js +2 -0
  244. package/dist/module-scope-retirement.js.map +1 -0
  245. package/dist/module-template-ir.d.ts +59 -0
  246. package/dist/owner-generation-retirement.d.ts +6 -0
  247. package/dist/owner-generation-retirement.js +2 -0
  248. package/dist/owner-generation-retirement.js.map +1 -0
  249. package/dist/owner-generation-state.d.ts +123 -0
  250. package/dist/owner-generation-state.js +2 -0
  251. package/dist/owner-generation-state.js.map +1 -0
  252. package/dist/owner-generation.d.ts +16 -0
  253. package/dist/owner-generation.js +2 -0
  254. package/dist/owner-generation.js.map +1 -0
  255. package/dist/public-module-definition.d.ts +9 -0
  256. package/dist/public-module-definition.js +2 -0
  257. package/dist/public-module-definition.js.map +1 -0
  258. package/dist/public-module-instance.d.ts +6 -0
  259. package/dist/public-module-instance.js +2 -0
  260. package/dist/public-module-instance.js.map +1 -0
  261. package/dist/public-module-retirement-diagnostics.d.ts +5 -0
  262. package/dist/public-module-retirement-diagnostics.js +2 -0
  263. package/dist/public-module-retirement-diagnostics.js.map +1 -0
  264. package/dist/public-module-retirement.d.ts +3 -0
  265. package/dist/public-module-retirement.js +2 -0
  266. package/dist/public-module-retirement.js.map +1 -0
  267. package/dist/public-module-scope.d.ts +5 -0
  268. package/dist/public-module-scope.js +2 -0
  269. package/dist/public-module-scope.js.map +1 -0
  270. package/dist/public-module-state.d.ts +28 -0
  271. package/dist/public-module-state.js +2 -0
  272. package/dist/public-module-state.js.map +1 -0
  273. package/dist/public-module-types.d.ts +295 -0
  274. package/dist/public-module.d.ts +4 -0
  275. package/dist/resource-cache.d.ts +12 -0
  276. package/dist/resource-cache.js +2 -0
  277. package/dist/resource-cache.js.map +1 -0
  278. package/dist/resource-controller.d.ts +29 -0
  279. package/dist/resource-controller.js +2 -0
  280. package/dist/resource-controller.js.map +1 -0
  281. package/dist/resource-policy.d.ts +16 -0
  282. package/dist/resource-policy.js +2 -0
  283. package/dist/resource-policy.js.map +1 -0
  284. package/dist/resource-snapshot.d.ts +12 -0
  285. package/dist/resource-snapshot.js +2 -0
  286. package/dist/resource-snapshot.js.map +1 -0
  287. package/dist/resource-types.d.ts +3 -0
  288. package/dist/runtime-error-reporting.d.ts +4 -0
  289. package/dist/runtime-error-reporting.js +2 -0
  290. package/dist/runtime-error-reporting.js.map +1 -0
  291. package/dist/stream-backpressure.d.ts +18 -0
  292. package/dist/stream-backpressure.js +2 -0
  293. package/dist/stream-backpressure.js.map +1 -0
  294. package/dist/stream-cleanup.d.ts +17 -0
  295. package/dist/stream-cleanup.js +2 -0
  296. package/dist/stream-cleanup.js.map +1 -0
  297. package/dist/stream-controller-types.d.ts +51 -0
  298. package/dist/stream-controller.d.ts +5 -0
  299. package/dist/stream-controller.js +2 -0
  300. package/dist/stream-controller.js.map +1 -0
  301. package/docs/agent-guide.md +214 -0
  302. package/docs/agent-guide.ru.md +208 -0
  303. package/docs/cookbook.md +734 -0
  304. package/docs/cookbook.ru.md +729 -0
  305. package/docs/decisions.md +1437 -0
  306. package/docs/devtools.md +423 -0
  307. package/docs/devtools.ru.md +419 -0
  308. package/docs/how-it-works.md +521 -0
  309. package/docs/how-it-works.ru.md +495 -0
  310. package/docs/releases.md +78 -0
  311. package/docs/releases.ru.md +78 -0
  312. package/docs/spec.md +874 -0
  313. package/docs/spec.ru.md +884 -0
  314. package/package.json +72 -0
package/docs/spec.md ADDED
@@ -0,0 +1,874 @@
1
+ # Opetope — Framework Specification
2
+
3
+ Normative specification of the Opetope feature-composition framework: `@opetope/core`, `@opetope/runtime`,
4
+ `@opetope/react`. Everything below describes the framework as it is. Where a rule would otherwise look arbitrary, a
5
+ single trailing pointer names the decision that fixed it.
6
+
7
+ ---
8
+
9
+ ## 1. Purpose and principles
10
+
11
+ Opetope composes features: a graph of features with explicit dependencies, owned state with an exact lifecycle,
12
+ typed calls with policies, extension points with cardinality, and a React layer without a service locator. Slots
13
+ and the explicit graph form a small author vocabulary. The runtime owns lifecycle, subscriptions and call guards;
14
+ observed data follows the lifetime of its readers without hand-written `useEffect`.
15
+
16
+ An explicit non-goal: SSR, hydration and persistence. Data lives in the models and resources of an instance and is not
17
+ serialized — the framework offers neither a snapshot for a server nor a restore from one; moving that boundary is a
18
+ separate RFC (see decisions.md, D155).
19
+
20
+ Everything else is derived from these principles.
21
+
22
+ 1. **One semantic fact is declared once.** Mechanical consequences are produced by normalization and checked by
23
+ validation.
24
+ 2. **One concept, one word, at every stage.** A model is declared with `defineModel`, created with `model(...)`, read
25
+ with `useModel`.
26
+ 3. **A feature author sees only the author vocabulary.** The kernel, the instance machine, the IR and authority live
27
+ in `internal` for framework implementation and host integration. The cap is 40 value exports and 60 types
28
+ across the three safe entries.
29
+ 4. **Only `id` is mandatory.** Sections appear on demand; an author never writes an empty section.
30
+ 5. **Layers are separated physically.** A feature's UI imports only that feature's own contracts. A model knows
31
+ nothing about the feature, its ports or its imports: it declares its dependencies as its own interface and the
32
+ feature supplies them. Another feature is reachable only through `imports` or `requires`.
33
+ 6. **Sugar lowers into one IR.** Every short form has a compile test proving equivalence to the long form and creates
34
+ no second runtime path.
35
+ 7. **The framework is independent of its host.** Application adapters depend on `@opetope/*`; framework packages
36
+ do not depend on application code. Host services enter through explicit contracts.
37
+
38
+ ---
39
+
40
+ ## 2. The shape of a feature
41
+
42
+ A feature is one `defineFeature` with seven sections. The example uses two features: `catalog.catalog` owns the item
43
+ catalog and offers a "resolve item" port; `checkout.createForm` requires that port and renders a form.
44
+
45
+ ```ts
46
+ // features/catalog/integration/platform/catalog/feature.ts
47
+ const catalogCatalogFeature = defineFeature({
48
+ id: 'catalog.catalog',
49
+ imports: { platform: catalogHost }, // defineHostContract<CatalogHost>('catalog.catalog.platform')
50
+ own: ({ imports, resource, calls }) => ({
51
+ // async materialization with retention; the source and the target selector are positional
52
+ catalog: resource(imports.platform, source => source.target, {
53
+ key: target => target,
54
+ load: (_target, { signal, source }) => source.load(signal),
55
+ retention: scoped({ capacity: 1 }),
56
+ }),
57
+ ...calls(imports.platform, ['resolveItem']), // host methods as Calls, one to one
58
+ }),
59
+ exports: ({ own }) => ({ resolveItem: own.resolveItem }), // for features that import this definition
60
+ provides: ({ port, own }) => ({
61
+ resolve: port(resolveItemPort, own.resolveItem), // the port for whoever requires it
62
+ }),
63
+ });
64
+ ```
65
+
66
+ ```ts
67
+ // features/checkout/models/orderModel.ts — knows nothing about the feature
68
+ type OrderPlatform = {
69
+ resolveItem(input: { itemId: string }, signal: AbortSignal): Promise<Item | null>;
70
+ submit(intent: Intent, signal: AbortSignal): Promise<Receipt>;
71
+ };
72
+
73
+ export const OrderModel = defineModel<{
74
+ amount: Readable<number>;
75
+ item: Readable<Item | null>;
76
+ resolveItem: Call<{ itemId: string }, Item | null>;
77
+ submit: Call<Intent, Receipt>;
78
+ }>('checkout.order');
79
+
80
+ export const createOrderModel = (ctx: ModelContext, platform: OrderPlatform): ModelOf<typeof OrderModel> => {
81
+ const amount = ctx.state(0); // owned state; writes go only through ctx.update
82
+ const item = ctx.state<Item | null>(null);
83
+ // a dependency method of the form `(input, signal)` becomes a `Call` in one line
84
+ const { resolveItem } = ctx.calls(platform, ['resolveItem']);
85
+ const submit = ctx.call({
86
+ run: async (intent: Intent, { invoke, signal }) => {
87
+ const resolved = await invoke(resolveItem, { itemId: intent.itemId }); // the call goes through the context
88
+ ctx.update(item, resolved);
89
+ return platform.submit({ ...intent, precision: resolved?.precision }, signal);
90
+ },
91
+ });
92
+
93
+ return { amount, item, resolveItem, submit };
94
+ };
95
+ ```
96
+
97
+ ```tsx
98
+ // features/checkout/integration/platform/createForm/feature.tsx — the only place that knows both sides
99
+ const createFormFeature = defineFeature({
100
+ id: 'checkout.createForm',
101
+ imports: { platform: createFormHost, catalog: catalogCatalogFeature }, // a host by contract, a feature by definition
102
+ requires: { resolveItem: resolveItemPort }, // a port; the application picks the provider
103
+ own: ({ model, imports, requires }) => ({
104
+ // the dependency map resolves refs: the factory receives the materialized host value
105
+ order: model(OrderModel, { platform: imports.platform }, (ctx, { platform }) => createOrderModel(ctx, platform)),
106
+ lookup: requires.resolveItem, // inside `own` a port stays a ref
107
+ }),
108
+ // here `own` is already materialized: both the port and the model field arrived as live `Call`s
109
+ exports: ({ own }) => ({ lookup: own.lookup, submit: own.order.submit }),
110
+ provides: ({ slot, pipe }) => ({
111
+ // a contribution is a value, an instance factory or a pipe descriptor; `priority` and `when` go in the options
112
+ content: slot(orderFormContentSlot, ({ exports, model }) => ({
113
+ Component: CreateForm,
114
+ // the contribution's UI model: created per mount, and it sees the `props` of exactly that mount
115
+ models: [
116
+ model(OrderActions, (ctx, props: Readable<OrderFormProps>) => ({
117
+ submit: ctx.call({
118
+ run: (amount: number, { invoke }) => invoke(exports.submit, { amount, itemId: props.getSnapshot().itemId }),
119
+ }),
120
+ })),
121
+ ],
122
+ props: ({ itemId }: OrderFormProps) => ({ itemId }), // adapts slot props to component props
123
+ })),
124
+ toolbar: slot(
125
+ orderHeaderToolbar,
126
+ { Component: SubmitToolbarButton },
127
+ // a predicate of this instance: `read` records what the answer depends on, and `false` withholds the entry
128
+ { priority: 10, when: ({ imports, read }) => read(imports.platform.submitAllowed) },
129
+ ),
130
+ // a pipe declares its handler as a descriptor: `fold` runs on a fold, with this instance's context
131
+ amount: pipe(orderAmountFormatter, { fold: value => value.trim() }),
132
+ }),
133
+ when: [authorized], // the feature lives while the condition holds; without `when` it lives with the application
134
+ });
135
+ ```
136
+
137
+ ```tsx
138
+ // features/checkout/ui/createForm/contracts.ts — the UI contracts
139
+ export const orderFormContentSlot = defineSlot<OrderFormProps>({ id: 'checkout.createForm.content' });
140
+
141
+ export const OrderActions = defineModel<{ submit: Call<number, Receipt> }>('checkout.createForm.actions');
142
+
143
+ // features/checkout/ui/createForm/CreateForm.tsx — the component imports only its own feature's contracts
144
+ export const CreateForm = requiresModels([OrderActions])(({ itemId }: { readonly itemId: string }) => {
145
+ const order = useModel(OrderModel); // an `own` model: the contribution itself serves it
146
+ const actions = useModel(OrderActions); // the contribution's UI model: it is listed in `requiresModels`
147
+ const amount = useReadable(order.amount);
148
+ const { run, inFlight, result, lastError } = useCommand(actions.submit);
149
+
150
+ return <Form amount={amount} itemId={itemId} busy={inFlight} error={lastError} receipt={result} onSubmit={run} />;
151
+ });
152
+ ```
153
+
154
+ Features are collected in an application manifest, which the host can load behind a dynamic boundary. The host
155
+ passes platform adapters through `bind` and mounts consumer-owned targets:
156
+
157
+ ```ts
158
+ const sampleAppApplication = defineApplication({
159
+ id: 'sampleApp',
160
+ features: [catalogCatalogFeature, createFormFeature],
161
+ reporter: error => sentry.capture(error),
162
+ });
163
+
164
+ const execution = openApplication(sampleAppApplication, {
165
+ conditions,
166
+ imports: [bind(catalogHost, catalogSource), bind(createFormHost, createFormSource)],
167
+ });
168
+ ```
169
+
170
+ ```tsx
171
+ <Slot props={{ itemId }} target={orderFormContentSlot} />
172
+ ```
173
+
174
+ What is not visible here, and must stay invisible to an author: module, fence, retire, drain, quarantine, lane-lease,
175
+ publication barrier. All of it belongs to the runtime and its integration layer.
176
+
177
+ ### 2.1 The sections of `defineFeature`
178
+
179
+ | Section | Answers | Form |
180
+ | ----------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
181
+ | `id` | What the feature is called | `'feature.entity'`, dots only, no suffixes |
182
+ | `imports` | What the outside gives me | a record of `defineHostContract` contracts and other feature definitions; `optional(x)` is a weak edge, `onDemand(hostContract)` defers binding to the first use |
183
+ | `requires` | Which port I need | a record of `port` or `optional(port)`; each `requires.x` is a ref, and the `Call` appears on the instance |
184
+ | ref vs Call | What is visible at which stage | `own` and the outer `provides` factory see refs; `exports` and nested contribution factories see materialized instance values |
185
+ | `own` | What I own | builder: `model`, `call`, `calls`, `lane`, `effect`, `event`, `resource`, `stream`, `scope`, `attach` |
186
+ | `exports` | What I hand to features that import me | `({ own }) => a record of Call, Readable and Resource` computed when the instance opens |
187
+ | `provides` | What I offer to extension points | `({ port, slot, pipe, register, own }) => ...` |
188
+ | `when` | How long the feature lives | a list of `Condition`; empty or absent means a permanent feature |
189
+
190
+ Only `id` is mandatory. A stage sees only the stages before it: `imports`, `requires` → `own` → `exports`,
191
+ `provides`. There is no `ui` section: UI enters the application as contributions.
192
+
193
+ A feature may also be written as two files (D186, D207). The header keeps `id`, `imports`, `requires`, a
194
+ `provides` of metadata — the target of every contribution with its priority, the port of every provider — with `when` and
195
+ `body: () => import('./feature.body')`; the body file writes `defineFeature.body(header, { own, exports, provides })`
196
+ and its loader names the export border as `FeatureBody<Exports>`, so the header never depends on the type of its own
197
+ body. Consumers and the application import only the header: the whole topology, its cycles and its lifetime
198
+ containment compile before a byte of the body is fetched. The body loads when an instance opens, once per feature and
199
+ shared by concurrent consumers; a loaded body is reused by later instances, a failed load is not cached, and a close
200
+ or a fence before the code arrives means the instance never opens at all. `defineFeature.preload(feature)` fetches
201
+ the code without opening anything, for a host that already knows the feature is about to be needed.
202
+
203
+ ### 2.2 The model layer
204
+
205
+ A model is the unit of a feature's owned state and behaviour. The declaration `defineModel<Shape>(id)` lives in
206
+ `features/x/models/` or in the UI contracts and fixes a record of `Readable` and `Call`; a class as `Shape` does not
207
+ type-check. The implementation is a factory `(ctx: ModelContext, deps) => Shape` or a class
208
+ `implements ModelOf<typeof X>` that receives `ctx` and `deps` in its constructor. `ModelContext` has no generic
209
+ parameter: `state`, `update`, `call`, `calls`, `lane`, `effect`, `event`, `resource`, `stream`, `scope`, `timers`,
210
+ `cleanup`, `signal`. `ctx.calls(deps, [...], { lane, policy })` selects methods of the forms `() => Output`,
211
+ `(input) => Output` and `(input, signal: AbortSignal) => Output`
212
+ from the dependencies and returns one `Call` per key — the same selection and the same type inference as `own.calls`,
213
+ plus an optional model lane and the policy every selected call is created with. A method without parameters gets
214
+ `void` input; an optional input retains `undefined`. The second parameter must be exactly `AbortSignal` or
215
+ `AbortSignal | undefined`, including an optional signal. Data fields, `any` signatures, unbounded rest parameters
216
+ and signatures with a possible third argument are not selectable. A bounded tuple rest with at most two parameters
217
+ follows the same rule. Overloads follow TypeScript's last-signature inference; use an explicit adapter when the
218
+ desired overload differs (D244). The policy is written where the call is created — `ctx.call`, `ctx.calls`,
219
+ `own.call` — and it holds for every caller the call allows, `context.invoke(target, input)` included (D185). `ctx.cleanup(disposer)`
220
+ registers owned cleanup: the disposer runs in the same drain as the disposers of `effect`, `event`, `resource`,
221
+ `stream` and `scope`, in reverse registration order, its failure goes to the `reporter`, and a call after the fence
222
+ is a `TypeError`, as with `timers` after close. `ctx.state` returns `OwnedState<Value>`, and only that is accepted by
223
+ `ctx.update`: a derived `Readable` does not type-check. A model declares its dependencies as its own interface and
224
+ the feature substitutes them in `own`. The UI receives a model through `useModel(Decl)` only inside a mount of the
225
+ contribution that granted it.
226
+
227
+ **Declaration and execution (D243).** `ctx.call(options)` on the model factory context and `own.call(options)`
228
+ declare a command; `ctx.calls(source, keys, options?)` or `own.calls(source, keys)` declares several. A command's `run` receives a different
229
+ context: `context.invoke(target, input)` executes an existing command with the current authority, cancellation and
230
+ lane stack. Destructuring is supported: `run: (input, { invoke }) => invoke(deps.submit, input)`. The policy remains
231
+ on the declaration; `invoke` adds no scheduler or policy override.
232
+
233
+ This is a breaking rename with no execution-context `call` alias. Migrate accesses, destructured bindings and
234
+ custom context type annotations together. Factory methods and the types `Call` and `CallInvocation` keep their names:
235
+
236
+ ```diff
237
+ const submit = ctx.call({
238
+ - run: (input: Input, { call }) => call(deps.submit, input),
239
+ + run: (input: Input, { invoke }) => invoke(deps.submit, input),
240
+ });
241
+ -await context.call(submit, input);
242
+ +await context.invoke(submit, input);
243
+ ```
244
+
245
+ The same rename applies to existing invocation capabilities in `ModelCallContext`, `FeatureCallContext` and the
246
+ trusted attachment execution contexts, including `ModuleAttachmentExecutionContext` passed as `step`'s `context`.
247
+ Contexts that did not provide invocation do not gain it; callbacks keep their own documented capabilities.
248
+
249
+ **Signal lifetimes (D244).** Keep the signal from the callback doing the work:
250
+
251
+ | Signal | Lifetime |
252
+ | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
253
+ | `ModelContext.signal` | The whole owned model. It is unsuitable for cancelling just one request. |
254
+ | Command `run` context's `signal` | One physical command execution. Nested `invoke` inherits cancellation, authority and the lane stack; `singleFlight` waiters still have separate logical outcomes. |
255
+ | `Resource.load` context's `signal` | The active load for the current target generation; replacement, refresh, retirement or owner close may abort it while the model stays alive. |
256
+ | `Stream.connect` / `consume` context's `signal` | The current connection execution; replacement, retry or retirement fences that execution and drains admitted work before a successor opens. |
257
+
258
+ `calls` always invokes a selected method with `(input, signal)` and preserves its receiver. Ordinary methods may
259
+ ignore the extra arguments; no dummy `_signal` is required. A single parameter named `signal` is still an input,
260
+ not an injected cancellation token. Map a client's options or extra arguments in a small ordinary adapter, such as
261
+ `load: (input: Input, signal: AbortSignal) => client.load(input, { signal })`. There is no `Function.length`
262
+ inspection, argument inference or additional adapter primitive. A method that ignores cancellation can keep
263
+ working after the caller is logically cancelled. The runtime rejects late Call results and controls publication
264
+ from retired Resource/Stream executions; it cannot stop arbitrary state writes or external side effects inside the
265
+ method. Those need the method's own signal checks. Physical drain still waits for work that has started.
266
+
267
+ The feature composes dependencies with `model(Decl, { platform: imports.platform, lookup: requires.lookup }, create)`;
268
+ the second factory argument is a readonly map of resolved values with inferred types. Use the same map for one
269
+ source, or `model(Decl, create)` for none. Only authentic imports and call refs of the current feature are accepted;
270
+ optional imports retain `Readable<Lookup<…>>`. Raw host objects, port declarations, foreign refs and model refs do
271
+ not form dependency entries. A named factory may declare its own dependency interface without seeing the feature
272
+ context. `provides.port(SubmitPort, { from: own.order, select: order => order.submit })` selects an authentic call
273
+ once from the materialized model before publication and adds the provider's lifetime fence, including passthrough
274
+ calls. A local call ref still uses `port(SubmitPort, own.submit)` (D169).
275
+
276
+ A contribution has two kinds of model, both declared with the same `defineModel`. `own` models live with the feature
277
+ instance and are served to a mount automatically. A contribution's UI model lives with the mount: it is listed in the
278
+ contribution's `models`, its factory receives the `Readable` of that mount's props as its second argument and either
279
+ takes fields as they are (`submit: exports.submit`) or wraps them in `ctx.call` when the UI contract differs in
280
+ input, in result shape or in the number of feature calls. There is no separate View concept.
281
+
282
+ ### 2.3 UI
283
+
284
+ UI enters the application by one path: `provides.slot(target, { Component, props?, models? })` publishes a component,
285
+ and `Slot` renders it inside the contributing feature's authority. Inside a contribution, `useModel` works on its
286
+ instance's models and on the UI models the contribution declared, and above their fields sit `useCommand`,
287
+ `useReadable`, `useSelector`, `useResource`. Slot props are typed by the target: without an adapter the component
288
+ takes them as they are, with a `props` adapter it takes exactly the adapter's result.
289
+ `requiresModels([...])(Component)` lists the UI models the component reads: the type checks that the contribution granted every one of them. Granting is hybrid: the `own` models of the feature are served implicitly to every component of the mount's subtree, while a per-mount UI model must be declared by the component or hook of the same module that reads it with `useModel`. The declaration belongs to each reader, not only to the contribution site (see decisions.md, D158).
290
+
291
+ `opetope/require-declared-models` in `@opetope/lint` recommended checks visible `slot` contributions and model readers within one module. It resolves import aliases and local bindings, including named components. Imported implementations and dynamic declarations remain unverified; no diagnostic does not prove their requirements complete (D250).
292
+
293
+ Hooks: `useModel`, `useReadable`, `useSelector`, `useCommand`, `useCommands`, `useResource`. Components: `Slot`, and
294
+ `FeatureBoundary` from `@opetope/react/integration`, which holds a feature open and renders `fallback` or `error`.
295
+ Both of its branches take either a node or a render callback: `children` receives the ready instance typed by the
296
+ demand, `error` receives `{ error, retry }`, only the branch that is shown runs, and a ready consumer needs no
297
+ second `useFeature` and no second lease (D177). A
298
+ command through `useCommand` follows the Call’s declared policy, does not turn instance cancellation into a user-visible
299
+ error, and holds the last outcome in `result` and `lastError`; a command has no observation mode — the state of
300
+ somebody else's invocation is exposed by the model as a `Readable` field. Component tests use
301
+ `renderSlot(target, { props, models, contribution })` and `command(run)` from `@opetope/react/testing`.
302
+
303
+ `run` keeps its identity while the invoker is unchanged; the hook result object changes with status. Depend on `run`
304
+ when an effect invokes the command. A consumer has no policy of its own: every `run` reaches the call, and the policy
305
+ the call was created with decides what happens to it (D203). What stays with the consumer is what only it knows — an
306
+ already-aborted input signal is refused as `cancelled` without reaching the call, a run of an unmounted consumer is
307
+ refused the same way, `inFlight` is true while any run this consumer started is unsettled, and each run carries its
308
+ own outcome, callbacks and signal. Two consumers of one call keep separate statuses.
309
+
310
+ `useModel(Declaration, (model, { read }) => ({ ... }))` selects data and command consumers in one hook (D205, D214).
311
+
312
+ A model selection can use a named interface without an index signature (D217). Its result is a flat data record;
313
+ arrays, functions, constructors and built-in collection/date/promise objects are not selection records.
314
+ The readonly inferred record maps authentic Call fields to `CommandHook`; other selected fields preserve their types.
315
+ `read(readable, project?)` reads explicitly, deduplicates source subscriptions and applies no automatic Resource retain.
316
+ Returned fields use `Object.is` equality; fresh nested objects are changed fields. The pure callback cannot call hooks,
317
+ commands or side effects. Subscription replacement and command admission happen at commit, not in an abandoned
318
+ render. Keys, aliases, local statuses and `.run` follow `useCommands`. No model creation, feature acquire or new
319
+ scheduler is implied. `useModel(Declaration)` keeps returning the granted model, with a constant hook layout when
320
+ selection changes. Sharing one hook is a DX choice, not a performance guarantee.
321
+
322
+ `useCommands({ save: model.save, remove: other.remove })` returns a `CommandHook` at each key, with exact
323
+ heterogeneous types (D174). Keys are local names; commands come from the granted model authority. There is no shared
324
+ queue, transaction or busy state, and no option: the policy belongs to each call. While the key and invoker stay the
325
+ same, `.run` is stable and the field object changes only with its own status. A fresh selection object or key reorder
326
+ changes nothing. Removing an entry drops its consumer and status; readding creates a new one. Two aliases of one Call
327
+ are independent consumers. Own enumerable properties (including symbols) are selected; the number of React hooks is
328
+ constant when keys change. Only commit activates a new selection: an abandoned render leaves the committed consumers
329
+ untouched. The single-command `useCommand` remains.
330
+
331
+ Direct component props and the props adapter read the same mount-owned snapshot as model
332
+ readers; incoming slot props publish in layout before paint, never through a state update in insertion effects.
333
+ A retry of a `FeatureBoundary` demand belongs to its source: replacing source A with B does not make B wait for
334
+ an unfinished retry of A (D170). `useFeature(...).retry` returns a promise that resolves when the attempt it started
335
+ has settled, and a second call while one runs joins that attempt instead of starting another. It answers that promise
336
+ even when the source refuses the acquisition synchronously: a refused acquisition is an attempt that ended, and the
337
+ refusal belongs to the state the source publishes, not to the caller of `retry` (D226). The boundary shows
338
+ `fallback` for the life of the attempt and reads the state again when it settles, so an attempt that fails with the
339
+ same error object the host already holds brings the `error` subtree back (D198). The `retry` of the error subtree
340
+ stays `() => void`: a host may pass it straight to `onClick`.
341
+
342
+ `resource(from, target, options)` and `stream(from, target, options)` take a selector returning
343
+ `Readable<T | null | undefined>` in both feature and model contexts. A scalar snapshot is invalid; a nullish target
344
+ closes materialization. Retention is optional; `scoped({ capacity })` selects bounded LRU retention.
345
+ `effect({ from, when, run })` tests `when(current, previous)` for each selected run. `calls(imports.x, keys)`
346
+ selects host methods, the exported calls of a hard feature import, or — over `optional(feature)` — the exported calls
347
+ of the provider that is there now: each selected call reaches the live instance and answers a `CallError` of code
348
+ `unavailable` while nothing provides it (D187). Data of a weak edge is read with
349
+ `fromOptional(source, select, { missing })`: `select` runs only on a `found` lookup and may return a value or the
350
+ provider's own `Readable`, the nested readable is tracked only while the provider exists, and `missing` is the answer
351
+ the rest of the time.
352
+
353
+ ### 2.4 Application and host
354
+
355
+ ```ts
356
+ const authorized = defineCondition({ id: 'workspace.authorized' }); // a fact the host binds
357
+ const premium = defineCondition({ from: authFeature, id: 'workspace.premium', select: e => e.premium });
358
+
359
+ const sessionsFeature = defineFeature({ id: 'workspace.sessions', when: [authorized] /* … */ });
360
+
361
+ const workspaceApp = defineApplication({
362
+ id: 'workspace', // the runtime identity of the application
363
+ features: [authFeature, sessionsFeature, catalogFeature], // enabled features; the world closes over imports
364
+ reporter: error => sentry.capture(error),
365
+ });
366
+
367
+ openApplication(workspaceApp, {
368
+ conditions: {
369
+ 'workspace.authorized': derive({ from: example.session, select: session => session.status === 'authorized' }),
370
+ },
371
+ imports: [bind(authHost, example), bind(sessionsHost, agents)],
372
+ });
373
+ ```
374
+
375
+ A feature declares its own lifetime. `defineCondition({ id })` is a fact the host binds;
376
+ `defineCondition({ from, id, select })` computes a fact from the exports of one feature, and the host has no right to
377
+ bind such an id. `defineFeature({ when: [conditions] })` says the feature lives while all of its conditions hold; an
378
+ empty or absent `when` is a permanent feature. `defineApplication({ id, features, reporter })` compiles the static
379
+ topology before start: `requires ↔ provides`, `imports`, contributions. `features` is the list of enabled features,
380
+ and the world is those plus the transitive closure of their hard imports, so a port's provider must be enabled or the
381
+ port stays without one. Features with the same set of conditions form a condition group: it gives the activation
382
+ order and one node in the devtools plan; a group is never declared separately. `openApplication` raises the
383
+ application: `conditions` is a record of `Readable<boolean>` by id, typed from `Application<Features>` as the union
384
+ of condition ids without `from`, and `imports` is an array of `bind(contract, value)`, which validates the value at
385
+ the call site. The optional `cleanupFailure` carries the same two words as `openFeature` and defaults to `report`:
386
+ the host that chooses `quarantine` keeps a failed cleanup as an exact frontier on `FeatureError.retryCleanup`
387
+ instead of a report, and the devtools control port can then retry it (D182).
388
+
389
+ ---
390
+
391
+ ## 3. Public vocabulary
392
+
393
+ Naming rules: `define*` declares a contract without behaviour; `use*` reads in React; `open*` raises a lifetime;
394
+ builder methods carry no prefix; errors are `<Subject>Error` with a `code` field. A kernel word is the name of
395
+ machinery an author does not control: `Module`, `Attachment`, `Executor`, `Authority`, `Owner`, `Blueprint`, `IR`.
396
+ Such a word lives only in `internal`, appears in no name on a safe entry, and appears in no author-facing error text
397
+ — those say "feature" and "instance". `module` is the unit of lowering from which the kernel assembles features and
398
+ the children of a dynamic scope: a separate concept, not a second name for a feature. Both prohibitions are held by
399
+ the `ci:public-surface` gate.
400
+
401
+ **`@opetope/core`.** Values `computed`, `derive`, `fromOptional`, `externalReadable`, `collection`, `selectByKey`,
402
+ `declarationId`, `defineModel`, `definePort`, `definePipe`, `defineRegistry`. Types `Readable`, `State`, `OwnedState`, `Collection`,
403
+ `Lookup`, `Equality`, `Model`, `ModelOf`, `ModelContext`, `Call`, `Port`, `PortRef`, `Pipe`, `Registry`,
404
+ `DeclarationId`, `Resource`, `ResourceSnapshot`, `ScopedRetention`, `LatestBackpressure`. Errors
405
+ `CancellationError`, `FeatureError`, `ReadableError`, `DeclarationError`, `CallError`; `CallError` codes are
406
+ `cancelled`, `closed`, `publication-rejected`, `unavailable`. `createState` and `isCancellation` live on
407
+ `@opetope/core/internal`: an author has no pilot for state outside a model and none for recognizing cancellation by
408
+ hand — state comes from `ctx.state`, and cancellation is read by `useCommand`.
409
+
410
+ **`@opetope/runtime`.** Values `defineFeature`, `defineHostContract`, `onDemand`, `optional`,
411
+ `openFeature`, `defineCondition`, `defineApplication`, `bind`, `openApplication`, and the policies `scoped`,
412
+ `latest`. Types `Feature`, `FeatureInstance`, `HostContract`, `Condition`, `Application`, `ApplicationExecution`,
413
+ `ApplicationImportBinding`, `FeatureBody`, `FeatureBodyOf`, `Resource`, `ResourceSnapshot`, `ScopedRetention`, `LatestBackpressure`, `ErrorReporter`,
414
+ `CleanupFailurePolicy`, plus the re-exported `Call` and `Readable`. Errors `FeatureError` (re-export) and
415
+ `ApplicationError`.
416
+
417
+ **`@opetope/react`.** Values `useModel`, `useReadable`, `useSelector`, `useCommand`, `useCommands`, `useResource`, `requiresModels`,
418
+ `Slot`, `defineSlot`, `defineSwitchSlot`. Types `SlotTarget`, `SwitchSlotTarget`, `SlotContribution`, `CommandHook`,
419
+ `CommandOutcome`. The error `ContributionError` carries the codes `binding-invalid`, `duplicate`, `inactive`,
420
+ `missing`: its subject is the contribution mount, which grants models and fences commands, so there are no separate
421
+ `ModelError`, `RootError` or `ActionError`.
422
+
423
+ **`@opetope/react/integration`.** `FeatureBoundary`, `useFeature`, `useFeatureRetry`,
424
+ `FeatureBoundaryError`. **`@opetope/react/testing`.** `renderSlot`, `command`, `createScenario`, `ScenarioTimeoutError` and fixture/scenario types. There is no `useFeatureError`: a host
425
+ that passed an error into `error` already knows it, and the boundary context stays internal to `useFeatureRetry`.
426
+
427
+ **Lifecycle capabilities.** `FeatureInstance` exposes `ready` and `close()`. Closing fences immediately and joins one
428
+ completion promise. `FeatureError.retryCleanup?: () => Promise<void>` exists only on a quarantined failure with
429
+ retryable cleanup; test for that capability before calling. The internal term and cancellation code `retired`
430
+ remain. Resource retry and feature-demand retry keep their own meaning. The former author names `retire`,
431
+ `FeatureError.retry`, `effect.condition`, `required(port)` and the `scoped` option `evict` have no aliases (D168).
432
+
433
+ **Inside `defineModel`.** `ModelContext` per §2.2, with `scope` reading `scope.while`, `scope.switch`, `scope.keyed`.
434
+
435
+ **Inside `defineFeature`.** The sections of §2.1; the methods of `own`: `model`, `call`, `calls`, `lane`, `effect`,
436
+ `event`, `resource`, `stream`, `scope.while`, `scope.switch`, `scope.keyed`, `attach`. For `resource`, `stream` and
437
+ `event` the source and the carrier of type inference are positional: `resource(from, target, {…})`,
438
+ `stream(from, target, {…})`, `event(from, subscribe, {…})`; the key order inside the options object is free. One
439
+ option carries the same word for both: a stream requires `backpressure: latest()` and an `event` admits it. The
440
+ methods of `provides`: `port`, `slot`, `pipe`, `register`; contributions take the target first, a value or an
441
+ instance factory second, and options third: `slot(target, contribution, { priority, when })`,
442
+ `pipe(target, { fold }, { priority, when })`, `register(target, entry, { priority, when })`, where `pipe`'s second
443
+ argument is a descriptor because a bare function would be indistinguishable from the instance factory. The
444
+ contribution factory context is `{ exports, imports, instance, model, own }`, and its `model(Decl, create)` builds
445
+ the mount's UI model, where `create` receives `(ctx, props)`. `when` is a `Readable<boolean>` or a predicate of the
446
+ instance — `({ exports, imports, own, read }) => boolean` — whose `read` records what the answer depends on: while
447
+ the answer is `false` the contribution does not enter the target's `entries`. In that evaluation context `own` is
448
+ the materialized form `exports` sees, so a model field is a `Readable` and not the ref the factory context carries.
449
+ A `pipe` descriptor's `fold` receives that same evaluation context as its third argument —
450
+ `(value, meta, { exports, imports, own, read }) => …` — bound to the instance once when the contribution publishes
451
+ and called only by a `fold`, never at declaration or preload; `target.fold(value, meta, read?)` accepts the reader
452
+ as its third parameter, so `computed({ read: read => target.fold(0, undefined, read) })` subscribes both to
453
+ `entries` and to everything the handlers read; without a reader the handlers read the current snapshot. Contexts:
454
+ `source`, `signal`,
455
+ `invoke`, `update`, `timers.delay`, `timers.interval`, `cleanup`, `current`, `previous`, `emit`, `payload`.
456
+
457
+ Totals: 39 value exports and 35 types across the three safe entries by the `ci:public-surface` gate, against targets
458
+ of 40 and 60. The daily vocabulary of a feature author is about 20 words.
459
+
460
+ **`@opetope/core/internal` and `@opetope/runtime/internal`**, for framework implementation and host integration:
461
+ `defineModule`, `instantiateModule`, every `Module*` and `Attachment*`, the call kernel `defineCallTarget`,
462
+ `createCallExecutor`, `defineCallLane`, contribution publication `publishContributions`, `bindFeatureResource`,
463
+ `getApplicationPlan`, `createState` and `isCancellation`. The entries are bounded by actual consumption: 43 value
464
+ exports in core and 20 in runtime, and whatever nothing imports from outside the package stays module-local (see
465
+ decisions.md, D159). `@opetope/react` has no internal entry.
466
+
467
+ ---
468
+
469
+ `@opetope/devtools/react` exports `defaultTheme`, a neutral standalone palette. Applications customize the panel through `DevtoolsTheme`; overrides stay scoped to the panel root (D249).
470
+
471
+ ## 4. Laws of the kernel
472
+
473
+ Each law is proved by package tests and survives renaming.
474
+
475
+ - **Ownership and the feature instance.** Everything created in `own` lives and dies with the feature instance. Every
476
+ attachment hangs off an import or a host contract: a feature owns no scopes, so a per-instance value arrives by
477
+ exactly one path — through `imports` (see decisions.md, D141). Retire first makes the instance stale and fences its commands, then notifies, then closes its children. There is no order among the attachments of one scope: they open and close as one wave, and a quarantine frontier is reported in canonical slot order (see decisions.md, D163). The order between features of different condition groups is held by the group order, not by this law. A late result of an old instance is not published.
478
+ - **Physical drain and rollback.** Logical cancellation settles the caller promptly but does not imply that user
479
+ work has stopped. Drain waits for admitted work, including a pending opening and stream consumption, before
480
+ releasing its source. Models register rollback ownership before their factories run, including per-mount
481
+ contribution models. A failed factory cleans up partially created nodes. The fence closes all owned state and
482
+ rejects new nodes from the same context; a sibling model's `OwnedState` is not writable by that context (D170).
483
+
484
+ - **Coordinated close.** A close already running on one attachment does not cancel the close of the others: a
485
+ coordinated fence joins that drain and reports its outcome, so one member rolling back does not cost its
486
+ neighbours their close.
487
+ - **Cleanup.** The default is `report`: a cleanup error goes to the application reporter and the instance counts as
488
+ closed. `quarantine` is an explicit opt-in for resources with a mandatory physical release.
489
+ - **Data.** One scheduler stabilizes `derive` and `computed`, notification happens once per transaction, and
490
+ `selectByKey` creates no cross-key notifications.
491
+ - **Settled reads.** Reading an active clean node does not walk the graph: it returns the settled value, because
492
+ everything upstream has already published (see decisions.md, D130).
493
+ - **Transactional activation.** A failure midway rolls back the nodes activation already reached, in reverse order,
494
+ and disposer failures during the rollback arrive together with the original cause.
495
+ - **Close propagation.** `close()` on a `State` is one notification to dependent nodes, after which `derive`,
496
+ `computed`, `collection` and `selectByKey` answer with a `ReadableError` of code `closed` instead of the last
497
+ value, then stay silent; an unsubscribed derived node gets the same failure on its next read of the closed source.
498
+ A close is workspace even against the code that caused it: an updater that closes the state while it computes gets
499
+ its result refused, and the state stays closed (D201).
500
+ - **Lookup.** `Lookup` is literal: `{ kind: 'found', value } | { kind: 'missing' }`, and the same shape projects a
501
+ weak edge.
502
+ - **Calls.** `call` is `queue` by default, `parallel` is explicit, `latest` keeps one waiting place, `once` caches,
503
+ `singleFlight` deduplicates by key; a nested call goes only through `context.invoke`, and cancellation and the lane
504
+ are inherited.
505
+ - **Latest.** A call declared `latest` runs on a lane like `queue`, and while its body runs a newer input replaces
506
+ the waiting one: the replaced invocation ends as a cancellation with its own callbacks and signal, and the body in
507
+ flight is never interrupted. Replacement stops at the lane: once another call has queued behind the waiting
508
+ invocation, that invocation keeps its place and the newer input waits after it. A price of 100, a submit and a
509
+ price of 200 on one lane therefore submit 100 and then apply 200 (D185).
510
+ - **Event delivery.** An `event` runs one payload at a time behind a slot for one more. `backpressure: latest()`
511
+ gives that slot to the newest payload and reports nothing; an `event` written without the option keeps the payload
512
+ that reached the slot first and drops a newer `emit` with a `queue-capacity` failure record. Neither policy
513
+ interrupts a `run` in flight, neither holds more than one waiting payload, and closing the owner drops what waits.
514
+ A queue that loses nothing is a separate question (D183, D247).
515
+ - **Command outcomes.** `useCommand` does not turn cancellation into an error and holds the last outcome: `result`
516
+ from the last `ok`, `lastError` from the last `failed`, `cancelled` moves neither, and starting a run clears
517
+ neither, so the previous failure stays readable while a retry is in flight. A missing provider and a rejected
518
+ publication arrive as `failed` and settle into `lastError`.
519
+ - **Publication.** Contributions are published atomically after their owner's critical opening barrier and before
520
+ public `instance.ready` resolves, then withdrawn on retire;
521
+ contribution targets are static, so the topology compiles before start. A target holds one contribution per
522
+ feature key, so two live instances of one feature cannot contribute to one target at the same time.
523
+ - **Withheld contributions.** A contribution whose `when` is `false` is published but does not enter the target's
524
+ `entries`, so `Slot`, `fold`, `select` and emptiness checks do not see it; a change of the value recomputes
525
+ `entries` in one transaction. A predicate is lowered to one computed readable of its instance before the
526
+ publication starts, so the paths below are the same for both forms, and an answer that is not a boolean fails
527
+ that read instead of publishing a truthy object as visibility (D220). Uniqueness of ids and keys is checked across every published contribution, so a
528
+ change of `when` can never fail validation.
529
+ - **Publication atomicity.** A publication keeps its foreign steps outside its writes. Subscribing to `when` and
530
+ reading it both run before any target has touched its `entries` — the read comes after the subscription, so a source
531
+ that flips from inside `subscribe` is published in the state it ended in — and releasing the watcher a departing
532
+ entry leaves behind runs after every target of that publication is committed, so a withdrawal made from such a
533
+ disposer removes from the committed state instead of being undone by it. A publication reads only the `when` of its
534
+ own entries; every other owner keeps the visibility it was last published with, so a mandatory withdrawal reads no
535
+ `when` at all and an unreadable predicate cannot hold another owner's entries in a target (D225). A `when` that
536
+ signals while the publication projects invalidates that projection, and the affected targets are projected again; a
537
+ source that signals from every read fails the publication loud instead of committing what it answered before the
538
+ signal. A target that refuses the publication rolls back the subscriptions the earlier targets made, so a failed
539
+ publication leaves no listener behind, and a failed withdrawal removes what already landed. A change of `when` made
540
+ from inside an `entries` listener is not lost: the reentrancy guard records it and the loop publishes it.
541
+ A later visibility refresh also checks its exact entries and visibility revision after each getter. It discards
542
+ an invalidated projection before reading a removed neighbour or publishing stale entries; after 100 restarts it
543
+ fails with `TypeError` and releases its guard so a later stable notification can recover (D242).
544
+ - **Feature opening and closing.** Eager definitions and lazy headers validate and snapshot the option record, imports
545
+ and requirements at `openFeature`, before any loader runs; later replacement of those fields cannot change the
546
+ instance. `close()` fences synchronously and concurrent or reentrant callers join one completion promise. A
547
+ loader failure, including a synchronous throw, rejects `ready` or `preload`. Closing before code arrives rejects
548
+ that instance's `ready` as `retired` and finishes without waiting for the shared load. The load remains available
549
+ to other instances and preloads; a late success cannot open the retired instance, and a failure permits retry.
550
+ Once materialization has started, close still drains the owned work and preserves quarantine recovery (D211).
551
+ - **Application startup.** A close requested from a condition subscription or its disposer owns the same close
552
+ promise as every other caller. It fences the groups already created, releases a subscription returned after that
553
+ close, creates no later groups and rejects application readiness; `close()` waits for all started cleanup (D210).
554
+ - **Ports.** A port has exactly one provider; the choice is made at application level; two providers are a topology
555
+ compilation error.
556
+ - **Lifetime as conditions.** A feature declares its lifetime: it lives while all conditions of its `when` hold, and
557
+ an empty `when` means "as long as the application lives". Features with the same set of conditions form one group,
558
+ so the set, not the order, determines the owner. A condition is a desired state and not an event: the runtime
559
+ follows the last snapshot, so a value that flips false and true again within one turn keeps the live generation,
560
+ and the end of a session is expressed as a fact of the condition — a session id, say — rather than as a brief
561
+ false (D190).
562
+ - **The containment law.** A hard edge is legal only when the provider's condition set is a subset of the
563
+ consumer's: the provider must live at least as long, otherwise the compiler refuses and points at `optional`. The
564
+ same law holds for a condition with `from`: the source feature must outlive whatever the condition gates. A group
565
+ opens in the canonical order of its features and closes in reverse, and a condition computed from a closed feature
566
+ reads as `false`.
567
+ - **The weak edge.** `optional(x)` in `imports` and `optional(port)` in `requires` are one word with one meaning: an
568
+ edge on which the consumer may outlive the provider. Exports arrive as a `Readable<Lookup<Exports>>` projection,
569
+ resources behind it are unreachable while the projection says `missing`, and a call on a weak port settles as a
570
+ `CallError` of code `unavailable` while no live provider exists — an error, not a cancellation. The reason for
571
+ absence is not distinguished: provider absent from the application, or its condition group closed, look the same.
572
+ - **Weak-edge sugar.** The two halves of a weak edge have one word each: `calls(imports.x, keys)` for the exported
573
+ calls of the provider and `fromOptional(source, select, { missing })` for its data. Both read the provider at the
574
+ moment they are used, so a new instance is followed without rebuilding anything, an invocation in flight ends with
575
+ the provider's own fence, and nothing of a missing provider is held (D187).
576
+ - **Weak-edge inertness.** A weak edge does not pull its provider into the application's world and never affects
577
+ activation order: the provider opens with its own condition group, whoever imports it. The containment law holds
578
+ for hard edges only, and the compiler message on its violation points at `optional` (see decisions.md, D105).
579
+ - **Exports.** `exports` is computed when the instance opens, from an already materialized `own`, so the record
580
+ carries live values: the `Call`, `Readable` and `Resource` of that instance, not a snapshot and not a ref. A whole
581
+ model is not exportable — only its fields, because a model is a record of values, not a value. Owned state leaves
582
+ narrowed to `Readable`: the writer stays with the owner, and the type enforces it.
583
+ - **Hard-edge visibility.** On a hard edge the provider opens before the consumer and the consumer closes before the
584
+ provider, so the consumer never sees closed values. After the provider retires an exported `Readable` is closed at
585
+ the fence, so a subscriber hears it once and a read yields a `ReadableError` of code `closed`.
586
+ - **UI authority.** `useModel` resolves only models granted by the contribution mount: the models of its own instance plus the UI models the contribution declared. Granting is hybrid: `own` models reach every component of the subtree implicitly, a per-mount model is declared by the component or hook that reads it, and the lint rule holds that boundary because one mount frame cannot tell nested readers apart. There is no tree walking and no access to other features; the frame is stable, and a model update does not change Context.
587
+ - **React compatibility.** `@opetope/react` supports React `>=19.0.0 <20`; the Devtools UI and browser integration support matching React/React DOM versions in that range. Release acceptance installs separate React/React DOM and type-package cohorts at `19.0.0` and at the contributor toolchain versions, then checks NodeNext/Bundler types, model-backed rendering, commands, demand and Devtools mount/unmount on Node 20.19+ (D251).
588
+ - **Mount lifetime.** The instances of the `models` plans are created in commit and never in render (D188): the first
589
+ pass of a contribution renders nothing, a layout effect keyed by the identity of the contribution builds the mount
590
+ from the props that committed, and React flushes that one extra synchronous render before paint. A render that is
591
+ thrown away — the second StrictMode copy, an abandoned concurrent pass, a subtree suspended by a lazy sibling —
592
+ creates nothing at all, so nothing has to be swept, adopted or rebuilt, and a child layout effect never sees a
593
+ closed mount.
594
+ - **Structural readers.** `Readable`, `Resource` and the demand source are structural contracts, so a host may
595
+ implement one with an object whose members are methods. Every hook calls them through the object it was handed and
596
+ never as a detached function, so an adapter may rely on its own `this` (D199).
597
+ - **Committed mounts.** A committed contribution identity owns its model bundle. StrictMode effect replay and
598
+ Suspense hide/reveal reuse that live bundle and state; disconnecting layout effects does not retire it. Actual
599
+ identity replacement or unmount releases it once. A hidden unmount releases the bundle in a microtask because
600
+ React has already disconnected its layout effects; readable notifications never run in insertion effects
601
+ (D209). After release a call is a cancellation, not a product error.
602
+ - **One Call, one command.** A mount holds one command per distinct Call, however many model fields name it: two
603
+ fields of one model are aliases, so they share the record, the activation and the fence (D197). Two models of one
604
+ mount that declare the same model id are still a duplicate. A status stays local to its consumer: `useCommand` and
605
+ every key of `useCommands` keep their own `inFlight`, `result` and `lastError`, even when they name one Call.
606
+ - **Errors.** One class per author-vocabulary subject, with the state in a `code` field: `FeatureError`, `CallError`,
607
+ `ReadableError`, `ContributionError`, `ApplicationError`, `DeclarationError`. Cancellation is a marker keyed by
608
+ code, not a place in a hierarchy: it is true for `CallError` with `cancelled` or `closed`, `FeatureError` with
609
+ `retired`, and `ContributionError` with `inactive`. `CallError` with `unavailable` or `publication-rejected`
610
+ carries no such brand: those are an answer to the product, not an abandoned call. `FeatureBoundaryError` with code
611
+ `missing` obeys the same law and lives in `@opetope/react/integration`, outside the three safe entries' budget.
612
+ - **Boundaries.** `@opetope/*` does not import application hosts, their adapters or UI design systems. A production file under
613
+ `@opetope/*/src` is at most 600 lines. Hot paths use direct calls without `Reflect.apply`, bind wrappers or
614
+ argument-array allocation; they create no `Error` on success and add no defensive freeze inside loops. New
615
+ per-operation records require focused allocation measurements within the budgets. Hostile-boundary validation
616
+ happens where external data enters.
617
+
618
+ ---
619
+
620
+ Reentrant callbacks preserve these laws (D218): application close fences ready members of a still-opening condition
621
+ group, including a member returned after close. Resource abort/getter/key callbacks cannot overwrite a newer selection
622
+ or restore state after fence. Latest admission reserves its waiter and lane position before replacement notifications;
623
+ reentrant close/retire settles that waiter. Physical cleanup still follows dependency order.
624
+
625
+ Stream target selection follows the same freshness rule (D219): if a target getter or key callback synchronously
626
+ causes a newer selection, the older callback cannot replace it after returning. Selection freshness is separate from
627
+ request-key equality, so a newer target with the same key remains available to a subsequent refresh.
628
+
629
+ ## 5. Gates and budgets
630
+
631
+ Performance and size budgets are ratchets: raising a limit must not hide a regression. A public-vocabulary addition
632
+ within the target requires an explicit decision and a compile-checked application pilot; the gate records the
633
+ authorized change, as for D187 and D207.
634
+
635
+ ### 5.1 `ci:public-surface`
636
+
637
+ Counts the author-visible vocabulary of the three safe entries — `opetope/core/src/index.ts`,
638
+ `opetope/runtime/src/index.ts`, `opetope/react/src/index.ts` — against the current budget in
639
+ [public surface contract](spec.md#5-gates-and-budgets), with the totals and targets of §3. Names are
640
+ deduplicated across entries, so a re-export costs nothing.
641
+
642
+ The same gate holds four further checks over the internal entries `core/src/internal.ts`, `runtime/src/internal.ts`,
643
+ `react/src/integration.ts` and `react/src/testing.tsx`:
644
+
645
+ | Check | What fails |
646
+ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
647
+ | Retired words | `[Tt]ask[A-Z]`, `^Task$`, `[Ss]ignal[A-Z]`, `Domain[A-Z]`, `^Domain`, `View[A-Z]`, `^Setup[A-Z]` in any position of an identifier on an internal entry |
648
+ | Signature leaks | a public value whose return type names an internal-only type, outside a three-entry allowlist (`onDemand`, `optional`, `selectByKey`) |
649
+ | Nested kernel words | a walk of depth 4 over the properties and call results of every public type, refusing `Module`, `Attachment`, `Executor`, `Authority`, `Owner`, `Blueprint`, `IR` |
650
+ | Author messages | a string or template literal reaching an author error class that says module, attachment, executor, authority, owner, blueprint, generation, lifetime, task, setup or domain |
651
+
652
+ The author error classes are `ApplicationError`, `CallError`, `ContributionError`, `DeclarationError`,
653
+ `FeatureError`, `ReadableError`.
654
+
655
+ ### 5.2 `ci:size-limit`
656
+
657
+ Five budgets, and all five measure what an author sees. The fixtures are compiled consumers, not whole packages,
658
+ except for `@opetope/react`, which is measured on `dist`.
659
+
660
+ | Budget | Package | Limit |
661
+ | ---------------------------- | ------------------ | ------ |
662
+ | `author primitives consumer` | `@opetope/core` | 1.1 kB |
663
+ | `data layer consumer` | `@opetope/core` | 4.9 kB |
664
+ | `public feature consumer` | `@opetope/runtime` | 32 kB |
665
+ | `application graph consumer` | `@opetope/runtime` | 48 kB |
666
+ | `dist/**/*.js` | `@opetope/react` | 18 kB |
667
+
668
+ The `feature-consumer.ts` fixture holds the current form: a condition in `when`, a weak import and a weak port
669
+ through `optional`, live `exports`, and `port`, `register` and `pipe` contributions. `public feature consumer` is dominated by the feature runtime itself, not by how richly the fixture is composed: the minimal possible feature — one host contract, one `calls`, one `openFeature` — already weighs most of the budget, and removing `resource`, `model`, `port` or `register` moves the weight by tens of bytes, because the feature runtime graph is nearly monolithic.
670
+
671
+ npm artifacts use minified ESM with `keepNames: false`, preserve `.d.ts` declarations, and ship source maps with
672
+ `sourcesContent` for the original TypeScript (D254). Reflected function and constructor names are not stable API;
673
+ explicit declaration IDs, public error names/codes and `instanceof` identity remain intact. Maps support source
674
+ navigation and expose the implementation. Smaller raw JavaScript does not guarantee smaller tarballs or final
675
+ application bundles; see [release artifacts](releases.md#published-artifacts).
676
+
677
+ ### 5.3 Application build verification
678
+
679
+ Application bundle budgets and chunk placement belong to the consuming host. When an application loads its
680
+ manifest or feature UI dynamically, its build verification checks that the intended chunks exist and that code
681
+ intended for deferred loading is absent from the initial page chunks. A source-level dynamic import alone does not prove the produced
682
+ bundle preserves that boundary. Package size fixtures measure framework consumers; the host also measures its
683
+ complete application bundle.
684
+
685
+ ### 5.4 `ci:perf-memory`
686
+
687
+ Runs workloads at sizes 1, 100 and 10 000 (application: 1, 100 and 1 000) against
688
+ `perf-memory-budgets.json`, schema 11. The gate validates the reference baseline's schema; numeric enforcement
689
+ uses absolute caps and within-run scaling guards, not a comparison with its historical timing values.
690
+ Node `--expose-gc` is required.
691
+
692
+ Each workload and size completes one full warmup, three timing passes and three separate memory passes.
693
+ `results.*.*.durationMs` is median wall time around the complete workload: harness setup, awaited cleanup and
694
+ natural GC remain included. Timing callbacks for heap sampling and the measurement harness are no-ops;
695
+ forced collection before each pass finishes before the clock starts. Instrumentation time is not subtracted
696
+ from a mixed measurement. Memory passes preserve post-GC sampling, the baseline after harness setup and the
697
+ final post-cleanup collection; peak-live and retained memory take the maximum of three passes.
698
+ `timingSamples` and `memorySamples` retain all values, including `instrumentedDurationMs` and probe counters.
699
+ Every pass must have a finite checksum matching the warmup and valid measurements; errors fail without retries.
700
+
701
+ Schema 11 replaces the mixed timing method of D252. The 500 ms command and 1 200 ms application caps, all other
702
+ numeric budgets, sizes and workload bodies are unchanged. Old schema 10 timing numbers are not comparable;
703
+ a fresh full reference is recorded explicitly. Lower measured time after removing probes is not a runtime
704
+ speedup. This separation applies to `results`; separately named allocation, adapter, graph and listener
705
+ workloads retain their documented measurement methods (D253).
706
+
707
+ `allocationRate.*.estimatedAllocatedBytesPerOperation` uses V8 Poisson sampling with a 16 384-byte mean interval,
708
+ including objects collected by minor and major GC. It reports the median of three passes and includes measurement
709
+ overhead, excluding external backing stores; it is an estimate, not exact accounting. With zero samples, the gate
710
+ checks the 95% upper bound `-log(0.05) * samplingInterval / operations`. GC observation drains two event-loop turns
711
+ and filters by the workload window; a 500 000-object calibration checks collected allocations independently of
712
+ retained heap. `peakLiveBytesPerOperation` measures live heap after GC, not allocation rate. A baseline is written
713
+ only after successful enforcement (D172).
714
+
715
+ The saturated CallLane case uses the production queue at 1k, 10k and 100k pending requests. It checks FIFO drain
716
+ and arbitrary cancellation of alternating interior entries; enqueue time is separate from drain/cancel, with a
717
+ median of five passes and a per-operation growth guard. Browser verification separately creates a real application
718
+ edge and holds consumer cleanup pending to prove that its provider remains physically open.
719
+
720
+ `applicationGraphCompile` separately times `defineApplication` on chain, star, grouped and independent graphs
721
+ with 256, 1 024 and 4 096 features. Definitions are built outside timing; two warmups precede seven samples. Each
722
+ shape has a 100 ms cap at 4 096 features and a 3× per-feature growth guard, with a 1 ms timing floor. This schema 9
723
+ addition measures application compilation without changing the allocation measurement or existing caps (D213).
724
+
725
+ `hotPathScaling` adds batch/append/withdraw in one contribution target at 2k/4k/8k/16k entries,
726
+ alternating `latest` and control `queue` admission behind a blocked shared lane at 1k/2k/4k/8k pairs,
727
+ and idle inspection reads at 256/1 024/4 096 features, both empty and with a host import per feature.
728
+ It reports the median of five samples after two warmups, excluding setup, forced GC and cleanup.
729
+ Publication results, blocked execution, cleanup and the idle inspection sequence are checked. Each shape
730
+ has an absolute cap at its largest size and a 3× per-operation growth guard with a 1 ms small-size floor (D245).
731
+ The gate requires every declared size and exactly five finite samples per row, and verifies the reported median.
732
+
733
+ Perf and TypeScript gates validate all required numeric budget fields against an independent schema before
734
+ workloads run. Missing or unknown keys and non-numeric, negative or non-finite limits fail; enforced comparisons
735
+ also reject missing measurements. With `--enforce --write-baseline`, a failed verdict leaves the previous baseline
736
+ untouched; a successful report replaces it atomically. `ci:gate-config` checks these failure paths (D245).
737
+
738
+ Budget keys and the shape of each group:
739
+
740
+ | Group | Keys |
741
+ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
742
+ | `workloads` | `command`, `definitionCompile`, `reusedDefinitionLifecycle`, `contribution` — each with `durationMsAt10k`, `peakLiveBytesPerOperationAt10k`, `retainedMbAt10k` |
743
+ | `applicationGraphCompile` | chain, star, grouped and independent graphs: `medianMsAt4096` 100, `perFeatureGrowthFactor` 3 |
744
+ | `hotPathScaling` | each of seven shapes: `medianMsAtLargest` 50 for contributions, 150 for admission/inspection; `perOperationGrowthFactor` 3 |
745
+ | `listenerMultiplicity` | `contributionDurationMsAt10k` 3 000, `callDurationMsAt10k` 3 000, `peakLiveBytesPerOperationAt10k` 32 768, `retainedMbAfterUnmountAt10k` 14.5 |
746
+ | `coreData` | `graph`, `leaseChurn`, `settledRead` — the last with `microsecondsPerReadAtDepth1000` 1 and `depthGrowthFactor` 3 |
747
+ | `allocationRate` | `command` 5 120, `contribution` 3 072 bytes per operation at 10 k, `unobservedWrite` 8 bytes per operation at 100 k |
748
+ | `adapterDepth` | modes `syncPrimitive`, `plainObject`, `nativePromise`, `customThenable`, `neverSettlingCancel` |
749
+ | `lifecycleStepDepth` | `sync` and `async`, each with open, close, per-step bytes and retained MB at 1 000 |
750
+ | single-shape groups | `cleanupRetry`, `coldInternalImport`, `mailbox`, `readableSelector` |
751
+
752
+ `workloads.contribution` publishes and withdraws 10 000 contributions under 1 000 ms of full-workload timing and 16 384 peak-live bytes per operation;
753
+ `workloads.command` holds 2 048 peak-live bytes per operation at 10 k. `listenerMultiplicity` asserts the listener contract: the call controller carries no subscription member (`getSnapshot`/`subscribe` are absent, exactly `activate`, `close`, `diagnostics`, `run` remain — a command is invoked, not watched, see decisions.md, D162), a mounted `useReadable` holds exactly one listener on its `Readable`, and zero after unmount. `coreData.settledRead` reads an active clean chain 20 000 times at depths 1,
754
+ 100 and 1 000 under two budgets: one microsecond per read at depth 1 000, and growth of no more than 3× from depth 1
755
+ to depth 1 000.
756
+
757
+ ### 5.5 `ci:type-stress`
758
+
759
+ Compiles three generated fixtures — `large-feature-01`, `-20`, `-40` — with `--extendedDiagnostics` and probes the
760
+ language service on each. Schema version 4; the gate compares against a baseline of the same schema, and
761
+ `type-stress-budgets.json` states per slice which numbers are `enforced` and which are `informational`.
762
+
763
+ | Enforced budget | 01 | 20 | 40 |
764
+ | ------------------------------ | -----: | -----: | ------: |
765
+ | `tscInstantiations` | 10 000 | 80 000 | 155 000 |
766
+ | `tscMemoryMb` | 160 | 220 | 260 |
767
+ | `ideRssMb` | 400 | 600 | 650 |
768
+ | `ideDiagnosticMaxCount` | 4 | 4 | 4 |
769
+ | `ideDiagnosticMaxLineDistance` | 30 | 30 | 30 |
770
+
771
+ | Informational reference | 01 | 20 | 40 |
772
+ | ------------------------ | --: | --: | ----: |
773
+ | `tscCheckMs` | 150 | 350 | 550 |
774
+ | `tscTotalMs` | 500 | 800 | 1 000 |
775
+ | `ideColdCompletionMs` | 300 | 350 | 400 |
776
+ | `ideWarmCompletionP95Ms` | 10 | 10 | 12 |
777
+ | `ideQuickInfoP95Ms` | 10 | 10 | 12 |
778
+ | `ideDiagnosticMs` | 60 | 200 | 400 |
779
+
780
+ **The timing rule (D224):** every millisecond in this gate is informational. Each one is printed as
781
+ `actual of reference` and stops nothing, including the two ratchets built on time — the check time against twice its
782
+ recorded baseline and the 40-section check time against `max(2.75 × the 20-section check time, 1 000 ms)` — because
783
+ the same fixture swings around them on a loaded machine with no line changing. What the code decides on its own is
784
+ what the gate enforces: instantiations against the table and against `1.15 ×` the recorded baseline, compiler memory,
785
+ language-service RSS, the shape of the diagnostic cascade, and the declared surface of a slice against the baseline
786
+ it was recorded from.
787
+
788
+ ### 5.6 `ci:browser-floor`
789
+
790
+ Two passes over the production sources of `core`, `runtime` and `react`, excluding tests. The declared floor is
791
+ Chrome 82, Firefox 110, Safari 15, iOS 15, Android 82.
792
+
793
+ The surface pass resolves every member access, global constructor and global function against the TypeScript lib
794
+ declarations and refuses anything above the floor — `Object.hasOwn`, `Promise.any`, the `AbortSignal` extras,
795
+ `AbortController.abort(reason)`, `String.prototype.at`/`replaceAll`, the newer array methods, `Error.cause`,
796
+ `Error(options)`, `AggregateError`, `FinalizationRegistry`, `WeakRef` — outside two audited allowances:
797
+ `core/src/abort-compat.ts` for the abort helpers and `core/src/platform-compat.ts` for `AggregateError`.
798
+
799
+ The raw-dist pass transforms every emitted `.js` file with esbuild targeting `chrome82`, `firefox110` and `safari15`
800
+ and fails on the first byte where the transform differs from the shipped output.
801
+
802
+ ### 5.7 `ci:inspection`
803
+
804
+ The observation boundary validates `ringCapacity` as a non-negative safe integer before attaching (default 256;
805
+ zero retains snapshots but no delta history). Application diagnostic ids use the `application:` namespace, so
806
+ equal application and feature declaration ids cannot collide. Optional `cause.at` is a non-negative integer
807
+ within the Date domain, at most `8_640_000_000_000_000` epoch milliseconds. An activity replacement is a frame
808
+ change even when graph operations are empty (D242).
809
+
810
+ Runs the executable acceptance kit `assertInspectionSessionContract` from `@opetope/devtools/testing` against the
811
+ real `@opetope/runtime` producer, on a fixture application with a provider feature, a consumer feature carrying a
812
+ weak import, a weak port, a hard port and a `pipe` contribution, and one host-bound condition. The corpus is real
813
+ transactions: a condition change is the one transaction the runtime commits synchronously, so a synchronous kit sees
814
+ exactly that while everything else has already landed. Exactly one check may be marked `skipped` — the dropped-frame
815
+ resync, which the harness cannot force and which the runtime's own ring test covers. A second skip fails the gate.
816
+ After the kit the fixture drives the two facts of D167 on its own harness: it flips the `when` of one contribution
817
+ both ways and expects `withheld` and `published` back, then closes the application while its instances are live and
818
+ expects no live instance, no live contribution and a closed group. Any failed check fails the gate, not only a second
819
+ skip. It then drives the control port of D176 on a live group with a dependent: suspend must apply, show
820
+ `force-inactive` on the group and take the dependent with it, resume must clear both, an unknown condition and a
821
+ healthy instance must be refused with data, and `close()` must release the override. The gate lives in
822
+ `tooling/stress` so the runtime keeps no dependency on devtools; the one edge in the other direction is the devtools
823
+ attach seam, which opens both ports on behalf of a host (D248).
824
+
825
+ ### 5.8 `ci:docs` — decision citations
826
+
827
+ Every `Dnnn` in `opetope/**/src` and `tooling/stress/scripts/*.mjs` must resolve to a
828
+ `### Dnnn —` heading in `docs/decisions.md`. That is what makes the archive's promise — numbers are stable, existing
829
+ rows are not edited — checkable rather than merely stated: code is the reason the numbers cannot move.
830
+
831
+ Review finding codes (`L3-F17`, `R1-1`) are refused in the same sources; the gate covers `L<digits>-F<digits>`
832
+ and `R<digits>-<digits>`. They name a row of a report the reader of the code
833
+ does not have; a finding worth keeping in a comment is either a decision, and cites one, or a piece of reasoning, and
834
+ says it in words. Every package has been through that pass, so the ratchet of recorded counts is empty and a code is
835
+ now a plain error wherever it appears.
836
+
837
+ ## Scenario tests and physical activity
838
+
839
+ `createScenario(application, options)` from `@opetope/react/testing` opens the real application and its existing
840
+ inspection session (D206, D215). Supply the normal `imports`/`conditions` and a test-owned
841
+ `host.mount(Component)` adapter returning an `unmount()` handle. The package adds no DOM renderer or test-runner
842
+ dependency. `scenario.mount(target, { props })` uses the published Slot contributions and returns
843
+ `{ host, updateProps, unmount }`; `host` is the renderer's original result. Typed targets require `options.props`,
844
+ while targets without props omit it, exactly as with `Slot` (D217). Fixture commands do not bypass authority.
845
+
846
+ The synchronous constructor exposes `ready`, so a test can inspect a pending lazy body before readiness.
847
+ `waitFor(snapshot => predicate, { label, timeoutMs, pollIntervalMs })` wakes on inspection changes and also polls
848
+ external UI predicates; `notify()` wakes it after a controlled fixture update. The default deadline is 1000ms,
849
+ with a 10ms predicate poll. A `ScenarioTimeoutError` carries the data-only snapshot, bounded history and observed
850
+ conditions, feature phases, body loads, lane blockers and resource retention facts. It does not infer repository
851
+ or network causes. `getSnapshot()` and `history()` use that same observation model; history defaults to 64 snapshots,
852
+ activity to 256 records. Capacities accept integers from 1 to 10000. Do not replace predicates with a fixed number of ticks.
853
+
854
+ `close()` fences application admission synchronously, unmounts all registered screens and joins their cleanup with
855
+ physical application drain. Its deadline does not cancel cleanup: a later `close()` can await the same drain.
856
+ A readiness deadline likewise leaves the application available for inspection and explicit cleanup.
857
+ `ownership()` reports only registered runtime ownership, with `unknown` for missing, stale or truncated evidence;
858
+ a workspace stale snapshot is complete only after the scenario witnessed successful physical cleanup. This permits
859
+ a scoped zero-count assertion, without proving absence of arbitrary host, UI or GC leaks. Successful cleanup clears
860
+ application imports and internal renderer references. A failed cleanup promise can retain original errors and retry
861
+ capabilities; a caller that keeps `mounted.host` also keeps its own renderer result.
862
+
863
+ The inspection graph/frame schema is `/3`, with optional `opetope.runtime-activity/1` snapshots. Within one session,
864
+ a frame without `activity` preserves the previous activity; a full snapshot/reset without it clears that observation
865
+ (D216). Activity-bearing frames replace the previous activity in full.
866
+ Use matching runtime/devtools versions: `/2` readers reject the new revision. Activity identifies the execution,
867
+ actual feature generation, physical Calls, exact current lane blockers, registered resource leases and load attempts.
868
+ Host demand and UI models are unknown; stream observation covers state, not physical load identities. `freshness`
869
+ and `truncated` distinguish a complete live view from a partial or detached one. A closed session is stale;
870
+ `closed: true` requires successful physical application drain. No control authority or product payload is added.
871
+ Activity output is bounded by record capacity. Snapshot collection still visits registered owners, executors and
872
+ resources, so capacity does not bound traversal cost. Collection stops once truncation is proven;
873
+ idle executors may still require traversal to establish completeness. Normal call dispatch allocates no diagnostic record with
874
+ observation disabled. Graph frames remain bounded by the existing ring capacity.