@things-factory/headless-twin 10.0.10 → 10.0.12

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 (138) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +25 -2
  2. package/dist-server/engine/canonical-ingest.js +48 -10
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/declared-stimulus.d.ts +43 -0
  5. package/dist-server/engine/declared-stimulus.js +57 -0
  6. package/dist-server/engine/declared-stimulus.js.map +1 -0
  7. package/dist-server/engine/index.d.ts +2 -0
  8. package/dist-server/engine/index.js +2 -0
  9. package/dist-server/engine/index.js.map +1 -1
  10. package/dist-server/engine/kpi-fold.d.ts +12 -0
  11. package/dist-server/engine/kpi-fold.js +21 -1
  12. package/dist-server/engine/kpi-fold.js.map +1 -1
  13. package/dist-server/engine/kpi-query.d.ts +3 -3
  14. package/dist-server/engine/kpi-query.js +4 -4
  15. package/dist-server/engine/kpi-query.js.map +1 -1
  16. package/dist-server/engine/live-feed-registry.d.ts +1 -1
  17. package/dist-server/engine/live-feed-registry.js +1 -1
  18. package/dist-server/engine/live-feed-registry.js.map +1 -1
  19. package/dist-server/engine/local-declarations.d.ts +3 -6
  20. package/dist-server/engine/local-declarations.js +97 -16
  21. package/dist-server/engine/local-declarations.js.map +1 -1
  22. package/dist-server/engine/measured-yield.d.ts +42 -0
  23. package/dist-server/engine/measured-yield.js +75 -0
  24. package/dist-server/engine/measured-yield.js.map +1 -0
  25. package/dist-server/engine/operation-basis.d.ts +16 -0
  26. package/dist-server/engine/operation-basis.js +20 -2
  27. package/dist-server/engine/operation-basis.js.map +1 -1
  28. package/dist-server/engine/property-effects.js +17 -0
  29. package/dist-server/engine/property-effects.js.map +1 -1
  30. package/dist-server/engine/restart-policy.d.ts +13 -0
  31. package/dist-server/engine/restart-policy.js +52 -0
  32. package/dist-server/engine/restart-policy.js.map +1 -0
  33. package/dist-server/engine/spec-coverage.d.ts +8 -0
  34. package/dist-server/engine/spec-coverage.js +3 -1
  35. package/dist-server/engine/spec-coverage.js.map +1 -1
  36. package/dist-server/engine/twin-engine.d.ts +48 -15
  37. package/dist-server/engine/twin-engine.js +179 -39
  38. package/dist-server/engine/twin-engine.js.map +1 -1
  39. package/dist-server/index.js +1 -1
  40. package/dist-server/index.js.map +1 -1
  41. package/dist-server/service/index.d.ts +1 -1
  42. package/dist-server/service/reference/control-routing.d.ts +14 -0
  43. package/dist-server/service/reference/control-routing.js +64 -0
  44. package/dist-server/service/reference/control-routing.js.map +1 -0
  45. package/dist-server/service/reference/index.d.ts +1 -0
  46. package/dist-server/service/reference/index.js +2 -0
  47. package/dist-server/service/reference/index.js.map +1 -1
  48. package/dist-server/service/reference/reference-adapter.d.ts +67 -0
  49. package/dist-server/service/reference/reference-adapter.js +18 -0
  50. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  51. package/dist-server/service/reference/reference-live.js +3 -3
  52. package/dist-server/service/reference/reference-live.js.map +1 -1
  53. package/dist-server/service/reference/reference-resolver.d.ts +3 -3
  54. package/dist-server/service/reference/reference-resolver.js +35 -18
  55. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  56. package/dist-server/service/twin-audit/command-audit.d.ts +34 -0
  57. package/dist-server/service/twin-audit/command-audit.js +15 -1
  58. package/dist-server/service/twin-audit/command-audit.js.map +1 -1
  59. package/dist-server/service/twin-audit/twin-audit-event.d.ts +3 -0
  60. package/dist-server/service/twin-audit/twin-audit-event.js +28 -2
  61. package/dist-server/service/twin-audit/twin-audit-event.js.map +1 -1
  62. package/dist-server/service/twin-control/twin-control-mutation.d.ts +15 -2
  63. package/dist-server/service/twin-control/twin-control-mutation.js +85 -34
  64. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  65. package/dist-server/service/twin-event/twin-event.js +1 -1
  66. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  67. package/dist-server/service/twin-instance/twin-instance.d.ts +1 -1
  68. package/dist-server/service/twin-instance/twin-instance.js +2 -2
  69. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  70. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +2 -1
  71. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  72. package/dist-server/service/twin-model/twin-equipment.js +1 -1
  73. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  74. package/dist-server/service/twin-model/twin-location.js +1 -1
  75. package/dist-server/service/twin-model/twin-location.js.map +1 -1
  76. package/dist-server/service/twin-model/twin-model-query.js +1 -1
  77. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  78. package/dist-server/service/twin-model/twin-operation.js +1 -1
  79. package/dist-server/service/twin-model/twin-operation.js.map +1 -1
  80. package/package.json +3 -3
  81. package/server/engine/canonical-ingest.ts +78 -14
  82. package/server/engine/declared-stimulus.ts +66 -0
  83. package/server/engine/index.ts +2 -0
  84. package/server/engine/kpi-fold.ts +29 -1
  85. package/server/engine/kpi-query.ts +8 -8
  86. package/server/engine/live-feed-registry.ts +2 -2
  87. package/server/engine/local-declarations.ts +97 -19
  88. package/server/engine/measured-yield.ts +89 -0
  89. package/server/engine/operation-basis.ts +33 -2
  90. package/server/engine/property-effects.ts +17 -0
  91. package/server/engine/restart-policy.ts +55 -0
  92. package/server/engine/spec-coverage.ts +23 -3
  93. package/server/engine/twin-engine.ts +201 -42
  94. package/server/index.ts +1 -1
  95. package/server/service/reference/control-routing.ts +62 -0
  96. package/server/service/reference/index.ts +1 -0
  97. package/server/service/reference/reference-adapter.ts +78 -0
  98. package/server/service/reference/reference-live.ts +3 -3
  99. package/server/service/reference/reference-resolver.ts +46 -7
  100. package/server/service/twin-audit/command-audit.ts +34 -1
  101. package/server/service/twin-audit/twin-audit-event.ts +50 -4
  102. package/server/service/twin-control/twin-control-mutation.ts +75 -28
  103. package/server/service/twin-event/twin-event.ts +10 -2
  104. package/server/service/twin-instance/twin-instance.ts +16 -10
  105. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +2 -1
  106. package/server/service/twin-model/twin-equipment.ts +8 -1
  107. package/server/service/twin-model/twin-location.ts +8 -1
  108. package/server/service/twin-model/twin-model-query.ts +1 -1
  109. package/server/service/twin-model/twin-operation.ts +8 -1
  110. package/test/adopt-structure-live.test.ts +7 -7
  111. package/test/boot-resume.test.ts +3 -3
  112. package/test/column-type-portability.test.ts +122 -0
  113. package/test/control-capability.test.ts +103 -0
  114. package/test/declared-stimulus.test.ts +88 -0
  115. package/test/ingest-running-guard.test.ts +5 -5
  116. package/test/instance-cache-lifecycle.test.ts +1 -1
  117. package/test/kpi-baseline-db.test.ts +1 -1
  118. package/test/kpi-query-bench.test.ts +1 -1
  119. package/test/lineage-survives-restart.test.ts +3 -3
  120. package/test/live-feed-registry.test.ts +6 -6
  121. package/test/local-declarations.test.ts +108 -1
  122. package/test/measured-yield.test.ts +90 -0
  123. package/test/operation-basis.test.ts +28 -1
  124. package/test/operational-vocabulary.test.ts +108 -0
  125. package/test/operations-capability-db.test.ts +4 -4
  126. package/test/project-structure-db.test.ts +1 -1
  127. package/test/projection-reaches-screen.test.ts +1 -1
  128. package/test/property-effects.test.ts +28 -0
  129. package/test/restart-policy.test.ts +111 -0
  130. package/test/resync-origin-site.test.ts +7 -1
  131. package/test/source-outcome-audit.test.ts +104 -0
  132. package/test/structure-revision-db.test.ts +27 -27
  133. package/test/tenant-registry-db.test.ts +2 -2
  134. package/test/twin-model-item-db.test.ts +3 -3
  135. package/test/twin-model-tree-db.test.ts +7 -7
  136. package/test/twin-origin-resync.test.ts +4 -4
  137. package/test/yield-loop.test.ts +144 -0
  138. package/tsconfig.tsbuildinfo +1 -1
