@things-factory/headless-twin 10.0.15 → 10.0.18

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 (513) hide show
  1. package/dist-server/engine/attention-digest.d.ts +4 -4
  2. package/dist-server/engine/attention-digest.js +7 -7
  3. package/dist-server/engine/attention-digest.js.map +1 -1
  4. package/dist-server/engine/canonical-ingest.d.ts +18 -82
  5. package/dist-server/engine/canonical-ingest.js +183 -18
  6. package/dist-server/engine/canonical-ingest.js.map +1 -1
  7. package/dist-server/engine/command-routing.js +1 -1
  8. package/dist-server/engine/command-routing.js.map +1 -1
  9. package/dist-server/engine/declared-stimulus.js +1 -1
  10. package/dist-server/engine/declared-stimulus.js.map +1 -1
  11. package/dist-server/engine/energy-topology.js +4 -4
  12. package/dist-server/engine/energy-topology.js.map +1 -1
  13. package/dist-server/engine/ingest-dedupe.d.ts +38 -0
  14. package/dist-server/engine/ingest-dedupe.js +145 -0
  15. package/dist-server/engine/ingest-dedupe.js.map +1 -0
  16. package/dist-server/engine/ingest-health.d.ts +90 -10
  17. package/dist-server/engine/ingest-health.js +125 -16
  18. package/dist-server/engine/ingest-health.js.map +1 -1
  19. package/dist-server/engine/integration-probes.d.ts +3 -3
  20. package/dist-server/engine/integration-probes.js +6 -6
  21. package/dist-server/engine/integration-probes.js.map +1 -1
  22. package/dist-server/engine/kpi-baseline.js +4 -4
  23. package/dist-server/engine/kpi-baseline.js.map +1 -1
  24. package/dist-server/engine/kpi-fold.d.ts +212 -14
  25. package/dist-server/engine/kpi-fold.js +374 -21
  26. package/dist-server/engine/kpi-fold.js.map +1 -1
  27. package/dist-server/engine/kpi-query.d.ts +11 -11
  28. package/dist-server/engine/kpi-query.js +281 -36
  29. package/dist-server/engine/kpi-query.js.map +1 -1
  30. package/dist-server/engine/live-feed-registry.d.ts +1 -1
  31. package/dist-server/engine/live-feed-registry.js +1 -1
  32. package/dist-server/engine/live-feed-registry.js.map +1 -1
  33. package/dist-server/engine/load-meter.d.ts +1 -1
  34. package/dist-server/engine/load-meter.js.map +1 -1
  35. package/dist-server/engine/local-declarations.d.ts +7 -7
  36. package/dist-server/engine/local-declarations.js +15 -14
  37. package/dist-server/engine/local-declarations.js.map +1 -1
  38. package/dist-server/engine/log.d.ts +1 -1
  39. package/dist-server/engine/log.js +1 -1
  40. package/dist-server/engine/log.js.map +1 -1
  41. package/dist-server/engine/measured-estimator.js +1 -1
  42. package/dist-server/engine/measured-estimator.js.map +1 -1
  43. package/dist-server/engine/measured-yield.js +1 -1
  44. package/dist-server/engine/measured-yield.js.map +1 -1
  45. package/dist-server/engine/model-basis.d.ts +1 -1
  46. package/dist-server/engine/model-basis.js +1 -1
  47. package/dist-server/engine/model-basis.js.map +1 -1
  48. package/dist-server/engine/model-gap.d.ts +3 -3
  49. package/dist-server/engine/model-gap.js +1 -1
  50. package/dist-server/engine/model-gap.js.map +1 -1
  51. package/dist-server/engine/model-vocabulary.js +3 -3
  52. package/dist-server/engine/model-vocabulary.js.map +1 -1
  53. package/dist-server/engine/oee-accumulator.d.ts +6 -5
  54. package/dist-server/engine/oee-accumulator.js +6 -5
  55. package/dist-server/engine/oee-accumulator.js.map +1 -1
  56. package/dist-server/engine/property-effects.js +2 -2
  57. package/dist-server/engine/property-effects.js.map +1 -1
  58. package/dist-server/engine/read-time.d.ts +22 -0
  59. package/dist-server/engine/read-time.js +81 -0
  60. package/dist-server/engine/read-time.js.map +1 -0
  61. package/dist-server/engine/restart-policy.js +1 -1
  62. package/dist-server/engine/restart-policy.js.map +1 -1
  63. package/dist-server/engine/runtime-key.js +1 -1
  64. package/dist-server/engine/runtime-key.js.map +1 -1
  65. package/dist-server/engine/spec-coverage.d.ts +2 -2
  66. package/dist-server/engine/spec-coverage.js +1 -1
  67. package/dist-server/engine/spec-coverage.js.map +1 -1
  68. package/dist-server/engine/stage-path.d.ts +4 -4
  69. package/dist-server/engine/stage-path.js +1 -1
  70. package/dist-server/engine/stage-path.js.map +1 -1
  71. package/dist-server/engine/state-axes.d.ts +1 -1
  72. package/dist-server/engine/state-axes.js +2 -2
  73. package/dist-server/engine/state-axes.js.map +1 -1
  74. package/dist-server/engine/travel-estimator.d.ts +2 -2
  75. package/dist-server/engine/travel-estimator.js +1 -1
  76. package/dist-server/engine/travel-estimator.js.map +1 -1
  77. package/dist-server/engine/twin-engine.d.ts +307 -54
  78. package/dist-server/engine/twin-engine.js +822 -196
  79. package/dist-server/engine/twin-engine.js.map +1 -1
  80. package/dist-server/engine/warm-start.d.ts +2 -2
  81. package/dist-server/engine/warm-start.js +5 -5
  82. package/dist-server/engine/warm-start.js.map +1 -1
  83. package/dist-server/index.js +1 -1
  84. package/dist-server/index.js.map +1 -1
  85. package/dist-server/migrations/1786000000000-RenameTwinInstanceBoardToModel.js +1 -1
  86. package/dist-server/migrations/1786000000000-RenameTwinInstanceBoardToModel.js.map +1 -1
  87. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js +3 -3
  88. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js.map +1 -1
  89. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js +2 -2
  90. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js.map +1 -1
  91. package/dist-server/migrations/1786300000000-IndexEventTypeByTime.d.ts +5 -0
  92. package/dist-server/migrations/1786300000000-IndexEventTypeByTime.js +54 -0
  93. package/dist-server/migrations/1786300000000-IndexEventTypeByTime.js.map +1 -0
  94. package/dist-server/migrations/1786400000000-IndexEventCreatedAt.d.ts +5 -0
  95. package/dist-server/migrations/1786400000000-IndexEventCreatedAt.js +44 -0
  96. package/dist-server/migrations/1786400000000-IndexEventCreatedAt.js.map +1 -0
  97. package/dist-server/migrations/1786500000000-CreateTwinSubjectEvents.d.ts +6 -0
  98. package/dist-server/migrations/1786500000000-CreateTwinSubjectEvents.js +84 -0
  99. package/dist-server/migrations/1786500000000-CreateTwinSubjectEvents.js.map +1 -0
  100. package/dist-server/migrations/index.js +7 -1
  101. package/dist-server/migrations/index.js.map +1 -1
  102. package/dist-server/routes.d.ts +1 -0
  103. package/dist-server/routes.js +69 -5
  104. package/dist-server/routes.js.map +1 -1
  105. package/dist-server/service/index.d.ts +2 -2
  106. package/dist-server/service/index.js +30 -24
  107. package/dist-server/service/index.js.map +1 -1
  108. package/dist-server/service/reference/control-routing.d.ts +1 -1
  109. package/dist-server/service/reference/control-routing.js +1 -1
  110. package/dist-server/service/reference/control-routing.js.map +1 -1
  111. package/dist-server/service/reference/discovery-result.d.ts +2 -2
  112. package/dist-server/service/reference/discovery-result.js +3 -3
  113. package/dist-server/service/reference/discovery-result.js.map +1 -1
  114. package/dist-server/service/reference/hook-contract.d.ts +43 -0
  115. package/dist-server/service/reference/hook-contract.js +59 -0
  116. package/dist-server/service/reference/hook-contract.js.map +1 -0
  117. package/dist-server/service/reference/ingest-space.d.ts +1 -1
  118. package/dist-server/service/reference/ingest-space.js +4 -4
  119. package/dist-server/service/reference/ingest-space.js.map +1 -1
  120. package/dist-server/service/reference/knob-defaults.d.ts +1 -1
  121. package/dist-server/service/reference/knob-defaults.js +4 -4
  122. package/dist-server/service/reference/knob-defaults.js.map +1 -1
  123. package/dist-server/service/reference/reference-adapter.d.ts +116 -9
  124. package/dist-server/service/reference/reference-adapter.js +27 -0
  125. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  126. package/dist-server/service/reference/reference-hook.d.ts +20 -0
  127. package/dist-server/service/reference/reference-hook.js +133 -0
  128. package/dist-server/service/reference/reference-hook.js.map +1 -0
  129. package/dist-server/service/reference/reference-live.d.ts +2 -2
  130. package/dist-server/service/reference/reference-live.js +76 -13
  131. package/dist-server/service/reference/reference-live.js.map +1 -1
  132. package/dist-server/service/reference/reference-master.d.ts +25 -16
  133. package/dist-server/service/reference/reference-master.js +35 -13
  134. package/dist-server/service/reference/reference-master.js.map +1 -1
  135. package/dist-server/service/reference/reference-resolver.d.ts +1 -1
  136. package/dist-server/service/reference/reference-resolver.js +12 -12
  137. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  138. package/dist-server/service/reference/template-registry.d.ts +1 -1
  139. package/dist-server/service/reference/template-registry.js.map +1 -1
  140. package/dist-server/service/reference/twin-reference.d.ts +3 -3
  141. package/dist-server/service/reference/twin-reference.js.map +1 -1
  142. package/dist-server/service/twin-attention/twin-attention-query.d.ts +1 -1
  143. package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
  144. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  145. package/dist-server/service/twin-audit/command-audit.d.ts +1 -1
  146. package/dist-server/service/twin-audit/command-audit.js.map +1 -1
  147. package/dist-server/service/twin-audit/twin-audit-event.d.ts +1 -1
  148. package/dist-server/service/twin-audit/twin-audit-event.js.map +1 -1
  149. package/dist-server/service/twin-audit/twin-audit-query.js +1 -1
  150. package/dist-server/service/twin-audit/twin-audit-query.js.map +1 -1
  151. package/dist-server/service/twin-backfill/backfill-period-facts.d.ts +24 -0
  152. package/dist-server/service/twin-backfill/backfill-period-facts.js +96 -0
  153. package/dist-server/service/twin-backfill/backfill-period-facts.js.map +1 -0
  154. package/dist-server/service/twin-backfill/backfill-shape.d.ts +34 -0
  155. package/dist-server/service/twin-backfill/backfill-shape.js +75 -0
  156. package/dist-server/service/twin-backfill/backfill-shape.js.map +1 -0
  157. package/dist-server/service/twin-backfill/index.d.ts +2 -0
  158. package/dist-server/service/twin-backfill/index.js +6 -0
  159. package/dist-server/service/twin-backfill/index.js.map +1 -0
  160. package/dist-server/service/twin-backfill/twin-backfill-resolver.d.ts +10 -0
  161. package/dist-server/service/twin-backfill/twin-backfill-resolver.js +44 -0
  162. package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +1 -0
  163. package/dist-server/service/twin-control/twin-control-mutation.js +3 -3
  164. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  165. package/dist-server/service/twin-event/twin-event-keys.d.ts +1 -1
  166. package/dist-server/service/twin-event/twin-event-keys.js +2 -2
  167. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  168. package/dist-server/service/twin-event/twin-event-type.d.ts +1 -0
  169. package/dist-server/service/twin-event/twin-event-type.js +8 -1
  170. package/dist-server/service/twin-event/twin-event-type.js.map +1 -1
  171. package/dist-server/service/twin-event/twin-event.d.ts +2 -2
  172. package/dist-server/service/twin-event/twin-event.js +32 -4
  173. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  174. package/dist-server/service/twin-forecast/forecast-metrics.js +1 -1
  175. package/dist-server/service/twin-forecast/forecast-metrics.js.map +1 -1
  176. package/dist-server/service/twin-forecast/gap-analytics.d.ts +1 -1
  177. package/dist-server/service/twin-forecast/gap-analytics.js.map +1 -1
  178. package/dist-server/service/twin-forecast/twin-forecast-query.js +29 -11
  179. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  180. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.d.ts +1 -1
  181. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js +1 -1
  182. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js.map +1 -1
  183. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js +1 -1
  184. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js.map +1 -1
  185. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  186. package/dist-server/service/twin-journal/event-type-split-shape.d.ts +50 -0
  187. package/dist-server/service/twin-journal/event-type-split-shape.js +111 -0
  188. package/dist-server/service/twin-journal/event-type-split-shape.js.map +1 -0
  189. package/dist-server/service/twin-journal/event-type-split.d.ts +15 -0
  190. package/dist-server/service/twin-journal/event-type-split.js +102 -0
  191. package/dist-server/service/twin-journal/event-type-split.js.map +1 -0
  192. package/dist-server/service/twin-journal/journal-order.d.ts +15 -0
  193. package/dist-server/service/twin-journal/journal-order.js +17 -0
  194. package/dist-server/service/twin-journal/journal-order.js.map +1 -0
  195. package/dist-server/service/twin-journal/twin-journal-query.d.ts +5 -5
  196. package/dist-server/service/twin-journal/twin-journal-query.js +232 -21
  197. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  198. package/dist-server/service/twin-lifecycle/domain-catalog.js +2 -1
  199. package/dist-server/service/twin-lifecycle/domain-catalog.js.map +1 -1
  200. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +27 -1
  201. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +59 -3
  202. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  203. package/dist-server/service/twin-metrics/twin-metrics-query.js +14 -1
  204. package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
  205. package/dist-server/service/twin-model/axis-journal-evidence.js +1 -1
  206. package/dist-server/service/twin-model/axis-journal-evidence.js.map +1 -1
  207. package/dist-server/service/twin-model/epcis-coverage.js +34 -3
  208. package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
  209. package/dist-server/service/twin-model/iec61850-coverage.js +1 -1
  210. package/dist-server/service/twin-model/iec61850-coverage.js.map +1 -1
  211. package/dist-server/service/twin-model/isa95-coverage.d.ts +12 -9
  212. package/dist-server/service/twin-model/isa95-coverage.js +33 -9
  213. package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
  214. package/dist-server/service/twin-model/item-ref.js +2 -2
  215. package/dist-server/service/twin-model/item-ref.js.map +1 -1
  216. package/dist-server/service/twin-model/name-index.d.ts +1 -1
  217. package/dist-server/service/twin-model/name-index.js +2 -2
  218. package/dist-server/service/twin-model/name-index.js.map +1 -1
  219. package/dist-server/service/twin-model/project-structure.d.ts +1 -1
  220. package/dist-server/service/twin-model/project-structure.js +7 -6
  221. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  222. package/dist-server/service/twin-model/twin-equipment.d.ts +2 -2
  223. package/dist-server/service/twin-model/twin-equipment.js +3 -3
  224. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  225. package/dist-server/service/twin-model/twin-lineage-query.js +16 -5
  226. package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
  227. package/dist-server/service/twin-model/twin-location.d.ts +1 -1
  228. package/dist-server/service/twin-model/twin-location.js.map +1 -1
  229. package/dist-server/service/twin-model/twin-model-item-query.d.ts +3 -3
  230. package/dist-server/service/twin-model/twin-model-item-query.js +15 -15
  231. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  232. package/dist-server/service/twin-model/twin-model-mutation.d.ts +2 -2
  233. package/dist-server/service/twin-model/twin-model-mutation.js +4 -4
  234. package/dist-server/service/twin-model/twin-model-mutation.js.map +1 -1
  235. package/dist-server/service/twin-model/twin-model-query.js +51 -30
  236. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  237. package/dist-server/service/twin-model/twin-model-tree-query.js +23 -1
  238. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  239. package/dist-server/service/twin-model/twin-operation.d.ts +1 -1
  240. package/dist-server/service/twin-model/twin-operation.js +1 -1
  241. package/dist-server/service/twin-model/twin-operation.js.map +1 -1
  242. package/dist-server/service/twin-space/move-space.js +2 -2
  243. package/dist-server/service/twin-space/move-space.js.map +1 -1
  244. package/dist-server/service/twin-space/representation-role.d.ts +2 -0
  245. package/dist-server/service/twin-space/representation-role.js +18 -0
  246. package/dist-server/service/twin-space/representation-role.js.map +1 -0
  247. package/dist-server/service/twin-space/space-integrity.d.ts +1 -1
  248. package/dist-server/service/twin-space/space-integrity.js.map +1 -1
  249. package/dist-server/service/twin-space/twin-space-representation.d.ts +2 -0
  250. package/dist-server/service/twin-space/twin-space-representation.js +16 -0
  251. package/dist-server/service/twin-space/twin-space-representation.js.map +1 -1
  252. package/dist-server/service/twin-space/twin-space-resolver.d.ts +2 -2
  253. package/dist-server/service/twin-space/twin-space-resolver.js +37 -8
  254. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  255. package/dist-server/service/twin-structure/twin-structure.js +3 -3
  256. package/dist-server/service/twin-structure/twin-structure.js.map +1 -1
  257. package/dist-server/service/twin-subject/event-subjects.d.ts +43 -0
  258. package/dist-server/service/twin-subject/event-subjects.js +137 -0
  259. package/dist-server/service/twin-subject/event-subjects.js.map +1 -0
  260. package/dist-server/service/twin-subject/index.d.ts +9 -0
  261. package/dist-server/service/twin-subject/index.js +16 -0
  262. package/dist-server/service/twin-subject/index.js.map +1 -0
  263. package/dist-server/service/twin-subject/item-resolver.d.ts +10 -0
  264. package/dist-server/service/twin-subject/item-resolver.js +93 -0
  265. package/dist-server/service/twin-subject/item-resolver.js.map +1 -0
  266. package/dist-server/service/twin-subject/subject-query-shape.d.ts +22 -0
  267. package/dist-server/service/twin-subject/subject-query-shape.js +88 -0
  268. package/dist-server/service/twin-subject/subject-query-shape.js.map +1 -0
  269. package/dist-server/service/twin-subject/twin-subject-backfill.d.ts +23 -0
  270. package/dist-server/service/twin-subject/twin-subject-backfill.js +159 -0
  271. package/dist-server/service/twin-subject/twin-subject-backfill.js.map +1 -0
  272. package/dist-server/service/twin-subject/twin-subject-event.d.ts +14 -0
  273. package/dist-server/service/twin-subject/twin-subject-event.js +111 -0
  274. package/dist-server/service/twin-subject/twin-subject-event.js.map +1 -0
  275. package/dist-server/service/twin-subject/twin-subject-fast-path.d.ts +39 -0
  276. package/dist-server/service/twin-subject/twin-subject-fast-path.js +295 -0
  277. package/dist-server/service/twin-subject/twin-subject-fast-path.js.map +1 -0
  278. package/dist-server/service/twin-subject/twin-subject-query.d.ts +27 -0
  279. package/dist-server/service/twin-subject/twin-subject-query.js +232 -0
  280. package/dist-server/service/twin-subject/twin-subject-query.js.map +1 -0
  281. package/dist-server/service/twin-subject/twin-subject-writer.d.ts +27 -0
  282. package/dist-server/service/twin-subject/twin-subject-writer.js +166 -0
  283. package/dist-server/service/twin-subject/twin-subject-writer.js.map +1 -0
  284. package/dist-server/service/twin-target/twin-target-resolver.js +1 -1
  285. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -1
  286. package/dist-shared/entity-delta.d.ts +1 -1
  287. package/dist-shared/entity-delta.js +4 -4
  288. package/dist-shared/entity-delta.js.map +1 -1
  289. package/dist-shared/touched-items.js +2 -2
  290. package/dist-shared/touched-items.js.map +1 -1
  291. package/dist-shared/twin-level.js +1 -1
  292. package/dist-shared/twin-level.js.map +1 -1
  293. package/package.json +7 -6
  294. package/server/engine/attention-digest.ts +9 -9
  295. package/server/engine/canonical-ingest.ts +202 -87
  296. package/server/engine/command-routing.ts +1 -1
  297. package/server/engine/declared-stimulus.ts +1 -1
  298. package/server/engine/energy-topology.ts +2 -2
  299. package/server/engine/ingest-dedupe.ts +149 -0
  300. package/server/engine/ingest-health.ts +171 -22
  301. package/server/engine/integration-probes.ts +7 -7
  302. package/server/engine/kpi-baseline.ts +4 -4
  303. package/server/engine/kpi-fold.ts +592 -41
  304. package/server/engine/kpi-query.ts +307 -48
  305. package/server/engine/live-feed-registry.ts +1 -1
  306. package/server/engine/load-meter.ts +1 -1
  307. package/server/engine/local-declarations.ts +15 -14
  308. package/server/engine/log.ts +1 -1
  309. package/server/engine/measured-estimator.ts +1 -1
  310. package/server/engine/measured-yield.ts +1 -1
  311. package/server/engine/model-basis.ts +2 -2
  312. package/server/engine/model-gap.ts +3 -3
  313. package/server/engine/model-vocabulary.ts +2 -2
  314. package/server/engine/oee-accumulator.ts +8 -6
  315. package/server/engine/property-effects.ts +2 -2
  316. package/server/engine/read-time.ts +77 -0
  317. package/server/engine/restart-policy.ts +1 -1
  318. package/server/engine/runtime-key.ts +1 -1
  319. package/server/engine/spec-coverage.ts +3 -3
  320. package/server/engine/stage-path.ts +5 -5
  321. package/server/engine/state-axes.ts +2 -2
  322. package/server/engine/travel-estimator.ts +2 -2
  323. package/server/engine/twin-engine.ts +906 -205
  324. package/server/engine/warm-start.ts +6 -6
  325. package/server/index.ts +1 -1
  326. package/server/migrations/1786000000000-RenameTwinInstanceBoardToModel.ts +1 -1
  327. package/server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.ts +3 -3
  328. package/server/migrations/1786200000000-CarryLiveFeedCursor.ts +2 -2
  329. package/server/migrations/1786300000000-IndexEventTypeByTime.ts +48 -0
  330. package/server/migrations/1786400000000-IndexEventCreatedAt.ts +38 -0
  331. package/server/migrations/1786500000000-CreateTwinSubjectEvents.ts +93 -0
  332. package/server/migrations/index.ts +7 -1
  333. package/server/routes.ts +73 -5
  334. package/server/service/index.ts +7 -1
  335. package/server/service/reference/control-routing.ts +1 -1
  336. package/server/service/reference/discovery-result.ts +3 -3
  337. package/server/service/reference/hook-contract.ts +103 -0
  338. package/server/service/reference/ingest-space.ts +4 -4
  339. package/server/service/reference/knob-defaults.ts +4 -4
  340. package/server/service/reference/reference-adapter.ts +145 -10
  341. package/server/service/reference/reference-hook.ts +145 -0
  342. package/server/service/reference/reference-live.ts +87 -15
  343. package/server/service/reference/reference-master.ts +51 -20
  344. package/server/service/reference/reference-resolver.ts +11 -11
  345. package/server/service/reference/template-registry.ts +1 -1
  346. package/server/service/reference/twin-reference.ts +3 -3
  347. package/server/service/twin-attention/twin-attention-query.ts +1 -1
  348. package/server/service/twin-audit/command-audit.ts +1 -1
  349. package/server/service/twin-audit/twin-audit-event.ts +2 -2
  350. package/server/service/twin-audit/twin-audit-query.ts +1 -1
  351. package/server/service/twin-backfill/backfill-period-facts.ts +168 -0
  352. package/server/service/twin-backfill/backfill-shape.ts +86 -0
  353. package/server/service/twin-backfill/index.ts +3 -0
  354. package/server/service/twin-backfill/twin-backfill-resolver.ts +35 -0
  355. package/server/service/twin-control/twin-control-mutation.ts +2 -2
  356. package/server/service/twin-event/twin-event-keys.ts +2 -2
  357. package/server/service/twin-event/twin-event-type.ts +18 -1
  358. package/server/service/twin-event/twin-event.ts +32 -6
  359. package/server/service/twin-forecast/forecast-metrics.ts +1 -1
  360. package/server/service/twin-forecast/gap-analytics.ts +1 -1
  361. package/server/service/twin-forecast/twin-forecast-query.ts +30 -11
  362. package/server/service/twin-ingest-window/twin-ingest-window-query.ts +1 -1
  363. package/server/service/twin-ingest-window/twin-ingest-window-writer.ts +1 -1
  364. package/server/service/twin-instance/twin-instance.ts +4 -4
  365. package/server/service/twin-journal/event-type-split-shape.ts +112 -0
  366. package/server/service/twin-journal/event-type-split.ts +144 -0
  367. package/server/service/twin-journal/journal-order.ts +20 -0
  368. package/server/service/twin-journal/twin-journal-query.ts +224 -20
  369. package/server/service/twin-lifecycle/domain-catalog.ts +2 -1
  370. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +58 -4
  371. package/server/service/twin-metrics/twin-metrics-query.ts +14 -1
  372. package/server/service/twin-model/axis-journal-evidence.ts +1 -1
  373. package/server/service/twin-model/epcis-coverage.ts +34 -3
  374. package/server/service/twin-model/iec61850-coverage.ts +1 -1
  375. package/server/service/twin-model/isa95-coverage.ts +43 -16
  376. package/server/service/twin-model/item-ref.ts +2 -2
  377. package/server/service/twin-model/name-index.ts +2 -2
  378. package/server/service/twin-model/project-structure.ts +7 -6
  379. package/server/service/twin-model/twin-equipment.ts +16 -4
  380. package/server/service/twin-model/twin-lineage-query.ts +16 -5
  381. package/server/service/twin-model/twin-location.ts +1 -1
  382. package/server/service/twin-model/twin-model-item-query.ts +15 -15
  383. package/server/service/twin-model/twin-model-mutation.ts +4 -4
  384. package/server/service/twin-model/twin-model-query.ts +49 -28
  385. package/server/service/twin-model/twin-model-tree-query.ts +26 -2
  386. package/server/service/twin-model/twin-operation.ts +2 -2
  387. package/server/service/twin-space/move-space.ts +3 -3
  388. package/server/service/twin-space/representation-role.ts +15 -0
  389. package/server/service/twin-space/space-integrity.ts +1 -1
  390. package/server/service/twin-space/twin-space-representation.ts +37 -0
  391. package/server/service/twin-space/twin-space-resolver.ts +40 -8
  392. package/server/service/twin-structure/twin-structure.ts +3 -3
  393. package/server/service/twin-subject/event-subjects.ts +155 -0
  394. package/server/service/twin-subject/index.ts +10 -0
  395. package/server/service/twin-subject/item-resolver.ts +92 -0
  396. package/server/service/twin-subject/subject-query-shape.ts +84 -0
  397. package/server/service/twin-subject/twin-subject-backfill.ts +196 -0
  398. package/server/service/twin-subject/twin-subject-event.ts +101 -0
  399. package/server/service/twin-subject/twin-subject-fast-path.ts +346 -0
  400. package/server/service/twin-subject/twin-subject-query.ts +210 -0
  401. package/server/service/twin-subject/twin-subject-writer.ts +189 -0
  402. package/server/service/twin-target/twin-target-resolver.ts +1 -1
  403. package/shared/entity-delta.ts +4 -4
  404. package/shared/touched-items.ts +2 -2
  405. package/shared/twin-level.ts +1 -1
  406. package/test/adopt-structure-live.test.ts +3 -3
  407. package/test/aggregate-not-findone.test.ts +112 -0
  408. package/test/attention-digest.test.ts +6 -6
  409. package/test/axis-read.test.ts +6 -5
  410. package/test/backfill-shape.test.ts +89 -0
  411. package/test/boot-resume.test.ts +20 -7
  412. package/test/broadcast-cost-baseline.test.ts +2 -1
  413. package/test/broadcast-period.test.ts +2 -2
  414. package/test/broadcast-queue-overflow.test.ts +244 -0
  415. package/test/canonical-ingest-vocabularies.test.ts +259 -8
  416. package/test/canonical-quantity-door.test.ts +2 -2
  417. package/test/capability-mapping.test.ts +1 -1
  418. package/test/checkpoint-refuses-empty.test.ts +2 -2
  419. package/test/connector-capability-declaration.test.ts +88 -0
  420. package/test/contract-layer-guard.test.ts +48 -0
  421. package/test/control-capability.test.ts +1 -1
  422. package/test/cursor-stall-not-read-failure.test.ts +2 -2
  423. package/test/declaration-reaches-model.test.ts +7 -5
  424. package/test/declared-stimulus.test.ts +4 -4
  425. package/test/discovery-result.test.ts +2 -2
  426. package/test/entity-delta.test.ts +4 -4
  427. package/test/equipment-identity-reaches.test.ts +57 -0
  428. package/test/event-subjects.test.ts +147 -0
  429. package/test/event-time-column.test.ts +3 -3
  430. package/test/event-type-split.test.ts +155 -0
  431. package/test/fact-scope-wiring.test.ts +96 -0
  432. package/test/fold-gap-speaks.test.ts +53 -0
  433. package/test/forecast-metrics.test.ts +1 -1
  434. package/test/forecast-tuning.test.ts +1 -1
  435. package/test/generated-not-counted.test.ts +99 -0
  436. package/test/ingest-bench.test.ts +2 -1
  437. package/test/ingest-health-engine.test.ts +2 -2
  438. package/test/ingest-health-wiring.test.ts +7 -5
  439. package/test/ingest-health.test.ts +6 -6
  440. package/test/ingest-history.test.ts +2 -2
  441. package/test/ingest-idempotent.test.ts +132 -0
  442. package/test/ingest-space.test.ts +3 -3
  443. package/test/ingest-wiring-guard.test.ts +197 -0
  444. package/test/integration-probes.test.ts +3 -3
  445. package/test/integration-runner.test.ts +1 -1
  446. package/test/item-ref.test.ts +1 -1
  447. package/test/journal-order-axis.test.ts +80 -0
  448. package/test/journal-read-discipline.test.ts +16 -6
  449. package/test/journal-retention.test.ts +171 -29
  450. package/test/journal-sort-axis.test.ts +1 -1
  451. package/test/journal-write-door.test.ts +3 -3
  452. package/test/journal-write-trend.test.ts +1 -1
  453. package/test/kernel-kind-guard.test.ts +3 -3
  454. package/test/knob-defaults.test.ts +1 -1
  455. package/test/kpi-baseline-db.test.ts +2 -2
  456. package/test/kpi-fold.test.ts +266 -14
  457. package/test/kpi-query-bench.test.ts +3 -3
  458. package/test/live-cursor-wiring.test.ts +6 -6
  459. package/test/live-kernel-facts.test.ts +11 -11
  460. package/test/live-mirror-parity.test.ts +9 -6
  461. package/test/load-meter.test.ts +1 -1
  462. package/test/local-declarations.test.ts +13 -12
  463. package/test/log-stamp.test.ts +1 -1
  464. package/test/master-to-twin.test.ts +2 -2
  465. package/test/measured-yield.test.ts +2 -2
  466. package/test/merge-streams.test.ts +70 -0
  467. package/test/mirror-resumes-from-checkpoint.test.ts +13 -13
  468. package/test/model-basis.test.ts +2 -2
  469. package/test/model-gap.test.ts +4 -4
  470. package/test/model-vocabulary.test.ts +2 -2
  471. package/test/move-space.test.ts +1 -1
  472. package/test/oee-accumulator.test.ts +20 -5
  473. package/test/operational-vocabulary.test.ts +2 -2
  474. package/test/project-structure-db.test.ts +2 -2
  475. package/test/projection-reaches-screen.test.ts +7 -2
  476. package/test/projection-reads-declared.test.ts +109 -0
  477. package/test/property-effects.test.ts +10 -6
  478. package/test/read-failure-ledger.test.ts +106 -0
  479. package/test/read-failure-visible.test.ts +7 -7
  480. package/test/read-time.test.ts +118 -0
  481. package/test/reference-grounding.test.ts +1 -1
  482. package/test/reference-hook.test.ts +86 -0
  483. package/test/registry-key-guard.test.ts +1 -1
  484. package/test/rename-twin.test.ts +81 -0
  485. package/test/restart-policy.test.ts +3 -3
  486. package/test/resync-origin-site.test.ts +3 -3
  487. package/test/revision-axis.test.ts +14 -8
  488. package/test/runtime-key.test.ts +3 -3
  489. package/test/scale-twin-bench.test.ts +2 -1
  490. package/test/snapshot-freshness.test.ts +4 -4
  491. package/test/source-outcome-audit.test.ts +1 -1
  492. package/test/space-integrity.test.ts +1 -1
  493. package/test/space-representation.test.ts +66 -0
  494. package/test/space-scope-includes-stopped.test.ts +55 -0
  495. package/test/spec-coverage.test.ts +1 -1
  496. package/test/standard-coverage.test.ts +19 -21
  497. package/test/status-tally.test.ts +1 -1
  498. package/test/structure-revision-db.test.ts +7 -7
  499. package/test/subject-fast-path.test.ts +148 -0
  500. package/test/subject-read-model.test.ts +175 -0
  501. package/test/touched-items.test.ts +2 -2
  502. package/test/twin-audit.test.ts +1 -1
  503. package/test/twin-event-keys.test.ts +1 -1
  504. package/test/twin-model-item-db.test.ts +5 -5
  505. package/test/twin-model-tree-db.test.ts +35 -2
  506. package/test/twin-origin-resync.test.ts +1 -1
  507. package/test/vocabulary-guard.test.ts +1 -1
  508. package/test/warm-start-seam.test.ts +1 -1
  509. package/test/warm-start.test.ts +6 -6
  510. package/test/withheld-door.test.ts +2 -2
  511. package/test/yield-loop.test.ts +4 -4
  512. package/tsconfig.shared.tsbuildinfo +1 -1
  513. package/tsconfig.tsbuildinfo +1 -1
