@things-factory/headless-twin 10.1.41 → 10.1.44

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.
@@ -0,0 +1,64 @@
1
+ /*
2
+ * **A twin made by one producer is not rebuilt by another.** Pure.
3
+ *
4
+ * ── What went wrong (2026-09-24) ─────────────────────────────────────────────
5
+ * An import's instance id is the adapter's site id (`master.source`). Importing the warehouse connection
6
+ * `wms-miratek` with site id `miratek` — the same as the plant connection's — landed on the running plant
7
+ * mirror `miratek`, and `ingestMaster` gave it the warehouse structure as a new revision: 16 places,
8
+ * 23 equipment, 17 people and 14 assets gone. The row already said who made it (`origin.source` was
9
+ * `mes-miratek`); nothing asked.
10
+ *
11
+ * ── The rule (chief architect ruling, 2026-09-24) ────────────────────────────
12
+ * Compare producers by identity, not by the whole origin: a reference is kind + source, a template is
13
+ * kind + templateId. The same import carries more or fewer fields at different call sites (siteId,
14
+ * spaceId), and a template re-run has new params — those are the same producer re-reading its own twin.
15
+ *
16
+ * both declared, same producer allowed (resync, re-import, re-run)
17
+ * both declared, different refused, naming the producer that owns it
18
+ * prior undeclared, incoming not refused — nobody can say who made it; delete it first to replace it
19
+ * prior declared, incoming not refused — same reason, the other way
20
+ * neither declared allowed — the demo seed re-ingesting its own twin at boot. Closes when
21
+ * the seed declares a template origin.
22
+ */
23
+
24
+ export const INSTANCE_TAKEN = 'instance-taken-by-another-source'
25
+
26
+ export interface ProducerRefusal {
27
+ code: typeof INSTANCE_TAKEN
28
+ params: { instance: string; producer: string }
29
+ message: string
30
+ }
31
+
32
+ /** Who produced a twin, as one comparable string — or undefined when the origin says nothing usable. */
33
+ export function producerOf(origin: unknown): string | undefined {
34
+ if (!origin || typeof origin !== 'object') return undefined
35
+ const o = origin as { kind?: unknown; source?: unknown; templateId?: unknown }
36
+ if (o.kind === 'reference' && typeof o.source === 'string' && o.source) return `reference:${o.source}`
37
+ if (o.kind === 'template' && typeof o.templateId === 'string' && o.templateId) return `template:${o.templateId}`
38
+ /* A kind this guard does not know is still somebody — keep it distinct rather than reading it as none. */
39
+ if (typeof o.kind === 'string' && o.kind) return `${o.kind}:${String(o.source ?? o.templateId ?? '')}`
40
+ return undefined
41
+ }
42
+
43
+ function nameOf(origin: unknown): string {
44
+ const o = (origin ?? {}) as { source?: unknown; templateId?: unknown }
45
+ return String(o.source ?? o.templateId ?? '') || 'undeclared'
46
+ }
47
+
48
+ /** null when `incoming` may rebuild the twin `prior` was made by; otherwise the refusal. */
49
+ export function refuseOverwriteByAnotherProducer(
50
+ instance: string,
51
+ priorOrigin: unknown,
52
+ incomingOrigin: unknown
53
+ ): ProducerRefusal | null {
54
+ const had = producerOf(priorOrigin)
55
+ const comes = producerOf(incomingOrigin)
56
+ if (!had && !comes) return null
57
+ if (had && comes && had === comes) return null
58
+ const producer = had ? nameOf(priorOrigin) : 'undeclared'
59
+ return {
60
+ code: INSTANCE_TAKEN,
61
+ params: { instance, producer },
62
+ message: `twin "${instance}" was made by ${producer} — delete it first, or import this site under another id`
63
+ }
64
+ }
@@ -25,6 +25,7 @@ import { planLiveContinuity, planWarmStart, unwrapState } from './warm-start.js'
25
25
  import { applyDeclarationLayers } from './local-declarations.js'
