@yolk-sdk/emulators 0.1.0-canary.96

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 (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
package/README.md ADDED
@@ -0,0 +1,2135 @@
1
+ # @yolk-sdk/emulators
2
+
3
+ > **EXPERIMENTAL.** This package is new and its API may change in any canary release, beyond the
4
+ > usual canary instability.
5
+
6
+ Emulators for outside services, for tests and local development. A route table sends an Effect
7
+ `HttpClient` to an emulator instead of the real service. Two emulators speak the OpenAI-compatible
8
+ Chat Completions wire: the Vercel AI Gateway and OpenAI itself. A third emulates Anthropic Messages,
9
+ and two more speak the OpenAI Responses wire of the subscription providers: the ChatGPT Codex
10
+ endpoint and the xAI Grok CLI proxy. The OpenCode Go emulator answers the Go chat, Messages,
11
+ Responses, and usage routes under one origin, and the Anthropic, Codex, and Grok emulators also
12
+ answer their subscription-usage endpoints; those newer routes are fixture-only (see below). Two
13
+ emulators are not HTTP at all: fixture-driven fake backends for the generic `EmailClient` port and
14
+ for the host R2 ports (`R2Presigner`, `R2ObjectClient`).
15
+ Emulators never import other `@yolk-sdk/*` code: their wire shapes follow conformance fixtures
16
+ (verified recordings for the Gateway, synthetic placeholders elsewhere), and each emulated route
17
+ names the conformance cases behind it. The Fortnox emulator is a stateful stand-in for the Fortnox
18
+ `/3` API that reproduces the observed quirks the Fortnox conformance cases claim, the Microsoft
19
+ Graph emulator is a stateful stand-in for the Outlook, calendar, and OneDrive routes the Microsoft
20
+ conformance cases use. The Dropbox, Notion, Todoist, Telegram, GitHub, Google, LinkedIn search, and
21
+ MCP emulators are stateful, fixture-only stand-ins for the Dropbox RPC and upload routes, the Notion
22
+ `/v1` routes, the Todoist API v1 routes, the Telegram Bot API routes, the GitHub REST routes, the
23
+ Gmail, Calendar, and Drive routes, the Exa and Enrich Layer routes, and the two synthetic MCP
24
+ servers their conformance cases use: they answer only what the fixtures show and refuse everything
25
+ else with a 400 not-emulated.
26
+
27
+ Canary APIs are unstable. Keep all `@yolk-sdk/*` packages on the same version.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pnpm add -D @yolk-sdk/emulators@canary effect@4.0.0-rc.115
33
+ ```
34
+
35
+ ## Subpaths
36
+
37
+ There is no root export. Import an explicit subpath:
38
+
39
+ | Subpath | Purpose |
40
+ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
41
+ | `@yolk-sdk/emulators/router` | `EmulatorRoute`, `EmulatedHttpClient.layer`, `InProcessHttpClient.layer` (Effect; no Node builtins) |
42
+ | `@yolk-sdk/emulators/gateway` | `makeGatewayEmulator`, `gatewayEmulatorRoutes`, `gatewayEvaluateEmulatorRoutes`, fault and scripted-turn schemas (plain fetch handler) |
43
+ | `@yolk-sdk/emulators/openai` | `makeOpenAiEmulator`, `openAiEmulatorRoutes`, fault and scripted-turn schemas (plain fetch handler) |
44
+ | `@yolk-sdk/emulators/anthropic` | `makeAnthropicEmulator`, `anthropicEmulatorRoutes`, fault and scripted-turn schemas (plain fetch handler) |
45
+ | `@yolk-sdk/emulators/codex` | `makeCodexEmulator`, `codexEmulatorRoutes`, fault and scripted-turn schemas (ChatGPT Codex Responses) |
46
+ | `@yolk-sdk/emulators/xai` | `makeXAiGrokEmulator`, `xAiGrokEmulatorRoutes`, fault and scripted-turn schemas (Grok CLI proxy Responses) |
47
+ | `@yolk-sdk/emulators/opencode` | `makeOpenCodeGoEmulator`, `openCodeGoEmulatorRoutes` (OpenCode Go chat, Messages, Responses, and usage) |
48
+ | `@yolk-sdk/emulators/email` | `makeEmailEmulator`, `emailEmulatorRoutes`, seed and fault schemas (plain-JSON `EmailClient` backend) |
49
+ | `@yolk-sdk/emulators/r2` | `makeR2Emulator`, `r2EmulatorRoutes`, seed and fault schemas (plain-JSON R2 port backend) |
50
+ | `@yolk-sdk/emulators/node` | `serveFetchHandler` (scoped Effect) and `startFetchHandlerServer` (Promise): serve a handler on `127.0.0.1` |
51
+ | `@yolk-sdk/emulators/fortnox` | `makeFortnoxEmulator`, `fortnoxEmulatorRoutes`, `fortnoxEmulatorQuirks`, seed and fault schemas (Node only) |
52
+ | `@yolk-sdk/emulators/microsoft` | `makeMicrosoftEmulator`, `microsoftEmulatorRoutes`, seed and fault schemas (Node only) |
53
+ | `@yolk-sdk/emulators/dropbox` | `makeDropboxEmulator`, `dropboxEmulatorRoutes`, seed and fault schemas (Node only) |
54
+ | `@yolk-sdk/emulators/notion` | `makeNotionEmulator`, `notionEmulatorRoutes`, seed and fault schemas (Node only) |
55
+ | `@yolk-sdk/emulators/todoist` | `makeTodoistEmulator`, `todoistEmulatorRoutes`, seed and fault schemas (Node only) |
56
+ | `@yolk-sdk/emulators/telegram` | `makeTelegramEmulator`, `telegramEmulatorRoutes`, seed and fault schemas (Node only) |
57
+ | `@yolk-sdk/emulators/github` | `makeGithubEmulator`, `githubEmulatorRoutes`, seed and fault schemas (Node only) |
58
+ | `@yolk-sdk/emulators/google` | `makeGoogleEmulator`, `googleEmulatorRoutes`, seed and fault schemas (Gmail, Calendar, Drive; Node only) |
59
+ | `@yolk-sdk/emulators/linkedin-search` | `makeLinkedInSearchEmulator`, `linkedInSearchEmulatorRoutes`, seed and fault schemas (Exa, Enrich Layer; Node only) |
60
+ | `@yolk-sdk/emulators/mcp` | `makeMcpEmulator`, `mcpEmulatorRoutes`, seed and fault schemas (synthetic MCP servers; Node only) |
61
+
62
+ ## Routing
63
+
64
+ Pick a transport by swapping `HttpClient` layers. Code under test keeps calling the real origin.
65
+
66
+ | Transport | Layer | What happens |
67
+ | --------- | ----------------------------------------- | ------------------------------------------------------------------------ |
68
+ | Replay | `@yolk-sdk/conformance/replay` | Recorded fixtures, offline |
69
+ | InProcess | `InProcessHttpClient.layer(routes)` | Calls the emulator's fetch handler directly (no sockets) |
70
+ | Emulated | `EmulatedHttpClient.layer(routes)` | Rewrites the origin to a loopback emulator process, over your own client |
71
+ | Live | your own `HttpClient` (for example Fetch) | The real service |
72
+
73
+ ```ts
74
+ import { Effect, Layer } from 'effect'
75
+ import { FetchHttpClient } from 'effect/unstable/http'
76
+ import { makeGatewayEmulator } from '@yolk-sdk/emulators/gateway'
77
+ import { serveFetchHandler } from '@yolk-sdk/emulators/node'
78
+ import { EmulatedHttpClient, EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
79
+
80
+ const gateway = makeGatewayEmulator()
81
+
82
+ // In-process: no sockets.
83
+ const inProcess = InProcessHttpClient.layer([
84
+ EmulatorRoute.handler('https://ai-gateway.vercel.sh', gateway.fetch)
85
+ ])
86
+
87
+ // Emulated: a real loopback server under the host's FetchHttpClient.
88
+ const emulated = Layer.unwrap(
89
+ serveFetchHandler(gateway.fetch).pipe(
90
+ Effect.map(server =>
91
+ EmulatedHttpClient.layer([
92
+ EmulatorRoute.url('https://ai-gateway.vercel.sh', server.url)
93
+ ]).pipe(Layer.provide(FetchHttpClient.layer))
94
+ )
95
+ )
96
+ )
97
+ ```
98
+
99
+ Both layers:
100
+
101
+ - keep the request path and query, and fail closed on any origin without a route with an
102
+ `HttpClientError` whose message names only the origin;
103
+ - reject malformed or duplicate origins at build time (`EmulatorRouteInvalid`);
104
+ - refuse to build when `NODE_ENV` is `production` or cannot be read (`EmulatorEnvironmentRefused`);
105
+ a missing `NODE_ENV` is allowed.
106
+
107
+ `EmulatedHttpClient` also requires every `baseUrl` to be `http(s)` on loopback (`127.0.0.0/8`,
108
+ `::1`, or `localhost`; IPv4-mapped forms such as `[::ffff:127.0.0.1]` are rejected) and needs your
109
+ real `HttpClient` underneath. `InProcessHttpClient` sends `Empty`, `Uint8Array`, and string `Raw`
110
+ bodies; other body kinds fail with an `EncodeError`.
111
+
112
+ Redirects never leave the route table. `EmulatedHttpClient` checks and rewrites each request at the
113
+ send step, so redirect follow-ups (`HttpClient.followRedirects` on top) and requests changed by
114
+ your own `HttpClient.mapRequest` are routed or fail closed too. With `FetchHttpClient` underneath,
115
+ routed requests are sent with `redirect: 'manual'`, so a 3xx comes back to the caller as a 3xx.
116
+ Other `FetchHttpClient.RequestInit` defaults are kept when provided around the whole client stack
117
+ (or where the request runs); defaults provided only to `FetchHttpClient.layer` itself are replaced.
118
+ Put `HttpClient.followRedirects` on top of `EmulatedHttpClient`, never underneath it. **Any other
119
+ underlying client must not follow redirects by itself**: a redirect it follows internally never
120
+ passes through the route table.
121
+
122
+ ## Gateway emulator
123
+
124
+ `makeGatewayEmulator(options?)` returns `{ fetch, ledger, reset, faults, script, coverage }` for
125
+ `POST /v1/chat/completions`. Each call has its own state. Its wire shapes follow the verified live
126
+ Gateway recordings (2026-09-30) in `@yolk-sdk/agent/providers/vercel/conformance`, and its route is
127
+ `verified`; ids, costs, and routing metadata are synthetic stand-ins.
128
+
129
+ Defaults (no script):
130
+
131
+ - `knownModels` defaults to a small synthetic-safe list including `openai/gpt-4.1-nano`,
132
+ `deepseek/deepseek-v3.2`, and `deepseek/deepseek-v4.1-flash`; `reasoningModels` defaults to the
133
+ two DeepSeek ids.
134
+ - `stream: true` streams `chat.completion.chunk` server-sent events as recorded: a
135
+ `{ role: 'assistant' }` opening delta, several text deltas, then one finish event whose `delta`
136
+ carries `provider_metadata` (the recorded upstream entry, `openai` for `openai/*` models or
137
+ `baseten` for `deepseek/*` models, then a `gateway` routing and cost entry, all synthetic) and
138
+ which carries `usage` (when `stream_options.include_usage` is set), `system_fingerprint`,
139
+ `service_tier` (`openai/*` models only), and `generationId`, followed only by `data: [DONE]`.
140
+ Every chunk carries `system_fingerprint`, and every choice `logprobs: null`.
141
+ `stream: false` returns one `chat.completion` JSON body (not covered by a recording).
142
+ - Events are packed several per network chunk, as the live Gateway sends them: `eventsPerChunk`
143
+ (default 2, a positive integer; 1 sends one event per chunk) counted from the end, so the last
144
+ chunk carries the finish event and `data: [DONE]` together and the first chunk may carry fewer.
145
+ Chunk faults count these network chunks. An invalid `eventsPerChunk` throws
146
+ `GatewayEmulatorInputInvalid`.
147
+ - A reasoning model asked for reasoning (`reasoning_effort`, or `thinking: { type: 'enabled' }`)
148
+ streams `delta.reasoning` with `delta.reasoning_details`
149
+ (`[{ type: 'reasoning.text', text, format, index }]`) before the text.
150
+ - A request with `tools` gets one tool call whose arguments are synthesized from the tool's JSON
151
+ Schema (required string properties get non-empty synthetic values), streamed as several
152
+ `delta.tool_calls[].function.arguments` fragments, finishing with `tool_calls`. A `tool_choice`
153
+ naming an offered function picks that tool (otherwise the first); `tool_choice: 'none'` answers
154
+ with text.
155
+ - An unknown model gets the recorded 404 envelope
156
+ `{ error: { message: "Model '<id>' not found", type: 'model_not_found', param: { modelId } } }`
157
+ (no `code`).
158
+ - A missing `Authorization: Bearer <non-empty>` header gets a 401 envelope
159
+ `{ error: { message, type, code } }` (synthetic, not recorded). The token is never checked or
160
+ stored.
161
+ - Unknown routes get a 404 JSON error (fail closed) and are written to the ledger.
162
+
163
+ `script.enqueue(turn)` queues a turn for the next chat request, sent exactly as given:
164
+
165
+ - a completion: `{ text?, reasoning?, reasoningField?, order?, toolCalls?, usage?, finishReason? }`
166
+ where each tool call is `{ name, argumentFragments }`, `usage: null` drops usage from the finish
167
+ event, `order: 'text-first'` sends reasoning after the text, and `reasoningField` defaults to
168
+ `reasoning` (with `reasoning_details`; `reasoning_content` sends the DeepSeek-native field alone);
169
+ - an error: `{ error: { status, body, headers? } }`.
170
+
171
+ Emulators never redirect, and every emulated response carries a body. Fault and scripted-error
172
+ statuses must be 200–599 without 204, 205, or any 3xx (including 304); header names must be HTTP
173
+ tokens, values must not contain control characters, and `location` is rejected. Invalid input
174
+ throws `GatewayEmulatorInputInvalid` (the control plane answers 400).
175
+
176
+ `faults.add(fault)` adds a wire fault with an optional `match: { path?, model? }` (`path` ending in
177
+ `*` is a prefix) and an optional `count`:
178
+
179
+ | Fault | Effect |
180
+ | ----------------------- | ------------------------------------------------------------------------- |
181
+ | `status` | Answer with a status, headers, and body (for example 429 + `retry-after`) |
182
+ | `error-after-chunks` | Send N body chunks, then error the body stream (a dropped connection) |
183
+ | `truncate-after-chunks` | Send N body chunks, then close cleanly (no `data: [DONE]`) |
184
+
185
+ Chunk faults count network chunks: with the default packing, `truncate-after-chunks` with N = 2
186
+ sends up to four events (four for the default plain-text response). A chunk fault that cannot
187
+ take effect answers 500 instead of silently doing nothing. If the emulator cannot build a planned
188
+ response, it answers an evidence-tagged 500, the ledger records 500 with `responseError`, and the
189
+ matching fault is not used up.
190
+
191
+ `ledger.entries()` records every emulated API request (control-plane requests are not recorded):
192
+ method, path, parsed JSON body, model, `stream`, the `max_tokens` limit (as `maxCompletionTokens`;
193
+ recorded, never validated), `reasoning_effort`, `thinking`, tool names, the fault applied, the route's evidence tag, the status actually sent, and the body chunks handed over
194
+ so far. Credential headers are never recorded.
195
+
196
+ Control plane (same fetch handler):
197
+
198
+ | Route | Methods |
199
+ | -------------------- | ---------------------------------------------------------------- |
200
+ | `/_emulate/ledger` | `GET`, `DELETE` |
201
+ | `/_emulate/faults` | `GET`, `POST`, `DELETE` (`POST` takes one fault or `{ faults }`) |
202
+ | `/_emulate/script` | `POST` (one turn or `{ turns }`) |
203
+ | `/_emulate/reset` | `POST` |
204
+ | `/_emulate/state` | `GET` |
205
+ | `/_emulate/coverage` | `GET` (the route evidence manifest with request counts) |
206
+
207
+ ## OpenAI emulator
208
+
209
+ `makeOpenAiEmulator(options?)` returns the same `{ fetch, ledger, reset, faults, script, coverage }`
210
+ shape for OpenAI Chat Completions: `POST /v1/chat/completions`, routed from
211
+ `https://api.openai.com`. It shares the Gateway emulator's Chat Completions core, so tool-call
212
+ fragments, JSON mode, faults, scripted turns, the ledger, and the control plane behave the same.
213
+ What differs:
214
+
215
+ - Streaming uses the plain OpenAI framing: one event per network chunk, a
216
+ `{ role: 'assistant', content: '' }` opening delta, and usage in a trailing chunk with empty
217
+ `choices`, without the Gateway's metadata fields.
218
+ - `knownModels` defaults to `openAiEmulatorDefaultModels` (`gpt-4.1-nano`, `gpt-4.1-mini`).
219
+ - Errors use the OpenAI envelope `{ error: { message, type, param, code } }`; an unknown model gets
220
+ 404 with code `model_not_found`, and a missing `Authorization: Bearer <non-empty>` header gets
221
+ 401 with code `invalid_api_key`. The token is never checked or stored.
222
+ - The ledger records `max_completion_tokens` as `maxCompletionTokens` (never validated).
223
+ - Reasoning models are not emulated yet: no default output streams reasoning, scripted turns
224
+ reject `reasoning`, `reasoningField`, and `order`, and `/_emulate/state` has no `reasoningModels`.
225
+ - Invalid faults or turns throw `OpenAiEmulatorInputInvalid`.
226
+
227
+ ```ts
228
+ import { makeOpenAiEmulator } from '@yolk-sdk/emulators/openai'
229
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
230
+
231
+ const openai = makeOpenAiEmulator()
232
+
233
+ const httpLayer = InProcessHttpClient.layer([
234
+ EmulatorRoute.handler('https://api.openai.com', openai.fetch)
235
+ ])
236
+ ```
237
+
238
+ `openAiEmulatorRoutes` links the route to the OpenAI chat conformance cases in
239
+ `@yolk-sdk/agent/providers/openai/conformance`.
240
+
241
+ ## Anthropic emulator
242
+
243
+ `makeAnthropicEmulator(options?)` returns the same `{ fetch, ledger, reset, faults, script,
244
+ coverage }` shape for Anthropic Messages: `POST /v1/messages`, routed from
245
+ `https://api.anthropic.com`. Faults, the ledger, the control plane, evidence tagging, and route
246
+ binding are shared with the Chat Completions emulators; the Messages wire is its own:
247
+
248
+ - `stream: true` streams the Messages events in API order (block order and `ping` placement are
249
+ emulator choices until a live recording): `message_start` (with input
250
+ usage), then per content block `content_block_start`, its deltas, and `content_block_stop` (a
251
+ `ping` follows the first block start), then `message_delta` (`stop_reason`, and usage: input
252
+ and cache counts next to the cumulative `output_tokens`, as the unverified fixtures record it)
253
+ and `message_stop`. `stream: false` returns one `message` JSON body.
254
+ - `thinking: { type: 'enabled' | 'adaptive' }` adds a `thinking` block (`thinking_delta` events,
255
+ then one `signature_delta`) before the answer.
256
+ - A request with `tools` gets one `tool_use` block whose input is synthesized from the tool's
257
+ `input_schema` and streamed as `input_json_delta` fragments (the first one empty), stopping with
258
+ `tool_use`. `tool_choice: { type: 'tool', name }` picks that tool (otherwise the first);
259
+ `tool_choice: { type: 'none' }` answers with text. `thinking` together with a forced
260
+ `tool_choice` (`tool` or `any`) gets 400 `invalid_request_error`.
261
+ - An answer that would not fit `max_tokens` (about four characters per token) is cut and stops
262
+ with `max_tokens`. A missing or non-positive `max_tokens` gets 400 `invalid_request_error`.
263
+ - Errors use `{ type: 'error', error: { type, message } }`; an unknown model gets 404
264
+ `not_found_error`.
265
+ - Authentication accepts a non-empty `x-api-key` (native API keys) or `Authorization: Bearer`
266
+ (Claude OAuth); anything else gets 401 `authentication_error`. Neither value is checked or
267
+ stored. The ledger records which header carried it (`credentialHeader`), `anthropic-version`,
268
+ `anthropic-beta`, `max_tokens` (`maxTokens`), `thinking`, `tool_choice`, and tool names.
269
+ - `anthropic-version` must be `2023-06-01` (the value the SDK providers send by default); a missing or
270
+ other value gets 400 `invalid_request_error`.
271
+ - Not enforced: the OAuth `anthropic-beta` header for bearer credentials, and `budget_tokens`
272
+ limits.
273
+ - `knownModels` defaults to `anthropicEmulatorDefaultModels` (`claude-haiku-4-5`,
274
+ `claude-sonnet-4-5`). Invalid faults or turns throw `AnthropicEmulatorInputInvalid`.
275
+
276
+ `script.enqueue(turn)` queues a message `{ thinking?, text?, toolUses?, order?, usage?,
277
+ stopReason? }` (each tool use is `{ name, inputFragments, id? }`; a block is sent only when its
278
+ field is present; `usage: null` drops usage; `order: 'text-first'` sends thinking after the text;
279
+ `stopReason` defaults to `tool_use` with tool uses, else `end_turn`) or an error
280
+ `{ error: { status, body, headers? } }`.
281
+
282
+ `faults.add(fault)` takes the shared `status`, `error-after-chunks`, and `truncate-after-chunks`
283
+ kinds (a `status` fault's default body is the Anthropic envelope for the status, for example
284
+ `rate_limit_error` for 429 and `overloaded_error` for 529), plus `error-event-after-chunks`: send N
285
+ events, then one `event: error` (default `overloaded_error`, or `error: { type, message }`) and
286
+ close without `message_stop`. It applies to streamed responses only and must come before
287
+ `message_stop`; otherwise the request answers 500 and the fault is kept.
288
+
289
+ ```ts
290
+ import { makeAnthropicEmulator } from '@yolk-sdk/emulators/anthropic'
291
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
292
+
293
+ const anthropic = makeAnthropicEmulator()
294
+
295
+ anthropic.faults.add({ kind: 'status', status: 529, count: 1 })
296
+
297
+ const httpLayer = InProcessHttpClient.layer([
298
+ EmulatorRoute.handler('https://api.anthropic.com', anthropic.fetch)
299
+ ])
300
+ ```
301
+
302
+ `anthropicEmulatorRoutes` links the route to the Anthropic Messages conformance cases in
303
+ `@yolk-sdk/agent/providers/anthropic/conformance`.
304
+
305
+ ## Responses emulators (Codex, xAI Grok)
306
+
307
+ `makeCodexEmulator(options?)` (`@yolk-sdk/emulators/codex`) and `makeXAiGrokEmulator(options?)`
308
+ (`@yolk-sdk/emulators/xai`) return the same `{ fetch, ledger, reset, faults, script, coverage }`
309
+ shape for the OpenAI Responses wire the subscription providers use:
310
+
311
+ | Subpath | Origin and route | Default models |
312
+ | -------- | ---------------------------------------------------------- | ------------------------ |
313
+ | `/codex` | `https://chatgpt.com`, `POST /backend-api/codex/responses` | `gpt-5.4`, `gpt-5.5` |
314
+ | `/xai` | `https://cli-chat-proxy.grok.com`, `POST /v1/responses` | `grok-build`, `grok-4.6` |
315
+
316
+ Both share one internal Responses core on the emulator kernel, so faults, the ledger, the control
317
+ plane, evidence tagging, and route binding behave as for the other emulators. The Responses wire:
318
+
319
+ - Requests carry `model`, an `input` string or array (400 without one), `instructions`, `tools`,
320
+ `tool_choice`, `reasoning`, `stream`, `store`, and `max_output_tokens`.
321
+ - `stream: true` streams server-sent events with typed `event:` names and a `sequence_number`, in
322
+ the API's order: `response.created`, `response.in_progress`, then per output item
323
+ `response.output_item.added`, its parts and deltas, and `response.output_item.done`, then
324
+ `response.completed` with the full `response` (output items and `usage`). `stream: false`
325
+ returns one completed `response` JSON body.
326
+ - A request whose `reasoning` asks for a `summary` gets a `reasoning` item first
327
+ (`response.reasoning_summary_part.added`, `response.reasoning_summary_text.delta` / `.done`,
328
+ `response.reasoning_summary_part.done`). Answers are a `message` item
329
+ (`response.content_part.added`, `response.output_text.delta` / `.done`,
330
+ `response.content_part.done`).
331
+ - A request with function `tools` gets one `function_call` item whose arguments are synthesized
332
+ from the tool's JSON Schema and streamed as `response.function_call_arguments.delta` fragments,
333
+ then `response.function_call_arguments.done`. `tool_choice: { type: 'function', name }` picks
334
+ that tool (otherwise the first); `tool_choice: 'none'` answers with text.
335
+ - Errors use the envelope `{ error: { message, type, param, code } }`; an unknown model gets 400
336
+ `model_not_found`, and a request without `Authorization: Bearer <non-empty>` gets 401
337
+ `invalid_api_key`. The bearer is never checked or stored.
338
+ - `/codex`: `max_output_tokens` gets 400 `unsupported_parameter` (the Codex endpoint takes no output
339
+ limit); the ledger records the `originator` header. `ChatGPT-Account-Id` is neither required nor
340
+ recorded.
341
+ - `/xai`: after the bearer, a missing `X-XAI-Token-Auth` gets 401, a missing
342
+ `x-grok-client-version` gets 426 (the proxy version-gates requests), and a missing
343
+ `x-grok-model-override` gets 400. The ledger records the client version and model override, never
344
+ the token-auth value. `max_output_tokens` must be a positive integer (or 400) and is recorded as
345
+ `maxOutputTokens`, not enforced.
346
+ - Not enforced (unverified leniency): `store: false`, `stream: true`, `instructions`, that the model
347
+ override matches `model`, the client version value, and output limits.
348
+
349
+ `script.enqueue(turn)` queues a response `{ reasoning?, text?, functionCalls?, order?, usage?,
350
+ format? }` (each function call is `{ name, argumentFragments, callId? }`; an item is sent only when
351
+ its field is present; `usage: null` drops usage from `response.completed`; `order: 'text-first'`
352
+ sends reasoning after the text; `format: 'json'` answers a JSON body even for `stream: true`, as
353
+ the JSON fallback the providers accept) or an error `{ error: { status, body, headers? } }`.
354
+
355
+ `faults.add(fault)` takes the shared `status`, `error-after-chunks`, and `truncate-after-chunks`
356
+ kinds (for example 429 with `retry-after`, or truncation before `response.completed`), plus
357
+ `error-event-after-chunks`: send N events, then one `error` event (default) or, with
358
+ `event: 'response.failed'`, a `response.failed` event, carrying `error: { code, message }`
359
+ (default `server_error`), and close without `response.completed`. It applies to streamed responses
360
+ only and must come before `response.completed`; otherwise the request answers 500 and the fault is
361
+ kept. Invalid faults or turns throw `CodexEmulatorInputInvalid` / `XAiGrokEmulatorInputInvalid`.
362
+
363
+ ```ts
364
+ import { makeXAiGrokEmulator } from '@yolk-sdk/emulators/xai'
365
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
366
+
367
+ const grok = makeXAiGrokEmulator()
368
+
369
+ grok.faults.add({ kind: 'truncate-after-chunks', chunks: 5, count: 1 })
370
+
371
+ const httpLayer = InProcessHttpClient.layer([
372
+ EmulatorRoute.handler('https://cli-chat-proxy.grok.com', grok.fetch)
373
+ ])
374
+ ```
375
+
376
+ `codexEmulatorRoutes` and `xAiGrokEmulatorRoutes` link each route to the Responses conformance
377
+ cases in `@yolk-sdk/agent/providers/openai/conformance` (Codex) and
378
+ `@yolk-sdk/agent/providers/xai/conformance` (Grok).
379
+
380
+ ## Fixture-only routes
381
+
382
+ The OpenCode Go routes, the Claude, Codex, and Grok subscription-usage routes, and the Gateway
383
+ classifier route follow a stricter rule than the earlier model routes (whose behaviour above is unchanged): response behaviour comes
384
+ only from the committed conformance fixtures.
385
+
386
+ - A request that matches a recorded request's shape, within the request-shape latitude below,
387
+ gets that fixture's recorded response, copied as data: the same status, headers, chunks, and
388
+ bytes.
389
+ - Everything else answers one 400 not-emulated, written to the ledger (`notEmulated`) and using up
390
+ no fault or turn: `{ error: { type: 'not_emulated', message: 'Not emulated: <reason>' } }`. That
391
+ covers unknown routes and methods, missing or invalid credentials, missing or other headers and
392
+ query parameters, unknown models, non-streamed modes, tools, reasoning, extra fields, and
393
+ anything else no fixture records. No provider status, envelope, or error code is guessed.
394
+ - Test controls: the shared faults (`status`, `error-after-chunks`, `truncate-after-chunks`; a
395
+ `status` fault without a body answers `{ error: { type: 'emulator_fault', message } }`) and
396
+ scripted error turns `{ error: { status, body, headers? } }`. Usage routes also take a scripted
397
+ `{ usage }` body and a `subscriptionUsage` option, both required to keep the recorded JSON shape
398
+ (the same keys and value kinds; only values change).
399
+ - Request-shape latitude (the only accepted deviations): any credential value (never checked or
400
+ stored); extra request headers; JSON key order; any string value except the discriminators
401
+ `model`, `role`, `type`, and `phase`; any positive integer where the recording has a number (the
402
+ output-token limit); an `anthropic-beta` list that includes `oauth-2025-04-20` (Claude usage);
403
+ any non-empty `x-userid` and `x-grok-client-version` (Grok usage); `content-type` parameters. Object keys, array lengths, booleans (`stream`, `store`,
404
+ `include_usage`, `parallel_tool_calls`, `additionalProperties`), `accept`, the query string (byte
405
+ for byte; a bare `?` counts as no query), the method, `X-XAI-Token-Auth: xai-grok-cli`, and `x-grok-client-mode: headless` must
406
+ equal the recording or the SDK's fixed value. Faults and scripted errors on these routes take
407
+ statuses of 400-599 only.
408
+
409
+ All fixtures behind these routes are synthetic and the routes are `unverified`.
410
+
411
+ ## Gateway classifier route
412
+
413
+ The Gateway emulator also answers AI Gateway's classifier route `POST /v1/evaluate` (Gateway calls
414
+ classification "evaluation"), fixture-only, from the four synthetic classifier fixtures in
415
+ `@yolk-sdk/agent/providers/vercel/conformance` (boolean, choice, score, and the unknown-model
416
+ `{ message, error_type }` envelope). A request with a non-empty bearer credential, the recorded
417
+ `accept` and `content-type`, no query, and a body matching one recording (the `model` and question
418
+ `type` exact; any other string; the recorded question ids, option keys, and level count) gets
419
+ that recording's response; anything else (another model, `providerOptions`, other questions)
420
+ answers 400 not-emulated. The route keeps its own manifest (`gatewayEvaluateEmulatorRoutes`,
421
+ `unverified`), ledger, faults, scripted errors, and coverage (`emulator.evaluate`, control plane
422
+ `/_emulate/evaluate/*`); the chat route's manifest, coverage, and top-level APIs are unchanged, and
423
+ `reset()` and `POST /_emulate/reset` reset both.
424
+
425
+ ```ts
426
+ import { makeGatewayEmulator } from '@yolk-sdk/emulators/gateway'
427
+
428
+ const gateway = makeGatewayEmulator()
429
+
430
+ gateway.evaluate.faults.add({
431
+ kind: 'status',
432
+ status: 429,
433
+ headers: { 'retry-after': '2' },
434
+ count: 1
435
+ })
436
+ ```
437
+
438
+ ## OpenCode Go emulator
439
+
440
+ `makeOpenCodeGoEmulator(options?)` (`@yolk-sdk/emulators/opencode`) answers the origin
441
+ `https://opencode.ai` with one fetch handler for the routes the OpenCode Go provider and usage
442
+ fetcher call under `/zen/go/v1`. Each route is fixture-only and answers its Go conformance fixture:
443
+
444
+ | Route | Headers the provider sends | Recorded answer |
445
+ | ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------- |
446
+ | `POST /zen/go/v1/chat/completions` | `Authorization: Bearer` | Streamed plain text with a usage chunk and `data: [DONE]` |
447
+ | `POST /zen/go/v1/messages` | `x-api-key`, `anthropic-version: 2023-06-01` | Streamed plain text ending with `message_stop` |
448
+ | `POST /zen/go/v1/responses` | `Authorization: Bearer` | Streamed plain text, or (replayed tool turn) a streamed text answer |
449
+ | `GET /zen/go/v1/usage` | `Authorization: Bearer` | `usage.rolling` / `weekly` / `monthly` as `{ percent, resetsAt }` |
450
+
451
+ Only the recorded models are emulated (`openCodeGoEmulatorDefaultModels`: `synthetic-go-chat`,
452
+ `synthetic-go-messages`, `synthetic-go-responses`, one per protocol). It returns
453
+ `{ fetch, reset, coverage, chat, messages, responses, usage }`: each part is a full emulator API
454
+ (`ledger`, `faults`, `script`, `coverage`, its own `fetch`), and over HTTP its control plane is
455
+ `/_emulate/<chat|messages|responses|usage>/*`. `coverage()` and `GET /_emulate/coverage` combine
456
+ all four routes; `reset()` and `POST /_emulate/reset` reset every part. Requests on no route
457
+ answer 400 not-emulated, ledgered by the chat part. `openCodeGoUsageDefault` is the recorded usage
458
+ body; `options.subscriptionUsage` replaces it with a same-shaped body. Invalid input throws
459
+ `OpenCodeGoEmulatorInputInvalid`.
460
+
461
+ ```ts
462
+ import { makeOpenCodeGoEmulator } from '@yolk-sdk/emulators/opencode'
463
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
464
+
465
+ const go = makeOpenCodeGoEmulator()
466
+
467
+ go.responses.faults.add({ kind: 'status', status: 429, headers: { 'retry-after': '2' }, count: 1 })
468
+
469
+ const httpLayer = InProcessHttpClient.layer([
470
+ EmulatorRoute.handler('https://opencode.ai', go.fetch)
471
+ ])
472
+ ```
473
+
474
+ `openCodeGoEmulatorRoutes` links the four routes to the cases in
475
+ `@yolk-sdk/agent/providers/opencode/conformance`.
476
+
477
+ ## Subscription-usage routes
478
+
479
+ The router takes one route per origin, so the usage endpoint of each subscription provider is
480
+ served by the emulator already bound to its origin, with its own manifest, ledger, faults, turns,
481
+ and coverage (`emulator.usage`, control plane `/_emulate/usage/*`). The model route's manifest,
482
+ coverage, and top-level `ledger` / `faults` / `script` are unchanged; the emulator types gain
483
+ `usage` and a `subscriptionUsage` option, and `reset()` and `POST /_emulate/reset` reset both.
484
+ Each usage route is fixture-only: a request with the headers the SDK fetcher sends, the recorded
485
+ `accept: application/json`, and the recorded query gets the recorded body (`*SubscriptionUsageDefault`);
486
+ anything else answers 400 not-emulated. Credential and account values are never recorded, and
487
+ are not checked except Grok's fixed `X-XAI-Token-Auth: xai-grok-cli`.
488
+
489
+ | Emulator | Route | Headers the fetcher sends (all required) | Recorded body |
490
+ | ------------ | -------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- |
491
+ | `/anthropic` | `GET /api/oauth/usage` | Bearer, `anthropic-beta` listing `oauth-2025-04-20` | `five_hour`, `seven_day` as `{ utilization, resets_at }` |
492
+ | `/codex` | `GET /backend-api/wham/usage` | Bearer, `ChatGPT-Account-Id` | `rate_limit.primary_window` / `secondary_window` |
493
+ | `/xai` | `GET /v1/billing?format=credits` | Bearer, `X-XAI-Token-Auth`, `x-userid`, `x-grok-client-version`, `x-grok-client-mode` | `config.creditUsagePercent` and `config.currentPeriod` |
494
+ | `/opencode` | `GET /zen/go/v1/usage` | Bearer | `usage.rolling` / `weekly` / `monthly` |
495
+
496
+ The ledger records `anthropic-beta` (Claude) and `x-grok-client-version` / `x-grok-client-mode`
497
+ (Grok), never `ChatGPT-Account-Id` or `x-userid`. The manifests
498
+ `anthropicSubscriptionUsageEmulatorRoutes`, `codexSubscriptionUsageEmulatorRoutes`,
499
+ `xAiGrokSubscriptionUsageEmulatorRoutes`, and the usage route of `openCodeGoEmulatorRoutes` cite
500
+ the usage snapshot cases of each vendor's conformance subpath.
501
+
502
+ ## Email emulator
503
+
504
+ `makeEmailEmulator({ seed? })` is an in-memory fake backend for the generic `EmailClient` port of
505
+ `@yolk-sdk/connectors/email`. Yolk never speaks IMAP, POP3, or SMTP, and neither does this
506
+ emulator: there is no socket, TLS, MIME, or mail library, no Node builtin, and no SDK import. It is
507
+ a plain object whose `call(method, request)` takes one port call as plain JSON (the request without
508
+ credential fields) and answers `{ response }`, `{ failure }`, or `{ notEmulated: { reason } }`.
509
+ `emailClientLayerFromBackend(emulator)` from `@yolk-sdk/connectors/email/conformance` turns it
510
+ into the `EmailClient` layer, so the email conformance cases and your own tests run the real
511
+ connector actions against it:
512
+
513
+ ```ts
514
+ import { Layer } from 'effect'
515
+ import { emailClientLayerFromBackend } from '@yolk-sdk/connectors/email/conformance'
516
+ import { makeEmailEmulator } from '@yolk-sdk/emulators/email'
517
+
518
+ const email = makeEmailEmulator()
519
+
520
+ email.faults.add({
521
+ kind: 'failure',
522
+ method: 'move',
523
+ count: 1,
524
+ failure: { kind: 'error', code: 'transport_failed', message: 'Synthetic outage.' }
525
+ })
526
+
527
+ const emailLayer = emailClientLayerFromBackend(email)
528
+ ```
529
+
530
+ Responses come only from the email conformance fixtures (copied as data; `emailEmulatorFixtures`).
531
+ The emulator keeps a mailbox (`emailEmulatorDefaultSeed`: folders with `\Drafts`, `\Sent`, and
532
+ `\Trash` SPECIAL-USE attributes and two INBOX messages) that only decides which fixture answers:
533
+ the first fixture whose method and request match and whose answer is consistent with the mailbox
534
+ (preferring one not used since the last reset). The mailbox then records what that fixture says
535
+ happened: flags set, a draft appended, a message moved to the destination id the fixture names, a
536
+ message deleted, a Sent copy saved. It never invents an id, flag, or response.
537
+
538
+ - Request-shape latitude: credential fields are never compared or recorded, and `connection.host`
539
+ is not compared, so a different practice host still matches. Every other connection field
540
+ (`protocol`, `port`, `security`) and everything else must equal a fixture request exactly.
541
+
542
+ Anything else fails closed with a ledgered `notEmulated` answer (the port analogue of HTTP 400):
543
+ an unknown method (`unknown-method`), a request that is not an object (`invalid-request`), no
544
+ matching fixture (`no-matching-fixture`), or no matching fixture consistent with the mailbox
545
+ (`state-conflict`). Faults (`kind: 'failure'`, a `method`, an optional deep-subset `match` on the
546
+ request, an optional `count`, and the `failure` to answer) change no state. `ledger` records every
547
+ call (credential-free request, outcome, fixture or fault id, reason), `state()` returns the current
548
+ mailbox, `reset()` restores the seed and clears the ledger, faults, and fixture use, and
549
+ `coverage()` reports calls per route, refusals, and unused fixtures. An invalid seed or fault
550
+ throws `EmailEmulatorInputInvalid`.
551
+
552
+ `emailEmulatorRoutes` names each emulated method as `PORT EmailClient.<method>` with the email
553
+ cases it follows. All routes are unverified: the fixtures are synthetic. Live verification needs a
554
+ host `EmailClient` implementation connected to a practice mailbox.
555
+
556
+ ## R2 emulator
557
+
558
+ `makeR2Emulator({ seed? })` is an in-memory fake backend for the host R2 ports of
559
+ `@yolk-sdk/connectors/r2-storage`: `R2Presigner` (a presigned PUT URL) and `R2ObjectClient`
560
+ (conditional get and put). There is no SigV4 signer, S3 client, socket, Node builtin, or SDK import:
561
+ it is a plain object whose `call(port, method, request)` takes one port call as plain JSON (the
562
+ request without credential fields; bytes as base64) and answers `{ response }`, `{ failure }`, or
563
+ `{ notEmulated: { reason } }`. `r2PortsLayerFromBackend(emulator)` from
564
+ `@yolk-sdk/connectors/r2-storage/conformance` turns it into both port layers, so the R2 conformance
565
+ cases and your own tests run the real connector action and object helpers against it:
566
+
567
+ ```ts
568
+ import { r2PortsLayerFromBackend } from '@yolk-sdk/connectors/r2-storage/conformance'
569
+ import { makeR2Emulator } from '@yolk-sdk/emulators/r2'
570
+
571
+ const r2 = makeR2Emulator()
572
+
573
+ r2.faults.add({
574
+ kind: 'failure',
575
+ port: 'R2ObjectClient',
576
+ method: 'put',
577
+ count: 1,
578
+ failure: { kind: 'error', code: 'transport_failed', message: 'Synthetic outage.' }
579
+ })
580
+
581
+ const r2Layer = r2PortsLayerFromBackend(r2)
582
+ ```
583
+
584
+ Responses come only from the R2 conformance fixtures (copied as data; `r2EmulatorFixtures`). The
585
+ emulator keeps a bucket (`r2EmulatorDefaultSeed`: the practice bucket with the one object the get
586
+ fixtures read) that only decides which fixture answers: the first fixture whose port, method, and
587
+ request match and whose answer is consistent with the bucket (preferring one not used since the
588
+ last reset). The bucket then records what that fixture says happened: an object created
589
+ (absent-only) or replaced (under the current etag), with the etag the fixture names. It never
590
+ invents an etag, a byte, or a failure, and presigning writes nothing. The connector cannot delete R2
591
+ objects, so objects the write cases create stay; running the write cases again on the same emulator
592
+ fails them without writing, as a reused run id does against a live bucket.
593
+
594
+ Credentials never reach the ledger or any other output. Every credential field (`credential(s)`,
595
+ `accessKeyId`, `secretAccessKey`, `sessionToken`, `token`, and the other names the shared port scan
596
+ classifies as credentials) is dropped at any depth before anything is compared or recorded. Every
597
+ refusal is ledgered with constant text only (request `<redacted>`), uses no fault, and changes no
598
+ state; only a request equal to a fixture request is recorded, with every `bodyBase64` as
599
+ `<redacted>` plus its decoded length (`bodyBytes`). Every `bodyBase64` (put bytes) must be canonical
600
+ standard base64 of UTF-8 text (else `uncheckable-body`), and its decoded text is checked like the
601
+ rest of the request. A request is refused as `credential-in-request` when any key, string value,
602
+ number (as printed or as its digit string), or decoded body repeats a value found under a credential
603
+ field (any non-empty key, string, or number, with no minimum length), or holds, outside the exact
604
+ canonical synthetic placeholders, `X-Amz-Credential`, `X-Amz-Signature`, `X-Amz-Security-Token`, a
605
+ credential query parameter, or a token the shared scan flags (a bearer token, a common API-key
606
+ prefix, a JSON Web Token, a PEM private key). Each text is checked raw, within three rounds of
607
+ percent-decoding and three of escape-decoding (`\uXXXX`, `\xXX`, and numeric HTML references, as the
608
+ R2 conformance guard decodes), and through any depth of percent-encoding and JSON escaping, failing
609
+ closed past a work cap. A request with an own `__proto__` key at any depth is refused as
610
+ `invalid-request`, and one the checks cannot walk (cyclic, or nested too deeply) as
611
+ `uncheckable-request`: `call` never throws. The copied key, parameter, and token lists have one test
612
+ sample each, counted against the list and checked against the shared scan. A presign answer is the
613
+ fixture's URL, which carries only those placeholders.
614
+
615
+ - Request-shape latitude: credential fields are never compared or recorded, and JSON key order is
616
+ not compared. Everything else (the endpoint, bucket, key, content type, `maxBytes`,
617
+ `expectedEtag`, the put `condition`, `bodyBase64`, and `maxUploadBytes`) must equal a fixture
618
+ request exactly, so only the fixtures' `run-synthetic` run id is emulated. A `bodyBase64` must be
619
+ canonical standard base64 of UTF-8 text (else `uncheckable-body`); it is compared as sent but
620
+ recorded only as its decoded length. No object may have an own `__proto__` key (else
621
+ `invalid-request`), and no key or value, the decoded body included, may carry a credential or
622
+ repeat a dropped credential value of any length (else `credential-in-request`). Every refusal is
623
+ ledgered with constant text only (request `<redacted>`).
624
+
625
+ Anything else fails closed with a ledgered `notEmulated` answer (the port analogue of HTTP 400),
626
+ with constant text only: a port and method outside the manifest (`unknown-method`; ledgered as
627
+ `<unrecognised>`), a request that is not an object or has an own `__proto__` key
628
+ (`invalid-request`), a body that cannot be checked (`uncheckable-body`), a credential outside the
629
+ credential fields (`credential-in-request`), a request the checks cannot walk
630
+ (`uncheckable-request`; ledgered as `<unrecognised>`), no matching fixture (`no-matching-fixture`),
631
+ or no matching fixture consistent with the bucket (`state-conflict`). None uses a fault or changes
632
+ the bucket. Faults (`kind: 'failure'`, a `port` and `method`, an optional deep-subset `match` on the
633
+ request, an optional `count`, and the `failure` to answer) apply only to a call a fixture would
634
+ answer and change no state. `ledger` records every call (the fixture request of an answered or
635
+ faulted call, `<redacted>` for a refusal; outcome, fixture or fault id, reason), `state()` returns
636
+ the current bucket, `reset()` restores the seed and clears the ledger, faults, and fixture use, and
637
+ `coverage()` reports calls per route, refusals, and unused fixtures. An invalid seed or fault throws
638
+ `R2EmulatorInputInvalid`.
639
+
640
+ `r2EmulatorRoutes` names each emulated method as `PORT <Port>.<method>` with the R2 cases it
641
+ follows: `R2Presigner.presignPutObject`, `R2ObjectClient.get`, and `R2ObjectClient.put` (the only
642
+ write). All routes are unverified: the fixtures are synthetic. Live verification needs a host
643
+ implementation of both ports connected to a practice bucket.
644
+
645
+ ## Fortnox emulator
646
+
647
+ > **Node only.** `@yolk-sdk/emulators/fortnox` runs on the upstream
648
+ > [`@emulators/core`](https://github.com/vercel-labs/emulate) custom runtime (Apache-2.0, pinned to
649
+ > exactly `0.12.0`), which imports Node builtins. The core is loaded lazily by
650
+ > `makeFortnoxEmulator`, so importing the subpath has no side effects.
651
+
652
+ `await makeFortnoxEmulator(options?)` returns
653
+ `{ fetch, baseUrl, ledger, faults, reset, seed, snapshot, coverage, close }`. Each call has its own
654
+ state; `await close()` when done. Serve `fetch` in-process (`InProcessHttpClient`) or on loopback
655
+ (`serveFetchHandler`) and route `https://api.fortnox.se` to it; the Fortnox connector runs
656
+ unchanged.
657
+
658
+ ```ts
659
+ import { Layer } from 'effect'
660
+ import { makeFortnoxEmulator } from '@yolk-sdk/emulators/fortnox'
661
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
662
+
663
+ const fortnox = await makeFortnoxEmulator()
664
+
665
+ const httpLayer = InProcessHttpClient.layer([
666
+ EmulatorRoute.handler('https://api.fortnox.se', fortnox.fetch)
667
+ ])
668
+ // ...run the code under test, then:
669
+ await fortnox.close()
670
+ ```
671
+
672
+ Routes (base path `/3`, JSON bodies, `Authorization: Bearer <non-empty>`; a missing bearer gets a
673
+ 401 `ErrorInformation`, and the token is never stored, forwarded, or ledgered):
674
+
675
+ | Route | Behavior |
676
+ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
677
+ | `GET /3/companyinformation` | `{ CompanyInformation }` |
678
+ | `GET /3/customers` | Search (`name`, `email`, `city`, ...), `filter` (`active`/`inactive`), `page`/`limit`, `MetaInformation` |
679
+ | `GET`, `PUT /3/customers/{CustomerNumber}` | `{ Customer }` envelope |
680
+ | `GET /3/invoices` | Filters `unbooked`, `unpaid`, `unpaidoverdue`, `fullypaid`, `cancelled`; search; `fromdate`/`todate`; `page`/`limit` |
681
+ | `GET`, `PUT /3/invoices/{DocumentNumber}` | `{ Invoice }` envelope with rows |
682
+ | `POST /3/invoices` | Create (201); an unknown `CustomerNumber` gets 400 `ErrorInformation` code `2000433` |
683
+ | `GET /3/invoices/{DocumentNumber}/preview` | A small synthetic PDF (`application/pdf`); does not mark the invoice sent |
684
+ | `GET /3/invoices/{DocumentNumber}/email` | Marks the invoice `Sent` and records an outbox entry in state; never sends anything |
685
+
686
+ `GET /3/companyinformation` and `GET /3/customers` have no recorded fixture: they cite no
687
+ conformance case ids, use minimal shapes named after the connector's read fields, and are
688
+ unverified, uncited read routes (the evidence check warns about them).
689
+
690
+ Anything else fails closed with a 404 `ErrorInformation` (ledgered); unsupported query parameters
691
+ (checked per route before it runs, so a rejected write writes nothing), unknown filters, unknown or
692
+ read-only body fields, and values the emulated company does not have (non-SEK currency, including
693
+ the currency a new invoice inherits from its customer; cost centers) get a 400 `ErrorInformation`
694
+ instead of being ignored. Customer categorical values are limited to the emulated subset (not
695
+ Fortnox's full enums): `VATType` `SEVAT`, `Type` `COMPANY` or `PRIVATE`, and `TermsOfPayment` as
696
+ whole days from `0` to `365`. Other values (named terms such as `K`, export or reverse-charge VAT)
697
+ get a 400 on a customer update, and a new invoice is rejected with a 400 before anything is
698
+ written when the customer it inherits from (a seed can hold anything) carries a `VATType` or
699
+ `TermsOfPayment` outside the subset, or when its
700
+ computed due date is not a representable `YYYY-MM-DD` date. An empty string still keeps the
701
+ stored value. The list filter
702
+ `lastmodified` (the connector's `lastModified` input) is not emulated: the emulator tracks no
703
+ modification times and answers it with a 400 saying so. Errors use the lowercase `{ ErrorInformation: { error, message, code } }` of the rejection
704
+ fixture; `fortnoxEmulatorErrorCodes` lists the codes (the `2999xxx` ones are synthetic).
705
+
706
+ Observed quirks (`fortnoxEmulatorQuirks`, each tied to its conformance case):
707
+
708
+ 1. **Row discount sticky** (`fortnox.invoice.row-discount-sticky`): `InvoiceRows` replaces the rows;
709
+ rows without `RowId` match existing rows by position; a matched row that omits
710
+ `Discount`/`DiscountType` keeps them; `Discount: 0` clears. RowIds are regenerated on every
711
+ update, and totals are recomputed (`Price × DeliveredQuantity × (1 − discount%)`, VAT 25% by
712
+ default, `Total` rounded to whole kronor).
713
+ 2. **Empty string keeps value** (`fortnox.customer.empty-string-keeps-value`): a customer update with
714
+ `""` keeps the stored value; omitted fields keep theirs.
715
+ 3. **Payment filters exclude unbooked** (`fortnox.invoice.payment-filters-exclude-unbooked`):
716
+ `unpaid`, `unpaidoverdue`, and `fullypaid` consider booked invoices only; `unpaidoverdue` needs a
717
+ `DueDate` before today (the injectable `now` clock, UTC).
718
+ 4. **Rejection** (`fortnox.write.rejection-error-information`): writes for unknown customers or with
719
+ invalid fields answer 400 `ErrorInformation`.
720
+ 5. **Read-only `Country`**: sending a customer `Country` answers 400 (no conformance case yet).
721
+
722
+ State and seeds: company information, customers, and invoices with rows, plus the email outbox.
723
+ The default seed is the synthetic fixture entities, with the same customer and document numbers as
724
+ `fortnoxConformanceFixtureSeeds`, so the Fortnox conformance cases run unmodified. Pass
725
+ `seed: { profile?, company?, customers?, invoices? }` (typed; entity lists replace the profile's)
726
+ with profiles `'default'`, `'empty-company'`, or `'no-booked-invoices'`. `reset()` restores the
727
+ current seed and clears the ledger and faults; `seed(next)` replaces the state and becomes what
728
+ `reset()` restores; `snapshot()` returns a deep copy of the state.
729
+
730
+ Faults (`faults.add` or `POST /_emulate/faults`): `{ kind: 'status', status, headers?, body?,
731
+ match?: { method?, path? }, count? }` answers matching requests the route would answer
732
+ successfully, instead of that answer. The route plans first: it reads the state without writing,
733
+ so a request it refuses (a 404 for an unknown id, a 400 for an invalid state or a query key, field,
734
+ or value it does not emulate) is answered by the route and uses up no fault, and a faulted request
735
+ writes nothing. As in the Gateway emulator, statuses without a body (1xx, 204, 205), redirects
736
+ (3xx), invalid header names or values, `location`, and framing headers are rejected when the fault
737
+ is added; a fault is used up only once its response is built, and a response that cannot be built
738
+ answers an evidence-tagged 500 `ErrorInformation` with `responseError` in the ledger, as does a
739
+ route handler that throws. For example a 429 with `retry-after: 2` reaches the connector as `fortnox_rate_limited`
740
+ with `retryAfterMs: 2000`. The ledger records method, path, route template, query, parsed body,
741
+ status, evidence, the applied fault, and any `responseError`.
742
+
743
+ Control plane: `/_emulate/ledger` (`GET`, `DELETE`), `/_emulate/faults` (`GET`, `POST`, `DELETE`),
744
+ `/_emulate/reset` (`POST`), `/_emulate/state` (`GET`), `/_emulate/seed` (`POST`), and
745
+ `/_emulate/coverage` (`GET`).
746
+
747
+ **Drill knobs (tests only).** `quirks: { stickyRowDiscount: false }`,
748
+ `{ emptyStringClears: true }`, and `{ paymentFiltersIncludeUnbooked: true }` each flip one observed
749
+ quirk to the plausible-but-wrong behavior. They exist only to prove that the matching conformance
750
+ case catches a disagreement (it fails with `ConformanceMismatch` while the others pass); never use
751
+ them to model Fortnox.
752
+
753
+ ## Microsoft Graph emulator
754
+
755
+ > **Node only.** `@yolk-sdk/emulators/microsoft` runs on the same pinned `@emulators/core` runtime
756
+ > as the Fortnox emulator, loaded lazily by `makeMicrosoftEmulator`, so importing the subpath has
757
+ > no side effects.
758
+
759
+ `await makeMicrosoftEmulator(options?)` returns
760
+ `{ fetch, baseUrl, sharePointOrigin, ledger, faults, monitors, reset, seed, snapshot, coverage, close }`.
761
+ Each call has its own state; `await close()` when done. It emulates only what the eleven Microsoft
762
+ conformance cases need, so the Microsoft connector and the cases run unchanged against it.
763
+
764
+ Route **two origins** to the same fetch handler: Graph (`https://graph.microsoft.com`) and the
765
+ SharePoint host of the copy monitor URLs (`sharePointOrigin`, default
766
+ `https://synthetic-my.sharepoint.com`, the fixtures' host; the connector only accepts monitor URLs on
767
+ `*.sharepoint.com` or `api.onedrive.com`). The paths never overlap, so both origins may share one
768
+ loopback server:
769
+
770
+ ```ts
771
+ import { makeMicrosoftEmulator } from '@yolk-sdk/emulators/microsoft'
772
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
773
+
774
+ const microsoft = await makeMicrosoftEmulator()
775
+
776
+ const httpLayer = InProcessHttpClient.layer([
777
+ EmulatorRoute.handler('https://graph.microsoft.com', microsoft.fetch),
778
+ EmulatorRoute.handler(microsoft.sharePointOrigin, microsoft.fetch)
779
+ ])
780
+ // With serveFetchHandler, use EmulatorRoute.url(origin, server.url) for both origins.
781
+ // ...run the code under test, then:
782
+ await microsoft.close()
783
+ ```
784
+
785
+ Graph routes (JSON; `Authorization: Bearer <non-empty>`, whose value is never checked, stored,
786
+ forwarded, or ledgered; a missing bearer gets 401 `InvalidAuthenticationToken`). Every Outlook
787
+ route needs `Prefer: IdType="ImmutableId"`, and every calendar route
788
+ `Prefer: outlook.timezone="UTC"`, as every fixture of theirs sends it:
789
+
790
+ | Route (`/v1.0` prefix) | Behavior |
791
+ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
792
+ | `GET /users/{userId}/calendars/{calendarId}/calendarView` | Events overlapping `[startDateTime, endDateTime)`, by start; `$select`, `$top` (required, ≤ 50; one page) |
793
+ | `POST /users/{userId}/calendars/{calendarId}/events` | 201 with the new event and its `id`; attendee-free, single-instance, UTC only |
794
+ | `GET`, `PATCH`, `DELETE /users/{userId}/events/{eventId}` | Read (`$select`), update `subject`, delete (204; later reads 404) |
795
+ | `POST /users/{userId}/events/{eventId}/cancel` | 202 with an empty body; the event is removed, so a later GET or DELETE is a 404 |
796
+ | `GET /users/{userId}/mailFolders/{folderId}/messages` | Folder by id; newest first; `$select`, `$top` (required, ≤ 2), `$skip`; opaque `@odata.nextLink` |
797
+ | `POST /users/{userId}/messages` | 201 draft in Drafts (never sent): `subject`, text `body`, `toRecipients`, the owner as `from` |
798
+ | `PATCH /users/{userId}/messages/{messageId}` | A draft's `subject` and `isRead` |
799
+ | `POST /users/{userId}/messages/{messageId}/move` | 201 with the draft moved to Deleted Items (`destinationId: "deleteditems"` only) |
800
+ | `GET /users/{userId}/messages/{messageId}/attachments[/{id}]` | Listing (`$select` only) includes inline ones, never `contentId`; retrieval has `contentId`, `contentBytes` |
801
+ | `POST /$batch` | Up to 20 `permanentDelete` subrequests of drafts that can all run (204 each); else the whole batch is 400 |
802
+ | `GET /drives/{driveId}/items/{itemId}` and `/children` | Item read (`$select`) and children by name (`$select`, `$top` required, ≤ 200; one page) |
803
+ | `POST /drives/{driveId}/items/{itemId}/children` | 201 folder with a free name; `@microsoft.graph.conflictBehavior` `fail` only |
804
+ | `DELETE /drives/{driveId}/items/{itemId}` | 204; the item and its subtree are removed (no recycle bin), later reads 404 |
805
+ | `POST /drives/{driveId}/items/{itemId}/copy` | A file to `parentReference { driveId, id }` on the drive, same name; 202 with one monitor `Location` |
806
+
807
+ The copy monitor, `GET /personal/{site}/_api/v2.0/monitor/{monitorId}` on the SharePoint origin,
808
+ needs no credential (like the real capability URL). Its first poll runs the copy and answers
809
+ `completed` (200) with the new item's `resourceId`, as the copy fixture records;
810
+ `copyInProgressPolls` (default 0) adds `inProgress` (202) answers before that. Monitors are runtime
811
+ data like the ledger: not part of `snapshot()`, listed by `monitors()` and `/_emulate/state`, and
812
+ cleared by `reset` and `seed`.
813
+
814
+ Wire behavior the cases claim:
815
+
816
+ - **Ids.** A moved message keeps its immutable id (`Prefer: IdType="ImmutableId"`), and a later
817
+ update by that id applies.
818
+ - **Times.** Event `dateTime` values are UTC with seven fractional digits
819
+ (`2026-09-23T12:00:00.0000000`) and `timeZone: "UTC"`; calendar reads answer
820
+ `preference-applied: outlook.timezone="UTC"`. A calendar request without that preference, or
821
+ with any other time zone, is refused.
822
+ - **Paging.** Folder message listings page with `$top`/`$skip`; their `@odata.nextLink` is the
823
+ configured `baseUrl`, the request's raw path (so `/users/ada%40example.test` keeps its `%40`),
824
+ and `%24select`/`%24top`/`%24skip`, byte for byte as the paging fixture. Calendar views and
825
+ children listings answer one page of at most `$top` (their fixtures never page), and attachment
826
+ listings every attachment (their fixtures send only `$select`).
827
+ - **Concurrent writes.** Of two overlapping writes to one message, one gets 409
828
+ `ErrorIrresolvableConflict` and changes nothing.
829
+ - **Envelopes.** Responses carry the fixtures' `@odata.context` (for example
830
+ `$metadata#users('ada%40example.test')/messages/$entity`), entity fields, and error envelopes;
831
+ `test/microsoft.test.ts` replays every fixture and compares each complete response, normalizing
832
+ only emulator-generated values (change keys and etags, draft conversation and internet message
833
+ ids, created ids, and `innerError` request ids and dates).
834
+
835
+ Emulator extrapolations (no fixture). This list predates the fixture-only rule as now stated
836
+ (`AGENTS.md`): it is legacy, to be removed, and never a precedent for another emulator or route. The
837
+ cases need each of these to run, except request-shape latitude (accepted request variations; no
838
+ invented wire behaviour) and the last, which is opt-in and off by default:
839
+
840
+ - **Concurrency window.** The first committed write (update or move) holds the message for
841
+ `conflictWindowMs` (default 25; a refused or faulted write holds nothing); an overlapping write
842
+ gets 409; non-overlapping writes both apply (the immutable-id case moves, then updates, the same
843
+ message).
844
+ - **Id counters.** Created ids (events, drafts, drive items) and change keys come from counters
845
+ that only advance, so a reversible case ends at the seed except the counters.
846
+ - **Removal.** A deleted or cancelled event, a permanently deleted draft, and a deleted folder
847
+ (with its subtree, such as the copy case's folder holding the copied file) are removed from the
848
+ state, so a reversible case ends at the seed.
849
+ - **Seed values.** Entities no fixture shows (the inbox, the attachment message itself, the drive
850
+ root and `Sources` folder) are synthesized; `hasAttachments` is answered as seeded (`false` for
851
+ new drafts), never derived from the attachments.
852
+ - **Request-shape latitude.** Requests that vary harmlessly from the fixtures' are answered like
853
+ them: `$select` may be omitted or name any fields the emulator renders, in any order; `$top` may
854
+ be below the fixture value (1 to 2, 50, or 200) and `$skip` any offset on folder messages; the
855
+ user segment may be the user's id, mail, or user principal name, case-insensitive; write bodies
856
+ may send any subset of the fixture keys (for example a draft with only `subject`), any `showAs`
857
+ free/busy status, either boolean for `isRead` and `isReminderOn`, and non-empty `toRecipients`
858
+ with or without names; and `calendarView` accepts any valid range, including UTC offsets. A
859
+ missing message or attachment answers 404 `ErrorItemNotFound`, the documented Graph code, which
860
+ no fixture records for them.
861
+ - **In-progress copies (opt-in).** `copyInProgressPolls` (default 0, so no case sees it) makes the
862
+ monitor answer `{ "@odata.context", "percentageComplete": 0, "status": "inProgress" }` (202)
863
+ that many times before the copy runs; no fixture records an in-progress poll.
864
+
865
+ Anything else fails closed with the Graph error envelope `{ error: { code, message, innerError } }`
866
+ (`innerError` holds a synthetic `date`, `request-id`, and `client-request-id`): unknown routes and
867
+ methods (including `/me` paths) get 404 `SyntheticRouteNotEmulated` and are ledgered; query keys a
868
+ route does not emulate get 400 before the route runs (so a rejected write writes nothing), as do
869
+ `$select` fields, body properties (including unknown keys inside `body`, recipients,
870
+ `emailAddress`, `start`/`end`, and `parentReference`), and values it does not emulate. That covers
871
+ Outlook requests without the immutable-id preference, `If-Match` conditional requests, conflict
872
+ behaviors other than `fail` (or none), name conflicts, HTML bodies, attendees, non-UTC times,
873
+ send-as `from`, calendar requests without `Prefer: outlook.timezone="UTC"`, collection listings
874
+ without `$top` or with `$top` above the largest value a fixture sends (2 for folder messages, 50
875
+ for calendar views, 200 for children), calendar views and children listings with more results than
876
+ `$top`, `$skip` anywhere but folder messages, folder message listings by well-known name or an
877
+ unknown folder id, moves to any destination but `deleteditems` (including `inbox`, `drafts`, and
878
+ folder ids), updates, moves, and permanent deletes of messages that are not drafts, permanently
879
+ deleting a message with attachments, copies of folders, with a new `name`, without
880
+ `parentReference.driveId`, or to another drive, file creation, a `$batch` subrequest that could
881
+ not answer 204, and a copy that can no longer run when its monitor is polled (400 at the monitor;
882
+ no fixture records a failed copy). A route handler that throws answers a 500 Graph error
883
+ envelope, recorded in the ledger with `responseError` (if the injected clock throws too, its
884
+ `innerError.date` is the fixed `1970-01-01T00:00:00`; unknown routes still answer, and ledger,
885
+ their 404, and a closed emulator its 503, with that date). `microsoftEmulatorErrorCodes` lists the
886
+ codes: `ErrorItemNotFound`, `itemNotFound`, and `ErrorIrresolvableConflict` come from the
887
+ fixtures; `InvalidAuthenticationToken`, `ErrorInvalidUser`, and `TooManyRequests` (the default 429
888
+ fault body) are documented Graph codes no fixture records; the `Synthetic*` ones are emulator
889
+ codes.
890
+
891
+ State and seeds: the mailbox user, mail folders, messages, file attachments (inline and regular),
892
+ calendars and events, the drive and its items, and id counters. The default seed is the synthetic
893
+ fixture entities with the same ids as `microsoftConformanceFixtureSeeds` (mailbox
894
+ `ada@example.test`, calendar, events, attachment message, paging folder with three messages, drive,
895
+ parent folder, and copy source). Pass `seed: { profile?, user?, mailFolders?, messages?,
896
+ attachments?, calendars?, events?, drive?, driveItems? }` (entity lists replace the profile's) with
897
+ profiles `'default'` or `'empty'`. `reset()` restores the current seed and clears the ledger,
898
+ faults, and monitors; `seed(next)` replaces the state; `snapshot()` returns a deep copy.
899
+
900
+ Faults, the ledger, and the control plane mirror the Fortnox emulator: `status` faults (`match`
901
+ by method and raw path, `count`) answer only a request the route would answer successfully (a
902
+ refusal such as a 404 for an unknown id, a 400 not emulated, or a 409 conflict uses up none), a
903
+ faulted request writes nothing, creates no copy monitor, and holds no message, and they follow the
904
+ shared status and header rules; the default body is a Graph error envelope (`TooManyRequests` for
905
+ 429, so a 429 with `retry-after: 2` reaches the connector as `microsoft_rate_limited` with
906
+ `retryAfterMs: 2000`). The ledger records method, raw path, route template, query
907
+ (credential-named keys such as `access_token`, and the conformance scan's credential query
908
+ parameters such as `X-Amz-Signature` and `X-Amz-Credential`, are redacted), parsed body
909
+ (credential-named keys at any depth, such as a `$batch` subrequest's `Authorization`, are
910
+ redacted), the `Prefer` header, status, evidence, the applied fault, and any `responseError`.
911
+ Control plane: `/_emulate/ledger`, `faults`, `reset`, `state`, `seed`, and `coverage`.
912
+
913
+ **Drill knobs (tests only).** `drills: { calendarRangeEmpty: true }` (empty calendar views),
914
+ `{ createOmitsId: true }` (event create answers without `id`), `{ timestampPrecisionDigits: 3 }`,
915
+ and `{ omitNextLink: true }` each make the emulator disagree with one claim, only to prove the
916
+ matching conformance case catches it. The timestamp and paging knobs fail only their case; an empty
917
+ view also fails the timestamp case's precondition, and an id-less create also fails the cancel
918
+ case, which creates its event the same way.
919
+
920
+ ## Dropbox emulator
921
+
922
+ > **Node only.** `@yolk-sdk/emulators/dropbox` runs on the same pinned `@emulators/core` runtime as
923
+ > the Fortnox and Microsoft emulators, loaded lazily by `makeDropboxEmulator`, so importing the
924
+ > subpath has no side effects.
925
+
926
+ `await makeDropboxEmulator(options?)` returns
927
+ `{ fetch, fetchOn, ledger, faults, cursors, reset, seed, snapshot, coverage, close }`. Each call has
928
+ its own state; `await close()` when done. It emulates only what the eight Dropbox conformance
929
+ cases (with their cleanup) send, so the Dropbox connector actions, the `createDropboxFile` /
930
+ `updateDropboxFile` upload helpers, and the cases run unchanged against it. The read-only leftover
931
+ lookup (`findDropboxConformanceLeftovers`) fails against the default seed: it lists the empty work
932
+ folder, and no fixture records a listing of an empty folder, so that listing answers the ledgered
933
+ 400 not-emulated (nothing is written) and the lookup fails with `DropboxConformanceActionFailed`
934
+ (`dropbox_list_folder_failed`, HTTP 400); the live runner turns that into its lookup-failed `WARN`.
935
+ It lists a work folder that holds entries. Each route answers only on the origin its fixtures record: the RPC routes on
936
+ `https://api.dropboxapi.com` (`dropboxEmulatorApiOrigin`) and the upload on
937
+ `https://content.dropboxapi.com` (`dropboxEmulatorContentOrigin`). `fetch` takes the origin from the
938
+ request URL (in-process routing keeps it); behind a loopback rewrite, which loses it, serve
939
+ `fetchOn(origin)` for each origin on its own server:
940
+
941
+ ```ts
942
+ import {
943
+ dropboxEmulatorApiOrigin,
944
+ dropboxEmulatorContentOrigin,
945
+ makeDropboxEmulator
946
+ } from '@yolk-sdk/emulators/dropbox'
947
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
948
+
949
+ const dropbox = await makeDropboxEmulator()
950
+
951
+ const httpLayer = InProcessHttpClient.layer([
952
+ EmulatorRoute.handler(dropboxEmulatorApiOrigin, dropbox.fetch),
953
+ EmulatorRoute.handler(dropboxEmulatorContentOrigin, dropbox.fetch)
954
+ ])
955
+ // With serveFetchHandler: one server per origin, serving dropbox.fetchOn(origin).
956
+ // ...run the code under test, then:
957
+ await dropbox.close()
958
+ ```
959
+
960
+ Routes (every one a `POST` under `/2`, `Authorization: Bearer <non-empty>`, whose value is never
961
+ checked, stored, forwarded, or ledgered; RPC routes take a JSON body with no query string):
962
+
963
+ | Route | Behavior |
964
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------- |
965
+ | `/2/files/list_folder` | `{ path, limit }` of a folder: `{ entries, cursor, has_more }`, at most `limit` entries per page |
966
+ | `/2/files/list_folder/continue` | `{ cursor }`: the next page of an unchanged listing; every page, the last included, carries a cursor |
967
+ | `/2/files/get_metadata` | `{ path }`: file metadata, or 409 `path/not_found`; `include_deleted: true` after a recorded delete |
968
+ | `/2/files/search_v2` | `{ query, options: { max_results, filename_only: true } }`: file name matches; a cursor with `has_more` |
969
+ | `/2/files/search/continue_v2` | `{ cursor }`: the next matches of an unchanged search (the last page has no cursor) |
970
+ | `/2/files/create_folder_v2` | `{ path, autorename: false }`: `{ metadata }` without `.tag`; an existing folder, any casing, is 409 |
971
+ | `/2/files/delete_v2` | `{ path }` (a path or `id:`) of a folder: `{ metadata }` tagged `folder`; the folder and its content go |
972
+ | `/2/files/copy_v2`, `move_v2` | `{ from_path, to_path, autorename: false }` of a file: `{ metadata }` (copy: new id; move: same id) |
973
+ | `/2/files/upload` (content host) | `Dropbox-API-Arg` `add` or `{ ".tag": "update", update: <rev> }` (by `id:`), `strict_conflict: true` |
974
+
975
+ Wire behavior, as the fixtures record it:
976
+
977
+ - **Errors.** Route errors are HTTP 409 with the fixtures' `error_summary` envelopes, byte for byte
978
+ (`dropboxEmulatorErrorBodies`): `path/not_found/.` for a missing path in an existing folder,
979
+ `path/conflict/folder/..` for an existing folder, and `path/conflict/file/..` (a `reason`
980
+ object) for an `add` upload onto an existing file or an `update` naming a stale rev. A rejected
981
+ write changes nothing.
982
+ - **Paths.** Lookups are case-insensitive; `path_lower` is the lower-cased path. `get_metadata`
983
+ answers `path_display` with the request's casing for every component but the last, which keeps
984
+ the stored casing, as the lower-cased lookup fixture records.
985
+ - **Paging.** `list_folder` pages through a folder's entries and `search_v2` through the files
986
+ whose names contain the query, case-insensitively, as the paging and search fixtures record.
987
+ - **Relocation.** `copy_v2` answers a new file with a new id and rev and the source's size, content
988
+ hash, and timestamps; `move_v2` keeps the id and timestamps and gets a new rev.
989
+ - **Uploads.** `add` never overwrites; `update` replaces the file under the same id with a new rev
990
+ only when its rev is current.
991
+ - **Deleted entries.** Deleting an empty folder keeps a deleted-entry record (state `deleted`), so
992
+ `get_metadata` with `include_deleted: true` answers the `deleted` metadata, as the delete fixture
993
+ records; afterwards `get_metadata` without it answers 409 `path/not_found`.
994
+
995
+ Every answer value comes from a fixture, through the seed or the request, except the values the
996
+ emulator mints (it never mints anything else):
997
+
998
+ - **Minted values.** Created ids (`id:SyntheticEntry00000001`) and revs (`a1b2c3d4e5f60101`) come
999
+ from counters that only advance and start above the highest seeded id and rev in that form, so a
1000
+ minted value never repeats a seeded one; a created file's content hash is 62 zeros and its rev's
1001
+ last two digits, as the upload fixture writes it; upload timestamps come from the injectable `now`
1002
+ clock; cursors (`AAHsyntheticListCursorNNNN`, `AAHsyntheticSearchCursorNNNN`) come from counters
1003
+ that never reset. A reversible case therefore ends at the seed except the counters and the
1004
+ deleted-entry record of an empty case folder it deleted.
1005
+ - **Implied folders.** The parent folders the seeded paths need (`/Conformance`,
1006
+ `/Conformance/Paging`, `/Conformance/Search`, `/Conformance/Work`) are `implied`: no fixture shows
1007
+ them, so lookups pass through them but any answer that would render one is not emulated.
1008
+ - **Cursors.** A cursor is accepted only when this emulator issued it since the last reset or seed
1009
+ (reset and seed clear the registry; cursor values are never reissued) and the listing or search
1010
+ it continues renders exactly as when it was issued. Cursors are runtime data, listed by
1011
+ `cursors()` and `/_emulate/state`.
1012
+ - **Request-shape latitude (`/dropbox`, the only accepted deviations).** Any bearer value (never
1013
+ checked or stored); extra request headers; JSON key order; `content-type` media-type parameters;
1014
+ any path and search query (looked up in the state); any `list_folder` `limit` from 1 to 2000 and
1015
+ any `search_v2` `options.max_results` from 1 to 1000; and any upload body bytes. Everything else
1016
+ (other keys, booleans, modes, query parameters, another origin, ids or revs where the fixtures
1017
+ send paths, the root folder, and cursors not issued since the last reset or whose listing
1018
+ changed) is not emulated.
1019
+
1020
+ Anything else answers one ledgered 400 not-emulated (`{ error: { type: 'not_emulated', message } }`,
1021
+ `notEmulated` in the ledger), writes nothing, and uses up no fault: unknown routes and methods, a
1022
+ route on another origin, a missing bearer, query parameters, other content types, unknown or
1023
+ missing body keys, `autorename` or `include_deleted: false`, `strict_conflict: false`, other upload
1024
+ modes, the root folder, `id:` or `rev:` paths where the fixtures send paths, `get_metadata` of a
1025
+ folder or of a path whose parent folder is missing or a file, `include_deleted` without a recorded
1026
+ delete or on a live entry, listing a file, a missing folder, an empty folder, or a folder holding an
1027
+ implied one, a search without matches or matching a folder, a missing parent folder, deleting a file, a missing
1028
+ entry, or an implied folder, copying or moving a folder or onto an existing entry, an `add` upload
1029
+ onto a folder, an `update` upload of a missing file, and the cursors above. A route that throws (for
1030
+ example when the upload clock throws) answers an evidence-tagged 500 emulator error with
1031
+ `responseError` in the ledger, and a closed emulator answers 503.
1032
+
1033
+ State and seeds: entries (files and folders by parent id; files carry rev, size, content hash, and
1034
+ timestamps; `implied` marks a folder no fixture shows), deleted-entry records, and counters. The
1035
+ default seed is the synthetic fixture entries at the paths of `dropboxConformanceFixtureSeeds`
1036
+ (the paging folder with three entries, the mixed-case file, two search matches, and the copy source,
1037
+ under implied folders). Pass `seed: { profile?, entries?, deleted? }` (entries by display path; a
1038
+ parent folder must be seeded too) with profiles `'default'` or `'empty'`. `reset()` restores the
1039
+ current seed and clears the ledger, faults, and cursors; `seed(next)` replaces the state;
1040
+ `snapshot()` returns a deep copy.
1041
+
1042
+ Faults (`faults.add` or `POST /_emulate/faults`):
1043
+ `{ kind: 'status', status, headers?, body?, match?: { method?, path?, route? }, count? }` with a
1044
+ status of 400-599 answers a matching request the emulator would otherwise answer, instead of its
1045
+ write (nothing is written). In `match`, `method` is the HTTP method, `path` the raw request path (a
1046
+ trailing `*` makes it a prefix), and `route` a route template of the manifest
1047
+ (`dropboxEmulatorRoutes`; a value that names no manifest row is rejected when the fault is added).
1048
+ The route's pure, state-reading eligibility check runs first, so a request that is not emulated
1049
+ never uses one up. The default body is `{ error: { type: 'emulator_fault', message } }` (never a
1050
+ guessed Dropbox body), so a 429 with `retry-after: 2` reaches the connector as
1051
+ `dropbox_rate_limited` with `retryAfterMs: 2000`. The ledger records method, raw path, route
1052
+ template, query (credential-named keys such as `authorization` and `access_token` redacted; a value
1053
+ starting with `{` or `[`, such as a browser-style `arg`, parsed with credential-named keys redacted
1054
+ at any depth, or `<redacted>` when unparseable), the parsed JSON body (credential-named keys
1055
+ redacted), an upload's body length (never its bytes), the `Dropbox-API-Arg` header (parsed,
1056
+ credential-named keys redacted at any depth; an unparseable value is recorded as `<redacted>`),
1057
+ status, evidence, the applied fault, `notEmulated`, and any `responseError`. Control plane:
1058
+ `/_emulate/ledger`, `faults`, `reset`, `state`, `seed`, and `coverage`.
1059
+
1060
+ **Drill knobs (tests only).** `drills: { listFolderSinglePage, getMetadataCaseSensitive,
1061
+ searchRepeatsMatches, notFoundAsPathLookup, folderConflictAsFile, deleteLeavesNoTombstone,
1062
+ moveMintsNewId, uploadIgnoresRev }` (booleans) each make the emulator disagree with exactly one
1063
+ Dropbox case, only to prove that case catches it.
1064
+
1065
+ ## Notion emulator
1066
+
1067
+ > **Node only.** `@yolk-sdk/emulators/notion` runs on the same pinned `@emulators/core` runtime,
1068
+ > loaded lazily by `makeNotionEmulator`, so importing the subpath has no side effects.
1069
+
1070
+ `await makeNotionEmulator(options?)` returns
1071
+ `{ fetch, fetchOn, ledger, faults, reset, seed, snapshot, coverage, close }`. Each call has
1072
+ its own state; `await close()` when done. It emulates only what the eight Notion conformance cases
1073
+ (with their cleanup) send, so the Notion connector and the cases run unchanged against it. The
1074
+ read-only leftover lookup (`findNotionConformanceLeftovers`) fails against it whenever its search
1075
+ for `yolk-conformance` has no match (a clean workspace) or matches a trashed page (after the write
1076
+ case): no fixture records either answer, so the search answers the ledgered 400 not-emulated
1077
+ (nothing is written) and the lookup fails with `NotionConformanceActionFailed`
1078
+ (`notion_search_failed`, HTTP 400); the live runner turns that into its lookup-failed `WARN`. It
1079
+ answers when every match is an untrashed page with timestamps. Every route answers only on the
1080
+ origin its fixtures record, `https://api.notion.com` (`notionEmulatorOrigin`): `fetch` reads the
1081
+ origin from the request URL (in-process routing keeps it); behind a loopback rewrite, which loses
1082
+ it, serve `fetchOn(notionEmulatorOrigin)`. Route `https://api.notion.com` to it:
1083
+
1084
+ ```ts
1085
+ import { makeNotionEmulator } from '@yolk-sdk/emulators/notion'
1086
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
1087
+
1088
+ const notion = await makeNotionEmulator()
1089
+
1090
+ const httpLayer = InProcessHttpClient.layer([
1091
+ EmulatorRoute.handler('https://api.notion.com', notion.fetch)
1092
+ ])
1093
+ // ...run the code under test, then:
1094
+ await notion.close()
1095
+ ```
1096
+
1097
+ Routes (under `/v1`; every request needs `Authorization: Bearer <non-empty>`, whose value is never
1098
+ checked, stored, forwarded, or ledgered, and `Notion-Version: 2025-09-03`, the version every fixture
1099
+ sends; bodies are JSON):
1100
+
1101
+ | Route | Behavior |
1102
+ | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
1103
+ | `POST /v1/search` | `{ query, filter: { property: "object", value: "page" }, page_size, start_cursor? }`: pages by title |
1104
+ | `GET /v1/users/me` | The integration's bot user |
1105
+ | `GET /v1/pages/{pageId}` | The page (trashed pages too); 404 `object_not_found` or 400 `validation_error` envelopes |
1106
+ | `POST /v1/pages` | The recorded create: a child of a page titled `yolk-conformance page: safe to delete` |
1107
+ | `PATCH /v1/pages/{pageId}` | `{ archived: true }`: the page with `archived` and `in_trash` true |
1108
+ | `GET /v1/blocks/{blockId}/children` | `page_size` (required), `start_cursor`: a page's child blocks |
1109
+ | `GET /v1/pages/{pageId}/properties/{propertyId}` | `page_size` (required), `start_cursor`: the items of a paginated property |
1110
+ | `GET /v1/databases/{databaseId}` | The database with its `data_sources` (`{ id, name }`) |
1111
+ | `GET /v1/data_sources/{dataSourceId}` | The data source: `properties` schema, `parent: { type: "database_id" }`, `database_parent` |
1112
+ | `POST /v1/data_sources/{dataSourceId}/query` | `{ page_size: 1 }`: the recorded first row, `parent: { type: "data_source_id" }`, and the next cursor |
1113
+
1114
+ Wire behavior, as the fixtures record it:
1115
+
1116
+ - **Version.** Every route needs `Notion-Version: 2025-09-03`; any other version (or none) is not
1117
+ emulated.
1118
+ - **Cursor paging.** Lists answer `{ object: "list", results, next_cursor, has_more, type, <type>:
1119
+ {} }`, and the last page carries `next_cursor: null` (present, not absent). Search and block
1120
+ cursors are the next result's id; property item cursors are opaque, with `property_item.next_url`
1121
+ naming the property id as the page object returns it.
1122
+ - **Errors.** A page read of a well-formed id that addresses no page answers 404
1123
+ `{ object: "error", status: 404, code: "object_not_found", message, request_id }`; a malformed id
1124
+ answers 400 `validation_error`, with the fixtures' messages.
1125
+ - **Property ids.** The property id path segment is decoded once, so the connector's second
1126
+ percent-encoding (`Syn%253Ap`) names the property the page returns as `Syn%3Ap`.
1127
+ - **Data source split.** The database lists its data sources; the data source holds the schema and
1128
+ names its database; its query answers the recorded first row, whose parent names the data source,
1129
+ with the next row's id (which the fixture only names) as `next_cursor`.
1130
+ - **Archive.** `archived: true` answers the page with `archived` and `in_trash` true and keeps
1131
+ `last_edited_time`; the page still reads back (200) as archived.
1132
+
1133
+ Every answer value comes from a fixture, through the seed or the request, except the values the
1134
+ emulator mints (it never mints anything else):
1135
+
1136
+ - **Minted values.** Created page ids (`1f0000e0-0000-4000-8000-000000000001`, ...) come from a
1137
+ counter that only advances and starts above the highest seeded id in that form; created pages take
1138
+ their timestamps from the injectable `now` clock; request ids come from the ledger sequence;
1139
+ property item cursors come from a counter that never resets (the first is the fixture's value);
1140
+ search and block cursors are the next result's id (the fixture's value) only in the generation
1141
+ that first issued that id for that list, and `<id>.g<generation>` after a reset or seed (each
1142
+ starts a generation). A reversible run ends at the seed except the counter and its own page, in
1143
+ the trash.
1144
+ - **Implied pages.** Pages a fixture only names by id (the blocks page, the write case's parent page,
1145
+ the database's parent page, and the second data source row) are `impliedPages`: their ids resolve
1146
+ where a fixture names them (a block parent, a create parent, a database parent, the query's next
1147
+ row), but no content exists for them, so any answer that would render one is not emulated.
1148
+ - **Search scope.** Search considers only the pages whose content the state holds (the `pages`):
1149
+ an implied page has no known title, so it never matches a search. A search answers only matches
1150
+ in the shape the search fixture records (untrashed pages with timestamps): a match that is
1151
+ trashed, or shown only as a query row (no timestamps), makes the search not emulated.
1152
+ - **Cursors.** A cursor is accepted only when this emulator issued it for the same list (the same
1153
+ search query, block parent, or property) since the last reset or seed, and the list renders
1154
+ exactly as when it was issued. No cursor value crosses a reset or seed (see minted values), so a
1155
+ pre-reset cursor stays refused even after the same first-page request.
1156
+ - **Request-shape latitude (`/notion`, the only accepted deviations).** Any bearer value (never
1157
+ checked or stored); extra request headers; JSON key order; `content-type` media-type parameters;
1158
+ the order of query parameters; Notion ids with or without dashes, in any case; any search `query`
1159
+ (looked up in the state); and any `page_size` from 1 to 100 whose page shows only recorded results
1160
+ (the data source query: 1). `Notion-Version` must be `2025-09-03`. Everything else (other keys,
1161
+ filters, booleans, sorts, query parameters, another origin, titles, repeated or missing `page_size`, and cursors
1162
+ not issued for the same list since the last reset or whose list changed) is not emulated.
1163
+
1164
+ Anything else answers one ledgered 400 not-emulated (`{ error: { type: 'not_emulated', message } }`,
1165
+ `notEmulated` in the ledger), writes nothing, and uses up no fault: unknown routes and methods (other
1166
+ users, comments, block reads or updates, database queries, page deletes), another origin, a missing bearer or
1167
+ `Notion-Version`, query parameters on routes that take none, other search filters and keys, a search
1168
+ without matches or whose matches include a trashed page or a page shown only as a query row, `sorts`, `filter`, or `start_cursor` on the data source query,
1169
+ a query page that would show a row no fixture shows (or a last page), the cursors above, children
1170
+ of anything but a page or of a page without recorded child blocks, a property with no seeded item list (or a singly encoded property id), a
1171
+ missing database or data source, a read of an implied page or of a page shown only as a query row,
1172
+ page creates with `children`, a database parent, another title, more than one title item,
1173
+ annotations, or a missing or trashed parent, page updates other than `{ archived: true }`, and
1174
+ archiving a missing, implied, row, or already trashed page. A route that throws answers an
1175
+ evidence-tagged 500 emulator error with `responseError` in the ledger, and a closed emulator answers 503.
1176
+
1177
+ State and seeds: the bot user, pages (`parent`, trash flags, `properties` as stored, `url`), implied
1178
+ pages, blocks, paginated property items, databases, data sources, and the page counter. The default
1179
+ seed is the synthetic fixture entities with the same ids as `notionConformanceFixtureSeeds`. Pass
1180
+ `seed: { profile?, botUser?, pages?, impliedPages?, blocks?, propertyItems?, databases?,
1181
+ dataSources? }` (lists replace the profile's) with profiles `'default'` or `'empty'`. Property
1182
+ item `next_url` values name the recorded origin. `reset()`, `seed(next)`, and
1183
+ `snapshot()` behave as in the Dropbox emulator; reset and seed also clear issued cursors.
1184
+
1185
+ Faults, the ledger (which records the `Notion-Version` header), and the control plane behave as in
1186
+ the Dropbox emulator; a 429 fault with `retry-after` reaches the connector as
1187
+ `notion_rate_limited`.
1188
+
1189
+ **Drill knobs (tests only).** `drills: { searchRepeatsResults, botUserAsPerson,
1190
+ envelopeStatusMismatch, omitTitlePlainText, blockCursorRepeats, rejectDoubleEncodedPropertyId,
1191
+ rowParentAsDatabase, trashedPageNotFound }` (booleans) each make the emulator disagree with exactly
1192
+ one Notion case, only to prove that case catches it.
1193
+
1194
+ ## Todoist and Telegram emulators
1195
+
1196
+ > **Node only.** `@yolk-sdk/emulators/todoist` and `@yolk-sdk/emulators/telegram` run on the same
1197
+ > pinned `@emulators/core` runtime as the Fortnox and Microsoft emulators, loaded lazily by
1198
+ > `makeTodoistEmulator` / `makeTelegramEmulator`, so importing either subpath has no side effects.
1199
+
1200
+ Both are **fixture-only** and stateful: response behaviour comes only from their committed
1201
+ conformance fixtures (copied as data), the state decides which recorded answer applies (created
1202
+ ids, a deleted project, a sent message), and everything the fixtures do not record answers one
1203
+ ledgered 400 not-emulated, `{ error: { type: 'not_emulated', message: 'Not emulated: <reason>' } }`
1204
+ (`notEmulated` in the ledger), with no guessed provider status, envelope, or error code. That covers
1205
+ unknown routes and methods, missing or malformed credentials, query parameters, body fields, and
1206
+ values no fixture records. A refused request writes nothing and uses up no fault (eligibility is
1207
+ checked against the request and the state before any fault is chosen, and the check, the fault
1208
+ decision, and the write run together, so concurrent requests never interleave). They run on the
1209
+ internal stateful wrapper the Dropbox, Notion, GitHub, Google, LinkedIn search, and MCP emulators
1210
+ share (ledger, faults, control plane, clock-free recovery); each returns
1211
+ `{ fetch, ledger, faults, reset, seed, snapshot, coverage, close }` (Todoist adds `cursors`). Each
1212
+ call has its own state; `await close()` when done (later requests answer 503).
1213
+
1214
+ ```ts
1215
+ import { makeTelegramEmulator } from '@yolk-sdk/emulators/telegram'
1216
+ import { makeTodoistEmulator } from '@yolk-sdk/emulators/todoist'
1217
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
1218
+
1219
+ const todoist = await makeTodoistEmulator()
1220
+ const telegram = await makeTelegramEmulator()
1221
+
1222
+ const httpLayer = InProcessHttpClient.layer([
1223
+ EmulatorRoute.handler('https://api.todoist.com', todoist.fetch),
1224
+ EmulatorRoute.handler('https://api.telegram.org', telegram.fetch)
1225
+ ])
1226
+ // ...run the code under test, then:
1227
+ await Promise.all([todoist.close(), telegram.close()])
1228
+ ```
1229
+
1230
+ ### Todoist emulator
1231
+
1232
+ Routes (under `/api/v1`, JSON, `Authorization: Bearer <token>` with a token of at least 8
1233
+ characters, whose value is never checked against anything, stored, forwarded, or ledgered; without
1234
+ it a request is not emulated):
1235
+
1236
+ | Route | Behavior |
1237
+ | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1238
+ | `GET /tasks?project_id&limit&cursor` | Active tasks by `child_order` of the paging project (`limit=2`) or a case project created here (no `limit`); REST v1 cursor paging (`{ results, next_cursor }`) |
1239
+ | `POST /tasks` | `{ content, project_id, due_date? }` in a case project created here: the new task (200) |
1240
+ | `GET /tasks/{taskId}` | An active task (labels as label names), or the fixtures' 404 `Task not found` body |
1241
+ | `POST /tasks/{taskId}` | `content` and/or `due_datetime` of an active task created here: the updated task |
1242
+ | `POST /tasks/{taskId}/close` | Closes an active task created here (204, no body); it leaves the active listing |
1243
+ | `GET /labels?limit` | Personal labels, one page (`next_cursor: null`) |
1244
+ | `POST /projects` | `{ name, parent_id }` for a case project `yolk-conformance-<runId>-<lifecycle\|due\|parent\|delete>` |
1245
+ | `GET /projects/{projectId}` | A project created here, or the fixtures' 404 `Project not found` body |
1246
+ | `DELETE /projects/{projectId}` | A project created here: 204; it and its task are removed, so later reads answer 404 |
1247
+
1248
+ Wire claims, all from the fixtures: project and task bodies carry the fixtures' keys, order, and
1249
+ default values (including `child_order: 1`); 404 bodies are `{ error, error_code: 478, error_extra:
1250
+ { event_id }, error_tag: 'NOT_FOUND', http_code: 404 }`; the first task page of a listing larger
1251
+ than `limit=2` answers `next_cursor` (`SyntheticTaskCursor0001`, ...), the cursor leads to the next
1252
+ page of the same project and `limit`, and the last page answers `next_cursor: null`;
1253
+ `due_date: '2030-01-15'` and `due_datetime: '2030-01-15T12:00:00Z'` answer the recorded due
1254
+ objects; an update keeps `updated_at`, as recorded.
1255
+
1256
+ Nothing else is synthesised. Seeded projects (the work and paging projects, whose objects no
1257
+ fixture records; the fixtures name only their ids) are reference targets only: reading one answers
1258
+ not-emulated, and only projects and tasks created through the recorded create flow are updated,
1259
+ closed, or deleted. A case project takes one task and a seeded parent one sub-project, because the
1260
+ fixtures record `child_order: 1` only (a second one is not emulated). The labeled task is in the
1261
+ work project (the synthetic label fixture was corrected to match the paging listing, which never
1262
+ lists it).
1263
+
1264
+ **Unrecognised requests.** The emulator fails closed: a request is recognised only when its raw path
1265
+ is exactly one of the routes above under its HTTP method, with raw id segments that are Todoist ids
1266
+ (percent-encoding is never recognised). Every other request is ledgered and answered with constant
1267
+ text only: the path `/<unrecognised>`, a standard method or `<other>`, no query, no body, and the
1268
+ reason `no emulated Todoist route for this method and path`. A request whose `Authorization` header
1269
+ is present but is not one recognisable bearer (a non-bearer scheme, a value under 8 characters,
1270
+ extra words, combined duplicate headers) is ledgered the same way, whatever its route, with the
1271
+ reason `an unrecognisable Authorization header is not emulated`: its credential cannot be extracted
1272
+ and scrubbed, so nothing from the request is recorded.
1273
+
1274
+ **Sharing one emulator.** Run write cases sequentially on one emulator, or `reset()` between cases.
1275
+ A seeded parent takes one case project at a time (the fixtures record `child_order: 1` only), so
1276
+ concurrent write cases, or a case that failed before its cleanup, make later project creates answer
1277
+ not-emulated (a definitive rejection: nothing is created). `test/todoist-conformance.test.ts` runs
1278
+ all seven cases one after another on one emulator, which ends at the seed except the counters.
1279
+
1280
+ Every answer value comes from a fixture, through the seed or the request, except the values the
1281
+ emulator mints (it never mints anything else):
1282
+
1283
+ - **Minted values.** Created project ids (`6XEmuProject0001`), task ids (`6XEmuTask0000001`), and
1284
+ 404 `event_id`s (`00000000000000000000000000000001`) come from counters that only advance;
1285
+ created timestamps come from the injectable `now` clock (default `Date.now`). Minted ids use the
1286
+ reserved prefix `6XEmu`, which a seed may not use (it is rejected), so seeded and created ids
1287
+ never collide. Cursors are runtime data (`cursors()`, `/_emulate/state`), valid only as issued
1288
+ since the last `reset` or `seed`.
1289
+ - **Removal.** A deleted project and its task are removed from the state, so a write case ends at
1290
+ the seed except the counters.
1291
+ - **Request-shape latitude** (the only accepted deviations from the fixture requests): any
1292
+ credential value of at least 8 characters that occurs nowhere else in the request (its path,
1293
+ query, or body; never checked against anything, stored, or ledgered); extra request headers;
1294
+ `content-type` parameters; query parameters in any order; any Todoist id (1-64 of `[A-Za-z0-9_-]`)
1295
+ of an existing item where a fixture has an id (reads: seeded or created tasks and created
1296
+ projects; task listings: the paging project with `limit=2` and case projects created here without
1297
+ `limit`; writes: only items created through the recorded create flow; a new project's `parent_id`:
1298
+ a seeded project); any `run-` run id (at most 40 characters) in a case project name; any non-empty
1299
+ task `content`; a task update sending `content`, `due_datetime`, or both; a label listing `limit`
1300
+ of 1 to 200 that covers every label.
1301
+
1302
+ Not emulated (400), among others: other routes (`/tasks/filter`, sections, comments, REST v2, and
1303
+ the project listing `GET /projects`); task listings without `project_id`, of any project but the
1304
+ paging project with `limit=2` or a case project created here without `limit`, or with a cursor the
1305
+ emulator did not issue since its last reset or that is sent with another `project_id` or `limit`;
1306
+ label listings without `limit` or with more labels than `limit` (paging them is not emulated); body
1307
+ fields no fixture sends (`labels`, `priority`, `due_string`, `description`, ...); other due values;
1308
+ project names outside the run namespace; reading a seeded project; projects under a case project or
1309
+ an unknown parent, or a second sub-project; tasks outside a case project created here, or a second
1310
+ one in it; reading a closed task; updating or closing a seeded, unknown, or closed task; deleting a
1311
+ seeded or unknown project; a path, query, or body that repeats the bearer value; write bodies that
1312
+ are not `application/json` objects. Reading an absent task or project answers the fixtures' 404.
1313
+
1314
+ **Leftover lookup.** No fixture records the project listing that `findTodoistConformanceLeftovers`
1315
+ sends (`GET /projects?limit=200`), so it answers the ledgered 400 not-emulated like any other
1316
+ unrecorded route: the lookup fails with `todoist_list_projects_failed` (HTTP 400), and the
1317
+ repository runners print their lookup-failed WARN (`WARN could not look for leftovers (lookup
1318
+ failed: todoist_list_projects_failed HTTP 400); check for yolk-conformance items by hand`) instead
1319
+ of leftover warnings. It never writes. Emulating the listing needs a committed fixture for it first.
1320
+
1321
+ Seeds: `seed: { profile?, userId?, projects?, tasks?, labels? }` (entity lists replace the
1322
+ profile's) with profiles `'default'` (the fixture entities, with the ids of
1323
+ `todoistConformanceFixtureSeeds`) and `'empty'` (only the work project). Ids starting with `6XEmu`
1324
+ are rejected.
1325
+
1326
+ **Drill knobs (tests only).** `drills: { cursorRestarts, notFoundWithoutError, taskLabelsAsIds,
1327
+ listIncludesClosed, ignoreDue, createOmitsParent, deleteKeepsTasks }` each make the emulator
1328
+ disagree with exactly one Todoist case (paging, not-found envelope, labels, lifecycle, due dates,
1329
+ parent id, delete), only to prove that case catches it.
1330
+
1331
+ ### Telegram emulator
1332
+
1333
+ Routes (on `https://api.telegram.org`; the bot token is a path segment):
1334
+
1335
+ | Route | Behavior |
1336
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------ |
1337
+ | `POST /bot<token>/getChat` | `{ chat_id }`: `{ ok: true, result }` for a member chat, else the recorded 400 `Bad Request: chat not found` |
1338
+ | `GET /bot<token>/getFile?file_id` | `{ ok: true, result: { file_id, file_unique_id, file_size, file_path } }` for a seeded file |
1339
+ | `GET /file/bot<token>/<file_path>` | The file's bytes (`application/octet-stream`), exactly `file_size` of them |
1340
+ | `POST /bot<token>/sendMessage` | `{ chat_id, text, disable_web_page_preview: true }`: `{ ok: true, result }` with the sent message |
1341
+
1342
+ **The bot token is required but never stored, forwarded, ledgered, or echoed.** The emulator fails
1343
+ closed. A request is recognised only when its raw path is exactly an emulated route shape under that
1344
+ route's HTTP method: `/bot<token>/<method>` with an emulated method, or
1345
+ `/file/bot<token>/<file_path>` with plain `[A-Za-z0-9_.-]` segments, the token on the raw segment
1346
+ matching `<digits>:<secret>` (a secret of at least 8 of `[A-Za-z0-9_-]`), and no other path text.
1347
+ Every other request (an unknown method or route, extra segments, a missing, malformed, or
1348
+ percent-encoded token, an encoded separator such as `%2F` or `%252F`) is ledgered and answered with
1349
+ constant text only: the path `/<unrecognised>`, a standard method or `<other>`, no query, no body,
1350
+ and the reason `no emulated Bot API route for this method and path`. For a recognised request, the
1351
+ token is taken from that exact segment; the emulator scrubs it and its secret part from the ledgered
1352
+ method, path (`/bot<redacted>/getChat`), query keys and values, and every not-emulated message, and
1353
+ it refuses, with constant text, a query, a remaining path segment, or a body that repeats either:
1354
+ raw, percent-decoded, or in any parsed JSON key, string value, or number (so `\u`-escaped forms and
1355
+ numbers such as `1.2345678e7` are caught). Refusal messages never quote a request key or value. A
1356
+ token whose bot id is `0` names no bot: `getChat` answers the recorded 401 `Unauthorized` (`{ ok:
1357
+ false, error_code: 401, description }`); other methods with it are not emulated. Fault `match.path`
1358
+ uses the redacted path; `match.route` the manifest template (`/bot{token}/sendMessage`). In both the
1359
+ Todoist and Telegram emulators, a `match.route` naming no manifest route is rejected when added.
1360
+
1361
+ `sendMessage` is irreversible on the real service: the emulator records each sent message in its
1362
+ state (`sentMessages`: `message_id` from 101, `chat_id`, `text`, `date` from the `now` clock in
1363
+ seconds) and never delivers anything; only `reset` or `seed` drops them. The answer's `from` is the
1364
+ seeded bot (never derived from the token).
1365
+
1366
+ Every answer value comes from a fixture, through the seed or the request, except the values the
1367
+ emulator mints (it never mints anything else): the `message_id` counter and the clock-derived
1368
+ `date`.
1369
+
1370
+ **Request-shape latitude** (the only accepted deviations from the fixture requests): any credential
1371
+ value of at least 8 characters that occurs nowhere else in the request (its path, query, or body;
1372
+ never checked against anything, stored, or ledgered); extra request headers; `content-type`
1373
+ parameters; a well-formed `<digits>:<secret>` bot token with a secret of at least 8 characters (the
1374
+ bot id `0` names no bot); any well-formed `chat_id` string (an integer or a public `@username`; one
1375
+ the bot is not in answers the recorded 400 on `getChat`); any non-empty message `text`.
1376
+
1377
+ Not emulated (400), among others: other methods (`getMe`, `deleteMessage`, ...), `GET getChat`,
1378
+ query parameters or body fields no fixture sends (`parse_mode`, `link_preview_options`, ...),
1379
+ `disable_web_page_preview` other than `true`, numeric `chat_id`s, `sendMessage` to a chat the bot is
1380
+ not in, `getFile` of a file the bot did not receive, and file paths `getFile` did not answer.
1381
+
1382
+ Seeds: `seed: { profile?, bot?, chats?, files?, nextMessageId? }` with profiles `'default'` (the
1383
+ fixture bot, chat, and 32-byte text file, with the ids of `telegramConformanceFixtureSeeds`) and
1384
+ `'empty'` (the bot only). A file's `file_size` must equal its UTF-8 content's byte length.
1385
+
1386
+ **Drill knobs (tests only).** `drills: { getChatOkFalse, errorsAs200, fileSizeOffByOne,
1387
+ sendOkFalse }` each make the emulator disagree with exactly one Telegram case.
1388
+
1389
+ ### Faults, ledger, recovery, and control plane
1390
+
1391
+ Faults are `{ kind: 'status', status, headers?, body?, match?: { method?, path?, route? }, count? }`
1392
+ with statuses 400-599 only (fixture-only routes never fake a success). A fault is chosen only after
1393
+ the request passed every check, including the route handler's eligibility check against the
1394
+ state (which writes nothing), and it answers before the commit (nothing is written); a refused
1395
+ request answers 400 not-emulated and leaves every fault unused. The default body is
1396
+ `{ error: { type: 'emulator_fault', message } }`. For example a 429 reaches the connectors as
1397
+ `todoist_rate_limited` / `telegram_rate_limited`. Header rules are the shared ones (valid names and
1398
+ values, no `location`, no framing headers); invalid faults throw `TodoistEmulatorInputInvalid` /
1399
+ `TelegramEmulatorInputInvalid`. The ledger records method and path, route template, query keys
1400
+ and values, parsed body, status, evidence, `notEmulated`, the applied fault, and `responseError`;
1401
+ credential-named keys are redacted, and guarded credential values are scrubbed from every one of
1402
+ them (a body that holds one is refused and never recorded). An unrecognised request (fail closed,
1403
+ above) is ledgered with constant fields only: `/<unrecognised>`, a standard method or `<other>`,
1404
+ an empty query, no body, and a constant `notEmulated` reason.
1405
+
1406
+ Recovery never reads the clock: a route handler that throws (for example because the injected clock
1407
+ throws while creating a project or sending a message) answers an evidence-tagged 500
1408
+ `{ error: { type: 'emulator_error', message } }` with `responseError` in the ledger and writes
1409
+ nothing; not-emulated answers (400) and a closed emulator (503) need no clock. The control plane is
1410
+ `/_emulate/ledger`, `faults`, `reset`, `state`, `seed`, and `coverage`, as for Fortnox.
1411
+
1412
+ ## GitHub emulator
1413
+
1414
+ > **Node only.** `@yolk-sdk/emulators/github` runs on the same pinned `@emulators/core` runtime,
1415
+ > loaded lazily by `makeGithubEmulator`, so importing the subpath has no side effects.
1416
+
1417
+ `await makeGithubEmulator(options?)` returns
1418
+ `{ fetch, fetchOn, ledger, faults, reset, seed, snapshot, coverage, close }`. Each call has its own
1419
+ state; `await close()` when done. It is a stateful, fixture-only stand-in for exactly the GitHub
1420
+ REST routes the seven GitHub conformance cases send, so the GitHub connector and the cases run
1421
+ unchanged against it. Every route answers only on the origin its fixtures record,
1422
+ `https://api.github.com` (`githubEmulatorOrigin`): `fetch` reads the origin from the request URL;
1423
+ behind a loopback rewrite, serve `fetchOn(githubEmulatorOrigin)`. Route `https://api.github.com` to
1424
+ it:
1425
+
1426
+ ```ts
1427
+ import { makeGithubEmulator } from '@yolk-sdk/emulators/github'
1428
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
1429
+
1430
+ const github = await makeGithubEmulator()
1431
+
1432
+ const httpLayer = InProcessHttpClient.layer([
1433
+ EmulatorRoute.handler('https://api.github.com', github.fetch)
1434
+ ])
1435
+ // ...run the code under test, then:
1436
+ await github.close()
1437
+ ```
1438
+
1439
+ Routes (every request needs exactly `Authorization: Bearer <token>` with a recognisable bearer (see
1440
+ Credentials), whose value is never compared against anything, stored, forwarded, or ledgered,
1441
+ `Accept: application/vnd.github+json`, and `X-GitHub-Api-Version: 2026-03-10`, what every fixture
1442
+ sends; bodies are JSON; `{owner}/{repo}` is the seeded repository):
1443
+
1444
+ | Route | Behavior |
1445
+ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
1446
+ | `GET /repos/{owner}/{repo}/labels` | `per_page`, `page`: repository labels with the paging fixture's `Link` header |
1447
+ | `GET /repos/{owner}/{repo}/issues/{issueNumber}` | The issue; the recorded 404 for a number the repository has not reached |
1448
+ | `GET /search/issues` | `q`: the recorded 422 for a scoped query longer than 256 characters |
1449
+ | `GET /repos/{owner}/{repo}/contents/{path+}` | A seeded file: `type: "file"`, base64 `content` folded every 60 characters |
1450
+ | `POST /repos/{owner}/{repo}/issues/{issueNumber}/comments` | `{ body }`: 201 and the comment, on an open issue |
1451
+ | `GET /repos/{owner}/{repo}/issues/{issueNumber}/comments` | `per_page=100`, `since`: the issue's comments updated at or after `since` (0 or 1) |
1452
+ | `DELETE /repos/{owner}/{repo}/issues/comments/{commentId}` | 204; the recorded 404 when this emulator deleted it already |
1453
+ | `POST /repos/{owner}/{repo}/issues/{issueNumber}/labels` | `{ labels: [name] }`: one repository label added; answers the issue labels |
1454
+ | `DELETE /repos/{owner}/{repo}/issues/{issueNumber}/labels/{name}` | The remaining labels (never none); the recorded 404 for a label not on the issue |
1455
+ | `POST /repos/{owner}/{repo}/issues` | `{ title, body }`: 201 and the open issue |
1456
+ | `PATCH /repos/{owner}/{repo}/issues/{issueNumber}` | `{ title }` or `{ state: "closed", state_reason: "completed" }` on an issue made here |
1457
+
1458
+ Wire behavior, as the fixtures record it:
1459
+
1460
+ - **Link paging.** Label pages carry the paging fixture's `Link` header, minted in its exact form:
1461
+ `<https://api.github.com/repositories/{id}/labels?per_page=N&page=M>` relations in the order
1462
+ `prev`, `next`, `last`, `first` (`next` and `last` while pages remain, `prev` and `first` after
1463
+ the first page; the page after the last answers `[]` with `prev`, `last`, and `first`). A listing
1464
+ that fits one page carries no `Link`, as the label fixture records. No other route mints `Link`.
1465
+ Label pages are page numbers the client computes, as GitHub's are, not cursors: the label list
1466
+ never changes within a seed (no route writes labels), so no page can drift. The `Link` URLs name
1467
+ `/repositories/{id}/labels`, which is not emulated (the connector never follows them).
1468
+ - **Errors.** The not-found, comment, label, and validation bodies are the fixtures' byte for byte
1469
+ (`githubEmulatorErrorBodies`), with `content-type: application/json; charset=utf-8`.
1470
+ - **Issues.** Issues render in the fixture key order with `comments: 0`, `locked: false`, no
1471
+ assignees or milestone. A rename keeps `updated_at` (the lifecycle fixture records that);
1472
+ closing sets `state_reason: "completed"` and `closed_at` and `updated_at` from the clock.
1473
+ - **Contents.** `content` is the UTF-8 file's base64, folded every 60 characters with a trailing
1474
+ line break, and `size` its byte length.
1475
+
1476
+ Every answer value comes from a fixture, through the seed or the request, except the values the
1477
+ emulator mints (it never mints anything else):
1478
+
1479
+ - **Minted values.** Created issue numbers and comment ids come from counters that only advance; the
1480
+ default seed starts them at the fixtures' created values (issue `42`, comment `9000000001`), and a
1481
+ seed's counters must lie above every seeded number. They end at the last addressable issue number
1482
+ (ten digits) and comment id (fifteen digits, what the delete route takes); past that, a create is
1483
+ refused before any fault. A created issue's `id` (`3000000000 + number`) and `node_id`
1484
+ (`I_kwSynthetic<number>`), and a comment's `node_id` (`IC_kwSynthetic<id>`), derive from the
1485
+ minted value in the fixtures' form; no seeded node id (of an issue or a label; node ids are unique
1486
+ across both) may use those forms at or above its counter. Timestamps come from the injectable
1487
+ `now` clock, in whole seconds.
1488
+ - **Implied issues.** Issue numbers below `nextIssueNumber` that the state does not hold are
1489
+ implied (the repository reached them, but no fixture shows them): any answer that would render
1490
+ one is not emulated. Numbers at or above it answer the not-found fixture's 404.
1491
+ - **State rules.** Comments and label changes apply to issues the state holds and that are open;
1492
+ only issues created here are renamed or closed; a label add takes one repository label not yet on
1493
+ the issue that sorts after the issue's labels (the fixture's answer is both appended and in name
1494
+ order); a label removal leaves at least one label (no fixture records an empty answer); a comment
1495
+ listing shows at most one comment (the order of several is not recorded); an issue holding
1496
+ comments is never rendered (every fixture answers `comments: 0`); a comment delete of an id this
1497
+ emulator never held is not emulated.
1498
+ - **Kept after writes.** A deleted comment's id stays in `deletedComments` (the comment fixture's
1499
+ second delete answers 404), and a closed issue stays in the repository (GitHub cannot delete
1500
+ issues). A write case ends at the seed except those and the counters.
1501
+ - **Fail closed.** A request is recognised only when its raw path is exactly an emulated route shape
1502
+ (every path parameter matches its raw pattern: owner and repository names, decimal issue numbers
1503
+ and comment ids, plain label names, plain file paths) under that route's method, and any
1504
+ `Authorization` header is exactly `Bearer <token>` (see Credentials). Every other request (an
1505
+ unknown route or method, an encoded character, a malformed or duplicated `Authorization` header)
1506
+ is ledgered and answered with constant text only: the path `/<unrecognised>`, a standard method or
1507
+ `<other>`, an empty query, no body, and a constant reason
1508
+ (`no emulated GitHub route for this method and path`,
1509
+ `an unrecognisable Authorization header is not emulated`).
1510
+ - **Credentials.** A recognised bearer must match the RFC 6750 `b64token` syntax exactly
1511
+ (`^[A-Za-z0-9\-._~+/]+=*$`, at least 8 characters), start with a character in `[G-Zg-z\-._~+/]`
1512
+ other than `n`, `r`, `t`, `u`, and hold at least one character outside the JSON-number alphabet
1513
+ `[0-9.eE+-]` (every GitHub and Google token form does: `ghp_…`, `github_pat_…`, `gho_…`,
1514
+ `ya29.…`). So no number's text can contain it; it holds no escape introducer (`%`, `\`, `"`), so
1515
+ no escape starts inside it; and its first character is no hex digit and no JSON escape letter, so
1516
+ no stray `%`, `\`, or partial escape to its left can complete with it, and its characters always
1517
+ decode in place. An `Authorization` header with any other value is unrecognisable. A recognised
1518
+ request that repeats the bearer value in its raw path, any path segment, the raw query or any
1519
+ query key or value, any recorded header, or its body is refused and ledgered with constant text
1520
+ only: a standard method, the path `/<unrecognised>`, its route template, an empty query, no
1521
+ headers or body, and a constant reason (`the query repeats the credential`, for example). Each
1522
+ part is checked through the closure of two total, lexical transforms that cannot fail: a tolerant
1523
+ percent-decode (every `%XX` below `%80` becomes its ASCII character; any other `%` sequence is
1524
+ left as it is) and a tolerant JSON-unescape (in any text, whether or not it parses as JSON,
1525
+ `\uXXXX` below `\u0080` and `\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t` become their
1526
+ characters). Starting from each part's raw text, either transform is applied to every text of the
1527
+ previous step, deduplicated, until no new text appears (a fixpoint), and every text is checked for
1528
+ the bearer as a substring; both transforms never lengthen a text and shorten it whenever they
1529
+ change it. So any depth of percent-encoding or JSON escaping, in any order, is seen through in
1530
+ every part: the raw path and each raw path segment, the raw query and each query key and value
1531
+ (already decoded once by `URLSearchParams`), each recorded header, and the raw body. The work is
1532
+ capped at 64 rounds, 1024 distinct texts, or 8 Mi characters read by the transforms, whichever
1533
+ comes first; a part whose closure hits a cap before its fixpoint counts as repeating the
1534
+ credential and is refused with the same constant entry (uncertainty refuses, it never admits; so
1535
+ any part over 4 Mi characters is always refused). Any other recognised request has the bearer
1536
+ value scrubbed from its ledgered fields and every not-emulated reason (plan-time reasons
1537
+ included); its recorded query is keyed by recorded key; a key recorded more than once lists its
1538
+ values in order (as a JSON array); and recorded headers and query keys and values that start like
1539
+ JSON (`{`, `[`, `"`) are recorded parsed with credential-named keys redacted at any depth, or as
1540
+ `<redacted>` when they do not parse, whatever the header's declared format. Refusals never echo a
1541
+ request's own query or body keys, and empty query components (a bare `?`, a stray `&`) are
1542
+ refused.
1543
+ - **Request-shape latitude (`/github`, the only accepted deviations).** Any bearer value in the RFC
1544
+ 6750 `b64token` syntax (`[A-Za-z0-9\-._~+/]+=*`) of at least 8 characters, starting with a
1545
+ character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u` (so a legacy all-hex token is
1546
+ refused), with at least one outside `[0-9.eE+-]`, that occurs nowhere else in the request (never
1547
+ compared against anything, stored, or ledgered); extra request headers; JSON key order;
1548
+ `content-type` media-type parameters; the order of query parameters; any non-empty issue title and
1549
+ comment body, and any issue body text; any comment listing `since` of the form
1550
+ `YYYY-MM-DDTHH:MM:SSZ`; any label listing `per_page` from 1 to 100, with no `page` or a `page`
1551
+ from 2 to one past the last page; any issue search `q` that starts with the seeded
1552
+ `repo:<owner>/<repo>` qualifier and whose query after it is longer than 256 characters (answered
1553
+ the recorded 422); any issue number the repository has not reached (answered the recorded 404);
1554
+ and any issue, comment, repository label, or file the state holds where a fixture has one, under
1555
+ the per-route state rules. `Authorization` must be exactly `Bearer <token>` (that spelling, one
1556
+ space), `Accept` `application/vnd.github+json`, and `X-GitHub-Api-Version` `2026-03-10`.
1557
+ Everything else (other keys and values, query parameters, empty query components such as a bare
1558
+ `?` or a stray `&`, another origin or repository, a repeated query key, an explicit `page=1`, and
1559
+ a comment listing `per_page` other than 100) is not emulated.
1560
+
1561
+ Anything else answers one ledgered 400 not-emulated (`{ error: { type: 'not_emulated', message } }`,
1562
+ `notEmulated` in the ledger), writes nothing, and uses up no fault: other routes (issue and pull
1563
+ request listings, locks, assignees, reactions, timelines), a search of at most 256 characters
1564
+ (no fixture records results), `sort`, `order`, or paging on search, a contents `ref` or a missing
1565
+ file, issue creates with `labels`, `assignees`, `milestone`, or `type`, updates other than the two
1566
+ recorded ones (so the lifecycle case's failure-path restore, which closes as `not_planned`, is not
1567
+ emulated), adding a label the repository lacks (that would create it) or two labels at once, and
1568
+ writes to closed or implied issues. That includes the open-issue listing
1569
+ (`GET /repos/{owner}/{repo}/issues`) of the read-only leftover lookup
1570
+ `findGithubConformanceLeftovers`, which no fixture records: the lookup fails
1571
+ (`GithubConformanceActionFailed`, `github.list_issues`, `github_validation`, HTTP 400), and the
1572
+ repository runner prints its lookup-failed `WARN` instead of leftover warnings. A route that throws
1573
+ answers an evidence-tagged 500 emulator error with `responseError` in the ledger, and a closed
1574
+ emulator answers 503; neither reads the clock.
1575
+
1576
+ State and seeds: the authenticated `viewer` (the author of everything created here), the
1577
+ `repository` (owner, name, the id the `Link` URLs name, default branch), labels, issues (labels by
1578
+ name; `createdHere` marks issues created here), comments, `deletedComments`, files (path, blob sha,
1579
+ UTF-8 text), and the counters. The default seed is the synthetic fixture entities with the values of
1580
+ `githubConformanceFixtureSeeds`: the five paging-fixture labels, open work issue 1 labelled `bug`,
1581
+ and `docs/synthetic-notes.txt`. Pass a `seed` with any of `profile`, `viewer`, `repository`,
1582
+ `labels`, `issues`, `files`, `nextIssueNumber`, and `nextCommentId` (lists replace the profile's)
1583
+ with profiles `'default'` or `'empty'`. `reset()`, `seed(next)`, and `snapshot()` behave as in the
1584
+ Dropbox emulator.
1585
+
1586
+ Faults, the ledger (which records the `Accept` and `X-GitHub-Api-Version` headers), and the control
1587
+ plane behave as in the Dropbox emulator; a 429 fault with `retry-after` reaches the connector as
1588
+ `github_rate_limited`.
1589
+
1590
+ **Drill knobs (tests only).** The `drills` booleans `linkOmitsNext`,
1591
+ `notFoundOmitsDocumentationUrl`, `validationWithoutErrors`, `contentUnfolded`, `sinceExcludesEqual`,
1592
+ `addAnswerOmitsLabel`, and `closeWithoutClosedAt` each make the emulator disagree with exactly one
1593
+ GitHub case, only to prove that case catches it.
1594
+
1595
+ ## Google emulator
1596
+
1597
+ > **Node only.** `@yolk-sdk/emulators/google` runs on the same pinned `@emulators/core` runtime,
1598
+ > loaded lazily by `makeGoogleEmulator`, so importing the subpath has no side effects.
1599
+
1600
+ `await makeGoogleEmulator(options?)` returns
1601
+ `{ fetch, fetchOn, ledger, faults, reset, seed, snapshot, coverage, close }`. Each call has its own
1602
+ state; `await close()` when done. It emulates only the Gmail, Calendar, and Drive routes the
1603
+ thirteen Google conformance cases (with their cleanup) send, so the Google connector actions and the
1604
+ cases run unchanged against it, the irreversible practice send included. Each route answers only on
1605
+ the origin its fixtures record: Gmail (the API and the multipart send upload) on
1606
+ `https://gmail.googleapis.com` (`googleEmulatorGmailOrigin`), Calendar and Drive on
1607
+ `https://www.googleapis.com` (`googleEmulatorApisOrigin`). `fetch` takes the origin from the request
1608
+ URL (in-process routing keeps it); behind a loopback rewrite, which loses it, serve
1609
+ `fetchOn(origin)` for each origin on its own server:
1610
+
1611
+ ```ts
1612
+ import {
1613
+ googleEmulatorApisOrigin,
1614
+ googleEmulatorGmailOrigin,
1615
+ makeGoogleEmulator
1616
+ } from '@yolk-sdk/emulators/google'
1617
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
1618
+
1619
+ const google = await makeGoogleEmulator()
1620
+
1621
+ const httpLayer = InProcessHttpClient.layer([
1622
+ EmulatorRoute.handler(googleEmulatorGmailOrigin, google.fetch),
1623
+ EmulatorRoute.handler(googleEmulatorApisOrigin, google.fetch)
1624
+ ])
1625
+ // With serveFetchHandler: one server per origin, serving google.fetchOn(origin).
1626
+ // ...run the code under test, then:
1627
+ await google.close()
1628
+ ```
1629
+
1630
+ The read-only leftover lookup (`findGoogleConformanceLeftovers`) fails against it: its first read,
1631
+ the Gmail label listing, has no fixture (nor do its draft search, free-text event query, and
1632
+ trashed-included Drive listing), so it answers the ledgered 400 not-emulated (nothing is written)
1633
+ and the lookup fails with `GoogleConformanceActionFailed` (`gmail_list_labels_failed`, HTTP 400);
1634
+ the live runner turns that into its lookup-failed `WARN`.
1635
+
1636
+ Routes (every request needs `Authorization: Bearer <token>`, a recognisable bearer; bodies are
1637
+ JSON unless noted; Drive requests also send `accept: application/json`, as recorded):
1638
+
1639
+ | Route | Behavior |
1640
+ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
1641
+ | `GET /gmail/v1/users/me/messages` | `labelIds`, `maxResults`, `pageToken`: `{ messages: [{ id, threadId }], nextPageToken? }` |
1642
+ | `GET /gmail/v1/users/me/messages/{messageId}` | `format=minimal`, `metadata`, or `full`, as recorded for that message; the recorded 404 |
1643
+ | `GET /gmail/v1/users/me/messages/{messageId}/attachments/{id}` | `{ size, data }` (base64url) of a seeded attachment |
1644
+ | `POST /gmail/v1/users/me/messages/{messageId}/modify` | `{ addLabelIds: [<created label>] }`: `{ id, threadId, labelIds }` |
1645
+ | `POST /gmail/v1/users/me/messages/{messageId}/trash`, `/untrash` | No body: adds or removes `TRASH`, answering `{ id, threadId, labelIds }` |
1646
+ | `POST /gmail/v1/users/me/labels` | `{ name: "yolk-conformance <runId> label" }`: the created user label (`Label_9101` first) |
1647
+ | `GET`, `DELETE /gmail/v1/users/me/labels/{labelId}` | Delete a created label (204; it leaves every message); a read of an absent label answers 404 |
1648
+ | `POST /gmail/v1/users/me/drafts`, `PUT .../drafts/{draftId}` | The recorded run draft without recipients: `{ id, message: { id, threadId, labelIds } }` |
1649
+ | `DELETE /gmail/v1/users/me/drafts/{draftId}` | 204 (the draft and its message go); an absent draft answers the recorded 404 |
1650
+ | `GET /gmail/v1/users/me/threads/{threadId}` | `format=full`: a draft thread created here |
1651
+ | `POST /upload/gmail/v1/users/me/messages/send` | `uploadType=multipart`, `multipart/related`: the practice message only (see below) |
1652
+ | `GET`, `POST /calendar/v3/calendars/{calendarId}/events` | Range listing (`singleEvents=true`, `orderBy=startTime`) with page tokens; the run event |
1653
+ | `GET`, `PATCH`, `DELETE .../events/{eventId}` | Read (cancelled too), the recorded rename, delete to `cancelled` (204), then the recorded 410 |
1654
+ | `GET`, `POST /drive/v3/files` | Folder listing (`'<folder>' in parents and trashed = false`) with page tokens; the run folder |
1655
+ | `GET`, `PATCH`, `DELETE /drive/v3/files/{fileId}` | Read with the connector `fields` (absent: the recorded 404), `{ trashed: true }`, delete 204 |
1656
+
1657
+ **The practice send is recorded, never delivered.** `google.gmail.send-practice-address` is
1658
+ irreversible on Gmail. The emulator accepts only the recorded 7-bit message whose sole recipient
1659
+ header is `To: <the seeded practiceAddress>` (no `Cc`, `Bcc`, other address, or list) with the
1660
+ run-scoped subject and `{}` metadata, records it in the state (a message with the `SENT` label whose
1661
+ `format=metadata` read answers the recorded headers, with the `Date` header from the `now` clock and
1662
+ a minted `Message-ID`), and delivers nothing anywhere; only `reset` or `seed` drops it. A seed may
1663
+ set another `practiceAddress`, but then every send answers 400 not-emulated (the reason names the
1664
+ recorded `practice@example.test`): the recorded `sizeEstimate` of the sent message covers its
1665
+ address, as it covers its subject, so draft and send subjects need a run id of the fixtures' length
1666
+ (13 characters; see the latitude below). The draft metadata `body.size` (64) and the sent message
1667
+ `body.size` (88) are the fixtures' recorded values, answered as recorded.
1668
+
1669
+ **Fail closed: the shared rule.** Google follows the shared fail-closed rule of the stateful
1670
+ wrapper, exactly as the GitHub emulator states it above: every route parameter has a raw pattern
1671
+ matched in full (Gmail ids, a calendar id whose only encoding is `%40`, event ids, Drive ids), so a
1672
+ request is recognised only when its raw path is exactly an emulated route shape under that route's
1673
+ method and any `Authorization` header is exactly `Bearer <token>` with a recognisable bearer (an RFC
1674
+ 6750 `b64token` of at least 8 characters, starting with a character in `[G-Zg-z\-._~+/]` other than
1675
+ `n`, `r`, `t`, `u`, with at least one outside `[0-9.eE+-]`; Google's `ya29.…` tokens qualify). Every
1676
+ other request is ledgered and answered with constant text only: the path `/<unrecognised>`, a
1677
+ standard method or `<other>`, no query, no body, and a constant reason. The bearer value is never
1678
+ compared against anything, stored, forwarded, or ledgered. A recognised request that repeats it in
1679
+ its raw path, any path segment, the query or any query key or value, the recorded `content-type`
1680
+ header, or its raw body (the multipart send body included), or, on the draft compose and update
1681
+ routes, in the base64url-decoded MIME of `message.raw` (a decoded view the routes give the wrapper),
1682
+ through any depth of percent-encoding or JSON escaping, is ledgered as the constant
1683
+ credential-repeat entry; any other recognised request has it scrubbed from its ledgered fields and
1684
+ every not-emulated message. A `message.raw` the route would refuse (anything but canonical unpadded
1685
+ base64url of exactly the recorded draft MIME, the run id aside) makes the view throw a
1686
+ `DecodedViewRefusal` with one of the route's declared constant reasons (`viewRefusalReasons`: the
1687
+ canonical-base64url reason, the other-than-the-recorded-run-draft reason, the 13-character run-id
1688
+ reason, the extra-key reason), which the wrapper ledgers in the constant entry before anything is
1689
+ recorded (or `the request body repeats the credential` when the raw decodes cleanly to text holding
1690
+ the bearer), so a refused `message.raw` never reaches the ledger. Refusals never echo a request's
1691
+ own query or body keys.
1692
+
1693
+ Every answer value comes from a fixture, through the seed or the request, except the values the
1694
+ emulator mints (it never mints anything else):
1695
+
1696
+ - **Minted values.** Created label ids (`Label_9101`, ...) start above every seeded label number
1697
+ (label ids are `Label_<1 to 999999999>`: a seeded `Label_<digits>` id outside that form is
1698
+ rejected, and a create when no number is left is not emulated); draft ids
1699
+ (`r-8000000000000000001`, ...), draft and sent message ids (`18f00000000000d1`,
1700
+ `18f00000000000e1`, ...; a created draft is its own thread), event ids
1701
+ (`syntheticconformance0001`, ...), and folder ids (`synthetic-conformance-folder-0001`, ...) use
1702
+ forms no seeded id or thread id may use. All come from counters in the state that only advance.
1703
+ Event `created` / `updated`, folder `createdTime` / `modifiedTime` / `trashedTime`, and the sent
1704
+ `Date` header come from the injectable `now` clock. Page tokens are the fixtures' values
1705
+ (`synthetic-gmail-page-2`, ...) in the generation that first issued them, and
1706
+ `<token>.g<generation>` after a reset or seed (each starts a generation); a token is never
1707
+ rebound: token values are globally unique, so another list or page size, or a changed list, gets a
1708
+ distinct `<token>.v<k>`.
1709
+ - **Implied entities.** The paging label (`impliedLabelIds`), the five messages its listing names
1710
+ (`impliedMessages`, rendered only as `{ id, threadId }` list entries), and the practice Drive
1711
+ folder (`impliedFolderIds`) are only named by the fixtures: references resolve through them (a
1712
+ label listing, a folder parent), but an answer that would render one is not emulated.
1713
+ - **Recorded renderings.** A Gmail message answers only the formats a fixture records for it (the
1714
+ work message `minimal`, the attachment message `full`, a composed draft `metadata` and `full`, an
1715
+ updated draft `full`, a sent message `metadata`); another format is not emulated.
1716
+ - **What writes leave.** A reversible case ends at the seed except the counters, plus each event
1717
+ case's own event, which Calendar keeps readable as `cancelled` (the fixtures read it back); the
1718
+ send leaves its sent message. Deleting a label removes it from every message.
1719
+ - **Page tokens.** A token is accepted only when this emulator issued it for the same list (label
1720
+ and `maxResults`; calendar, range, and `maxResults`; folder and `pageSize`) since the last reset
1721
+ or seed, and the list renders exactly as when it was issued.
1722
+ - **Request-shape latitude (`/google`, the only accepted deviations).** Any bearer value in the RFC
1723
+ 6750 `b64token` syntax (`[A-Za-z0-9\-._~+/]+=*`) of at least 8 characters, starting with a
1724
+ character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, with at least one outside
1725
+ `[0-9.eE+-]` (Google's `ya29.…` access tokens qualify), that occurs nowhere else in the request
1726
+ (never compared against anything, stored, or ledgered); extra request headers (except
1727
+ `X-Goog-Drive-Resource-Keys`, which no fixture sends); JSON key order; `content-type` media-type
1728
+ parameters on JSON requests; query parameters in any order; any `run-` run id (at most 40
1729
+ characters) in a run-scoped label name, event summary, or folder name; in a draft subject (compose
1730
+ and update) and the sent subject, only a run id of exactly 13 characters, the length of the
1731
+ fixtures' `run-synthetic`, because the recorded `sizeEstimate` of the draft and sent messages
1732
+ (answered by their message reads and the draft thread) covers the subject; on the practice send, a
1733
+ `content-type` of exactly `multipart/related; boundary=<b>` with any one unquoted boundary of 1 to
1734
+ 70 `[A-Za-z0-9_]` characters and no other parameter; a `gmail.list` `maxResults` from 1 to 500, a
1735
+ `calendar.list_events` `maxResults` from 1 to 2500, and a `drive.list_files` `pageSize` from 1 to
1736
+ 1000; any `timeMin` before `timeMax` (RFC 3339 instants with a real calendar date, hour 0 to 23,
1737
+ minute and second 0 to 59, and a `Z` or in-range numeric offset); any id of an item the state
1738
+ holds where a fixture has an id (writes: only items created here, plus label changes, trash, and
1739
+ untrash of a stored non-draft message); and, for an id the state does not hold, only the recorded
1740
+ not-found answers (a `format=minimal` read of a 16-hex-digit message id, a read of a
1741
+ `Label_<1 to 999999999>` label, a delete of an `r-<digits>` draft, and a Drive file read). A seed
1742
+ may set another `practiceAddress`, but then every send is refused, since the recorded
1743
+ `sizeEstimate` of the sent message also covers the address: the send answers only while the seeded
1744
+ address is the recorded `practice@example.test`. On the draft compose and update routes,
1745
+ `message.raw` must be canonical unpadded base64url of exactly the recorded draft MIME of that
1746
+ route, the run id aside; any other `message.raw` (line-wrapped, the standard alphabet, padded,
1747
+ with a stray character, or with MIME-level encodings such as RFC 2047 encoded-words,
1748
+ quoted-printable, or UTF-16) is refused before anything is recorded or a fault is decided, as a
1749
+ constant entry with the route's own declared reason
1750
+ (`message.raw must be canonical base64url UTF-8 MIME`,
1751
+ `a draft compose other than the recorded run draft is not emulated` or its `update` form, the
1752
+ 13-character run-id reason, or `message has a key this route does not take`), or
1753
+ `the request body repeats the credential` when that raw decodes cleanly to text holding the
1754
+ bearer, so a refused `message.raw` never reaches the ledger and an admitted one is the recorded
1755
+ text. `Authorization` must be exactly `Bearer <token>` (that spelling, one space). Everything else
1756
+ (other keys, values, formats, query parameters, empty query components such as a bare `?` or a
1757
+ stray `&`, recorded headers such as Drive's `accept: application/json` missing, another origin,
1758
+ repeated query parameters, any recipient but the seeded practice address, a draft or send run id
1759
+ of another length, a bearer repeated anywhere in the request, including base64url-encoded inside a
1760
+ draft's `message.raw`, and page tokens not issued for the same list since the last reset or seed,
1761
+ or whose list changed) is not emulated.
1762
+
1763
+ Anything else answers one ledgered 400 not-emulated (`{ error: { type: 'not_emulated', message } }`,
1764
+ `notEmulated` in the ledger), writes nothing, and uses up no fault: among others the label listing,
1765
+ draft listing, and calendar listing, `q` on Gmail or Calendar listings, an empty listing (no fixture
1766
+ records one), a Gmail listing that would leave out messages in Trash or Spam, a Calendar range
1767
+ holding a cancelled event, a Drive listing including trashed items, reads of an existing label or
1768
+ of an implied entity, threads of seeded messages, label changes other than adding one label
1769
+ created here, writes to seeded events and files, and the page tokens above. A route that throws
1770
+ (for example when the injected clock throws while creating an event or a folder, or sending)
1771
+ answers an evidence-tagged 500 `{ error: { type: 'emulator_error', message } }` with
1772
+ `responseError` in the ledger and writes nothing; a closed emulator answers 503. No recovery answer
1773
+ reads the clock.
1774
+
1775
+ State and seeds: `practiceAddress`, Gmail `messages` (with their recorded renderings),
1776
+ `impliedMessages`, `impliedLabelIds`, created `labels`, `attachments`, `drafts`, Calendar
1777
+ `calendars` and `events`, Drive `files` and `impliedFolderIds`, and the counters. The default seed
1778
+ is the synthetic fixture entities with the ids of `googleConformanceFixtureSeeds`. Pass
1779
+ `seed: { profile?, practiceAddress?, messages?, impliedMessages?, impliedLabelIds?, attachments?,
1780
+ calendars?, events?, files?, impliedFolderIds? }` (lists replace the profile's; labels and drafts
1781
+ are never seeded) with profiles `'default'` or `'empty'`. `reset()`, `seed(next)`, and
1782
+ `snapshot()` behave as in the Dropbox emulator; reset and seed also clear issued page tokens.
1783
+
1784
+ Faults and the control plane behave as in the Dropbox emulator (the ledger records only the
1785
+ `content-type` request header); a 429 fault with `retry-after` reaches the connector as
1786
+ `google_rate_limited` with `retryAfterMs`.
1787
+
1788
+ **Drill knobs (tests only).** `drills: { gmailPageRepeats, attachmentStandardBase64,
1789
+ notFoundWithoutMessage, labelDeleteKeepsOnMessages, draftUpdateKeepsContent,
1790
+ trashAnswerOmitsTrash, sentMessageWithoutTo, calendarPageRepeats, eventPatchKeepsSummary,
1791
+ repeatedEventDeleteConflict, drivePageRepeats, getFileWithoutParents, listIncludesTrashed }`
1792
+ (booleans) each make the emulator disagree with exactly one Google case, only to prove that case
1793
+ catches it.
1794
+
1795
+ ## LinkedIn search emulator
1796
+
1797
+ > **Node only.** `@yolk-sdk/emulators/linkedin-search` runs on the same pinned `@emulators/core`
1798
+ > runtime, loaded lazily by `makeLinkedInSearchEmulator`, so importing the subpath has no side
1799
+ > effects.
1800
+
1801
+ `await makeLinkedInSearchEmulator(options?)` returns
1802
+ `{ fetch, fetchOn, ledger, faults, reset, seed, snapshot, coverage, close }`. Each call has its own
1803
+ state; `await close()` when done. It emulates only the Exa and Enrich Layer routes the seven
1804
+ LinkedIn search conformance cases send, so the LinkedIn search connector actions and the cases run
1805
+ unchanged against it. Each route answers only on the origin its fixtures record: the Exa people
1806
+ search on `https://api.exa.ai` (`linkedInSearchEmulatorExaOrigin`), the Enrich Layer profile and
1807
+ email lookups on `https://enrichlayer.com` (`linkedInSearchEmulatorEnrichLayerOrigin`, under the
1808
+ connector's `/api/v2` base). `fetch` takes the origin from the request URL (in-process routing keeps
1809
+ it); behind a loopback rewrite, which loses it, serve `fetchOn(origin)` for each origin on its own
1810
+ server:
1811
+
1812
+ ```ts
1813
+ import {
1814
+ linkedInSearchEmulatorEnrichLayerOrigin,
1815
+ linkedInSearchEmulatorExaOrigin,
1816
+ makeLinkedInSearchEmulator
1817
+ } from '@yolk-sdk/emulators/linkedin-search'
1818
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
1819
+
1820
+ const linkedIn = await makeLinkedInSearchEmulator()
1821
+
1822
+ const httpLayer = InProcessHttpClient.layer([
1823
+ EmulatorRoute.handler(linkedInSearchEmulatorExaOrigin, linkedIn.fetch),
1824
+ EmulatorRoute.handler(linkedInSearchEmulatorEnrichLayerOrigin, linkedIn.fetch)
1825
+ ])
1826
+ // With serveFetchHandler: one server per origin, serving linkedIn.fetchOn(origin).
1827
+ // ...run the code under test, then:
1828
+ await linkedIn.close()
1829
+ ```
1830
+
1831
+ Routes (every request needs `Authorization: Bearer <key>`, a recognisable bearer; each provider
1832
+ takes its own key, and both are reads):
1833
+
1834
+ | Route | Behavior |
1835
+ | --------------------------- | -------------------------------------------------------------------------------------------------- |
1836
+ | `POST /search` | `{ query, category: "people", numResults, type: "auto", contents: { text: true } }`: `{ results }` |
1837
+ | `GET /api/v2/profile` | `linkedin_profile_url`: a held profile, or the recorded 404 for a seeded absent profile |
1838
+ | `GET /api/v2/profile/email` | `linkedin_profile_url`: `{ email }` of a held profile |
1839
+
1840
+ Every answer comes from a fixture, byte for byte, through the seed; the emulator mints nothing (no
1841
+ ids, cursors, or clock reads), so nothing is ever written and every case ends exactly at its seed:
1842
+
1843
+ - **Searches.** A search answers only the results the state holds for exactly its query and
1844
+ `numResults`. The default seed holds the three answers the fixtures record for the seeded query:
1845
+ `numResults` 10 (the people-results case), and 3 and 2 (the control and the limited search of the
1846
+ num-results-limit case, which answers its second result without `publishedDate`, as recorded).
1847
+ Another query or `numResults` (the unauthorized probe with an accepted key, for example) is not
1848
+ emulated; no fixture records an empty search.
1849
+ - **Profiles.** A profile lookup answers a held profile in the profile fixture's fields
1850
+ (`public_identifier`, `full_name`, `headline`), and an absent profile (the seeded
1851
+ `absentProfileUrl`) the recorded 404 body; an email lookup answers a held profile's recorded
1852
+ `{ email }`. Any other profile URL, and the email of an absent profile, is not emulated.
1853
+ - **Rejected keys, per origin.** A key the seed marks as rejected on an origin answers that
1854
+ origin's recorded 401 body (Exa's `{ requestId, error }`, Enrich Layer's
1855
+ `{ code, description, name }`), for any request of the emulated shape on that origin. The default
1856
+ seed rejects the synthetic invalid keys the two unauthorized cases send
1857
+ (`yolk-conformance-invalid-exa-key` on Exa, `yolk-conformance-invalid-enrich-layer-key` on Enrich
1858
+ Layer); a key rejected on one origin is accepted on the other. The error bodies are
1859
+ `linkedInSearchEmulatorErrorBodies`.
1860
+
1861
+ **Fail closed: the shared rule, and keys kept only as digests.** LinkedIn search follows the shared
1862
+ fail-closed rule of the stateful wrapper, exactly as the GitHub emulator states it above: a request
1863
+ is recognised only when its raw path is exactly an emulated route path under that route's method and
1864
+ any `Authorization` header is exactly `Bearer <key>` with a recognisable bearer (an RFC 6750
1865
+ `b64token` of at least 8 characters, starting with a character in `[G-Zg-z\-._~+/]` other than `n`,
1866
+ `r`, `t`, `u`, with at least one outside `[0-9.eE+-]`; a UUID-form key, which starts with a hex
1867
+ digit, is unrecognisable, so hand the emulator a synthetic key). Every other request is ledgered and
1868
+ answered with constant text only. A recognised request that repeats its bearer in the raw path, the
1869
+ query or any query key or value, the recorded `content-type` header, or the body, through any depth
1870
+ of percent-encoding or JSON escaping, is ledgered as the constant credential-repeat entry; any other
1871
+ has it scrubbed from its ledgered fields and every not-emulated message. The bearer is never stored
1872
+ or ledgered: through the wrapper's opt-in per-origin `bearerDigest`, routes see only SHA-256 of the
1873
+ origin, a space, and the key, which a plan compares with the digests of the seed's rejected keys for
1874
+ that origin. The state holds only those digests, so a key a request carries as its bearer, a
1875
+ rejected one included, never reaches the state, a snapshot, or `/_emulate/*` (a key sent as data
1876
+ elsewhere in a request is ledgered like any other text). Refusals never echo a request's own query
1877
+ or body keys. This guarding covers the emulated provider API calls only: `/_emulate/*` is the
1878
+ host's trusted control plane, and the seed and fault data a host gives it is stored and returned as
1879
+ given.
1880
+
1881
+ - **Request-shape latitude (`/linkedin-search`, the only accepted deviations).** Any bearer value in
1882
+ the RFC 6750 `b64token` syntax (`[A-Za-z0-9\-._~+/]+=*`) of at least 8 characters, starting with a
1883
+ character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, with at least one outside
1884
+ `[0-9.eE+-]`, that occurs nowhere else in the request (never stored or ledgered; only its
1885
+ per-origin digest is compared, with the digests of the keys the seed marks as rejected on that
1886
+ origin); extra request headers; JSON key order; `content-type` media-type parameters on the
1887
+ search; any percent-encoding of the `linkedin_profile_url` value that decodes once to the same
1888
+ profile URL; and, with a key the seed marks as rejected on the request's origin, any search
1889
+ `query` (one trimmed line of at most 500 characters) with any integer `numResults` from 1 to 100,
1890
+ and any profile URL of the form `https://<host>/in/<slug>`, each answered the origin's
1891
+ recorded 401. `Authorization` must be exactly `Bearer <token>` (that spelling, one space).
1892
+ Everything else (other body keys or values, a `category` other than `people`, a `type` other than
1893
+ `auto`, `contents` other than `{ "text": true }`, a search the state holds no answer for (another
1894
+ query, or a `numResults` no seeded search of that query records), a profile URL the state holds
1895
+ neither as a profile nor as absent, an email lookup of an absent profile, any query parameter on
1896
+ the search, other, missing, or repeated query parameters on a lookup, a query parameter name in
1897
+ any but its plain form (such as `%6cinkedin_profile_url`), empty query components such as a bare
1898
+ `?` or a stray `&`, a body on a lookup, another origin, and a bearer repeated anywhere in the
1899
+ request) is not emulated.
1900
+
1901
+ Anything else answers one ledgered 400 not-emulated (`{ error: { type: 'not_emulated', message } }`,
1902
+ `notEmulated` in the ledger) and uses up no fault. A closed emulator answers 503; a route that
1903
+ throws answers an evidence-tagged 500 with `responseError` in the ledger.
1904
+
1905
+ State and seeds: `searches` (`{ query, numResults, results }`, 1 to `numResults` results with the
1906
+ fixtures' optional `title`, `url`, `author`, `publishedDate`, and `text`), `profiles`
1907
+ (`{ url, publicIdentifier, fullName, headline, email? }`), `absentProfileUrls`, and the digests of
1908
+ the rejected keys (`exaRejectedKeyDigests`, `enrichLayerRejectedKeyDigests`). Pass
1909
+ `seed: { searches?, profiles?, absentProfileUrls?, exaRejectedKeys?, enrichLayerRejectedKeys? }`:
1910
+ each given list replaces the default seed's (the fixture entities); rejected keys are given as keys
1911
+ (recognisable bearer values) and kept only as their digests, and no seed error quotes one: every
1912
+ seed error is constant text, a category and a field path (`duplicate search at searches[1]`,
1913
+ `unexpected key at the seed root`). `reset()`, `seed(next)`, and `snapshot()` behave as in the
1914
+ Dropbox emulator.
1915
+
1916
+ Faults and the control plane behave as in the Dropbox emulator (the ledger records only the
1917
+ `content-type` request header); the connector maps every non-2xx answer to its action's failure
1918
+ code, so a 429 fault reaches it as `linkedin_search_failed` (or `linkedin_profile_failed`,
1919
+ `linkedin_email_failed`) with status 429 and no `retryAfterMs`.
1920
+
1921
+ **Drill knobs (tests only).** `drills: { defaultSearchWithoutText, numResultsIgnored,
1922
+ profileAnswersEmptyObject, emailAnswerOmitsEmail, exaUnauthorizedAs5xx,
1923
+ enrichLayerUnauthorizedAs2xx, absentProfileAs2xx }` (booleans) each make the emulator disagree with
1924
+ exactly one LinkedIn search case, only to prove that case catches it.
1925
+
1926
+ ## MCP emulator
1927
+
1928
+ > **Node only.** `@yolk-sdk/emulators/mcp` runs on the same pinned `@emulators/core` runtime,
1929
+ > loaded lazily by `makeMcpEmulator`, so importing the subpath has no side effects.
1930
+
1931
+ `await makeMcpEmulator(options?)` returns
1932
+ `{ fetch, fetchOn, ledger, faults, reset, seed, snapshot, coverage, close }`. Each call has its own
1933
+ state and session counter; `await close()` when done. It emulates the two synthetic servers of the
1934
+ `@yolk-sdk/mcp/conformance` fixtures, so `@yolk-sdk/mcp/client` and the MCP conformance cases run
1935
+ unchanged against it: profile `synthetic-modern` on `https://mcp.example.test/modern/mcp`
1936
+ (stateless `2026-07-28`, JSON answers) and profile `synthetic-legacy` on
1937
+ `https://mcp.example.test/legacy/mcp` (an `initialize` handshake, sessions, SSE answers). Both
1938
+ answer only on that origin (`mcpEmulatorOrigin`); behind a loopback rewrite, serve
1939
+ `fetchOn(mcpEmulatorOrigin)`:
1940
+
1941
+ ```ts
1942
+ import { makeMcpEmulator, mcpEmulatorOrigin } from '@yolk-sdk/emulators/mcp'
1943
+ import { EmulatorRoute, InProcessHttpClient } from '@yolk-sdk/emulators/router'
1944
+
1945
+ const mcp = await makeMcpEmulator()
1946
+
1947
+ const httpLayer = InProcessHttpClient.layer([EmulatorRoute.handler(mcpEmulatorOrigin, mcp.fetch)])
1948
+ // Remote server config: { type: 'remote', url: 'https://mcp.example.test/modern/mcp',
1949
+ // headers: { authorization: 'Bearer <a synthetic token>' } }
1950
+ // ...run the code under test, then:
1951
+ await mcp.close()
1952
+ ```
1953
+
1954
+ Routes (`mcpEmulatorRoutes`: one `RPC <origin><path>#<method>` row per recorded JSON-RPC method of
1955
+ each profile, plus the legacy `GET` row; every row is a read, and every request needs
1956
+ `Authorization: Bearer <token>`, a recognisable bearer):
1957
+
1958
+ | Row | Answer (the recorded bytes) |
1959
+ | --------------------------------------- | ------------------------------------------------------------------------------- |
1960
+ | `/modern/mcp#server/discover` | The modern discover result |
1961
+ | `/modern/mcp#tools/list` | The one-page listing, or the paged listing's pages (seed `two-pages`) |
1962
+ | `/modern/mcp#tools/call` | The read result, the `isError` tool result, or the absent tool's 400 error |
1963
+ | `/legacy/mcp#server/discover` | The recorded 400 JSON-RPC error that makes the client fall back to `initialize` |
1964
+ | `/legacy/mcp#initialize` | The SSE `initialize` result with a minted `mcp-session-id` |
1965
+ | `/legacy/mcp#notifications/initialized` | 202, no body, on an initializing session (which becomes ready) |
1966
+ | `/legacy/mcp#tools/list` | The SSE listing, on a ready session |
1967
+ | `/legacy/mcp#tools/call` | The three recorded SSE call answers, on a ready session |
1968
+ | `GET /legacy/mcp` | 405, no body, on a ready session |
1969
+ | `/{modern,legacy}/mcp#server/discover` | With the reserved invalid credential: the recorded 401, byte for byte |
1970
+
1971
+ Every answer comes from a fixture, byte for byte (the copies live in `mcpEmulatorFixtures`), with
1972
+ exactly three request-derived or minted substitutions:
1973
+
1974
+ - **The request id**, at exactly the recorded place: the top-level `id` of a JSON answer, or the
1975
+ `id` of the SSE response event's payload. Notification events and SSE `id:` lines stay byte for
1976
+ byte, and an answer that does not carry the recorded request id (the legacy era probe's
1977
+ `id: null` error, the 401) is unchanged.
1978
+ - **The session id.** `initialize` mints `yolk-emu-session-<n>`, `n` from a counter that never
1979
+ resets (reset, seed, and a ledger clear never rewind it), a form no seed can hold (seeds hold no
1980
+ sessions); the SSE answers carry the session's id in the recorded `mcp-session-id` header. At most
1981
+ 256 sessions are held (`mcpEmulatorSessionCap`): another `initialize` is refused before any
1982
+ fault. `reset()` and `seed()` clear the sessions.
1983
+ - **The cursor.** With `seed: { modernListing: 'two-pages' }`, the first page issues the recorded
1984
+ `synthetic-cursor-0001` in the generation that first issues it and `<cursor>.g<generation>` after
1985
+ a reset or seed (each starts a generation); the second page answers only the cursor issued in the
1986
+ current generation.
1987
+
1988
+ **Fail closed: the shared rule, every header, the output, and the reserved credential seen only as a
1989
+ digest.** MCP follows the shared fail-closed rule of the stateful wrapper, exactly as the GitHub
1990
+ emulator states it above, with its opt-in constant refusals: every refusal, by shape or by state, is
1991
+ ledgered with constant text only (`/<unrecognised>`, a standard method or `<other>`, an empty query,
1992
+ no headers or body, a constant reason, and the route template or row), so request text reaches the
1993
+ ledger only once a request equals a recorded one. A recognised request that repeats its bearer in
1994
+ the path, the query, any request header name or value other than `Authorization` (the wrapper's
1995
+ opt-in `guardAllHeaders`, whatever the ledger records), or the body, through any depth of
1996
+ percent-encoding or JSON escaping, is refused the same way. The output is guarded too (the opt-in
1997
+ `guardOutput`): before any fault is decided or anything is committed, the prepared answer (every
1998
+ header and chunk) and the minted session id or cursor the request would store are checked for the
1999
+ bearer, and a hit is refused with the constant credential-repeat entry
2000
+ (`the answer would repeat the credential`), no fault used and nothing written. So a bearer such as
2001
+ `yolk-emu-session-1` on a fresh emulator, or `synthetic-mcp` (inside the recorded
2002
+ `yolk-synthetic-mcp`), never reaches a response, the state, or `/_emulate/*`. Scope: the bearer is
2003
+ never copied from the request into a response, the state, or `/_emulate/*`; the output guard also
2004
+ refuses a prepared fixture answer, minted session id, or cursor that happens to contain it, but the
2005
+ emulator's other constants (state values such as `initializing`, wrapper headers such as
2006
+ `x-emulator-evidence`) and host-configured control-plane data (a fault body) may coincidentally
2007
+ equal a bearer and are not checked. The bearer is never stored, ledgered, or echoed: routes see only
2008
+ its digest (the wrapper's opt-in `bearerDigest`: SHA-256 of the origin, a space, and the bearer),
2009
+ which they compare only with the digest of the public reserved invalid credential
2010
+ `yolk-conformance-invalid-credential-0000` (`mcpEmulatorReservedInvalidCredential`, itself a
2011
+ recognisable bearer) to answer the recorded 401 on the era probe.
2012
+
2013
+ - **Request-shape latitude (`/mcp`, the only accepted deviations).** Any bearer value in the RFC
2014
+ 6750 `b64token` syntax (`[A-Za-z0-9\-._~+/]+=*`) of at least 8 characters, starting with a
2015
+ character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, with at least one outside
2016
+ `[0-9.eE+-]`, that occurs nowhere else in the request (any header name or value included) and in
2017
+ no answer or value the request would store (never stored or ledgered; only its digest is compared,
2018
+ with the digest of the public reserved invalid credential
2019
+ `yolk-conformance-invalid-credential-0000`, which answers the recorded 401 on the era probe);
2020
+ extra request headers, except `mcp-*` headers other than `mcp-method`, `mcp-name`,
2021
+ `mcp-protocol-version`, and `mcp-session-id`; a recorded header value sent as several headers that
2022
+ the HTTP layer joins into the recorded value; JSON key order; any JSON-RPC request id that is an
2023
+ integer from 0 to 2^53 - 1 or 1 to 64 printable ASCII characters where the recording has an id;
2024
+ any non-empty `name` and `version` (and no other key) in the `_meta` client info
2025
+ (`io.modelcontextprotocol/clientInfo`) of a modern request; a session id this emulator minted
2026
+ since the last reset or seed where the recording sends `mcp-session-id` (initializing for
2027
+ `notifications/initialized`, ready otherwise); and, with the seed's `two-pages` listing, the
2028
+ cursor this emulator issued in the current generation on the second page. `Authorization` must be
2029
+ exactly `Bearer <token>` (that spelling, one space). Everything else (another origin or path, any
2030
+ query, other HTTP methods such as `DELETE` or a `GET` on the modern profile, JSON-RPC methods no
2031
+ fixture of the profile records such as `ping`, `resources/*`, or `prompts/*`, batches and
2032
+ client-sent responses, other members, a JSON body repeating a key (compared after unescaping), a
2033
+ `null`, negative, or fractional id, other params (other tools, arguments, protocol versions, or
2034
+ capabilities, extra client-info keys, and a legacy `initialize` client info other than the
2035
+ recorded one), the MCP headers `accept`, `content-type`, `mcp-method`, `mcp-protocol-version`,
2036
+ `mcp-name`, and `last-event-id` other than the recorded values or present where none is recorded,
2037
+ any other `mcp-*` header (such as `mcp-param-*`), `mcp-session-id` missing where recorded or
2038
+ present where not, an unknown session or one in the wrong phase, a cursor not issued in the
2039
+ current generation, the reserved invalid credential on anything but the era probe, a bearer
2040
+ repeated anywhere in the request, and a bearer an answer or a minted session id or cursor would
2041
+ repeat) is not emulated.
2042
+
2043
+ Not emulated (a constant-text 400 that writes nothing and uses up no fault): `DELETE` (the client
2044
+ never sends it), `ping`, `resources/*`, `prompts/*`, `logging/*`, `completion/*`, `tasks/*`,
2045
+ JSON-RPC batches and client-sent responses, a JSON body repeating a key (compared after unescaping;
2046
+ the wrapper's opt-in `uniqueJsonKeys`), `mcp-*` headers no recording carries (such as
2047
+ `mcp-param-*`), cursors this emulator did not issue, any other tool or arguments, any other origin,
2048
+ path, or query, and a missing `Authorization` (no fixture records the answer to one). A closed
2049
+ emulator answers 503; a route that throws answers an evidence-tagged 500 with `responseError` in the
2050
+ ledger. `makeMcpEmulator` throws when a copied recording is not canonical JSON (every JSON body and
2051
+ SSE `data:` payload equal to `JSON.stringify(JSON.parse(text))`), which id substitution relies on.
2052
+
2053
+ Faults are status faults (400-599) and `truncate-after-chunks` faults (`McpFault`), decided only
2054
+ after a request is admitted and planned, against the answer the plan prepared; a faulted request
2055
+ writes nothing. In `match`, `method` is the HTTP method (`POST` or `GET`, never a row's `RPC`),
2056
+ `path` the raw request path, and `route` one manifest row (for example
2057
+ `https://mcp.example.test/legacy/mcp#tools/list`); a `route` naming no row is rejected when the
2058
+ fault is added. An SSE answer has two chunks (the notification, then the response) and a JSON answer
2059
+ one, so truncating an SSE answer after one chunk ends the stream before its response: the client
2060
+ fails the operation as an `McpError` at its timeout, never hangs. A truncation sends the prepared
2061
+ answer cut short and never runs its commit: a truncated `initialize` holds no session and leaves the
2062
+ counter and the session cap where they were, and a truncated first page of the two-page listing
2063
+ issues no cursor, so its continuation is refused. A truncation that cannot apply (a 202 or 405 has
2064
+ no chunk) answers 500 and is not used up. The ledger, coverage (per row), recovery, and the control
2065
+ plane behave as in the Dropbox emulator; `/_emulate/state` also reports `nextSession`,
2066
+ `cursorGeneration`, and `issuedCursor`.
2067
+
2068
+ **Drill knobs (tests only).** `drills: { discoverCarriesErrorResponse, discoverWithoutResultType,
2069
+ sessionIdNotVisibleAscii, discoverAnsweredTwice, writeToolMarkedReadOnly, readCallAnswersToolError,
2070
+ invalidCallAnswersRpcError, absentCallAnswersResult, unauthorizedWithoutChallenge }` (booleans) each
2071
+ make the emulator disagree with exactly one MCP conformance case, only to prove that case catches
2072
+ it.
2073
+
2074
+ ## Evidence
2075
+
2076
+ `gatewayEmulatorRoutes`, `gatewayEvaluateEmulatorRoutes`, `openAiEmulatorRoutes`,
2077
+ `anthropicEmulatorRoutes`, `codexEmulatorRoutes`, `xAiGrokEmulatorRoutes`, `openCodeGoEmulatorRoutes`, the three subscription-usage manifests,
2078
+ `emailEmulatorRoutes`, `r2EmulatorRoutes`, `fortnoxEmulatorRoutes`, `microsoftEmulatorRoutes`,
2079
+ `dropboxEmulatorRoutes`, `notionEmulatorRoutes`, `todoistEmulatorRoutes`, `telegramEmulatorRoutes`,
2080
+ `githubEmulatorRoutes`, `googleEmulatorRoutes`, `linkedInSearchEmulatorRoutes`, and
2081
+ `mcpEmulatorRoutes` (one `RPC <origin><path>#<method>` row per JSON-RPC method) list every
2082
+ emulated route with `method`, `path`, `kind`, `write`, the conformance `caseIds` it follows,
2083
+ `evidence` (`verified` or `unverified`), and `observedAt`. Every response from an unverified route
2084
+ of a fetch-handler emulator carries `x-emulator-evidence: unverified`; the email and R2 emulators
2085
+ record evidence on each ledger entry instead, since their plain-JSON replies carry no header. The
2086
+ Gateway route is `verified` (`observedAt: '2026-09-30'`): its wire shapes are checked against the
2087
+ verified live recordings. Every other route (OpenAI, Anthropic, Codex, Grok, OpenCode Go, the usage
2088
+ routes, email, R2, Fortnox, Microsoft, Dropbox, Notion, Todoist, Telegram, GitHub, Google,
2089
+ LinkedIn search, and MCP) is unverified, like the synthetic fixtures it follows.
2090
+ Each manifest route maps to its own handler; an emulator whose manifest has a route without a
2091
+ handler throws when it is constructed. The Yolk repository checks these manifests: unknown case ids,
2092
+ duplicate routes, connector write routes without verified evidence, verified connector write routes
2093
+ whose `observedAt` is missing, unreadable, or in the future, and verified routes whose cited cases
2094
+ have no verified fixture fail, as do verified connector write routes citing no cases; unverified or
2095
+ stale (over 30 days) evidence and other routes citing no cases warn.
2096
+
2097
+ The email emulator's eight write routes are unverified connector writes. Until an owner-approved
2098
+ live run against a practice mailbox verifies them, the repository lists them in a visible,
2099
+ time-bounded allowlist (`scripts/emulator-evidence-pending.json`, which holds each entry's expiry
2100
+ date): the check reports them as PENDING warnings until that date and fails again after it. The
2101
+ R2 emulator's one write route, `PORT R2ObjectClient.put`, is held in the same list until an
2102
+ owner-approved live run against a practice bucket, through a host `R2ObjectClient`
2103
+ implementation, verifies it.
2104
+
2105
+ All Fortnox, Microsoft, Dropbox, Notion, Todoist, Telegram, GitHub, Google, LinkedIn search, and
2106
+ MCP routes are currently `unverified` (no live recording yet), including four Fortnox, eleven
2107
+ Microsoft, five Dropbox, two Notion, five Todoist, one Telegram, six GitHub, and fifteen Google
2108
+ connector write routes (the three LinkedIn search routes and the nine MCP rows are reads, so none of
2109
+ them needs an entry). Until an
2110
+ owner-approved live run verifies them, the repository lists them in a visible, time-bounded
2111
+ allowlist (`scripts/emulator-evidence-pending.json`, which holds each entry's expiry date): the
2112
+ check reports them as PENDING warnings until that date and fails again after it.
2113
+
2114
+ ## Node server
2115
+
2116
+ `serveFetchHandler(handler, { port = 0 })` serves on `127.0.0.1` only (any other `host` is
2117
+ refused) as a scoped Effect resource returning `{ url, close }`. Streamed bodies are written chunk
2118
+ by chunk, so progressive delivery survives the socket; a body stream error drops the connection.
2119
+ `startFetchHandlerServer(handler, { port = 0 })` is the same server as a Promise, for
2120
+ non-Effect test runners and hosts without an Effect runtime:
2121
+
2122
+ ```ts
2123
+ import { makeGatewayEmulator } from '@yolk-sdk/emulators/gateway'
2124
+ import { startFetchHandlerServer } from '@yolk-sdk/emulators/node'
2125
+
2126
+ const server = await startFetchHandlerServer(makeGatewayEmulator().fetch)
2127
+ // ... point the code under test at server.url ...
2128
+ await server.close()
2129
+ ```
2130
+
2131
+ ## License
2132
+
2133
+ `@yolk-sdk/emulators` is MIT. The Fortnox, Microsoft, Dropbox, Notion, Todoist, Telegram, GitHub,
2134
+ Google, LinkedIn search, and MCP emulators depend on (does not vendor or bundle) the Apache-2.0
2135
+ [`@emulators/core`](https://github.com/vercel-labs/emulate) package, which ships no `NOTICE` file.