@things-factory/headless-twin 10.0.19 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (327) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +10 -1
  2. package/dist-server/engine/canonical-ingest.js +110 -33
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/event-time.d.ts +25 -0
  5. package/dist-server/engine/event-time.js +98 -0
  6. package/dist-server/engine/event-time.js.map +1 -0
  7. package/dist-server/engine/index.d.ts +2 -0
  8. package/dist-server/engine/index.js +23 -0
  9. package/dist-server/engine/index.js.map +1 -1
  10. package/dist-server/engine/ingest-dedupe.d.ts +18 -6
  11. package/dist-server/engine/ingest-dedupe.js +201 -15
  12. package/dist-server/engine/ingest-dedupe.js.map +1 -1
  13. package/dist-server/engine/ingest-health.d.ts +60 -1
  14. package/dist-server/engine/ingest-health.js +43 -8
  15. package/dist-server/engine/ingest-health.js.map +1 -1
  16. package/dist-server/engine/kpi-fold.d.ts +19 -0
  17. package/dist-server/engine/kpi-fold.js +4 -1
  18. package/dist-server/engine/kpi-fold.js.map +1 -1
  19. package/dist-server/engine/kpi-query.js +23 -3
  20. package/dist-server/engine/kpi-query.js.map +1 -1
  21. package/dist-server/engine/oee-accumulator.d.ts +35 -2
  22. package/dist-server/engine/oee-accumulator.js +56 -4
  23. package/dist-server/engine/oee-accumulator.js.map +1 -1
  24. package/dist-server/engine/twin-engine.d.ts +72 -3
  25. package/dist-server/engine/twin-engine.js +118 -5
  26. package/dist-server/engine/twin-engine.js.map +1 -1
  27. package/dist-server/index.js +21 -0
  28. package/dist-server/index.js.map +1 -1
  29. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.d.ts +5 -0
  30. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js +66 -0
  31. package/dist-server/migrations/1786600000000-RenameOperatoMesAdapterType.js.map +1 -0
  32. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.d.ts +5 -0
  33. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js +79 -0
  34. package/dist-server/migrations/1786700000000-AddDispatchRetryColumns.js.map +1 -0
  35. package/dist-server/migrations/index.js +5 -1
  36. package/dist-server/migrations/index.js.map +1 -1
  37. package/dist-server/routes.js +12 -20
  38. package/dist-server/routes.js.map +1 -1
  39. package/dist-server/service/actuation/actuation-advisor.d.ts +60 -0
  40. package/dist-server/service/actuation/actuation-advisor.js +53 -0
  41. package/dist-server/service/actuation/actuation-advisor.js.map +1 -0
  42. package/dist-server/service/actuation/actuation-outcome.d.ts +52 -0
  43. package/dist-server/service/actuation/actuation-outcome.js +34 -0
  44. package/dist-server/service/actuation/actuation-outcome.js.map +1 -0
  45. package/dist-server/service/actuation/actuation-rule-resolver.d.ts +19 -0
  46. package/dist-server/service/actuation/actuation-rule-resolver.js +161 -0
  47. package/dist-server/service/actuation/actuation-rule-resolver.js.map +1 -0
  48. package/dist-server/service/actuation/actuation-rule.d.ts +55 -0
  49. package/dist-server/service/actuation/actuation-rule.js +130 -0
  50. package/dist-server/service/actuation/actuation-rule.js.map +1 -0
  51. package/dist-server/service/actuation/approval-gateway.d.ts +20 -0
  52. package/dist-server/service/actuation/approval-gateway.js +14 -0
  53. package/dist-server/service/actuation/approval-gateway.js.map +1 -0
  54. package/dist-server/service/actuation/catch-up-effects.d.ts +16 -0
  55. package/dist-server/service/actuation/catch-up-effects.js +120 -0
  56. package/dist-server/service/actuation/catch-up-effects.js.map +1 -0
  57. package/dist-server/service/actuation/command-dispatcher.d.ts +206 -8
  58. package/dist-server/service/actuation/command-dispatcher.js +191 -14
  59. package/dist-server/service/actuation/command-dispatcher.js.map +1 -1
  60. package/dist-server/service/actuation/command-store.d.ts +10 -2
  61. package/dist-server/service/actuation/command-store.js +168 -5
  62. package/dist-server/service/actuation/command-store.js.map +1 -1
  63. package/dist-server/service/actuation/dispatch-after-approval.d.ts +11 -0
  64. package/dist-server/service/actuation/dispatch-after-approval.js +73 -0
  65. package/dist-server/service/actuation/dispatch-after-approval.js.map +1 -0
  66. package/dist-server/service/actuation/effect-match.d.ts +53 -0
  67. package/dist-server/service/actuation/effect-match.js +65 -0
  68. package/dist-server/service/actuation/effect-match.js.map +1 -0
  69. package/dist-server/service/actuation/index.d.ts +21 -2
  70. package/dist-server/service/actuation/index.js +21 -2
  71. package/dist-server/service/actuation/index.js.map +1 -1
  72. package/dist-server/service/actuation/retry-plan.d.ts +63 -0
  73. package/dist-server/service/actuation/retry-plan.js +99 -0
  74. package/dist-server/service/actuation/retry-plan.js.map +1 -0
  75. package/dist-server/service/actuation/rule-evaluate.d.ts +127 -0
  76. package/dist-server/service/actuation/rule-evaluate.js +205 -0
  77. package/dist-server/service/actuation/rule-evaluate.js.map +1 -0
  78. package/dist-server/service/actuation/rule-loop.d.ts +5 -0
  79. package/dist-server/service/actuation/rule-loop.js +156 -0
  80. package/dist-server/service/actuation/rule-loop.js.map +1 -0
  81. package/dist-server/service/actuation/rule-runner.d.ts +52 -0
  82. package/dist-server/service/actuation/rule-runner.js +135 -0
  83. package/dist-server/service/actuation/rule-runner.js.map +1 -0
  84. package/dist-server/service/actuation/rule-wiring.d.ts +24 -0
  85. package/dist-server/service/actuation/rule-wiring.js +80 -0
  86. package/dist-server/service/actuation/rule-wiring.js.map +1 -0
  87. package/dist-server/service/actuation/settle-effects.d.ts +21 -0
  88. package/dist-server/service/actuation/settle-effects.js +101 -0
  89. package/dist-server/service/actuation/settle-effects.js.map +1 -0
  90. package/dist-server/service/actuation/sweep-dispatch.d.ts +37 -0
  91. package/dist-server/service/actuation/sweep-dispatch.js +125 -0
  92. package/dist-server/service/actuation/sweep-dispatch.js.map +1 -0
  93. package/dist-server/service/actuation/twin-command-resolver.d.ts +120 -0
  94. package/dist-server/service/actuation/twin-command-resolver.js +406 -0
  95. package/dist-server/service/actuation/twin-command-resolver.js.map +1 -0
  96. package/dist-server/service/actuation/twin-command.d.ts +81 -0
  97. package/dist-server/service/actuation/twin-command.js +60 -0
  98. package/dist-server/service/actuation/twin-command.js.map +1 -1
  99. package/dist-server/service/actuation/twin-now.d.ts +41 -0
  100. package/dist-server/service/actuation/twin-now.js +65 -0
  101. package/dist-server/service/actuation/twin-now.js.map +1 -0
  102. package/dist-server/service/index.d.ts +5 -2
  103. package/dist-server/service/index.js +8 -0
  104. package/dist-server/service/index.js.map +1 -1
  105. package/dist-server/service/reference/actuation-routing.d.ts +60 -0
  106. package/dist-server/service/reference/actuation-routing.js +126 -0
  107. package/dist-server/service/reference/actuation-routing.js.map +1 -0
  108. package/dist-server/service/reference/actuation-target.d.ts +65 -0
  109. package/dist-server/service/reference/actuation-target.js +94 -0
  110. package/dist-server/service/reference/actuation-target.js.map +1 -0
  111. package/dist-server/service/reference/connection-portability-resolver.d.ts +29 -0
  112. package/dist-server/service/reference/connection-portability-resolver.js +181 -0
  113. package/dist-server/service/reference/connection-portability-resolver.js.map +1 -0
  114. package/dist-server/service/reference/connection-portability.d.ts +138 -0
  115. package/dist-server/service/reference/connection-portability.js +230 -0
  116. package/dist-server/service/reference/connection-portability.js.map +1 -0
  117. package/dist-server/service/reference/fill-cadence.d.ts +17 -0
  118. package/dist-server/service/reference/fill-cadence.js +31 -0
  119. package/dist-server/service/reference/fill-cadence.js.map +1 -0
  120. package/dist-server/service/reference/fill-dedupe.d.ts +16 -0
  121. package/dist-server/service/reference/fill-dedupe.js +63 -0
  122. package/dist-server/service/reference/fill-dedupe.js.map +1 -0
  123. package/dist-server/service/reference/fill-loop.d.ts +35 -0
  124. package/dist-server/service/reference/fill-loop.js +187 -0
  125. package/dist-server/service/reference/fill-loop.js.map +1 -0
  126. package/dist-server/service/reference/fill-span.d.ts +10 -0
  127. package/dist-server/service/reference/fill-span.js +37 -0
  128. package/dist-server/service/reference/fill-span.js.map +1 -0
  129. package/dist-server/service/reference/hook-contract.d.ts +88 -4
  130. package/dist-server/service/reference/hook-contract.js +105 -4
  131. package/dist-server/service/reference/hook-contract.js.map +1 -1
  132. package/dist-server/service/reference/hook-rejected.d.ts +25 -0
  133. package/dist-server/service/reference/hook-rejected.js +48 -0
  134. package/dist-server/service/reference/hook-rejected.js.map +1 -0
  135. package/dist-server/service/reference/index.d.ts +4 -1
  136. package/dist-server/service/reference/index.js +5 -1
  137. package/dist-server/service/reference/index.js.map +1 -1
  138. package/dist-server/service/reference/live-cadence.d.ts +68 -0
  139. package/dist-server/service/reference/live-cadence.js +44 -0
  140. package/dist-server/service/reference/live-cadence.js.map +1 -0
  141. package/dist-server/service/reference/live-feed-lease.d.ts +15 -0
  142. package/dist-server/service/reference/live-feed-lease.js +56 -0
  143. package/dist-server/service/reference/live-feed-lease.js.map +1 -0
  144. package/dist-server/service/reference/live-ingest.d.ts +21 -0
  145. package/dist-server/service/reference/live-ingest.js +66 -0
  146. package/dist-server/service/reference/live-ingest.js.map +1 -0
  147. package/dist-server/service/reference/reference-adapter.d.ts +291 -2
  148. package/dist-server/service/reference/reference-adapter.js +19 -1
  149. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  150. package/dist-server/service/reference/reference-fill-run.d.ts +50 -0
  151. package/dist-server/service/reference/reference-fill-run.js +142 -0
  152. package/dist-server/service/reference/reference-fill-run.js.map +1 -0
  153. package/dist-server/service/reference/reference-fill.d.ts +90 -0
  154. package/dist-server/service/reference/reference-fill.js +141 -0
  155. package/dist-server/service/reference/reference-fill.js.map +1 -0
  156. package/dist-server/service/reference/reference-hook.d.ts +22 -0
  157. package/dist-server/service/reference/reference-hook.js +135 -55
  158. package/dist-server/service/reference/reference-hook.js.map +1 -1
  159. package/dist-server/service/reference/reference-link-groups.d.ts +10 -0
  160. package/dist-server/service/reference/reference-link-groups.js +15 -0
  161. package/dist-server/service/reference/reference-link-groups.js.map +1 -0
  162. package/dist-server/service/reference/reference-links.d.ts +14 -0
  163. package/dist-server/service/reference/reference-links.js +73 -0
  164. package/dist-server/service/reference/reference-links.js.map +1 -0
  165. package/dist-server/service/reference/reference-live.d.ts +1 -0
  166. package/dist-server/service/reference/reference-live.js +32 -6
  167. package/dist-server/service/reference/reference-live.js.map +1 -1
  168. package/dist-server/service/reference/reference-master.d.ts +56 -2
  169. package/dist-server/service/reference/reference-master.js +33 -3
  170. package/dist-server/service/reference/reference-master.js.map +1 -1
  171. package/dist-server/service/reference/reference-resolver.d.ts +27 -0
  172. package/dist-server/service/reference/reference-resolver.js +79 -1
  173. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  174. package/dist-server/service/twin-event/twin-event-keys.d.ts +31 -3
  175. package/dist-server/service/twin-event/twin-event-keys.js +36 -4
  176. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  177. package/dist-server/service/twin-event/twin-event.d.ts +1 -0
  178. package/dist-server/service/twin-event/twin-event.js +11 -2
  179. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  180. package/dist-server/service/twin-model/choose-recipe.d.ts +15 -0
  181. package/dist-server/service/twin-model/choose-recipe.js +33 -0
  182. package/dist-server/service/twin-model/choose-recipe.js.map +1 -0
  183. package/dist-server/service/twin-model/external-resolver.d.ts +76 -0
  184. package/dist-server/service/twin-model/external-resolver.js +109 -0
  185. package/dist-server/service/twin-model/external-resolver.js.map +1 -0
  186. package/dist-server/service/twin-model/project-structure.js +16 -3
  187. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  188. package/dist-server/service/twin-model/twin-equipment.d.ts +17 -0
  189. package/dist-server/service/twin-model/twin-equipment.js +10 -0
  190. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  191. package/dist-server/service/twin-model/twin-lineage-query.js +75 -1
  192. package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
  193. package/dist-server/service/twin-model/twin-model-item-query.js +94 -5
  194. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  195. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  196. package/dist-server/service/twin-space/twin-space.d.ts +29 -0
  197. package/dist-server/service/twin-space/twin-space.js +13 -0
  198. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  199. package/dist-server/service/twin-subject/event-subjects.d.ts +3 -1
  200. package/dist-server/service/twin-subject/event-subjects.js +8 -1
  201. package/dist-server/service/twin-subject/event-subjects.js.map +1 -1
  202. package/dist-shared/entity-delta.js +1 -1
  203. package/dist-shared/entity-delta.js.map +1 -1
  204. package/package.json +7 -7
  205. package/server/engine/canonical-ingest.ts +119 -33
  206. package/server/engine/event-time.ts +96 -0
  207. package/server/engine/index.ts +20 -0
  208. package/server/engine/ingest-dedupe.ts +203 -27
  209. package/server/engine/ingest-health.ts +91 -6
  210. package/server/engine/kpi-fold.ts +24 -1
  211. package/server/engine/kpi-query.ts +23 -4
  212. package/server/engine/oee-accumulator.ts +80 -8
  213. package/server/engine/twin-engine.ts +119 -5
  214. package/server/index.ts +23 -0
  215. package/server/migrations/1786600000000-RenameOperatoMesAdapterType.ts +66 -0
  216. package/server/migrations/1786700000000-AddDispatchRetryColumns.ts +82 -0
  217. package/server/migrations/index.ts +5 -1
  218. package/server/routes.ts +12 -24
  219. package/server/service/actuation/actuation-advisor.ts +124 -0
  220. package/server/service/actuation/actuation-outcome.ts +97 -0
  221. package/server/service/actuation/actuation-rule-resolver.ts +147 -0
  222. package/server/service/actuation/actuation-rule.ts +139 -0
  223. package/server/service/actuation/approval-gateway.ts +44 -0
  224. package/server/service/actuation/catch-up-effects.ts +152 -0
  225. package/server/service/actuation/command-dispatcher.ts +401 -20
  226. package/server/service/actuation/command-store.ts +182 -8
  227. package/server/service/actuation/dispatch-after-approval.ts +71 -0
  228. package/server/service/actuation/effect-match.ts +124 -0
  229. package/server/service/actuation/index.ts +21 -2
  230. package/server/service/actuation/retry-plan.ts +145 -0
  231. package/server/service/actuation/rule-evaluate.ts +352 -0
  232. package/server/service/actuation/rule-loop.ts +160 -0
  233. package/server/service/actuation/rule-runner.ts +216 -0
  234. package/server/service/actuation/rule-wiring.ts +84 -0
  235. package/server/service/actuation/settle-effects.ts +115 -0
  236. package/server/service/actuation/sweep-dispatch.ts +179 -0
  237. package/server/service/actuation/twin-command-resolver.ts +387 -0
  238. package/server/service/actuation/twin-command.ts +128 -1
  239. package/server/service/actuation/twin-now.ts +97 -0
  240. package/server/service/index.ts +9 -1
  241. package/server/service/reference/actuation-routing.ts +165 -0
  242. package/server/service/reference/actuation-target.ts +163 -0
  243. package/server/service/reference/connection-portability-resolver.ts +171 -0
  244. package/server/service/reference/connection-portability.ts +285 -0
  245. package/server/service/reference/fill-cadence.ts +29 -0
  246. package/server/service/reference/fill-dedupe.ts +66 -0
  247. package/server/service/reference/fill-loop.ts +153 -0
  248. package/server/service/reference/fill-span.ts +31 -0
  249. package/server/service/reference/hook-contract.ts +185 -5
  250. package/server/service/reference/hook-rejected.ts +53 -0
  251. package/server/service/reference/index.ts +5 -1
  252. package/server/service/reference/live-cadence.ts +129 -0
  253. package/server/service/reference/live-feed-lease.ts +56 -0
  254. package/server/service/reference/live-ingest.ts +94 -0
  255. package/server/service/reference/reference-adapter.ts +327 -5
  256. package/server/service/reference/reference-fill-run.ts +165 -0
  257. package/server/service/reference/reference-fill.ts +223 -0
  258. package/server/service/reference/reference-hook.ts +162 -56
  259. package/server/service/reference/reference-link-groups.ts +21 -0
  260. package/server/service/reference/reference-links.ts +77 -0
  261. package/server/service/reference/reference-live.ts +30 -5
  262. package/server/service/reference/reference-master.ts +90 -5
  263. package/server/service/reference/reference-resolver.ts +77 -2
  264. package/server/service/twin-event/twin-event-keys.ts +37 -4
  265. package/server/service/twin-event/twin-event.ts +18 -1
  266. package/server/service/twin-model/choose-recipe.ts +31 -0
  267. package/server/service/twin-model/external-resolver.ts +161 -0
  268. package/server/service/twin-model/project-structure.ts +16 -3
  269. package/server/service/twin-model/twin-equipment.ts +22 -0
  270. package/server/service/twin-model/twin-lineage-query.ts +82 -1
  271. package/server/service/twin-model/twin-model-item-query.ts +95 -4
  272. package/server/service/twin-model/twin-model-tree-query.ts +1 -1
  273. package/server/service/twin-space/twin-space.ts +39 -0
  274. package/server/service/twin-subject/event-subjects.ts +8 -1
  275. package/shared/entity-delta.ts +1 -1
  276. package/test/ack-shape-alignment.test.ts +97 -0
  277. package/test/actuation-approval-door.test.ts +421 -0
  278. package/test/actuation-dispatch.test.ts +16 -3
  279. package/test/actuation-effect.test.ts +162 -0
  280. package/test/actuation-outcome.test.ts +152 -0
  281. package/test/actuation-rule-runner.test.ts +281 -0
  282. package/test/actuation-rule.test.ts +266 -0
  283. package/test/actuation-seam.test.ts +249 -0
  284. package/test/actuation-settle.test.ts +164 -0
  285. package/test/actuation-target.test.ts +145 -0
  286. package/test/canonical-ingest-vocabularies.test.ts +49 -2
  287. package/test/capability-mapping.test.ts +1 -1
  288. package/test/choose-recipe.test.ts +117 -0
  289. package/test/command-store-writes-columns.test.ts +126 -0
  290. package/test/connection-portability-doors.test.ts +87 -0
  291. package/test/connector-capability-declaration.test.ts +54 -4
  292. package/test/declaration-reaches-model.test.ts +43 -0
  293. package/test/dedupe-transformation.test.ts +112 -0
  294. package/test/entity-delta.test.ts +1 -1
  295. package/test/event-subjects.test.ts +2 -1
  296. package/test/external-resolver.test.ts +171 -0
  297. package/test/fill-dedupe.test.ts +59 -0
  298. package/test/fill-loop.test.ts +35 -0
  299. package/test/first-envelope.test.ts +153 -0
  300. package/test/fold-shift-dst.test.ts +110 -0
  301. package/test/home-in-area.test.ts +90 -0
  302. package/test/hook-rejected-shape.test.ts +86 -0
  303. package/test/hook-response-sum.test.ts +193 -0
  304. package/test/hook-sequence.test.ts +1 -1
  305. package/test/ingest-dedupe.test.ts +56 -0
  306. package/test/ingest-expected-quiet.test.ts +111 -0
  307. package/test/ingest-idempotent.test.ts +79 -0
  308. package/test/ingest-reconcile-four-ways.test.ts +97 -0
  309. package/test/ingest-rules-header.test.ts +100 -0
  310. package/test/ingest-window-rolls-unobserved.test.ts +72 -0
  311. package/test/intake-mapping-parity.test.ts +175 -0
  312. package/test/live-cadence.test.ts +170 -0
  313. package/test/live-feed-lease.test.ts +63 -0
  314. package/test/live-mirror-parity.test.ts +1 -1
  315. package/test/oee-accumulator.test.ts +3 -3
  316. package/test/oee-site-calendar.test.ts +146 -0
  317. package/test/order-and-transaction-split.test.ts +107 -0
  318. package/test/reference-fill.test.ts +235 -0
  319. package/test/reference-hook.test.ts +1 -1
  320. package/test/reference-links.test.ts +56 -0
  321. package/test/retry-plan.test.ts +139 -0
  322. package/test/sweep-dispatch.test.ts +191 -0
  323. package/test/twin-event-keys.test.ts +20 -6
  324. package/test/twin-model-item-db.test.ts +6 -6
  325. package/test/twin-model-tree-db.test.ts +6 -6
  326. package/tsconfig.shared.tsbuildinfo +1 -1
  327. package/tsconfig.tsbuildinfo +1 -1
