@things-factory/headless-twin 10.1.41 → 10.1.45

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 (28) hide show
  1. package/dist-server/engine/producer-guard.d.ts +13 -0
  2. package/dist-server/engine/producer-guard.js +62 -0
  3. package/dist-server/engine/producer-guard.js.map +1 -0
  4. package/dist-server/engine/twin-engine.js +15 -0
  5. package/dist-server/engine/twin-engine.js.map +1 -1
  6. package/dist-server/engine/warm-start.d.ts +3 -0
  7. package/dist-server/engine/warm-start.js +17 -1
  8. package/dist-server/engine/warm-start.js.map +1 -1
  9. package/dist-server/service/reference/reference-resolver.js +1 -1
  10. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  11. package/dist-server/service/twin-event/twin-event-keys.d.ts +4 -1
  12. package/dist-server/service/twin-event/twin-event-keys.js +6 -3
  13. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  14. package/dist-server/service/twin-forecast/forecast-metrics.js +26 -2
  15. package/dist-server/service/twin-forecast/forecast-metrics.js.map +1 -1
  16. package/package.json +8 -8
  17. package/server/engine/producer-guard.ts +64 -0
  18. package/server/engine/twin-engine.ts +15 -0
  19. package/server/engine/warm-start.ts +20 -1
  20. package/server/service/reference/reference-resolver.ts +1 -1
  21. package/server/service/twin-event/twin-event-keys.ts +7 -3
  22. package/server/service/twin-forecast/forecast-metrics.ts +23 -2
  23. package/test/forecast-metrics.test.ts +44 -1
  24. package/test/producer-guard.test.ts +66 -0
  25. package/test/twin-event-keys.test.ts +7 -0
  26. package/test/twin-origin-resync.test.ts +35 -16
  27. package/test/warm-start-seam.test.ts +18 -0
  28. package/tsconfig.tsbuildinfo +1 -1
@@ -19,6 +19,30 @@ Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.FLOW_METRICS = exports.ENERGY_METRICS = exports.FORECAST_METRICS = void 0;
20
20
  exports.metricsForKind = metricsForKind;
21
21
  const ops_contract_1 = require("@operato/ops-contract");
