@operato/ops-contract 0.9.17 → 0.9.20

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/README.md CHANGED
@@ -14,6 +14,90 @@ IEC 61850 · ISO 50001 전기 계통과 성과지표
14
14
 
15
15
  여기 없는 것은 **정책과 엔진**입니다. 그 사실로 무엇을 할지는 `@operato/twin-kernel` 이 정합니다.
16
16
 
17
+ ## 제품 간 AI 분석 입력 — `OperationalAttentionV1`
18
+
19
+ `OperationalAttentionV1`은 Plant·WMS·YMS·EMS가 자신이 관측한 운영 주의 사실을 Twin 또는 AI 분석기에
20
+ 보낼 때 쓰는 공통 봉투입니다. 국제 표준의 알람 포맷을 새로 만드는 것이 아닙니다. EPCIS/ISA-95 등의
21
+ 원본 사실을 **어떤 제품이, 어떤 근거로, 무엇을 분석 대상으로 열었거나 닫았는지** 안전하게 전달하는
22
+ Operato의 제품 간 계약입니다.
23
+
24
+ ```ts
25
+ import {
26
+ OPERATIONAL_ATTENTION_CONTRACT,
27
+ assertOperationalAttentionV1,
28
+ type OperationalAttentionV1
29
+ } from '@operato/ops-contract'
30
+
31
+ const attention: OperationalAttentionV1 = {
32
+ contract: OPERATIONAL_ATTENTION_CONTRACT,
33
+ domainId: 'factory-a',
34
+ attentionId: 'schedule-overdue:line-a:101',
35
+ dedupeKey: 'schedule-overdue:line-a:101',
36
+ kind: 'plant.schedule-overdue',
37
+ subjectLabel: 'Line A schedule 101',
38
+ transition: 'opened',
39
+ impact: 'high',
40
+ observedAt: '2026-09-12T01:00:00.000Z',
41
+ evidenceFingerprint: 'sha256:...',
42
+ facts: [{ system: 'operato-plant', type: 'schedule-entry', id: '101' }]
43
+ }
44
+
45
+ assertOperationalAttentionV1(attention)
46
+ ```
47
+
48
+ ### 공통으로 고정한 것
49
+
50
+ - 식별·재전송: `attentionId`, `dedupeKey`
51
+ - 분석 수명주기: `opened`, `changed`, `resolved`, `reopened`
52
+ - 분석 우선순위: `medium`, `high`
53
+ - 근거: 최소 하나 이상의 안정된 `{ system, type, id }` 원본 사실 참조와 `evidenceFingerprint`
54
+
55
+ 사람이 읽는 제목만 있고 원본 사실 ID가 없는 입력은 거부합니다. AI가 그럴듯한 설명을 만들더라도
56
+ 근거를 다시 열어 볼 수 없으면 운영 분석의 입력이 될 수 없기 때문입니다.
57
+
58
+ ### 제품별로 남기는 것
59
+
60
+ `kind`와 `facts[].type`은 생산자가 소유합니다. 예를 들어 Plant는 `plant.schedule-overdue`, WMS는
61
+ `wms.short-pick`을 쓰며 서로의 업무 모델을 흉내 내지 않습니다. 또한 `acknowledged`, `suppressed`,
62
+ 승인, 실제 조치 결과는 생산 제품의 업무 흐름에 남습니다. 이 계약은 분석에 필요한 **사실 변화**만
63
+ 전달하며, 수신자가 현장을 조치할 권한을 주지 않습니다.
64
+
65
+ 새 제품이 붙을 때는 공통 봉투를 넓히기 전에 그 제품의 adapter에서 자기 `kind`·사실 유형·판정 기준을
66
+ 검증합니다. 모든 제품이 실제로 공유하는 필드만 이 계약에 추가합니다.
67
+
68
+ ### 기존 webhook을 통한 전송
69
+
70
+ 새 URL은 만들지 않는다. attention도 기존 `POST /domain/{domain}/twin/hook/{source}/{instanceId}`로 보내며,
71
+ 봉투의 `lane: 'ATTENTIONS'`가 Twin의 AI inbox 경로를 고른다. `scope`는 계속 원천의 순번 흐름 이름이며
72
+ `lane`과 합치지 않는다. `lane`을 생략한 기존 커넥터는 `FACTS`로 읽혀 기존 canonical ingest 경로를 그대로
73
+ 쓴다. 따라서 주소·서명·재시도·순번 규약은 하나이고, 사실과 attention의 저장·처리만 Twin 내부에서 갈린다.
74
+
75
+ #### Webhook 봉투에서의 구분
76
+
77
+ `lane`은 **전달 목적**, `scope`는 **생산자의 순번 스트림 이름**이다. 같은 값으로 쓰거나 `scope`를
78
+ `ATTENTIONS`로 바꾸지 않는다.
79
+
80
+ ```ts
81
+ const batch = {
82
+ lane: 'ATTENTIONS', // 없으면 FACTS: 기존 커넥터와 호환
83
+ scope: 'mes-line-a', // 이 생산자가 관리하는 순번 스트림
84
+ items: [{
85
+ eventId: 'outbox-1002',
86
+ seq: 1002,
87
+ record: attention
88
+ }]
89
+ }
90
+ ```
91
+
92
+ | lane | Twin 내부 목적지 | 정본 저널 반영 |
93
+ | --- | --- | --- |
94
+ | `FACTS` 또는 생략 | 기존 canonical ingest | 예. EPCIS/운영 사실로 유입한다. |
95
+ | `ATTENTIONS` | 제품이 등록한 AI attention inbox | 아니오. 분석을 요청하는 근거 묶음으로 분리한다. |
96
+
97
+ 두 lane 모두 같은 webhook 인증·서명·순번·재시도 응답 계약을 쓴다. 수신자는 `OperationalAttentionV1`의
98
+ 도메인 일치와 원본 사실 참조를 검증하고, 같은 `domain/source/instance/attention/evidenceFingerprint`는
99
+ 중복으로 처리한다. 이 계약은 attention을 현장 명령으로 바꾸지 않는다.
100
+
17
101
  ## 왜 나뉘어 있나
18
102
 
19
103
  사실을 **만드는 쪽**(MES · 커넥터)은 계약만 필요하고 엔진은 필요 없습니다. 한 패키지에 두면 레코드
