@operato/twin-kernel 0.7.61 → 0.7.63

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.
package/dist/contract.js CHANGED
@@ -944,6 +944,27 @@ export const OP_EVENT = {
944
944
  * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
945
945
  */
946
946
  test: 'test.result',
947
+ /**
948
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
949
+ *
950
+ * ── 왜 필요한가 (2026-08-25 실측) ────────────────────────────────────────
951
+ * 연결된 시스템은 매 주기 현재 재고를 전부 보낸다. 그런데 트윈은 그것을 낱낱의 관측으로 받아
952
+ * **더하기만 했다.** 「그리고 이것 말고는 없다」를 받는 곳이 없어서, 목록에서 빠진 줄은 아무 말도
953
+ * 오지 않은 것이 되고 트윈에 영원히 남았다.
954
+ *
955
+ * 실측: 실제 재고 1,704건인데 트윈이 10,038건을 갖고 있었다.
956
+ *
957
+ * ── 왜 「사라진 것을 알려 주기」가 아니라 이 방식인가 ──────────────────────
958
+ * 연결 쪽이 앞 주기와 비교해 사라진 줄을 찾아 알리는 방법도 있다. 그런데 그것은 **하나 빠뜨리면
959
+ * 그 줄이 영원히 남는다.** 이 방식은 주기마다 스스로 바로잡는다 — 이미 잘못 쌓인 것도 다음 주기에
960
+ * 사라진다.
961
+ *
962
+ * ── 보내는 쪽이 지킬 것 ─────────────────────────────────────────────────
963
+ * **끝까지 읽었을 때만 보낸다.** 읽다가 끊긴 주기에 이것을 보내면 **살아 있는 재고를 지운다.**
964
+ * 그리고 `since` 는 그 주기를 **시작한** 시각이다(끝낸 시각이 아니다) — 주기 도중에 들어온 관측이
965
+ * 지워지지 않아야 한다.
966
+ */
967
+ complete: 'axis.complete',
947
968
  /**
948
969
  * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
949
970
  *
@@ -1040,25 +1061,25 @@ export const CMD = {
1040
1061
  };
1041
1062
  // ── 보드 청사진 바인딩 (최소) ──────────────────────────────────────────────
1042
1063
  /**
1043
- * **저장된 보드를 읽는 단 하나의 입구.**
1064
+ * **저장된 트윈 모델을 읽는 단 하나의 입구.**
1044
1065
  *
1045
- * `equipment` 로 개명하기 전에 저장된 보드는 `movers` 키를 갖고 있다(개명 시점 23개 인스턴스). (vocabulary-guard: allow — 읽기 호환 설명)
1066
+ * `equipment` 로 개명하기 전에 저장된 트윈 모델은 `movers` 키를 갖고 있다(개명 시점 23개 인스턴스). (vocabulary-guard: allow — 읽기 호환 설명)
1046
1067
  * 저장물을 다시 쓰지 않고 **읽을 때 흡수**한다 — 마이그레이션은 되돌리기 어렵고, 읽기 호환은 값싸다.
1047
1068
  *
1048
1069
  * 규율 둘:
1049
1070
  * - 이 함수를 **거치지 않고** `def.equipment` 를 직접 읽는 코드를 두지 않는다. 하나라도 남으면
1050
- * 그 경로에서만 옛 보드의 설비가 조용히 사라진다(빈 배열).
1071
+ * 그 경로에서만 옛 트윈 모델의 설비가 조용히 사라진다(빈 배열).
1051
1072
  * - **쓸 때는 새 이름만** 쓴다. 두 이름으로 쓰기 시작하면 저장물에 두 벌이 영구히 섞인다.
1052
1073
  *
1053
- * 제거 시점: 저장된 보드가 모두 `equipment` 키로 바뀐 것이 확인되면(운영 데이터 점검 후) 이 함수는
1074
+ * 제거 시점: 저장된 트윈 모델이 모두 `equipment` 키로 바뀐 것이 확인되면(운영 데이터 점검 후) 이 함수는
1054
1075
  * 사라진다. 그때까지 남겨 두는 이유를 여기 적어 두는 것이 주석의 일이다.
1055
1076
  */
1056
1077
  /**
1057
- * **저장된 보드의 자리를 읽는 단 하나의 입구.** `readBoardEquipment` 와 같은 규율.
1078
+ * **저장된 트윈 모델의 자리를 읽는 단 하나의 입구.** `readBoardEquipment` 와 같은 규율.
1058
1079
  *
1059
- * `nodes` → `locations` 개명(2026-08-01) 전에 저장된 보드는 `nodes` 키를 갖고 있다(개명 시점 23개). (vocabulary-guard: allow — 읽기 호환 설명)
1080
+ * `nodes` → `locations` 개명(2026-08-01) 전에 저장된 트윈 모델은 `nodes` 키를 갖고 있다(개명 시점 23개). (vocabulary-guard: allow — 읽기 호환 설명)
1060
1081
  * 이 함수를 거치지 않고 `def.locations` 를 직접 읽는 코드를 두지 않는다 — 하나라도 남으면 그 경로에서만
1061
- * 옛 보드의 자리가 조용히 사라진다(빈 배열 = 자리 없는 트윈 = 아무 일도 일어나지 않는다).
1082
+ * 옛 트윈 모델의 자리가 조용히 사라진다(빈 배열 = 자리 없는 트윈 = 아무 일도 일어나지 않는다).
1062
1083
  */
1063
1084
  export function readBoardLocations(def) {
1064
1085
  const d = def; // vocabulary-guard: allow — 옛 키 캐스트
@@ -1066,12 +1087,12 @@ export function readBoardLocations(def) {
1066
1087
  }
1067
1088
  export function readBoardEquipment(def) {
1068
1089
  /* `movers` 는 개명 **이전의 이름**이다(movers → equipmentList → equipment). 저장된 보드에는 세 세대가
1069
- 섞여 있어(실측: 23개 중 13개가 `movers`) 하나라도 빠뜨리면 그 보드는 **설비가 0인 공장**으로 읽힌다 —
1090
+ 섞여 있어(실측: 23개 중 13개가 `movers`) 하나라도 빠뜨리면 그 트윈 모델은 **설비가 0인 공장**으로 읽힌다 —
1070
1091
  오류 없이. 실제로 그랬다: 화면의 설비 수가 0이고, 용량 판정에 자원이 없고, 카탈로그 통합 테스트가
1071
1092
  "완제품 0" 으로 떨어졌다. 셋 다 원인이 이 한 줄이었다. */
1072
1093
  const d = def; // vocabulary-guard: allow — 옛 키를 읽어야 하는 자리
1073
1094
  const list = d.equipment ?? d.equipmentList ?? d.movers ?? []; // vocabulary-guard: allow — 옛 키 흡수
1074
- /* 소속 자리 키도 함께 정규화한다 — `homeNode → homeLocation` 개명 전 보드가 23개 있다. (vocabulary-guard: allow — 옛 키 정규화 설명)
1095
+ /* 소속 자리 키도 함께 정규화한다 — `homeNode → homeLocation` 개명 전 트윈 모델이 23개 있다. (vocabulary-guard: allow — 옛 키 정규화 설명)
1075
1096
  배열만 흡수하고 안쪽 키를 놓치면 설비는 나타나지만 **소속이 전부 비어** 롤업이 통째로 사라진다. */
1076
1097
  return list.map(e => normalizeHomeLocation(e));
1077
1098
  }
@@ -1083,7 +1104,7 @@ function normalizeHomeLocation(entry) {
1083
1104
  const { homeNode: _drop, ...rest } = entry; // vocabulary-guard: allow — 옛 키 제거
1084
1105
  return { ...rest, homeLocation: legacy };
1085
1106
  }
1086
- /** 저장된 보드의 반복사용 자산 — 설비와 같은 정규화를 거친다. */
1107
+ /** 저장된 트윈 모델의 반복사용 자산 — 설비와 같은 정규화를 거친다. */
1087
1108
  export function readBoardAssets(def) {
1088
1109
  const list = (def.assets ?? []);
1089
1110
  return list.map(a => normalizeHomeLocation(a));
@@ -86,7 +86,7 @@ export interface TwinAxisInfo {
86
86
  * 현장이 낳는 사실이고, 그 집은 커널 상태와 저널이다(ISA-95 Part 4).
87
87
  *
88
88
  * 이 칸이 없으면 축만 늘려도 소비처가 board 에서 찾다가 **언제나 0 을 답한다** — 오류 없이,
89
- * 그냥 빈 공장처럼. 이 프로젝트가 이미 그 모양으로 한 번 무너졌다(옛 어휘 보드가 자리 0·설비 0).
89
+ * 그냥 빈 공장처럼. 이 프로젝트가 이미 그 모양으로 한 번 무너졌다(옛 어휘 트윈 모델이 자리 0·설비 0).
90
90
  *
91
91
  * · `document` — 저장된 트윈 모델 안. `path` 가 그 자리를 말한다
92
92
  * · `state` — 커널 상태. **지금의 사실**이고 커널이 돌 때만 있다(멈추면 `null`, 0 이 아니다)
@@ -41,7 +41,7 @@ export declare function replayWithCheckpoint(model: TwinModelDef, events: readon
41
41
  /**
42
42
  * 한 구조 아래에서 일어난 이벤트들 — 재생의 한 마디.
43
43
  *
44
- * 필드 이름이 `model` 인 이유: 이 값의 정체는 **트윈 모델**이다(`TwinModelDef`). 보드는 그것을 그리는
44
+ * 필드 이름이 `model` 인 이유: 이 값의 정체는 **트윈 모델**이다(`TwinModelDef`). 트윈 모델은 그것을 그리는
45
45
  * 표현 중 하나일 뿐이고, 소비처는 이미 전부 `model` 로 옮겼다. 그동안 호스트가 넘길 때마다 `model` 을
46
46
  * `board` 키로 되돌려 담는 **어휘 번역기**가 경계에 끼어 있었다 — 조용히 `model` 로 넘기면 커널이
47
47
  * `undefined` 를 읽고 재생이 통째로 죽던 자리다. 이름을 맞추면 그 브릿지가 사라진다.
@@ -635,7 +635,7 @@ export declare abstract class FlowEngine implements TwinKernel {
635
635
  private materialSpecCrossKeyOverlaps;
636
636
  private observedDirty;
637
637
  private observeMode;
638
- /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
638
+ /** 관측 구동이 투영기를 세울 때 필요한 원본 트윈 모델(구조는 이벤트가 아니라 마스터에서 온다). */
639
639
  protected boardDef?: TwinModelDef;
640
640
  /** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
641
641
  private specUse;
@@ -1598,7 +1598,7 @@ export declare abstract class FlowEngine implements TwinKernel {
1598
1598
  /**
1599
1599
  * 시뮬 시각의 분(0..1439) — 캘린더 판정의 기준.
1600
1600
  *
1601
- * **보드가 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
1601
+ * **트윈 모델이 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
1602
1602
  * 06시 교대가 7시간 틀렸다 — 시각대만 적고 기준을 안 적으면 반드시 이렇게 된다.
1603
1603
  */
1604
1604
  protected minuteOfDay(): number;
@@ -705,7 +705,7 @@ export class FlowEngine {
705
705
  materialSpecCrossKeyOverlaps = 0;
706
706
  observedDirty = false;
707
707
  observeMode = false;
708
- /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
708
+ /** 관측 구동이 투영기를 세울 때 필요한 원본 트윈 모델(구조는 이벤트가 아니라 마스터에서 온다). */
709
709
  boardDef;
710
710
  /** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
711
711
  specUse = new Map();
@@ -1035,7 +1035,7 @@ export class FlowEngine {
1035
1035
  /* 계획 정지를 이어받는다 — 잃으면 씨앗이 **정비 중인 설비를 가용으로 놓고** 미래를 시뮬레이션한다
1036
1036
  (예측이 낙관 쪽으로 치우친다). 씨앗 왕복 대조가 이것을 잡았다. */
1037
1037
  ...(m.held ? { held: true } : {}),
1038
- /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 보드에서 읽어 실어 온다). */
1038
+ /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 트윈 모델에서 읽어 실어 온다). */
1039
1039
  ...(m.properties ? { properties: m.properties } : {}),
1040
1040
  ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}),
1041
1041
  /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
@@ -1561,7 +1561,7 @@ export class FlowEngine {
1561
1561
  ...(this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {})
1562
1562
  } }
1563
1563
  : {}),
1564
- /* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
1564
+ /* 출처 표시 — 트윈 모델(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
1565
1565
  * 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
1566
1566
  locations: [...this.locations.values()].map(n => {
1567
1567
  const { status, ...rest } = n;
@@ -3532,7 +3532,7 @@ export class FlowEngine {
3532
3532
  /**
3533
3533
  * 시뮬 시각의 분(0..1439) — 캘린더 판정의 기준.
3534
3534
  *
3535
- * **보드가 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
3535
+ * **트윈 모델이 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
3536
3536
  * 06시 교대가 7시간 틀렸다 — 시각대만 적고 기준을 안 적으면 반드시 이렇게 된다.
3537
3537
  */
3538
3538
  minuteOfDay() {
@@ -18,6 +18,14 @@ interface ProjItem {
18
18
  expiry?: number;
19
19
  /** 이 로트의 시험 결과 — **명세당 최신 하나**(상태가 계측 주기로 자라지 않게). */
20
20
  testResults?: TestResult[];
21
+ /**
22
+ * **마지막으로 관측된 시각**(ms) — 「이 목록이 전부다」를 받았을 때 무엇을 지울지 가리는 값.
23
+ *
24
+ * 물품 안에 두는 이유: 순서 판정에 쓰는 시각(`lastAt`)은 **중간 저장본에 들어가지 않는다.**
25
+ * 그것에 기대면 재기동 직후 첫 신호가 재고를 전부 지운다(본 적이 없는 것으로 보이므로).
26
+ * 물품은 중간 저장본에 들어가므로 이 값도 함께 살아남는다.
27
+ */
28
+ seenAtMs?: number;
21
29
  }
22
30
  /**
23
31
  * 마스터 동기 — 선언적 로케이션 upsert/remove.
@@ -179,7 +187,7 @@ export declare class ObservedReducer {
179
187
  private corrections;
180
188
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
181
189
  private unhandled;
182
- /** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
190
+ /** 선언된 판정 기준(트윈 모델에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
183
191
  private testSpecs;
184
192
  /**
185
193
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
@@ -195,7 +203,7 @@ export declare class ObservedReducer {
195
203
  * 통과시키는 어긋남이 생기고, 그 어긋남은 조용하다.
196
204
  */
197
205
  private classDefs;
198
- /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
206
+ /** 시각 해석 기준(트윈 모델 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
199
207
  private utcOffsetMinutes?;
200
208
  constructor(model: TwinModelDef);
201
209
  /**
@@ -228,7 +236,7 @@ export declare class ObservedReducer {
228
236
  * 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
229
237
  *
230
238
  * 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
231
- * (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
239
+ * (트윈 모델에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
232
240
  */
233
241
  private touchLocation;
234
242
  /**
@@ -68,7 +68,7 @@ export class ObservedReducer {
68
68
  corrections = [];
69
69
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
70
70
  unhandled = new Map();
71
- /** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
71
+ /** 선언된 판정 기준(트윈 모델에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
72
72
  testSpecs = new Map();
73
73
  /**
74
74
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
@@ -84,7 +84,7 @@ export class ObservedReducer {
84
84
  * 통과시키는 어긋남이 생기고, 그 어긋남은 조용하다.
85
85
  */
86
86
  classDefs = {};
87
- /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
87
+ /** 시각 해석 기준(트윈 모델 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
88
88
  utcOffsetMinutes;
89
89
  constructor(model) {
90
90
  this.utcOffsetMinutes = model.utcOffsetMinutes;
@@ -209,7 +209,7 @@ export class ObservedReducer {
209
209
  * 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
210
210
  *
211
211
  * 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
212
- * (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
212
+ * (트윈 모델에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
213
213
  */
214
214
  touchLocation(id) {
215
215
  if (!id || this.master.has(id))
@@ -271,6 +271,32 @@ export class ObservedReducer {
271
271
  * 예측을 이어 굴릴 수 있고, 무자원이 설계인지(체류) 구별할 수 있다. */
272
272
  this.touchLocation(d.fromNode);
273
273
  this.touchLocation(d.toNode);
274
+ /*
275
+ * ── ★ **일어난 일은 다시 말하지 않아도 지우지 않는다** (2026-08-24) ────────
276
+ *
277
+ * 이 자리는 매번 **새 객체를 짓는다.** 그래서 뒤에 온 전이가 앞의 필드를 조용히 지웠다 — 물품은
278
+ * 오래전부터 병합하는데(`mergeItem`: 「아는 것을 잃지 않는다」) **작업만 그 규율 밖에 있었다.**
279
+ *
280
+ * 실측(포천): 작업 하나가 전이 여러 건으로 온다((로트,공정) 76짝 중 52짝이 2건 이상). 완료
281
+ * 전이에 투입 실적을 실으면 **그 뒤 어떤 전이도 그것을 지웠다.**
282
+ *
283
+ * ── 그런데 **통째 병합은 반대 방향으로 틀린다** ────────────────────────────
284
+ * 어떤 필드는 **일부러 사라진다**: `remainingMs`·`progress`·`startedAtSimMs` 는 진행 중일 때만
285
+ * 실리고(§`emitTask`), 자원 참조는 놓으면 빠진다. 그것을 지키면 끝난 작업이 남은 시간을 들고
286
+ * 놓은 자원을 계속 쥔 것으로 보인다.
287
+ *
288
+ * ── 그래서 선을 이렇게 긋는다 ──────────────────────────────────────────
289
+ * **과거의 사실**은 다시 말하지 않아도 지우지 않는다 — 일어난 일은 안 일어난 일이 될 수 없다
290
+ * **지금의 값**은 말하지 않으면 없는 것이다 — 그것이 「지금」의 뜻이다
291
+ *
292
+ * 지금 이 규율이 걸리는 것은 `materialActual`(무엇을 얼마나 썼나) 하나다 — 상태가 드는 것 중
293
+ * 「일어난 일」이 그것뿐이다(`outcome` 은 상태 계약에 아직 없다). 명시로 지우려면 원천이
294
+ * **빈 배열**을 보낸다(`materialActual: []`) — 그때는 말한 것이다.
295
+ *
296
+ * 늘릴 때는 「이 필드가 사라지는 것이 사실일 수 있나」를 먼저 물어야 한다. 그렇다면 넣지 않는다.
297
+ */
298
+ const prevTask = this.tasks.get(d.taskId);
299
+ const keptActual = d.materialActual === undefined ? prevTask?.materialActual : undefined;
274
300
  this.tasks.set(d.taskId, {
275
301
  id: d.taskId, kind: d.kind, status: d.status, fromNode: d.fromNode, toNode: d.toNode,
276
302
  itemRefs: d.itemRefs, resourceRef: d.resourceRef, orderId: d.orderId,
@@ -279,8 +305,13 @@ export class ObservedReducer {
279
305
  ...(d.personnel?.length ? { personnel: d.personnel.slice() } : {}),
280
306
  ...(d.assets?.length ? { assets: d.assets.slice() } : {}),
281
307
  ...(d.resources?.length ? { resources: d.resources.slice() } : {}),
282
- /* 실제 자재 이동 — 인원·설비와 같은 채널로 온다(실적을 한 곳에서 읽는다). */
283
- ...(d.materialActual?.length ? { materialActual: d.materialActual.map(r => ({ ...r })) } : {}),
308
+ /* 실제 자재 이동 — 인원·설비와 같은 채널로 온다(실적을 한 곳에서 읽는다).
309
+ 말하지 않았으면 **앞의 것을 지킨다**(위 주석) — 빈 배열은 「지웠다」로 말한 것이다. */
310
+ ...(d.materialActual?.length
311
+ ? { materialActual: d.materialActual.map(r => ({ ...r })) }
312
+ : keptActual?.length
313
+ ? { materialActual: keptActual.map(r => ({ ...r })) }
314
+ : {}),
284
315
  ...(d.priority !== undefined ? { priority: d.priority } : {}),
285
316
  ...(d.startTime ? { startTime: d.startTime } : {}),
286
317
  ...(d.endTime ? { endTime: d.endTime } : {})
@@ -404,6 +435,44 @@ export class ObservedReducer {
404
435
  item.testResults = [...kept, next];
405
436
  break;
406
437
  }
438
+ case OP_EVENT.complete: {
439
+ /*
440
+ * ── ★ **이 목록이 전부다** (2026-08-25) ────────────────────────────────
441
+ *
442
+ * 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다. 이 주기에 오지 않은 것은
443
+ * **그 시스템에 더 이상 없는 것**이므로 지운다.
444
+ *
445
+ * 이것이 없던 동안 트윈은 받은 것을 **더하기만 했다** — 목록에서 빠진 줄은 아무 말도 오지 않은
446
+ * 것이 되어 영원히 남았다(실측: 실제 1,704건인데 트윈이 10,038건).
447
+ *
448
+ * ── 아무것도 못 들은 주기에는 지우지 않는다 ────────────────────────────
449
+ * 그 주기에 관측이 **한 건도** 없었다면 그것은 「다 없어졌다」가 아니라 **우리가 못 들은 것**이다.
450
+ * 그때 지우면 살아 있는 재고가 사라진다 — 이 저장소가 여러 번 거절한 모양이다(결측 ≠ 없음).
451
+ *
452
+ * 보내는 쪽의 규율은 계약에 적었다: **끝까지 읽었을 때만 보낸다.** 읽다가 끊긴 주기에 보내면
453
+ * 살아 있는 재고를 지운다. 그 판단은 보내는 쪽만 할 수 있다.
454
+ *
455
+ * 지운 수를 `unhandled` 로 세지 않는다 — 그것은 반영하지 못한 사건을 세는 자리다. 여기서 지운
456
+ * 것은 **정상적으로 반영한 결과**다.
457
+ */
458
+ const d = e.data;
459
+ if (d?.completeAxis !== 'items')
460
+ break;
461
+ const since = Date.parse(String(d.since ?? ''));
462
+ if (!Number.isFinite(since))
463
+ break;
464
+ let heard = 0;
465
+ for (const it of this.items.values())
466
+ if ((it.seenAtMs ?? -1) >= since)
467
+ heard++;
468
+ if (!heard)
469
+ break;
470
+ for (const [key, it] of [...this.items.entries()]) {
471
+ if ((it.seenAtMs ?? -1) < since)
472
+ this.remove(key);
473
+ }
474
+ break;
475
+ }
407
476
  case OP_EVENT.attentionAck: {
408
477
  /* 확인한 사실만 담는다 — 그 신호가 지금도 성립하는지는 상태가 답한다(여기서 판단하지 않는다). */
409
478
  const d = e.data;
@@ -601,6 +670,45 @@ export class ObservedReducer {
601
670
  if (ev.action === 'DELETE') {
602
671
  for (const epc of ev.epcList)
603
672
  this.remove(epc);
673
+ /*
674
+ * ── ★ **수량으로 온 물품도 없어질 수 있어야 한다** (2026-08-25 실측) ────────
675
+ *
676
+ * 여기는 `epcList` 만 봤다. 그래서 **낱개 번호가 없는 현장에서는 재고를 지울 방법이 하나도
677
+ * 없었다** — 승화푸드는 모든 재고가 수량으로 오는 현장이다.
678
+ *
679
+ * 결과: 한 번 본 재고가 영원히 남았다. 연결된 시스템의 목록에서 그 줄이 없어져도 우리는
680
+ * 「없어졌다」를 들을 길이 없었다(있는 것만 오고, 없어진 것은 아무 말도 오지 않는다).
681
+ * 실측으로 실제 재고 1,704건인데 트윈이 10,038건을 갖고 있었다.
682
+ *
683
+ * ── 무엇을 지우는지는 말한 범위대로 ────────────────────────────────────
684
+ * 자리를 함께 말했다 그 자리의 그 로트만 지운다 (꺼내 가서 그 칸이 빈 경우)
685
+ * 자리를 말하지 않았다 그 로트를 모든 자리에서 지운다 (그 로트가 통째로 없어진 경우)
686
+ *
687
+ * 짐작이 아니라 **진술의 범위를 읽는 것**이다. 자리를 말했으면 그 자리를 말한 것이고,
688
+ * 말하지 않았으면 자리를 가리지 않고 말한 것이다.
689
+ *
690
+ * 순서 판정을 함께 지난다: 늦게 온 옛 「없어졌다」가 그 뒤에 다시 들어온 재고를 지우면 안 된다.
691
+ * 열쇠는 관측과 같은 것을 쓴다 — 같은 대상의 두 방향이므로 한 열쇠로 재야 한다.
692
+ */
693
+ const goneAt = ev.readPoint?.id;
694
+ for (const qe of ev.quantityList ?? []) {
695
+ if (!qe?.epcClass)
696
+ continue;
697
+ if (goneAt) {
698
+ const key = `${qe.epcClass}@${goneAt}`;
699
+ if (envelope && this.stale(`item:${key}`, envelope))
700
+ continue;
701
+ this.remove(key);
702
+ continue;
703
+ }
704
+ for (const key of [...this.items.keys()]) {
705
+ if (!key.startsWith(`${qe.epcClass}@`))
706
+ continue;
707
+ if (envelope && this.stale(`item:${key}`, envelope))
708
+ continue;
709
+ this.remove(key);
710
+ }
711
+ }
604
712
  return;
605
713
  }
606
714
  const loc = ev.readPoint?.id;
@@ -615,7 +723,9 @@ export class ObservedReducer {
615
723
  /* 물품별 순서 판정 — 늦게 온 옛 관측이 최신 위치를 덮지 않게. */
616
724
  if (envelope && this.stale(`item:${epc}`, envelope))
617
725
  continue;
618
- this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q, all));
726
+ const seen = this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q, all);
727
+ const atSeen = Date.parse(String(envelope?.eventTime ?? ''));
728
+ this.items.set(itemKeyOf(seen), Number.isFinite(atSeen) ? { ...seen, seenAtMs: atSeen } : seen);
619
729
  }
620
730
  /* 개체 없이 수량만 오는 입고(비직렬 자재) — 표준이 허용하고 검증기도 유효로 판정한다.
621
731
  * 이 경우 클래스 식별자 자체가 물품의 키다(로트 관리 자재는 LGTIN 이라 로트별로 갈린다). */
@@ -652,7 +762,9 @@ export class ObservedReducer {
652
762
  */
653
763
  if (envelope && this.stale(`item:${subLotId}`, envelope))
654
764
  continue;
655
- this.items.set(subLotId, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe, mine, subLotId));
765
+ const seenQty = this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe, mine, subLotId);
766
+ const atQty = Date.parse(String(envelope?.eventTime ?? ''));
767
+ this.items.set(subLotId, Number.isFinite(atQty) ? { ...seenQty, seenAtMs: atQty } : seenQty);
656
768
  }
657
769
  }
658
770
  }
@@ -905,8 +1017,16 @@ export class ObservedReducer {
905
1017
  origin: n.origin
906
1018
  };
907
1019
  }),
908
- /* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
909
- items: [...this.items.values()].map(i => ({ ...i })),
1020
+ /*
1021
+ * 갖고 있는 것을 전부 내보낸다 — 줄이면 그 자리에서 정보가 사라진다.
1022
+ *
1023
+ * **`seenAtMs` 는 뺀다.** 그것은 물품에 대한 사실이 아니라 **우리가 언제 들었나**이고, 「이 목록이
1024
+ * 전부다」를 받았을 때 무엇을 지울지 가리는 데만 쓴다. 화면이 알 필요가 없고, 상태에 내보내면
1025
+ * 자체 구동에는 없는 값이라 두 구동이 어긋난다(적합성 검사가 그것을 잡았다).
1026
+ *
1027
+ * 중간 저장본에는 남는다 — 재기동 뒤에도 무엇을 지울지 가릴 수 있어야 한다.
1028
+ */
1029
+ items: [...this.items.values()].map(({ seenAtMs: _seen, ...i }) => ({ ...i })),
910
1030
  /* 유효 기간 판정은 **저장하지 않고 여기서 낸다** — 시뮬과 **같은 함수**(`effectivityAt`)를 부른다.
911
1031
  만료는 이벤트 없이 시각만으로 일어나므로, 델타로 받아 두면 유휴 자원이 영원히 유효하게 남는다. */
912
1032
  persons: [...this.persons.values()].map(p => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`), ...this.capabilityPart(p, `person:${p.id}`, p.personnelClassIds, this.classDefs.personnel) })),
@@ -1,6 +1,6 @@
1
1
  import type { IngestResult } from './face2-adapter.ts';
2
2
  /** 이 문이 받는 여섯 가지 — 리듀서가 접는 것과 같은 목록(주목 확인은 우리 안의 행위라 제외). */
3
- export type OperationalKind = 'task' | 'equipment' | 'person' | 'asset' | 'order' | 'quality' | 'test';
3
+ export type OperationalKind = 'task' | 'equipment' | 'person' | 'asset' | 'order' | 'quality' | 'test' | 'observation' | 'complete';
4
4
  /**
5
5
  * 정규 운영 레코드 — **델타의 필드 이름 + 시각(`at`)**.
6
6
  *
@@ -140,6 +140,40 @@ const SPECS = {
140
140
  derived: 'boolean', propertyMeasurements: 'object[]', recordTime: 'string'
141
141
  },
142
142
  enums: { result: ['pass', 'fail'] }
143
+ },
144
+ /*
145
+ * **자리의 물리 관측** — 어느 자리의 어느 속성을 언제 얼마로 쟀나.
146
+ *
147
+ * 값에 **단위를 함께** 받는다(`ValueType`). 단위 없는 물리량은 판정의 재료가 못 된다 — 3 이 섭씨인지
148
+ * 화씨인지 모르면 어떤 기준으로도 판정할 수 없다. 다만 **요구하지는 않는다**: 단위를 비우는 실 원본이
149
+ * 흔하고, 요구하면 그 원본의 관측을 아예 담지 못한다(그때 판정은 커널이 거부한다 — `outsideLimit`).
150
+ *
151
+ * `effectiveTime` 도 요구하지 않는다. 없으면 봉투의 시각이 그 자리를 대신한다(§`resolve`) — 원본이
152
+ * 시각을 말하지 않는 수기 점검이 실재하고, 그때 지어낸 시각보다 폴링 시각이 정직하다.
153
+ */
154
+ /*
155
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 알린다.
156
+ *
157
+ * 둘 다 요구한다: 어느 목록인지와 그 주기를 **시작한** 시각. 시각이 없으면 무엇을 지울지 가릴 수
158
+ * 없고, 그때 지우면 전부 지운다 — 받지 않는 것이 옳다.
159
+ */
160
+ complete: {
161
+ eventType: OP_EVENT.complete,
162
+ identity: 'completeAxis',
163
+ required: ['completeAxis', 'since'],
164
+ fields: { completeAxis: 'string', since: 'string', recordTime: 'string' }
165
+ },
166
+ observation: {
167
+ eventType: OP_EVENT.observation,
168
+ identity: 'locationId',
169
+ /* 자리와 속성 — 둘 중 하나가 없으면 그 관측은 아무 데도 붙지 못한다. */
170
+ required: ['locationId', 'propertyId'],
171
+ fields: {
172
+ locationId: 'string', propertyId: 'string',
173
+ value: 'string', dataType: 'string', uom: 'string',
174
+ effectiveTime: 'string', effectiveEndTime: 'string', recordTime: 'string',
175
+ source: 'string', derived: 'boolean'
176
+ }
143
177
  }
144
178
  };
145
179
  /**
@@ -187,6 +221,23 @@ export function operationalKindOf(record) {
187
221
  */
188
222
  if (has('testableObjectId'))
189
223
  return 'test';
224
+ /*
225
+ * ── ★ **채널을 열고 또 길을 내지 않았다** (2026-08-24) ──────────────────────
226
+ * `OP_EVENT.observation`(`location.measured`)을 내고 상태(`LocationState.observations`)와 조회
227
+ * (`observationAt`)까지 붙였는데 **이 라우팅이 `locationId` 를 보지 않았다.** 커넥터가 방의 온습도를
228
+ * 실어 보내면 「어느 운영 사실인지 모른다」로 거부됐다.
229
+ *
230
+ * 같은 부류를 하루에 일곱 번 만났다. 다만 이번엔 **거부되고 이유가 남았다** — 시험 결과 때는 어느
231
+ * 통도 아니어서 조용히 사라졌다. 그 차이가 이것을 5분 만에 찾게 했다(§`isOperationalRecord`).
232
+ *
233
+ * **둘을 함께 요구한다.** `locationId` 만으로는 자리를 말하는 다른 사실과 섞인다. 관측은 「어느
234
+ * 자리의 **무엇**을 쟀나」이므로 속성 없이는 담을 곳이 없다 — 그때는 받지 않는 것이 옳다.
235
+ */
236
+ if (has('locationId') && has('propertyId'))
237
+ return 'observation';
238
+ /* 「이 목록이 전부다」 — 어느 목록인지를 스스로 말하므로 다른 사실과 섞이지 않는다. */
239
+ if (has('completeAxis'))
240
+ return 'complete';
190
241
  return undefined;
191
242
  }
192
243
  /** 이 레코드가 운영 사실인가 — 호스트의 라우팅이 묻는 자리. */
@@ -34,13 +34,13 @@ export const VOCABULARY_EXCEPTIONS = [
34
34
  { token: 'moverId', why: 'journal wire field — renaming would mix two keys for one fact across history' },
35
35
  { token: 'fromNode', why: 'journal wire field (task.status payload)' },
36
36
  { token: 'toNode', why: 'journal wire field (task.status payload)' },
37
- /* ── 보드의 옛 세대 키 — 저장된 데이터라 읽어는 줘야 한다 ────────────────
37
+ /* ── 트윈 모델의 옛 세대 키 — 저장된 데이터라 읽어는 줘야 한다 ────────────────
38
38
  * 설비 배열의 이름은 세 세대를 거쳤다(movers → equipmentList → equipment). 저장된 보드에는
39
- * 셋이 섞여 있고(실측 23개 중 13개가 `movers`), 하나라도 안 읽으면 그 보드는 **설비가 0인 공장**
39
+ * 셋이 섞여 있고(실측 23개 중 13개가 `movers`), 하나라도 안 읽으면 그 트윈 모델은 **설비가 0인 공장**
40
40
  * 으로 조용히 읽힌다 — 화면의 설비 수가 0이 되고 용량 판정에서 자원이 사라진다. 쓰는 곳은
41
41
  * `readBoardEquipment` 한 곳뿐이고, 거기서 새 이름으로 정규화해 내보낸다. */
42
42
  { token: 'movers', why: 'legacy board key (movers → equipmentList → equipment); read-only normalization in readBoardEquipment, 13 stored boards still use it' },
43
- /* ── 씬 컴포넌트 타입 — 보드에 저장된 값이고, 뜻이 어긋나지도 않는다 ──────
43
+ /* ── 씬 컴포넌트 타입 — 트윈 모델에 저장된 값이고, 뜻이 어긋나지도 않는다 ──────
44
44
  * 보드 7개가 이 타입으로 컴포넌트를 담고 있어 개명하면 그 컴포넌트가 조용히 안 그려진다.
45
45
  * 그리고 씬에서 이 이름은 자원이 아니라 **움직임 표현**을 가리킨다(표준과 충돌 아님). */
46
46
  { token: 'twin-mover', why: 'scene component type persisted in boards; names a motion representation, not a resource' },