22
+ /*
23
+ * ── Folded orders are counted too (ADR-0092 decision 3, ruling 2026-09-24) ─────────────
24
+ *
25
+ * A twin folds finished orders out of its hot list at checkpoint time and keeps only their counts
26
+ * (`foldedOrders.counts`, kind × terminal status, never trimmed). Counting the hot list alone would drop
27
+ * `orders` and `shipped` the moment a fold happens — and "the increase since the fork" alone would still
28
+ * break `twinForecastGapTrend`, whose actual at T+h is read from recovered state: a fold between T and
29
+ * T+h makes the actual increase negative. So both metrics are cumulative including the folded side, and
30
+ * an increase is the difference of the same number. Which folded kinds are flow orders is asked of the
31
+ * contract's `isFlowOrder`, the same as the hot list; `shipped` uses the same word on both sides.
32
+ */
33
+ function foldedFlowOrders(s, status) {
34
+ let n = 0;
35
+ for (const [kind, byStatus] of Object.entries((s?.foldedOrders?.counts ?? {}))) {
36
+ if (!(0, ops_contract_1.isFlowOrder)({ kind }))
37
+ continue;
38
+ if (status)
39
+ n += Number(byStatus?.[status]) || 0;
40
+ else
41
+ for (const c of Object.values(byStatus ?? {}))
42
+ n += Number(c) || 0;
43
+ }
44
+ return n;
45
+ }
22
46
  exports.FORECAST_METRICS = {
23
47
  items: s => (s?.items || []).length,
24
48
  occupancy: s => (s?.locations || []).reduce((a, n) => a + (n.occupancy || 0), 0),
@@ -30,9 +54,9 @@ exports.FORECAST_METRICS = {
30
54
  * 한 건씩 늘고 백로그는 영영 안 줄어든다 — 예측 그래프가 있지도 않은 일을 그린다. 무엇이 흐름
31
55
  * 오더인지는 계약의 `isFlowOrder` 한 곳이 정한다(여기서 `kind` 를 다시 적지 않는다).
32
56
  */
33
- orders: s => (s?.orders || []).filter(ops_contract_1.isFlowOrder).length,
57
+ orders: s => (s?.orders || []).filter(ops_contract_1.isFlowOrder).length + foldedFlowOrders(s),
34
58
  // 이행 지표 — 라이브 예측의 핵심 질문("언제 다 나가나·백로그 언제 풀리나").
35
- shipped: s => (s?.orders || []).filter(ops_contract_1.isFlowOrder).filter((o) => o.status === 'shipped').length,
59
+ shipped: s => (s?.orders || []).filter(ops_contract_1.isFlowOrder).filter((o) => o.status === 'shipped').length + foldedFlowOrders(s, 'shipped'),
36
60
  /*
37
61
  * ── 백로그는 **「종결인가」**로 묻는다 (2026-09-19) ─────────────────────────
38
62
  *
@@ -1 +1 @@
1
- {"version":3,"file":"forecast-metrics.js","sourceRoot":"","sources":["../../../server/service/twin-forecast/forecast-metrics.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;GAeG;;;AAmEH,wCAEC;AAnED,wDAAoE;AAMvD,QAAA,gBAAgB,GAA6B;IACxD,KAAK,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM;IACnC,SAAS,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,SAAS,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAS,EAAE,CAAM,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC;IAC7F,KAAK,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM;IACnC;;;;;;OAMG;IACH,MAAM,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,0BAAW,CAAC,CAAC,MAAM;IACzD,gDAAgD;IAChD,OAAO,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,0BAAW,CAAC,CAAC,MAAM,CAAC,CAAC,CAAM,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,MAAM;IACrG;;;;;;;;;;;;OAYG;IACH,OAAO,EAAE,CAAC,CAAC,EAAE,CACX,CAAC,CAAC,EAAE,MAAM,IAAI,EAAE,CAAC;SACd,MAAM,CAAC,0BAAW,CAAC;SACnB,MAAM,CAAC,CAAC,CAAM,EAAE,EAAE,CAAC,CAAC,IAAA,8BAAe,EAAC,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,MAAM;IAE7E;;;;OAIG;IACH,MAAM,EAAE,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,CAAC,IAAI,CAAC;IAClD;;;OAGG;IACH,WAAW,EAAE,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC;IACtF;;;OAGG;IACH,WAAW,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAM,EAAE,EAAE,CAAC,CAAC,EAAE,YAAY,KAAK,IAAI,CAAC,CAAC,MAAM;CAChG,CAAA;AAED,gDAAgD;AACnC,QAAA,cAAc,GAAG,CAAC,aAAa,EAAE,QAAQ,EAAE,aAAa,CAAU,CAAA;AAClE,QAAA,YAAY,GAAG,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS,CAAU,CAAA;AAEpG;;;;GAIG;AACH,SAAgB,cAAc,CAAC,IAAa;IAC1C,OAAO,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,sBAAc,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,oBAAY,CAAC,CAAA;AACjE,CAAC","sourcesContent":["/*\n * 예측 지표 — **스냅샷에서 수 하나를 뽑는 규칙.**\n *\n * ── 왜 순수 모듈인가 (2026-08-15) ───────────────────────────────────────────\n * 이 표가 리졸버 안에 있어서 시험이 불러올 수 없었다(type-graphql·shell 을 물고 있다). 그런데 여기서\n * 틀리면 예측 그래프 전체가 알리지 않고 다른 것을 그린다 — 오류 없이. 규칙은 시험이 닿는 자리에 둔다.\n *\n * ── 에너지 지표를 더한 이유 ─────────────────────────────────────────────────\n * 예측 화면의 지표가 물류 여섯(재고·점유·작업·오더·출고·백로그)으로 고정돼 있어서, 에너지 트윈을\n * 고르면 **남의 질문에 답하는 그래프**가 떴다. 에너지 트윈에는 재고도 오더도 없으므로 그 값들은\n * 전부 0 이고, 0 은 「없다」가 아니라 「이 트윈의 질문이 아니다」이다.\n *\n * 여기 더한 셋은 **커널이 이미 상태로 들고 있는 것**이다 — 새 계산을 만들지 않았다. 특히 부하 합은\n * 커널이 뿌리 계량기로만 세는데(계층 계량의 이중 계상 방지), 그 규칙을 여기서 다시 쓰면 두 벌이 된다.\n * 그래서 지점을 직접 더하지 않고 **구간 값**을 읽는다.\n */\n\nimport { isFlowOrder, isOrderTerminal } from '@operato/ops-contract'\n\n/** 스냅샷 → 수 하나. 값이 없으면 0 이 아니라 **그 지표를 그릴 수 없다**는 뜻이지만, 그래프 계약이\n * 수를 요구하므로 0 을 낸다 — 그래서 에너지 지표는 「없음」이 0 과 헷갈리지 않는 것만 골랐다. */\nexport type MetricFn = (s: any) => number\n\nexport const FORECAST_METRICS: Record<string, MetricFn> = {\n items: s => (s?.items || []).length,\n occupancy: s => (s?.locations || []).reduce((a: number, n: any) => a + (n.occupancy || 0), 0),\n tasks: s => (s?.tasks || []).length,\n /*\n * ── 세는 것은 **흐름 오더**다 (2026-09-19, ADR-0076 보탬 ⑥) ────────────────\n *\n * 받는 오더(입고 · 반품 입고)는 도착이 세우고 도착이 이행한다. 그대로 세면 「나간 것」이 받을 때마다\n * 한 건씩 늘고 백로그는 영영 안 줄어든다 — 예측 그래프가 있지도 않은 일을 그린다. 무엇이 흐름\n * 오더인지는 계약의 `isFlowOrder` 한 곳이 정한다(여기서 `kind` 를 다시 적지 않는다).\n */\n orders: s => (s?.orders || []).filter(isFlowOrder).length,\n // 이행 지표 — 라이브 예측의 핵심 질문(\"언제 다 나가나·백로그 언제 풀리나\").\n shipped: s => (s?.orders || []).filter(isFlowOrder).filter((o: any) => o.status === 'shipped').length,\n /*\n * ── 백로그는 **「종결인가」**로 묻는다 (2026-09-19) ─────────────────────────\n *\n * 낱말 둘(`shipped`·`cancelled`)로 물으면 **원본이 다른 낱말을 쓰는 미러에서 끝난 오더가 영영\n * 백로그에 남는다.** `status` 는 열린 축이고(표준도 `RequestState` 를 열거하지 않는다) 실 시스템은\n * `completed`·`FINISHED` 를 쓴다 — 그 트윈의 백로그 곡선은 내려가지 않는다. 종결 판정은 커널이\n * 한 곳에서 한다(`isOrderTerminal` — 낱말이 아니라 **양**을 1차 근거로 본다).\n *\n * `shipped` 한 낱말은 남는다. 그것은 이 커널이 **자기가 쓰는 말**이고(WMS 의 출하 완료), 계약의\n * 종결 목록(`completed` · `cancelled`)에는 없다. 양을 말하지 않는 스냅샷에서 그 오더가 백로그로\n * 되돌아오지 않게 둘을 함께 본다 — 빼는 쪽이라 이 셈이 전보다 커지는 일은 없다. 커널의 낱말이\n * 계약의 종결 목록에 들어오는 날 이 줄이 지워진다(아키텍트에게 올림).\n */\n backlog: s =>\n (s?.orders || [])\n .filter(isFlowOrder)\n .filter((o: any) => !isOrderTerminal(o) && o.status !== 'shipped').length,\n\n /*\n * ── 에너지 ────────────────────────────────────────────────────────────────\n * `peakKW` 는 **마감된 구간의 최대**다 — 단조 증가라 지평선 위에서 「최대수요가 어디까지 오르나」로\n * 읽힌다. 열린 구간은 넣지 않는다(아직 확정이 아니다 — 커널이 지키는 규율을 여기서도 지킨다).\n */\n peakKW: s => Number(s?.energy?.peakSince?.kW) || 0,\n /*\n * 이번 구간이 이대로 가면 얼마로 마감되나 — 커널이 낸 투영(`mean-so-far`)을 그대로 읽는다.\n * 우리가 다시 계산하지 않는다: 투영 규칙이 두 벌이 되면 화면과 판정이 다른 말을 한다.\n */\n projectedKW: s => Number(s?.energy?.open?.projectedKW ?? s?.energy?.open?.meanKW) || 0,\n /*\n * 계약을 넘긴 구간 수 — 「몇 번 넘나」는 요금이 걸린 질문이라 최대치와 따로 본다.\n * 계약을 모르는 트윈에서는 늘 0 이다(넘김을 판정할 근거가 없다 — 지어내지 않는다).\n */\n overWindows: s => (s?.energy?.closed || []).filter((w: any) => w?.overContract === true).length\n}\n\n/** 이 지표가 그 종류의 트윈에서 뜻이 있나 — 화면이 목록을 고를 때 쓴다. */\nexport const ENERGY_METRICS = ['projectedKW', 'peakKW', 'overWindows'] as const\nexport const FLOW_METRICS = ['items', 'occupancy', 'tasks', 'orders', 'shipped', 'backlog'] as const\n\n/**\n * 트윈 종류에 맞는 지표 목록 — **한 곳에서 정한다.**\n *\n * 화면이 각자 목록을 들고 있으면 종류가 늘 때 어느 화면이 뒤처졌는지 알 수 없다.\n */\nexport function metricsForKind(kind?: string): string[] {\n return kind === 'ems' ? [...ENERGY_METRICS] : [...FLOW_METRICS]\n}\n"]}
1
+ {"version":3,"file":"forecast-metrics.js","sourceRoot":"","sources":["../../../server/service/twin-forecast/forecast-metrics.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 예측 지표 — **스냅샷에서 수 하나를 뽑는 규칙.**\n *\n * ── 왜 순수 모듈인가 (2026-08-15) ───────────────────────────────────────────\n * 이 표가 리졸버 안에 있어서 시험이 불러올 수 없었다(type-graphql·shell 을 물고 있다). 그런데 여기서\n * 틀리면 예측 그래프 전체가 알리지 않고 다른 것을 그린다 — 오류 없이. 규칙은 시험이 닿는 자리에 둔다.\n *\n * ── 에너지 지표를 더한 이유 ─────────────────────────────────────────────────\n * 예측 화면의 지표가 물류 여섯(재고·점유·작업·오더·출고·백로그)으로 고정돼 있어서, 에너지 트윈을\n * 고르면 **남의 질문에 답하는 그래프**가 떴다. 에너지 트윈에는 재고도 오더도 없으므로 그 값들은\n * 전부 0 이고, 0 은 「없다」가 아니라 「이 트윈의 질문이 아니다」이다.\n *\n * 여기 더한 셋은 **커널이 이미 상태로 들고 있는 것**이다 — 새 계산을 만들지 않았다. 특히 부하 합은\n * 커널이 뿌리 계량기로만 세는데(계층 계량의 이중 계상 방지), 그 규칙을 여기서 다시 쓰면 두 벌이 된다.\n * 그래서 지점을 직접 더하지 않고 **구간 값**을 읽는다.\n */\n\nimport { isFlowOrder, isOrderTerminal } from '@operato/ops-contract'\n\n/*\n * ── Folded orders are counted too (ADR-0092 decision 3, ruling 2026-09-24) ─────────────\n *\n * A twin folds finished orders out of its hot list at checkpoint time and keeps only their counts\n * (`foldedOrders.counts`, kind × terminal status, never trimmed). Counting the hot list alone would drop\n * `orders` and `shipped` the moment a fold happens — and \"the increase since the fork\" alone would still\n * break `twinForecastGapTrend`, whose actual at T+h is read from recovered state: a fold between T and\n * T+h makes the actual increase negative. So both metrics are cumulative including the folded side, and\n * an increase is the difference of the same number. Which folded kinds are flow orders is asked of the\n * contract's `isFlowOrder`, the same as the hot list; `shipped` uses the same word on both sides.\n */\nfunction foldedFlowOrders(s: any, status?: string): number {\n let n = 0\n for (const [kind, byStatus] of Object.entries((s?.foldedOrders?.counts ?? {}) as Record<string, Record<string, number>>)) {\n if (!isFlowOrder({ kind })) continue\n if (status) n += Number(byStatus?.[status]) || 0\n else for (const c of Object.values(byStatus ?? {})) n += Number(c) || 0\n }\n return n\n}\n\n/** 스냅샷 → 수 하나. 값이 없으면 0 이 아니라 **그 지표를 그릴 수 없다**는 뜻이지만, 그래프 계약이\n * 수를 요구하므로 0 을 낸다 — 그래서 에너지 지표는 「없음」이 0 과 헷갈리지 않는 것만 골랐다. */\nexport type MetricFn = (s: any) => number\n\nexport const FORECAST_METRICS: Record<string, MetricFn> = {\n items: s => (s?.items || []).length,\n occupancy: s => (s?.locations || []).reduce((a: number, n: any) => a + (n.occupancy || 0), 0),\n tasks: s => (s?.tasks || []).length,\n /*\n * ── 세는 것은 **흐름 오더**다 (2026-09-19, ADR-0076 보탬 ⑥) ────────────────\n *\n * 받는 오더(입고 · 반품 입고)는 도착이 세우고 도착이 이행한다. 그대로 세면 「나간 것」이 받을 때마다\n * 한 건씩 늘고 백로그는 영영 안 줄어든다 — 예측 그래프가 있지도 않은 일을 그린다. 무엇이 흐름\n * 오더인지는 계약의 `isFlowOrder` 한 곳이 정한다(여기서 `kind` 를 다시 적지 않는다).\n */\n orders: s => (s?.orders || []).filter(isFlowOrder).length + foldedFlowOrders(s),\n // 이행 지표 — 라이브 예측의 핵심 질문(\"언제 다 나가나·백로그 언제 풀리나\").\n shipped: s => (s?.orders || []).filter(isFlowOrder).filter((o: any) => o.status === 'shipped').length + foldedFlowOrders(s, 'shipped'),\n /*\n * ── 백로그는 **「종결인가」**로 묻는다 (2026-09-19) ─────────────────────────\n *\n * 낱말 둘(`shipped`·`cancelled`)로 물으면 **원본이 다른 낱말을 쓰는 미러에서 끝난 오더가 영영\n * 백로그에 남는다.** `status` 는 열린 축이고(표준도 `RequestState` 를 열거하지 않는다) 실 시스템은\n * `completed`·`FINISHED` 를 쓴다 — 그 트윈의 백로그 곡선은 내려가지 않는다. 종결 판정은 커널이\n * 한 곳에서 한다(`isOrderTerminal` — 낱말이 아니라 **양**을 1차 근거로 본다).\n *\n * `shipped` 한 낱말은 남는다. 그것은 이 커널이 **자기가 쓰는 말**이고(WMS 의 출하 완료), 계약의\n * 종결 목록(`completed` · `cancelled`)에는 없다. 양을 말하지 않는 스냅샷에서 그 오더가 백로그로\n * 되돌아오지 않게 둘을 함께 본다 — 빼는 쪽이라 이 셈이 전보다 커지는 일은 없다. 커널의 낱말이\n * 계약의 종결 목록에 들어오는 날 이 줄이 지워진다(아키텍트에게 올림).\n */\n backlog: s =>\n (s?.orders || [])\n .filter(isFlowOrder)\n .filter((o: any) => !isOrderTerminal(o) && o.status !== 'shipped').length,\n\n /*\n * ── 에너지 ────────────────────────────────────────────────────────────────\n * `peakKW` 는 **마감된 구간의 최대**다 — 단조 증가라 지평선 위에서 「최대수요가 어디까지 오르나」로\n * 읽힌다. 열린 구간은 넣지 않는다(아직 확정이 아니다 — 커널이 지키는 규율을 여기서도 지킨다).\n */\n peakKW: s => Number(s?.energy?.peakSince?.kW) || 0,\n /*\n * 이번 구간이 이대로 가면 얼마로 마감되나 — 커널이 낸 투영(`mean-so-far`)을 그대로 읽는다.\n * 우리가 다시 계산하지 않는다: 투영 규칙이 두 벌이 되면 화면과 판정이 다른 말을 한다.\n */\n projectedKW: s => Number(s?.energy?.open?.projectedKW ?? s?.energy?.open?.meanKW) || 0,\n /*\n * 계약을 넘긴 구간 수 — 「몇 번 넘나」는 요금이 걸린 질문이라 최대치와 따로 본다.\n * 계약을 모르는 트윈에서는 늘 0 이다(넘김을 판정할 근거가 없다 — 지어내지 않는다).\n */\n overWindows: s => (s?.energy?.closed || []).filter((w: any) => w?.overContract === true).length\n}\n\n/** 이 지표가 그 종류의 트윈에서 뜻이 있나 — 화면이 목록을 고를 때 쓴다. */\nexport const ENERGY_METRICS = ['projectedKW', 'peakKW', 'overWindows'] as const\nexport const FLOW_METRICS = ['items', 'occupancy', 'tasks', 'orders', 'shipped', 'backlog'] as const\n\n/**\n * 트윈 종류에 맞는 지표 목록 — **한 곳에서 정한다.**\n *\n * 화면이 각자 목록을 들고 있으면 종류가 늘 때 어느 화면이 뒤처졌는지 알 수 없다.\n */\nexport function metricsForKind(kind?: string): string[] {\n return kind === 'ems' ? [...ENERGY_METRICS] : [...FLOW_METRICS]\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/headless-twin",
3
- "version": "10.1.41",
3
+ "version": "10.1.45",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh <heartyoh@hatiolab.com>",
@@ -27,13 +27,13 @@
27
27
  "clean:shared": "rm -rf dist-shared tsconfig.shared.tsbuildinfo"
28
28
  },
29
29
  "dependencies": {
30
- "@operato/ops-contract": "^0.9.37",
31
- "@operato/twin-kernel": "^0.11.26",
32
- "@things-factory/auth-base": "^10.1.40",
33
- "@things-factory/cache-service": "^10.1.40",
30
+ "@operato/ops-contract": "^0.9.38",
31
+ "@operato/twin-kernel": "^0.11.27",
32
+ "@things-factory/auth-base": "^10.1.44",
33
+ "@things-factory/cache-service": "^10.1.44",
34
34
  "@things-factory/env": "^10.1.20",
35
- "@things-factory/ingest": "^10.1.40",
36
- "@things-factory/shell": "^10.1.40"
35
+ "@things-factory/ingest": "^10.1.44",
36
+ "@things-factory/shell": "^10.1.44"
37
37
  },
38
- "gitHead": "4f41243edca4c965bc6992a10fb52d75a58fd3a9"
38
+ "gitHead": "d72e1fd1285797fcd7839d97a18e31f5bf076ae3"
39
39
  }
@@ -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). 삭제 연쇄·상태 동기·네비게이션 근거.
@@ -1,3 +1,5 @@
1
+ import { bizStepName } from '@operato/ops-contract'
2
+
1
3
  import { twinWarn } from '../../engine/log.js'
2
4
  /*
3
5
  * 저널 검색 키 추출 — **순수**. 인제스트가 기록할 때 한 번 뽑아 인덱스 가능한 실컬럼으로 승격한다.
@@ -49,11 +51,13 @@ function clip(v: unknown, field: string): string | undefined {
49
51
  return s.slice(0, MAX_KEY)
50
52
  }
51
53
 
52
- /** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */
54
+ /**
55
+ * bizStep 의 짧은 이름 — 자르는 규칙은 계약의 `bizStepName` 하나다(CBV URN 과 사설 이름공간 URL 둘 다).
56
+ * 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거).
57
+ */
53
58
  export function bizStepOf(envelope: any): string | undefined {
54
59
  const d = envelope?.data ?? envelope ?? {}
55
- const tail = String(d.bizStep ?? '').split(':').pop()
56
- return tail || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined
60
+ return bizStepName(d.bizStep) || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined
57
61
  }
58
62
 
59
63
  /**
@@ -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
+ })
@@ -63,6 +63,13 @@ test('bizStep 이 없으면 이벤트 타입에서 유추한다 — 운영 델
63
63
  assert.equal(bizStepOf({ eventType: 'epcis.AggregationEvent', data: {} }), 'AggregationEvent')
64
64
  })
65
65
 
66
+ test('a private business step is indexed by its short name, like a CBV one', () => {
67
+ /* `split(':')` on `https://hatiolab.com/voc/bizstep/consuming` kept `//hatiolab.com/voc/bizstep/consuming` — a journal
68
+ search for `consuming` found nothing. The rule is the contract's bizStepName. */
69
+ assert.equal(bizStepOf({ data: { bizStep: 'https://hatiolab.com/voc/bizstep/consuming' } }), 'consuming')
70
+ assert.equal(bizStepOf({ data: { bizStep: 'urn:epcglobal:cbv:bizstep:shipping' } }), 'shipping')
71
+ })
72
+
66
73
  test('집합 이벤트는 parentID 를, 수량 이벤트는 epcClass 를 품목으로 쓴다', () => {
67
74
  assert.equal(epcOf({ data: { parentID: 'urn:epc:id:sscc:0614141.1234567890' } }), 'urn:epc:id:sscc:0614141.1234567890')
68
75
  assert.equal(epcOf({ data: { quantityList: [{ epcClass: 'urn:epc:idpat:sgtin:0614141.107346.*', quantity: 40 }] } }), 'urn:epc:idpat:sgtin:0614141.107346.*')
@@ -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
+ })