@@ -8,15 +8,19 @@
8
8
  */
9
9
 
10
10
  import { twinLog, twinWarn, twinError } from './log.js'
11
+ import { hydratedDate } from './read-time.js'
11
12
  import { pubsub, getRepository, Domain } from '@things-factory/shell'
12
13
  /* 저널을 자를 조건은 SQL 이 안다 — 관용구만 쓴다(원시 SQL 은 5개 드라이버에서 갈라진다). */
13
14
  import { And, IsNull, LessThanOrEqual, MoreThan } from 'typeorm'
14
15
  import { cacheService } from '@things-factory/cache-service'
15
16
 
16
17
  import type { ReducerCheckpoint } from '@operato/twin-kernel'
18
+ import type { VocabularyElement } from '@operato/ops-contract'
17
19
  import type { OeeCheckpoint } from './oee-accumulator.js'
18
20
  import { TwinEvent } from '../service/twin-event/twin-event.js'
19
21
  import { twinEventKeys } from '../service/twin-event/twin-event-keys.js'
22
+ import { writeSubjectRows, pruneSubjectRows } from '../service/twin-subject/twin-subject-writer.js'
23
+ import { itemResolverFor } from '../service/twin-subject/item-resolver.js'
20
24
  import { planLiveContinuity, planWarmStart, unwrapState } from './warm-start.js'
21
25
  import { applyDeclarationLayers } from './local-declarations.js'
22
26
  import { isOfDomain, parseRuntimeKey, runtimeKey } from './runtime-key.js'
@@ -60,8 +64,11 @@ import {
60
64
  recordReadFailure,
61
65
  clearCursorStall,
62
66
  clearReadFailure,
67
+ recordRecovered,
63
68
  recordCursorStall,
64
69
  recordWithheld,
70
+ recordIngestSource,
71
+ recordDuplicates,
65
72
  rollIngestWindow,
66
73
  /* 별칭 — 같은 이름의 정적 메서드와 헷갈리지 않게(그 메서드가 이것을 부른다). */
67
74
  recordJournalWrite as recordLedgerWrite,
@@ -69,11 +76,14 @@ import {
69
76
  type IngestLedger,
70
77
  type IngestWindow
71
78
  } from './ingest-health.js'
72
- import { EMS_PROPERTY } from '@operato/twin-kernel'
73
- import type { TwinKernel, TwinModelDef, StructureShift, SubscriptionMessage, TwinRuntime as TwinRuntimeType, CanonicalEnvelope } from '@operato/twin-kernel'
79
+ import { FactDeduper } from './ingest-dedupe.js'
80
+ import { EMS_PROPERTY } from '@operato/ops-contract'
81
+ import type { SubscriptionMessage, TwinRuntime as TwinRuntimeType } from '@operato/twin-kernel'
82
+ import type { TwinKernel, TwinModelDef, StructureShift, CanonicalEnvelope } from '@operato/ops-contract'
74
83
 
75
84
  /* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */
76
- const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT, validateScenario } = require('@operato/twin-kernel')
85
+ const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments } = require('@operato/twin-kernel')
86
+ const { readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT, validateScenario } = require('@operato/ops-contract')
77
87
 
78
88
  const KERNELS: Record<string, any> = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel, ems: EmsKernel }
79
89
 