26
26
  import { isOfDomain, parseRuntimeKey, runtimeKey } from './runtime-key.js'
27
27
  import { TWIN_STATE_TOPIC, twinStatePayload } from './twin-state-channel.js'
28
+ import { refuseOverwriteByAnotherProducer } from './producer-guard.js'
28
29
  import { routeCommand } from './command-routing.js'
29
30
  import { TwinInstance } from '../service/twin-instance/twin-instance.js'
30
31
  /* 자극의 집은 원본이다(ADR-0029) — 그 행을 읽고 쓴다. */
@@ -1217,6 +1218,7 @@ export class TwinEngine {
1217
1218
  `${plan.itemCount} item(s)`,
1218
1219
  `${plan.equipmentCount} equipment`,
1219
1220
  `${plan.orderCount} open order(s)`,
1221
+ ...(plan.foldedOrderCount ? [`${plan.foldedOrderCount} folded finished order(s)`] : []),
1220
1222
  `${plan.taskCount} task(s)`,
1221
1223
  ...(plan.personCount ? [`${plan.personCount} person(s)`] : []),
1222
1224
  ...(plan.assetCount ? [`${plan.assetCount} asset(s)`] : []),
@@ -3419,6 +3421,19 @@ export class TwinEngine {
3419
3421
  * 대상이 사라졌으면(구조가 바뀌었다) 그 사실을 경고로 낸다 — 갈 곳 없는 선언을 알리지 않고 버리지 않는다.
3420
3422
  */
3421
3423
  const prior = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId: master.source } })
3424
+ /*
3425
+ * A twin another producer made is not rebuilt here (engine/producer-guard.ts). Refused before anything
3426
+ * below writes — `prior` is saved a few lines on, and refusing after that would leave it half-changed.
3427
+ */
3428
+ if (prior) {
3429
+ const taken = refuseOverwriteByAnotherProducer(master.source, prior.origin, (master as any).origin)
3430
+ if (taken) {
3431
+ const err: any = new Error(taken.message)
3432
+ err.code = taken.code
3433
+ err.params = taken.params
3434
+ throw err
3435
+ }
3436
+ }
3422
3437
  /*
3423
3438
  * 겹이 **둘**이다 — 현장(공간)의 것과 이 트윈의 것. 순서가 규율이다: 현장 먼저, 트윈이 나중.
3424
3439
  * 요금처럼 현장이 정한 수는 그 현장의 트윈 전부가 같이 읽어야 하고, 한 트윈만 다르게 두는 실험은
@@ -66,6 +66,8 @@ export interface WarmStartCounts {
66
66
  * progress 만 있는 오더는 남은 수량을 알 수 없어 재계획의 근거가 되지 못한다. 지어내지 않고 뺀다.
67
67
  */
68
68
  ordersWithoutDemand: number
69
+ /** Finished orders carried folded (ADR-0092) — 0 when the twin had folded nothing. */
70
+ foldedOrderCount: number
69
71
  }
70
72
 
71
73
  export type WarmStartPlan =
@@ -86,10 +88,17 @@ interface ObservedState {
86
88
  assets?: unknown[]
87
89
  tasks?: unknown[]
88
90
  orders?: unknown[]
91
+ foldedOrders?: unknown
89
92
  acked?: string[]
90
93
  attentionSince?: { id: string; since: string }[]
91
94
  }
92
95
 
96
+ function foldedTotal(f: { counts: Record<string, Record<string, number>> }): number {
97
+ let n = 0
98
+ for (const byStatus of Object.values(f.counts)) for (const c of Object.values(byStatus ?? {})) n += Number(c) || 0
99
+ return n
100
+ }
101
+
93
102
  /** 관측 상태가 실려 있는지 — 축 하나라도 있으면 상태다(봉투에는 없다). */