@@ -0,0 +1,47 @@
1
+ import type { CommandAck, RefusalCode, RefusalParams } from './contract.ts';
2
+ export type CommandVerdictKind = 'accepted' | 'refused' | 'unavailable';
3
+ /** Why a command was refused, or why no preview could be made. Language-neutral (ADR-0037 ④). */
4
+ export interface CommandVerdictReason {
5
+ /** Stable code — `CommandAck.errorCode` verbatim for a refusal; a host code for `unavailable`. */
6
+ code: RefusalCode;
7
+ /** Raw values the code refers to — `CommandAck.errorParams` verbatim. */
8
+ params?: RefusalParams;
9
+ /** Developer fallback in English (`CommandAck.error`). Not for viewers; hosts translate `code`. */
10
+ message?: string;
11
+ }
12
+ export type CommandVerdict = {
13
+ verdict: 'accepted';
14
+ commandId: string;
15
+ } | {
16
+ verdict: 'refused';
17
+ commandId: string;
18
+ reason: CommandVerdictReason;
19
+ } | {
20
+ verdict: 'unavailable';
21
+ reason: CommandVerdictReason;
22
+ };
23
+ /** The code a refusal carries when the kernel gave none — the same word the intervention log uses. */
24
+ export declare const REFUSED_WITHOUT_CODE = "refused";
25
+ /**
26
+ * Fold a kernel ack into a verdict. Pure: reads the ack, invents nothing.
27
+ *
28
+ * An ack that was not accepted and names no code still becomes `refused`, with the code the
29
+ * intervention log already uses for that case, so the two never disagree about what "no code" is.
30
+ */
31
+ export declare function verdictOfAck(ack: CommandAck): CommandVerdict;
32
+ /**
33
+ * The verdict when no preview can be made. The host decides this **before** dispatching, from what
34
+ * it can see: whether the runtime has `fork`, whether a checkpoint exists at that time.
35
+ */
36
+ export declare function unavailableVerdict(code: RefusalCode, params?: RefusalParams, message?: string): CommandVerdict;
37
+ /**
38
+ * Can this runtime preview at all? Read as a capability, not by trying and catching (ADR-0042 ②).
39
+ *
40
+ * This answers "can we look", not "can we act". A mirror twin refuses to *apply* an actuation
41
+ * (`actuation-not-wired` — the real system would not know), yet previewing it on a fork is fine and
42
+ * is exactly where a preview is worth most. Keep the two answers apart (ADR-0042 ⑤).
43
+ */
44
+ export declare function canPreview(runtime: unknown): runtime is {
45
+ fork(): unknown;
46
+ dispatch(cmd: unknown): CommandAck;
47
+ };
@@ -0,0 +1,65 @@
1
+ /*
2
+ * Preview verdict — the seam between a kernel `CommandAck` and a screen (ADR-0042).
3
+ *
4
+ * A proposal is previewed by dispatching the same command on a fork of the live kernel. Three
5
+ * things can come back, and a viewer has to act differently on each:
6
+ *
7
+ * accepted the fork took the command — the preview shows what follows
8
+ * refused the fork did not take it — **this is a successful preview**: we learned, before
9
+ * anyone pressed apply, that the kernel will not do this here. Drawing it as an
10
+ * error tells the viewer the preview broke, which is the opposite of what happened
11
+ * unavailable no preview could be made at all — the runtime has no `fork`, there is no
12
+ * checkpoint at that time, the twin has no runtime. Decided from capability first,
13
+ * never from an exception that happened to be thrown (ADR-0036 ③ · ADR-0042 ②)
14
+ *
15
+ * The reason keeps the shape ADR-0037 ④ fixed for every violation in this repo: `{ code, params,
16
+ * message }`. `code` and `params` are `CommandAck.errorCode` and `errorParams` **verbatim** — there
17
+ * is no mapping table, because a table goes stale the day the kernel emits a new code, and that
18
+ * day nobody notices. `message` is the developer fallback (`ack.error`), never the viewer line.
19
+ *
20
+ * Why this lives in the contract rather than as a convention: a convention holds only while the
21
+ * next author remembers it. A type does not need remembering.
22
+ */
23
+ /** The code a refusal carries when the kernel gave none — the same word the intervention log uses. */
24
+ export const REFUSED_WITHOUT_CODE = 'refused';
25
+ /**
26
+ * Fold a kernel ack into a verdict. Pure: reads the ack, invents nothing.
27
+ *
28
+ * An ack that was not accepted and names no code still becomes `refused`, with the code the
29
+ * intervention log already uses for that case, so the two never disagree about what "no code" is.
30
+ */
31
+ export function verdictOfAck(ack) {
32
+ if (ack.accepted)
33
+ return { verdict: 'accepted', commandId: ack.commandId };
34
+ const reason = { code: ack.errorCode ?? REFUSED_WITHOUT_CODE };
35
+ if (ack.errorParams !== undefined)
36
+ reason.params = ack.errorParams;
37
+ if (ack.error !== undefined)
38
+ reason.message = ack.error;
39
+ return { verdict: 'refused', commandId: ack.commandId, reason };
40
+ }
41
+ /**
42
+ * The verdict when no preview can be made. The host decides this **before** dispatching, from what
43
+ * it can see: whether the runtime has `fork`, whether a checkpoint exists at that time.
44
+ */
45
+ export function unavailableVerdict(code, params, message) {
46
+ const reason = { code };
47
+ if (params !== undefined)
48
+ reason.params = params;
49
+ if (message !== undefined)
50
+ reason.message = message;
51
+ return { verdict: 'unavailable', reason };
52
+ }
53
+ /**
54
+ * Can this runtime preview at all? Read as a capability, not by trying and catching (ADR-0042 ②).
55
+ *
56
+ * This answers "can we look", not "can we act". A mirror twin refuses to *apply* an actuation
57
+ * (`actuation-not-wired` — the real system would not know), yet previewing it on a fork is fine and
58
+ * is exactly where a preview is worth most. Keep the two answers apart (ADR-0042 ⑤).
59
+ */
60
+ export function canPreview(runtime) {
61
+ if (runtime === null || typeof runtime !== 'object')
62
+ return false;
63
+ const r = runtime;
64
+ return typeof r.fork === 'function' && typeof r.dispatch === 'function';
65
+ }
@@ -1463,6 +1463,12 @@ export interface ItemState {
1463
1463
  * 「이 로트가 지금 어떤 것이냐」다. 시험 결과를 명세당 하나만 든 것과 같은 규율이다.
1464
1464
  */
1465
1465
  nonconformance?: DispositionFact;
1466
+ /**
1467
+ * **로트 상태** — 표준 `MaterialLot.Status`. `nonconformance`(표준 `Disposition`)와 다른 칸이다: 처분은
1468
+ * 부적합을 어떻게 하나, 상태는 이 로트가 지금 어떤 것이냐(released · quarantine · on-hold …). 낱말은 도메인의
1469
+ * 것(열림). 마지막 하나만 든다 — 이력은 저널이 답한다(§`OP_EVENT.materialLot`).
1470
+ */
1471
+ status?: string;
1466
1472
  /** 소속 물류단위(팔레트 SSCC 등) — AggregationEvent 로 맺어진다. 3D 적재 표현의 재료. */
1467
1473
  parent?: string;
1468
1474
  /**
@@ -2559,6 +2565,17 @@ export interface Command<T = unknown> {
2559
2565
  correlationId?: string;
2560
2566
  args: T;
2561
2567
  }
2568
+ /**
2569
+ * 거절 사유의 **낱말 하나** — 커맨드가 낸 언어 중립 코드(`resource-not-found` 꼴, kebab-case).
2570
+ *
2571
+ * 같은 값이 세 자리에 실린다: `CommandAck.errorCode`(조치 ack) · `InterventionOutcome.refusedCode`(시나리오
2572
+ * 개입 로그) · 구동 검증의 실패 코드(호스트가 저장). 칸 이름은 자리마다 다르지만 **값은 하나**이고, 그 사실을
2573
+ * 이 타입이 말한다 — 이름이 셋이면 어딘가에 매핑 표가 생기고, 그것이 ADR-0042 ③ 이 금지한 것이다.
2574
+ * 칸 이름을 하나로 줄이는 개명은 저장되는 개입 로그에 닿아 따로 결정한다(ADR-0042 「하지 않는 결정」).
2575
+ */
2576
+ export type RefusalCode = string;
2577
+ /** `RefusalCode` 가 가리키는 원시값들 — 사람 언어가 아니라 값이다. 표현층이 문장에 끼운다. */
2578
+ export type RefusalParams = Record<string, string | number>;
2562
2579
  export interface CommandAck {
2563
2580
  commandId: string;
2564
2581
  accepted: boolean;
@@ -2566,8 +2583,8 @@ export interface CommandAck {
2566
2583
  * 거절 사유 — 언어 중립. errorCode(안정 코드) + errorParams(원시값)로 방출하고 사람 언어는
2567
2584
  * 표현계층(클라 i18next)이 렌더한다(무방언·다국어, attention 과 동형). error 는 개발자/로그용 영어 폴백.
2568
2585
  */
2569
- errorCode?: string;
2570
- errorParams?: Record<string, string | number>;
2586
+ errorCode?: RefusalCode;
2587
+ errorParams?: RefusalParams;
2571
2588
  error?: string;
2572
2589
  }
2573
2590
  /**
@@ -2699,8 +2716,8 @@ export interface InterventionOutcome {
2699
2716
  kind: string;
2700
2717
  args?: Record<string, unknown>;
2701
2718
  applied: boolean;
2702
- /** 거절 이유(커맨드가 코드). 걸렸으면 없다. */
2703
- refusedCode?: string;
2719
+ /** 거절 이유 `CommandAck.errorCode` 와 **같은 값**(`RefusalCode`). 걸렸으면 없다. */
2720
+ refusedCode?: RefusalCode;
2704
2721
  }
2705
2722
  export interface ScenarioControl {
2706
2723
  load(def: ScenarioDef): void;
@@ -2758,6 +2775,22 @@ export declare const OP_EVENT: {
2758
2775
  * 이것은 **결정**이다. 누가·언제·왜 그렇게 정했는지의 자리는 그쪽에 없다.
2759
2776
  */
2760
2777
  readonly disposition: "nonconformance.disposition";
2778
+ /**
2779
+ * **로트 상태** — ISA-95 `MaterialLot.Status`(B2MML-Material.xsd). 「이 로트가 지금 어떤 것이냐」의 결정.
2780
+ *
2781
+ * ── `nonconformance.disposition` 과 다른 사실이다 (2026-09-16) ──────────────
2782
+ * 그것은 표준 `Disposition` — **부적합을 어떻게 처리하나**(재작업·특채·폐기). 이것은 표준 `Status` — 적합한
2783
+ * 로트에도 붙는 상태(released · quarantine · on-hold …). 출하 승인(batch release)이 이 축이다. 처분에 실으면
2784
+ * 정상 출하가 전부 부적합 이력이 된다 — 진짜 term 을 틀린 뜻으로 쓰는 것이 자리가 없는 것보다 나쁘다.
2785
+ *
2786
+ * ── 낱말은 열려 있다 ──────────────────────────────────────────────────────
2787
+ * 오더 상태와 같은 규율 — 커널이 이 낱말로 무엇을 계산하지 않으므로 닫을 근거가 없다. 도메인이 소유한다.
2788
+ *
2789
+ * ── 출발과 다른 사실이다 ─────────────────────────────────────────────────
2790
+ * 승인은 자재를 움직이지 않는다. 자재가 트윈 밖으로 나가는 것은 EPCIS `shipping` 사건이다. 둘을 한 사건으로
2791
+ * 접지 않는다 — 승인됐는데 안 나간 배치가 트윈이 보여야 할 상태다.
2792
+ */
2793
+ readonly materialLot: "material-lot.status";
2761
2794
  /**
2762
2795
  * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
2763
2796
  *
package/dist/contract.js CHANGED
@@ -1097,6 +1097,22 @@ export const OP_EVENT = {
1097
1097
  * 이것은 **결정**이다. 누가·언제·왜 그렇게 정했는지의 자리는 그쪽에 없다.
1098
1098
  */
1099
1099
  disposition: 'nonconformance.disposition',
1100
+ /**
1101
+ * **로트 상태** — ISA-95 `MaterialLot.Status`(B2MML-Material.xsd). 「이 로트가 지금 어떤 것이냐」의 결정.
1102
+ *
1103
+ * ── `nonconformance.disposition` 과 다른 사실이다 (2026-09-16) ──────────────
1104
+ * 그것은 표준 `Disposition` — **부적합을 어떻게 처리하나**(재작업·특채·폐기). 이것은 표준 `Status` — 적합한
1105
+ * 로트에도 붙는 상태(released · quarantine · on-hold …). 출하 승인(batch release)이 이 축이다. 처분에 실으면
1106
+ * 정상 출하가 전부 부적합 이력이 된다 — 진짜 term 을 틀린 뜻으로 쓰는 것이 자리가 없는 것보다 나쁘다.
1107
+ *
1108
+ * ── 낱말은 열려 있다 ──────────────────────────────────────────────────────
1109
+ * 오더 상태와 같은 규율 — 커널이 이 낱말로 무엇을 계산하지 않으므로 닫을 근거가 없다. 도메인이 소유한다.
1110
+ *
1111
+ * ── 출발과 다른 사실이다 ─────────────────────────────────────────────────
1112
+ * 승인은 자재를 움직이지 않는다. 자재가 트윈 밖으로 나가는 것은 EPCIS `shipping` 사건이다. 둘을 한 사건으로
1113
+ * 접지 않는다 — 승인됐는데 안 나간 배치가 트윈이 보여야 할 상태다.
1114
+ */
1115
+ materialLot: 'material-lot.status',
1100
1116
  /**
1101
1117
  * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
1102
1118
  *
package/dist/index.d.ts CHANGED
@@ -17,6 +17,8 @@ export * from './vocabulary.ts';
17
17
  export * from './wms-profile.ts';
18
18
  export * from './yms-profile.ts';
19
19
  export * from './canonical-record.ts';
20
+ export * from './command-verdict.ts';
21
+ export * from './webhook-delivery.ts';
20
22
  export * from './webhook.ts';
21
23
  export * from './webhook-secret.ts';
22
24
  export * from './oee.ts';
@@ -25,3 +27,4 @@ export * from './yield.ts';
25
27
  export * from './reliability.ts';
26
28
  export * from './erp.ts';
27
29
  export * from './actuation.ts';
30
+ export * from './operational-attention.ts';
package/dist/index.js CHANGED
@@ -38,6 +38,8 @@ export * from "./vocabulary.js";
38
38
  export * from "./wms-profile.js";
39
39
  export * from "./yms-profile.js";
40
40
  export * from "./canonical-record.js";
41
+ export * from "./command-verdict.js";
42
+ export * from "./webhook-delivery.js";
41
43
  /* `webhook-signature.ts` 는 여기 없다 — `node:crypto` 를 쓰므로 `@operato/ops-contract/webhook` 으로만 나간다. */
42
44
  export * from "./webhook.js";
43
45
  /* 비밀값 **이름** 규약 — 값을 읽지 않으므로 여기서 나간다(§`webhook-secret`). */
@@ -48,3 +50,4 @@ export * from "./yield.js";
48
50
  export * from "./reliability.js";
49
51
  export * from "./erp.js";
50
52
  export * from "./actuation.js";
53
+ export * from "./operational-attention.js";
@@ -4,6 +4,12 @@ export declare const MES_BIZSTEP: {
4
4
  readonly receiving: "urn:epcglobal:cbv:bizstep:receiving";
5
5
  readonly producing: "urn:epcglobal:cbv:bizstep:commissioning";
6
6
  readonly storing: "urn:epcglobal:cbv:bizstep:storing";
7
+ /**
8
+ * **출하** — CBV `shipping`: 물품이 시설을 떠난다. 자재를 트윈 밖으로 내는 것은 이 사건이다(disposition
9
+ * `in_transit`, readPoint = 출하 dock). 출하 **승인**은 이 사건이 아니라 로트 상태(`OP_EVENT.materialLot`)다 —
10
+ * 승인됐는데 안 나간 배치가 보여야 한다(2026-09-16, ADR-0046 곁).
11
+ */
12
+ readonly shipping: "urn:epcglobal:cbv:bizstep:shipping";
7
13
  };
8
14
  /** 작업지시(Work Order) = 생산 오더 거래 유형. */
9
15
  export declare const BTT_PRODORDER = "urn:epcglobal:cbv:btt:prodorder";
@@ -2,7 +2,13 @@
2
2
  export const MES_BIZSTEP = {
3
3
  receiving: 'urn:epcglobal:cbv:bizstep:receiving', // 원자재 수령
4
4
  producing: 'urn:epcglobal:cbv:bizstep:commissioning', // 생산(제품 최초 생성)
5
- storing: 'urn:epcglobal:cbv:bizstep:storing' // 완제품 저장
5
+ storing: 'urn:epcglobal:cbv:bizstep:storing', // 완제품 저장
6
+ /**
7
+ * **출하** — CBV `shipping`: 물품이 시설을 떠난다. 자재를 트윈 밖으로 내는 것은 이 사건이다(disposition
8
+ * `in_transit`, readPoint = 출하 dock). 출하 **승인**은 이 사건이 아니라 로트 상태(`OP_EVENT.materialLot`)다 —
9
+ * 승인됐는데 안 나간 배치가 보여야 한다(2026-09-16, ADR-0046 곁).
10
+ */
11
+ shipping: 'urn:epcglobal:cbv:bizstep:shipping'
6
12
  };
7
13
  /** 작업지시(Work Order) = 생산 오더 거래 유형. */
8
14
  export const BTT_PRODORDER = 'urn:epcglobal:cbv:btt:prodorder';
@@ -0,0 +1,43 @@
1
+ import type { ISOTime } from './contract.ts';
2
+ /**
3
+ * Product-neutral evidence for an operational concern that another product's
4
+ * Twin/AI may analyse. It is not an alarm protocol and never authorises work.
5
+ *
6
+ * A producer owns how it detects and manages an attention. Consumers receive
7
+ * only its evidence-backed lifecycle change; acknowledgement, suppression,
8
+ * approval, and outcome remain in the producer's workflow.
9
+ */
10
+ export declare const OPERATIONAL_ATTENTION_CONTRACT: "operational.attention.v1";
11
+ export declare const OPERATIONAL_ATTENTION_TRANSITIONS: readonly ["opened", "changed", "resolved", "reopened"];
12
+ export declare const OPERATIONAL_ATTENTION_IMPACTS: readonly ["medium", "high"];
13
+ export type OperationalAttentionTransition = (typeof OPERATIONAL_ATTENTION_TRANSITIONS)[number];
14
+ export type OperationalAttentionImpact = (typeof OPERATIONAL_ATTENTION_IMPACTS)[number];
15
+ /** Stable reference to a fact held by the producing product. */
16
+ export interface OperationalFactReference {
17
+ /** Product/system that owns the source fact, e.g. operato-plant or operato-wms. */
18
+ system: string;
19
+ /** Producer-owned entity vocabulary, e.g. schedule-entry or inventory. */
20
+ type: string;
21
+ id: string;
22
+ observedAt?: ISOTime;
23
+ label?: string;
24
+ }
25
+ export interface OperationalAttentionV1 {
26
+ contract: typeof OPERATIONAL_ATTENTION_CONTRACT;
27
+ domainId: string;
28
+ /** Stable producer identifier; unchanged across lifecycle updates. */
29
+ attentionId: string;
30
+ dedupeKey: string;
31
+ /** Producer-owned, namespaced reason code (for example plant.schedule-overdue). */
32
+ kind: string;
33
+ subjectLabel: string;
34
+ transition: OperationalAttentionTransition;
35
+ impact: OperationalAttentionImpact;
36
+ observedAt: ISOTime;
37
+ /** Deterministic hash of the evidence used for this particular observation. */
38
+ evidenceFingerprint: string;
39
+ /** At least one stable producer fact is mandatory; labels alone are not evidence. */
40
+ facts: OperationalFactReference[];
41
+ }
42
+ export declare function isOperationalAttentionV1(value: unknown): value is OperationalAttentionV1;
43
+ export declare function assertOperationalAttentionV1(value: unknown): asserts value is OperationalAttentionV1;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Product-neutral evidence for an operational concern that another product's
3
+ * Twin/AI may analyse. It is not an alarm protocol and never authorises work.
4
+ *
5
+ * A producer owns how it detects and manages an attention. Consumers receive
6
+ * only its evidence-backed lifecycle change; acknowledgement, suppression,
7
+ * approval, and outcome remain in the producer's workflow.
8
+ */
9
+ export const OPERATIONAL_ATTENTION_CONTRACT = 'operational.attention.v1';
10
+ export const OPERATIONAL_ATTENTION_TRANSITIONS = ['opened', 'changed', 'resolved', 'reopened'];
11
+ export const OPERATIONAL_ATTENTION_IMPACTS = ['medium', 'high'];
12
+ export function isOperationalAttentionV1(value) {
13
+ const one = value;
14
+ return !!one && typeof one === 'object' &&
15
+ one.contract === OPERATIONAL_ATTENTION_CONTRACT &&
16
+ nonEmpty(one.domainId) && nonEmpty(one.attentionId) && nonEmpty(one.dedupeKey) &&
17
+ nonEmpty(one.kind) && nonEmpty(one.subjectLabel) && nonEmpty(one.observedAt) && nonEmpty(one.evidenceFingerprint) &&
18
+ OPERATIONAL_ATTENTION_TRANSITIONS.includes(one.transition) &&
19
+ OPERATIONAL_ATTENTION_IMPACTS.includes(one.impact) &&
20
+ Array.isArray(one.facts) && one.facts.length > 0 && one.facts.every(isOperationalFactReference);
21
+ }
22
+ export function assertOperationalAttentionV1(value) {
23
+ if (!isOperationalAttentionV1(value)) {
24
+ throw new Error('invalid operational.attention.v1: lifecycle fields and stable source-fact references are required');
25
+ }
26
+ }
27
+ function isOperationalFactReference(value) {
28
+ const one = value;
29
+ return !!one && typeof one === 'object' && nonEmpty(one.system) && nonEmpty(one.type) && nonEmpty(one.id) &&
30
+ (one.observedAt === undefined || nonEmpty(one.observedAt)) &&
31
+ (one.label === undefined || nonEmpty(one.label));
32
+ }
33
+ function nonEmpty(value) {
34
+ return typeof value === 'string' && value.trim().length > 0;
35
+ }
@@ -1,6 +1,10 @@
1
1
  import type { IngestResult } from './face2-adapter.ts';
2
- /** 이 문이 받는 여섯 가지 — 리듀서가 다루는 것과 같은 목록(주목 확인은 우리 안의 행위라 제외). */
3
- export type OperationalKind = 'task' | 'equipment' | 'equipment-period' | 'person' | 'asset' | 'order' | 'quality' | 'test' | 'disposition' | 'observation' | 'complete';
2
+ /**
3
+ * 문이 받는 종류 **아래 union 정본이고 수를 여기 적지 않는다**(2026-08-19 여섯이었고 이제 열하나다.
4
+ * 주석의 수가 union 보다 오래 살아 읽는 사람이 여섯에서 멈췄다). 리듀서가 다루는 것과 같은 목록이다(주목 확인은 우리 안의
5
+ * 행위라 제외).
6
+ */
7
+ export type OperationalKind = 'task' | 'equipment' | 'equipment-period' | 'person' | 'asset' | 'order' | 'quality' | 'test' | 'disposition' | 'material-lot' | 'observation' | 'complete';
4
8
  /**
5
9
  * 정규 운영 레코드 — **델타의 필드 이름 + 시각(`at`)**.
6
10
  *
@@ -320,6 +320,21 @@ const SPECS = {
320
320
  },
321
321
  enums: { decision: DISPOSITION_DECISION }
322
322
  },
323
+ /*
324
+ * **로트 상태** — ISA-95 `MaterialLot.Status`. 출하 승인(batch release)이 들어오는 문(§`OP_EVENT.materialLot`).
325
+ *
326
+ * `disposition`(subjectId + decision) 과 필드가 겹치지 않는다 — 여기는 `epc` + `status`. 상태 낱말은 열려
327
+ * 있어 enum 이 없다(오더 상태와 같은 규율). 로트 전체의 사실이므로 `subLotId` 는 받지 않는다 — 부분마다 다른
328
+ * 상태가 필요해지면 그것은 다른 사실이다.
329
+ */
330
+ 'material-lot': {
331
+ eventType: OP_EVENT.materialLot,
332
+ match: ['epc', 'status'],
333
+ matchOrder: 85,
334
+ identity: 'epc',
335
+ required: ['epc', 'status'],
336
+ fields: { epc: 'string', status: 'string', decidedBy: 'string', reason: 'string', decidedAt: 'string', recordTime: 'string' }
337
+ },
323
338
  test: {
324
339
  eventType: OP_EVENT.test,
325
340
  match: ['testableObjectId'],
@@ -0,0 +1,42 @@
1
+ import type { RefusalParams } from './contract.ts';
2
+ export declare const WEBHOOK_DELIVERY_CODES: {
3
+ /** The peer received and verified the envelope, then declined the job order itself (200, accepted=false). */
4
+ readonly jobOrderRefused: "job-order-refused";
5
+ /** The envelope does not match the specification — retry never helps until the sender changes (400). */
6
+ readonly envelopeInvalid: "envelope-invalid";
7
+ /** The signature did not verify (401). `params.reason` is `mismatch` or `expired`. */
8
+ readonly signatureRejected: "signature-rejected";
9
+ /** The peer has no such site (404). */
10
+ readonly siteUnknown: "site-unknown";
11
+ /** The peer has no verify key under the name the sender used (503). `params.peer` names that peer — never the env var. */
12
+ readonly verifySecretMissing: "verify-secret-missing";
13
+ /** No HTTP answer at all — DNS, connection refused, timeout. */
14
+ readonly peerUnreachable: "peer-unreachable";
15
+ /** The peer verified the envelope and then failed on its own side — storing, for instance (500). Retrying may help. */
16
+ readonly peerInternalFailure: "peer-internal-failure";
17
+ /** An answer came back that fits none of the above — the code is the honest "unclassified", not a guess. */
18
+ readonly responseUnrecognised: "response-unrecognised";
19
+ };
20
+ export type WebhookDeliveryCode = (typeof WEBHOOK_DELIVERY_CODES)[keyof typeof WEBHOOK_DELIVERY_CODES];
21
+ /** Reasons a signature can be rejected — the only two the verifier can tell apart. */
22
+ export declare const SIGNATURE_REJECTED_REASONS: readonly ["mismatch", "expired"];
23
+ export type SignatureRejectedReason = (typeof SIGNATURE_REJECTED_REASONS)[number];
24
+ /** Is this string one of the delivery codes? For the host that stores what a connector hands it. */
25
+ export declare function isWebhookDeliveryCode(code: string): code is WebhookDeliveryCode;
26
+ /**
27
+ * What a receiving door answers when it does not take the envelope (any non-2xx).
28
+ *
29
+ * The **receiver** names the fact as a code; the sender's connector passes it on verbatim and never
30
+ * parses the sentence. Two connectors reading one door then cannot invent two spellings, and a door
31
+ * that rewords its sentence cannot silently blank the code. `error` stays for people and logs.
32
+ *
33
+ * `params` is scalar only (`RefusalParams`): a list such as the specification checks a 400 failed is
34
+ * not a parameter of the code, it is detail — it rides in its own field of the body and is not stored
35
+ * by the host.
36
+ */
37
+ export interface WebhookIntakeRefusal {
38
+ code: WebhookDeliveryCode;
39
+ params?: RefusalParams;
40
+ /** Developer sentence in English. Never the thing a screen translates from. */
41
+ error: string;
42
+ }
@@ -0,0 +1,40 @@
1
+ /*
2
+ * Webhook delivery outcome codes — what a sender records when an envelope did not land.
3
+ *
4
+ * The connector that delivers a job order to a peer (twin → plant intake) sees an HTTP status and a
5
+ * body. Neither is what gets stored: a status number is the transport's word, and the body sentence
6
+ * is a person's language (the intake writes Korean today). What the host records on the connection
7
+ * (ADR: actuation verification axis — `actuationFailure.code`) is a `RefusalCode`, the same kind of
8
+ * word a kernel refusal carries, so one vocabulary reaches the screen through one door.
9
+ *
10
+ * Spelling follows the kernel's refusal codes: kebab-case, a noun phrase naming the fact, no HTTP
11
+ * number in the word. The number is how the connector *learned* the fact; the code is the fact.
12
+ *
13
+ * The mapping from status → code is the connector's, not the contract's: a 401 can mean two
14
+ * different facts (signature mismatch, replay expired) and only the connector reading the body knows
15
+ * which. The contract fixes the words so two connectors never invent two spellings for one fact.
16
+ */
17
+ export const WEBHOOK_DELIVERY_CODES = {
18
+ /** The peer received and verified the envelope, then declined the job order itself (200, accepted=false). */
19
+ jobOrderRefused: 'job-order-refused',
20
+ /** The envelope does not match the specification — retry never helps until the sender changes (400). */
21
+ envelopeInvalid: 'envelope-invalid',
22
+ /** The signature did not verify (401). `params.reason` is `mismatch` or `expired`. */
23
+ signatureRejected: 'signature-rejected',
24
+ /** The peer has no such site (404). */
25
+ siteUnknown: 'site-unknown',
26
+ /** The peer has no verify key under the name the sender used (503). `params.peer` names that peer — never the env var. */
27
+ verifySecretMissing: 'verify-secret-missing',
28
+ /** No HTTP answer at all — DNS, connection refused, timeout. */
29
+ peerUnreachable: 'peer-unreachable',
30
+ /** The peer verified the envelope and then failed on its own side — storing, for instance (500). Retrying may help. */
31
+ peerInternalFailure: 'peer-internal-failure',
32
+ /** An answer came back that fits none of the above — the code is the honest "unclassified", not a guess. */
33
+ responseUnrecognised: 'response-unrecognised'
34
+ };
35
+ /** Reasons a signature can be rejected — the only two the verifier can tell apart. */
36
+ export const SIGNATURE_REJECTED_REASONS = ['mismatch', 'expired'];
37
+ /** Is this string one of the delivery codes? For the host that stores what a connector hands it. */
38
+ export function isWebhookDeliveryCode(code) {
39
+ return Object.values(WEBHOOK_DELIVERY_CODES).includes(code);
40
+ }
@@ -81,18 +81,6 @@ export interface WebhookSecretLookup {
81
81
  * 받되, 열리면 부르는 쪽이 한 번 알린다 — 조용히 되면 아무도 새 이름으로 옮기지 않는다.
82
82
  */
83
83
  legacyBases?: readonly string[];
84
- /**
85
- * **검증 쪽에서 넓은 칸까지 내려가는 것을 허락한다.** 기본은 거짓이고, 그것이 ③ 규칙이다.
86
- *
87
- * 이 인자를 참으로 주려면 부르는 쪽이 **그 도메인에 상대가 하나뿐임을 확인**해야 한다. 둘이면
88
- * 둘이 같은 키로 풀리고, 그 순간 주소가 「이 사실이 누구 것인가」를 정하는데 아무도 그것을
89
- * 검사하지 않는다 — 상대 A 가 제대로 서명한 봉투를 상대 B 의 주소에 앉힐 수 있다
90
- * (§`webhook-signature` 의 불변식: 주소는 키가 이미 고정한 것만 정할 수 있다).
91
- *
92
- * 그리고 그 상태는 **두 번째 상대가 붙는 날 아무 경고 없이 끝난다.** 이름을 명시적으로 주게 한
93
- * 이유가 그것이다 — 코드에서 찾을 수 있어야 한다.
94
- */
95
- allowSinglePeerFallback?: boolean;
96
84
  }
97
85
  /**
98
86
  * ③ 사다리 — 찾아볼 이름을 순서대로.
@@ -104,15 +104,16 @@ export function webhookSecretEnvName(base, domain, peer) {
104
104
  * 상태이고, 뒤엣것은 사람이 볼 수 없다.
105
105
  */
106
106
  export function webhookSecretCandidates(lookup) {
107
- const { purpose, domain, peer, legacyBases = [], allowSinglePeerFallback = false } = lookup;
107
+ const { purpose, domain, peer, legacyBases = [] } = lookup;
108
108
  const base = WEBHOOK_SECRET_BASE[purpose];
109
109
  const out = [
110
110
  { name: webhookSecretEnvName(base, domain, peer), pins: 'peer', compatibility: false }
111
111
  ];
112
- /* 검증은 여기서 멈춘다 — 확인한 사람이 명시적으로 열지 않는 한. */
113
- if (purpose === 'verify' && !allowSinglePeerFallback)
112
+ /* 검증은 여기서 멈춘다 — 여는 인자가 없다. 넓어지면 아무 상대나 통과한다. */
113
+ if (purpose === 'verify')
114
114
  return out;
115
- out.push({ name: webhookSecretEnvName(base, domain), pins: 'domain', compatibility: purpose === 'verify' });
115
+ /* 여기부터는 서명 쪽만이다 도메인 칸은 서명이 원래 내려가는 칸이라 「호환」이 아니다. */
116
+ out.push({ name: webhookSecretEnvName(base, domain), pins: 'domain', compatibility: false });
116
117
  for (const legacy of legacyBases) {
117
118
  out.push({ name: webhookSecretEnvName(legacy, domain), pins: 'domain', compatibility: true });
118
119
  out.push({ name: legacy, pins: 'installation', compatibility: true });
package/dist/webhook.d.ts CHANGED
@@ -30,6 +30,18 @@ export declare const WEBHOOK_STATUS: {
30
30
  readonly notLive: 503;
31
31
  };
32
32
  export type WebhookStatus = (typeof WEBHOOK_STATUS)[keyof typeof WEBHOOK_STATUS];
33
+ /**
34
+ * A webhook lane says what the envelope is for; it is deliberately separate
35
+ * from `scope`, which names the producer's sequence stream.
36
+ *
37
+ * Omitting it means FACTS so every existing connector remains compatible.
38
+ */
39
+ export declare const WEBHOOK_LANE: {
40
+ readonly facts: "FACTS";
41
+ readonly attentions: "ATTENTIONS";
42
+ };
43
+ export type WebhookLane = (typeof WEBHOOK_LANE)[keyof typeof WEBHOOK_LANE];
44
+ export declare function webhookLaneOf(value: unknown): WebhookLane | undefined;
33
45
  /**
34
46
  * 그 응답을 받은 뒤 보내는 쪽이 할 일.
35
47
  *
@@ -217,6 +229,11 @@ export interface WebhookEnvelope {
217
229
  seq: number;
218
230
  /** 번호를 매기는 단위의 이름. 보내는 쪽이 정하고 받는 쪽은 열쇠로만 다룬다. */
219
231
  scope: string;
232
+ /**
233
+ * Delivery purpose. `scope` remains the source-owned numbering stream;
234
+ * `lane` selects the receiver path. Missing means `FACTS` for compatibility.
235
+ */
236
+ lane?: WebhookLane;
220
237
  /** 멱등 키. 같은 값이 두 번 와도 결과가 같아야 한다. */
221
238
  eventId?: string;
222
239
  /** 사실이 일어난 시각. */
package/dist/webhook.js CHANGED
@@ -45,6 +45,23 @@ export const WEBHOOK_STATUS = {
45
45
  unsupported: 501,
46
46
  notLive: 503
47
47
  };
48
+ /**
49
+ * A webhook lane says what the envelope is for; it is deliberately separate
50
+ * from `scope`, which names the producer's sequence stream.
51
+ *
52
+ * Omitting it means FACTS so every existing connector remains compatible.
53
+ */
54
+ export const WEBHOOK_LANE = {
55
+ facts: 'FACTS',
56
+ attentions: 'ATTENTIONS'
57
+ };
58
+ export function webhookLaneOf(value) {
59
+ return value === undefined || value === WEBHOOK_LANE.facts
60
+ ? WEBHOOK_LANE.facts
61
+ : value === WEBHOOK_LANE.attentions
62
+ ? WEBHOOK_LANE.attentions
63
+ : undefined;
64
+ }
48
65
  /**
49
66
  * 응답 코드 → 보내는 쪽의 행동. **모르는 코드도 답을 낸다.**
50
67
  *
@@ -58,13 +58,18 @@ __export(index_exports, {
58
58
  MES_TYPES: () => MES_TYPES,
59
59
  METER_DIRECTION: () => METER_DIRECTION,
60
60
  OBSERVATION_BASIS: () => OBSERVATION_BASIS,
61
+ OPERATIONAL_ATTENTION_CONTRACT: () => OPERATIONAL_ATTENTION_CONTRACT,
62
+ OPERATIONAL_ATTENTION_IMPACTS: () => OPERATIONAL_ATTENTION_IMPACTS,
63
+ OPERATIONAL_ATTENTION_TRANSITIONS: () => OPERATIONAL_ATTENTION_TRANSITIONS,
61
64
  OP_EVENT: () => OP_EVENT,
62
65
  OP_PARAM: () => OP_PARAM,
63
66
  ORDER_TERMINAL_STATUS: () => ORDER_TERMINAL_STATUS,
64
67
  PRIORITY_UNSET: () => PRIORITY_UNSET,
68
+ REFUSED_WITHOUT_CODE: () => REFUSED_WITHOUT_CODE,
65
69
  RESOURCE_KIND: () => RESOURCE_KIND,
66
70
  RETIRED_VOCABULARY: () => RETIRED_VOCABULARY,
67
71
  SCHEDULE_STATUS: () => SCHEDULE_STATUS,
72
+ SIGNATURE_REJECTED_REASONS: () => SIGNATURE_REJECTED_REASONS,
68
73
  TWIN_AXES: () => TWIN_AXES,
69
74
  TWIN_PROPERTIES: () => TWIN_PROPERTIES,
70
75
  TWIN_RELATIONS: () => TWIN_RELATIONS,
@@ -72,6 +77,8 @@ __export(index_exports, {
72
77
  UTC_OFFSET: () => UTC_OFFSET,
73
78
  VOCABULARY_EXCEPTIONS: () => VOCABULARY_EXCEPTIONS,
74
79
  VOCABULARY_TYPE: () => VOCABULARY_TYPE,
80
+ WEBHOOK_DELIVERY_CODES: () => WEBHOOK_DELIVERY_CODES,
81
+ WEBHOOK_LANE: () => WEBHOOK_LANE,
75
82
  WEBHOOK_SECRET_BASE: () => WEBHOOK_SECRET_BASE,
76
83
  WEBHOOK_SECRET_NAME_SHAPE: () => WEBHOOK_SECRET_NAME_SHAPE,
77
84
  WEBHOOK_STATUS: () => WEBHOOK_STATUS,
@@ -88,11 +95,13 @@ __export(index_exports, {
88
95
  analyzeCapacity: () => analyzeCapacity,
89
96
  approveCommand: () => approveCommand,
90
97
  asApproved: () => asApproved,
98
+ assertOperationalAttentionV1: () => assertOperationalAttentionV1,
91
99
  axesOfSystem: () => axesOfSystem,
92
100
  axisAppliesTo: () => axisAppliesTo,
93
101
  axisInfo: () => axisInfo,
94
102
  axisSource: () => axisSource,
95
103
  bizTransactionUri: () => bizTransactionUri,
104
+ canPreview: () => canPreview,
96
105
  capabilitiesForType: () => capabilitiesForType,
97
106
  capabilityOf: () => capabilityOf,
98
107
  checkSequence: () => checkSequence,
@@ -154,11 +163,13 @@ __export(index_exports, {
154
163
  isEquipmentStatus: () => isEquipmentStatus,
155
164
  isFailureStatus: () => isFailureStatus,
156
165
  isMasterDataRecord: () => isMasterDataRecord,
166
+ isOperationalAttentionV1: () => isOperationalAttentionV1,
157
167
  isOperationalRecord: () => isOperationalRecord,
158
168
  isOrderTerminal: () => isOrderTerminal,
159
169
  isPlannedStopStatus: () => isPlannedStopStatus,
160
170
  isResourceKind: () => isResourceKind,
161
171
  isTransformationRecord: () => isTransformationRecord,
172
+ isWebhookDeliveryCode: () => isWebhookDeliveryCode,
162
173
  isWebhookSecretSegment: () => isWebhookSecretSegment,
163
174
  isoDurationHours: () => isoDurationHours,
164
175
  itemKeyOf: () => itemKeyOf,
@@ -218,10 +229,13 @@ __export(index_exports, {
218
229
  testPassedAt: () => testPassedAt,
219
230
  transactionEvent: () => transactionEvent,
220
231
  transformationEvent: () => transformationEvent,
232
+ unavailableVerdict: () => unavailableVerdict,
221
233
  validateDomainDefinition: () => validateDomainDefinition,
222
234
  validateEpcisEvent: () => validateEpcisEvent,
223
235
  validatePerformance: () => validatePerformance,
224
236
  validateScenario: () => validateScenario,
237
+ verdictOfAck: () => verdictOfAck,
238
+ webhookLaneOf: () => webhookLaneOf,
225
239
  webhookSecretCandidates: () => webhookSecretCandidates,
226
240
  webhookSecretEnvName: () => webhookSecretEnvName,
227
241
  webhookSecretFrom: () => webhookSecretFrom,
@@ -895,6 +909,22 @@ var OP_EVENT = {
895
909
  * 이것은 **결정**이다. 누가·언제·왜 그렇게 정했는지의 자리는 그쪽에 없다.
896
910
  */
897
911
  disposition: "nonconformance.disposition",
912
+ /**
913
+ * **로트 상태** — ISA-95 `MaterialLot.Status`(B2MML-Material.xsd). 「이 로트가 지금 어떤 것이냐」의 결정.
914
+ *
915
+ * ── `nonconformance.disposition` 과 다른 사실이다 (2026-09-16) ──────────────
916
+ * 그것은 표준 `Disposition` — **부적합을 어떻게 처리하나**(재작업·특채·폐기). 이것은 표준 `Status` — 적합한
917
+ * 로트에도 붙는 상태(released · quarantine · on-hold …). 출하 승인(batch release)이 이 축이다. 처분에 실으면
918
+ * 정상 출하가 전부 부적합 이력이 된다 — 진짜 term 을 틀린 뜻으로 쓰는 것이 자리가 없는 것보다 나쁘다.
919
+ *
920
+ * ── 낱말은 열려 있다 ──────────────────────────────────────────────────────
921
+ * 오더 상태와 같은 규율 — 커널이 이 낱말로 무엇을 계산하지 않으므로 닫을 근거가 없다. 도메인이 소유한다.
922
+ *
923
+ * ── 출발과 다른 사실이다 ─────────────────────────────────────────────────
924
+ * 승인은 자재를 움직이지 않는다. 자재가 트윈 밖으로 나가는 것은 EPCIS `shipping` 사건이다. 둘을 한 사건으로
925
+ * 접지 않는다 — 승인됐는데 안 나간 배치가 트윈이 보여야 할 상태다.
926
+ */
927
+ materialLot: "material-lot.status",
898
928
  /**
899
929
  * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
900
930
  *
@@ -1286,8 +1316,14 @@ var MES_BIZSTEP = {
1286
1316
  // 원자재 수령
1287
1317
  producing: "urn:epcglobal:cbv:bizstep:commissioning",
1288
1318
  // 생산(제품 최초 생성)
1289
- storing: "urn:epcglobal:cbv:bizstep:storing"
1319
+ storing: "urn:epcglobal:cbv:bizstep:storing",
1290
1320
  // 완제품 저장
1321
+ /**
1322
+ * **출하** — CBV `shipping`: 물품이 시설을 떠난다. 자재를 트윈 밖으로 내는 것은 이 사건이다(disposition
1323
+ * `in_transit`, readPoint = 출하 dock). 출하 **승인**은 이 사건이 아니라 로트 상태(`OP_EVENT.materialLot`)다 —
1324
+ * 승인됐는데 안 나간 배치가 보여야 한다(2026-09-16, ADR-0046 곁).
1325
+ */
1326
+ shipping: "urn:epcglobal:cbv:bizstep:shipping"
1291
1327
  };
1292
1328
  var BTT_PRODORDER = "urn:epcglobal:cbv:btt:prodorder";
1293
1329
  function sgtinUri(companyPrefix, itemRef, serial) {
@@ -3674,6 +3710,21 @@ var SPECS = {
3674
3710
  },
3675
3711
  enums: { decision: DISPOSITION_DECISION }
3676
3712
  },
3713
+ /*
3714
+ * **로트 상태** — ISA-95 `MaterialLot.Status`. 출하 승인(batch release)이 들어오는 문(§`OP_EVENT.materialLot`).
3715
+ *
3716
+ * `disposition`(subjectId + decision) 과 필드가 겹치지 않는다 — 여기는 `epc` + `status`. 상태 낱말은 열려
3717
+ * 있어 enum 이 없다(오더 상태와 같은 규율). 로트 전체의 사실이므로 `subLotId` 는 받지 않는다 — 부분마다 다른
3718
+ * 상태가 필요해지면 그것은 다른 사실이다.
3719
+ */
3720
+ "material-lot": {
3721
+ eventType: OP_EVENT.materialLot,
3722
+ match: ["epc", "status"],
3723
+ matchOrder: 85,
3724
+ identity: "epc",
3725
+ required: ["epc", "status"],
3726
+ fields: { epc: "string", status: "string", decidedBy: "string", reason: "string", decidedAt: "string", recordTime: "string" }
3727
+ },
3677
3728
  test: {
3678
3729
  eventType: OP_EVENT.test,
3679
3730
  match: ["testableObjectId"],
@@ -4038,6 +4089,51 @@ function retiredVocabularyIn(line) {
4038
4089
  return hits;
4039
4090
  }
4040
4091
 
4092
+ // src/command-verdict.ts
4093
+ var REFUSED_WITHOUT_CODE = "refused";
4094
+ function verdictOfAck(ack) {
4095
+ if (ack.accepted) return { verdict: "accepted", commandId: ack.commandId };
4096
+ const reason = { code: ack.errorCode ?? REFUSED_WITHOUT_CODE };
4097
+ if (ack.errorParams !== void 0) reason.params = ack.errorParams;
4098
+ if (ack.error !== void 0) reason.message = ack.error;
4099
+ return { verdict: "refused", commandId: ack.commandId, reason };
4100
+ }
4101
+ function unavailableVerdict(code, params, message) {
4102
+ const reason = { code };
4103
+ if (params !== void 0) reason.params = params;
4104
+ if (message !== void 0) reason.message = message;
4105
+ return { verdict: "unavailable", reason };
4106
+ }
4107
+ function canPreview(runtime) {
4108
+ if (runtime === null || typeof runtime !== "object") return false;
4109
+ const r = runtime;
4110
+ return typeof r.fork === "function" && typeof r.dispatch === "function";
4111
+ }
4112
+
4113
+ // src/webhook-delivery.ts
4114
+ var WEBHOOK_DELIVERY_CODES = {
4115
+ /** The peer received and verified the envelope, then declined the job order itself (200, accepted=false). */
4116
+ jobOrderRefused: "job-order-refused",
4117
+ /** The envelope does not match the specification — retry never helps until the sender changes (400). */
4118
+ envelopeInvalid: "envelope-invalid",
4119
+ /** The signature did not verify (401). `params.reason` is `mismatch` or `expired`. */
4120
+ signatureRejected: "signature-rejected",
4121
+ /** The peer has no such site (404). */
4122
+ siteUnknown: "site-unknown",
4123
+ /** The peer has no verify key under the name the sender used (503). `params.peer` names that peer — never the env var. */
4124
+ verifySecretMissing: "verify-secret-missing",
4125
+ /** No HTTP answer at all — DNS, connection refused, timeout. */
4126
+ peerUnreachable: "peer-unreachable",
4127
+ /** The peer verified the envelope and then failed on its own side — storing, for instance (500). Retrying may help. */
4128
+ peerInternalFailure: "peer-internal-failure",
4129
+ /** An answer came back that fits none of the above — the code is the honest "unclassified", not a guess. */
4130
+ responseUnrecognised: "response-unrecognised"
4131
+ };
4132
+ var SIGNATURE_REJECTED_REASONS = ["mismatch", "expired"];
4133
+ function isWebhookDeliveryCode(code) {
4134
+ return Object.values(WEBHOOK_DELIVERY_CODES).includes(code);
4135
+ }
4136
+
4041
4137
  // src/webhook.ts
4042
4138
  var WEBHOOK_STATUS = {
4043
4139
  ok: 200,
@@ -4050,6 +4146,13 @@ var WEBHOOK_STATUS = {
4050
4146
  unsupported: 501,
4051
4147
  notLive: 503
4052
4148
  };
4149
+ var WEBHOOK_LANE = {
4150
+ facts: "FACTS",
4151
+ attentions: "ATTENTIONS"
4152
+ };
4153
+ function webhookLaneOf(value) {
4154
+ return value === void 0 || value === WEBHOOK_LANE.facts ? WEBHOOK_LANE.facts : value === WEBHOOK_LANE.attentions ? WEBHOOK_LANE.attentions : void 0;
4155
+ }
4053
4156
  function webhookSenderAction(status) {
4054
4157
  switch (status) {
4055
4158
  case WEBHOOK_STATUS.ok:
@@ -4125,13 +4228,13 @@ function webhookSecretEnvName(base, domain, peer) {
4125
4228
  return [base, ...segments.map((s) => s.toUpperCase().replace(/-/g, "_"))].join("__");
4126
4229
  }
4127
4230
  function webhookSecretCandidates(lookup) {
4128
- const { purpose, domain, peer, legacyBases = [], allowSinglePeerFallback = false } = lookup;
4231
+ const { purpose, domain, peer, legacyBases = [] } = lookup;
4129
4232
  const base = WEBHOOK_SECRET_BASE[purpose];
4130
4233
  const out = [
4131
4234
  { name: webhookSecretEnvName(base, domain, peer), pins: "peer", compatibility: false }
4132
4235
  ];
4133
- if (purpose === "verify" && !allowSinglePeerFallback) return out;
4134
- out.push({ name: webhookSecretEnvName(base, domain), pins: "domain", compatibility: purpose === "verify" });
4236
+ if (purpose === "verify") return out;
4237
+ out.push({ name: webhookSecretEnvName(base, domain), pins: "domain", compatibility: false });
4135
4238
  for (const legacy of legacyBases) {
4136
4239
  out.push({ name: webhookSecretEnvName(legacy, domain), pins: "domain", compatibility: true });
4137
4240
  out.push({ name: legacy, pins: "installation", compatibility: true });
@@ -4453,6 +4556,27 @@ function commandSpecGaps(specs, command) {
4453
4556
  }
4454
4557
  return gaps;
4455
4558
  }
4559
+
4560
+ // src/operational-attention.ts
4561
+ var OPERATIONAL_ATTENTION_CONTRACT = "operational.attention.v1";
4562
+ var OPERATIONAL_ATTENTION_TRANSITIONS = ["opened", "changed", "resolved", "reopened"];
4563
+ var OPERATIONAL_ATTENTION_IMPACTS = ["medium", "high"];
4564
+ function isOperationalAttentionV1(value) {
4565
+ const one = value;
4566
+ return !!one && typeof one === "object" && one.contract === OPERATIONAL_ATTENTION_CONTRACT && nonEmpty(one.domainId) && nonEmpty(one.attentionId) && nonEmpty(one.dedupeKey) && nonEmpty(one.kind) && nonEmpty(one.subjectLabel) && nonEmpty(one.observedAt) && nonEmpty(one.evidenceFingerprint) && OPERATIONAL_ATTENTION_TRANSITIONS.includes(one.transition) && OPERATIONAL_ATTENTION_IMPACTS.includes(one.impact) && Array.isArray(one.facts) && one.facts.length > 0 && one.facts.every(isOperationalFactReference);
4567
+ }
4568
+ function assertOperationalAttentionV1(value) {
4569
+ if (!isOperationalAttentionV1(value)) {
4570
+ throw new Error("invalid operational.attention.v1: lifecycle fields and stable source-fact references are required");
4571
+ }
4572
+ }
4573
+ function isOperationalFactReference(value) {
4574
+ const one = value;
4575
+ return !!one && typeof one === "object" && nonEmpty(one.system) && nonEmpty(one.type) && nonEmpty(one.id) && (one.observedAt === void 0 || nonEmpty(one.observedAt)) && (one.label === void 0 || nonEmpty(one.label));
4576
+ }
4577
+ function nonEmpty(value) {
4578
+ return typeof value === "string" && value.trim().length > 0;
4579
+ }
4456
4580
  // Annotate the CommonJS export names for ESM import in node:
4457
4581
  0 && (module.exports = {
4458
4582
  BIZSTEP,
@@ -4494,13 +4618,18 @@ function commandSpecGaps(specs, command) {
4494
4618
  MES_TYPES,
4495
4619
  METER_DIRECTION,
4496
4620
  OBSERVATION_BASIS,
4621
+ OPERATIONAL_ATTENTION_CONTRACT,
4622
+ OPERATIONAL_ATTENTION_IMPACTS,
4623
+ OPERATIONAL_ATTENTION_TRANSITIONS,
4497
4624
  OP_EVENT,
4498
4625
  OP_PARAM,
4499
4626
  ORDER_TERMINAL_STATUS,
4500
4627
  PRIORITY_UNSET,
4628
+ REFUSED_WITHOUT_CODE,
4501
4629
  RESOURCE_KIND,
4502
4630
  RETIRED_VOCABULARY,
4503
4631
  SCHEDULE_STATUS,
4632
+ SIGNATURE_REJECTED_REASONS,
4504
4633
  TWIN_AXES,
4505
4634
  TWIN_PROPERTIES,
4506
4635
  TWIN_RELATIONS,
@@ -4508,6 +4637,8 @@ function commandSpecGaps(specs, command) {
4508
4637
  UTC_OFFSET,
4509
4638
  VOCABULARY_EXCEPTIONS,
4510
4639
  VOCABULARY_TYPE,
4640
+ WEBHOOK_DELIVERY_CODES,
4641
+ WEBHOOK_LANE,
4511
4642
  WEBHOOK_SECRET_BASE,
4512
4643
  WEBHOOK_SECRET_NAME_SHAPE,
4513
4644
  WEBHOOK_STATUS,
@@ -4524,11 +4655,13 @@ function commandSpecGaps(specs, command) {
4524
4655
  analyzeCapacity,
4525
4656
  approveCommand,
4526
4657
  asApproved,
4658
+ assertOperationalAttentionV1,
4527
4659
  axesOfSystem,
4528
4660
  axisAppliesTo,
4529
4661
  axisInfo,
4530
4662
  axisSource,
4531
4663
  bizTransactionUri,
4664
+ canPreview,
4532
4665
  capabilitiesForType,
4533
4666
  capabilityOf,
4534
4667
  checkSequence,
@@ -4590,11 +4723,13 @@ function commandSpecGaps(specs, command) {
4590
4723
  isEquipmentStatus,
4591
4724
  isFailureStatus,
4592
4725
  isMasterDataRecord,
4726
+ isOperationalAttentionV1,
4593
4727
  isOperationalRecord,
4594
4728
  isOrderTerminal,
4595
4729
  isPlannedStopStatus,
4596
4730
  isResourceKind,
4597
4731
  isTransformationRecord,
4732
+ isWebhookDeliveryCode,
4598
4733
  isWebhookSecretSegment,
4599
4734
  isoDurationHours,
4600
4735
  itemKeyOf,
@@ -4654,10 +4789,13 @@ function commandSpecGaps(specs, command) {
4654
4789
  testPassedAt,
4655
4790
  transactionEvent,
4656
4791
  transformationEvent,
4792
+ unavailableVerdict,
4657
4793
  validateDomainDefinition,
4658
4794
  validateEpcisEvent,
4659
4795
  validatePerformance,
4660
4796
  validateScenario,
4797
+ verdictOfAck,
4798
+ webhookLaneOf,
4661
4799
  webhookSecretCandidates,
4662
4800
  webhookSecretEnvName,
4663
4801
  webhookSecretFrom,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/ops-contract",
3
- "version": "0.9.17",
3
+ "version": "0.9.20",
4
4
  "description": "Operations domain contract — the standard vocabulary that producers and readers agree on (EPCIS 2.0/GS1, ISA-95, IEC 61850/ISO 50001). Types, guards, validation. No state, no engine.",
5
5
  "type": "module",
6
6
  "main": "./dist-cjs/index.cjs",