@@ -42,18 +42,38 @@
42
42
  * 경계가 이미 거릅니다(§`FactDeduper`) — 이 파일이 따로 하지 않습니다. 응답에 몇 건이 중복이었는지
43
43
  * 함께 알립니다.
44
44
  */
45
+ /*
46
+ * `WebhookResponse` comes from the root, not from `@operato/ops-contract/webhook`.
47
+ *
48
+ * That subpath resolves to `webhook-signature`, which is a separate module kept out of the barrel
49
+ * because it pulls in `node:crypto`. The module actually named `webhook.ts` — status codes, the
50
+ * envelope, the response — reaches consumers through the root barrel. Importing the response type
51
+ * from the subpath fails with TS2305, which is what happened here first.
52
+ */
53
+ import { checkSequence, type WebhookResponse } from '@operato/ops-contract'
45
54
  import { WEBHOOK_HEADER, verifyWebhookSignature } from '@operato/ops-contract/webhook'
46
55
 
56
+ /*
57
+ * The body is typed by the contract, not by this file (2026-09-07).
58
+ *
59
+ * It was `Record<string, unknown>`, which accepts anything — so nothing checked that what we reply
60
+ * with is what a sender was told to expect. It was not: the contract declared `rejected` in prose
61
+ * only, operato-plant guessed "a list of envelope ids", and a whole batch was stamped delivered
62
+ * while one envelope never landed (mes_outbox_events seq 178 — SENT, attempts 0, no error).
63
+ *
64
+ * With `WebhookResponse` here, adding a field to a reply without declaring it fails the build on
65
+ * this side, which is the side that would otherwise ship the surprise.
66
+ */
47
67
  /** 훅 요청의 결과 — 상태 코드와 본문을 함께 정한다(부르는 쪽이 그대로 답한다). */