@@ -25,7 +25,19 @@ export type ForecastQualification =
25
25
  | 'calibrated'
26
26
 
27
27
  export interface SpecCoverageReport {
28
- operations: { kind: string; duration: 'measured' | 'declared' | 'default'; variability?: string; parameters: string[] }[]
28
+ operations: {
29
+ kind: string
30
+ duration: 'measured' | 'declared' | 'default'
31
+ variability?: string
32
+ parameters: string[]
33
+ /**
34
+ * 그 모수를 **무엇으로** 읽었나(`id → measured|declared`) — 없는 id 는 코드 상수를 썼다는 뜻이다.
35
+ *
36
+ * 소요시간이 근거를 밝히듯 모수도 밝힌다(2026-08-19): 수율이 이력에서 온 값인지 사람이 적은 값인지
37
+ * 상수인지에 따라 「불량이 늘었다」는 화면 문장의 무게가 다르다.
38
+ */
39
+ parameterBasis?: Record<string, 'measured' | 'declared'>
40
+ }[]
29
41
  /** 이력·계산에서 온 소요(추정기) — 선언값보다 강한 근거. */
30
42
  measuredDurations: number
31
43
  declaredDurations: number
@@ -40,7 +52,13 @@ export interface SpecCoverageReport {
40
52
  * 관측된 작업이 하나도 없으면(`operations` 빈 배열) 판정할 근거가 없으므로 **undefined**(모른다).
41
53
  */
42
54
  export function classifySpecCoverage(cov: {
43
- operations?: { kind: string; duration: 'measured' | 'declared' | 'default'; variability?: string; parameters?: string[] }[]
55
+ operations?: {
56
+ kind: string
57
+ duration: 'measured' | 'declared' | 'default'
58
+ variability?: string
59
+ parameters?: string[]
60
+ parameterBasis?: Record<string, 'measured' | 'declared'>
61
+ }[]
44
62
  measuredDurations?: number
45
63
  declaredDurations?: number
46
64
  defaultDurations?: number
@@ -51,7 +69,9 @@ export function classifySpecCoverage(cov: {
51
69
  kind: o.kind,
52
70
  duration: o.duration,
53
71
  ...(o.variability ? { variability: o.variability } : {}),
54
- parameters: o.parameters ?? []
72
+ parameters: o.parameters ?? [],
73
+ /* 근거는 커널이 말한 것만 나른다 — 없으면 필드를 만들지 않는다(상수를 썼다는 뜻이 빈칸이다). */
74
+ ...(o.parameterBasis && Object.keys(o.parameterBasis).length ? { parameterBasis: o.parameterBasis } : {})
55
75
  }))
56
76
  const measuredDurations = cov.measuredDurations ?? operations.filter(o => o.duration === 'measured').length
57
77
  const declaredDurations = cov.declaredDurations ?? operations.filter(o => o.duration === 'declared').length
@@ -21,6 +21,8 @@ import { applyDeclarationLayers } from './local-declarations.js'
21
21
  import { isOfDomain, parseRuntimeKey, runtimeKey } from './runtime-key.js'
22
22
  import { routeCommand } from './command-routing.js'
23
23
  import { TwinInstance } from '../service/twin-instance/twin-instance.js'
24
+ /* 자극의 집은 원본이다(ADR-0029) — 그 행을 읽고 쓴다. */
25
+ import { TwinReference } from '../service/reference/twin-reference.js'
24
26
  import { TwinStructure } from '../service/twin-structure/twin-structure.js'
25
27
  import { TwinSpace } from '../service/twin-space/twin-space.js'
26
28
  import { utcOffsetOf } from '../service/reference/reference-master.js'
@@ -33,6 +35,9 @@ import { type IngestWarning, describeWarnings, unresolvedReference, projectionFa
33
35
  import { projectStructure } from '../service/twin-model/project-structure.js'
34
36
  import { buildTravelEstimator, chainEstimators } from './travel-estimator.js'
35
37
  import { buildMeasuredEstimator } from './measured-estimator.js'
38
+ import { buildYieldEstimator } from './measured-yield.js'
39
+ import { planStimulus, withStimulus } from './declared-stimulus.js'
40
+ import { readRestartPolicy, type RestartPolicy } from './restart-policy.js'
36
41
  import { describeModelBasis, type ModelBasis } from './model-basis.js'
37
42
  import { computeTwinKpi } from './kpi-query.js'
38
43
  import { createHash } from 'node:crypto'
@@ -49,7 +54,7 @@ import { EMS_PROPERTY } from '@operato/twin-kernel'
49
54
  import type { TwinKernel, TwinModelDef, StructureShift, SubscriptionMessage, TwinRuntime as TwinRuntimeType, CanonicalEnvelope } from '@operato/twin-kernel'
50
55
 
51
56
  /* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */
52
- const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT } = require('@operato/twin-kernel')
57
+ const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT, validateScenario } = require('@operato/twin-kernel')
53
58
 
54
59
  const KERNELS: Record<string, any> = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel, ems: EmsKernel }
55
60
 
@@ -86,19 +91,19 @@ interface TwinMetrics {
86
91
  }
87
92
 
88
93
  /*
89
- * realityMode — 이 트윈의 "현실이 어디서 오나"(runtime-state-model §0·§1 프레임의 ① 선언).
90
- * 부팅 거동을 결정하는 1급 선언: mirror=외부 실물 재동기 / sim-world=생성 타임라인 지속(resume) / sim-experiment=seed 재현(reset).
91
- * 미선언 = 'sim-experiment'(가장 보수적 — 기존 데모 거동 보존).
94
+ * 재기동 정책은 **선언이고 기본값이 없다** — 정의·판정은 `engine/restart-policy.ts` 가 든다(ADR-0029 §2).
95
+ *
96
+ * 예전에는 여기에 `DEFAULT_REALITY_MODE = 'sim-experiment'` 가 있었고, 미선언이 조용히 **저널 초기화**로
97
+ * 떨어졌다. 그 관용은 값이 하나 어긋나는 순간 이력을 지우는 길이 된다 — 그래서 없앴다.
92
98
  */
93
- export type RealityMode = 'mirror' | 'sim-world' | 'sim-experiment'
94
- export const DEFAULT_REALITY_MODE: RealityMode = 'sim-experiment'
99
+ export type { RestartPolicy } from './restart-policy.js'
95
100
 
96
101
  /* 인메모리 라이브 런타임 홀더(영속 엔티티 TwinInstance 와 구분). */
97
102
  interface InstanceRuntime {
98
103
  /** 부하 계기판 — 작업별 소요. 시뮬·라이브 모두 붙는다(누가 루프를 점유하는지 보려면 둘 다 필요하다). */
99
104
  load?: LoadMeter
100
105
  /** 현실 출처 선언(§0 프레임 ①). 부팅 거동(reset/resume/resync)의 근거. */
101
- realityMode?: RealityMode
106
+ restartPolicy?: RestartPolicy
102
107
  id: string
103
108
  domainId: string
104
109
  domain?: any // 라이브 바인딩(data 채널)용 Domain 객체(subdomain 필터). start 시 비동기 해석.
@@ -400,7 +405,7 @@ export class TwinEngine {
400
405
  *
401
406
  * ── 모드를 지어내지 않는다 ──────────────────────────────────────────────────
402
407
  * 미러였던 트윈을 시뮬로 되살리면 **없던 움직임을 만들어 낸다**(관측 트윈이 스스로 물건을 옮긴다).
403
- * 그래서 선언된 `realityMode` 그대로 되살린다 — 미러는 관측 구동으로, 시뮬은 시뮬로.
408
+ * 그래서 선언된 `restartPolicy` 그대로 되살린다 — 미러는 관측 구동으로, 시뮬은 시뮬로.
404
409
  *
405
410
  * ── 되살릴 수 없으면 그렇게 적는다 ──────────────────────────────────────────
406
411
  * 실패를 삼키면 등록부가 계속 `running` 이라 말한다 — 우리가 고치려던 그 거짓말이다. 그래서 실패한
@@ -422,7 +427,7 @@ export class TwinEngine {
422
427
  }
423
428
 
424
429
  try {
425
- if (row.realityMode === 'mirror') {
430
+ if (row.restartPolicy === 'resync') {
426
431
  if (!row.model) throw new Error('no model')
427
432
  /* 시각 기준은 **공간**이 갖는다 — 교대의 HH:MM 을 어느 기준으로 읽나(라이브 기동과 같은 규칙). */
428
433
  this.startLive(instanceId, domainId, row.kind, await this.withSpaceTimeBase(row.model as TwinModelDef, domainId))
@@ -431,7 +436,7 @@ export class TwinEngine {
431
436
  console.log(`[twin-engine] resumed mirror "${instanceId}" — feed reattach is the reference layer's job.`)
432
437
  } else {
433
438
  await this.startFromRegistry(domainId, instanceId)
434
- console.log(`[twin-engine] resumed ${row.realityMode} "${instanceId}".`)
439
+ console.log(`[twin-engine] resumed ${row.restartPolicy} "${instanceId}".`)
435
440
  }
436
441
  } catch (err: any) {
437
442
  await this.markStopped(row, err?.message ?? 'resume failed')
@@ -548,6 +553,22 @@ export class TwinEngine {
548
553
  }
549
554
  }
550
555
  }
556
+ /* 공정 모수(수율·셋업)도 같은 규율로 싣는다 — 커널이 그 창구를 갖지 않으면 그 사실을 말한다. */
557
+ const params = model?.localParams as Record<string, Record<string, string>> | undefined
558
+ if (params && Object.keys(params).length) {
559
+ if (typeof kernel?.declareParameters !== 'function') {
560
+ console.warn(
561
+ `[twin-engine] "${id}": this facility declared operation parameters for ${Object.keys(params).length} operation(s) but the kernel cannot consume them ` +
562
+ '(declareParameters missing — kernel needs publishing). Yield and setup keep running on built-in constants.'
563
+ )
564
+ } else {
565
+ try {
566
+ kernel.declareParameters(params)
567
+ } catch (err: any) {
568
+ console.warn(`[twin-engine] "${id}": declared operation parameter rejected by the kernel — ${err?.message ?? err}`)
569
+ }
570
+ }
571
+ }
551
572
  const ops = model?.operations
552
573
  if (!ops?.length) return
553
574
  if (typeof kernel?.loadOperations !== 'function') {
@@ -580,6 +601,35 @@ export class TwinEngine {
580
601
  return
581
602
  }
582
603
  kernel.durationEstimator = chained
604
+ /*
605
+ * **양품률도 이력에서 배운다** (2026-08-19) — 소요와 같은 자리에서 붙인다.
606
+ *
607
+ * 커널이 그 시임을 갖지 않은 버전이면(발행 이전) 조용히 넘어가지 않고 말한다: 수율이 상수로 남은
608
+ * 이유를 모르고 지나가면, 화면의 불량 판정이 그 현장의 사실이 아니라 우리 상수의 결과다.
609
+ */
610
+ const yields = await this.measuredYield(domainId, instanceId)
611
+ if (yields?.estimator) {
612
+ if (typeof kernel.yieldOf !== 'function') {
613
+ console.warn(
614
+ `[twin-engine] "${instanceId}": learned yield for ${Object.keys(yields.learned).length} operation kind(s) but the kernel cannot consume it ` +
615
+ '(yieldOf missing — kernel needs publishing). Yield keeps running on the declared value or the built-in constant.'
616
+ )
617
+ } else {
618
+ kernel.yieldEstimator = yields.estimator
619
+ console.log(
620
+ `[twin-engine] "${instanceId}": measured yield installed — ${Object.entries(yields.learned)
621
+ .map(([k, v]) => `${k}=${Math.round(v * 1000) / 10}%(${yields.samples[k].good + yields.samples[k].scrap}건)`)
622
+ .join(', ')}${yields.skipped.length ? ` · 표본 부족으로 뺀 종류: ${yields.skipped.map(s => `${s.kind}(${s.judged})`).join(', ')}` : ''}`
623
+ )
624
+ }
625
+ } else if (yields?.skipped.length) {
626
+ /* 배운 것이 없고 버린 것만 있으면 그 사실도 말한다 — 「이력이 없다」와 「표본이 모자라다」는 다르다. */
627
+ console.log(
628
+ `[twin-engine] "${instanceId}": no measured yield yet — samples below ${yields.minSamples}: ${yields.skipped
629
+ .map(s => `${s.kind}(${s.judged})`)
630
+ .join(', ')}`
631
+ )
632
+ }
583
633
  const learned = Object.keys(measured?.learned ?? {})
584
634
  const spread = Object.keys(measured?.spreads ?? {})
585
635
  console.log(
@@ -596,7 +646,15 @@ export class TwinEngine {
596
646
  * 거기에 KPI 조회를 그대로 달면 요청당 저널 스캔이 하나씩 붙는다 — 실측은 분 단위로 바뀌지 않으므로
597
647
  * 짧은 TTL 로 재사용한다. 캐시는 인스턴스별이고, 만료 전에는 같은 값을 쓴다(예측 간 일관성도 얻는다).
598
648
  */
599
- private static measuredCache = new Map<string, { at: number; value: ReturnType<typeof buildMeasuredEstimator> | undefined }>()
649
+ private static measuredCache = new Map<
650
+ string,
651
+ {
652
+ at: number
653
+ value: ReturnType<typeof buildMeasuredEstimator> | undefined
654
+ /** 같은 폴드에서 나온 양품률 — **저널을 두 번 접지 않는다**(소요와 수율은 같은 창의 같은 사실이다). */
655
+ yields?: ReturnType<typeof buildYieldEstimator>
656
+ }
657
+ >()
600
658
  private static readonly MEASURED_TTL_MS = 60_000
601
659
  /**
602
660
  * 캐시 항목 상한 — **라이프사이클이 놓친 것까지 막는 두 번째 방어.**
@@ -613,22 +671,31 @@ export class TwinEngine {
613
671
  */
614
672
  private static readonly MEASURED_MAX = 5_000
615
673
 
674
+ /** 이 트윈이 이력에서 배운 양품률 — 소요와 **같은 폴드·같은 캐시**에서 온다(저널을 두 번 접지 않는다). */
675
+ private static async measuredYield(domainId: string, instanceId: string) {
676
+ await this.measuredEstimator(domainId, instanceId)
677
+ return this.measuredCache.get(runtimeKey(domainId, instanceId))?.yields
678
+ }
679
+
616
680
  private static async measuredEstimator(domainId: string, instanceId: string) {
617
681
  /* 키는 `runtimeKey` 하나로 — 손으로 조립하면 지우는 쪽과 어긋나 못 지우는 항목이 생긴다. */
618
682
  const key = runtimeKey(domainId, instanceId)
619
683
  const hit = this.measuredCache.get(key)
620
684
  if (hit && Date.now() - hit.at < this.MEASURED_TTL_MS) return hit.value
621
685
  let value: ReturnType<typeof buildMeasuredEstimator> | undefined
686
+ let yields: ReturnType<typeof buildYieldEstimator> | undefined
622
687
  try {
623
688
  /* 작업 종류별 실측 — 창은 넉넉히(하루) 두고 표본이 모자란 종류는 추정기가 스스로 뺀다. */
624
689
  const kpi: any = await computeTwinKpi({ domainId, instanceId, windowMinutes: 24 * 60, groupBy: 'taskKind' })
625
690
  value = buildMeasuredEstimator(kpi?.groups?.items, {})
691
+ /* 같은 그룹에서 양품률도 배운다 — 한 번 접은 저널을 둘이 나눠 쓴다. */
692
+ yields = buildYieldEstimator(kpi?.groups?.items, {})
626
693
  } catch (err) {
627
694
  console.warn(`[twin-engine] "${instanceId}": measured duration lookup failed — falling back to declared/default durations.`, (err as any)?.message)
628
695
  }
629
696
  /* 다시 넣어 **최근 쓴 것**으로 만든다 — 삽입 순서가 곧 버릴 순서이므로 이 한 줄이 LRU 를 만든다. */
630
697
  this.measuredCache.delete(key)
631
- this.measuredCache.set(key, { at: Date.now(), value })
698
+ this.measuredCache.set(key, { at: Date.now(), value, yields })
632
699
  if (this.measuredCache.size > this.MEASURED_MAX) {
633
700
  const oldest = this.measuredCache.keys().next()
634
701
  if (!oldest.done) this.measuredCache.delete(oldest.value)
@@ -636,6 +703,68 @@ export class TwinEngine {
636
703
  return value
637
704
  }
638
705
 
706
+ /**
707
+ * **원본이 선언한 자극을 싣는다** — 재기동해도 살아 있게 (2026-08-19).
708
+ *
709
+ * ── 무엇이 났나 ────────────────────────────────────────────────────────────
710
+ * 데모의 시나리오는 시드 코드가 메모리에만 실었다. 그래서 트윈을 재기동하면 자극이 사라지고 구조만
711
+ * 서 있는 트윈이 남았다(작업 0·오더 0) — 「살아 있는 데모」가 **첫 재기동까지만** 살았다.
712
+ *
713
+ * 자극의 집은 **원본**이다(`TwinReference.connectionConfig.scenario`, ADR-0029 ·
714
+ * `plans/simulator-as-source.md` §4): 무엇이 들어오고 무슨 주문이 나는지는 시뮬레이터가 정하는 사실이고
715
+ * 트윈은 반영한다. 트윈에 새 축을 만들면 ADR-0029 가 걷어내야 할 표면이 하나 늘어난다.
716
+ *
717
+ * 판정은 순수 함수가 한다(`planStimulus`) — 미러에 싣지 않고, 검사를 통과하지 못한 선언은 태우지 않고
718
+ * (그 이력이 있다: 잘못된 선언 하나가 다음 틱에서 서버를 내렸다), 못 실은 이유는 **말한다.**
719
+ */
720
+ private static async installStimulus(domainId: string, instanceId: string, inst: InstanceRuntime): Promise<void> {
721
+ let config: any
722
+ try {
723
+ const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } })
724
+ config = ref?.connectionConfig
725
+ } catch (err: any) {
726
+ console.warn(`[twin-engine] "${instanceId}": could not read the declared stimulus — ${err?.message ?? err}`)
727
+ return
728
+ }
729
+ const plan = planStimulus(config, { hasScenarioEngine: !!inst.runtime?.scenario, mode: inst.mode }, validateScenario as any)
730
+ if (plan.action === 'skip') {
731
+ /* 선언이 없는 것은 정상이므로 조용히 지난다. 나머지 셋은 **말한다** — 선언했는데 안 실린 상태다. */
732
+ if (plan.reason !== 'none') {
733
+ console.warn(
734
+ `[twin-engine] "${instanceId}": a stimulus is declared on its source but was not loaded (${plan.reason}${plan.detail ? `: ${plan.detail}` : ''}).` +
735
+ (plan.reason === 'observed' ? ' This twin runs on observation — we do not manufacture arrivals for it.' : '')
736
+ )
737
+ }
738
+ return
739
+ }
740
+ try {
741
+ inst.runtime!.scenario.load(plan.scenario)
742
+ inst.runtime!.scenario.start()
743
+ const kinds = (plan.scenario.generators ?? []).map((g: any) => g?.kind).filter(Boolean)
744
+ console.log(`[twin-engine] "${instanceId}": stimulus from its source started — ${kinds.length ? kinds.join(', ') : 'no generators'}.`)
745
+ } catch (err: any) {
746
+ console.warn(`[twin-engine] "${instanceId}": the declared stimulus was rejected at load — ${err?.message ?? err}`)
747
+ }
748
+ }
749
+
750
+ /**
751
+ * 자극을 **원본에 선언한다** — 프로비저닝·데모 시드가 부르는 문.
752
+ *
753
+ * 트윈이 아니라 원본에 적는 이유는 위와 같다(ADR-0029). 원본 행이 없으면 만들지 않는다 — 어떤 원본에서
754
+ * 온 트윈인지 모르는 채 자극을 지어 붙이면, 그 자극이 어디서 왔는지 아무도 되짚을 수 없다.
755
+ */
756
+ static async declareStimulus(domainId: string, instanceId: string, scenario: any): Promise<boolean> {
757
+ const repo = getRepository(TwinReference)
758
+ const ref = await repo.findOne({ where: { domain: { id: domainId }, source: instanceId } })
759
+ if (!ref) {
760
+ console.warn(`[twin-engine] "${instanceId}": no source reference — a stimulus has nowhere to be declared.`)
761
+ return false
762
+ }
763
+ ref.connectionConfig = withStimulus(ref.connectionConfig, scenario)
764
+ await repo.save(ref)
765
+ return true
766
+ }
767
+
639
768
  /**
640
769
  * 이 트윈이 **이력에서 시간을 배운 작업 종류들** — 재기동에도 남는 근거.
641
770
  *
@@ -769,7 +898,7 @@ export class TwinEngine {
769
898
  console.log(`[twin-engine] mirror "${id}" carried over ${carried} — observation axes come from the source.`)
770
899
  }
771
900
 
772
- static start(id: string, domainId: string, kind: string, model: TwinModelDef, realityMode?: RealityMode, purpose?: string, resumeFrom?: number): InstanceRuntime {
901
+ static start(id: string, domainId: string, kind: string, model: TwinModelDef, restartPolicy?: RestartPolicy, purpose?: string, resumeFrom?: number): InstanceRuntime {
773
902
  const key = runtimeKey(domainId, id)
774
903
  if (this.instances[key]) return this.instances[key]
775
904
 
@@ -808,11 +937,23 @@ export class TwinEngine {
808
937
  * 실제로는 도는 커널이 옛 수를 그대로 쓰고 재기동 때 받는데, 그 사실이 답에서 사라진 것이다.
809
938
  * 규약에 기대는 대신 사실을 적는다: 「돌고 있나」를 묻는 쪽이 그 답을 받아야 한다.
810
939
  */
811
- const inst: InstanceRuntime = { id, domainId, mode: 'sim', runtime, kernel, realityMode: realityMode ?? DEFAULT_REALITY_MODE, spaceId: (model as any)?.spaceId, unsub: () => {} }
940
+ const inst: InstanceRuntime = {
941
+ id,
942
+ domainId,
943
+ mode: 'sim',
944
+ runtime,
945
+ kernel,
946
+ /* 정책은 부르는 쪽이 선언한다 — 여기서 고르면 같은 트윈이 부르는 자리에 따라 다르게 재기동한다. */
947
+ restartPolicy: readRestartPolicy(restartPolicy, `start("${id}")`),
948
+ spaceId: (model as any)?.spaceId,
949
+ unsub: () => {}
950
+ }
812
951
  this.instances[key] = inst
813
952
  /* 다시 세웠으므로 지난 정지 이유는 사실이 아니다 — 남겨 두면 도는 트윈이 「굶겨서 멈췄다」고 말한다. */
814
953
  this.stopNotes.delete(key)
815
954
  delete this.recovered[key] // 웜스타트로 커널에 옮겨 심었다 — 이제 라이브가 진실이다.
955
+ /* 원본이 선언한 자극 — 기동을 막지 않는다(추정기와 같은 규율). 못 실었으면 그 사실을 말한다. */
956
+ this.installStimulus(domainId, id, inst).catch(err => console.warn(`[twin-engine] "${id}": stimulus install failed — ${err?.message ?? err}`))
816
957
 
817
958
  /* 라이브 바인딩(P3): data 채널 필터가 subdomain 을 보므로 Domain 객체를 1회 해석해 둔다. */
818
959
  getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {})
@@ -845,8 +986,8 @@ export class TwinEngine {
845
986
  })
846
987
  inst.unsub = () => sub.unsubscribe()
847
988
 
848
- /* 복구 앵커: 레지스트리에 model/kind/status/realityMode 영속(재부팅 시 이게 있어야 replay·선언 거동 가능). */
849
- this.register(domainId, id, kind, model, 'running', inst.realityMode).catch(err => console.error('twin register fail', err))
989
+ /* 복구 앵커: 레지스트리에 model/kind/status/restartPolicy 영속(재부팅 시 이게 있어야 replay·선언 거동 가능). */
990
+ this.register(domainId, id, kind, model, 'running', inst.restartPolicy).catch(err => console.error('twin register fail', err))
850
991
 
851
992
  /* 워커 tick — 스켈레톤은 setInterval(메인 루프). 긴 시뮬 오프-루프(worker thread)는 스케일 하드닝(향후, §host 경계). */
852
993
  inst.timer = setInterval(() => this.tickGuarded(domainId, id, runtime), this.TICK_MS)
@@ -932,7 +1073,7 @@ export class TwinEngine {
932
1073
  /* `projector` 필드는 옛 이름으로 남긴다 — 소비처가 `snapshot()` 을 부르므로 얇은 어댑터로 잇는다.
933
1074
  * (P3 에서 소비처를 커널 어휘로 바꾸면 사라진다.) */
934
1075
  const projector = { apply: (e: CanonicalEnvelope) => kernel.apply(e), snapshot: () => kernel.getSnapshot() }
935
- const inst: InstanceRuntime = { id, domainId, mode: 'live', realityMode: 'mirror', kernel, projector, oee: new OeeAccumulator(), spaceId: (model as any)?.spaceId, unsub: () => {} }
1076
+ const inst: InstanceRuntime = { id, domainId, mode: 'live', restartPolicy: 'resync', kernel, projector, oee: new OeeAccumulator(), spaceId: (model as any)?.spaceId, unsub: () => {} }
936
1077
  /*
937
1078
  * ── 커널이 **판정으로 낸 사실**도 저널에 남는다 (2026-08-14 실측으로 잡음) ────
938
1079
  *
@@ -960,6 +1101,8 @@ export class TwinEngine {
960
1101
  this.installEstimators(kernel, domainId, id, model).catch(err => console.warn('[twin-engine] estimator install failed', err?.message))
961
1102
  inst.metrics = { ingestedTotal: 0, broadcastTotal: 0, journaledTotal: 0, ingestRate: 0, broadcastRate: 0, journalRate: 0, backlog: 0, _accIngest: 0, _accBroadcast: 0, _accJournal: 0, _windowStartMs: Date.now() }
962
1103
  this.instances[key] = inst
1104
+ /* 미러에도 부른다 — 선언이 있으면 「미러에는 싣지 않는다」고 말해야 한다(조용한 무시 금지). */
1105
+ this.installStimulus(domainId, id, inst).catch(err => console.warn(`[twin-engine] "${id}": stimulus check failed — ${err?.message ?? err}`))
963
1106
  /*
964
1107
  * **원천이 되풀어 주지 않는 것만 잇는다** (2026-08-18 실측으로 붙임).
965
1108
  *
@@ -980,7 +1123,7 @@ export class TwinEngine {
980
1123
  .findOne({ where: { domain: { id: domainId }, instanceId: id }, order: { revision: 'DESC' } })
981
1124
  .then(top => (inst.revision = top?.revision ?? 0))
982
1125
  .catch(() => (inst.revision = 0))
983
- this.register(domainId, id, kind, model, 'running', 'mirror').catch(err => console.error('twin register fail', err))
1126
+ this.register(domainId, id, kind, model, 'running', 'resync').catch(err => console.error('twin register fail', err))
984
1127
  return inst
985
1128
  }
986
1129
 
@@ -1155,7 +1298,7 @@ export class TwinEngine {
1155
1298
  kind: string,
1156
1299
  model: TwinModelDef,
1157
1300
  status: 'running' | 'stopped' = 'running',
1158
- realityMode?: RealityMode,
1301
+ restartPolicy?: RestartPolicy,
1159
1302
  origin?: any,
1160
1303
  /*
1161
1304
  * 사람이 부르는 이름과 **누가 했나** — 업무키를 나눠 둔 대가로 이름을 함께 실어야 한다.
@@ -1176,10 +1319,15 @@ export class TwinEngine {
1176
1319
  // 인스턴스↔공간 1급 링크(space #3) — model.spaceId/areaId 를 컬럼으로 승격(dual-write). model JSON 도 유지(커널·복구용).
1177
1320
  spaceId: (model as any)?.spaceId ?? existing?.spaceId,
1178
1321
  areaId: (model as any)?.areaId ?? existing?.areaId,
1179
- /* 현실 출처 선언(§0 ①) — 명시값 우선, 없으면 기존값 보존(재프로비전이 선언을 지우지 않게).
1180
- 둘 다 없으면 여기서 **각인한다**. 컬럼을 비워 두면 읽는 자리마다 기본값을 고르게 되고,
1181
- 그러면 같은 트윈이 부르는 곳에 따라 다르게 재기동한다. 선언은 저장소에서 항상 명시적이다. */
1182
- realityMode: realityMode ?? existing?.realityMode ?? DEFAULT_REALITY_MODE,
1322
+ /* 재기동 정책(ADR-0029 §2) — 명시값 우선, 없으면 기존값 보존(재프로비전이 선언을 지우지 않게).
1323
+ **둘 다 없으면 던진다**: 예전에는 여기서 기본값을 각인했는데, 그 기본값이 「저널 초기화」라서
1324
+ 선언을 빠뜨린 프로비저닝이 조용히 이력을 지우는 트윈을 만들었다. 선언은 부르는 쪽의 몫이다. */
1325
+ restartPolicy:
1326
+ restartPolicy ??
1327
+ readRestartPolicy(
1328
+ existing?.restartPolicy,
1329
+ `register("${instanceId}") did not declare a restart policy and the stored row has none`
1330
+ ),
1183
1331
  /* 용도도 각인한다. 벤치로 뒤집는 것은 `setPurpose` 하나뿐이므로 기존값을 반드시 보존한다
1184
1332
  — 여기서 덮으면 재프로비전이 벤치 사본을 운영 트윈으로 되돌린다. */
1185
1333
  purpose: existing?.purpose ?? 'operational',
@@ -1327,6 +1475,14 @@ export class TwinEngine {
1327
1475
  instanceId: string,
1328
1476
  kind: string,
1329
1477
  model: TwinModelDef,
1478
+ /**
1479
+ * **재기동 정책은 생성 시점의 선언이다**(ADR-0029 §2) — 트윈은 정책 없이 존재할 수 없다.
1480
+ *
1481
+ * 예전에는 이 자리가 없었고 `register` 가 기본값(`sim-experiment` = 저널 초기화)을 각인했다. 그래서
1482
+ * 선언을 빠뜨린 프로비저닝이 **재기동마다 이력을 지우는 트윈**을 조용히 만들었다. 이제 만드는 쪽이
1483
+ * 말해야 한다: 이 트윈이 자기 과거를 어떻게 대하는지는 만드는 사람이 아는 사실이다.
1484
+ */
1485
+ restartPolicy: RestartPolicy,
1330
1486
  comment?: string,
1331
1487
  origin?: any,
1332
1488
  /** 이름·저자 — 업무키를 나눠 둔 대가로 이름을 함께 남긴다(`register` 의 `meta` 그대로). */
@@ -1351,7 +1507,7 @@ export class TwinEngine {
1351
1507
  * 달고 다닌다. 재생은 구조가 바뀌는 지점에서 전환한 뒤 이어 접는다(`replaySegments`).
1352
1508
  */
1353
1509
  await this.recordStructure(domainId, instanceId, model, comment)
1354
- await this.register(domainId, instanceId, kind, model, existing?.status === 'running' ? 'stopped' : (existing?.status as any) ?? 'stopped', undefined, origin, meta)
1510
+ await this.register(domainId, instanceId, kind, model, existing?.status === 'running' ? 'stopped' : (existing?.status as any) ?? 'stopped', restartPolicy, origin, meta)
1355
1511
  }
1356
1512
 
1357
1513
  /**
@@ -1464,7 +1620,7 @@ export class TwinEngine {
1464
1620
  }
1465
1621
 
1466
1622
  /** 레지스트리 model 로 기동(프로비전된 인스턴스 start). model 인자 없이 저장된 구조로 재기동. */
1467
- static async startFromRegistry(domainId: string, instanceId: string, realityMode?: RealityMode): Promise<InstanceRuntime> {
1623
+ static async startFromRegistry(domainId: string, instanceId: string, restartPolicy?: RestartPolicy): Promise<InstanceRuntime> {
1468
1624
  const key = runtimeKey(domainId, instanceId)
1469
1625
  if (this.instances[key]) return this.instances[key]
1470
1626
  const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
@@ -1490,7 +1646,7 @@ export class TwinEngine {
1490
1646
  /*
1491
1647
  * 저널에 남아 있는 마지막 번호 — **모드와 무관하게** 이것을 이어 센다.
1492
1648
  *
1493
- * 저널을 초기화하고 기동하는 모드(sim-experiment)라면 이 값이 0이라 아무 영향이 없다. 규칙을
1649
+ * 저널을 초기화하고 기동하는 정책(`reset`)이라면 이 값이 0이라 아무 영향이 없다. 규칙을
1494
1650
  * 모드별로 구분하지 않는 이유: "저널이 비어 있지 않으면 그 뒤부터" 하나면 어느 모드에서도
1495
1651
  * 겹칠 수 없고, 모드가 늘어도 이 자리를 다시 손볼 일이 없다.
1496
1652
  */
@@ -1508,29 +1664,30 @@ export class TwinEngine {
1508
1664
  메웠는데, 그건 YMS/MES 트윈을 **조용히 WMS 로 부팅**시키는 길이었다 — 오류 없이 다른 공장이 뜬다. */
1509
1665
  reg.kind,
1510
1666
  reg.model as TwinModelDef,
1511
- realityMode ?? (reg.realityMode as RealityMode),
1667
+ /* 저장된 값은 **엄격히** 읽는다 — 모르는 값을 기본값으로 메우면 그 트윈이 조용히 다르게 재기동한다. */
1668
+ restartPolicy ?? readRestartPolicy(reg.restartPolicy, `twin "${instanceId}"`),
1512
1669
  reg.purpose,
1513
1670
  Number(last?.max ?? 0) || 0
1514
1671
  )
1515
1672
  }
1516
1673
 
1517
1674
  /**
1518
- * 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한 realityMode 에 따라 재기동 방식을 구분한다.
1675
+ * 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한 restartPolicy 에 따라 재기동 방식을 구분한다.
1519
1676
  * 부팅 경로를 한 곳에 모아 "런타임 ≠ 현실" 범주오류를 코드로 강제한다.
1520
- * - 'mirror' : 현실=외부 실물 → startLive(재동기). 이벤트는 어댑터 ingest 로 유입, 커널 tick 없음.
1521
- * - 'sim-world' : 생성 타임라인 지속 → 저널 보존 + resume(현실 이어감).
1677
+ * - 'resync' : 현실=외부 실물 → startLive(재동기). 이벤트는 어댑터 ingest 로 유입, 커널 tick 없음.
1678
+ * - 'resume' : 생성 타임라인 지속 → 저널 보존 + resume(현실 이어감).
1522
1679
  * ⚠ GATE(§8 ②③): 커널 상태 직렬화/재개가 아직 없어 진짜 resume 불가 → 실 필요·실부하 시 구현.
1523
1680
  * 그때까지는 저널을 보존하되 revision 충돌을 피하려 fresh 로 기동하고, 선언과 구현의 간극을 크게 경고한다.
1524
- * - 'sim-experiment' : seed 재현 → resetJournal + fresh 기동(백지 시작이 버그가 아니라 선언된 거동).
1681
+ * - 'reset' : seed 재현 → resetJournal + fresh 기동(백지 시작이 버그가 아니라 선언된 거동).
1525
1682
  * 반환: 기동된 InstanceRuntime.
1526
1683
  */
1527
- static async bootDeclared(domainId: string, instanceId: string, mode: RealityMode = DEFAULT_REALITY_MODE): Promise<InstanceRuntime> {
1528
- if (mode === 'mirror') {
1684
+ static async bootDeclared(domainId: string, instanceId: string, mode: RestartPolicy): Promise<InstanceRuntime> {
1685
+ if (mode === 'resync') {
1529
1686
  const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
1530
1687
  if (!reg?.model) throw new Error(`instance "${instanceId}" not provisioned (no model)`)
1531
1688
  return this.startLive(instanceId, domainId, reg.kind, reg.model as TwinModelDef)
1532
1689
  }
1533
- if (mode === 'sim-world') {
1690
+ if (mode === 'resume') {
1534
1691
  /*
1535
1692
  * **이어지는 현실** — 저널을 지우지 않는다.
1536
1693
  *
@@ -1543,11 +1700,11 @@ export class TwinEngine {
1543
1700
  * 같은 씨앗에서 다시 시작하므로 자극의 패턴이 재기동 지점에서 한 번 끊긴다. 쌓인 사실과
1544
1701
  * 상태는 이어지고, 앞으로 일어날 일의 무작위 순서만 새로 시작한다.
1545
1702
  */
1546
- return this.startFromRegistry(domainId, instanceId, 'sim-world')
1703
+ return this.startFromRegistry(domainId, instanceId, 'resume')
1547
1704
  }
1548
- // sim-experiment(기본): seed 재현 — 저널 초기화 후 revision 0 부터 재실행.
1705
+ // `reset`: seed 재현 — 저널 초기화 후 revision 0 부터 재실행.
1549
1706
  await this.resetJournal(domainId, instanceId)
1550
- return this.startFromRegistry(domainId, instanceId, 'sim-experiment')
1707
+ return this.startFromRegistry(domainId, instanceId, 'reset')
1551
1708
  }
1552
1709
 
1553
1710
  /**
@@ -1637,7 +1794,7 @@ export class TwinEngine {
1637
1794
  spaceId: r.spaceId, // co-location: 같은 spaceId 인스턴스들이 한 현장 공유 — 현장뷰 집약 키(G7)
1638
1795
  /* 사람이 읽는 현장 이름 — 없으면 `undefined`(화면이 "이름 없음" 을 알아볼 수 있어야 한다). */
1639
1796
  spaceName: (r.spaceId ? spaceNameOf.get(r.spaceId) : undefined) || undefined,
1640
- realityMode: r.realityMode, // 현실 출처 선언(§0 ①) — mirror/sim-world/sim-experiment. 저장 시 각인되므로 비지 않는다.
1797
+ restartPolicy: r.restartPolicy, // 재기동 정책(ADR-0029 §2) — resync/resume/reset. 선언이므로 비어 있을 수 없다.
1641
1798
  purpose: r.purpose, // 운영 vs 벤치 사본(1급 구별 — 이름 접두사 아님). 저장 시 각인되므로 비지 않는다.
1642
1799
  copyOf: r.copyOf ?? undefined,
1643
1800
  running: !!this.instances[runtimeKey(domainId, r.instanceId)],
@@ -1651,7 +1808,7 @@ export class TwinEngine {
1651
1808
  /* 스스로 멈춘 이유 — 있으면 낸다. 재기동 뒤에는 없다(그때는 「모른다」가 사실이다). */
1652
1809
  stopNote: this.stopNotes.get(runtimeKey(domainId, r.instanceId)) || undefined,
1653
1810
  liveFeed: liveFeedStateOf({
1654
- realityMode: r.realityMode,
1811
+ restartPolicy: r.restartPolicy,
1655
1812
  running: !!this.instances[runtimeKey(domainId, r.instanceId)],
1656
1813
  instanceId: r.instanceId
1657
1814
  }),
@@ -1744,6 +1901,8 @@ export class TwinEngine {
1744
1901
  static async ingestMaster(
1745
1902
  domainId: string,
1746
1903
  master: ReferenceMaster,
1904
+ /** 만들어지는 트윈의 재기동 정책 — 원본이 정하는 것이 아니라 **만드는 쪽의 선언**이다(ADR-0029 §2). */
1905
+ restartPolicy: RestartPolicy,
1747
1906
  into?: { spaceId?: string },
1748
1907
  /** 누가 인제스트했나 — 사람이 없는 경로(부팅)는 주지 않는다(감사 기록을 지어내지 않는다). */
1749
1908
  actor?: { id: string }
@@ -1914,7 +2073,7 @@ export class TwinEngine {
1914
2073
  warnings.push(structureAdopted(shift.rev, shift))
1915
2074
  } else {
1916
2075
  /* 사이트 이름이 곧 트윈의 이름이다 — 마스터가 이미 말했으므로 지어내지 않고 그대로 싣는다. */
1917
- await this.provision(domainId, master.source, master.system, model, undefined, master.origin, {
2076
+ await this.provision(domainId, master.source, master.system, model, restartPolicy, undefined, master.origin, {
1918
2077
  name: master.siteName,
1919
2078
  description: (master as any).description,
1920
2079
  actor
@@ -1960,7 +2119,7 @@ export class TwinEngine {
1960
2119
  kind: r.kind,
1961
2120
  status: r.status,
1962
2121
  running: !!this.instances[runtimeKey(domainId, r.instanceId)],
1963
- realityMode: r.realityMode, // 현실 선언 — what-if 적용 가드용(mirror=예측 전용, 자극 주입 불가)
2122
+ restartPolicy: r.restartPolicy, // 현실 선언 — what-if 적용 가드용(mirror=예측 전용, 자극 주입 불가)
1964
2123
  model: r.model
1965
2124
  }
1966
2125
  }
@@ -2652,7 +2811,7 @@ export class TwinEngine {
2652
2811
  mode: inst.mode ?? 'sim',
2653
2812
  domainId: inst.domainId,
2654
2813
  domainLabel: inst.domain?.subdomain,
2655
- realityMode: inst.realityMode,
2814
+ restartPolicy: inst.restartPolicy,
2656
2815
  tickMs: this.TICK_MS,
2657
2816
  running: !!inst.timer || inst.mode === 'live',
2658
2817
  ...(loadSummary(inst.load, this.TICK_MS) ?? { recent: null, total: null, forksCreated: 0, overBudget: 0, budgetMs: this.TICK_MS * 0.5, loadRatio: null })
package/server/index.ts CHANGED
@@ -11,7 +11,7 @@ import { resumeReferenceLiveFeeds } from './service/reference/reference-live.js'
11
11
  /*
12
12
  * 모듈 부팅 — **도는 트윈을 되살린다.**
13
13
  *
14
- * 두 층이 순서대로다: ① 엔진이 등록부의 `running` 행을 선언된 `realityMode` 그대로 되살리고
14
+ * 두 층이 순서대로다: ① 엔진이 등록부의 `running` 행을 선언된 `restartPolicy` 그대로 되살리고
15
15
  * (`bootstrap`), ② 레퍼런스 계층이 미러 트윈의 커넥터 피드를 다시 붙인다. 순서가 뒤집히면 피드가
16
16
  * 아직 없는 커널에 계측을 붓게 된다.
17
17
  *
@@ -0,0 +1,62 @@
1
+ /*
2
+ * **구동은 원본을 지난다** — 트윈을 우회하지 않는다 (ADR-0029 §8, 2026-08-19).
3
+ *
4
+ * ── 무엇이 뚫려 있었나 ──────────────────────────────────────────────────────
5
+ * `controlTwinScenario` 가 `TwinEngine.runtime(...).scenario` 를 **직접** 잡았다. 그래서 시뮬만 할 수 있는
6
+ * 일(자극·배속·정지)이 계약 밖의 지름길로 들어왔고, 실 시스템에는 그 자리가 아예 없었다 — ADR-0029 의
7
+ * 판정 문장에 걸리는 상태였다.
8
+ *
9
+ * 이제 길은 하나다: **트윈 → 그 트윈의 원본 → 어댑터가 선언한 `control` 능력.** 선언하지 않은 원본에는
10
+ * 문이 열리지 않고, 그 사실을 코드로 말한다(`not-declared`). 실 WMS 가 정지되지 않는 것은 결손이 아니다.
11
+ *
12
+ * ── 전송은 여기서 묻지 않는다 (§7) ──────────────────────────────────────────
13
+ * 인프로세스 시뮬레이터는 같은 계약을 인프로세스로 이행한다. 나중에 그것이 다른 프로세스로 나가도 이 경로는
14
+ * 바뀌지 않는다 — 「계약을 지나는가」만 보고 「네트워크를 타는가」는 묻지 않는다.
15
+ */
16
+ import { getRepository } from '@things-factory/shell'
17
+
18
+ import { TwinInstance } from '../twin-instance/twin-instance.js'
19
+ import { TwinReference } from './twin-reference.js'
20
+ import { capabilitiesOf, getAdapter, type ControlCommand, type ControlResult, type SiteDescriptor } from './reference-adapter.js'
21
+
22
+ /**
23
+ * 이 트윈의 원본에 구동 명령을 보낸다 — **원본이 없거나 능력을 선언하지 않으면 거절한다.**
24
+ *
25
+ * 거절을 조용히 하지 않는다: 사유 코드를 돌려주고 화면이 그것을 사람 말로 옮긴다. 「아무 일도 일어나지
26
+ * 않았다」가 가장 비싼 답이다.
27
+ */
28
+ export async function routeControl(domainId: string, instanceId: string, command: ControlCommand): Promise<ControlResult> {
29
+ const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
30
+ if (!inst) return { ok: false, code: 'unknown-twin', detail: instanceId }
31
+
32
+ const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } })
33
+ if (!ref) {
34
+ /* 원본을 모르는 트윈 — 구동할 대상이 없다. 트윈을 직접 흔드는 길은 두지 않는다(그것이 지름길이었다). */
35
+ return { ok: false, code: 'no-source', detail: `twin "${instanceId}" does not remember a source to drive` }
36
+ }
37
+
38
+ const adapter = getAdapter(ref.adapterType)
39
+ if (!adapter) return { ok: false, code: 'no-adapter', detail: ref.adapterType }
40
+ if (!capabilitiesOf(adapter).includes('control')) {
41
+ return {
42
+ ok: false,
43
+ code: 'not-declared',
44
+ detail: `source "${ref.source}" (${ref.adapterType}) does not declare the control capability — a real system is not paused or seeded, and that is a fact rather than a gap`
45
+ }
46
+ }
47
+
48
+ /*
49
+ * 사이트를 함께 넘긴다 — 원본 하나가 여러 사이트를 낼 수 있다(`discoverSites`). 우리가 아는 것은
50
+ * 이 트윈이 어느 사이트에서 왔는지이고(`origin.siteId`), 모르면 넘기지 않는다(지어내지 않는다).
51
+ */
52
+ const siteId = (inst.origin as any)?.siteId
53
+ const site: SiteDescriptor | undefined = siteId ? { siteId } : undefined
54
+ return adapter.control!(ref.connectionConfig ?? {}, site, { ...command, instanceId, domainId })
55
+ }
56
+
57
+ /** 이 트윈의 원본이 선언한 능력 — 화면이 「무엇을 할 수 있나」를 먼저 알 수 있게. */
58
+ export async function controlCapabilityOf(domainId: string, instanceId: string): Promise<{ source?: string; adapterType?: string; capabilities: string[] }> {
59
+ const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } })
60
+ if (!ref) return { capabilities: [] }
61
+ return { source: ref.source, adapterType: ref.adapterType, capabilities: capabilitiesOf(getAdapter(ref.adapterType)) }
62
+ }
@@ -3,3 +3,4 @@ import { TwinReference } from './twin-reference.js'
3
3
 
4
4
  export const entities = [TwinReference]
5
5
  export const resolvers = [TwinReferenceResolver]
6
+ export * from './control-routing.js'