@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
@@ -0,0 +1,734 @@
1
+ # Opetope: cookbook
2
+
3
+ > Recipes use the public entries; abbreviated snippets assume the surrounding declarations shown in the recipe.
4
+ > Stress compiles spec §2 examples, and the app type gate checks production pilots; it does not compile every
5
+ > cookbook fragment. Sections 3 and 9 use _illustrative_ names (`hasTasks`, `sessionsFeature`, `Badge`): the API is
6
+ > real, the production caller does not exist yet.
7
+
8
+ Practical uses of the framework through application examples. Every recipe gives you: when to apply it, the
9
+ code, what the framework checks, and the typical mistakes. The normative laws are in [spec.md](spec.md); how it
10
+ works inside is in [how-it-works.md](how-it-works.md); the rules for agents are in
11
+ [agent-guide.md](agent-guide.md).
12
+
13
+ ## 0. The shape of a feature in one minute
14
+
15
+ ```ts
16
+ const feature = defineFeature({
17
+ id: 'area.feature', // required, and the only required option
18
+ when: [authorizedCondition], // lifetime: while every condition holds; without when — with the application
19
+ imports: { platform: hostContract, other: otherFeature, maybe: optional(thirdFeature) },
20
+ requires: { resolve: resolveItemPort },
21
+ own: ({ imports, requires, calls, attach, call, lane, effect, event, resource, stream, scope, model }) => ({ … }),
22
+ exports: ({ own }) => ({ x: own.x }), // Call | Readable | Resource out of own; a whole model is not allowed
23
+ provides: ({ slot, pipe, register, port, own }) => ({ … }),
24
+ });
25
+ ```
26
+
27
+ A stage sees only the stages before it: `imports`/`requires` → `own` → `exports`/`provides`. Three words for edges:
28
+ hard (`imports: { x: feature }`, `port`) — the provider lives at least as long as I do and opens earlier;
29
+ weak (`optional(x)`) — the provider may be absent or may switch off, and I read a `Lookup`;
30
+ `when` — the conditions of my own lifetime.
31
+
32
+ ## 1. Host contract and host calls (`calls`), port provider
33
+
34
+ When: the feature takes an external capability from the application and hands it to other
35
+ features through a port.
36
+
37
+ ```ts
38
+ // contract.ts — a neutral file, with no feature imports
39
+ const catalogResolveSource = defineHostContract<{
40
+ resolveItem: (input: ResolveItemInput, signal: AbortSignal) => ItemSummary | null;
41
+ }>('catalog.resolve.platform');
42
+ const resolveItemPort = definePort<ResolveItemInput, ItemSummary | null>('checkoutControl.catalog.resolve');
43
+
44
+ // feature.ts
45
+ const catalogResolveFeature = defineFeature({
46
+ id: catalogResolveFeatureId,
47
+ imports: { platform: catalogResolveSource },
48
+ own: ({ calls, imports }) => ({ ...calls(imports.platform, ['resolveItem']) }), // host methods as Call
49
+ provides: ({ own, port }) => ({ catalog: port(resolveItemPort, own.resolveItem) }),
50
+ });
51
+ ```
52
+
53
+ What is checked: a port has exactly one provider in the application (two is a topology compile error); `calls` takes
54
+ only keys that exist on the contract (a type check). The mistake: putting the raw `imports.platform`, or a number,
55
+ into `own` — `own` accepts only values the builder produced.
56
+
57
+ ### Plain methods and client adapters
58
+
59
+ Do not add `_signal` to ordinary methods just to select them with `calls` (D244):
60
+
61
+ ```ts
62
+ import type { ModelContext } from '@opetope/core';
63
+
64
+ type Input = { readonly id: string };
65
+ type Result = { readonly title: string };
66
+ declare const ctx: ModelContext;
67
+ declare const form: { clear(): void; setAmount(amount: number): void };
68
+ declare const client: { load(input: Input, options: { signal: AbortSignal }): Promise<Result> };
69
+
70
+ const editing = ctx.calls(form, ['clear', 'setAmount']);
71
+ const api = {
72
+ load: (input: Input, signal: AbortSignal) => client.load(input, { signal }),
73
+ };
74
+ const { load } = ctx.calls(api, ['load']);
75
+ const reload = ctx.call({
76
+ run: (input: Input, { invoke }) => invoke(load, input),
77
+ });
78
+ ```
79
+
80
+ `editing.clear` takes `void`, `editing.setAmount` takes a number, and `load`/`reload` return `Result`.
81
+ `calls` preserves `form` as the methods' receiver. The adapter explicitly maps a client's options without changing
82
+ the client or adding a runtime primitive. Optional inputs and optional signals are accepted; a possible third
83
+ parameter, an arbitrary options object in the second position or an unbounded rest parameter needs an adapter.
84
+
85
+ ## 2. Requiring a port: a bare port and `optional`
86
+
87
+ ```ts
88
+ const checkoutItemLookupFeature = defineFeature({
89
+ id: checkoutItemLookupFeatureId,
90
+ requires: { catalog: resolveItemPort }, // the provider must be present in the application
91
+ own: ({ requires }) => ({ resolveItem: requires.catalog }), // requires.x is a ref; the Call appears on the instance
92
+ });
93
+ // a weak requirement: requires: { catalog: optional(resolveItemPort) } — a call with no live provider
94
+ // rejects with CallError code 'unavailable'; useCommand shows it as { status: 'failed', error }
95
+ ```
96
+
97
+ ## 3. Lifetime: conditions and `when`
98
+
99
+ When: the feature must live only while authorized, on a route, or behind an enabled flag.
100
+
101
+ ```ts
102
+ // a condition the host binds by id when it opens the application:
103
+ const flagEnabled = defineCondition({ id: 'sampleApp.flag' });
104
+ // features/auth/…/feature.ts — a condition computed from the exports of a feature; the host binds its source, not
105
+ // the condition, and the condition lives next to the feature it reads (D184):
106
+ const authorizedCondition = defineCondition({
107
+ from: authSessionFeature,
108
+ id: 'sampleApp.authorized',
109
+ select: exports => exports.authorized,
110
+ });
111
+
112
+ // one feature may own several conditions with different meanings: use separate conditions for
113
+ // UI visibility and strict authorization when their predicates differ (D204)
114
+ const sessionCondition = defineCondition({
115
+ from: authSessionFeature,
116
+ id: 'sampleApp.session',
117
+ select: exports => exports.hasSession,
118
+ });
119
+
120
+ const confirmActionFeature = defineFeature({ id, when: [authorizedCondition], … });
121
+ const logOutFeature = defineFeature({ id, when: [sessionCondition], … });
122
+
123
+ // the application
124
+ const app = defineApplication({ id: 'sampleApp', features: [...], reporter });
125
+ openApplication(app, { conditions: { 'sampleApp.flag': flagReadable }, imports: [bind(contract, value)] });
126
+ ```
127
+
128
+ The laws: features that share one set of conditions form a group, open in canonical order and close in reverse; a
129
+ hard edge is legal only when `provider.when ⊆ consumer.when` (otherwise the compiler points you at `optional`); a
130
+ consumer group waits for the providers in other groups to be ready and closes before them (D123). A condition read
131
+ from a closed feature reads as `false`. An application that names a computed condition must enable the feature it is
132
+ computed from; otherwise the topology does not compile.
133
+
134
+ ## 4. Lazy host contract, `attach`, lanes and call policies
135
+
136
+ When: the host contract is acquired on demand, and the calls must run strictly in order, with
137
+ an initialization step and without duplicates.
138
+
139
+ ```ts
140
+ own: ({ attach, call, imports, lane }) => {
141
+ const commands = attach(imports.commands); // imports.commands = onDemand(actionHost)
142
+ const commandsLane = lane({ within: commands }); // a queue that lives while the source is attached
143
+ const initialize = call({ lane: commandsLane, once: true, within: commands,
144
+ run: (_input: void, { signal, source }) => source.initialize(signal) });
145
+ const retry = call({ lane: commandsLane, within: commands,
146
+ run: (_input: void, context) => context.invoke(initialize, undefined) });
147
+ const confirmAction = call({ policy: 'parallel', within: commands,
148
+ singleFlight: (intent: ConfirmActionIntent) => intent.itemId,
149
+ run: (intent, { signal, source }) => runConfirmActionWorkflow(source, intent, signal) });
150
+ return { commands, initialize, retry, confirmAction };
151
+ },
152
+ exports: ({ own }) => ({ retry: own.retry, confirmAction: own.confirmAction }),
153
+ ```
154
+
155
+ `once` caches the first successful result; `singleFlight` joins parallel calls that share one key; `policy:
156
+ 'parallel'` lifts the lane queue; `within` fences the call together with its source. Cancellation is a `cancelled`
157
+ outcome, not a product error.
158
+
159
+ ## 5. A feature model built from an import, and live exports
160
+
161
+ ```ts
162
+ own: ({ imports, model, resource }) => ({
163
+ form: model(CheckoutCreateFormView, { platform: imports.platform }, (ctx, { platform }) => createCheckoutCreateFormModel(ctx, platform)),
164
+ tasks: resource(imports.platform, platform => platform.target, {
165
+ key: target => target, load: (_t, { signal, source }) => source.load(signal),
166
+ retention: scoped({ capacity: 1 }),
167
+ }),
168
+ }),
169
+ exports: ({ own }) => ({ tasks: own.tasks }), // a Resource goes out; owned state narrows to Readable
170
+ ```
171
+
172
+ `model(Decl, { source: imports.source }, (ctx, { source }) => …)` passes a readonly map of materialized
173
+ dependencies, including a single source. `model(Decl, create)` needs no dependencies. Inside the factory: `ctx.state` (owned state, written only through `ctx.update`), `ctx.call`, `ctx.lane`,
174
+ `ctx.effect`, `ctx.event`, `ctx.resource`, `ctx.stream`, `ctx.scope`, `ctx.timers`, `ctx.cleanup`. Model fields are
175
+ only `Readable` and `Call`.
176
+
177
+ Inside a model, `context.calls(deps, ['setAmount', 'setDirection'], { lane })` selects ordinary methods with no
178
+ parameters, one input, or `(input, signal: AbortSignal)` into Calls on one model lane (D143, D244). Optional
179
+ parameters are supported. A single parameter is always the input: for a signal-only method declare a command
180
+ with `context.call({ run: (_input: void, { signal }) => deps.load(signal) })` or use an explicit adapter.
181
+
182
+ A model can compose a host import and a required call without receiving the feature context. Its factory declares
183
+ its own dependency interface. A model call may also implement a port; the projection runs once before publication
184
+ and the provider's lifetime fences the selected call even when it is passed through from a dependency (D169).
185
+
186
+ ```ts
187
+ own: ({ imports, model, requires }) => ({
188
+ order: model(OrderModel, { platform: imports.platform, lookup: requires.lookup }, createOrderModel),
189
+ }),
190
+ provides: ({ own, port }) => ({
191
+ submit: port(SubmitPort, { from: own.order, select: order => order.submit }),
192
+ }),
193
+ ```
194
+
195
+ ## 6. UI as a slot contribution: per-mount model, props adapter, `requiresModels`
196
+
197
+ ```ts
198
+ // ui/…/contracts.ts
199
+ const CheckoutCreateFormActions = defineModel<{ deposit: Call<{ accountId: number; currency: string }, void>; … }>('checkout.createForm.actions');
200
+ const checkoutCreateFormContentSlot = defineSlot<CheckoutCreateFormContentProps>({ id: 'checkout.createForm.content' });
201
+
202
+ // ui/…/CheckoutCreateFormContent.tsx — the component declares only the models created per mount
203
+ const CheckoutCreateFormContent = requiresModels([CheckoutCreateFormActions])(({ renderForm }) => renderForm());
204
+
205
+ // integration/platform/…/feature.tsx
206
+ provides: ({ slot }) => ({
207
+ content: slot(checkoutCreateFormContentSlot, ({ model }) => ({
208
+ Component: CheckoutCreateFormContent,
209
+ models: [model(CheckoutCreateFormActions, (ctx, props: Readable<CheckoutCreateFormContentProps>) => ({
210
+ deposit: ctx.call({ run: ({ accountId, currency }) => props.getSnapshot().onDepositClick(accountId, currency) }),
211
+ }))],
212
+ props: ({ renderForm }: CheckoutCreateFormContentProps) => ({ renderForm }), // what the component sees
213
+ })),
214
+ }),
215
+ ```
216
+
217
+ The `own` models are available to the contribution component automatically (`useModel(CheckoutCreateFormView)`);
218
+ per-mount models are created in commit and close on unmount, and their commands are cancelled after unmount. An
219
+ abandoned render creates no models (D188). A component or hook reading a per-mount model must declare it through
220
+ `requiresModels`; check nested components and hooks as well. At runtime, a model absent
221
+ from the mount’s granted models fails with `ContributionError('missing')`.
222
+
223
+ A UI model wraps an export when the UI contract differs in input or in error shape:
224
+
225
+ ```ts
226
+ confirmAction: ctx.call({ run: async (_input: void, { invoke }) => {
227
+ const outcome = await invoke(exports.confirmAction, { itemId: props.getSnapshot().itemId });
228
+ if (outcome.type !== 'ok') throw new ConfirmActionCommandError(outcome.code, outcome.message);
229
+ }}),
230
+ ```
231
+
232
+ In React: `const { run, inFlight, result, lastError } = useCommand(actions.confirmAction)`; outcomes are values, and
233
+ `throw` is only for exceptional situations.
234
+
235
+ For an effect that invokes a command, depend on its destructured `run`, whose identity is stable for one invoker.
236
+ The whole hook result object changes with status. The hook itself schedules nothing: every `run` reaches the call,
237
+ and the policy the call was created with decides. An absolute setter such as amount or an enabled flag is declared
238
+ `policy: 'latest'` in the model's `context.call`, so the newest input replaces the waiting one and the displaced
239
+ input settles as `cancelled` without success or failure callbacks (D185, D203). Leave the default `queue` for submit
240
+ buttons: `latest` drops intents that never started and is wrong when every operation must execute.
241
+
242
+ A button that must not act twice says so in its call: `submit` declares `singleFlight`, so a second click while the
243
+ first submit is in flight joins that flight instead of queueing a second order. `inFlight` still disables the button;
244
+ the call is what makes a fast double click harmless.
245
+
246
+ Two controls that overwrite the same value need one command intent: AmountInput sends either
247
+ `{ amount, currency }` or `{ percent, currency }` to the same `setAmount` call, which is where the policy lives.
248
+ Two calls would each keep their own waiting place and could reorder the newest intent across controls. The model
249
+ dispatches the union to the existing host methods.
250
+
251
+ Direct props, an optional props adapter and model props all read the same mount
252
+ snapshot; a new slot snapshot publishes in layout before paint (D170).
253
+
254
+ ## 7. `pipe` with tracked reads and `slot` with `when`
255
+
256
+ ```ts
257
+ provides: ({ pipe, slot }) => ({
258
+ homeHeader: slot(exampleToolbarSlot('home'), { Component: ExampleActionButton },
259
+ { priority: 1, when: ({ imports, read }) => read(imports.platform.actions).config.actionId !== '' }),
260
+ itemCount: pipe(exampleCountPipe, { // a descriptor, bound once (D223)
261
+ fold: (count: number, _meta, { imports, read }) =>
262
+ count + read(imports.platform.actions).pendingCount, // read makes the fold trackable (D82)
263
+ }),
264
+ }),
265
+ ```
266
+
267
+ A `pipe` declares its handler as a `{ fold }` descriptor, which sees the same instance context as the predicate and
268
+ runs on a fold, never at declaration or preload. A predicate answers a boolean and its `read` records what the answer
269
+ depends on, so a dynamic branch follows the source it read last. `when: false` removes the contribution from
270
+ `entries` without withdrawing the publication; a change of the answer recomputes the target in one transaction.
271
+
272
+ ## 8. Resources, streams, events, effects, scopes
273
+
274
+ ```ts
275
+ own: ({ effect, event, imports, resource, scope, stream }) => ({
276
+ initialLoad: resource(imports.platform, p => p.target, { key: t => t,
277
+ load: (_t, { signal, source }) => source.initialize(signal),
278
+ retention: scoped({ capacity: 1 }), retry: { attempts: 2, delayMs: 1_000 } }),
279
+ quote: stream(imports.platform, p => p.target, { backpressure: latest(),
280
+ connect: (target, { emit, source }) => source.connect(target, emit),
281
+ consume: ({ data, source, target }) => source.updateTitle(`${data.middle} ${target.title}`),
282
+ key: t => `${t.pageId}:${t.itemId}`, retention: scoped({ capacity: 8 }) }),
283
+ polling: event(imports.platform, ({ emit, source, timers }) => {
284
+ timers.interval(pollingIntervalMs, () => emit(undefined));
285
+ return source.subscribeForeground(() => emit(undefined));
286
+ }, { run: ({ signal, source }) => (source.isOnline() ? source.refresh(signal) : undefined) }),
287
+ autoOpen: effect({ from: imports.platform, run: ({ current, source, timers }) => {
288
+ if (current === null || current.viewed) return;
289
+ return timers.delay(3_000, () => { if (!source.getSnapshot()?.viewed) source.showDetails(); });
290
+ }}),
291
+ inactivePageReset: effect({ from: imports.lifetime,
292
+ when: (current, previous) => !current.active && (previous === undefined || (!previous.active && previous.pageId !== current.pageId)),
293
+ run: ({ current }) => current.resetTitle() }),
294
+ activePage: scope.while({ from: imports.lifetime, when: l => l.active, open: l => () => l.resetTitle() }),
295
+ }),
296
+ ```
297
+
298
+ The positional form everywhere: `resource(from, target, {…})`, `stream(from, target, {…})`, `event(from, subscribe, {…})`.
299
+ Timers (`timers.delay/interval`) live with the instance and are cancelled on retire. The disposers of `effect/event/
300
+ resource/stream/scope/cleanup` run in one drain, in reverse order; registering after the fence is a `TypeError`.
301
+
302
+ The `target` selector of a resource or stream returns a `Readable<T | null | undefined>`, never a scalar snapshot.
303
+ Nullish targets close the current materialization. This contract and optional `retention` are identical in feature
304
+ and model factories; `scoped({ capacity })` uses LRU. The effect predicate is `when(current, previous)`.
305
+ Close a feature with `await instance.close()`; retry cleanup only after checking `error.retryCleanup` on a
306
+ quarantined `FeatureError`. A `FeatureError` is quarantined only when the host asked for it:
307
+ `openFeature(feature, { cleanupFailure: 'quarantine' })` for one instance and
308
+ `openApplication(app, { cleanupFailure: 'quarantine', … })` for every instance of an application; the default,
309
+ `report`, hands the failure to the reporter and calls the instance closed (D182). Resource retry and business commands named `retry` keep their existing meaning.
310
+
311
+ ### The contract of an external source adapter
312
+
313
+ The host owns the adapter, the feature owns the work. An adapter is a plain record of methods and readables whose
314
+ shape is the contract; it holds no state of the feature, opens nothing, and closes nothing.
315
+
316
+ ```ts
317
+ // features/tasks/integration/platform/resourceSource.ts — the host side of `tasks.resource.platform`
318
+ const tasksTarget = Object.freeze({
319
+ getSnapshot: () => 'tasks' as const, // a Readable, so the feature decides when to read it
320
+ subscribe: (_listener: () => void) => () => undefined, // a constant target still returns a disposer
321
+ });
322
+
323
+ function createTasksResourceSource(tasks: TasksInitializer): TasksResourceSource {
324
+ return Object.freeze({
325
+ load: async (signal: AbortSignal) => {
326
+ signal.throwIfAborted(); // the signal is the contract, not decoration
327
+ await tasks.init({ state: 'load' });
328
+ signal.throwIfAborted(); // and it is checked again after the await
329
+ },
330
+ target: tasksTarget,
331
+ });
332
+ }
333
+ ```
334
+
335
+ Four rules hold for every adapter: cancellable work uses the operation's signal; a value the feature must follow
336
+ is a `Readable`, never a snapshot the adapter refreshes itself; `subscribe` returns the disposer
337
+ of the underlying source, so releasing it releases exactly what was taken; and the adapter never imports the feature,
338
+ so the direction of the dependency is the direction of the contract.
339
+
340
+ In `load`, `connect` and `consume`, keep the signal passed by that callback: it belongs to the current target
341
+ execution, which can end before the model does. `ModelContext.signal` lasts for the whole model. A command's `run`
342
+ likewise gets its own execution signal, and nested `invoke` inherits cancellation; do not substitute the model
343
+ signal for it (D243, D244).
344
+
345
+ A synchronous setter needs no signal. If external work cannot be cancelled, state that in the adapter instead of
346
+ adding a cosmetic `_signal`. The caller can still settle as cancelled and Runtime rejects late Call results or
347
+ Resource/Stream publication, but the physical work may continue and drain must wait for it. Arbitrary state writes
348
+ inside that work and external side effects need their own signal checks; cancellation cannot undo them. Never assume
349
+ that an ignored signal or a check after `await` physically cancels the underlying client request.
350
+
351
+ ## 9. A weak edge in practice
352
+
353
+ ```ts
354
+ imports: { sessions: optional(sessionsFeature) },
355
+ own: ({ calls, imports, model }) => ({
356
+ // the calls of the provider that is there now; while nothing provides them: CallError 'unavailable' (D187)
357
+ ...calls(imports.sessions, ['confirmAction']),
358
+ badge: model(Badge, { sessions: imports.sessions }, (ctx, { sessions }) => ({ // sessions: Readable<Lookup<Exports>>
359
+ // the data half: `select` runs only on found, may return a value or the provider's own Readable
360
+ count: fromOptional(sessions, found => found.sessions.count, { missing: 0 }),
361
+ })),
362
+ }),
363
+ ```
364
+
365
+ Write the raw `derive({ from: sessions, select: lookup => … })` only when the two branches are genuinely different work;
366
+ `fromOptional` is the same thing without the `lookup.kind` ceremony, and it tracks a nested `Readable` only while
367
+ the provider exists.
368
+
369
+ `found`/`missing` do not tell you the reason: either the provider is absent from the application, or its condition
370
+ group is closed. A weak edge does not pull the provider into the application and does not affect activation order.
371
+ A call on a weak port without a provider rejects with `CallError` `unavailable`, and `useCommand` reports it as `failed` with the error in `lastError` — an answer, not a cancellation (D138).
372
+
373
+ Closing propagates (D146): once the provider instance is gone, its state is closed, and `derive`/`computed` over a direct (hard) import answer with a `ReadableError` `closed` instead of the last value; a weak edge shows `missing` instead — which is exactly the reason to take `optional` when the provider can die first.
374
+
375
+ ### A feature demand boundary
376
+
377
+ When host integration supplies a `FeatureDemandSource`, `FeatureBoundary` holds its lease and renders readiness,
378
+ failure and retry states:
379
+
380
+ ```tsx
381
+ // a node in both branches: the error subtree reads its retry with `useFeatureRetry`
382
+ <FeatureBoundary demand={host} error={<Retry />} fallback={<Spinner />}>
383
+ <Slot props={{ itemId }} target={confirmActionContentSlot} />
384
+ </FeatureBoundary>
385
+
386
+ // render callbacks, when the branch needs what only the boundary knows (D177)
387
+ <FeatureBoundary
388
+ demand={tasksDemand}
389
+ error={({ error, retry }) => <Failure error={error} onRetry={retry} />}
390
+ fallback={null}
391
+ >
392
+ {({ exports }) => <TasksResourceLease resource={exports.tasks}>{children}</TasksResourceLease>}
393
+ </FeatureBoundary>
394
+ ```
395
+
396
+ The callback of `children` is typed by the demand, so `exports` is the export record of that feature; the callback of
397
+ `error` receives `{ error, retry }`. Only the branch that is shown runs its callback, and the boundary already holds
398
+ the feature — a ready consumer therefore needs no second `useFeature` and takes no second lease. Hooks belong in the
399
+ child components the callback returns, not in the callback itself: the callback runs during the boundary's own
400
+ render, and the authority for `useModel`/`useCommand` still comes from a contribution through `Slot`.
401
+
402
+ ### A feature in two files
403
+
404
+ When the implementation is heavy — models, components, their libraries — the feature is written as a header and a
405
+ body, and only the header is imported by the application and by consumers (D186, D207):
406
+
407
+ ```ts
408
+ // features/tasks/…/feature.ts — the header: identity, edges, declared border, loader
409
+ const tasksFeature = defineFeature({
410
+ id: 'tasks.resource',
411
+ imports: { platform: tasksResourcePlatform },
412
+ when: [authorizedCondition],
413
+ provides: { screen: tasksScreenSlot }, // metadata: the target, and `{ priority, target }` when it matters
414
+ body: (): Promise<FeatureBody<TasksExports>> =>
415
+ import(/* webpackChunkName: "feature.tasks" */ './feature.body').then(module => module.tasksBody),
416
+ });
417
+
418
+ // features/tasks/…/feature.body.ts — the body: the three sections, in the chunk the header loads
419
+ const tasksBody = defineFeature.body(tasksFeature, {
420
+ own: ({ imports, resource }) => ({ tasks: resource(imports.platform /* … */) }),
421
+ exports: ({ own }) => ({ tasks: own.tasks }),
422
+ });
423
+ ```
424
+
425
+ The export type is written once, in the neutral contract file, and named by the loader — that is what keeps the two
426
+ files from inferring each other in a circle. The body implements exactly the border the header declared: another
427
+ target, another priority or another port is refused before anything opens. Loading starts when an instance opens,
428
+ never at declaration, so the plan is compiled and checked while the body is still on the network.
429
+
430
+ ## 10. Testing
431
+
432
+ ```ts
433
+ // mounting a contribution with no application
434
+ const harness = renderSlot(checkoutCreateFormOverlayHeaderSlot, { props, models: [[HeaderModel, { itemId }]] });
435
+ // a command fixture for a model
436
+ const confirm = command<ConfirmActionIntent, void>(async () => undefined);
437
+ // one feature with stubbed imports
438
+ const instance = openFeature(confirmActionFeature, { imports: { commands, view }, reporter: () => undefined });
439
+ await instance.ready; … await instance.close();
440
+ ```
441
+
442
+ The acceptance check for the inspection port is `assertInspectionSessionContract` from `@opetope/devtools/testing` (the `ci:inspection` gate in
443
+ `tooling/stress`).
444
+
445
+ ## 11. Anti-patterns
446
+
447
+ - `exports: ({ own }) => ({ order: own.order })` where `order` is a model: a whole model is not exported, only its fields.
448
+ - `throw` inside commands for product outcomes: return an author-defined outcome record such as `{ type: 'error', message }`; `throw` is for exceptions only.
449
+ - `onDemand(feature)` — no such word; a weak edge is `optional`. `onDemand` applies only to host contracts.
450
+ - Reading a per-mount contribution model with `useModel(X)` in a component or hook without `requiresModels([X])`, including nested readers (D158); `own` models need no declaration.
451
+ - `imports.platform.submit` inside a model factory — that is a ref, not a value; use `model(Decl, { platform: imports.platform }, (ctx, { platform }) => platform.submit…)`.
452
+ - Writing into someone else's `Readable`: `ctx.update` accepts only an `OwnedState` created by this context.
453
+
454
+ ## 12. Practices
455
+
456
+ 0. Separate feature composition, models and UI, for example as `features/<f>/{integration,models,ui}`. The host
457
+ declares the application graph and supplies external bindings.
458
+ 1. Contracts (`defineHostContract`, `definePort`, `defineSlot`, `defineModel`, `defineCondition`) go into neutral
459
+ `*.contract.ts` files with no feature imports; a feature imports a contract, not another feature, except for a
460
+ hard edge.
461
+ 2. One fact is declared once: do not duplicate the shape of a host contract in a contribution model, take the
462
+ `own` model.
463
+ 3. Keep `own` flat: the field names are the vocabulary of `exports` and of the contributions.
464
+ 4. Read errors by `code` (`FeatureError`, `CallError`, `ReadableError`, `ContributionError`, `ApplicationError`); cancellation is only `CallError` `cancelled`/`closed`, `FeatureError` `retired` and `ContributionError` `inactive` (D138) — `unavailable` and `publication-rejected` are shown by the product as an error.
465
+ 5. Before a commit: `tsc`, the package `ci:test`, `ci:eslint` and `ci:size-limit`; run the consuming application's
466
+ affected feature tests and production build checks. When a public name changes — `ci:public-surface` and a line
467
+ in the decision log.
468
+
469
+ ## Select a component contract from one model
470
+
471
+ For a form that already receives its model through a Slot, select the values and commands together.
472
+ Keep the Call policy in the model. Different granted models can still be combined with `useCommands`.
473
+ The standalone [React example](../../react/README.md#hello-ui) contains declarations and imports.
474
+
475
+ ```tsx
476
+ const { quantity, setQuantity, submit } = useModel(OrderForm, (model, { read }) => ({
477
+ quantity: read(model.state, state => state.quantity),
478
+ setQuantity: model.setQuantity,
479
+ submit: model.submit,
480
+ }));
481
+ ```
482
+
483
+ Only the selected quantity drives data renders. `submit.inFlight` is local to this consumer; aliases are independent.
484
+ For a projection returning an object, spread its fields into the returned selection or keep its reference stable.
485
+ A selector extracted from the component body (a module function or `useCallback`) is not called at all on an
486
+ unrelated render; an inline one runs again, and an equal selection keeps the previous snapshot, so the work
487
+ below the hook does not repeat.
488
+
489
+ ## Exercise an application through its Slot
490
+
491
+ The test supplies its existing renderer and controlled repository fixtures. This wiring sketch uses React Testing Library.
492
+
493
+ ```tsx
494
+ import { createScenario } from '@opetope/react/testing';
495
+
496
+ const scenario = createScenario(application, {
497
+ conditions: {},
498
+ imports: bindings,
499
+ host: { mount: Component => render(<Component />) },
500
+ });
501
+ try {
502
+ await scenario.ready;
503
+ const screen = scenario.mount(OrderScreenSlot);
504
+ fireEvent.click(screen.host.getByRole('button', { name: 'Submit' }));
505
+ await scenario.waitFor(() => repository.submitted.length === 1, { label: 'order submitted' });
506
+ } finally {
507
+ await scenario.close();
508
+ }
509
+ expect(scenario.ownership()).toMatchObject({ scope: 'registered-runtime', status: 'complete', resources: 0 });
510
+ ```
511
+
512
+ Use a named predicate for a lane wait or controlled load. A timeout contains the same graph/activity facts as the inspector; arbitrary host work remains unknown. See the [scenario contract](../../react/README.md).
513
+
514
+ ## Read a model through one selector
515
+
516
+ A component shows several fields of a model and runs two of its commands. Taken hook by hook, that becomes a list
517
+ nobody reads as a whole:
518
+
519
+ ```tsx
520
+ // what a consumer wrote first: the model, then a hook per field, then a hook per command
521
+ const actions = useModel(OrderActions);
522
+ const amount = useReadable(actions.amount);
523
+ const currency = useReadable(actions.currency);
524
+ const submit = useCommand(actions.submit);
525
+ const reset = useCommand(actions.reset);
526
+ ```
527
+
528
+ Nothing in the component states what it depends on: the answer is assembled by reading the body top to bottom, and
529
+ the next field is one `actions.total.getSnapshot()` away — a read with no subscription behind it, which renders once
530
+ and never updates again.
531
+
532
+ One selection says it in one place:
533
+
534
+ ```tsx
535
+ const { amount, currency, reset, submit } = useModel(OrderActions, (model, { read }) => ({
536
+ amount: read(model.amount),
537
+ currency: read(model.currency, value => value.code),
538
+ reset: model.reset,
539
+ submit: model.submit,
540
+ }));
541
+ ```
542
+
543
+ The record is the component's contract with the model: `read` is the only way in, so a field that is not read is not
544
+ subscribed and cannot silently go stale; each distinct `Readable` is subscribed once; and an authentic `Call` taken
545
+ as a field becomes the same `CommandHook` the individual hook returns, with its own `run`, `inFlight` and
546
+ `lastError` per alias (D205, D214). The selector is pure — no hooks, no commands, no side effects — and its fields
547
+ are compared with `Object.is`, so select scalars or projections rather than freshly allocated objects. The full
548
+ contract of the selection is in the section on selecting a component contract above.
549
+
550
+ This is not a promise of fewer renders: combining hooks changes what the component declares, not how fast React is.
551
+ The triad stays right where there is no model selection to make — a `Readable` that arrived as a prop or from a
552
+ `Resource` (`useReadable`, `useResource`), a component that only passes the granted model on (the one-argument
553
+ `useModel`), and a shared hook that reads one field for several components, where one `useReadable` is simply
554
+ smaller than a selector.
555
+
556
+ ## Decide whether an edge is weak
557
+
558
+ A settings surface shows a diagnostics section when the build has one. Written as a hard edge, it does not compile:
559
+
560
+ ```ts
561
+ // the settings feature, always alive; the provider lives under a development flag
562
+ imports: { devTools: devToolsFeature },
563
+ when: [settingsVisible],
564
+ ```
565
+
566
+ A hard edge requires `provider.when ⊆ consumer.when`, so the compiler refuses this application and names `optional`.
567
+ Putting the consumer under the same flag would compile and be worse: the whole settings surface would then disappear
568
+ with the flag it was only supposed to show one section for.
569
+
570
+ The weak edge is the answer, and its data is read without unfolding the lookup by hand:
571
+
572
+ ```ts
573
+ imports: { devTools: optional(devToolsFeature) },
574
+ own: ({ imports, model }) => ({
575
+ panel: model(SettingsPanel, { devTools: imports.devTools }, (_context, { devTools }) => ({
576
+ // devTools: Readable<Lookup<Exports>>; `select` runs only on found, and the fallback is written once
577
+ diagnostics: fromOptional(devTools, found => found.diagnostics, { missing: [] }),
578
+ })),
579
+ }),
580
+ ```
581
+
582
+ The criterion is one question: does the provider live at least as long as this feature and open before it? Yes — a
583
+ hard import, and the type gives the exports directly. No — `optional`: another condition group, another flag, a
584
+ build that does not ship the provider at all, or a provider the application may simply not enable.
585
+
586
+ What it gives the types and the graph: one word covers both absence and demand, and the exports arrive as
587
+ `Readable<Lookup<Exports>>` rather than something `undefined` at compile time (D105); a weak edge neither pulls the
588
+ provider into the application nor changes activation order; a call through a weak port answers `CallError`
589
+ `unavailable` instead of throwing (D187). `found`/`missing` never says which reason applies — absent from the
590
+ application or closed with its condition group — and that is deliberate: the reader shows the same section either
591
+ way, and a reason that cannot be acted on has no place in the branch.
592
+
593
+ ## Register through the target, not beside it
594
+
595
+ Several features contribute named things to one place: payment methods, tools by id, screens by route. The shape a
596
+ consumer reaches for first is a module-level map with hand-made notifications:
597
+
598
+ ```ts
599
+ // ui/methods.ts — a registry outside the graph
600
+ const entries = new Map<string, PaymentMethod>();
601
+ const listeners = new Set<() => void>();
602
+
603
+ const registerMethod = (key: string, value: PaymentMethod): (() => void) => {
604
+ entries.set(key, value);
605
+ for (const listener of listeners) listener();
606
+
607
+ return () => {
608
+ entries.delete(key);
609
+ for (const listener of listeners) listener();
610
+ };
611
+ };
612
+ ```
613
+
614
+ A feature then calls `registerMethod` from an effect and releases it in cleanup. Nothing ties that entry to the
615
+ feature: a fence does not withdraw it, an opening that failed halfway leaves it behind, two features that claim one
616
+ key are discovered by whoever renders last, and the inspector shows no contribution at all — a missing payment
617
+ method is then debugged by reading code instead of the runtime graph.
618
+
619
+ The target already exists, and it belongs outside features:
620
+
621
+ ```ts
622
+ // contracts.ts
623
+ const paymentMethods = defineRegistry<string, PaymentMethodEntry>({ id: 'checkout.methods' });
624
+
625
+ // feature.ts — the entry is a contribution, so it opens and closes with the instance that made it
626
+ provides: ({ register }) => ({
627
+ card: register(paymentMethods, ({ exports }) => ({ key: 'card', value: { submit: exports.submit, title: 'Card' } })),
628
+ }),
629
+
630
+ // UI reads the target, never the provider's module
631
+ const method = useReadable(paymentMethods.select(selectedKey));
632
+ const methods = useReadable(paymentMethods.list);
633
+ ```
634
+
635
+ The publication is atomic and owned: the entry lands when the instance is ready, the fence withdraws it on close, a
636
+ duplicate key is refused before anything moves, the order is `priority` and then id, and the inspector lists the
637
+ entry with the feature that owns it (D127, D196). `select(key)` answers the same `Lookup` as a weak edge, so an
638
+ absent method is a branch rather than an `undefined`.
639
+
640
+ Local UI state is still local: which method is selected, whether the panel is open, what the cursor is on. That is a
641
+ model field of the mount, not a registry entry — a registry answers "who contributed what", a model answers "what
642
+ this screen is doing right now".
643
+
644
+ ## Test a composition with `createScenario`
645
+
646
+ The unit tests of a component say nothing about the composition: whether the feature under a condition really
647
+ publishes into the slot, and whether it really takes the contribution back. That is one scenario:
648
+
649
+ ```tsx
650
+ const scenario = createScenario(application, {
651
+ conditions: { 'checkout.promo': promo }, // the test owns this source: `getSnapshot`/`subscribe` over a local flag
652
+ host: { mount: Component => render(<Component />) },
653
+ imports: bindings,
654
+ });
655
+ const ready = (snapshot: RuntimeGraphSnapshot): boolean =>
656
+ snapshot.runtime.instances.some(node => node.id.includes('checkout.promo') && node.state.kind === 'ready');
657
+
658
+ try {
659
+ await scenario.ready;
660
+ const view = scenario.mount(checkoutAsideSlot);
661
+
662
+ expect(view.host.queryByTestId('promo')).toBeNull();
663
+ promo.set(true);
664
+ scenario.notify();
665
+ await scenario.waitFor(ready, { label: 'promo feature ready' });
666
+ expect(view.host.getByTestId('promo')).toBeTruthy();
667
+
668
+ promo.set(false);
669
+ scenario.notify();
670
+ await scenario.waitFor(snapshot => !ready(snapshot), { label: 'promo feature retired' });
671
+ expect(view.host.queryByTestId('promo')).toBeNull();
672
+ } finally {
673
+ await scenario.close();
674
+ }
675
+ ```
676
+
677
+ The test drives the real application: its own condition source, its own renderer, the graph in between. `notify()`
678
+ wakes the predicates after a fixture change without publishing a runtime event, and a `waitFor` deadline reports the
679
+ graph and activity facts the inspector holds rather than a bare timeout (D206, D215).
680
+
681
+ What it catches on an upgrade of the framework is what a component test cannot: the form of `when` and the moment a
682
+ group opens, the publication into the slot and its ordering, and the withdrawal on close — including a contribution
683
+ that stays in `entries` one turn too long. Close in `finally`, and assert `scenario.ownership()` when the test also
684
+ claims the application released what it owned.
685
+
686
+ ## Smooth a bursty host source
687
+
688
+ A host source fires several times per frame — a price tick, a resize, a scroll position — and one `run` cannot keep
689
+ up with it. The `event` queue holds one payload, so a burst costs a `queue-capacity` failure record per dropped
690
+ emit, and a consumer that sees those records in its reporter usually smooths the source by hand:
691
+
692
+ ```ts
693
+ // models/…/TickModel.ts — a second queue in front of the runtime's own
694
+ let scheduled = false;
695
+ let last: Tick | undefined;
696
+
697
+ const emitLatest = (emit: (tick: Tick) => void, tick: Tick): void => {
698
+ last = tick;
699
+
700
+ if (scheduled) return;
701
+
702
+ scheduled = true;
703
+ queueMicrotask(() => {
704
+ scheduled = false;
705
+
706
+ if (last !== undefined) emit(last);
707
+ });
708
+ };
709
+ ```
710
+
711
+ That queue is outside everything: its microtask is not owned by the instance, so a fence does not cancel it and a
712
+ payload can reach `emit` after the owner closed; it hides the drops instead of answering them; and the two queues
713
+ now disagree about which payload is the newest one.
714
+
715
+ The policy is an option of the event, and it is the same word a stream uses:
716
+
717
+ ```ts
718
+ own: ({ event, imports }) => ({
719
+ ticks: event(imports.platform, ({ emit, source }) => source.subscribeTicks(emit), {
720
+ backpressure: latest(), // the newest payload takes the waiting slot (D247)
721
+ run: ({ payload, signal, source }) => source.applyTick(payload, signal),
722
+ }),
723
+ }),
724
+ ```
725
+
726
+ While `run` is busy and one payload waits, the next `emit` replaces the one that waits, and nothing is reported —
727
+ there is nothing to report, because no author asked for that payload to be delivered. A `run` in flight is never
728
+ interrupted, so the tick being applied finishes on its own; only the waiting slot changes hands. Closing the owner
729
+ drops whatever waits in it, under this policy and under the default alike.
730
+
731
+ Keep the default where an event is a trigger or a command rather than a value: a refetch on focus, a submit, an
732
+ "open the details" intent. There the first event of a burst is the one that matters, the second is genuinely
733
+ excess work, and the `queue-capacity` record is how the reporter tells you the source is louder than the handler.
734
+ A queue that loses nothing is neither policy and remains an open question (D183).