48
68
  export interface HookOutcome {
49
69
  status: number
50
- body: Record<string, unknown>
70
+ body: WebhookResponse
51
71
  }
52
72
 
53
73
  /**
54
74
  * 응답 코드 규약 — **계약이 정하고 여기서는 그것을 쓴다**(2026-08-31).
55
75
  *
56
- * 예전에는 이 표가 여기 있었고 보내는 쪽(operato-mes)에 또 있었다. 그래서 같은 409 를 양쪽이 다른
76
+ * 예전에는 이 표가 여기 있었고 보내는 쪽(operato-plant)에 또 있었다. 그래서 같은 409 를 양쪽이 다른
57
77
  * 뜻으로 썼다 — 여기서는 「그 트윈이 실시간으로 돌지 않는다」, 저쪽에서는 「번호가 비었다」였다.
58
78
  * 붙였다면 트윈이 안 도는 동안 보내는 쪽이 같은 구간을 계속 다시 보냈다.
59
79
  *
@@ -90,7 +110,7 @@ export function secretMatches(given: string, expected: string): boolean {
90
110
  * 인증 — **서명이 왔으면 서명을 보고, 아니면 비밀값을 본다.**
91
111
  *
92
112
  * 두 방식을 두는 이유는 밀어 주는 쪽의 사정이 다르기 때문이다. 이미 붙어 있는 커넥터는 비밀값을 헤더에
93
- * 싣는 방식으로 만들어졌고, 새로 만드는 것(operato-mes)은 서명을 쓴다. 서명 쪽이 낫다 — 비밀값을 그대로
113
+ * 싣는 방식으로 만들어졌고, 새로 만드는 것(operato-plant)은 서명을 쓴다. 서명 쪽이 낫다 — 비밀값을 그대로
94
114
  * 싣는 방식은 그 요청을 그대로 다시 보내는 것을 막지 못한다.
95
115
  *
96
116
  * **서명이 왔는데 확인할 수 없으면 거절한다.** 받은 바이트를 들고 있지 않으면(`rawBody` 가 없으면)
@@ -165,13 +185,173 @@ export function seqOf(cursor: unknown, scope: string): number | undefined {
165
185
  }
166
186
 
167
187
  /** 그 단위의 번호만 바꾼 커서를 만든다 — 다른 흐름과 `since`·`seen` 은 그대로 둔다. */