@@ -118,11 +128,11 @@ function parseTime(v: unknown): number | null {
118
128
  * 종류 문자열 → 커널. **모르는 값이면 오류를 낸다.**
119
129
  *
120
130
  * 예전에는 표를 찾고 없으면 WmsKernel 로 떨어졌다. `kind` 는 검증 없는 자유 문자열(`@Arg('kind') kind: string`)
121
- * 이라 오타 하나·대소문자 하나로 야드/생산 트윈이 **조용히 창고 커널로 돌았다.** 오류가 없으니 화면에는
131
+ * 이라 오타 하나·대소문자 하나로 야드/생산 트윈이 **오류 없이 창고 커널로 돌았다.** 오류가 없으니 화면에는
122
132
  * 트윈이 정상으로 보이고, 안에서 도는 규칙만 다른 도메인의 것이다. 예측 경로가 가장 나쁘다 —
123
133
  * 야드의 미래를 창고 규칙으로 실행해 놓고 숫자만 뜬다.
124
134
  *
125
- * 틀린 공장을 조용히 띄우는 것보다 뜨지 않는 편이 낫다.
135
+ * 틀린 공장을 알리지 않고 띄우는 것보다 뜨지 않는 편이 낫다.
126
136
  */
127
137
  function kernelFor(kind: string): any {
128
138
  const K = KERNELS[kind]
@@ -137,16 +147,35 @@ let observeUnavailableWarned = false
137
147
  * **이 커널의 진실이 원본에서 온다고 선언한다** — 그리고 선언이 닿지 않았으면 말한다.
138
148
  *
139
149
  * ── 무엇이 틀렸나 (2026-08-21) ──────────────────────────────────────────────
140
- * 예전에는 `kernel.observe?.()` 였다. 옵셔널 호출이라 **메서드가 없으면 조용히 아무것도 하지 않는다.**
150
+ * 예전에는 `kernel.observe?.()` 였다. 옵셔널 호출이라 **메서드가 없으면 알리지 않고 아무것도 하지 않는다.**
141
151
  * 설치된 커널 0.7.39 에는 그 메서드가 없으므로, 지금 배포본에서는 미러가 관측 구동으로 선언되지
142
152
  * 않는다 — 그런데 코드를 읽으면 선언한 것처럼 보인다.
143
153
  *
144
154
  * 그 차이가 실제 거동을 가른다: 관측 구동은 원본의 빈틈을 받아들이고 세지만, 시뮬로 오인된 미러는
145
- * **멈춘다.** 즉 「관용이 켜진 줄 알았는데 안 켜져 있다」가 조용히 지나가고, 장애가 났을 때 원인이
155
+ * **멈춘다.** 즉 「관용이 켜진 줄 알았는데 안 켜져 있다」가 알리지 않고 지나가고, 장애가 났을 때 원인이
146
156
  * 커널에 있는 것처럼 보인다 — 원인은 버전이다.
147
157
  *
148
158
  * 그래서 옵셔널 호출을 없애고, 없으면 **한 번 경고한다.** 커널이 배포되면 이 경고는 사라진다.
149
159
  */
160
+ /**
161
+ * 저장된 기록을 **도는 동안과 같은 코드로** 처리하게 한다 (2026-08-27).
162
+ *
163
+ * ── 무엇이 틀렸나 ───────────────────────────────────────────────────────────
164
+ * 도는 동안 사건을 처리하는 것은 도메인 커널이다(`startLive` 가 `new Kernel(...)` 을 세운다). 그런데
165
+ * 저장된 기록으로 과거 시점 상태를 만드는 경로는 커널 종류를 넘기지 않아서 도메인 규칙 없이
166
+ * 처리했다. 그 결과 「어제 오후 3시에 이 발전소가 어땠나」를 물으면 발전량과 계량이 늘 비어 있었다 —
167
+ * 받아서 저장까지 해 둔 값인데 읽는 쪽이 건너뛴 것이다.
168
+ *
169
+ * 종류는 모델에 없다(등록부가 든다). 그래서 호스트가 말해 준다.
170
+ */
171
+ function replayOptions(reg: { kind?: string; model?: any } | undefined, domainId: string): any {
172
+ return {
173
+ kind: reg?.kind,
174
+ productionSpec: (reg?.model as any)?.productionSpec,
175
+ tenantId: domainId
176
+ }
177
+ }
178
+
150
179
  function declareObserved(kernel: any, what: string): void {
151
180
  if (typeof kernel?.observe === 'function') {
152
181
  kernel.observe()
@@ -191,7 +220,7 @@ interface TwinMetrics {
191
220
  /*
192
221
  * 재기동 정책은 **선언이고 기본값이 없다** — 정의·판정은 `engine/restart-policy.ts` 가 든다(ADR-0029 §2).
193
222
  *
194
- * 예전에는 여기에 `DEFAULT_REALITY_MODE = 'sim-experiment'` 가 있었고, 미선언이 조용히 **저널 초기화**로
223
+ * 예전에는 여기에 `DEFAULT_REALITY_MODE = 'sim-experiment'` 가 있었고, 미선언이 알리지 않고 **저널 초기화**로
195
224
  * 떨어졌다. 그 관용은 값이 하나 어긋나는 순간 이력을 지우는 길이 된다 — 그래서 없앴다.
196
225
  */
197
226
  export type { RestartPolicy } from './restart-policy.js'
@@ -210,12 +239,26 @@ interface InstanceRuntime {
210
239
  mode?: 'sim' | 'live' // 상태 드라이버 — sim=커널 tick / live=projector 미러(face2-inbound-live). 미지정=sim.
211
240
  /** 이 트윈이 선 현장 — 같은 현장의 트윈끼리 서로의 설비 상태를 읽을 수 있게 하는 열쇠. */
212
241
  spaceId?: string
242
+ /**
243
+ * 이 트윈의 모델 — **대상별 사건 목록에 품목 줄을 적을 때** 쓴다 (2026-08-27).
244
+ *
245
+ * 개체 식별자에서 선언된 품목을 찾는 판정이 그 트윈의 선언을 본다(§`item-resolver`). 사건마다
246
+ * 등록부를 읽으면 쓰기마다 DB 조회가 하나 늘어나므로, 세울 때 받은 모델을 들고 있는다.
247
+ */
248
+ model?: TwinModelDef
213
249
  /**
214
250
  * live 의 상태 접근 어댑터 — 이제 **관측 모드 커널**을 가리킨다(`kernel` 과 같은 것).
215
251
  * 이름은 소비처 호환으로 남았고, P3 에서 커널 어휘로 정리하면 사라진다.
216
252
  */
217
253
  projector?: any
218
254
  oee?: OeeAccumulator // live: OEE 계산 층(이벤트 누적 → equipment payload 보강). sim 은 커널이 계산.
255
+ /**
256
+ * 같은 사실이 두 번 오는 것을 거르는 자리(§`FactDeduper`).
257
+ *
258
+ * 트윈마다 하나다 — 정체는 트윈 안에서만 뜻이 있고, 트윈 하나가 되풀이 받는 것을 거르는 것이 목적이다.
259
+ * 프로세스가 새로 뜨면 비어 있다(그 사실은 `FactDeduper` 주석에 적어 두었다).
260
+ */
261
+ deduper?: FactDeduper
219
262
  unsub: () => void
220
263
  /**
221
264
  * 방금 인입한 봉투들 — 커널이 그것을 재방출할 때 **저널에 두 번 적지 않기** 위한 표시.
@@ -234,6 +277,8 @@ interface InstanceRuntime {
234
277
  * 하나도 건드리지 않았다」이고, 그 둘은 다른 사실이다.
235
278
  */
236
279
  dirtyItems?: Set<string>
280
+ /** 기동 이후 이 트윈이 발행한 건수 — 무엇이 대기열을 채우는지 값으로 말한다. */
281
+ publishedTotal?: number
237
282
  /** 다음 브로드캐스팅에서 전부 만들어야 하나 — 주기마다 한 번은 그물로 전부 만든다. */
238
283
  fullBroadcastDue?: boolean
239
284
  /** 전부 만들기까지 남은 창 수. */
@@ -283,7 +328,7 @@ export class TwinEngine {
283
328
  *
284
329
  * 판정을 예산(500ms)이 아니라 **굶김 문턱**으로 따로 둔다: 조금 느린 트윈은 계기판이 말하게 두고
285
330
  * (경고), 호스트를 굶기는 트윈만 멈춘다. 한 번으로 멈추지 않는다 — 웜스타트 직후의 첫 틱은 원래
286
- * 무겁다(복구한 상태를 처음 접는다). **연속**으로 이어질 때가 구조적으로 느린 것이다.
331
+ * 무겁다(복구한 상태를 처음 계산한다). **연속**으로 이어질 때가 구조적으로 느린 것이다.
287
332
  */
288
333
  /*
289
334
  * ── 틱을 **한 순간에 몰지 않는다** (2026-08-21 실측) ────────────────────────
@@ -301,7 +346,7 @@ export class TwinEngine {
301
346
  /**
302
347
  * 흩어진 첫 발화 뒤 주기 틱. **핸들은 항상 진짜 타이머**다 — 정지하는 쪽이 `clearInterval(inst.timer)`
303
348
  * 하나로 끝내야 하므로, 첫 발화 전에는 그 `setTimeout` 을, 이후에는 `setInterval` 을 같은 자리에 둔다
304
- * (감싼 객체를 주면 `clearInterval` 이 아무 일도 하지 않고 트윈이 멈추지 않는다 — 조용한 결함이 된다).
349
+ * (감싼 객체를 주면 `clearInterval` 이 아무 일도 하지 않고 트윈이 멈추지 않는다 — 들어온 것이 없는 결함이 된다).
305
350
  */
306
351
  private static startTickTimer(fn: () => void, hold: (t: any) => void): any {
307
352
  const spread = Math.max(1, Math.round(this.TICK_MS / 20))
@@ -332,7 +377,7 @@ export class TwinEngine {
332
377
  * 저널은 그대로 진실(시간여행/history replay 무변경) — 캐시는 display-only 웜스타트 최적화. */
333
378
  static SNAPSHOT_CACHE_ID = 'twin-snapshot'
334
379
  /*
335
- * ── 재개점 **사슬** — 과거를 물었을 때 목표 직전에서 접기 위해 (2026-08-18) ──
380
+ * ── 재개점 **사슬** — 과거를 물었을 때 목표 직전에서 계산 위해 (2026-08-18) ──
336
381
  *
337
382
  * 최신 재개점 하나로는 시간여행을 도울 수 없다: 그것은 언제나 목표보다 **뒤**에 있다. 그래서 지점을
338
383
  * 여러 개 남긴다. 다만 그것들은 각각 상태 전체를 들고 있어 무겁다 — 한 행에 몰아 넣으면 거대한
@@ -340,13 +385,20 @@ export class TwinEngine {
340
385
  *
341
386
  * 간격은 리비전 눈금으로 잡는다(`CHAIN_STRIDE`): 눈금을 넘을 때만 한 지점을 남기므로, 저널이 빠르게
342
387
  * 자라는 트윈에서도 지점 수가 폭발하지 않는다. 오래된 것부터 버리고 최근 `CHAIN_KEEP` 개만 든다 —
343
- * 과거로 깊이 갈수록 지점이 없어 0부터 접는 것은 **알려진 한계**다(무한 보관보다 정직하다).
388
+ * 과거로 깊이 갈수록 지점이 없어 0부터 계산하는 것은 **알려진 한계**다(무한 보관보다 정직하다).
344
389
  */
345
390
  static CHAIN_CACHE_ID = 'twin-fold-chain'
346
391
  static CHAIN_INDEX_CACHE_ID = 'twin-fold-chain-index'
347
392
  static CHAIN_STRIDE = 5000 // 리비전 눈금 — 이 간격을 넘을 때만 한 지점을 남긴다
348
- static CHAIN_KEEP = 5 // 최근 몇 지점을 들고 있나(그보다 과거는 0부터 접는다)
393
+ static CHAIN_KEEP = 5 // 최근 몇 지점을 들고 있나(그보다 과거는 0부터 계산한다)
349
394
  static SNAPSHOT_TTL_S = 7 * 24 * 3600 // 7일 — 정상 다운타임 생존, 만료 시 저널 replay 폴백
395
+ /**
396
+ * 멈춘 트윈의 마지막 상태를 두는 기간 — 사실상 만료 없음 (2026-08-27).
397
+ *
398
+ * 캐시 계층에 「만료 없음」을 뜻하는 값이 없어서 큰 수를 쓴다(`ICacheService` 는 초 단위 TTL 하나만
399
+ * 받는다). 100년이면 이 시스템의 수명보다 길다.
400
+ */
401
+ static SNAPSHOT_KEEP_S = 100 * 365 * 24 * 3600
350
402
  static CHECKPOINT_MS = 20000 // 체크포인트 주기(핫 브로드캐스트 경로와 분리, O(state) 스로틀)
351
403
  /**
352
404
  * **저널 보존 — 지우는 것은 선언이 있을 때만 한다.**
@@ -359,16 +411,16 @@ export class TwinEngine {
359
411
  * ── 그런데 저널을 지우는 것은 사실을 잃는 일이다 ──────────────────────────
360
412
  * 그래서 세 규율을 지킨다.
361
413
  *
362
- * ① **선언이 없으면 아무것도 지우지 않는다.** 기본값은 없음이다 — 조용히 지우는 편이 조용히 쌓는
414
+ * ① **선언이 없으면 아무것도 지우지 않는다.** 기본값은 없음이다 — 알리지 않고 지우는 편이 알리지 않고 쌓는
363
415
  * 것보다 나쁘다. 지우는 것은 사람이 정한다.
364
416
  * ② **체크포인트가 대신할 수 있는 만큼만.** 스냅샷이 없거나 그 리비전을 넘는 자리는 건드리지 않는다.
365
417
  * 주석이 「만료 시 저널 replay 폴백」이라고 적어 둔 그대로 — 스냅샷이 사라지면 저널이 **유일한**
366
418
  * 복구 수단이므로, 둘을 함께 잃으면 그 트윈의 상태는 되돌릴 수 없다.
367
- * ③ **지운 것을 말한다.** 몇 건을 어느 시각까지 지웠는지 로그에 남긴다. 조용히 줄어든 저널은
419
+ * ③ **지운 것을 말한다.** 몇 건을 어느 시각까지 지웠는지 로그에 남긴다. 알리지 않고 줄어든 저널은
368
420
  * 「없었던 일」과 구별되지 않는다.
369
421
  *
370
422
  * 그리고 보존 기간은 **스냅샷 TTL(7일)보다 짧을 수 없다.** 더 짧으면 스냅샷이 살아 있는데 그것이
371
- * 가리키는 앞쪽 저널이 없는 구간이 생기고, 시간여행·계보 추적이 그 구간에서 조용히 빈다.
423
+ * 가리키는 앞쪽 저널이 없는 구간이 생기고, 시간여행·계보 추적이 그 구간에서 알리지 않고 빈다.
372
424
  */
373
425
  static JOURNAL_RETENTION_DAYS?: number = undefined
374
426
  /**
@@ -389,13 +441,27 @@ export class TwinEngine {
389
441
  private static checkpointTimer?: any
390
442
 
391
443
  /** 최신 스냅샷을 cache-service 에 체크포인트(도메인+instanceId 키). display-only·비차단·오류흡수. */
392
- static async persistSnapshot(domainId: string, instanceId: string): Promise<void> {
444
+ static async persistSnapshot(
445
+ domainId: string,
446
+ instanceId: string,
447
+ /**
448
+ * 만료를 두지 않는다 — **트윈을 멈출 때만** 참이다 (2026-08-27).
449
+ *
450
+ * 도는 트윈은 주기적으로 다시 쓰므로 7일 만료가 걸리지 않는다. 멈춘 트윈은 아무도 쓰지 않아서
451
+ * 7일 뒤 만료되고, 그러면 그 트윈의 마지막 상태를 아는 것이 아무것도 없다. 멈추는 순간이 그
452
+ * 상태가 메모리에 있는 마지막 순간이므로 그때 만료 없이 적는다.
453
+ *
454
+ * 만료된 뒤에도 그 트윈은 보관된 구간에서 다시 세워진다(보관 기간만큼이므로 유한하다). 다만 그
455
+ * 구간 앞의 상태는 알 수 없다. 멈추는 순간에 적어 두면 그 손실이 없다.
456
+ */
457
+ keepForever = false
458
+ ): Promise<void> {
393
459
  const inst = this.instances[runtimeKey(domainId, instanceId)]
394
460
  if (!inst) return
395
461
  /*
396
462
  * **봉투가 아니라 상태를 저장한다.** `snapshot()` 은 시뮬에서 `runtime.resync()` 봉투를 주는데,
397
463
  * 그것을 다시 `{revision, state}` 로 감싸 넣어 왔다 → 꺼낸 값에 축이 하나도 없어 웜스타트가
398
- * 조용히 넘어갔다(저널에 수천 건이 있어도 트윈이 빈 채로 떴다).
464
+ * 알리지 않고 넘어갔다(저널에 수천 건이 있어도 트윈이 빈 채로 떴다).
399
465
  */
400
466
  const state = unwrapState(this.snapshot(domainId, instanceId))
401
467
  if (!state) return
@@ -417,7 +483,7 @@ export class TwinEngine {
417
483
  *
418
484
  * 원본이 실제로 「다 비었다」고 말한 경우는 막지 않는다 — 그때는 유입이 있었으므로 이 문을 지난다.
419
485
  *
420
- * 그리고 **거절을 말한다**: 조용히 거절하면 왜 체크포인트가 낡아 가는지 아무도 모른다.
486
+ * 그리고 **거절을 말한다**: 알리지 않고 거절하면 왜 체크포인트가 낡아 가는지 아무도 모른다.
421
487
  */
422
488
  if (!(inst.metrics?.ingestedTotal > 0)) {
423
489
  const observed = (s: any) => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0)
@@ -438,20 +504,20 @@ export class TwinEngine {
438
504
  /* 구조 리비전도 함께 — 읽는 쪽이 "이 상태가 지금의 공장인가" 를 가릴 수 있어야 한다. */
439
505
  const { structureRev } = await this.tipOf(domainId, instanceId).catch(() => ({ structureRev: null }) as any)
440
506
  /*
441
- * ── **이어 접을 씨앗을 함께 적는다** (2026-08-24) ──────────────────────────
507
+ * ── **이어 계산할 씨앗을 함께 적는다** (2026-08-24) ──────────────────────────
442
508
  *
443
509
  * 이 함수는 오랫동안 `{revision, state, structureRev}` 만 적었다. 그래서 **재개점을 읽는 쪽은 다
444
- * 있는데 쓰는 쪽이 없었다**: 조회 경로(`recover`)는 `fold` 가 있으면 꼬리만 접고, 없으면 저널을
445
- * 0부터 접는다. 실측으로 저장된 스냅샷 34건 전부 `fold` 가 비어 있었고, 저널은 2,960만 줄이었다 —
446
- * 그래서 재기동·조회마다 처음부터 다시 접었다. 규모 기준(엔티티 10만·품목 100만)에서 이것은
510
+ * 있는데 쓰는 쪽이 없었다**: 조회 경로(`recover`)는 `fold` 가 있으면 꼬리만 계산하고, 없으면 저널을
511
+ * 0부터 계산한다. 실측으로 저장된 스냅샷 34건 전부 `fold` 가 비어 있었고, 저널은 2,960만 줄이었다 —
512
+ * 그래서 재기동·조회마다 처음부터 다시 계산했다. 규모 기준(엔티티 10만·품목 100만)에서 이것은
447
513
  * 느린 것이 아니라 **못 하는 것**이다.
448
514
  *
449
515
  * 씨앗은 상태가 대신할 수 없다: 리듀서는 소비처가 보는 값 말고도 든다(부모를 기다리는 담김·집계
450
- * 중인 수량·담을 줄 몰라 세어 둔 사건). 상태만 되돌리고 뒤를 접으면 0부터 접은 결과와 **조용히**
516
+ * 중인 수량·담을 줄 몰라 세어 둔 사건). 상태만 되돌리고 뒤를 계산하면 0부터 계산한 결과와 **알리지 않고**
451
517
  * 달라진다. 그 동치는 커널 시험이 증명한다(`observed-checkpoint.test.ts`).
452
518
  *
453
519
  * 관측 구동이 아니면 씨앗이 없다 — 시뮬은 리듀서를 갖지 않고, 그 상태의 권위는 커널 자신이다.
454
- * 그때는 `fold` 를 **넣지 않는다**(빈 씨앗을 넣으면 읽는 쪽이 「이어 접을 수 있다」고 잘못 본다).
520
+ * 그때는 `fold` 를 **넣지 않는다**(빈 씨앗을 넣으면 읽는 쪽이 「이어 계산할 수 있다」고 잘못 본다).
455
521
  */
456
522
  const reducer: ReducerCheckpoint | undefined = inst.kernel?.observedCheckpoint?.()
457
523
  const fold = reducer && inst.oee ? { reducer, oee: inst.oee.serialize() } : undefined
@@ -459,38 +525,38 @@ export class TwinEngine {
459
525
  this.SNAPSHOT_CACHE_ID,
460
526
  { domainId, instanceId },
461
527
  { revision, state, structureRev, ...(fold ? { fold } : {}) },
462
- this.SNAPSHOT_TTL_S
528
+ keepForever ? this.SNAPSHOT_KEEP_S : this.SNAPSHOT_TTL_S
463
529
  )
464
530
  }
465
531
 
466
532
  /**
467
- * 접기의 **재개점** — 리듀서 내부 상태 전부 + 가동 누적기.
533
+ * 계산의 **재개점** — 리듀서 내부 상태 전부 + 가동 누적기.
468
534
  *
469
- * 스냅샷(`state`)은 소비처가 보는 값이라 이어 접기의 씨앗이 되지 못한다(보류된 담김·집합·반영 못 한
470
- * 사건 집계가 없다 — 그 상태로 뒤를 접으면 0부터 접은 결과와 조용히 달라진다). 그래서 씨앗은 따로 든다.
535
+ * 스냅샷(`state`)은 소비처가 보는 값이라 이어 계산의 씨앗이 되지 못한다(보류된 담김·집합·반영 못 한
536
+ * 사건 집계가 없다 — 그 상태로 뒤를 계산하면 0부터 계산한 결과와 오류 없이 달라진다). 그래서 씨앗은 따로 든다.
471
537
  */
472
538
  private static readonly FOLD_NOTE = 'reducer + oee checkpoint — the seed for folding only the tail'
473
539
 
474
540
  /**
475
- * 재기동에 쓸 **웜스타트 씨앗**을 만든다 — 상태 + 이어 접을 재개점.
541
+ * 재기동에 쓸 **웜스타트 씨앗**을 만든다 — 상태 + 이어 계산할 재개점.
476
542
  *
477
543
  * ── 왜 이 자리가 생겼나 (2026-08-24) ──────────────────────────────────────
478
544
  * 두 호출부가 같은 일을 조금씩 다르게 하고 있었고(겹포장을 한쪽만 벗겼다), 둘 다 **재개점을 버리고**
479
545
  * 상태만 들고 갔다. 그래서 저장된 재개점을 읽는 쪽이 다 있는데도 재기동은 매번 저널을 처음부터
480
- * 접었다(실측: 저널 2,960만 줄).
546
+ * 계산했다(실측: 저널 2,960만 줄).
481
547
  *
482
548
  * 여기서 하는 일 셋:
483
549
  * ① 겹포장을 벗긴다 — 옛 형식으로 저장된 값이 한 번은 반드시 나온다
484
550
  * ② **그 공장이 아직 그 공장인지** 심판한다 — 아니면 씨앗을 버린다(아래)
485
- * ③ 씨앗이 저널 끝보다 앞서 있으면 **그 꼬리만 접어** 끝까지 밀어 둔다
551
+ * ③ 씨앗이 저널 끝보다 앞서 있으면 **그 꼬리만 계산해** 끝까지 밀어 둔다
486
552
  *
487
553
  * ②가 필요한 이유: 재개점은 그때의 보드 위에서 만들어진 것이다. 그 뒤 구조가 바뀌었다면(자리가
488
554
  * 빠졌다·설비가 옮겨졌다) 되세운 리듀서는 **지금 없는 자리와 설비를 든다** — 없는 냉장실이 화면에
489
555
  * 나오고 그 자리의 판정이 계속 돌아간다. 오류 없이 틀리므로 눈에 띄지 않는다.
490
556
  *
491
557
  * ③이 필요한 이유: 스냅샷은 체크포인트 주기로 쓰이므로 마지막 주기 이후의 사실은 저널에만 있다.
492
- * 그것을 빼고 되세우면 그만큼이 조용히 사라진다 — 「모름」을 「없음」으로 적는 것과 같은 부류다.
493
- * 접는 구간은 **그 틈뿐**이고(저널 전체가 아니다), `recover` 가 그 자리에서 새 재개점을 남겨 준다.
558
+ * 그것을 빼고 되세우면 그만큼이 오류 없이 사라진다 — 「모름」을 「없음」으로 적는 것과 같은 부류다.
559
+ * 계산하는 구간은 **그 틈뿐**이고(저널 전체가 아니다), `recover` 가 그 자리에서 새 재개점을 남겨 준다.
494
560
  */
495
561
  private static async warmSeedFor(
496
562
  domainId: string,
@@ -511,19 +577,46 @@ export class TwinEngine {
511
577
  return { revision: cached.revision, state: unwrapState(cached.state) }
512
578
  }
513
579
 
514
- if (tip && (cached.revision ?? 0) < tip.revision) {
580
+ /*
581
+ * ── **묻지 못했으면 그 사실을 말한다** (2026-08-28) ─────────────────────────
582
+ *
583
+ * 여기가 `if (tip && …)` 이었다. 위에서 `tipOf` 가 실패하면 `null` 이 되고, 그러면 이 갈래가
584
+ * **아무 말 없이 건너뛴다** — 저장본 뒤의 사건을 반영하지 못한 채 뜨고, 로그에 아무 흔적이 없다.
585
+ * 실측(2026-08-28 10:34): 저장본 3,372,228 · 저널 머리 3,374,894 인데 계산했다는 줄도, 못 했다는
586
+ * 줄도 없었다.
587
+ */
588
+ if (!tip) {
589
+ twinWarn(
590
+ `[twin-engine] "${instanceId}": could not read the journal tip — starting from the stored seed at revision ` +
591
+ `${cached.revision ?? 0} without folding what came after it. Some recent facts may be missing from state.`
592
+ )
593
+ return { revision: cached.revision, ...seedOf(cached), state: unwrapState(cached.state) }
594
+ }
595
+
596
+ if ((cached.revision ?? 0) < tip.revision) {
515
597
  /*
516
- * 틈을 접는다 — `recover` 가 이 씨앗으로 **꼬리만** 접고, 끝 지점의 새 재개점을 남긴다.
598
+ * 틈을 계산한다 — `recover` 가 이 씨앗으로 **꼬리만** 계산하고, 끝 지점의 새 재개점을 남긴다.
517
599
  * 아직 이 트윈의 런타임이 없으므로 `recover` 는 메모리 대신 저널 경로를 탄다(그것이 여기의 전제다).
518
600
  */
519
601
  const gap = tip.revision - (cached.revision ?? 0)
520
- await this.recover(domainId, instanceId).catch(err =>
521
- twinWarn(`[twin-engine] "${instanceId}": could not fold the ${gap} event(s) after the checkpoint — ${err?.message ?? err}`)
522
- )
602
+ let failed: string | undefined
603
+ await this.recover(domainId, instanceId).catch(err => {
604
+ failed = err?.message ?? String(err)
605
+ twinWarn(`[twin-engine] "${instanceId}": could not fold the ${gap} event(s) after the checkpoint — ${failed}`)
606
+ })
523
607
  const advanced = await this.loadSnapshot(domainId, instanceId).catch(() => null)
524
608
  if (advanced?.state && (advanced.revision ?? 0) > (cached.revision ?? 0)) {
525
609
  twinLog(`[twin-engine] "${instanceId}": folded ${gap} event(s) after the checkpoint → revision ${advanced.revision}.`)
526
610
  cached = advanced
611
+ } else if (!failed) {
612
+ /*
613
+ * **아무 일도 하지 않은 것을 말한다.** 계산이 오류 없이 끝났는데 재개점이 나아가지 않았다는 뜻이고,
614
+ * 그러면 그 사건들의 결과가 상태에 없다. 예전에는 이 갈래가 조건에 걸리지 않아 침묵했다.
615
+ */
616
+ twinWarn(
617
+ `[twin-engine] "${instanceId}": folding the ${gap} event(s) after the checkpoint left the seed at revision ` +
618
+ `${cached.revision ?? 0} — state starts without those facts. The screens may show them as absent.`
619
+ )
527
620
  }
528
621
  }
529
622
  return { revision: cached.revision, state: unwrapState(cached.state), ...seedOf(cached) }
@@ -539,10 +632,10 @@ export class TwinEngine {
539
632
  }
540
633
 
541
634
  /**
542
- * 접은 상태를 스냅샷으로 남긴다 — **라이브가 아니어도.**
635
+ * 계산한 상태를 스냅샷으로 남긴다 — **라이브가 아니어도.**
543
636
  *
544
637
  * `persistSnapshot` 은 기동 중인 인스턴스에서만 뜬다(메모리가 진실이므로 옳다). 그런데 **멈춘**
545
- * 트윈을 조회할 때마다 저널을 전량 다시 접고 있었다(23,731건짜리 트윈에서 4.6초). 그 폴드의 결과를
638
+ * 트윈을 조회할 때마다 저널을 전량 다시 계산하고 있었다(23,731건짜리 트윈에서 4.6초). 그 폴드의 결과를
546
639
  * 남겨 두면 **처음 한 번만 느리다.**
547
640
  *
548
641
  * `structureRev` 를 함께 적는다: 이벤트가 하나도 안 늘어도 구조를 갈아치우면(재프로비저닝) 그
@@ -568,11 +661,11 @@ export class TwinEngine {
568
661
  }
569
662
 
570
663
  /**
571
- * 목표 이전의 **가장 가까운 지점**을 고른다 — 없으면 `null`(0부터 접는다).
664
+ * 목표 이전의 **가장 가까운 지점**을 고른다 — 없으면 `null`(0부터 계산한다).
572
665
  *
573
666
  * 시각으로 물었으면 그 지점의 마지막 사실 시각이 목표 이내여야 한다(리비전만 보면 목표보다 뒤의
574
667
  * 사실이 씨앗에 섞인다). 구조가 바뀐 트윈에서는 쓰지 않는다 — 마디를 건너뛴 씨앗은 그 경계의
575
- * 판정을 잃는다(그 경우는 0부터 접는 것이 옳다).
668
+ * 판정을 잃는다(그 경우는 0부터 계산하는 것이 옳다).
576
669
  */
577
670
  private static async chainSeedFor(
578
671
  domainId: string,
@@ -626,20 +719,50 @@ export class TwinEngine {
626
719
 
627
720
  /** 이 트윈 저널의 끝 리비전 · 최신 구조 리비전 — 스냅샷이 지금의 사실인지 가리는 두 값. */
628
721
  private static async tipOf(domainId: string, instanceId: string): Promise<{ revision: number; structureRev: number | null }> {
722
+ /*
723
+ * ── **관계로 묻지 않는다** (2026-08-28) ─────────────────────────────────────
724
+ *
725
+ * `findOne({ where: { domain: { id } } })` 는 관계를 가진 엔티티에서 두 단계 질의가 되고 **안쪽에
726
+ * 상한이 없다** — 조건에 맞는 행을 전부 만들어 놓고 밖에서 한 줄을 고른다. 이 파일이 같은 것을
727
+ * 실측해 적어 두었다(§`twinEvents`: 0.024초 대 12초, 저널 375만 행).
728
+ *
729
+ * 이 함수는 **부팅마다 트윈마다** 불린다. 승화푸드 저널이 337만 행이므로 그 한 번이 부팅을
730
+ * 붙잡는다. 관계를 컬럼으로 물으면 감싸지 않고 색인(`domain, instance, revision`)의 첫 줄로 끝난다.
731
+ */
629
732
  const [tip, newest] = await Promise.all([
630
- getRepository(TwinEvent).findOne({ where: { domain: { id: domainId }, instanceId }, order: { revision: 'DESC' } }),
631
- getRepository(TwinStructure).findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } })
733
+ getRepository(TwinEvent)
734
+ .createQueryBuilder('e')
735
+ .select('e.revision', 'revision')
736
+ .where('e.domain = :domainId', { domainId })
737
+ .andWhere('e.instanceId = :instanceId', { instanceId })
738
+ .orderBy('e.revision', 'DESC')
739
+ .limit(1)
740
+ .getRawOne<{ revision: unknown }>(),
741
+ getRepository(TwinStructure)
742
+ .createQueryBuilder('s')
743
+ .select('s.rev', 'rev')
744
+ .where('s.domain = :domainId', { domainId })
745
+ .andWhere('s.instanceId = :instanceId', { instanceId })
746
+ .orderBy('s.rev', 'DESC')
747
+ .limit(1)
748
+ .getRawOne<{ rev: unknown }>()
632
749
  ])
633
- return { revision: tip?.revision ?? 0, structureRev: newest?.rev ?? null }
750
+ const revision = Number(tip?.revision)
751
+ const structureRev = Number(newest?.rev)
752
+ return {
753
+ revision: Number.isFinite(revision) ? revision : 0,
754
+ structureRev: Number.isFinite(structureRev) ? structureRev : null
755
+ }
634
756
  }
635
757
 
636
758
  /**
637
- * **저널을 보존 기간까지만 둔다** — 체크포인트가 대신할 수 있는 만큼만 지운다.
759
+ * **저널을 보존 기간까지만 둔다.**
638
760
  *
639
- * 한 인스턴스에서 지우는 조건은 **둘 다** 만족해야 한다.
761
+ * 지우는 조건은 하나다 — `createdAt` 이 보존 기간보다 오래됐다(**행이 쓰인 실제 시각**이 기준이다).
640
762
  *
641
- * · `createdAt` 이 보존 기간보다 오래됐다 — **행이 쓰인 실제 시각**이 기준이다
642
- * · `revision` 이 **체크포인트 리비전 이하**다 — 그 앞은 스냅샷이 대신한다
763
+ * **도는 트윈**에서만 조건이 하나 더 붙는다: `revision` 이 체크포인트 리비전 이하여야 한다. 도는
764
+ * 트윈은 메모리에서 이어 집계하므로, 체크포인트가 아직 반영하지 못한 사건을 지우면 그 구간이
765
+ * 어디에도 없어진다. 멈춘 트윈에는 이어 집계할 메모리가 없어서 이 조건이 없다.
643
766
  *
644
767
  * ── 왜 `eventTime` 이 아니라 `createdAt` 인가 (2026-08-22 실측으로 고침) ────
645
768
  * 처음에 `eventTime` 으로 적었다. 그것은 **트윈의 시계**다 — 시뮬레이션은 자기 시계로 사건을 찍고,
@@ -654,74 +777,229 @@ export class TwinEngine {
654
777
  * `createdAt` 은 그 행이 DB 에 쓰인 시각이고 1,599만 행 전부 채워져 있다(확인함). 도메인 시각을
655
778
  * 정책에 쓰지 않는다 — 그 둘을 섞으면 시뮬과 미러에서 같은 설정이 다르게 동작한다.
656
779
  *
657
- * 스냅샷이 없으면 **그 인스턴스는 건드리지 않는다.** 저널이 유일한 복구 수단인 상태이므로, 지우면
658
- * 그 트윈의 상태를 되돌릴 수 없다. 「지울 수 없었다」는 사실도 함께 센다 — 조용히 넘기면 「보존이
659
- * 도는데 왜 안 줄어드나」가 된다.
780
+ * ── 왜 체크포인트를 전제 조건으로 두지 않나 (2026-08-27) ───────────────────
781
+ * 예전에는 체크포인트가 없으면 그 트윈을 건드리지 않았다. 「저널이 유일한 복구 수단」이라는 전제
782
+ * 때문이었고, 그 전제가 틀렸다. 트윈이 자기 저널과 어떤 관계인지는 `restartPolicy` 가 선언한다 —
783
+ * `resync` 는 연결된 시스템에서 다시 읽고, `reset` 은 씨앗부터 돌리고, `resume` 은 보관된 구간에서
784
+ * 세운다. 셋 다 보관 기간 밖의 사건을 요구하지 않는다.
785
+ *
786
+ * 보존 기간을 7일로 선언한 것이 곧 「7일 전 상태로는 세우지 않는다」는 뜻이다. 그 전제를 지키느라
787
+ * 지우지 않으면, 멈춘 트윈의 저널이 영구히 남는다 — 실측으로 세 트윈에 2,900만 행이 그렇게 남았다.
788
+ *
789
+ * 「지울 수 없었다」는 사실은 그대로 센다(도는데 체크포인트가 아직 없는 경우) — 알리지 않고 넘기면
790
+ * 「보존이 도는데 왜 안 줄어드나」가 된다.
660
791
  *
661
792
  * `createdAt` 이 빈 옛 행은 **지우지 않는다**(시각을 모르는 것을 「오래됐다」로 읽지 않는다).
662
793
  *
663
794
  * 드라이버 다섯을 다 지나야 하므로 raw SQL 을 쓰지 않는다 — 조건 삭제는 쿼리빌더가 이식한다.
664
795
  */
665
- static async pruneJournal(domainId: string): Promise<{ deleted: number; instances: number; skipped: string[] }> {
796
+ static async pruneJournal(domainId: string): Promise<{ deleted: number; instances: number }> {
666
797
  /* 도메인의 정책이 먼저다(§`retentionDaysOf`). 없으면 프로세스 기본값. 둘 다 없으면 지우지 않는다. */
667
798
  const perDomain = this.retentionDaysOf ? await this.retentionDaysOf(domainId).catch(() => undefined) : undefined
668
799
  const days = perDomain ?? this.JOURNAL_RETENTION_DAYS
669
- if (!days || days <= 0) return { deleted: 0, instances: 0, skipped: [] }
800
+ if (!days || days <= 0) return { deleted: 0, instances: 0 }
670
801
 
671
- /* 보존 기간은 스냅샷 TTL 보다 짧을 수 없다 — 짧으면 스냅샷이 가리키는 앞쪽이 비는 구간이 생긴다. */
672
- const minDays = this.SNAPSHOT_TTL_S / 86400
673
- const effectiveDays = Math.max(days, minDays)
674
- if (effectiveDays !== days) {
675
- twinWarn(
676
- `[twin-engine] journal retention ${days}일은 스냅샷 TTL(${minDays}일)보다 짧다 — ${effectiveDays}일로 올린다 ` +
677
- '(더 짧으면 스냅샷이 살아 있는데 그것이 가리키는 앞쪽 저널이 없는 구간이 생긴다)'
678
- )
679
- }
802
+ /*
803
+ * ── 보존 기간을 스냅샷 TTL 로 올리지 않는다 (2026-08-27) ────────────────────
804
+ *
805
+ * 여기가 `max(보존 기간, 스냅샷 TTL)` 이었다. 둘 다 7일이라 상향은 없었지만, 그 식은 두 값을
806
+ * 묶어 놓아서 하나를 줄이면 다른 하나가 따라오게 만든다. 두 값은 다른 것을 정한다.
807
+ *
808
+ * 보존 기간 이력을 며칠 두나
809
+ * 스냅샷 TTL 빠르게 세우기 위한 저장본이 며칠 사나
810
+ *
811
+ * 저장본이 만료되어도 트윈은 보관된 구간에서 세워진다. 그래서 보존 기간이 더 짧아도 성립한다.
812
+ */
813
+ const effectiveDays = days
680
814
  const cutoff = new Date(Date.now() - effectiveDays * 86400 * 1000)
681
815
 
682
816
  const rows = await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } })
683
817
  let deleted = 0
684
818
  let instances = 0
685
- const skipped: string[] = []
686
819
 
687
820
  for (const r of rows) {
821
+ /*
822
+ * ── 체크포인트는 **상한**이지 전제 조건이 아니다 (2026-08-27 사용자 지시로 고침) ──
823
+ *
824
+ * 여기가 체크포인트가 없으면 건너뛰었다. 그 규칙은 「저널이 유일한 복구 수단」이라는 전제에서
825
+ * 나왔는데, 그 전제가 틀렸다. 트윈이 자기 저널과 어떤 관계인지는 `restartPolicy` 가 선언한다.
826
+ *
827
+ * resync 연결된 시스템에서 다시 읽는다 저널은 복구 수단이 아니다
828
+ * reset 씨앗부터 다시 돌린다 저널은 복구 수단이 아니다
829
+ * resume 저널을 이어 세운다 보관된 구간에서 세운다
830
+ *
831
+ * 셋 다 보관 기간 밖의 사건을 요구하지 않는다. 보관 기간 7일이라는 선언이 곧 「7일 전 상태로는
832
+ * 세우지 않는다」는 뜻이다. 그래서 정리는 체크포인트 없이도 지운다.
833
+ *
834
+ * 체크포인트가 하는 일은 하나 남는다. 체크포인트가 **있으면** 그 리비전을 상한으로 쓴다 — 다음
835
+ * 기동이 그 지점에서 이어 집계하므로, 그 뒤의 사건을 지우면 그 구간이 어디에도 없어진다.
836
+ *
837
+ * **없으면 상한도 없다.** 이어 집계할 지점이 없으니 지킬 것도 없고, 보관 기간 조건만으로 지운다.
838
+ * 없다는 이유로 건너뛰면 멈춘 트윈은 영구히 줄지 않는다(실측: 세 트윈에 2,900만 행).
839
+ */
688
840
  const snap = await this.loadSnapshot(domainId, r.instanceId).catch(() => null)
689
- const upTo = Number(snap?.revision ?? 0)
690
- if (!upTo) {
691
- /* 스냅샷이 없다 — 저널이 유일한 복구 수단이므로 손대지 않는다. */
692
- skipped.push(r.instanceId)
693
- continue
841
+ const at = Number(snap?.revision ?? 0)
842
+ const upTo = at > 0 ? at : Number.MAX_SAFE_INTEGER
843
+ /*
844
+ * ── 나눠서 지운다 (2026-08-25 실측으로 고침) ────────────────────────────────
845
+ *
846
+ * 여기가 **한 문장으로 통째** 지웠다. 지울 것이 며칠치면 그 한 문장이 수백만 행이 되고, sqlite 는
847
+ * 쓰기가 하나이므로 그동안 호스트가 아무 일도 못 한다. 실측: 한 번의 지우기가 74초·79초였고,
848
+ * 그 뒤에 한 줄짜리 `domains` 조회가 67초로 찍혔다 — 그 질의의 문제가 아니라 줄을 선 값이다.
849
+ * 개발 환경에서 보관 기간 7일에 13일치가 쌓여 있었고, 그 밀린 몫이 한 번에 나왔다.
850
+ *
851
+ * 그래서 **한 번에 지우는 수를 묶고**, 배치 사이에 루프를 비워 준다. 그리고 한 창에서 쓰는
852
+ * 시간에도 상한을 둔다 — 남은 것은 다음 창이 이어서 지운다. 지우는 총량은 같고, 그 사이에
853
+ * 사람의 요청이 처리된다.
854
+ *
855
+ * `DELETE … LIMIT` 은 드라이버마다 다르므로 쓰지 않는다(sqlite 는 기본 빌드에서 지원하지 않는다).
856
+ * 대신 **경계 리비전을 먼저 찾아** 그 아래만 지운다 — 다섯 드라이버에서 같은 뜻이 되는 방법이다.
857
+ */
858
+ let n = 0
859
+ const startedMs = Date.now()
860
+ for (;;) {
861
+ /*
862
+ * ── 경계를 **시각**으로 찾는다 (2026-08-27 실측으로 고침) ──────────────────
863
+ *
864
+ * 여기가 `ORDER BY revision` 이었다. 그러면 계획기가 리비전 색인을 고르고, 지울 대상인지는
865
+ * 행마다 `created_at` 을 열어 확인한다. 지울 것이 적으면 그 트윈의 저널을 끝까지 걸어야
866
+ * 「한 배치를 못 채웠다」를 알 수 있다 — 1,029만 행에서 그것이 2초 예산을 다 썼고 44건만
867
+ * 지우고 멈췄다.
868
+ *
869
+ * 시각순으로 물으면 `(domain, instance, created_at)` 색인을 그대로 쓴다. 실측 계획:
870
+ *
871
+ * ORDER BY revision SEARCH USING INDEX ix_twin_event_0 ← created_at 을 행마다 확인
872
+ * ORDER BY createdAt SEARCH USING COVERING INDEX ix_twin_event_8 ← 테이블을 열지 않는다
873
+ *
874
+ * 지우기는 정렬이 없어 색인만 있으면 바로 그것을 쓴다(실측 확인).
875
+ */
876
+ const edge = await getRepository(TwinEvent)
877
+ .createQueryBuilder('e')
878
+ .select('e.createdAt', 'createdAt')
879
+ .where('e.domain = :domainId', { domainId })
880
+ .andWhere('e.instanceId = :instanceId', { instanceId: r.instanceId })
881
+ .andWhere('e.createdAt IS NOT NULL')
882
+ .andWhere('e.createdAt < :cutoff', { cutoff })
883
+ .orderBy('e.createdAt', 'ASC')
884
+ .offset(this.JOURNAL_PRUNE_BATCH - 1)
885
+ .limit(1)
886
+ .getRawOne<{ createdAt: unknown }>()
887
+ /*
888
+ * 경계가 없으면 남은 것이 한 배치 안이다 — 그때는 보관 기간 경계까지 지우고 끝난다.
889
+ *
890
+ * 경계가 있으면 그 시각 **이하**까지 지운다(`<=`). 같은 시각의 행이 여럿이면 한 배치가 조금
891
+ * 커지지만, `<` 로 두면 그 시각의 행들이 남아 다음 배치가 같은 경계를 다시 찾는다(제자리걸음).
892
+ */
893
+ /*
894
+ * 원시 결과의 컬럼 값은 **드라이버에게 해석시킨다**(§`hydratedDate`). sqlite 가 주는 값에는
895
+ * 시간대가 없어서 그대로 `new Date()` 로 읽으면 지역 시간으로 해석된다 — 경계가 그만큼 이르게
896
+ * 밀리고, 그러면 **한 건도 지우지 못해 정리가 그대로 멈춘다**(0건이면 반복을 끝낸다).
897
+ */
898
+ const batchCutoff = hydratedDate(getRepository(TwinEvent), 'createdAt', edge?.createdAt)
899
+
900
+ const del = getRepository(TwinEvent)
901
+ .createQueryBuilder()
902
+ .delete()
903
+ .from(TwinEvent)
904
+ .where('domain_id = :domainId', { domainId })
905
+ .andWhere('instance_id = :instanceId', { instanceId: r.instanceId })
906
+ /* 체크포인트가 아직 반영하지 못한 사건은 지우지 않는다(도는 트윈에서만 상한이 걸린다). */
907
+ .andWhere('revision <= :upTo', { upTo })
908
+ .andWhere('created_at IS NOT NULL')
909
+ const res = await (batchCutoff
910
+ ? del.andWhere('created_at <= :batchCutoff', { batchCutoff })
911
+ : del.andWhere('created_at < :cutoff', { cutoff })
912
+ ).execute()
913
+ const batch = res.affected ?? 0
914
+ n += batch
915
+ if (!batch) break
916
+ if (Date.now() - startedMs >= this.JOURNAL_PRUNE_BUDGET_MS) {
917
+ twinLog(
918
+ `[twin-engine] journal prune paused "${r.instanceId}" — 이번 창에서 ${n}건까지 지웠다. ` +
919
+ '남은 것은 다음 창이 이어서 지운다(호스트가 그 사이에 요청을 처리한다).'
920
+ )
921
+ break
922
+ }
923
+ /* 루프를 비워 준다 — 이 한 줄이 없으면 배치로 나눈 뜻이 없다(같은 틱에서 계속 지운다). */
924
+ await new Promise(resolve => setImmediate(resolve))
694
925
  }
695
- const res = await getRepository(TwinEvent)
696
- .createQueryBuilder()
697
- .delete()
698
- .from(TwinEvent)
699
- .where('domain_id = :domainId', { domainId })
700
- .andWhere('instance_id = :instanceId', { instanceId: r.instanceId })
701
- .andWhere('revision <= :upTo', { upTo })
702
- .andWhere('created_at IS NOT NULL')
703
- .andWhere('created_at < :cutoff', { cutoff })
704
- .execute()
705
- const n = res.affected ?? 0
706
926
  if (n > 0) {
707
927
  deleted += n
708
928
  instances++
709
- /* **지운 것을 말한다** — 조용히 줄어든 저널은 「없었던 일」과 구별되지 않는다. */
929
+ /* **지운 것을 말한다** — 알리지 않고 줄어든 저널은 「없었던 일」과 구별되지 않는다. */
710
930
  twinLog(
711
- `[twin-engine] journal pruned "${r.instanceId}" — ${n}건 (revision ≤ ${upTo} · ${cutoff.toISOString()} 이전). ` +
712
- '그 앞은 체크포인트가 대신한다.'
931
+ `[twin-engine] journal pruned "${r.instanceId}" — ${n}건 (${cutoff.toISOString()} 이전` +
932
+ `${upTo < Number.MAX_SAFE_INTEGER ? ` · revision ≤ ${upTo} 까지만, 그 뒤는 체크포인트가 대신하지 못한다` : ''}).`
713
933
  )
714
934
  }
935
+ /*
936
+ * ── 조회용 파생 표도 같은 창에서 줄인다 (2026-08-27) ────────────────────────
937
+ *
938
+ * 이 표는 **버릴 수 있는 값**이다(사건에서 파생된 것이므로). 지우면 그 구간의 이력 조회가 저널로
939
+ * 넘어가 느려지고, 사실은 사라지지 않는다.
940
+ *
941
+ * 그래서 체크포인트 상한을 보지 않는다 — 그 상한은 「사실을 잃지 않기 위한 것」이고 여기에는 잃을
942
+ * 사실이 없다. 보관 기간만 본다.
943
+ */
944
+ const subjectPruned = await pruneSubjectRows(
945
+ domainId,
946
+ r.instanceId,
947
+ cutoff,
948
+ this.JOURNAL_PRUNE_BUDGET_MS,
949
+ this.JOURNAL_PRUNE_BATCH
950
+ ).catch(() => 0)
951
+ if (subjectPruned > 0) {
952
+ twinLog(`[twin-subject] pruned "${r.instanceId}" — ${subjectPruned}줄 (${cutoff.toISOString()} 이전).`)
953
+ }
715
954
  }
716
- if (skipped.length) {
717
- twinLog(
718
- `[twin-engine] journal prune skipped ${skipped.length} instance(s) with no checkpoint — ` +
719
- `저널이 유일한 복구 수단이라 손대지 않았다: ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? ' …' : ''}`
720
- )
721
- }
722
- return { deleted, instances, skipped }
955
+ return { deleted, instances }
723
956
  }
724
957
 
958
+ /**
959
+ * 한 번의 지우기가 다루는 행 수 — 쓰기 잠금을 이 만큼만 잡는다.
960
+ *
961
+ * ── 20,000 이 왜 컸나 (2026-08-28 실측) ────────────────────────────────────
962
+ * 앞 판은 20,000 이었고 근거는 「한 문장으로 수백만 행을 지우면 74초였다」였다. 그것은 **더 나쁜
963
+ * 쪽과 견준 수**이고, 20,000 자체를 잰 것이 아니었다. 실제로 재 보니 이렇다(서버 기록):
964
+ *
965
+ * hatio-mx1 20,123줄 5.6초
966
+ * hatio-mx2 20,130줄 5.2초
967
+ * hatio-us 20,139줄 4.2초
968
+ * 한 창의 합계 약 15초
969
+ *
970
+ * sqlite 는 연결이 하나다. 그래서 그 4~5초 동안 **화면의 모든 질의가 줄을 선다** — 실측으로 첫
971
+ * 화면 조회가 12.2초였다(정상 상태에서는 40밀리초다).
972
+ *
973
+ * 그리고 예산(`JOURNAL_PRUNE_BUDGET_MS` = 2초)이 **문장이 끝난 뒤에** 검사되므로, 한 문장이 예산을
974
+ * 2~3배 넘긴다. 배치를 나눈 뜻이 그만큼 없어진다.
975
+ *
976
+ * ── 2,000 의 근거 ──────────────────────────────────────────────────────────
977
+ * 위 실측이 줄당 약 0.26밀리초다(5.2초 / 20,130줄). 2,000줄이면 **한 문장이 약 0.5초**다. 화면이
978
+ * 한 번 멈추는 길이가 5초에서 0.5초로 줄고, 예산 검사도 뜻을 갖는다(한 창에 문장 넷).
979
+ *
980
+ * 총량은 같고 나눠서 치른다 — 창마다 지우는 수가 줄지만 지우는 일은 급하지 않다(창이 10분마다 온다).
981
+ *
982
+ * 값을 바꾸려면 다시 재고 이 문장을 고칠 것.
983
+ */
984
+ static JOURNAL_PRUNE_BATCH = 2_000
985
+ /**
986
+ * 한 창에서 지우는 데 쓰는 시간 상한(ms) — 남은 것은 다음 창이 이어서 지운다.
987
+ *
988
+ * 밀린 몫이 며칠치면 한 창에서 다 지울 수 없다. 다 지우려 들면 그동안 호스트가 멈추고, 그것이 바로
989
+ * 고치려는 증상이다. 총량은 같고 나눠서 치른다.
990
+ *
991
+ * ── 2초에서 5초로 올린 이유 (2026-08-28) ───────────────────────────────────
992
+ * 배치를 20,000 → 2,000 으로 줄였다(§`JOURNAL_PRUNE_BATCH`). 예산을 그대로 두면 **창마다 지우는
993
+ * 양이 2.5배 줄어** 밀린 몫이 빠지지 않는다.
994
+ *
995
+ * 예산을 5초로 두면 한 창에 문장 열 개(각 0.5초)가 돌아 **지우는 양은 전과 같고**, 잠금은 열 번
996
+ * 나뉘어 그 사이에 화면 질의가 처리된다. 고치려던 것은 총 시간이 아니라 **한 번에 멈추는 길이**다.
997
+ *
998
+ * 전 한 문장 5.2초 · 그동안 모든 조회가 줄을 선다
999
+ * 후 문장 열 개 × 0.5초 · 사이마다 루프를 비워 준다
1000
+ */
1001
+ static JOURNAL_PRUNE_BUDGET_MS = 5_000
1002
+
725
1003
  /** 보존 정리 주기 기동(1회) — 선언이 없으면 아무것도 하지 않는다. */
726
1004
  static startRetentionLoop(domainId: string): void {
727
1005
  if (this.retentionTimer) return
@@ -821,7 +1099,7 @@ export class TwinEngine {
821
1099
  * `bootstrap()` 은 상태만 되찾아 `recovered` 에 담았고, 커널을 세우는 것은 **명시 mutation 뿐**이었다
822
1100
  * (이 파일 위쪽 주석이 「향후」라고 적어 둔 그 자리다). 그래서 서버를 한 번 재기동하면 등록부는
823
1101
  * `running` 이라 말하는데 **아무 커널도 돌지 않았다** — 화면은 도는 트윈을, 실제로는 멈춘 트윈을.
824
- * 미러 트윈에서는 더 나쁘다: 계측이 조용히 끊기고, 사람은 「값이 안 변하네」로 알게 된다.
1102
+ * 미러 트윈에서는 더 나쁘다: 계측이 알리지 않고 끊기고, 사람은 「값이 안 변하네」로 알게 된다.
825
1103
  *
826
1104
  * ── 모드를 지어내지 않는다 ──────────────────────────────────────────────────
827
1105
  * 미러였던 트윈을 시뮬로 되살리면 **없던 움직임을 만들어 낸다**(관측 트윈이 스스로 물건을 옮긴다).
@@ -934,7 +1212,7 @@ export class TwinEngine {
934
1212
  ...(plan.attentionSinceCount ? [`${plan.attentionSinceCount} attention start time(s)`] : [])
935
1213
  ].join(', ')
936
1214
  twinLog(`[twin-engine] warm-started "${id}" — restored ${restored}.`)
937
- /* 뺀 것은 조용히 넘기지 않는다 — 지어내지 않았다는 사실 자체를 말해야 화면의 빈칸이 읽힌다. */
1215
+ /* 뺀 것은 알리지 않고 넘기지 않는다 — 지어내지 않았다는 사실 자체를 말해야 화면의 빈칸이 읽힌다. */
938
1216
  if (plan.ordersWithoutDemand > 0) {
939
1217
  twinWarn(
940
1218
  `[twin-engine] "${id}": ${plan.ordersWithoutDemand} order(s) could not be restored — they carry progress only, ` +
@@ -950,7 +1228,7 @@ export class TwinEngine {
950
1228
  * `model.operations`(마스터 인제스트가 통과시킨 ISA-95 OperationsSegment 명세)를 커널이 소비한다.
951
1229
  * 없으면 커널 기본 상수로 굴러가고, 커널 `specCoverage()` 가 무엇을 기본값으로 썼는지 보고한다.
952
1230
  *
953
- * 커널이 아직 이 API 를 갖지 않은 버전이면(발행 이전) **조용히 넘어가지 않고 경고한다** — 명세를
1231
+ * 커널이 아직 이 API 를 갖지 않은 버전이면(발행 이전) **알리지 않고 넘어가지 않고 경고한다** — 명세를
954
1232
  * 선언했는데 반영되지 않는 상태를 모르고 지나가면, 예측이 상수로 돌아간 것을 아무도 알 수 없다.
955
1233
  */
956
1234
  private static applyOperations(kernel: any, model: any, id: string): void {
@@ -972,7 +1250,7 @@ export class TwinEngine {
972
1250
  try {
973
1251
  kernel.declareDurations(declared)
974
1252
  } catch (err: any) {
975
- /* 커널이 거절한 값은 조용히 넘기지 않는다 — 화면은 「넣었습니다」라고 말한 값이다. */
1253
+ /* 커널이 거절한 값은 알리지 않고 넘기지 않는다 — 화면은 「넣었습니다」라고 말한 값이다. */
976
1254
  twinWarn(`[twin-engine] "${id}": declared operation duration rejected by the kernel — ${err?.message ?? err}`)
977
1255
  }
978
1256
  }
@@ -1012,7 +1290,7 @@ export class TwinEngine {
1012
1290
  * 둘 다 못 만들면 주입하지 않는다 — 커널이 명세·상수로 굴러가고 `specCoverage()` 가 그 사실을 남긴다.
1013
1291
  *
1014
1292
  * 실측은 DB 조회라 비동기다. 그래서 이 함수는 **await 하지 않는 쪽에서도 안전**하도록 실패를 삼키되,
1015
- * 무엇을 왜 못 넣었는지는 로그로 남긴다(조용한 무효화 금지).
1293
+ * 무엇을 왜 못 넣었는지는 로그로 남긴다(들어온 것이 없는 무효화 금지).
1016
1294
  */
1017
1295
  static async installEstimators(kernel: any, domainId: string, instanceId: string, model: any): Promise<void> {
1018
1296
  if (!kernel || typeof kernel !== 'object') return
@@ -1028,7 +1306,7 @@ export class TwinEngine {
1028
1306
  /*
1029
1307
  * **양품률도 이력에서 배운다** (2026-08-19) — 소요와 같은 자리에서 붙인다.
1030
1308
  *
1031
- * 커널이 그 시임을 갖지 않은 버전이면(발행 이전) 조용히 넘어가지 않고 말한다: 수율이 상수로 남은
1309
+ * 커널이 그 시임을 갖지 않은 버전이면(발행 이전) 알리지 않고 넘어가지 않고 말한다: 수율이 상수로 남은
1032
1310
  * 이유를 모르고 지나가면, 화면의 불량 판정이 그 현장의 사실이 아니라 우리 상수의 결과다.
1033
1311
  */
1034
1312
  const yields = await this.measuredYield(domainId, instanceId)
@@ -1064,7 +1342,7 @@ export class TwinEngine {
1064
1342
  }
1065
1343
 
1066
1344
  /**
1067
- * 실측 추정기 — **예측 요청마다 저널을 다시 접지 않는다.**
1345
+ * 실측 추정기 — **예측 요청마다 저널을 다시 계산하지 않는다.**
1068
1346
  *
1069
1347
  * 예측 커널은 요청마다 새로 세워지고(미러 예측·백테스트), 화면은 시각을 긁으면 계속 재예측한다.
1070
1348
  * 거기에 KPI 조회를 그대로 달면 요청당 저널 스캔이 하나씩 붙는다 — 실측은 분 단위로 바뀌지 않으므로
@@ -1075,7 +1353,7 @@ export class TwinEngine {
1075
1353
  {
1076
1354
  at: number
1077
1355
  value: ReturnType<typeof buildMeasuredEstimator> | undefined
1078
- /** 같은 폴드에서 나온 양품률 — **저널을 두 번 접지 않는다**(소요와 수율은 같은 창의 같은 사실이다). */
1356
+ /** 같은 폴드에서 나온 양품률 — **저널을 두 번 계산하지 않는다**(소요와 수율은 같은 창의 같은 사실이다). */
1079
1357
  yields?: ReturnType<typeof buildYieldEstimator>
1080
1358
  }
1081
1359
  >()
@@ -1091,11 +1369,11 @@ export class TwinEngine {
1091
1369
  * 다시 넣으므로). 버리는 것이 손해가 아닌 이유: 이 값은 캐시이고, 없으면 다시 계산한다.
1092
1370
  *
1093
1371
  * 수를 크게 잡는다 — 트윈 규모는 늘 크고, 항목 하나는 작업 종류별 소요 몇 줄이다. 상한이 작으면
1094
- * 정상 규모에서 서로 밀어내며 캐시가 무의미해진다(그게 더 나쁘다: 조용히 느려진다).
1372
+ * 정상 규모에서 서로 밀어내며 캐시가 무의미해진다(그게 더 나쁘다: 알리지 않고 느려진다).
1095
1373
  */
1096
1374
  private static readonly MEASURED_MAX = 5_000
1097
1375
 
1098
- /** 이 트윈이 이력에서 배운 양품률 — 소요와 **같은 폴드·같은 캐시**에서 온다(저널을 두 번 접지 않는다). */
1376
+ /** 이 트윈이 이력에서 배운 양품률 — 소요와 **같은 폴드·같은 캐시**에서 온다(저널을 두 번 계산하지 않는다). */
1099
1377
  private static async measuredYield(domainId: string, instanceId: string) {
1100
1378
  await this.measuredEstimator(domainId, instanceId)
1101
1379
  return this.measuredCache.get(runtimeKey(domainId, instanceId))?.yields
@@ -1112,7 +1390,7 @@ export class TwinEngine {
1112
1390
  /* 작업 종류별 실측 — 창은 넉넉히(하루) 두고 표본이 모자란 종류는 추정기가 스스로 뺀다. */
1113
1391
  const kpi: any = await computeTwinKpi({ domainId, instanceId, windowMinutes: 24 * 60, groupBy: 'taskKind' })
1114
1392
  value = buildMeasuredEstimator(kpi?.groups?.items, {})
1115
- /* 같은 그룹에서 양품률도 배운다 — 한 번 접은 저널을 둘이 나눠 쓴다. */
1393
+ /* 같은 그룹에서 양품률도 배운다 — 한 번 계산한 저널을 둘이 나눠 쓴다. */
1116
1394
  yields = buildYieldEstimator(kpi?.groups?.items, {})
1117
1395
  } catch (err) {
1118
1396
  twinWarn(`[twin-engine] "${instanceId}": measured duration lookup failed — falling back to declared/default durations.`, (err as any)?.message)
@@ -1152,7 +1430,7 @@ export class TwinEngine {
1152
1430
  }
1153
1431
  const plan = planStimulus(config, { hasScenarioEngine: !!inst.runtime?.scenario, mode: inst.mode }, validateScenario as any)
1154
1432
  if (plan.action === 'skip') {
1155
- /* 선언이 없는 것은 정상이므로 조용히 지난다. 나머지 셋은 **말한다** — 선언했는데 안 실린 상태다. */
1433
+ /* 선언이 없는 것은 정상이므로 알리지 않고 지난다. 나머지 셋은 **말한다** — 선언했는데 안 실린 상태다. */
1156
1434
  if (plan.reason !== 'none') {
1157
1435
  twinWarn(
1158
1436
  `[twin-engine] "${instanceId}": a stimulus is declared on its source but was not loaded (${plan.reason}${plan.detail ? `: ${plan.detail}` : ''}).` +
@@ -1204,7 +1482,7 @@ export class TwinEngine {
1204
1482
  * 보여야 하는 화면이 정작 그때 아무 말도 못 한다.
1205
1483
  *
1206
1484
  * 그 빈칸을 이력로 메운다: 저널에서 배운 종류는 **추정기가 이미 답할 수 있는 종류**이므로, 지어내는
1207
- * 것이 아니라 있는 사실을 꺼내는 것이다. 같은 캐시(60초)를 쓰므로 조회마다 저널을 다시 접지 않는다.
1485
+ * 것이 아니라 있는 사실을 꺼내는 것이다. 같은 캐시(60초)를 쓰므로 조회마다 저널을 다시 계산하지 않는다.
1208
1486
  */
1209
1487
  static async measuredOperationKinds(domainId: string, instanceId: string): Promise<string[]> {
1210
1488
  const measured = await this.measuredEstimator(domainId, instanceId)
@@ -1233,7 +1511,7 @@ export class TwinEngine {
1233
1511
  *
1234
1512
  * 계산은 커널이 자기 상태에서 한다(`kernel.capacity`). 여기서 하는 일은 **기준 주를 정해 주는
1235
1513
  * 것**뿐이다: 공휴일이 없는 평상주여야 한다 — 공휴일은 연간 가용량을 따로 깎지, 이 공장의 평상시
1236
- * 상한을 정하지 않는다. 커널이 임의로 고르게 두면 그 주에 공휴일이 끼었을 때 상한이 조용히 낮아진다.
1514
+ * 상한을 정하지 않는다. 커널이 임의로 고르게 두면 그 주에 공휴일이 끼었을 때 상한이 알리지 않고 낮아진다.
1237
1515
  *
1238
1516
  * 트윈이 기동 중이 아니면 `undefined` 다 — 0 이 아니다. 기동하지 않은 트윈의 상한을 0 이라고 답하면 화면은
1239
1517
  * "이 공장은 아무것도 못 만든다" 고 말한다.
@@ -1297,7 +1575,7 @@ export class TwinEngine {
1297
1575
  }
1298
1576
 
1299
1577
  /**
1300
- * 미러 기동의 연속성 씨앗 — 이어받은 것은 **말한다**(조용히 잇지 않는다).
1578
+ * 미러 기동의 연속성 씨앗 — 이어받은 것은 **말한다**(알리지 않고 잇지 않는다).
1301
1579
  *
1302
1580
  * 커널이 그 문을 열어 두지 않았으면 그 사실도 말한다: 그 트윈은 재기동마다 열린 구간을 잃는다.
1303
1581
  */
@@ -1334,6 +1612,8 @@ export class TwinEngine {
1334
1612
 
1335
1613
  const Kernel = kernelFor(kind)
1336
1614
  const kernel: TwinKernel = new Kernel(domainId, undefined, this.productionSpecOf(model))
1615
+ /* 커널은 도메인으로 선다 — 어느 트윈인지는 여기서만 알려 줄 수 있다(§`factScope`). */
1616
+ ;(kernel as any).scopeId = id
1337
1617
  kernel.loadTwinModel(model) // 구조만. 상태는 아래 웜스타트가 주입한다.
1338
1618
  this.applyOperations(kernel, model, id) // 시간·수율 명세(있으면) — 없으면 커널 기본값
1339
1619
  /* 추정기는 DB 조회를 포함해 비동기 — 기동을 막지 않고 붙는다(붙기 전 작업은 명세·상수로 산출). */
@@ -1384,6 +1664,7 @@ export class TwinEngine {
1384
1664
  /* 정책은 부르는 쪽이 선언한다 — 여기서 고르면 같은 트윈이 부르는 자리에 따라 다르게 재기동한다. */
1385
1665
  restartPolicy: readRestartPolicy(restartPolicy, `start("${id}")`),
1386
1666
  spaceId: (model as any)?.spaceId,
1667
+ model,
1387
1668
  /*
1388
1669
  * **시뮬도 계기를 든다** (2026-08-20).
1389
1670
  *
@@ -1430,7 +1711,7 @@ export class TwinEngine {
1430
1711
  * 것은 시뮬만 옆문으로 들어오기 때문"* 이라 예고한 그 자리다.
1431
1712
  *
1432
1713
  * **리비전은 커널의 것을 그대로 든다**(라이브는 flush 때 호스트가 부여한다). 시뮬의 저널은
1433
- * 커널 리비전으로 접히므로 여기서 다시 번호를 매기면 시간여행이 어긋난다.
1714
+ * 커널 리비전으로 번호가 정해지므로 여기서 다시 번호를 매기면 시간여행이 어긋난다.
1434
1715
  */
1435
1716
  ;(inst.pendingJournal ?? (inst.pendingJournal = [])).push({ event: msg.event, revision: (msg as any).revision })
1436
1717
  /*
@@ -1474,7 +1755,7 @@ export class TwinEngine {
1474
1755
  * 줄줄이 생겼다: 예측하려면 임시 커널을 세워야 했고(`buildForecastKernel`), 주목 신호를 호스트가
1475
1756
  * 덧붙여야 했고(`withLiveAttentions`), AI 예측 도구는 미러에서 "찾을 수 없다" 로 끝났다.
1476
1757
  *
1477
- * 이제 라이브 인스턴스도 **커널이다.** 같은 규칙(`ObservedReducer`)으로 이벤트를 접고, 주목 신호를
1758
+ * 이제 라이브 인스턴스도 **커널이다.** 같은 규칙(`ObservedReducer`)으로 이벤트를 계산하고, 주목 신호를
1478
1759
  * 스스로 내고, 그 자리에서 `fork` 해 예측한다. 구동만 다르다 — sim 은 `tick`, live 는 `apply`.
1479
1760
  *
1480
1761
  * 실 이벤트원 = reference 어댑터 openLiveFeed → face2-adapter.ingest → CanonicalEnvelope → ingestLive().
@@ -1496,7 +1777,7 @@ export class TwinEngine {
1496
1777
  * 이름이 그대로 굳은 것), 일반 기제에 한 시스템 이름이 붙어 있었기 때문에 창고 트윈이 이 자리를
1497
1778
  * 쓰지 못했다. 별명으로 남겨 두면 그 혼동이 계속되므로 하나로 통일했다.
1498
1779
  *
1499
- * 옛 이름만 가진 모델이 있으면 **조용히 생산 선언을 잃는 대신 분명히 멈춘다** — 그 트윈은 공정이
1780
+ * 옛 이름만 가진 모델이 있으면 **알리지 않고 생산 선언을 잃는 대신 분명히 멈춘다** — 그 트윈은 공정이
1500
1781
  * 없는 채로 돌게 되고(라인이 서 있는 창고), 원인을 찾기 어렵다.
1501
1782
  */
1502
1783
  private static productionSpecOf(model: TwinModelDef | undefined): any {
@@ -1538,6 +1819,8 @@ export class TwinEngine {
1538
1819
  if (this.instances[key]) return this.instances[key]
1539
1820
  const Kernel = kernelFor(kind)
1540
1821
  const kernel: any = new Kernel(domainId, undefined, this.productionSpecOf(model))
1822
+ /* 커널은 도메인으로 선다 — 어느 트윈인지는 여기서만 알려 줄 수 있다(§`factScope`). */
1823
+ ;(kernel as any).scopeId = id
1541
1824
  kernel.loadTwinModel(model)
1542
1825
  /* **세우는 쪽이 아는 사실은 세울 때 말한다.** 예전에는 첫 이벤트가 도착해야 커널이 스스로를
1543
1826
  관측 구동으로 여겼고, 그래서 아직 아무것도 못 받은 미러는 시뮬레이션 취급을 받았다. */
@@ -1546,11 +1829,11 @@ export class TwinEngine {
1546
1829
  /* `projector` 필드는 옛 이름으로 남긴다 — 소비처가 `snapshot()` 을 부르므로 얇은 어댑터로 잇는다.
1547
1830
  * (P3 에서 소비처를 커널 어휘로 바꾸면 사라진다.) */
1548
1831
  const projector = { apply: (e: CanonicalEnvelope) => kernel.apply(e), snapshot: () => kernel.getSnapshot() }
1549
- const inst: InstanceRuntime = { id, domainId, mode: 'live', restartPolicy: 'resync', kernel, projector, oee: new OeeAccumulator(), spaceId: (model as any)?.spaceId, unsub: () => {} }
1832
+ const inst: InstanceRuntime = { id, domainId, mode: 'live', restartPolicy: 'resync', kernel, projector, oee: new OeeAccumulator(), spaceId: (model as any)?.spaceId, model, unsub: () => {} }
1550
1833
  /*
1551
1834
  * ── 커널이 **판정으로 낸 사실**도 저널에 남는다 (2026-08-14 실측으로 잡음) ────
1552
1835
  *
1553
- * 라이브는 인입 봉투만 저널에 적고 있었다(`ingestLive`). 그런데 커널은 관측을 접다가 **자기 사실**을
1836
+ * 라이브는 인입 봉투만 저널에 적고 있었다(`ingestLive`). 그런데 커널은 관측을 계산하다가 **자기 사실**을
1554
1837
  * 낸다 — 에너지의 수요 구간 마감·피크 경신·감축 제안이 그렇다(`emitOp`). 그것을 구독하는 곳이
1555
1838
  * 없어서 그 사실들이 **커널 안에서 사라졌다**: 표본 48건이 저널에 쌓였는데 구간 마감은 0건이었고,
1556
1839
  * 저널을 읽는 성과 화면의 전력 타일은 영원히 나오지 않았다.
@@ -1575,7 +1858,7 @@ export class TwinEngine {
1575
1858
  this.installEstimators(kernel, domainId, id, model).catch(err => twinWarn('[twin-engine] estimator install failed', err?.message))
1576
1859
  inst.metrics = this.newMetrics()
1577
1860
  this.instances[key] = inst
1578
- /* 미러에도 부른다 — 선언이 있으면 「미러에는 싣지 않는다」고 말해야 한다(조용한 무시 금지). */
1861
+ /* 미러에도 부른다 — 선언이 있으면 「미러에는 싣지 않는다」고 말해야 한다(들어온 것이 없는 무시 금지). */
1579
1862
  this.installStimulus(domainId, id, inst).catch(err => twinWarn(`[twin-engine] "${id}": stimulus check failed — ${err?.message ?? err}`))
1580
1863
  /*
1581
1864
  * **원천이 되풀어 주지 않는 것만 잇는다** (2026-08-18 실측으로 붙임).
@@ -1596,11 +1879,11 @@ export class TwinEngine {
1596
1879
  * 정정이 오지 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채로 남는다.
1597
1880
  * 그리고 그 빈 채로 화면이 「이상 없음」을 보였다 — 사실이 사라지는 동안 화면이 안심시킨 것이다.
1598
1881
  *
1599
- * 되돌리는 것은 **상태가 아니라 재개점**이다. 상태만 심으면 그 뒤를 이어 접은 결과가 0부터 접은
1600
- * 결과와 조용히 달라진다(리듀서는 보류된 담김·집계 중인 수량도 든다). 그 동치는 커널 시험이
1601
- * 증명한다(`observed-checkpoint.test.ts` — 재개점 + 꼬리 == 0부터 접기).
1882
+ * 되돌리는 것은 **상태가 아니라 재개점**이다. 상태만 심으면 그 뒤를 이어 계산한 결과가 0부터 계산한
1883
+ * 결과와 오류 없이 달라진다(리듀서는 보류된 담김·집계 중인 수량도 든다). 그 동치는 커널 시험이
1884
+ * 증명한다(`observed-checkpoint.test.ts` — 재개점 + 꼬리 == 0부터 계산하기).
1602
1885
  *
1603
- * 씨앗은 **그 공장이 아직 그 공장일 때만** 오고, 마지막 체크포인트 이후의 사실은 이미 접혀 들어
1886
+ * 씨앗은 **그 공장이 아직 그 공장일 때만** 오고, 마지막 체크포인트 이후의 사실은 이미 계산되어 들어
1604
1887
  * 있다(`warmSeedFor`). 씨앗이 없으면 전과 같이 빈 채로 시작한다 — 지어내지 않는다.
1605
1888
  */
1606
1889
  const seed = this.recovered[key]?.fold?.reducer
@@ -1651,10 +1934,19 @@ export class TwinEngine {
1651
1934
  * 비동기라 그 사이에 이벤트가 몇 건 들어와 있을 수 있는데, 그때 뒤로 되돌리는 것은 커널이 거절한다
1652
1935
  * (겹치는 번호를 막는 그 판정이다). 거절은 삼키지 않고 남긴다.
1653
1936
  */
1937
+ /*
1938
+ * 관계로 묻지 않는다 — `findOne({ where: { domain: { id } } })` 은 두 단계 질의가 되고 안쪽에
1939
+ * 상한이 없어 그 트윈의 저널을 **전부 만든 뒤** 한 줄을 고른다(§`tipOf` 의 실측). 여기는 부팅
1940
+ * 경로이므로 그 비용이 기동을 붙잡는다. 색인의 끝 한 줄만 읽는다.
1941
+ */
1654
1942
  getRepository(TwinEvent)
1655
- .findOne({ where: { domain: { id: domainId }, instanceId: id }, order: { revision: 'DESC' } })
1943
+ .createQueryBuilder('e')
1944
+ .select('MAX(e.revision)', 'max')
1945
+ .where('e.domain = :domainId', { domainId })
1946
+ .andWhere('e.instanceId = :instanceId', { instanceId: id })
1947
+ .getRawOne<{ max: unknown }>()
1656
1948
  .then(top => {
1657
- const head = top?.revision ?? 0
1949
+ const head = Number(top?.max) || 0
1658
1950
  inst.revision = head
1659
1951
  if (!head || typeof (kernel as any).resumeRevision !== 'function') return
1660
1952
  try {
@@ -1676,17 +1968,110 @@ export class TwinEngine {
1676
1968
  *
1677
1969
  * ── 넣은 수를 **답한다** (2026-08-20) ────────────────────────────────────────
1678
1970
  * 트윈이 라이브로 돌지 않으면 여기서 봉투를 버린다. 그것 자체는 맞다(넣을 커널이 없다). 문제는
1679
- * **조용히** 버린 것이었다: 트윈이 멈춘 뒤에도 피드는 남아 레코드를 나르고, 유입 장부는 그것을
1971
+ * **알리지 않고** 버린 것이었다: 트윈이 멈춘 뒤에도 피드는 남아 레코드를 나르고, 유입 장부는 그것을
1680
1972
  * 「통과」로 셌다. 화면은 멈춘 트윈 옆에 「150 통과 · 100%」라고 적었다 — 사실이 사라지는 동안
1681
1973
  * 화면이 안심시킨 것이다.
1682
1974
  *
1683
1975
  * 그래서 **넣은 수를 돌려준다.** 부르는 쪽이 제시 수와 견주어 버려진 수를 장부에 적는다. 반환을
1684
1976
  * 무시하는 호출부는 그대로 동작한다(전과 같다).
1685
1977
  */
1686
- static ingestLive(domainId: string, id: string, envelopes: CanonicalEnvelope[]): number {
1978
+ /**
1979
+ * 유입 결과를 수로 낸다 — 받은 수 · 중복으로 버린 수 · 보낸 수.
1980
+ *
1981
+ * `ingestLive` 는 받은 수만 돌려주는데, 웹훅으로 받을 때는 **몇 건이 중복이었는지도 답해야** 한다.
1982
+ * 밀어 주는 쪽이 그 수를 보고 자기가 되풀이 보내고 있다는 것을 알 수 있어야 하고, 그것을 모르면
1983
+ * 우리 응답이 「전부 새 것이었다」로 읽힌다.
1984
+ */
1985
+ static ingestLiveResult(
1986
+ domainId: string,
1987
+ id: string,
1988
+ envelopes: CanonicalEnvelope[],
1989
+ source = 'poll'
1990
+ ): { offered: number; applied: number; duplicates: number } {
1991
+ const before = this.ingestLedgers[runtimeKey(domainId, id)]?.duplicates ?? 0
1992
+ const offered = envelopes.length
1993
+ const applied = this.ingestLive(domainId, id, envelopes, source)
1994
+ const after = this.ingestLedgers[runtimeKey(domainId, id)]?.duplicates ?? 0
1995
+ return { offered, applied, duplicates: Math.max(0, after - before) }
1996
+ }
1997
+
1998
+ /**
1999
+ * **마스터데이터를 받는다** — 변하지 않는 속성을 상태에 세운다. 저널에 적지 않는다.
2000
+ *
2001
+ * ── 왜 저널에 적지 않나 ────────────────────────────────────────────────────
2002
+ * 유통기한·로트번호 같은 값은 그 물건의 생애 동안 같다. 그래서 「그때는 얼마였나」라는 물음이
2003
+ * 성립하지 않는다 — 시각축이 없는 값이므로 사건이 아니다. 되세울 근거는 중간 저장본이 든다
2004
+ * (리듀서가 그 값을 저장본에 담는다).
2005
+ *
2006
+ * ── 이 문이 없어서 무엇이 났나 (2026-08-28 실측) ───────────────────────────
2007
+ * 표준은 그 값을 `ObjectEvent(action=ADD)` 에만 실을 수 있게 한다. 그래서 전량을 읽는 원본이
2008
+ * 재고를 다시 말할 때마다 **들어오지 않은 것을 「들어왔다」** 고 했고, 사건 단위 값이라 자리별로
2009
+ * 묶을 수도 없어 낱개로 나갔다 — 재기동 한 번에 2,665건이 「읽은 시각」으로 쌓였다.
2010
+ *
2011
+ * 돌려주는 수는 **상태에 반영된 건수**다. 라이브로 돌지 않는 트윈은 0 을 낸다 — 그 차이를 부르는
2012
+ * 쪽이 알아야 한다(버려진 것을 통과로 세면 멈춘 트윈이 정상으로 보인다).
2013
+ *
2014
+ * 설계: `operato-twin/design/plans/master-data.md`
2015
+ */
2016
+ static applyMasterData(domainId: string, id: string, elements: readonly VocabularyElement[]): number {
2017
+ if (!elements.length) return 0
2018
+ const inst = this.instances[runtimeKey(domainId, id)]
2019
+ if (inst?.mode !== 'live' || !inst.kernel) return 0
2020
+ const kernel = inst.kernel as any
2021
+ if (typeof kernel.applyMasterData !== 'function') {
2022
+ twinWarn(
2023
+ `[twin-engine] kernel for "${id}" cannot take master data (no applyMasterData) — ` +
2024
+ 'lot attributes such as expiry will stay empty for this twin.'
2025
+ )
2026
+ return 0
2027
+ }
2028
+ const n = kernel.applyMasterData(elements) as number
2029
+ /*
2030
+ * **돌았다는 것을 로그로 남긴다** (2026-08-28).
2031
+ *
2032
+ * 이 값은 지난 기록에 적히지 않으므로(시각축이 없다) 세지 않으면 이 통로가 돌았는지 아무 데도
2033
+ * 남지 않는다. 처음에는 유입 장부에 수를 더했는데 **그것이 편법이었다**: 유입 장부는 「원본에서
2034
+ * 사실이 얼마나 들어오고 거부되고 버려지나」를 답하는 자리이고, 마스터데이터는 사실의 흐름이 아니라
2035
+ * 선언의 동기다. 그리고 그 수를 읽는 화면이 없었다 — 내가 확인하려고 만든 값이었다.
2036
+ *
2037
+ * 확인은 **로그의 일**이다. 정상 상태에서는 이 줄이 안 나오는 것이 맞다 — 값이 그대로면 원본이
2038
+ * 다시 말하지 않는다. 나오면 그때가 무언가 바뀐 때다.
2039
+ */
2040
+ if (n > 0) twinLog(`[twin-engine] "${id}": 마스터데이터 ${n}건을 상태에 세웠다(지난 기록에는 적지 않는다).`)
2041
+ /* 상태가 바뀌었으니 다음 창에서 화면으로 흘러야 한다 — 관측 반영과 같은 규율이다. */
2042
+ inst.dirty = true
2043
+ this.ensureBroadcastCoalescer()
2044
+ return n
2045
+ }
2046
+
2047
+ static ingestLive(domainId: string, id: string, envelopes: CanonicalEnvelope[], source = 'poll'): number {
1687
2048
  const inst = this.instances[runtimeKey(domainId, id)]
1688
2049
  if (inst?.mode !== 'live' || !inst.projector) return 0
1689
2050
  const tIngest = performance.now()
2051
+ /*
2052
+ * ── 같은 사실을 두 번 받으면 한 번만 반영한다 (2026-08-26) ─────────────────
2053
+ *
2054
+ * 이 자리가 유입 경계다 — 커넥터가 물어서 받은 것 · 시나리오가 넣는 것 · 부하 도구가 넣는 것이
2055
+ * 모두 여기를 지난다(웹훅으로 받는 길이 생기면 그것도 여기로 온다). 그래서 중복을 여기서 한 번
2056
+ * 거른다.
2057
+ *
2058
+ * 지금까지는 커넥터마다 각자 막고 있었다. 그래서 잊으면 트윈이 그대로 두 번 셌다 — 태양광
2059
+ * 커넥터가 한 주기에 76건을 되풀이 보냈고, 커넥터를 고쳐서 2건이 됐다. 다음 커넥터가 같은 것을
2060
+ * 잊으면 또 난다.
2061
+ *
2062
+ * 절대값으로 말하는 사실은 두 번 반영해도 결과가 같지만(잔량 · 상태 · 적산), 세는 값은 아니다:
2063
+ * 구간의 표본 수 · 평균 부하 · OEE 카운터 · 유입 계수. 그리고 지난 기록에 같은 사실이 두 줄로
2064
+ * 남으면, 나중에 그 기록으로 다시 계산할 때 또 두 번 센다.
2065
+ *
2066
+ * **정확히 같은 것만 버린다.** 늦게 도착한 옛 사실은 버리지 않는다 — 그것이 왔다는 것도 사실이고,
2067
+ * 상태에 반영하지 않는 판단은 커널이 이미 한다.
2068
+ */
2069
+ const { fresh, duplicates } = (inst.deduper ??= new FactDeduper()).filter(envelopes)
2070
+ envelopes = fresh
2071
+ const ledgerKey = runtimeKey(domainId, id)
2072
+ const ledger = this.ingestLedgers[ledgerKey] ?? (this.ingestLedgers[ledgerKey] = newIngestLedger())
2073
+ if (duplicates) recordDuplicates(ledger, duplicates, Date.now())
2074
+ if (envelopes.length) recordIngestSource(ledger, source, envelopes.length, Date.now())
1690
2075
  /* 인입 봉투를 표시해 두고 넣는다 — 커널이 그것을 재방출해도 저널에 두 번 적히지 않게(위 구독 주석). */
1691
2076
  for (const e of envelopes) {
1692
2077
  if (e && typeof e === 'object') inst.applying?.add(e as object)
@@ -1710,20 +2095,41 @@ export class TwinEngine {
1710
2095
  * 구간 성과 브로드캐스팅은 **없앴다**(2026-08-06). 카드가 `twinKpi` 를 직접 묻는다.
1711
2096
  *
1712
2097
  * 왜: 카드를 여러 단계(공간·트윈·구역·자리·설비)에 붙이려면 브로드캐스팅으로는 태그가 트윈당 1,200개가 되고,
1713
- * **모델에 카드를 하나도 안 놓아도** 30초마다 트윈마다 저널을 접었다. 질의로 바꾸니 보고 있는 카드
2098
+ * **모델에 카드를 하나도 안 놓아도** 30초마다 트윈마다 저널을 계산했다. 질의로 바꾸니 보고 있는 카드
1714
2099
  * 수만큼만 들고, 같은 (대상·창·축) 은 클라이언트가 하나로 합친다.
1715
2100
  *
1716
2101
  * 덤으로 질의만 할 수 있는 것이 둘 생겼다 — **과거 시각**(`toTime`)과 **공간 단위 합산**(여러 트윈을
1717
- * 한 번에 접기). 브로드캐스팅 루프는 트윈별이라 둘 다 못 했다.
2102
+ * 한 번에 계산). 브로드캐스팅 루프는 트윈별이라 둘 다 못 했다.
1718
2103
  *
1719
2104
  * 축을 나눠도 폴드 비용이 같다는 실측이 근거다(`test/kpi-query-bench.test.ts`).
1720
2105
  */
1721
2106
  /**
1722
2107
  * 몇 창마다 한 번은 **전부** 만드나 — 사건 없이 값이 바뀌는 자리에 대한 그물.
1723
2108
  *
1724
- * 25 창이면 기본 주기에서 5초다. 보장이 아니라 그물이다(위 `publishEntityData` 주석).
2109
+ * 보장이 아니라 그물이다(위 `publishEntityData` 주석). **세는 단위는 창이지만 뜻은 시간**이므로,
2110
+ * 기본 주기를 200밀리초에서 1초로 늘린 날 이 수도 함께 줄였다(25창 → 5창). 그러지 않으면 그물이
2111
+ * 5초에서 25초로 벌어져, 시각만으로 바뀌는 값(유효 기간 만료 같은 것)이 그만큼 늦게 보인다.
1725
2112
  */
1726
- static FULL_BROADCAST_EVERY = 25
2113
+ static FULL_BROADCAST_EVERY = 5
2114
+ /**
2115
+ * **한 실행 차례에 발행할 수 있는 건수** — 이만큼 발행하면 루프를 비워 준다 (2026-08-27).
2116
+ *
2117
+ * ── 왜 상한이 필요한가 (실측으로 서버가 두 번 죽었다) ──────────────────────
2118
+ * 구독의 대기열에는 버퍼가 없다(`pubsub.subscribe` 가 `new Repeater(fn)` 을 버퍼 없이 만든다).
2119
+ * 발행하는 순간에 「다음 값을 달라」는 요청이 이미 걸려 있어야 바로 전달되고, 아니면 대기열에
2120
+ * 쌓인다. 상한은 1024 이고 넘으면 예외가 난다(`@repeaterjs/repeater` 의 `MAX_QUEUE_LENGTH`).
2121
+ *
2122
+ * 값을 꺼내는 쪽은 비동기다. 그래서 **동기 반복문이 도는 중에는 한 건도 꺼내지 못한다.** 라이브러리로
2123
+ * 직접 재 본 값이다 — 소비자를 붙여 두고 2,000건을 동기로 발행하면 소비자가 꺼낸 것은 1건이었고
2124
+ * 나머지가 쌓여 예외가 났다.
2125
+ *
2126
+ * 그 예외는 `publish()` 호출자에게 오지 않는다. `EventTarget` 이 리스너 예외를 uncaughtException 으로
2127
+ * 보내므로 try/catch 로는 잡을 수 없다(2026-08-14 에 그 가드를 붙였는데 그래서 무력했다). 막는 방법은
2128
+ * **한 차례에 상한을 넘기지 않는 것** 하나다.
2129
+ *
2130
+ * 1024 보다 넉넉히 작게 둔다 — 소비자가 한 청크를 다 꺼내지 못해도 다음 청크까지 여유가 남는다.
2131
+ */
2132
+ static PUBLISH_CHUNK = 200
1727
2133
  /** 전부 만든 횟수 — 범위를 좁히지 못한 창이 얼마나 되는지 값으로 남는다. */
1728
2134
  static broadcastFullPasses = 0
1729
2135
  /**
@@ -1759,7 +2165,21 @@ export class TwinEngine {
1759
2165
  }
1760
2166
 
1761
2167
  /** 브로드캐스팅 병합 주기(ms) — 브로드캐스팅률 상한. 인제스트가 아무리 빨라도 이 주기로만 브로드캐스팅. */
1762
- static BROADCAST_COALESCE_MS = 200
2168
+ /*
2169
+ * ── 200 → 1000 (2026-08-25 · 사용자 결정) ──────────────────────────────────
2170
+ * 트윈 **둘**에서 이미 주기를 스스로 늘리고 있었다(한 번 보내는 데 270밀리초). 200밀리초는 상태가
2171
+ * 작을 때의 값이고, 지금 승화푸드 하나가 엔티티 17,600개다(물품 6,494 · 오더 4,794 · 작업 6,353).
2172
+ *
2173
+ * 한 번 보내는 값은 상태 크기에 비례하므로 주기를 늘리는 것으로 그 값이 줄지는 않는다. 대신 **단위
2174
+ * 시간에 그 값을 치르는 횟수**가 5분의 1이 된다. 그 사이 호스트가 HTTP·구독을 처리한다.
2175
+ *
2176
+ * 화면이 늦게 갱신되는 것은 최대 1초다. 최신-상태 채널이라 밀린 것을 쌓아 보내지 않고 마지막 하나만
2177
+ * 가므로, 늦어질 뿐 내용은 같다.
2178
+ *
2179
+ * **비용을 줄인 것이 아니다.** 줄이는 것은 변경분만 만드는 일이고(바뀐 것만 다시 만드는 일) 그것은 별
2180
+ * 작업이다 — 계획: `design/plans/live-broadcast-cost.md`.
2181
+ */
2182
+ static BROADCAST_COALESCE_MS = 1000
1763
2183
  /**
1764
2184
  * ── 브로드캐스팅 주기는 **재 본 비용에 맞춘다** (2026-08-21 실측) ────────────────────
1765
2185
  * 한 번의 브로드캐스팅은 상태 크기에 비례한다(실측: 물품 2,400 개인 트윈 하나가 4.5ms — 상태 투영 1.7ms,
@@ -1773,12 +2193,13 @@ export class TwinEngine {
1773
2193
  * 이것은 브로드캐스팅 비용을 **줄이는 것이 아니다** — 비용을 줄이는 것은 변경분만 만드는 일이고 그것은 별
1774
2194
  * 작업이다. 여기서는 그때까지 호스트가 굶지 않게 상한을 둔다.
1775
2195
  */
1776
- static BROADCAST_MAX_COALESCE_MS = 1000
2196
+ /* 기본이 1초가 되었으므로 늘릴 여지를 함께 올린다 — 상한이 기본과 같으면 늘릴 곳이 없다. */
2197
+ static BROADCAST_MAX_COALESCE_MS = 2000
1777
2198
  /** 주기의 몇 몫까지 브로드캐스팅에 써도 되는가 — 넘으면 주기를 늘린다(절반이면 나머지 절반은 남긴다). */
1778
2199
  static BROADCAST_LOAD_RATIO = 0.3
1779
2200
  /** 지금 쓰고 있는 주기(ms) — 계기판이 이 값을 읽는다. 늘어난 채로 있으면 그것이 사실이다. */
1780
2201
  static broadcastPeriodMs = 200
1781
- /** 주기를 늘린 횟수 — 조용히 늦추지 않는다. */
2202
+ /** 주기를 늘린 횟수 — 알리지 않고 늦추지 않는다. */
1782
2203
  static broadcastBackoffs = 0
1783
2204
  private static broadcastTimer?: any
1784
2205
 
@@ -1826,7 +2247,33 @@ export class TwinEngine {
1826
2247
  * 한 tick 에서 수백 번 반복되며 이벤트 루프를 막았다(위 `start()` 주석의 35초 틱). 브로드캐스팅을 모으는
1827
2248
  * 규율은 모드의 성질이 아니라 **채널의 성질**이다 — 최신-상태 채널이면 중간 상태는 보낼 값이 없다.
1828
2249
  */
2250
+ /**
2251
+ * 브로드캐스팅 한 창 — **겹쳐 돌지 않는다** (2026-08-27).
2252
+ *
2253
+ * 발행이 청크로 나뉘어 사이에 루프를 비워 주므로 한 창이 주기를 넘길 수 있다. 그때 다음 타이머가
2254
+ * 겹쳐 들어오면 같은 태그가 두 순서로 나가고 시그니처 표가 엉킨다. 그래서 앞 창이 끝날 때까지
2255
+ * 다음 타이머는 그대로 돌아간다 — **창을 버리는 것이 아니다.** 다음 타이머가 이어서 한다.
2256
+ *
2257
+ * 겹친 횟수를 센다. 계속 겹치면 주기보다 한 창이 오래 걸리고 있다는 뜻이고, 그것은
2258
+ * `adjustBroadcastPeriod` 가 주기를 올려 스스로 풀어야 하는 사실이다.
2259
+ */
2260
+ private static flushInFlight = false
2261
+ static flushOverruns = 0
2262
+
1829
2263
  static flushLiveBroadcasts(): void {
2264
+ if (this.flushInFlight) {
2265
+ this.flushOverruns++
2266
+ return
2267
+ }
2268
+ this.flushInFlight = true
2269
+ void this.flushBroadcastWindow()
2270
+ .catch(err => twinError('[twin-engine] broadcast window failed', err?.message ?? err))
2271
+ .finally(() => {
2272
+ this.flushInFlight = false
2273
+ })
2274
+ }
2275
+
2276
+ private static async flushBroadcastWindow(): Promise<void> {
1830
2277
  const now = Date.now()
1831
2278
  const flushStart = performance.now()
1832
2279
  for (const inst of Object.values(this.instances)) {
@@ -1860,7 +2307,7 @@ export class TwinEngine {
1860
2307
  inst.fullBroadcastCountdown = left
1861
2308
  }
1862
2309
  // ① 엔티티 data(tag) 브로드캐스팅 — 보드 컴포넌트 라이브 렌더.
1863
- this.publishEntityData(inst)
2310
+ await this.publishEntityData(inst)
1864
2311
  if (m) { m.broadcastTotal++; m._accBroadcast++ }
1865
2312
  /* ③ 저널 배치 기록 — **두 구동이 같은 문을 쓴다**(시뮬도 여기서 흘린다, §7.1). */
1866
2313
  this.flushJournal(inst)
@@ -1909,7 +2356,7 @@ export class TwinEngine {
1909
2356
  correlationId: e?.correlationId,
1910
2357
  revision,
1911
2358
  /* **이 사실이 일어난 공장**을 함께 찍는다 — 이것이 없으면 나중에 구조가 바뀌었을 때 이 행을
1912
- 새 공장에 대고 접게 되고, 그때 없던 설비에서 일이 있었던 것처럼 보인다. */
2359
+ 새 공장에 대고 계산하게 되고, 그때 없던 설비에서 일이 있었던 것처럼 보인다. */
1913
2360
  ...(structureRev === undefined ? {} : { structureRev }),
1914
2361
  /* 커널은 ISO 문자열을 준다. 컬럼은 날짜다 — **여기서 옮긴다.** 문자열을 그대로 넣으면
1915
2362
  드라이버가 제 형식으로 정규화하지 않아, 나중에 날짜로 거는 질의에 안 걸린다. */
@@ -1949,12 +2396,12 @@ export class TwinEngine {
1949
2396
  * 모아 둔 저널을 **한 번에** 쓴다 — 두 구동이 같은 문을 쓴다 (§7.1).
1950
2397
  *
1951
2398
  * ── 왜 한 함수인가 ─────────────────────────────────────────────────────────
1952
- * 쓰는 자리가 둘이면(주기 flush · 정지) 한쪽만 고쳐지고, 그 어긋남은 **사실이 조용히 사라지는**
2399
+ * 쓰는 자리가 둘이면(주기 flush · 정지) 한쪽만 고쳐지고, 그 어긋남은 **사실이 오류 없이 사라지는**
1953
2400
  * 모양으로 나타난다. 그래서 흘리는 규칙을 여기 한 곳에 둔다.
1954
2401
  *
1955
2402
  * ── 리비전을 누가 매기나 ───────────────────────────────────────────────────
1956
2403
  * · 라이브 — 원천은 리비전을 주지 않으므로 **호스트가** 이어 붙인다(저널 high-water 에서 시드).
1957
- * · 시뮬 — **커널의 리비전**이 실려 온다(저널이 그것으로 접히고 시간여행이 그것을 딛는다).
2404
+ * · 시뮬 — **커널의 리비전**이 실려 온다(저널이 그 번호로 정렬되고 시간여행이 그것을 딛는다).
1958
2405
  * 그래서 버퍼는 두 모양을 함께 든다: 봉투만 있으면 라이브, `{ event, revision }` 이면 시뮬이다.
1959
2406
  */
1960
2407
  private static flushJournal(inst: InstanceRuntime): Promise<void> {
@@ -1976,9 +2423,24 @@ export class TwinEngine {
1976
2423
 
1977
2424
  if (carried.length) jobs.push(this.persistCarried(inst.domainId, inst.id, carried))
1978
2425
  if (plain.length && inst.revision != null) {
2426
+ /*
2427
+ * ── 마감된 구간 사실은 한 번만 적는다 (2026-08-30) ───────────────────────
2428
+ *
2429
+ * 이미 있는 것을 걸러낸 뒤에 번호를 매긴다. 걸러내기 전에 매기면 **쓰지 않은 번호가 비고**,
2430
+ * 그 구멍이 저널의 리비전 연속성을 끊는다.
2431
+ */
2432
+ /*
2433
+ * 번호는 **먼저 잡는다**(동기). 걸러낸 뒤에 잡으면 두 flush 가 겹칠 때 같은 번호를 두 번 쓴다.
2434
+ * 걸러진 만큼 번호가 비지만, 그 구멍은 해롭지 않다 — 리비전은 같은 시각을 가리는 데만 쓰고
2435
+ * 이어짐을 요구하지 않는다(저널 머리는 최대값이다).
2436
+ */
1979
2437
  const start = inst.revision
1980
2438
  inst.revision = start + plain.length
1981
- jobs.push(this.persistBatch(inst.domainId, inst.id, plain, start))
2439
+ jobs.push(
2440
+ this.dropAlreadyWritten(inst.domainId, inst.id, plain).then(fresh =>
2441
+ fresh.length ? this.persistBatch(inst.domainId, inst.id, fresh, start) : undefined
2442
+ )
2443
+ )
1982
2444
  }
1983
2445
  if (!jobs.length) return Promise.resolve()
1984
2446
  /*
@@ -2005,11 +2467,106 @@ export class TwinEngine {
2005
2467
  * 예전에는 이 경로가 **델타마다 한 행씩** 저장했다(그리고 행마다 구조 리비전을 물었다). 규모에서 그것이
2006
2468
  * 호스트를 먹었다(§7.1 실측). 여기서 구조 리비전은 **한 번만** 묻는다.
2007
2469
  */
2470
+ /**
2471
+ * **마감된 구간 사실은 한 번만 적는다** — 같은 사실이 두 번 오면 한 사실이다 (2026-08-30).
2472
+ *
2473
+ * ── 왜 쓰는 자리에서 막나 ──────────────────────────────────────────────────
2474
+ * 커널이 그 사실들의 정체성을 내용에서 만든다(종류·대상·시작·끝). 그런데 저널의 기본키는 새로
2475
+ * 만드는 uuid 라, **id 가 글자 하나까지 같아도 행이 따로 쌓였다.**
2476
+ *
2477
+ * 실측(2026-08-30): 발전 단가가 행 7개·서로 다른 id 4개였다. 8월 27일 단가가 세 벌 있었고,
2478
+ * 그 구간의 수익이 세 번 세어진다.
2479
+ *
2480
+ * 커넥터가 커서로 막는 것이 첫 방어선이고 그쪽도 고쳤다. 그런데 **커넥터가 한 번 실수하면 저널이
2481
+ * 부풀고 그 위의 모든 합이 틀린다.** 정체성은 커널이 선언한 사실이므로, 어느 문으로 들어오든
2482
+ * 지켜져야 한다 — 채우기 경로에만 있으면 그 보증은 「어느 문으로 왔느냐」에 걸린다.
2483
+ *
2484
+ * ── 값이 싼 이유 ───────────────────────────────────────────────────────────
2485
+ * 마감된 구간 사실만 본다(시점의 관측은 그대로 지난다 — 반복이 뜻을 가진다). 그것들은 드물어서
2486
+ * (한 시간에 한 줄) 한 묶음에 몇 개뿐이고, 그 구간의 그 종류만 색인으로 읽는다.
2487
+ */
2488
+ private static readonly PERIOD_FACT_TYPES = new Set([
2489
+ 'energy.usage.period',
2490
+ 'energy.generation.period',
2491
+ 'energy.generation.price',
2492
+ 'energy.tariff.basis',
2493
+ 'energy.bill'
2494
+ ])
2495
+
2496
+ static async dropAlreadyWritten(domainId: string, instanceId: string, envelopes: any[]): Promise<any[]> {
2497
+ const periodFacts = envelopes.filter(e => this.PERIOD_FACT_TYPES.has(String(e?.eventType)) && typeof e?.eventId === 'string')
2498
+ if (!periodFacts.length) return envelopes
2499
+
2500
+ const times = periodFacts.map(e => Date.parse(String(e.eventTime))).filter(n => Number.isFinite(n))
2501
+ if (!times.length) return envelopes
2502
+ const types = [...new Set(periodFacts.map(e => String(e.eventType)))]
2503
+
2504
+ const existing = await getRepository(TwinEvent)
2505
+ .createQueryBuilder('e')
2506
+ .select(['e.payload AS "payload"'])
2507
+ .where('e.domain = :domainId', { domainId })
2508
+ .andWhere('e.instanceId = :instanceId', { instanceId })
2509
+ .andWhere('e.eventType IN (:...types)', { types })
2510
+ .andWhere('e.eventTime BETWEEN :fromAt AND :toAt', { fromAt: new Date(Math.min(...times)), toAt: new Date(Math.max(...times)) })
2511
+ .getRawMany<{ payload: unknown }>()
2512
+ .catch(() => [] as { payload: unknown }[])
2513
+
2514
+ const seen = new Set<string>()
2515
+ for (const row of existing) {
2516
+ let p: any = row.payload
2517
+ if (typeof p === 'string') {
2518
+ try {
2519
+ p = JSON.parse(p)
2520
+ } catch {
2521
+ p = undefined
2522
+ }
2523
+ }
2524
+ if (typeof p?.eventId === 'string' && p.eventId) seen.add(p.eventId)
2525
+ }
2526
+ /* 한 묶음 안에 같은 것이 둘 있어도 하나만 적는다. */
2527
+ const kept: any[] = []
2528
+ for (const e of envelopes) {
2529
+ if (!this.PERIOD_FACT_TYPES.has(String(e?.eventType)) || typeof e?.eventId !== 'string') {
2530
+ kept.push(e)
2531
+ continue
2532
+ }
2533
+ if (seen.has(e.eventId)) continue
2534
+ seen.add(e.eventId)
2535
+ kept.push(e)
2536
+ }
2537
+ return kept
2538
+ }
2539
+
2008
2540
  static async persistCarried(domainId: string, instanceId: string, items: { event: any; revision: number }[]): Promise<void> {
2009
2541
  const repo = getRepository(TwinEvent)
2010
2542
  const structureRev = await this.structureRevOf(domainId, instanceId)
2011
2543
  const rows = items.map(it => this.journalRow(repo, domainId, instanceId, it.event, it.revision, structureRev))
2012
2544
  await this.insertRows(repo, rows)
2545
+ await this.writeSubjects(domainId, instanceId, items.map(it => ({ envelope: it.event, revision: it.revision })))
2546
+ }
2547
+
2548
+ /**
2549
+ * **대상별 사건 목록에 함께 적는다** — 조회용 파생 표 (2026-08-27).
2550
+ *
2551
+ * 저널을 쓰는 두 문(`persistBatch` · `persistCarried`) 뒤에서 부른다. 다른 자리에서 나중에 적으면
2552
+ * 「저널에는 있고 그 표에는 없는 사건」이 생기고, 그것은 화면에서 오류 없이 빠진 사건이 된다.
2553
+ *
2554
+ * **저널이 먼저다.** 이 표에 적다가 실패해도 저널 쓰기를 되돌리지 않는다 — 사실의 원본은 저널이고
2555
+ * 이 표는 다시 만들 수 있다. 실패는 세어 남긴다(§`subjectWriteFailures`).
2556
+ *
2557
+ * 설계: `operato-twin/design/plans/read-models.md`
2558
+ */
2559
+ private static async writeSubjects(
2560
+ domainId: string,
2561
+ instanceId: string,
2562
+ entries: { envelope: any; revision: number }[]
2563
+ ): Promise<void> {
2564
+ if (!entries.length) return
2565
+ const reg = this.instances[runtimeKey(domainId, instanceId)]
2566
+ const itemOf = itemResolverFor(domainId, instanceId, reg?.model)
2567
+ await writeSubjectRows(domainId, instanceId, entries, itemOf).catch(err =>
2568
+ twinWarn(`[twin-subject] "${instanceId}" 대상 목록 쓰기 실패 — ${err?.message ?? err}`)
2569
+ )
2013
2570
  }
2014
2571
 
2015
2572
  /**
@@ -2053,6 +2610,11 @@ export class TwinEngine {
2053
2610
  const structureRev = await this.structureRevOf(domainId, instanceId)
2054
2611
  const rows = envelopes.map((e, i) => this.journalRow(repo, domainId, instanceId, e, startRevision + i + 1, structureRev))
2055
2612
  await this.insertRows(repo, rows)
2613
+ await this.writeSubjects(
2614
+ domainId,
2615
+ instanceId,
2616
+ envelopes.map((e, i) => ({ envelope: e, revision: startRevision + i + 1 }))
2617
+ )
2056
2618
  }
2057
2619
 
2058
2620
  /** 레지스트리 upsert(도메인+instanceId 유니크). status 인자로 provision(stopped)/start(running) 공용. */
@@ -2085,7 +2647,7 @@ export class TwinEngine {
2085
2647
  areaId: (model as any)?.areaId ?? existing?.areaId,
2086
2648
  /* 재기동 정책(ADR-0029 §2) — 명시값 우선, 없으면 기존값 보존(재프로비전이 선언을 지우지 않게).
2087
2649
  **둘 다 없으면 오류를 낸다**: 예전에는 여기서 기본값을 각인했는데, 그 기본값이 「저널 초기화」라서
2088
- 선언을 빠뜨린 프로비저닝이 조용히 이력을 지우는 트윈을 만들었다. 선언은 부르는 쪽의 몫이다. */
2650
+ 선언을 빠뜨린 프로비저닝이 알리지 않고 이력을 지우는 트윈을 만들었다. 선언은 부르는 쪽의 몫이다. */
2089
2651
  restartPolicy:
2090
2652
  restartPolicy ??
2091
2653
  readRestartPolicy(
@@ -2110,6 +2672,23 @@ export class TwinEngine {
2110
2672
  }
2111
2673
 
2112
2674
  /**
2675
+ * ── **트윈은 설비의 마스터가 아니다** (사용자 결정 2026-08-30) ─────────────
2676
+ *
2677
+ * 인원·설비·자산의 마스터는 `@things-factory/ops-master` 다. 트윈은 그것을 **당겨 와** 모델에
2678
+ * 반영하고, 여기 남는 것은 그 결과의 캐시다.
2679
+ *
2680
+ * 그래서 이 함수가 하는 일에 선이 있다.
2681
+ *
2682
+ * 트윈 안에서 사람이 더한 설비 가정·시뮬레이션의 사실이다 — 트윈이 든다 (지금 이 함수)
2683
+ * 현장에 실제로 있는 설비 마스터의 사실이다 — 트윈이 만들지 않는다
2684
+ *
2685
+ * 이 함수는 **트윈 제어 명령**(`resource.add`) 뒤에만 불린다. 관측에서 설비가 생기는 경로가 아니다.
2686
+ * 관측으로 모르는 설비가 들어오면 그것은 **마스터에 없는 것**이고, 트윈이 조용히 만들어 주면 두
2687
+ * 곳이 서로 다른 설비 목록을 갖게 된다. 그때는 만들지 말고 「모르는 설비가 왔다」로 세어야 한다.
2688
+ *
2689
+ * 당겨 오는 길이 붙으면 이 주석의 위쪽 절반만 남는다.
2690
+ *
2691
+ * ── 원래 설명 ─────────────────────────────────────────────────────────────
2113
2692
  * 라이브 구조 변이(resource.add 등)를 저장된 model 에 반영 — 런타임 커널의 무버를 registry model.equipment 에 동기.
2114
2693
  * 이게 없으면 런타임엔 추가돼도(상태·저널엔 반영) 저장 model 는 원본이라 프로비저닝 편집기·재기동(loadTwinModel)이
2115
2694
  * 추가분을 잃는다. 기존 model.equipment 항목은 보존(homeLocation 유지)하고 새 id 만 append(추가 시점 location=homeLocation).
@@ -2142,7 +2721,7 @@ export class TwinEngine {
2142
2721
  /**
2143
2722
  * 실행 중이면 구조를 교체하지 않는다 — **판정 문장을 한 곳에 둔다.**
2144
2723
  *
2145
- * 인제스트는 전량 교체라, 이벤트를 접고 있는 커널 밑에서 바닥을 바꾸는 셈이 된다. 지금은 거절이
2724
+ * 인제스트는 전량 교체라, 이벤트를 계산하고 있는 커널 밑에서 바닥을 바꾸는 셈이 된다. 지금은 거절이
2146
2725
  * 유일한 답이지만 최종형은 아니다(`adoptStructure` 배선). 두 곳에서 같은 문장으로 거절해야
2147
2726
  * 나중에 이 규칙을 걷어낼 때 걷어낼 것이 하나로 보인다.
2148
2727
  */
@@ -2173,7 +2752,7 @@ export class TwinEngine {
2173
2752
  * 세 가지가 **함께** 일어나야 한다. 하나라도 빠지면 층이 어긋난다:
2174
2753
  * ① 리비전 — 이 시점 이후의 이벤트가 새 번호를 달고 다닌다(재생이 마디를 나눌 수 있게)
2175
2754
  * ② 저장 model·구조 행 — 조회가 새 구조를 본다
2176
- * ③ 커널 — 지금 접히는 이벤트가 새 구조 위에서 접힌다
2755
+ * ③ 커널 — 지금 계산에 들어가는 이벤트가 새 구조 위에서 계산된다
2177
2756
  */
2178
2757
  static async adoptStructure(
2179
2758
  domainId: string,
@@ -2192,7 +2771,7 @@ export class TwinEngine {
2192
2771
 
2193
2772
  const rev = await this.recordStructure(domainId, instanceId, model, comment)
2194
2773
  await this.register(domainId, instanceId, reg.kind, model, 'running', undefined, origin)
2195
- /* 커널을 **마지막에** 전환한다 — 저장이 실패하면 메모리만 새 구조가 되어, 재기동하면 조용히
2774
+ /* 커널을 **마지막에** 전환한다 — 저장이 실패하면 메모리만 새 구조가 되어, 재기동하면 알리지 않고
2196
2775
  옛 구조로 돌아간다(고치기 어려운 어긋남이다). */
2197
2776
  const shift: StructureShift = inst.kernel.adoptStructure(model)
2198
2777
  /*
@@ -2210,7 +2789,7 @@ export class TwinEngine {
2210
2789
  * 준다**(넣은 값이 화면에서 보이지 않는다). 구조가 바뀐 순간이 곧 캐시를 다시 그릴 순간이다.
2211
2790
  *
2212
2791
  * 실패는 흡수한다: 투영이 막혀도(예: 모델에 중복 id) 커널은 이미 새 구조로 돌고 있으므로 그 사실을
2213
- * 되돌리지 않는다 — 다만 조용히 넘기지 않고 말한다.
2792
+ * 되돌리지 않는다 — 다만 알리지 않고 넘기지 않고 말한다.
2214
2793
  */
2215
2794
  await projectStructure(domainId, instanceId, model, instanceId).catch((err: any) =>
2216
2795
  twinWarn(`[twin-engine] "${instanceId}" adopted a new structure but its projected rows were not refreshed — ${err?.message ?? err}`)
@@ -2220,7 +2799,7 @@ export class TwinEngine {
2220
2799
  * **구조가 바뀐 순간이 상태가 바뀐 순간이다** — 그러니 브로드캐스팅한다.
2221
2800
  *
2222
2801
  * ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────
2223
- * 구조 전환은 커널만 갈고 조용히 끝났다. 그런데 화면이 보는 것 상당수가 구조에서 파생된다 —
2802
+ * 구조 전환은 커널만 갈고 알리지 않고 끝났다. 그런데 화면이 보는 것 상당수가 구조에서 파생된다 —
2224
2803
  * 계약 대비 판정, 주목 신호, 자리 색. 현장이 계약을 고쳐 선언한 순간 조건이 성립하는데도, 상태
2225
2804
  * 브로드캐스팅이 없어서 지도 레일은 **다음 계측 표본이 올 때까지** 옛 화면을 들고 있었다(그 사이 헤더
2226
2805
  * 배지는 4초 폴링으로 먼저 알아, 「배지엔 있고 목록엔 없는」 어긋난 화면이 실제로 보였다).
@@ -2245,7 +2824,7 @@ export class TwinEngine {
2245
2824
  * **재기동 정책은 생성 시점의 선언이다**(ADR-0029 §2) — 트윈은 정책 없이 존재할 수 없다.
2246
2825
  *
2247
2826
  * 예전에는 이 자리가 없었고 `register` 가 기본값(`sim-experiment` = 저널 초기화)을 각인했다. 그래서
2248
- * 선언을 빠뜨린 프로비저닝이 **재기동마다 이력을 지우는 트윈**을 조용히 만들었다. 이제 만드는 쪽이
2827
+ * 선언을 빠뜨린 프로비저닝이 **재기동마다 이력을 지우는 트윈**을 알리지 않고 만들었다. 이제 만드는 쪽이
2249
2828
  * 말해야 한다: 이 트윈이 자기 과거를 어떻게 대하는지는 만드는 사람이 아는 사실이다.
2250
2829
  */
2251
2830
  restartPolicy: RestartPolicy,
@@ -2265,12 +2844,12 @@ export class TwinEngine {
2265
2844
  * **구조가 바뀌어도 역사를 지우지 않는다.**
2266
2845
  *
2267
2846
  * 예전에는 서명이 다르면 그 트윈의 저널을 통째로 지웠다. 안 지우면 옛 이벤트를 새 공장에 대고
2268
- * 접게 되어 이력이 거짓말을 했기 때문이다 — 부스가 둘이던 시절의 사실을 여섯 개짜리 공장에
2269
- * 접으면 그때 없던 부스에서 일이 있었던 것처럼 보인다. 선택지가 **역사를 잃거나 거짓말을
2847
+ * 계산하게 되어 이력이 거짓말을 했기 때문이다 — 부스가 둘이던 시절의 사실을 여섯 개짜리 공장에
2848
+ * 계산하면 그때 없던 부스에서 일이 있었던 것처럼 보인다. 선택지가 **역사를 잃거나 거짓말을
2270
2849
  * 하거나** 둘뿐이었고, 그래서 공장을 고칠 때마다 이력을 버려야 했다.
2271
2850
  *
2272
2851
  * 이제 셋째 길로 간다: 바뀐 구조를 **새 리비전**으로 남기고, 앞으로 쓰이는 이벤트가 그 번호를
2273
- * 달고 다닌다. 재생은 구조가 바뀌는 지점에서 전환한 뒤 이어 접는다(`replaySegments`).
2852
+ * 달고 다닌다. 재생은 구조가 바뀌는 지점에서 전환한 뒤 이어서 계산한다(`replaySegments`).
2274
2853
  */
2275
2854
  await this.recordStructure(domainId, instanceId, model, comment)
2276
2855
  await this.register(domainId, instanceId, kind, model, existing?.status === 'running' ? 'stopped' : (existing?.status as any) ?? 'stopped', restartPolicy, origin, meta)
@@ -2433,7 +3012,7 @@ export class TwinEngine {
2433
3012
  * 웜스타트 재료를 **여기서 확실히 확보한다.**
2434
3013
  * `start()` 는 동기라 스스로 캐시를 읽을 수 없어서 `recovered` 에 미리 담겨 있기를 기대하는데,
2435
3014
  * 그건 `bootstrap()` 이 먼저 돌았을 때만 참이다. 부팅 순서에 기대면 어떤 날은 재고가 살아나고
2436
- * 어떤 날은 조용히 빈 채로 뜬다 — 재현되지 않는 결함이 가장 나쁘다.
3015
+ * 어떤 날은 알리지 않고 빈 채로 뜬다 — 재현되지 않는 결함이 가장 나쁘다.
2437
3016
  * 체크포인트 캐시 우선(O(1) + 라이브 파생상태 보존), 없으면 저널 replay 폴백(부팅과 같은 순서).
2438
3017
  */
2439
3018
  if (!this.recovered[key] && reg.purpose !== 'bench') {
@@ -2464,10 +3043,10 @@ export class TwinEngine {
2464
3043
  instanceId,
2465
3044
  domainId,
2466
3045
  /* 커널 종류·현실 선언은 레지스트리에 반드시 있다(둘 다 NOT NULL). 예전에는 `?? 'wms'` 로
2467
- 메웠는데, 그건 YMS/MES 트윈을 **조용히 WMS 로 부팅**시키는 길이었다 — 오류 없이 다른 공장이 뜬다. */
3046
+ 메웠는데, 그건 YMS/MES 트윈을 **알리지 않고 WMS 로 부팅**시키는 길이었다 — 오류 없이 다른 공장이 뜬다. */
2468
3047
  reg.kind,
2469
3048
  reg.model as TwinModelDef,
2470
- /* 저장된 값은 위에서 **엄격히** 읽었다 — 모르는 값을 기본값으로 메우면 그 트윈이 조용히 다르게 재기동한다. */
3049
+ /* 저장된 값은 위에서 **엄격히** 읽었다 — 모르는 값을 기본값으로 메우면 그 트윈이 알리지 않고 다르게 재기동한다. */
2471
3050
  policy,
2472
3051
  reg.purpose,
2473
3052
  Number(last?.max ?? 0) || 0
@@ -2497,7 +3076,7 @@ export class TwinEngine {
2497
3076
  * 있는데 아무 일도 일어나지 않는」 상태가 됐다.
2498
3077
  *
2499
3078
  * 이것은 `start` 가 이미 배운 교훈과 같은 자리다: 부팅 순서에 기대면 어떤 날은 상태가 살아나고
2500
- * 어떤 날은 조용히 빈 채로 뜬다 — **재현되지 않는 결함이 가장 나쁘다.** 그래서 순서에 기대지 않고
3079
+ * 어떤 날은 알리지 않고 빈 채로 뜬다 — **재현되지 않는 결함이 가장 나쁘다.** 그래서 순서에 기대지 않고
2501
3080
  * 이 자리에서 확보한다(이미 담겨 있으면 그것을 쓴다).
2502
3081
  */
2503
3082
  const key = runtimeKey(domainId, instanceId)
@@ -2704,7 +3283,7 @@ export class TwinEngine {
2704
3283
  const agg = new Map<string, { spaceId: string; name: string; instances: number; locations: number; equipment: number }>()
2705
3284
  for (const r of insts) {
2706
3285
  /* 벤치 제외는 **선언으로만** 판단한다. 예전에는 spaceId 이름(`bench-`·`loadtest*`)으로도 추측했는데,
2707
- 그러면 그렇게 이름 지은 진짜 운영 공간이 목록에서 조용히 사라진다. purpose 가 정본이다. */
3286
+ 그러면 그렇게 이름 지은 진짜 운영 공간이 목록에서 오류 없이 사라진다. purpose 가 정본이다. */
2708
3287
  if (!r.spaceId || r.purpose === 'bench') continue
2709
3288
  const model: any = r.model ?? {}
2710
3289
  const a = agg.get(r.spaceId) ?? { spaceId: r.spaceId, name: nameOf.get(r.spaceId) ?? r.spaceId, instances: 0, locations: 0, equipment: 0 }
@@ -2801,7 +3380,7 @@ export class TwinEngine {
2801
3380
  * 다시 그리면 그대로 사라진다 — 금액이 사라지고, 사용자는 자기가 넣은 값이 어디로 갔는지 알 수 없다.
2802
3381
  * 그래서 겹으로 보관한 선언을 매번 다시 얹는다(`engine/local-declarations`).
2803
3382
  *
2804
- * 대상이 사라졌으면(구조가 바뀌었다) 그 사실을 경고로 낸다 — 갈 곳 없는 선언을 조용히 버리지 않는다.
3383
+ * 대상이 사라졌으면(구조가 바뀌었다) 그 사실을 경고로 낸다 — 갈 곳 없는 선언을 알리지 않고 버리지 않는다.
2805
3384
  */
2806
3385
  const prior = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId: master.source } })
2807
3386
  /*
@@ -2838,7 +3417,7 @@ export class TwinEngine {
2838
3417
  }
2839
3418
  }
2840
3419
  const repo = getRepository(TwinSpace)
2841
- /* 합칠 공간을 골랐으면 **있는지 먼저 확인한다** — 없는 곳에 조용히 새 공간을 만들면 사용자는
3420
+ /* 합칠 공간을 골랐으면 **있는지 먼저 확인한다** — 없는 곳에 알리지 않고 새 공간을 만들면 사용자는
2842
3421
  합쳤다고 믿고 화면은 따로 논다. 판정 함수가 그 경우 거절한다. */
2843
3422
  const wanted = (into?.spaceId ?? '').trim()
2844
3423
  const wantedExists = wanted
@@ -2849,7 +3428,7 @@ export class TwinEngine {
2849
3428
  model.spaceId = spaceId // 보드(커널 소비)도 같은 공간을 가리켜야 한다
2850
3429
  const existing = await repo.findOne({ where: { domain: { id: domainId }, spaceId } })
2851
3430
  if (choice.joined) {
2852
- /* 같은 id 의 구역·랜드마크는 합집합으로 접힌다 — 구분할 수 없는 것을 고르지 않고 무엇이
3431
+ /* 같은 id 의 구역·랜드마크는 합집합으로 합쳐진다 — 구분할 수 없는 것을 고르지 않고 무엇이
2853
3432
  합쳐졌는지 말한다(정당한 경우가 많으므로 막지 않는다). */
2854
3433
  }
2855
3434
  /*
@@ -2875,7 +3454,7 @@ export class TwinEngine {
2875
3454
  * 대표 표현 포인터는 **표현을 저장한 뒤** 실제 행 id 로 채운다(아래). 여기서 마스터의 논리
2876
3455
  * id(`r-map` 따위)를 넣으면 표현 행은 생성 uuid 를 받으므로 포인터가 **처음부터 허공을
2877
3456
  * 가리킨다** — 실제로 13개 현장이 그 상태였고 정합성 점검이 잡았다(2026-08-13). 화면은
2878
- * 행의 `isPrimary` 로 ★를 그려서 증상이 보이지 않았다(사실이 두 벌이면 이렇게 조용하다).
3457
+ * 행의 `isPrimary` 로 ★를 그려서 증상이 보이지 않았다(사실이 두 벌이면 이렇게 들어온 것이 없다).
2879
3458
  */
2880
3459
  primaryRepresentationId: existing?.primaryRepresentationId ?? null
2881
3460
  })
@@ -2938,7 +3517,7 @@ export class TwinEngine {
2938
3517
 
2939
3518
  if (running) {
2940
3519
  /* 실행 중인 미러 — 멈추지 않고 전환한다. 무엇이 사라졌는지는 **경고로 말한다**
2941
- (멈췄다 세우는 마디가 없으므로, 말하지 않으면 자리 하나가 조용히 없어진다). */
3520
+ (멈췄다 세우는 마디가 없으므로, 말하지 않으면 자리 하나가 알리지 않고 없어진다). */
2942
3521
  const shift = await this.adoptStructure(domainId, master.source, model, undefined, master.origin)
2943
3522
  warnings.push(structureAdopted(shift.rev, shift))
2944
3523
  } else {
@@ -2955,7 +3534,7 @@ export class TwinEngine {
2955
3534
  *
2956
3535
  * 인제스트가 유일한 쓰기 경로다. 여기서 실패해도 인제스트 자체는 성공으로 둔다 —
2957
3536
  * 행은 **원본에서 언제든 다시 그릴 수 있는 캐시**이고, 트윈 자체는 model 로 이미 동작한다.
2958
- * 다만 **조용히 넘기지 않는다**: 못 이은 참조는 인제스트 경고로 올라간다.
3537
+ * 다만 **알리지 않고 넘기지 않는다**: 못 이은 참조는 인제스트 경고로 올라간다.
2959
3538
  */
2960
3539
  try {
2961
3540
  const projected = await projectStructure(domainId, master.source, model, master.source)
@@ -3014,6 +3593,19 @@ export class TwinEngine {
3014
3593
  * 못 보낸 것은 **사실로 남긴다**(횟수를 세고 창마다 한 줄 남긴다). 삼키면 「보냈는데 화면이 낡았다」가
3015
3594
  * 되고, 그건 가장 찾기 어려운 부류다.
3016
3595
  */
3596
+ /**
3597
+ * 브로드캐스팅 한 건 — **이 가드가 대기열 넘침을 막지는 못한다** (2026-08-27 측정으로 바로잡음).
3598
+ *
3599
+ * 2026-08-14 에 이 함수를 「방송이 호스트를 죽이지 못하게」라는 이름으로 붙였다. 그것이 사실이 아니다.
3600
+ * 대기열이 넘칠 때 나는 예외는 `EventTarget` 이 부른 리스너 안에서 난다. 런타임은 리스너 예외를
3601
+ * 호출자에게 주지 않고 uncaughtException 으로 보낸다. 그래서 이 try/catch 에는 잡을 것이 없다.
3602
+ * 실제로 그 뒤 3주 동안 막힌 줄 알고 있었고, 2026-08-27 10:24 에 같은 예외로 다시 죽었다.
3603
+ *
3604
+ * 넘침을 막는 것은 **한 실행 차례에 발행하는 수를 상한 아래로 두는 것**이다(§`PUBLISH_CHUNK`).
3605
+ *
3606
+ * 그래도 이 가드를 남기는 이유는 둘이다. `publish` 가 직접 던지는 경우(직렬화 실패 등)를 잡고,
3607
+ * 못 보낸 태그의 시그니처를 되돌린다 — 남겨 두면 그 태그가 오류 없이 영원히 낡은 값을 보인다.
3608
+ */
3017
3609
  private static publishGuarded(channel: 'data' | 'twin-state', payload: any, what: string): boolean {
3018
3610
  try {
3019
3611
  pubsub.publish(channel as any, payload)
@@ -3031,7 +3623,7 @@ export class TwinEngine {
3031
3623
  }
3032
3624
  }
3033
3625
 
3034
- static publishEntityData(inst: InstanceRuntime): void {
3626
+ static async publishEntityData(inst: InstanceRuntime): Promise<void> {
3035
3627
  const domain = inst.domain
3036
3628
  if (!domain) return
3037
3629
  /* 상태 출처 스왑 — sim: 커널 runtime, live: projector 미러(+OEE 계산 층 보강). 계약·payload 동일, 드라이버만 다름. */
@@ -3064,7 +3656,7 @@ export class TwinEngine {
3064
3656
  * 문자열로 바꾼 뒤 「같다」를 확인하고 버렸다.
3065
3657
  *
3066
3658
  * 범위는 이 창에 들어온 사건에서 모았다(`touchedItemKeys`). **말할 수 없는 사건이 하나라도 있으면
3067
- * 범위는 없고 전부 만든다** — 낯선 어휘가 오면 조용히 빠뜨리는 대신 비싸게 안전한 쪽으로 떨어진다.
3659
+ * 범위는 없고 전부 만든다** — 낯선 어휘가 오면 알리지 않고 빠뜨리는 대신 비싸게 안전한 쪽으로 떨어진다.
3068
3660
  *
3069
3661
  * 그리고 주기마다 한 번은 **무조건 전부** 만든다(`FULL_BROADCAST_EVERY`). 커널이 사건 없이 물품을
3070
3662
  * 바꾸는 자리가 생기면 그 값이 화면에 남을 수 있는데, 그 창을 몇 초로 묶는 그물이다. **보장이 아니라
@@ -3080,6 +3672,22 @@ export class TwinEngine {
3080
3672
  recordPhase(load, 'deltas', performance.now() - tDelta)
3081
3673
 
3082
3674
  const tPub = performance.now()
3675
+ /*
3676
+ * ── **한 실행 차례에 상한까지만 발행한다** (2026-08-27 실측으로 고침) ────────
3677
+ *
3678
+ * 이 반복문이 동기였다. 구독의 대기열에는 버퍼가 없고, 발행하는 순간에 「다음 값을 달라」는 요청이
3679
+ * 걸려 있지 않으면 쌓인다. 값을 꺼내는 쪽은 비동기라서 **동기 반복문이 도는 중에는 한 건도 꺼내지
3680
+ * 못한다.** 그래서 N 건을 발행하면 첫 건만 전달되고 나머지가 쌓였고, 1024 를 넘는 순간 예외가 나서
3681
+ * 프로세스가 내려갔다(2026-08-27 10:24 · 2026-08-14 에도 같은 예외).
3682
+ *
3683
+ * `PUBLISH_CHUNK` 마다 루프를 비워 준다. 그 틈에 소비자가 대기열을 꺼내 간다.
3684
+ *
3685
+ * **건너뛰지 않는다.** 이 창의 발행은 끝까지 한다 — 중간에 그만두면 그 태그가 낡은 값으로 남는다.
3686
+ * 이 함수가 주기보다 오래 걸리면 다음 타이머는 그대로 돌아가고(§`flushLiveBroadcasts`),
3687
+ * `adjustBroadcastPeriod` 가 주기를 올린다.
3688
+ */
3689
+ let published = 0
3690
+ let sinceYield = 0
3083
3691
  for (const { tag, data } of deltas) {
3084
3692
  seen.add(tag)
3085
3693
  const sig = JSON.stringify(data)
@@ -3090,8 +3698,28 @@ export class TwinEngine {
3090
3698
  * 그 태그는 영원히 낡은 값을 보여 준다(오류 없이). 되돌려 두면 다음 주기가 다시 시도한다.
3091
3699
  */
3092
3700
  if (!this.publishGuarded('data', { data: { domain, tag, data } }, `data:${inst.id}`)) sigs.delete(tag)
3701
+ published++
3702
+ if (++sinceYield >= this.PUBLISH_CHUNK) {
3703
+ sinceYield = 0
3704
+ await new Promise(resolve => setImmediate(resolve))
3705
+ }
3093
3706
  }
3094
3707
  recordPhase(load, 'publish', performance.now() - tPub)
3708
+ /*
3709
+ * ── **몇 건을 발행했는지 남긴다** (2026-08-27) ──────────────────────────────
3710
+ *
3711
+ * 10:24 에 서버가 죽었을 때 그 창이 몇 건을 발행했는지 알 수 없었다. 그 수가 없으면 「대기열이
3712
+ * 넘쳤다」는 사실만 있고 무엇이 넘겼는지 판단할 수 없어, 원인을 추측으로 고르게 된다.
3713
+ *
3714
+ * 상한을 넘긴 창만 남긴다 — 매 창을 남기면 로그가 그 자체로 부하가 된다.
3715
+ */
3716
+ inst.publishedTotal = (inst.publishedTotal ?? 0) + published
3717
+ if (published >= this.PUBLISH_CHUNK) {
3718
+ twinLog(
3719
+ `[twin-engine] "${inst.id}" 이번 창에서 ${published}건 발행 ` +
3720
+ `(전체 ${deltas.length}건 중 · ${full ? '전량' : '좁힘'} · 청크 ${this.PUBLISH_CHUNK})`
3721
+ )
3722
+ }
3095
3723
  /*
3096
3724
  * 사라진 엔티티의 시그니처 정리(맵 무한 성장 방지) — **전부 만든 창에서만.**
3097
3725
  * 범위를 좁힌 창의 `seen` 에는 만들지 않은 엔티티가 없으므로, 그때 정리하면 살아 있는 태그의
@@ -3127,18 +3755,18 @@ export class TwinEngine {
3127
3755
  if (live?.mode === 'live' && live.projector) return this.snapshot(domainId, instanceId)
3128
3756
  }
3129
3757
  /*
3130
- * **끝에 있는 스냅샷이면 접지 않는다.**
3758
+ * **끝에 있는 스냅샷이면 계산하지 않는다.**
3131
3759
  *
3132
- * 멈춘 트윈을 조회할 때마다 저널을 전량 접고 있었다 — 트윈을 하나도 안 돌려도 모델 조회가
3760
+ * 멈춘 트윈을 조회할 때마다 저널을 전량 계산하고 있었다 — 트윈을 하나도 안 돌려도 모델 조회가
3133
3761
  * 4.6~7.7초였던 이유다(crew-probe 23,731건). 스냅샷은 같은 폴드의 결과이므로, 그 뒤로 이벤트도
3134
- * 구조 변경도 없다면 **다시 접어 봐야 같은 값**이다.
3762
+ * 구조 변경도 없다면 **다시 계산해 봐야 같은 값**이다.
3135
3763
  *
3136
- * 쓰지 않는 조건을 좁게 잡는다: 지금을 물었을 때만(시간여행은 그 시점까지 접어야 한다), 리비전이
3137
- * 끝과 같을 때만, 구조 리비전까지 같을 때만. 하나라도 어긋나면 접는다 — 캐시가 사실을 이기지 않는다.
3764
+ * 쓰지 않는 조건을 좁게 잡는다: 지금을 물었을 때만(시간여행은 그 시점까지 계산해야 한다), 리비전이
3765
+ * 끝과 같을 때만, 구조 리비전까지 같을 때만. 하나라도 어긋나면 계산한다 — 캐시가 사실을 이기지 않는다.
3138
3766
  */
3139
3767
  const asOfNowRead = untilRevision == null && untilTime == null
3140
3768
  const tip = asOfNowRead ? await this.tipOf(domainId, instanceId).catch(() => null) : null
3141
- /* 한 번만 읽는다 — 끝에 있으면 그대로 쓰고, 아니면 아래에서 **이어 접는 씨앗**으로 쓴다. */
3769
+ /* 한 번만 읽는다 — 끝에 있으면 그대로 쓰고, 아니면 아래에서 **이어 계산하는 씨앗**으로 쓴다. */
3142
3770
  const cachedForResume = asOfNowRead && tip ? await this.loadSnapshot(domainId, instanceId).catch(() => null) : null
3143
3771
  if (cachedForResume) {
3144
3772
  const state = unwrapState(cachedForResume.state)
@@ -3150,22 +3778,22 @@ export class TwinEngine {
3150
3778
  if (!reg?.model) throw new Error(`twin instance "${instanceId}" not registered (no model to replay)`)
3151
3779
 
3152
3780
  /*
3153
- * ── 재개점에서 **이어 접는다** (2026-08-18) ────────────────────────────
3781
+ * ── 재개점에서 **이어서 계산한다** (2026-08-18) ────────────────────────────
3154
3782
  *
3155
- * 우리는 이미 접은 결과를 남기고 있다(`saveFoldedSnapshot`). 그 지점의 **재개점**(리듀서 내부 상태
3156
- * 전부 + 가동 누적기)이 함께 있으면, 그 뒤에 일어난 것만 접어도 같은 답이 나온다 — 그 동치는
3157
- * 커널 시험이 증명한다(0부터 접기 == 재개점 + 꼬리).
3783
+ * 우리는 이미 계산한 결과를 남기고 있다(`saveFoldedSnapshot`). 그 지점의 **재개점**(리듀서 내부 상태
3784
+ * 전부 + 가동 누적기)이 함께 있으면, 그 뒤에 일어난 것만 계산해도 같은 답이 나온다 — 그 동치는
3785
+ * 커널 시험이 증명한다(0부터 계산하기 == 재개점 + 꼬리).
3158
3786
  *
3159
3787
  * 쓰는 조건을 좁게 잡는다: **지금을 물었을 때만**(시간여행은 목표 이전 재개점이 필요한데 지금은
3160
3788
  * 최신 하나만 남긴다 — 사슬은 다음 단계다), 구조가 그대로일 때만(구조가 바뀌면 그 경계에서 갈라
3161
- * 접어야 한다), 재개점이 저널 끝보다 앞설 때만. 하나라도 어긋나면 0부터 접는다 — **캐시가 사실을
3789
+ * 계산해야 한다), 재개점이 저널 끝보다 앞설 때만. 하나라도 어긋나면 0부터 계산한다 — **캐시가 사실을
3162
3790
  * 이기지 않는다.**
3163
3791
  */
3164
3792
  /*
3165
3793
  * 씨앗은 둘 중 하나다: **지금**을 물으면 최신 재개점, **과거**를 물으면 사슬에서 목표 직전 지점.
3166
3794
  *
3167
- * 과거 씨앗은 구조가 한 번도 바뀌지 않은 트윈에서만 쓴다 — 구조가 갈린 저널은 마디마다 갈아 접어야
3168
- * 하고, 마디를 건너뛴 씨앗은 그 경계의 판정을 잃는다(그때는 0부터 접는 것이 옳다).
3795
+ * 과거 씨앗은 구조가 한 번도 바뀌지 않은 트윈에서만 쓴다 — 구조가 갈린 저널은 마디마다 갈아 계산해야
3796
+ * 하고, 마디를 건너뛴 씨앗은 그 경계의 판정을 잃는다(그때는 0부터 계산하는 것이 옳다).
3169
3797
  */
3170
3798
  const structureCount = asOfNowRead
3171
3799
  ? 0
@@ -3196,10 +3824,10 @@ export class TwinEngine {
3196
3824
  const cutoffMs = untilTime != null ? Date.parse(untilTime) : NaN
3197
3825
  const useTime = untilTime != null && !Number.isNaN(cutoffMs)
3198
3826
  const baseWhere = { domain: { id: domainId }, instanceId }
3199
- /* 이어 접을 때는 **그 뒤만** 읽는다 — 재개점까지의 사실은 이미 씨앗 안에 있다. */
3827
+ /* 이어 계산할 때는 **그 뒤만** 읽는다 — 재개점까지의 사실은 이미 씨앗 안에 있다. */
3200
3828
  const from = resume ? (resume.revision ?? 0) : undefined
3201
3829
  /*
3202
- * journal-fold: 재생은 사실을 하나씩 접는 것이므로 그 구간의 행이 필요하다. 커서(`from`)와 시각
3830
+ * journal-fold: 재생은 사실을 하나씩 계산하는 것이므로 그 구간의 행이 필요하다. 커서(`from`)와 시각
3203
3831
  * 상한이 구간을 자르고, 체크포인트가 앞쪽을 씨앗으로 대신한다 — 표 전체를 읽지 않는다.
3204
3832
  */
3205
3833
  const rows = await getRepository(TwinEvent).find({
@@ -3226,14 +3854,14 @@ export class TwinEngine {
3226
3854
  const wanted = rows
3227
3855
 
3228
3856
  /*
3229
- * **그때의 공장으로 접는다.**
3857
+ * **그때의 공장으로 계산한다.**
3230
3858
  *
3231
- * 예전에는 모든 이벤트를 지금 등록된 보드 하나로 접었다. 구조가 바뀐 적 없으면 맞지만, 바뀐
3232
- * 뒤에는 옛 사실을 새 공장에 대고 접게 되어 — 그때 없던 설비에서 일이 있었던 것처럼 보인다.
3859
+ * 예전에는 모든 이벤트를 지금 등록된 보드 하나로 계산했다. 구조가 바뀐 적 없으면 맞지만, 바뀐
3860
+ * 뒤에는 옛 사실을 새 공장에 대고 계산하게 되어 — 그때 없던 설비에서 일이 있었던 것처럼 보인다.
3233
3861
  * (그래서 예전 프로비저닝은 구조가 바뀌면 저널을 아예 지웠다. 역사를 잃거나 거짓말을 하거나.)
3234
3862
  *
3235
- * 이제 이벤트가 자기 구조를 달고 오므로, 리비전이 바뀌는 지점에서 구조를 갈아타며 이어 접는다.
3236
- * 리비전이 하나뿐이거나(대다수) 아예 없으면(구조 이력 이전) 예전과 똑같이 한 번에 접는다.
3863
+ * 이제 이벤트가 자기 구조를 달고 오므로, 리비전이 바뀌는 지점에서 구조를 갈아타며 이어서 계산한다.
3864
+ * 리비전이 하나뿐이거나(대다수) 아예 없으면(구조 이력 이전) 예전과 똑같이 한 번에 계산한다.
3237
3865
  */
3238
3866
  const structures = await getRepository(TwinStructure).find({
3239
3867
  where: { domain: { id: domainId }, instanceId },
@@ -3257,7 +3885,7 @@ export class TwinEngine {
3257
3885
  * **가장 새 구조가 지금의 공장이다** — 그 아래에서 아직 아무 일도 없었더라도.
3258
3886
  *
3259
3887
  * 재프로비저닝 직후가 정확히 그 상태다: 부스를 넷 늘렸는데 새 이벤트는 아직 하나도 없다. 이때
3260
- * 마지막 이벤트의 구조로만 접으면 화면은 **옛 공장**을 보여준다 — 방금 늘린 것이 안 보인다.
3888
+ * 마지막 이벤트의 구조로만 계산하면 화면은 **옛 공장**을 보여준다 — 방금 늘린 것이 안 보인다.
3261
3889
  * 그래서 마디의 끝에 지금 구조를 한 번 더 얹는다(이벤트 없는 마디).
3262
3890
  *
3263
3891
  * **다만 "지금" 을 물었을 때만이다.** 과거 시점을 물었는데 최신 구조를 얹으면, 그 시점에 없던
@@ -3268,9 +3896,9 @@ export class TwinEngine {
3268
3896
  if (asOfNow && newest && segments[segments.length - 1]?.model !== newest) segments.push({ model: newest, events: [] })
3269
3897
 
3270
3898
  /*
3271
- * 접은 상태에 **주의 신호와 시각을 채운다.**
3899
+ * 계산한 상태에 **주의 신호와 시각을 채운다.**
3272
3900
  *
3273
- * 라이브·시뮬은 커널이 신호를 스스로 내지만, 저널을 접는 이 경로는 프로젝터 상태만 낸다 — 신호도
3901
+ * 라이브·시뮬은 커널이 신호를 스스로 내지만, 저널을 계산하는 이 경로는 프로젝터 상태만 낸다 — 신호도
3274
3902
  * `nowTime` 도 없다. 그래서 **과거를 다시 계산하면 주의 레일이 텅 비었고**(지도는 `snap.attentions` 를
3275
3903
  * 읽는다), 기동돼 있지 않은 트윈을 보는 화면도 같았다. 신호는 상태에서 계산되는 것이므로
3276
3904
  * 여기서 같은 공식(`deriveAttentions`)으로 채우면 된다 — 두 벌을 두지 않는다.
@@ -3285,7 +3913,7 @@ export class TwinEngine {
3285
3913
  * **가동 이력도 되살린다** — 다시 계산한 화면에 설비 계측이 비어 있던 것.
3286
3914
  *
3287
3915
  * OEE 는 원 시스템이 누적을 보내 주지 않아 호스트가 상태 전이를 적분해 만든다(그래서 커널이 아니라
3288
- * 여기 있다). 그런데 그 누적기는 **라이브에서만** 돌았고, 저널을 접는 경로는 그 계산을 하지 않았다 —
3916
+ * 여기 있다). 그런데 그 누적기는 **라이브에서만** 돌았고, 저널을 계산하는 경로는 그 계산을 하지 않았다 —
3289
3917
  * 과거를 다시 계산하면 모든 설비가 "가동 이력이 전혀 없음" 으로 보였다.
3290
3918
  *
3291
3919
  * 없는 것은 데이터가 아니라 계산이다: 입력(`equipment.status` 전이·`quality.output`)은 저널에 다
@@ -3295,7 +3923,7 @@ export class TwinEngine {
3295
3923
  * 가동 이력이 나온다(그 뒤에 일어난 고장이 과거 화면에 섞이지 않는다).
3296
3924
  */
3297
3925
  const oee = new OeeAccumulator()
3298
- /* 재개점이 있으면 그 위에 꼬리만 얹는다 — 없으면 꼬리분만 세어 가용률이 조용히 작아진다. */
3926
+ /* 재개점이 있으면 그 위에 꼬리만 얹는다 — 없으면 꼬리분만 세어 가용률이 알리지 않고 작아진다. */
3299
3927
  if (resume?.fold?.oee) oee.restore(resume.fold.oee)
3300
3928
  for (const r of wanted) {
3301
3929
  try {
@@ -3315,21 +3943,21 @@ export class TwinEngine {
3315
3943
  return withLiveAttentions(withMetrics)
3316
3944
  }
3317
3945
 
3318
- /* 접은 결과를 남긴다 — **지금을 물었을 때만**(시간여행 결과를 "지금" 으로 저장하면 거짓이 된다). */
3946
+ /* 계산한 결과를 남긴다 — **지금을 물었을 때만**(시간여행 결과를 "지금" 으로 저장하면 거짓이 된다). */
3319
3947
  /*
3320
- * 접은 지점의 리비전 — 사슬에 적을 이름이다. 꼬리를 접었으면 그 꼬리의 끝, 아무것도 안 읽었으면
3948
+ * 계산한 지점의 리비전 — 사슬에 적을 이름이다. 꼬리를 계산했으면 그 꼬리의 끝, 아무것도 안 읽었으면
3321
3949
  * 씨앗의 자리 그대로다(모르면 0).
3322
3950
  */
3323
3951
  const foldedTo = rows.length ? (rows[rows.length - 1]?.revision ?? 0) : (from ?? 0)
3324
3952
 
3325
3953
  const keep = (st: any, fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint }): any => {
3326
- /* 재개점을 함께 남긴다 — 상태만 남기면 다음 번에 또 0부터 접어야 한다(그것이 이 작업의 요점이다).
3954
+ /* 재개점을 함께 남긴다 — 상태만 남기면 다음 번에 또 0부터 계산해야 한다(그것이 이 작업의 요점이다).
3327
3955
  **구조가 갈린 폴드에는 재개점을 붙이지 않는다**: 마디를 건너뛴 씨앗은 그 경계의 판정을 잃는다. */
3328
3956
  if (asOfNowRead && tip && st) {
3329
3957
  void this.saveFoldedSnapshot(domainId, instanceId, { revision: tip.revision, state: st, structureRev: tip.structureRev, fold })
3330
3958
  }
3331
3959
  /*
3332
- * 사슬에는 **과거를 접었을 때도** 한 지점을 남긴다 (2026-08-18 실측으로 고침).
3960
+ * 사슬에는 **과거를 계산했을 때도** 한 지점을 남긴다 (2026-08-18 실측으로 고침).
3333
3961
  *
3334
3962
  * 처음에는 「지금 읽기」에서만 남겼다. 그런데 도는 트윈의 지금 읽기는 커널 스냅샷으로 즉시 답하고
3335
3963
  * 폴드에 닿지 않는다 — 그래서 사슬이 **영원히 비어 있었다**(실측: 지점 0개, 시간여행 3.7s 그대로).
@@ -3351,28 +3979,28 @@ export class TwinEngine {
3351
3979
  }
3352
3980
 
3353
3981
  /*
3354
- * 씨앗이 있으면 **이어 접는다** — 한 구조 안에서만(구조가 갈리면 아래 마디 경로가 맡는다).
3355
- * 새 재개점도 함께 남긴다: 다음 번에 또 꼬리만 접을 수 있어야 이 지름길이 계속 산다.
3982
+ * 씨앗이 있으면 **이어서 계산한다** — 한 구조 안에서만(구조가 갈리면 아래 마디 경로가 맡는다).
3983
+ * 새 재개점도 함께 남긴다: 다음 번에 또 꼬리만 계산할 수 있어야 이 지름길이 계속 산다.
3356
3984
  */
3357
3985
  if (resume?.fold?.reducer && segments.length <= 1) {
3358
3986
  const model = (segments[0]?.model ?? (asOfNow && newest) ?? reg.model) as TwinModelDef
3359
- const out = replayFrom(model, resume.fold.reducer, segments[0]?.events ?? [])
3987
+ const out = replayFrom(model, resume.fold.reducer, segments[0]?.events ?? [], replayOptions(reg, domainId))
3360
3988
  return keep(withNow(out.state), { reducer: out.checkpoint, oee: oee.serialize() })
3361
3989
  }
3362
3990
 
3363
3991
  if (!segments.length) {
3364
3992
  const model = ((asOfNow && newest) || reg.model) as TwinModelDef
3365
- const out = replayWithCheckpoint(model, [])
3993
+ const out = replayWithCheckpoint(model, [], replayOptions(reg, domainId))
3366
3994
  return keep(withNow(out.state), { reducer: out.checkpoint, oee: oee.serialize() })
3367
3995
  }
3368
3996
  if (segments.length === 1) {
3369
- const out = replayWithCheckpoint(segments[0].model, segments[0].events)
3997
+ const out = replayWithCheckpoint(segments[0].model, segments[0].events, replayOptions(reg, domainId))
3370
3998
  return keep(withNow(out.state), { reducer: out.checkpoint, oee: oee.serialize() })
3371
3999
  }
3372
4000
 
3373
4001
  /* 커널 계약도 `model` 이다(0.6.14) — 경계에서 어휘를 되돌려 담던 브릿지가 사라졌다. */
3374
- const { state, shifts } = replaySegments(segments)
3375
- /* 경계에서 사라진 것을 조용히 넘기지 않는다 — 수가 줄어든 이유를 어딘가에는 남겨야 한다. */
4002
+ const { state, shifts } = replaySegments(segments, replayOptions(reg, domainId))
4003
+ /* 경계에서 사라진 것을 알리지 않고 넘기지 않는다 — 수가 줄어든 이유를 어딘가에는 남겨야 한다. */
3376
4004
  for (const sh of shifts)
3377
4005
  if (sh.equipmentDropped || sh.locationsDropped || sh.personsDropped || sh.assetsDropped)
3378
4006
  console.info(`[twin-engine] "${instanceId}" replay crossed a structure change — dropped ${sh.equipmentDropped} equipment, ${sh.locationsDropped} locations, ${sh.personsDropped} persons, ${sh.assetsDropped} assets that no longer exist.`)
@@ -3463,10 +4091,10 @@ export class TwinEngine {
3463
4091
  /*
3464
4092
  * **끄기 전에 남긴다** — 지금이 메모리가 진실인 마지막 순간이다.
3465
4093
  *
3466
- * 남기지 않으면 다음 조회가 저널을 전량 다시 접는다(그 값은 어차피 방금 메모리에 있던 것이다).
3467
- * 체크포인트 루프가 20초마다 뜨지만 그 사이에 멈추면 그 구간이 통째로 다시 접힌다.
4094
+ * 남기지 않으면 다음 조회가 저널을 전량 다시 계산한다(그 값은 어차피 방금 메모리에 있던 것이다).
4095
+ * 체크포인트 루프가 20초마다 뜨지만 그 사이에 멈추면 그 구간이 통째로 다시 계산된다.
3468
4096
  */
3469
- await this.persistSnapshot(domainId, id).catch(err =>
4097
+ await this.persistSnapshot(domainId, id, true).catch(err =>
3470
4098
  twinError(`[twin-engine] snapshot on stop fail "${id}"`, err?.message ?? err)
3471
4099
  )
3472
4100
  /*
@@ -3493,7 +4121,7 @@ export class TwinEngine {
3493
4121
  * 선언이 모든 테넌트를 멈춘 셈이다. 문 앞에서 막는 것이 1차 방벽이고(`validateScenario`),
3494
4122
  * 이것이 2차 방벽이다.
3495
4123
  *
3496
- * **조용히 삼키지 않는다.** 실행을 멈추고 그 사실을 남긴다 — 예외를 무시하고 계속 tick 하면
4124
+ * **알리지 않고 삼키지 않는다.** 실행을 멈추고 그 사실을 남긴다 — 예외를 무시하고 계속 tick 하면
3497
4125
  * 같은 오류가 매 주기 쏟아지고, 그 트윈은 "도는 것처럼 보이면서" 아무것도 진행하지 않는다.
3498
4126
  */
3499
4127
  /**
@@ -3640,7 +4268,7 @@ export class TwinEngine {
3640
4268
  * 미러 — 관측 커널로 보내고, **커맨드가 낸 사실을 저널 큐에 실어** 코얼레서가 번호를 부여하게 한다.
3641
4269
  * 미러에는 State 구독 배관이 없어(시뮬은 그 경로로 저널링) 커널 방출이 아무 데도 닿지 않는다.
3642
4270
  * 여기서 DB 를 따로 읽어 번호를 매기면 인메모리 카운터와 어긋나 리비전이 겹친다(겹침은 오류를
3643
- * 내지 않고 재생 순서만 조용히 뒤섞는다).
4271
+ * 내지 않고 재생 순서만 알리지 않고 뒤섞는다).
3644
4272
  */
3645
4273
  const kernel: any = inst!.kernel
3646
4274
  const emitted: any[] = []
@@ -3727,7 +4355,7 @@ export class TwinEngine {
3727
4355
  const snap = kernel?.getSnapshot?.() ?? this.snapshot(domainId, id)
3728
4356
  return { instanceId: id, attentions: snap?.attentions ?? [] }
3729
4357
  })
3730
- /* 모으는 규칙(태깅·급한 순서·자리 색)은 순수 함수가 들고 있다 — 여기서 손으로 접지 않는다. */
4358
+ /* 모으는 규칙(태깅·급한 순서·자리 색)은 순수 함수가 들고 있다 — 여기서 손으로 계산하지 않는다. */
3731
4359
  return mergeLensAttentions(lenses, limit)
3732
4360
  }
3733
4361
 
@@ -3853,6 +4481,55 @@ export class TwinEngine {
3853
4481
  * `offered` 는 **제시된 레코드 수**다(거부 여부 무관). 통과율을 서로 다른 두 계수기에서 나눠 계산하면
3854
4482
  * 분모와 분자가 다른 것을 세게 되므로, 한자리에서 본 수를 그대로 넘긴다.
3855
4483
  */
4484
+ /**
4485
+ * 이 트윈의 저널이 지금 몇 번까지 적혔나 — **접근자로 낸다.**
4486
+ *
4487
+ * 레지스트리를 밖에서 직접 색인하지 않게 하는 것이 이 저장소의 규율이다(§`engine-registry-access`).
4488
+ * 지난 기록을 채우는 길이 다음 번호를 알아야 해서 열었다.
4489
+ *
4490
+ * 트윈이 도는 중이 아니면 0 이다 — 그때는 저널만 있고 이어 붙일 번호를 커널이 들고 있지 않다.
4491
+ */
4492
+ /**
4493
+ * 마감된 구간 사실의 **이름을 지을 때 쓸 범위와 선언** — 유입 문에 넘긴다.
4494
+ *
4495
+ * ── 왜 호스트가 주나 (2026-08-30) ────────────────────────────────────────
4496
+ * 커널은 `new Kernel(domainId, …)` 으로 서므로 **자기가 어느 트윈인지 모른다.** 그런데 설비 번호는
4497
+ * 트윈 안에서만 통하는 이름표라, 한 도메인에 현장이 둘이면 서로 다른 설비의 같은 날이 한 사실이
4498
+ * 된다. 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳐, 여러 현장을 함께 계산하는 성과 화면에서
4499
+ * 한쪽 발전량이 사라진다.
4500
+ *
4501
+ * 선언된 정체성은 **커널이 답한다**(§`FlowEngine.equipmentIdentity`) — 판정을 두 곳에 두지 않는다.
4502
+ * 트윈이 돌고 있지 않으면 선언을 물을 곳이 없으므로 범위만 넘긴다(그때도 겹치지는 않는다).
4503
+ */
4504
+ static factScope(
4505
+ domainId: string,
4506
+ instanceId: string
4507
+ ): { scopeId: string; identityOf?: (kind: 'equipment' | 'meter', localId: string) => string | undefined } {
4508
+ const inst = this.instances[runtimeKey(domainId, instanceId)]
4509
+ const kernel: any = inst?.kernel
4510
+ if (typeof kernel?.equipmentIdentity !== 'function') return { scopeId: instanceId }
4511
+ return {
4512
+ scopeId: instanceId,
4513
+ identityOf: (kind, localId) => (kind === 'equipment' ? kernel.equipmentIdentity(localId) : undefined)
4514
+ }
4515
+ }
4516
+
4517
+ static journalHead(domainId: string, instanceId: string): number {
4518
+ const inst = this.instances[runtimeKey(domainId, instanceId)]
4519
+ return Number(inst?.revision ?? 0)
4520
+ }
4521
+
4522
+ /**
4523
+ * 저널에 그만큼 적었으니 번호를 밀어 준다.
4524
+ *
4525
+ * 밀어 주지 않으면 다음에 라이브가 **같은 번호를 다시 쓴다** — 같은 시각의 두 사실을 가릴 수 없게 된다.
4526
+ */
4527
+ static advanceJournalHead(domainId: string, instanceId: string, by: number): void {
4528
+ const inst = this.instances[runtimeKey(domainId, instanceId)]
4529
+ if (!inst || !(by > 0)) return
4530
+ inst.revision = Number(inst.revision ?? 0) + by
4531
+ }
4532
+
3856
4533
  static recordIngestResult(
3857
4534
  domainId: string,
3858
4535
  instanceId: string,
@@ -3882,23 +4559,47 @@ export class TwinEngine {
3882
4559
  * **원본에 닿지 못했다**를 적는다 — 「받은 것이 없다」와 가른다(§`recordReadFailure`).
3883
4560
  *
3884
4561
  * 이 문이 없던 동안 실 원본이 끊겨도 트윈의 조회 가능한 상태에 그 사실이 없었다. 화면이 볼 수 있는
3885
- * 것은 「새 사실이 없다」뿐이었고 그것은 「원본이 조용하다」와 구별되지 않는다 — 실증 중에 원본이
4562
+ * 것은 「새 사실이 없다」뿐이었고 그것은 「연결된 시스템에서 들어온 것이 없다」와 구별되지 않는다 — 실증 중에 원본이
3886
4563
  * 끊기면 사용자가 원인을 찾을 수 없다.
3887
4564
  *
3888
4565
  * 로그로는 말하고 있었다(어댑터가 재시도를 경고한다). 그러나 **로그는 사람이 볼 때만 값이 있다** —
3889
4566
  * 화면이 말하려면 상태에 있어야 한다.
3890
4567
  */
3891
- static recordIngestReadFailure(domainId: string, instanceId: string, reason: string, nowMs = Date.now(), stream?: string): void {
4568
+ static recordIngestReadFailure(
4569
+ domainId: string,
4570
+ instanceId: string,
4571
+ reason: string,
4572
+ nowMs = Date.now(),
4573
+ stream?: string,
4574
+ nextRetryMs?: number
4575
+ ): void {
4576
+ const key = runtimeKey(domainId, instanceId)
4577
+ const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger())
4578
+ recordReadFailure(ledger, reason, nowMs, stream, nextRetryMs)
4579
+ }
4580
+
4581
+ /**
4582
+ * **끊겼다가 돌아왔다** — 실패 기록을 지우고 그 사실을 남긴다 (2026-08-27).
4583
+ *
4584
+ * `clearIngestReadFailure` 와 나누어 둔다: 지우기만 하면 「끊긴 적이 있었다」가 화면에서 사라진다.
4585
+ */
4586
+ static recordIngestRecovered(
4587
+ domainId: string,
4588
+ instanceId: string,
4589
+ afterFailures: number,
4590
+ downMs: number,
4591
+ nowMs = Date.now()
4592
+ ): void {
3892
4593
  const key = runtimeKey(domainId, instanceId)
3893
4594
  const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger())
3894
- recordReadFailure(ledger, reason, nowMs, stream)
4595
+ recordRecovered(ledger, afterFailures, downMs, nowMs)
3895
4596
  }
3896
4597
 
3897
4598
  /**
3898
4599
  * 읽기가 성공했다 — 단절 기록을 지운다.
3899
4600
  *
3900
4601
  * **빈 읽기도 성공이다.** 원본이 「새 것이 없다」고 답한 것은 닿았다는 뜻이므로, 그때도 부른다.
3901
- * 그 둘을 같게 두면 조용한 원본이 끊긴 원본으로 보인다.
4602
+ * 그 둘을 같게 두면 들어온 것이 없는 연결이 끊긴 원본으로 보인다.
3902
4603
  */
3903
4604
  static clearIngestReadFailure(domainId: string, instanceId: string): void {
3904
4605
  const ledger = this.ingestLedgers[runtimeKey(domainId, instanceId)]
@@ -3924,7 +4625,7 @@ export class TwinEngine {
3924
4625
  * **원본에 있는데 세우지 않은 것을 적는다** — 이유와 수(§`IngestLedger.withheld`).
3925
4626
  *
3926
4627
  * 어댑터가 주기마다 불러도 된다: 장부가 이유로 묶어 **마지막 수로 덮는다**(누적하지 않는다).
3927
- * `count: 0` 은 「그 이유가 풀렸다」로 그 줄을 지운다 — 조용히 그치면 낡은 수가 남는다.
4628
+ * `count: 0` 은 「그 이유가 풀렸다」로 그 줄을 지운다 — 알리지 않고 그치면 낡은 수가 남는다.
3928
4629
  */
3929
4630
  static recordIngestWithheld(domainId: string, instanceId: string, reason: string, count: number, nowMs = Date.now()): void {
3930
4631
  const key = runtimeKey(domainId, instanceId)
@@ -4036,7 +4737,7 @@ export class TwinEngine {
4036
4737
  /**
4037
4738
  * 도는 인스턴스 전체의 계측(모니터 대시보드용) — **시뮬과 미러를 함께**.
4038
4739
  *
4039
- * 예전에는 시뮬에 계기가 없어 이 목록에서 조용히 빠졌다(계기가 `null` 이라 걸러졌다). 도는 트윈
4740
+ * 예전에는 시뮬에 계기가 없어 이 목록에서 알리지 않고 빠졌다(계기가 `null` 이라 걸러졌다). 도는 트윈
4040
4741
  * 대부분이 시뮬인 서버에서 그 목록은 「부하가 거의 없다」로 보였다.
4041
4742
  */
4042
4743
  static async allMetrics(domainId?: string): Promise<any[]> {
@@ -4092,10 +4793,10 @@ export class TwinEngine {
4092
4793
  if (!state) return null
4093
4794
  const cutoff = untilTime != null ? Date.parse(untilTime) : Infinity
4094
4795
  /*
4095
- * journal-fold: 오더의 마지막 상태는 그 오더에 일어난 사실들을 접어야 나온다.
4796
+ * journal-fold: 오더의 마지막 상태는 그 오더에 일어난 사실들을 계산해야 나온다.
4096
4797
  *
4097
4798
  * **없어질 조건** — 지금 이 읽기에는 상한이 없고 `eventType` 에 인덱스도 없다. 그래서 저널이 큰
4098
- * 트윈에서는 이 한 번이 그 트윈의 저널 전체 주사다. 오더 상태를 따로 접어 둔 표(투영)를 두거나,
4799
+ * 트윈에서는 이 한 번이 그 트윈의 저널 전체 주사다. 오더 상태를 따로 계산해 둔 표(투영)를 두거나,
4099
4800
  * `(domain, instanceId, eventType, revision)` 인덱스를 붙이고 커서를 넣으면 이 예외는 사라진다.
4100
4801
  * 시각 상한(`cutoff`)을 SQL 로 내려도 절반은 준다.
4101
4802
  */