@operato/twin-kernel 0.7.63 → 0.7.64

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.
@@ -5,6 +5,31 @@ export interface CanonicalEnvelope<T = unknown> {
5
5
  eventTime: ISOTime;
6
6
  tenantId: string;
7
7
  correlationId?: string;
8
+ /**
9
+ * **사람이 그때 적어 둔 말** — 연결된 시스템에서 온 자유 문장.
10
+ *
11
+ * ── 왜 이 자리가 필요한가 (2026-08-25) ────────────────────────────────────
12
+ * 재고 조정만은 **원인이 트윈의 모델 밖에 있다.** 다른 이동은 원인을 트윈이 스스로 압니다 —
13
+ * 꺼내 간 것은 작업 지시가 시켰고, 적치는 입고가 시켰습니다. 그런데 조정은 **사람이 판단한 것**이고,
14
+ * 그 이유는 그 사람이 적은 문장에만 있습니다(「실재고로 조정(192kg→198kg)」).
15
+ *
16
+ * 그 문장이 없으면 트윈은 「6kg 이 늘었다」까지만 알고 **왜인지 영원히 답하지 못합니다.**
17
+ *
18
+ * ── 왜 봉투에 두고 상태에 두지 않나 ──────────────────────────────────────
19
+ * 그 문장은 **지난 사건에 대한 사실**이고 지금 수량에 대한 사실이 아닙니다. 상태에 두면 조정 열 번에
20
+ * 문장 열 개가 쌓여 시간에 비례해 자랍니다. 지난 기록이 답하는 물음입니다.
21
+ *
22
+ * 그리고 EPCIS 사건 안에 넣지 않습니다 — 표준에 그 칸이 없고, 이름을 지어 넣으면 그 사건이
23
+ * 표준을 벗어납니다. 봉투는 우리 것이므로 여기가 맞고, 지난 기록에는 봉투가 그대로 남습니다.
24
+ *
25
+ * ── 커널은 읽지 않습니다 ─────────────────────────────────────────────────
26
+ * 자유 문장이므로 어떤 판단에도 쓰지 않습니다. 나르기만 합니다. 비어 있으면 이 칸을 만들지
27
+ * 않습니다 — 빈 문장은 「적지 않았다」와 다르게 보입니다.
28
+ *
29
+ * 표준 근거: ISA-95 가 거의 모든 타입에 두는 `Description`(DescriptionType). 사람의 글을 담는
30
+ * 표준 자리이고, EPCIS 핵심에는 없어 확장으로 갑니다.
31
+ */
32
+ description?: string;
8
33
  data: T;
9
34
  }
10
35
  /**
@@ -225,11 +225,18 @@ export function ingest(records, rules, opts) {
225
225
  rejected.push({ record, errors });
226
226
  continue;
227
227
  }
228
+ /*
229
+ * 사람이 적어 둔 말은 **봉투에** 싣는다(§`CanonicalEnvelope.description`) — EPCIS 사건 안에 넣으면
230
+ * 그 사건이 표준을 벗어난다. 지난 기록에는 봉투가 그대로 남으므로 잃지 않는다.
231
+ * 비어 있으면 이 칸을 만들지 않는다 — 빈 문장은 「적지 않았다」와 다르게 보인다.
232
+ */
233
+ const note = typeof record['description'] === 'string' ? String(record['description']).trim() : '';
228
234
  accepted.push({
229
235
  eventId: `${opts.tenantId}-ingest-${++seq}`,
230
236
  eventType: `epcis.${ev.type}`,
231
237
  eventTime: ev.eventTime,
232
238
  tenantId: opts.tenantId,
239
+ ...(note ? { description: note } : {}),
233
240
  data: ev
234
241
  });
235
242
  }