94
103
  const looksLikeState = (x: any): boolean =>
95
104
  !!x && typeof x === 'object' && ['locations', 'items', 'equipment', 'orders', 'tasks', 'persons', 'assets', 'acked'].some(k => k in x)
@@ -146,8 +155,16 @@ export function planWarmStart(state: ObservedState | null | undefined, purpose:
146
155
  ? state.attentionSince.filter((x: any): x is { id: string; since: string } => !!x && typeof x.id === 'string' && typeof x.since === 'string')
147
156
  : []
148
157
 
158
+ /*
159
+ * Folded orders (ADR-0092) — their counts are the only record of the finished orders taken out of the hot
160
+ * list; nothing re-sends them. The seed used to leave them out, so a restart lost every folded count.
161
+ * Carried as the kernel wrote them; a value without a `counts` object is not a folded side.
162
+ */
163
+ const f: any = (state as any).foldedOrders
164
+ const foldedOrders = f && typeof f === 'object' && f.counts && typeof f.counts === 'object' ? f : undefined
165
+
149
166
  /* 어느 축도 없으면 심을 것이 없다 — 빈 주입으로 로그만 남기지 않는다. */
150
- if (!locations.length && !items.length && !equipment.length && !persons.length && !assets.length && !tasks.length && !orders.length && !acked.length && !attentionSince.length) {
167
+ if (!locations.length && !items.length && !equipment.length && !persons.length && !assets.length && !tasks.length && !orders.length && !foldedOrders && !acked.length && !attentionSince.length) {
151
168
  return { action: 'skip', reason: 'no-state' }
152
169
  }
153
170
 
@@ -167,6 +184,7 @@ export function planWarmStart(state: ObservedState | null | undefined, purpose:
167
184
  ...(tasks.length ? { tasks } : {}),
168
185
  /* 원값 없는 오더는 넘기지 않는다 — 커널도 걸러 내지만, 넘기지 않으면 세어 둔 수와 어긋날 일이 없다. */
169
186
  ...(seedable.length ? { orders: seedable } : {}),
187
+ ...(foldedOrders ? { foldedOrders } : {}),
170
188
  /* 확인 처리는 조건이 사라진 id 가 섞여 있어도 해롭지 않다 — 그 신호가 없으면 표시할 대상이 없다. */
171
189
  ...(acked.length ? { acked } : {}),
172
190
  /* 조건이 사라진 id 가 섞여 있어도 해롭지 않다 — 커널이 그 신호가 없으면 함께 지운다. */
@@ -178,6 +196,7 @@ export function planWarmStart(state: ObservedState | null | undefined, purpose:
178
196
  assetCount: assets.length,
179
197
  taskCount: tasks.length,
180
198
  orderCount: seedable.length,
199
+ foldedOrderCount: foldedOrders ? foldedTotal(foldedOrders) : 0,
181
200
  ordersWithoutDemand: orders.length - seedable.length,
182
201
  ackedCount: acked.length,
183
202
  attentionSinceCount: attentionSince.length
@@ -914,7 +914,7 @@ export class TwinReferenceResolver {
914
914
  const at = (adapter.masterSteps?.[lastDone] ?? lastStep) as ReferenceProgress['step']
915
915
  emit({ siteId: site.siteId, siteIndex, step: at, done: lastDone, total: stepTotal, failed: true, error: e?.message ?? String(e) })
916
916
 
917
- failed.push({ siteId: site.siteId, error: e?.message ?? 'failed' })
917
+ failed.push({ siteId: site.siteId, error: e?.message ?? 'failed', ...(e?.code ? { errorCode: e.code, errorParams: e.params } : {}) })
918
918
  }
919
919
  }
920
920
  // provenance — 이 레퍼런스가 만든 인스턴스 추적(scopeSpec.produced). 삭제 연쇄·상태 동기·네비게이션 근거.
@@ -17,6 +17,27 @@
17
17
 
18
18
  import { isFlowOrder, isOrderTerminal } from '@operato/ops-contract'
19
19
 
20
+ /*
21
+ * ── Folded orders are counted too (ADR-0092 decision 3, ruling 2026-09-24) ─────────────
22
+ *
23
+ * A twin folds finished orders out of its hot list at checkpoint time and keeps only their counts
24
+ * (`foldedOrders.counts`, kind × terminal status, never trimmed). Counting the hot list alone would drop
25
+ * `orders` and `shipped` the moment a fold happens — and "the increase since the fork" alone would still
26
+ * break `twinForecastGapTrend`, whose actual at T+h is read from recovered state: a fold between T and
27
+ * T+h makes the actual increase negative. So both metrics are cumulative including the folded side, and
28
+ * an increase is the difference of the same number. Which folded kinds are flow orders is asked of the
29
+ * contract's `isFlowOrder`, the same as the hot list; `shipped` uses the same word on both sides.
30
+ */
31
+ function foldedFlowOrders(s: any, status?: string): number {
32
+ let n = 0
33
+ for (const [kind, byStatus] of Object.entries((s?.foldedOrders?.counts ?? {}) as Record<string, Record<string, number>>)) {
34
+ if (!isFlowOrder({ kind })) continue
35
+ if (status) n += Number(byStatus?.[status]) || 0
36
+ else for (const c of Object.values(byStatus ?? {})) n += Number(c) || 0
37
+ }
38
+ return n
39
+ }
40
+
20
41
  /** 스냅샷 → 수 하나. 값이 없으면 0 이 아니라 **그 지표를 그릴 수 없다**는 뜻이지만, 그래프 계약이
21
42
  * 수를 요구하므로 0 을 낸다 — 그래서 에너지 지표는 「없음」이 0 과 헷갈리지 않는 것만 골랐다. */
22
43
  export type MetricFn = (s: any) => number
@@ -32,9 +53,9 @@ export const FORECAST_METRICS: Record<string, MetricFn> = {
32
53
  * 한 건씩 늘고 백로그는 영영 안 줄어든다 — 예측 그래프가 있지도 않은 일을 그린다. 무엇이 흐름
33
54
  * 오더인지는 계약의 `isFlowOrder` 한 곳이 정한다(여기서 `kind` 를 다시 적지 않는다).
34
55
  */
35
- orders: s => (s?.orders || []).filter(isFlowOrder).length,
56
+ orders: s => (s?.orders || []).filter(isFlowOrder).length + foldedFlowOrders(s),
36
57
  // 이행 지표 — 라이브 예측의 핵심 질문("언제 다 나가나·백로그 언제 풀리나").
37
- shipped: s => (s?.orders || []).filter(isFlowOrder).filter((o: any) => o.status === 'shipped').length,
58
+ shipped: s => (s?.orders || []).filter(isFlowOrder).filter((o: any) => o.status === 'shipped').length + foldedFlowOrders(s, 'shipped'),
38
59
  /*
39
60
  * ── 백로그는 **「종결인가」**로 묻는다 (2026-09-19) ─────────────────────────
40
61
  *
@@ -7,7 +7,7 @@
7
7
  import { test } from 'node:test'
8
8
  import assert from 'node:assert/strict'
9
9
 
10
- import { FORECAST_METRICS, metricsForKind } from '../dist-server/service/twin-forecast/forecast-metrics.js'
10
+ import { FORECAST_METRICS, metricsForKind } from '../server/service/twin-forecast/forecast-metrics.ts'
11
11
 
12
12
  test('물류 지표 — 기존 여섯은 그대로 센다', () => {
13
13
  const s = {
@@ -112,3 +112,46 @@ test('목록의 모든 키가 실제로 계산 가능하다 — 화면에 뜨는
112
112
  }
113
113
  }
114
114
  })
115
+
116
+ /*
117
+ * ★ Folding does not move the count (ADR-0092 decision 3, ruling 2026-09-24).
118
+ * A twin folds finished orders out of its hot list and keeps kind × status counts. Counting the hot list
119
+ * alone dropped orders/shipped at every fold, and made the gap trend's actual increase negative.
120
+ */
121
+ const hot = (orders: any[], foldedOrders?: any) => ({ orders, ...(foldedOrders ? { foldedOrders } : {}) })
122
+
123
+ test('folding the finished orders leaves orders and shipped where they were', () => {
124
+ const before = hot([
125
+ { id: 'o1', kind: 'outbound', status: 'shipped' },
126
+ { id: 'o2', kind: 'outbound', status: 'cancelled' },
127
+ { id: 'o3', kind: 'outbound', status: 'picking' },
128
+ { id: 'r1', kind: 'inbound', status: 'completed' }
129
+ ])
130
+ const after = hot([{ id: 'o3', kind: 'outbound', status: 'picking' }], {
131
+ counts: { outbound: { shipped: 1, cancelled: 1 }, inbound: { completed: 1 } },
132
+ boundaryMs: 1
133
+ })
134
+ for (const m of ['orders', 'shipped', 'backlog'] as const) assert.equal(FORECAST_METRICS[m](after), FORECAST_METRICS[m](before), m)
135
+ assert.equal(FORECAST_METRICS.orders(after), 3, 'receiving orders are not counted, folded or not')
136
+ })
137
+
138
+ test('a fold between T and T+h keeps the actual increase from going negative', () => {
139
+ const atT = hot([{ kind: 'outbound', status: 'shipped' }, { kind: 'outbound', status: 'shipped' }, { kind: 'outbound', status: 'picking' }])
140
+ /* by T+h: one more shipped, and the three shipped have been folded */
141
+ const atTh = hot([{ kind: 'outbound', status: 'picking' }], { counts: { outbound: { shipped: 3 } }, boundaryMs: 2 })
142
+ assert.equal(FORECAST_METRICS.shipped(atTh) - FORECAST_METRICS.shipped(atT), 1)
143
+ assert.ok(FORECAST_METRICS.orders(atTh) - FORECAST_METRICS.orders(atT) >= 0)
144
+ })
145
+
146
+ test('a twin with nothing folded counts as before', () => {
147
+ const s = hot([{ kind: 'outbound', status: 'shipped' }, { status: 'picking' }])
148
+ assert.equal(FORECAST_METRICS.orders(s), 2)
149
+ assert.equal(FORECAST_METRICS.shipped(s), 1)
150
+ })
151
+
152
+ test('a folded order with no kind is a flow order, as it is in the hot list', () => {
153
+ const folded = hot([], { counts: { '': { shipped: 2 } }, boundaryMs: 1 })
154
+ const unfolded = hot([{ status: 'shipped' }, { status: 'shipped' }])
155
+ assert.equal(FORECAST_METRICS.orders(folded), FORECAST_METRICS.orders(unfolded))
156
+ assert.equal(FORECAST_METRICS.shipped(folded), FORECAST_METRICS.shipped(unfolded))
157
+ })
@@ -0,0 +1,66 @@
1
+ /*
2
+ * ★ A twin made by one producer is not rebuilt by another (chief architect ruling, 2026-09-24).
3
+ *
4
+ * On 09-24 the warehouse connection `wms-miratek`, imported with site id `miratek`, landed on the running
5
+ * plant mirror `miratek` and replaced its structure. The row already named its producer. These pin the
6
+ * rule, and the last test pins where it runs: before `ingestMaster` writes anything.
7
+ */
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { readFileSync } from 'node:fs'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ import { INSTANCE_TAKEN, producerOf, refuseOverwriteByAnotherProducer } from '../server/engine/producer-guard.ts'
14
+
15
+ const PLANT = { kind: 'reference', source: 'mes-miratek' }
16
+
17
+ test('a different connection is refused, and the refusal names the connection that made the twin', () => {
18
+ const r = refuseOverwriteByAnotherProducer('miratek', PLANT, { kind: 'reference', source: 'wms-miratek', siteId: 'miratek' })
19
+ assert.equal(r?.code, INSTANCE_TAKEN)
20
+ assert.deepEqual(r?.params, { instance: 'miratek', producer: 'mes-miratek' })
21
+ })
22
+
23
+ test('the same producer re-reading its own twin passes, whatever else its origin carries', () => {
24
+ /* Import sites carry { kind, source } at one call and add siteId · spaceId at another (resolver 168 vs 889). */
25
+ assert.equal(refuseOverwriteByAnotherProducer('miratek', PLANT, { ...PLANT, siteId: 'miratek', spaceId: 'miratek' }), null)
26
+ /* Resync hands the stored origin back as it is (masterFromOrigin). */
27
+ assert.equal(refuseOverwriteByAnotherProducer('miratek', { ...PLANT, siteId: 'miratek' }, { ...PLANT, siteId: 'miratek' }), null)
28
+ /* A template re-run with other knobs is the same template. */
29
+ assert.equal(
30
+ refuseOverwriteByAnotherProducer('w1', { kind: 'template', templateId: 'wms-unit-load', params: { racks: 4 } }, { kind: 'template', templateId: 'wms-unit-load', params: { racks: 9 } }),
31
+ null
32
+ )
33
+ })
34
+
35
+ test('a template and a reference are different producers even with the same name', () => {
36
+ assert.equal(refuseOverwriteByAnotherProducer('x', { kind: 'template', templateId: 'x' }, { kind: 'reference', source: 'x' })?.code, INSTANCE_TAKEN)
37
+ })
38
+
39
+ test('(a) a twin nobody can name is not rebuilt by a declared producer', () => {
40
+ const r = refuseOverwriteByAnotherProducer('old', undefined, PLANT)
41
+ assert.deepEqual(r?.params, { instance: 'old', producer: 'undeclared' })
42
+ })
43
+
44
+ test('(b) a declared twin is not rebuilt by an undeclared producer', () => {
45
+ assert.deepEqual(refuseOverwriteByAnotherProducer('miratek', PLANT, undefined)?.params, { instance: 'miratek', producer: 'mes-miratek' })
46
+ })
47
+
48
+ test('(c) neither declared passes — the demo seed re-ingesting its own twin at boot', () => {
49
+ assert.equal(refuseOverwriteByAnotherProducer('rosarito-mes', undefined, undefined), null)
50
+ assert.equal(refuseOverwriteByAnotherProducer('rosarito-mes', {}, { kind: '' }), null)
51
+ })
52
+
53
+ test('a kind the guard does not know is still somebody, not nobody', () => {
54
+ assert.notEqual(producerOf({ kind: 'bench', source: 'hatio-yard' }), undefined)
55
+ })
56
+
57
+ test('ingestMaster refuses before its first write — a refusal after it would leave the twin half-changed', () => {
58
+ const engine = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8')
59
+ const at = engine.indexOf('static async ingestMaster(')
60
+ const body = engine.slice(at, engine.indexOf('\n static ', at + 10))
61
+ const guard = body.indexOf('refuseOverwriteByAnotherProducer(')
62
+ const firstWrite = body.search(/\.save\(|\.update\(|\.insert\(|\.delete\(/)
63
+ assert.ok(guard > 0, 'ingestMaster no longer asks who made the twin')
64
+ assert.ok(firstWrite > 0, 'no write found — this check would pass by finding nothing')
65
+ assert.ok(guard < firstWrite, 'the refusal must come before the first write')
66
+ })
@@ -10,6 +10,12 @@
10
10
  * · 인제스트가 원천을 각인한다 — 원천이 무엇이든 한 자리에
11
11
  * · 원천을 모르는 재프로비전이 **이미 아는 것을 지우지 않는다** — 한 번 지우면 다시 못 읽는다
12
12
  * · 못 읽는 트윈은 **못 읽는다고 말한다** — 짐작으로 아무 원본이나 집지 않는다
13
+ *
14
+ * Since 2026-09-24 (chief architect ruling, engine/producer-guard.ts) a re-provision that does not come
15
+ * from the twin's own producer is **refused** rather than applied: a silent one used to keep the origin
16
+ * but replace the structure, and a different producer used to replace both — the second is how the
17
+ * plant mirror `miratek` got the warehouse's structure. The two tests below now pin the refusal, and
18
+ * that the refused twin's origin and structure are untouched.
13
19
  */
14
20
  import { test, before, after } from 'node:test'
15
21
  import assert from 'node:assert/strict'
@@ -72,26 +78,39 @@ test('인제스트가 원천을 각인한다 — 원천이 무엇이든 한 자
72
78
  assert.deepEqual(await originOf(BORN), { kind: 'template', templateId: 'wh-unit-load', params: { bins: 3 } })
73
79
  })
74
80
 
75
- test('원천을 모르는 재프로비전이 이미 아는 것을 지우지 않는다 — 한 번 지우면 다시 못 읽는다', async () => {
81
+ const TEMPLATE = { kind: 'template', templateId: 'wh-unit-load', params: { bins: 3 } }
82
+ const locationsOf = async (instanceId: string) =>
83
+ (await ds.getRepository(TwinInstance).findOne({ where: { instanceId } }))?.model?.locations?.length
84
+
85
+ test('원천을 모르는 재프로비전은 거절된다 — 원천도 구조도 그대로다', async () => {
76
86
  /*
77
- * 이 경로는 실제로 있다 — 원천을 모르는 호출자(스크립트·복구·옛 코드)가 같은 트윈을 다시
78
- * 인제스트한다. 그때 `origin` 을 덮어 비우면 그 트윈은 **알리지 않고 다시 읽을 수 없게 된다.**
79
- * 그런 종류의 손상은 다음번에 다시 읽으려 할 때까지 아무도 모른다.
87
+ * 이 경로는 실제로 있다 — 원천을 모르는 호출자(스크립트·복구·옛 코드)가 같은 트윈을 다시 인제스트한다.
88
+ * Its origin used to be kept while the structure was replaced by whatever that caller had. It is refused
89
+ * now (ruling (b)): the twin's own producer re-reads it; anything else deletes it first.
80
90
  */
81
- await TwinEngine.ingestMaster(domainId, master(BORN, 5), 'reset')
82
- assert.deepEqual(
83
- await originOf(BORN),
84
- { kind: 'template', templateId: 'wh-unit-load', params: { bins: 3 } },
85
- '원천을 말하지 않은 인제스트는 기존 원천을 보존한다'
86
- )
91
+ await assert.rejects(TwinEngine.ingestMaster(domainId, master(BORN, 5), 'reset'), (e: any) => {
92
+ assert.equal(e.code, 'instance-taken-by-another-source')
93
+ assert.deepEqual(e.params, { instance: BORN, producer: 'wh-unit-load' })
94
+ return true
95
+ })
96
+ assert.deepEqual(await originOf(BORN), TEMPLATE)
97
+ assert.equal(await locationsOf(BORN), 4, 'the refused master did not land (dock 1 + bins 3)')
98
+ })
87
99
 
88
- const inst = await ds.getRepository(TwinInstance).findOne({ where: { instanceId: BORN } })
89
- assert.equal(inst.model.locations.length, 6, '구조 자체는 새 마스터로 갱신된다(도크 1 + 빈 5)')
100
+ test('다른 생산자는 거절된다 — 이름을 대고, 원천도 구조도 그대로다', async () => {
101
+ await assert.rejects(
102
+ TwinEngine.ingestMaster(domainId, master(BORN, 5, { kind: 'reference', source: 'oracle-wms' }), 'reset'),
103
+ (e: any) => e.code === 'instance-taken-by-another-source' && e.params?.producer === 'wh-unit-load'
104
+ )
105
+ assert.deepEqual(await originOf(BORN), TEMPLATE)
106
+ assert.equal(await locationsOf(BORN), 4)
90
107
  })
91
108
 
92
- test('새 원천을 말하면 갱신된다 — 보존은 침묵에만 적용된다', async () => {
93
- await TwinEngine.ingestMaster(domainId, master(BORN, 5, { kind: 'reference', source: 'oracle-wms' }), 'reset')
94
- assert.deepEqual(await originOf(BORN), { kind: 'reference', source: 'oracle-wms' })
109
+ test('같은 생산자가 다시 읽으면 지나간다 — 노브가 달라도', async () => {
110
+ const again = { ...TEMPLATE, params: { bins: 5 } }
111
+ await TwinEngine.ingestMaster(domainId, master(BORN, 5, again), 'reset')
112
+ assert.deepEqual(await originOf(BORN), again)
113
+ assert.equal(await locationsOf(BORN), 6, 'its own template re-run rebuilds it (dock 1 + bins 5)')
95
114
  })
96
115
 
97
116
  test('원천 없이 태어난 트윈은 원천이 없다 — 짐작해 채우지 않는다', async () => {
@@ -106,7 +125,7 @@ test('조회가 다시 읽을 수 있는지를 먼저 답한다 — 못 하는
106
125
 
107
126
  const born = await q.twinModelSummary(BORN, ctx)
108
127
  assert.equal(born.canResync, true)
109
- assert.deepEqual(born.origin, { kind: 'reference', source: 'oracle-wms' })
128
+ assert.deepEqual(born.origin, { ...TEMPLATE, params: { bins: 5 } })
110
129
 
111
130
  const orphan = await q.twinModelSummary(ORPHAN, ctx)
112
131
  assert.equal(orphan.canResync, false, '버튼을 그대로 내면 누를 때마다 실패한다')
@@ -142,3 +142,21 @@ test('라이브 상태의 확인 처리가 신호에 반영된다 — 라이브
142
142
  const after = withLiveAttentions({ ...state, acked: [signal.id] }).attentions ?? []
143
143
  assert.equal(after.find((a: any) => a.id === signal.id)?.state, 'acknowledged', '확인 처리가 라이브 신호에 반영돼야 한다')
144
144
  })
145
+
146
+ /*
147
+ * ★ Folded orders survive a restart (ADR-0092). Their counts are the only record of the finished orders
148
+ * taken out of the hot list, and the seed used to leave them out — a restart after a fold lost them all.
149
+ */
150
+ test('the folded side comes back with the twin — nothing re-sends those counts', () => {
151
+ const foldedOrders = { counts: { outbound: { shipped: 40, cancelled: 2 }, inbound: { completed: 55 } }, boundaryMs: 1_700_000_000_000 }
152
+ const { k, plan } = seedKernel({ ...observed([]), foldedOrders } as any)
153
+ assert.equal(plan.foldedOrderCount, 97)
154
+ assert.deepEqual((k as any).getSnapshot().foldedOrders?.counts, foldedOrders.counts)
155
+ })
156
+
157
+ test('a value without counts is not a folded side, and a twin that folded nothing carries none', () => {
158
+ const junk = planWarmStart({ ...observed([]), foldedOrders: { boundaryMs: 1 } } as any, undefined, true)
159
+ assert.equal(junk.action === 'hydrate' && 'foldedOrders' in junk.seed, false)
160
+ const none = planWarmStart(observed([]), undefined, true)
161
+ assert.equal(none.action === 'hydrate' && none.foldedOrderCount, 0)
162
+ })