168
- export function withSeq(cursor: unknown, scope: string, seq: number): Record<string, unknown> {
188
+ export function withSeq(cursor: unknown, scope: string, seq: number, firstSeq?: number): Record<string, unknown> {
169
189
  const base = (cursor && typeof cursor === 'object' ? (cursor as Record<string, unknown>) : {}) as Record<string, any>
170
190
  const streams = base.streams && typeof base.streams === 'object' ? { ...base.streams } : {}
171
- streams[scope] = { ...(streams[scope] ?? {}), seq }
191
+ const prev = streams[scope] ?? {}
192
+ /*
193
+ * **`firstSeq` 는 한 번만 적힌다.** 이 연결이 이 단위를 몇 번부터 받기 시작했는지는 바뀌지 않는
194
+ * 사실이고, 나중에 덮으면 「앞이 비었나」를 다시 물을 수 없다.
195
+ *
196
+ * 그래서 첫 봉투 판정이 있을 때만 넘어온다(`SequencedTake.firstSeq`). 커서가 이미 나아간 뒤에
197
+ * 이 값을 만들면 그 번호는 첫 번호가 아니라 그때 마침 온 번호다.
198
+ */
199
+ streams[scope] = { ...prev, seq, ...(firstSeq !== undefined && prev.firstSeq === undefined ? { firstSeq } : {}) }
172
200
  return { ...base, streams }
173
201
  }
174
202
 
203
+ /**
204
+ * 이 연결이 이 단위를 **몇 번부터 받기 시작했나** — 적힌 적이 없으면 `undefined`.
205
+ *
206
+ * 「앞이 비었나」를 답하려면 이 값이 있어야 한다. 없으면 **모른다고 말해야 한다** — 0 으로 답하면
207
+ * 1번부터 다 받은 것으로 읽힌다.
208
+ */
209
+ export function firstSeqOf(cursor: unknown, scope: string): number | undefined {
210
+ const seq = (cursor as any)?.streams?.[scope]?.firstSeq
211
+ return typeof seq === 'number' && Number.isInteger(seq) ? seq : undefined
212
+ }
213
+
214
+ /**
215
+ * **번호대로 받을 수 있는 데까지 자른다** — 밀어 받는 길과 backfill 하는 길이 이 함수 하나를 지난다.
216
+ *
217
+ * ── 왜 갈라 두나 (2026-08-31) ────────────────────────────────────────────
218
+ * 이 판정이 훅 안에 박혀 있었다. backfill(`fetchSince`)이 같은 판정을 해야 하는데, 거기서 다시 만들면
219
+ * **두 길이 다른 규칙으로 받는다** — 한쪽만 고쳐지는 날이 오고, 어긋난 그 길에서만 사실이 사라진다.
220
+ *
221
+ * 번호 확인을 커넥터가 아니라 프레임워크로 가져온 것과 같은 이유다. 자리가 하나여야 한다.
222
+ *
223
+ * ── 첫 구멍에서 멈춘다 ───────────────────────────────────────────────────
224
+ * 구멍 뒤의 봉투를 받아들이면 그 구멍이 영영 메워지지 않는다. 보내는 쪽은 `expectedSeq` 부터 다시
225
+ * 보내고, 뒤엣것은 그때 함께 온다.
226
+ */
227
+ export interface SequencedTake {
228
+ /** 받아들인 레코드 — 구멍 앞까지다. */
229
+ records: unknown[]
230
+ /** 번호를 확인했나. 번호를 매기지 않는 원본은 거짓이고, 그 사실을 응답에 남긴다. */
231
+ numbered: boolean
232
+ /** 커서에 적을 번호 — 받은 것이 없으면 없다. */
233
+ advancedSeq?: number
234
+ /** 구멍이 났다 — 그 앞부터 다시 받아야 한다. */
235
+ gap?: { expectedSeq: number; lastSeq: number }
236
+ /**
237
+ * 이 단위의 **첫 봉투였다** — 그 앞으로 몇 개를 못 봤나.
238
+ *
239
+ * ── 왜 이 칸이 생겼나 (2026-09-06 실측) ──────────────────────────────────
240
+ * plant 아웃박스의 1~20 이 400 으로 거절돼 그 연결에 커서가 없었다. 다음에 온 21번이 첫 봉투로
241
+ * 앉으며 **커서가 21 로 뛰었다.** 커서가 0 이었다면 같은 21이 구멍이었다.
242
+ *
243
+ * 스무 건이 없는데 구멍으로도 안 세어지고, 경고도 없고, backfill 대상도 아니었다 — backfill 은 커서부터
244
+ * 묻는다. 그 사실이 아무 데도 안 남았다.
245
+ *
246
+ * **손실이라고 말하지 않는다.** 원본이 오래 돌고 있는데 트윈을 나중에 붙이면 그 아래는 애초에
247
+ * 안 받기로 한 것이다. 계약은 둘을 구별할 수 없으므로 수만 낸다.
248
+ *
249
+ * 0 이면 1번부터 시작한 것이라 이 칸을 내지 않는다 — 말할 것이 없다.
250
+ */
251
+ firstUnseenBefore?: number
252
+ /**
253
+ * 이 묶음의 첫 봉투가 **이 단위의 첫 봉투였다** — 그 번호.
254
+ *
255
+ * `firstUnseenBefore` 와 다르다: 이쪽은 1번부터 시작한 연결에도 있다(값 1). 커서에 적어 두면
256
+ * 나중에 「앞이 비었나」를 답할 수 있다.
257
+ */
258
+ firstSeq?: number
259
+ /**
260
+ * 이 묶음에서 **가장 낮게 받아들인 번호** — 하나도 못 받았으면 없다.
261
+ *
262
+ * `firstSeq` 와 다르다. `firstSeq` 는 「이 단위의 첫 봉투였다」일 때만 있고, 이쪽은 언제나 있다.
263
+ *
264
+ * 밑에서부터 다시 받을 때 이것이 필요하다. `from: 0` 으로 부르면 커서가 0 이므로 seq 1 은
265
+ * `next` 판정이고 `firstSeq` 가 안 생긴다 — 그런데 우리는 실제로 1번부터 갖게 된다. 그 사실을
266
+ * 적어야 「앞이 비었나」에 답할 수 있다.
267
+ */
268
+ lowestSeq?: number
269
+ /**
270
+ * 받을 수 없는 묶음 — 그 이유.
271
+ *
272
+ * 번호가 있는 봉투와 없는 봉투가 섞였거나 번호가 정수가 아니다. 둘 다 보내는 쪽의 결함이고,
273
+ * **반쯤 확인한 것을 확인했다고 말하지 않는다.**
274
+ */
275
+ refused?: string
276
+ }
277
+
278
+ export function takeInSequence(args: {
279
+ items: { record: unknown; seq?: number; at?: string }[]
280
+ scope?: string
281
+ /** 이 단위에서 마지막으로 받아들인 번호. 받은 적이 없으면 `undefined`. */
282
+ lastSeq?: number
283
+ }): SequencedTake {
284
+ const { items, scope, lastSeq } = args
285
+
286
+ const withSeq = items.filter(i => typeof i?.seq === 'number').length
287
+ if (withSeq && withSeq !== items.length) {
288
+ return { records: [], numbered: false, refused: `mixed batch — ${withSeq} of ${items.length} items carry a sequence number` }
289
+ }
290
+ const numbered = withSeq > 0 && !!scope
291
+
292
+ if (!numbered) return { records: items.map(i => i.record), numbered: false }
293
+
294
+ let cursor = lastSeq
295
+ let take = 0
296
+ let gap: { expectedSeq: number; lastSeq: number } | undefined
297
+ /* 첫 봉투가 몇 번부터 시작했나 — 그 앞을 못 본 사실을 응답에 남긴다. */
298
+ let firstUnseenBefore: number | undefined
299
+ /* 이 단위를 몇 번부터 받기 시작했나 — 커서에 적어 두면 「앞이 비었나」를 나중에 답할 수 있다. */
300
+ let firstSeq: number | undefined
301
+ /* 이 묶음에서 가장 낮게 받아들인 번호 — 밑에서부터 다시 받을 때 이것이 firstSeq 가 된다. */
302
+ let lowestSeq: number | undefined
303
+ for (const item of items) {
304
+ let verdict
305
+ try {
306
+ verdict = checkSequence(cursor, item.seq as number)
307
+ } catch (e: any) {
308
+ return { records: [], numbered: true, refused: e?.message ?? 'bad sequence number' }
309
+ }
310
+ if (verdict.kind === 'gap') {
311
+ gap = { expectedSeq: verdict.expectedSeq, lastSeq: verdict.lastSeq }
312
+ break
313
+ }
314
+ if (verdict.kind === 'first') {
315
+ firstSeq = item.seq as number
316
+ /*
317
+ * 계약이 `unseenBefore` 를 내면 그것을 쓴다. 설치된 판이 그보다 오래됐으면 번호에서 센다 —
318
+ * 첫 봉투의 번호 아래로 그만큼이 있고 우리는 그것을 받은 적이 없다.
319
+ *
320
+ * ⚠ 이 두 번째 길은 계약 0.9.9 가 깔리면 지운다. 그때까지 두는 이유는, 계약을 기다리며
321
+ * 비워 두면 **코드는 있는데 아무 일도 안 하는 상태**가 되고 그것이 이 결함의 원인과 같은
322
+ * 모양이기 때문이다.
323
+ */
324
+ const unseen = (verdict as any).unseenBefore ?? (item.seq as number) - 1
325
+ if (unseen > 0) firstUnseenBefore = unseen
326
+ }
327
+ if (lowestSeq === undefined) lowestSeq = item.seq as number
328
+ cursor = verdict.lastSeq
329
+ take++
330
+ }
331
+
332
+ return {
333
+ /*
334
+ * **봉투의 시각을 레코드에 얹는다** — 레코드가 자기 시각을 말하면 그것이 이긴다.
335
+ *
336
+ * 시각을 안 싣는 레코드는 ingest 시각으로 떨어지고, 그 값이 실행할 때마다 달라서 fact identity 가
337
+ * 매번 바뀐다. 그래서 같은 사실을 다시 받으면 dedupe 가 안 되고 새 사실로 앉는다 —
338
+ * `fillTwinScope` 를 두 번 돌렸더니 저널이 40 → 69건이 됐다(2026-09-06 측정).
339
+ */
340
+ records: items.slice(0, take).map(i => {
341
+ const r = i.record as any
342
+ if (!i.at || r == null || typeof r !== 'object') return i.record
343
+ return r.at === undefined && r.eventTime === undefined ? { ...r, at: i.at } : i.record
344
+ }),
345
+ numbered: true,
346
+ /* 앞의 것을 하나도 못 받았으면 커서를 올릴 것이 없다. */
347
+ ...(take > 0 && typeof cursor === 'number' ? { advancedSeq: cursor } : {}),
348
+ ...(gap ? { gap } : {}),
349
+ ...(firstUnseenBefore ? { firstUnseenBefore } : {}),
350
+ ...(firstSeq !== undefined ? { firstSeq } : {}),
351
+ ...(lowestSeq !== undefined ? { lowestSeq } : {})
352
+ }
353
+ }
354
+
175
355
  /** 이 연결이 이 트윈을 만들었나 — 남의 트윈에 밀어 넣지 못하게. */
176
356
  export function producedInstance(ref: { scopeSpec?: any } | null | undefined, instanceId: string): { siteId: string } | undefined {
177
357
  const produced: any[] = (ref?.scopeSpec as any)?.produced ?? []
@@ -0,0 +1,53 @@
1
+ /*
2
+ * 떨어진 것을 **보내는 쪽이 알아들을 모양으로** 바꾼다 — 순수 판정, 한 곳.
3
+ *
4
+ * ── 무엇이 있었나 (2026-09-07 측정) ─────────────────────────────────────────
5
+ * 훅 응답의 떨어진 목록이 `{ record, errors }` 였고, plant 은 그것을 **봉투 id 문자열 목록**으로
6
+ * 읽고 있었다.
7
+ *
8
+ * ```
9
+ * headless-twin rejected: { record, errors }[] 객체 배열
10
+ * plant "rejected is a list of envelope ids" 문자열 배열
11
+ * ```
12
+ *
13
+ * `Array.isArray` 는 통과하므로 그쪽의 새 guard 도 안 걸렸다. id 를 맞추는데 객체라 하나도 안 맞고
14
+ * **묶음 전체가 「보냈음」으로 찍혔다.** 422 와 `ok: false` 를 보냈는데 그쪽 행은 재시도 0 ·
15
+ * 오류 없음 · 보냈음이었다. 인티그레이션 레인이 두 파일을 나란히 읽어서 찾았다.
16
+ *
17
+ * 우리가 말했는데 상대가 못 알아듣는 모양이면, 말한 것이 아니다.
18
+ *
19
+ * ── 왜 배선에서 갈라 놓나 ───────────────────────────────────────────────────
20
+ * `reference-hook.ts` 는 엔티티를 물어서 시험 러너가 못 읽는다. 이 판정은 평범한 값으로 확인할 수
21
+ * 있어야 하고, 오늘 이 저장소에서 「고쳐도 통과하고 안 고쳐도 통과하는 시험」이 여럿 나왔다.
22
+ */
23
+
24
+ /** 훅 응답에 실리는 떨어진 것 하나 — 봉투 id 를 알면 그것을, 모르면 레코드를. */
25
+ export type RejectedForCaller = { eventId: string; errors: string[] } | { record: unknown; errors: string[] }
26
+
27
+ /**
28
+ * 떨어진 것에 봉투 id 를 붙인다.
29
+ *
30
+ * **짝은 객체 동일성으로 짓는다.** 유입에 넘긴 레코드가 배치의 원소 그대로이므로(§`takeInSequence`)
31
+ * 커넥터를 고치지 않고도 봉투로 되짚을 수 있다. 값으로 비교하면 같은 모양의 레코드 둘이 서로의 id 를
32
+ * 가져간다 — 그러면 보내는 쪽이 **엉뚱한 행**을 떨어진 것으로 표시한다.
33
+ *
34
+ * **id 를 못 찾으면 레코드를 그대로 싣는다.** 빈 id 를 실으면 보내는 쪽이 아무 행도 못 찾으면서
35
+ * 「알았다」고 여긴다 — 지금 났던 일이 정확히 그것이다.
36
+ */
37
+ export function rejectedForCaller(
38
+ rejected: readonly { record: unknown; errors: string[] }[],
39
+ items: readonly { record?: unknown; eventId?: unknown }[]
40
+ ): RejectedForCaller[] {
41
+ const idOf = new Map<unknown, string>()
42
+
43
+ for (const it of items) {
44
+ const id = typeof it?.eventId === 'string' && it.eventId.trim() ? it.eventId : undefined
45
+ if (id && it?.record !== undefined) idOf.set(it.record, id)
46
+ }
47
+
48
+ return rejected.map(r => {
49
+ const eventId = idOf.get(r.record)
50
+
51
+ return eventId ? { eventId, errors: r.errors } : { record: r.record, errors: r.errors }
52
+ })
53
+ }
@@ -1,11 +1,15 @@
1
+ import { ConnectionPortabilityResolver } from './connection-portability-resolver.js'
1
2
  import { TwinReferenceResolver } from './reference-resolver.js'
2
3
  import { TwinReferenceProgressSubscription } from './reference-progress-subscription.js'
3
4
  import { TwinReference } from './twin-reference.js'
4
5
 
5
6
  export const entities = [TwinReference]
6
- export const resolvers = [TwinReferenceResolver, TwinReferenceProgressSubscription]
7
+ export const resolvers = [TwinReferenceResolver, TwinReferenceProgressSubscription, ConnectionPortabilityResolver]
7
8
  export * from './control-routing.js'
8
9
  /* 진행 단계 어휘 — **모든 커넥터가 같은 낱말로 말한다.** 화면은 단계마다 번역 하나만 두면 된다. */
9
10
  export * from './reference-progress.js'
10
11
  /* 커넥터 점검 하네스 — 실 원본에 붙여 무엇이 채워지고 무엇이 비는지 센다(앱을 띄우지 않는다). */
11
12
  export * from './reference-probe.js'
13
+ /* 놓친 구간을 backfill 해 채우는 길 — 밀어 받는 길과 같은 번호 검사를 지난다. */
14
+ export * from './reference-fill.js'
15
+ export * from './connection-portability.js'
@@ -0,0 +1,129 @@
1
+ /*
2
+ * **원천이 축마다 언제·얼마나 자주 내놓나** — 유입 건강이 이 선언으로 견준다.
3
+ *
4
+ * ── 왜 필요한가 (2026-09-04 실측) ──────────────────────────────────────────
5
+ * 유입 건강이 **한 자로 전부를 쟀다.** 10분 안에 아무것도 오지 않으면 「유입 없음」이다. 그런데
6
+ * 원천마다 시간의 결이 다르다.
7
+ *
8
+ * 태양광 발전 일몰에 멈춘다 — 두 사이트가 같은 분 19:25 에 함께
9
+ * 부하 계측 밤에도 온다 · 15분마다
10
+ * 인증서 가격 화·목만 거래된다 · 그 날 하루 한 번
11
+ * MES · 주방 현장 근무시간 밖에는 사실이 안 난다 · 훅이라 「보내면 온다」
12
+ *
13
+ * 그래서 **정상인데 매일 밤 절반이 빨갛게 났다.** 거짓 빨강이 쌓이면 진짜 빨강도 같이 안 읽힌다.
14
+ *
15
+ * ── 두 축이고 서로 다르다 ───────────────────────────────────────────────────
16
+ * window 지금이 그 축의 활동 시각인가 → 아니면 조용한 것이 정상
17
+ * expectedSilenceMs 활동 시각 안에서 이만큼 조용해도 정상 → 넘기면 유입 없음
18
+ *
19
+ * 주기를 넘겼어도 활동 시각 밖이면 정상이다. 한 값으로 접을 수 없다 — 인증서 가격이 그 증거다.
20
+ * 「매일 와야 한다」로 재면 주 5일 중 3일이 빨갛고, 「주 2회」로 재면 이어 쓴 날을 못 본다.
21
+ *
22
+ * ── 커널이 일출·일몰을 계산하지 않는다 ─────────────────────────────────────
23
+ * 굴절·고위도·박명 정의가 다 선택이고, 우리가 정하면 그 순간 방언이다. 그리고 계약이 같은 문제에
24
+ * 이미 답해 뒀다(§`TwinModelDef.utcOffsetMinutes`): 「긴 지평선의 정확한 답은 호스트가 표준대로
25
+ * **절대 구간**을 계산해 넣는 것이다」.
26
+ *
27
+ * 그래서 원천은 `windowKind: 'daylight'` 로 **종류만** 선언하고, 좌표를 아는 호스트가 날마다
28
+ * 구체적인 구간을 채운다. **고정 시각을 선언에 박지 않는다** — 일몰 19:25 는 오늘 잰 값이고 두 달
29
+ * 뒤에는 틀리다. 그것을 박으면 겨울마다 거짓 빨강이 다시 난다.
30
+ *
31
+ * ── 「주기 없음」과 「모른다」는 다르다 ──────────────────────────────────────
32
+ * MES 는 훅으로 밀어 준다 — 몇 분마다가 아니라 「보내면 온다」다. 보낼 것이 없으면 안 보내는 것이
33
+ * 정상이고, 그것을 침묵으로 판정하면 안 된다.
34
+ *
35
+ * 선언하지 않은 축은 **지금까지처럼** 창 하나로 견준다(옛 거동). 「모른다」를 「안 와도 된다」로
36
+ * 접지 않는다 — 그러면 정말 끊긴 원천이 통째로 조용해진다.
37
+ */
38
+ import type { WorkCalendarEntry } from '@operato/ops-contract'
39
+
40
+ /**
41
+ * 창의 종류 — **호스트가 채워야 하는 것**만 이름이 있다.
42
+ *
43
+ * 지금은 하나다. 늘리기 전에 「호스트가 그것을 계산할 근거를 갖고 있나」를 먼저 물어야 한다 —
44
+ * 좌표 없이 `daylight` 를 선언하면 호스트가 채울 것이 없고, 그러면 그 축은 창이 없는 것과 같다.
45
+ */
46
+ export type LiveWindowKind = 'daylight'
47
+
48
+ /** 한 축의 시간성 — 어느 사실이 언제·얼마나 자주 오나. */
49
+ export interface LiveCadence {
50
+ /**
51
+ * 어느 축인가 — **`OP_EVENT` 의 값 그대로**(`equipment.status` · `quality.output` …).
52
+ *
53
+ * 새 낱말을 만들지 않는다. 만들면 커넥터가 부르는 이름과 장부가 세는 이름이 갈리고, 그때
54
+ * 선언이 조용히 아무 축에도 안 붙는다.
55
+ */
56
+ axis: string
57
+ /**
58
+ * 활동 시각 안에서 **이만큼 조용해도 정상**(ms). 없으면 창 하나로 견준다.
59
+ *
60
+ * 15분마다 긷는 원천은 이 값을 선언해야 한다 — 안 하면 긷는 사이마다 「유입 없음」으로 보인다.
61
+ */
62
+ expectedSilenceMs?: number
63
+ /**
64
+ * 이 축이 **활동하는 창** — 근무 달력 어휘 그대로(요일·시간대·휴일).
65
+ *
66
+ * 계약이 이미 그 셋을 표현한다. 새 모양을 만들지 않는 이유는 판정하는 함수가 이미 하나
67
+ * 있기 때문이다(`inWorkCalendar`) — 두 벌이면 화면과 커널이 다른 답을 낸다.
68
+ */
69
+ window?: readonly WorkCalendarEntry[]
70
+ /**
71
+ * 창을 **호스트가 채우는 종류** — 좌표·표준으로 계산해야 하는 것.
72
+ *
73
+ * `window` 와 함께 선언하면 호스트가 채운 것이 이긴다(계산이 선언보다 그 날에 맞다).
74
+ */
75
+ windowKind?: LiveWindowKind
76
+ /**
77
+ * **보내면 온다** — 이 축은 침묵으로 판정하지 않는다(훅·구독).
78
+ *
79
+ * 「주기가 없다」와 「모른다」를 가르는 값이다. 훅은 보낼 것이 없으면 안 보내고, 그것이 정상이다.
80
+ */
81
+ pushed?: boolean
82
+ }
83
+
84
+ /** 그 축의 선언을 찾는다 — 없으면 `undefined`(옛 거동으로 견준다). */
85
+ export function cadenceOf(cadences: readonly LiveCadence[] | undefined, axis: string): LiveCadence | undefined {
86
+ return cadences?.find(c => c.axis === axis)
87
+ }
88
+
89
+ /**
90
+ * 유입 건강이 이 축을 어떻게 견줄 것인가 — **판정에 넘길 두 값으로 옮긴다.**
91
+ *
92
+ * `syncVerdictOf` 가 받는 모양(§`ingest-health.ts`)에 맞춘다. 그 함수는 순수하고 달력을 보지
93
+ * 않으므로, 창 판정은 여기서 끝내고 참·거짓만 넘긴다 — `capabilityOf` 가 `requiredTests` 를 받는
94
+ * 것과 같은 규율이다.
95
+ *
96
+ * ── 「보내면 온다」는 침묵으로 판정하지 않는다 ──────────────────────────────
97
+ * `pushed` 면 `quietNow` 를 늘 참으로 낸다. 그러면 침묵이 「유입 없음」이 되지 않는다. **다른
98
+ * 갈래는 그대로 산다** — 버려지는 중·결선 없음은 몇 시든 문제이고 그것들이 이 값보다 앞선다.
99
+ *
100
+ * ── 모르면 아무것도 답하지 않는다 ──────────────────────────────────────────
101
+ * 선언이 없으면 둘 다 비운다. 그때 판정은 지금까지처럼 창 하나로 견준다 — 「모른다」를 「안 와도
102
+ * 된다」로 접으면 정말 끊긴 원천이 통째로 조용해진다.
103
+ */
104
+ export function ingestExpectation(
105
+ cadence: LiveCadence | undefined,
106
+ ctx: { nowMs: number; utcOffsetMinutes?: number; inWindow?: (window: readonly WorkCalendarEntry[]) => boolean }
107
+ ): { expectedSilenceMs?: number; quietNow?: boolean } {
108
+ if (!cadence) return {}
109
+
110
+ const out: { expectedSilenceMs?: number; quietNow?: boolean } = {}
111
+
112
+ if (typeof cadence.expectedSilenceMs === 'number' && cadence.expectedSilenceMs > 0) {
113
+ out.expectedSilenceMs = cadence.expectedSilenceMs
114
+ }
115
+
116
+ if (cadence.pushed) {
117
+ /* 보낼 것이 없으면 안 보내는 것이 정상이다 — 침묵을 근거로 삼지 않는다. */
118
+ out.quietNow = true
119
+ return out
120
+ }
121
+
122
+ /*
123
+ * 창이 있으면 그것으로 판정한다. **판정할 수단을 안 받았으면 답하지 않는다** — 부르는 쪽이
124
+ * 달력 판정 함수를 주지 않았는데 우리가 「활동 시각이다」로 단언하면 그것이 지어낸 값이다.
125
+ */
126
+ if (cadence.window?.length && ctx.inWindow) out.quietNow = !ctx.inWindow(cadence.window)
127
+
128
+ return out
129
+ }
@@ -0,0 +1,56 @@
1
+ /*
2
+ * What a live feed takes its task lease under — pure naming, one place.
3
+ *
4
+ * ── Why the name is built here and not by the adapter ──────────────────────
5
+ * `TaskLease` deliberately has no domain column: the thing made exclusive is a
6
+ * loop, not a tenant's slice of one, and a task that does need to be per-tenant
7
+ * puts the tenant in its name so the unique index keeps meaning what it says.
8
+ *
9
+ * An adapter cannot honour that. It receives `cfg` and `site`, and neither
10
+ * carries a domain — so a name built from `siteId` would let two domains that
11
+ * happen to share a plant code block each other's channel. It would look
12
+ * exactly like "someone else holds it", which is the ordinary case on every
13
+ * instance but one, so nothing would be logged and nothing would look wrong.
14
+ *
15
+ * ── Why the source and not the site ───────────────────────────────────────
16
+ * One reference is one connection to one system, and that connection is what a
17
+ * second instance must not open twice. A source that discovers several sites
18
+ * still reads them over that one connection.
19
+ *
20
+ * ── Why the parts are joined with `::` ────────────────────────────────────
21
+ * The same separator the push cursor key already uses (`instanceId::scope`), so
22
+ * a person reading `task_leases` during an incident sees the same shape they
23
+ * see in `liveCursor`.
24
+ */
25
+
26
+ /** Marks the name as a live feed, so a lease table read tells you what kind of task it is. */
27
+ const PREFIX = 'twin-live'
28
+
29
+ /**
30
+ * The lease name for one twin's feed.
31
+ *
32
+ * ── Domain and instance are required; the source is not ───────────────────
33
+ * A feed is registered per instance, so domain plus instance already names it
34
+ * uniquely. The source is appended when known because it is what a person
35
+ * reading `task_leases` wants to see — which system this channel talks to.
36
+ *
37
+ * Missing a domain or an instance is refused rather than filled in. A blank
38
+ * there would collapse two different feeds onto one name, and the failure
39
+ * would be that one of them silently stops: it would look exactly like
40
+ * "someone else holds it", which is the ordinary case on every instance but
41
+ * one, so nothing would be logged.
42
+ */
43
+ export function liveFeedLeaseName(domainId: string, instanceId: string, source?: string): string {
44
+ for (const [what, value] of [
45
+ ['domainId', domainId],
46
+ ['instanceId', instanceId]
47
+ ] as const) {
48
+ if (!value || !String(value).trim()) {
49
+ throw new Error(`cannot name a live feed lease without ${what} — two feeds would share one name`)
50
+ }
51
+ }
52
+
53
+ const named = source && String(source).trim()
54
+
55
+ return named ? `${PREFIX}::${domainId}::${instanceId}::${named}` : `${PREFIX}::${domainId}::${instanceId}`
56
+ }
@@ -0,0 +1,94 @@
1
+ /*
2
+ * **원본이 준 레코드를 트윈에 넣는다** — 밀어 받는 길과 backfill 이 같이 쓴다.
3
+ *
4
+ * ── 왜 한 곳에 두나 ─────────────────────────────────────────────────────────
5
+ * 이 몸통이 `routes.ts` 의 훅 안에 박혀 있었습니다. backfill 문을 만들면서 복사하면 두 길이 서로
6
+ * 다른 규칙으로 받게 되고, 한쪽만 수정되는 날이 옵니다.
7
+ *
8
+ * 실제로 그 위험이 이 자리에 있습니다. 유입 장부의 네 갈래(앉음 · 되풀이 · 어휘 · 거절)를
9
+ * 2026-09-05 에 고쳤는데, 그때 이 몸통이 두 벌이었다면 한쪽 셈만 닫혔을 것입니다.
10
+ *
11
+ * ── 커널을 직접 부르지 않는 쪽이 넘겨받는다 ────────────────────────────────
12
+ * 훅 판정(`reference-hook.ts`)은 이 함수를 인자로 받습니다. 그 파일이 엔진을 직접 부르면 테스트할
13
+ * 때 엔진이 필요해집니다. 그 규율은 그대로 두고, 이 파일이 엔진을 아는 유일한 자리가 됩니다.
14
+ */
15
+ import type { IngestResult } from './reference-hook.js'
16
+
17
+ import { TwinEngine } from '../../engine/index.js'
18
+ import { ingestCanonicalRecords } from '../../engine/canonical-ingest.js'
19
+ import { databaseCommandStore, settleEffects } from '../actuation/index.js'
20
+ import { shouldHaveTime } from '../../engine/event-time.js'
21
+ import { twinWarn } from '../../engine/log.js'
22
+
23
+ /**
24
+ * 레코드를 넣고 **네 갈래로 답한다.**
25
+ *
26
+ * 두 갈래로는 셈이 안 닫힙니다(인티그레이션 레인 실측 2026-09-05).
27
+ *
28
+ * ```
29
+ * 두 갈래 157 − 46 − 2 = 109 「설명 못 함」 진실은 되풀이 108 + 어휘 1
30
+ * 네 갈래 157 − 46 − 2 − 108 − 1 = 0
31
+ * ```
32
+ *
33
+ * 109 를 「사라졌다」로 내면 그날부터 아무도 그 수를 안 봅니다. 늘 참인 경고는 벽지가 됩니다.
34
+ *
35
+ * `vocabulary` 는 저널에 안 가는 어휘 요소의 수입니다. 사라진 것이 아닙니다.
36
+ *
37
+ * @param via 유입 장부에 남길 길 이름 — `hook`(밀어 받음) · `fill`(빠진 구간을 다시 받음)
38
+ */
39
+ export function liveIngest(
40
+ domainId: string,
41
+ instanceId: string,
42
+ records: unknown[],
43
+ via: string
44
+ ): IngestResult & { vocabulary: number; undated?: Record<string, number> } {
45
+ const { accepted, rejected, masterData, undated } = ingestCanonicalRecords(
46
+ records as any,
47
+ domainId,
48
+ new Date().toISOString(),
49
+ TwinEngine.factScope(domainId, instanceId)
50
+ )
51
+ /* 사건이 아닌 것은 상태만 세운다 — 두 길이 같은 규율이다(§`applyMasterData`). */
52
+ if (masterData.length) TwinEngine.applyMasterData(domainId, instanceId, masterData)
53
+ const { applied, duplicates } = TwinEngine.ingestLiveResult(domainId, instanceId, accepted, via)
54
+
55
+ /*
56
+ * **나간 지시와 잇는다** — 폐루프. 기다리지 않습니다: 훅의 응답이 이것 때문에 늦으면 보내는 쪽이
57
+ * 되풀이하고, 그러면 같은 관측이 두 번 옵니다. 실패해도 관측 유입은 멈추지 않습니다.
58
+ */
59
+ void settleEffects({ domainId, envelopes: accepted, store: databaseCommandStore() })
60
+
61
+ /*
62
+ * 커널이 받지 못한 수를 이름으로 남깁니다 — 트윈이 멈춘 사이 버려진 레코드를 「통과」로 세면
63
+ * 화면이 100% 라고 말합니다. 중복으로 걸러진 것은 버려진 것이 아니므로 빼고 셉니다.
64
+ */
65
+ const undelivered = Math.max(0, accepted.length - applied - duplicates)
66
+ TwinEngine.recordIngestResult(domainId, instanceId, records.length, rejected, Date.now(), undelivered, masterData.length)
67
+
68
+ /*
69
+ * 떨어진 것을 **수가 아니라 목록으로** 넘깁니다 — 응답에 실어야 보내는 쪽이 그것만 따로 둘 수
70
+ * 있습니다. 수만 주면 배치 전체를 다시 보내거나 전부 버리는 두 갈래밖에 없습니다.
71
+ */
72
+ /*
73
+ * **표에 있는 type 인데 시각을 못 읽었으면 그것은 결함이다.** 그 fact 는 자기 시각을 싣기로 되어
74
+ * 있는데 안 실려 온 것이고, 그대로 두면 ingest 시각이 그 사실의 시각으로 저장된다.
75
+ *
76
+ * 표에 없는 type 은 세기만 한다 — 절대값 snapshot 은 polling 순간이 맞는 시각이다.
77
+ */
78
+ const wrong = Object.entries(undated ?? {}).filter(([type]) => shouldHaveTime(type))
79
+ if (wrong.length) {
80
+ twinWarn(
81
+ `[twin-ingest] "${instanceId}" — 시각을 실어야 하는 fact 가 시각 없이 왔습니다: ` +
82
+ wrong.map(([t, n]) => `${t} ${n}건`).join(' · ') +
83
+ `. 그 사실들은 지금 시각으로 저널에 앉습니다`
84
+ )
85
+ }
86
+
87
+ return {
88
+ applied,
89
+ duplicates,
90
+ vocabulary: masterData.length,
91
+ rejected,
92
+ ...(undated && Object.keys(undated).length ? { undated } : {})
93
+ }
94
+ }