@@ -267,8 +267,15 @@ export function ingestOperationalRecords(records, opts) {
267
267
  const spec = SPECS[kind];
268
268
  const r = record;
269
269
  const errors = [];
270
- /* 모르는 이름은 거부한다 — 한 글자 틀린 필드가 조용히 사라지는 것을 막는다. */
271
- const unknown = Object.keys(r).filter(k => k !== 'at' && spec.fields[k] === undefined);
270
+ /*
271
+ * 모르는 이름은 거부한다 — 한 글자 틀린 필드가 오류 없이 사라지는 것을 막는다.
272
+ *
273
+ * 둘은 뺀다. **사실의 칸이 아니라 봉투의 칸**이다: `at` 은 그 사실이 일어난 시각이고,
274
+ * `description` 은 사람이 그때 적어 둔 말이다(§`CanonicalEnvelope.description`). 사실 칸으로
275
+ * 검사하면 명세마다 같은 두 줄을 적어야 하고, 한 곳을 빠뜨리면 그 통로만 거부한다.
276
+ */
277
+ const ENVELOPE_FIELDS = ['at', 'description'];
278
+ const unknown = Object.keys(r).filter(k => !ENVELOPE_FIELDS.includes(k) && spec.fields[k] === undefined);
272
279
  if (unknown.length)
273
280
  errors.push(`${kind}: 계약에 없는 필드 — ${unknown.join(', ')}`);
274
281
  for (const name of spec.required) {
@@ -357,11 +364,14 @@ export function ingestOperationalRecords(records, opts) {
357
364
  rejected.push({ record, errors });
358
365
  continue;
359
366
  }
367
+ /* 사람이 적어 둔 말 — 봉투에 싣는다(§`CanonicalEnvelope.description`). 물류 쪽과 같은 자리다. */
368
+ const note = typeof r.description === 'string' ? String(r.description).trim() : '';
360
369
  accepted.push({
361
370
  eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
362
371
  eventType: spec.eventType,
363
372
  eventTime: new Date(atMs).toISOString(),
364
373
  tenantId: opts.tenantId,
374
+ ...(note ? { description: note } : {}),
365
375
  data
366
376
  });
367
377
  }
@@ -3377,11 +3377,13 @@ function ingest(records, rules, opts) {
3377
3377
  rejected.push({ record, errors });
3378
3378
  continue;
3379
3379
  }
3380
+ const note = typeof record["description"] === "string" ? String(record["description"]).trim() : "";
3380
3381
  accepted.push({
3381
3382
  eventId: `${opts.tenantId}-ingest-${++seq}`,
3382
3383
  eventType: `epcis.${ev.type}`,
3383
3384
  eventTime: ev.eventTime,
3384
3385
  tenantId: opts.tenantId,
3386
+ ...note ? { description: note } : {},
3385
3387
  data: ev
3386
3388
  });
3387
3389
  }
@@ -9172,7 +9174,8 @@ function ingestOperationalRecords(records, opts) {
9172
9174
  const spec = SPECS[kind];
9173
9175
  const r = record;
9174
9176
  const errors = [];
9175
- const unknown = Object.keys(r).filter((k) => k !== "at" && spec.fields[k] === void 0);
9177
+ const ENVELOPE_FIELDS = ["at", "description"];
9178
+ const unknown = Object.keys(r).filter((k) => !ENVELOPE_FIELDS.includes(k) && spec.fields[k] === void 0);
9176
9179
  if (unknown.length) errors.push(`${kind}: \uACC4\uC57D\uC5D0 \uC5C6\uB294 \uD544\uB4DC \u2014 ${unknown.join(", ")}`);
9177
9180
  for (const name of spec.required) {
9178
9181
  const v = r[name];
@@ -9256,11 +9259,13 @@ function ingestOperationalRecords(records, opts) {
9256
9259
  rejected.push({ record, errors });
9257
9260
  continue;
9258
9261
  }
9262
+ const note = typeof r.description === "string" ? String(r.description).trim() : "";
9259
9263
  accepted.push({
9260
9264
  eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
9261
9265
  eventType: spec.eventType,
9262
9266
  eventTime: new Date(atMs).toISOString(),
9263
9267
  tenantId: opts.tenantId,
9268
+ ...note ? { description: note } : {},
9264
9269
  data
9265
9270
  });
9266
9271
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.63",
3
+ "version": "0.7.64",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {