@operato/ops-contract 0.9.19 → 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
@@ -72,6 +72,32 @@ assertOperationalAttentionV1(attention)
72
72
  `lane`과 합치지 않는다. `lane`을 생략한 기존 커넥터는 `FACTS`로 읽혀 기존 canonical ingest 경로를 그대로
73
73
  쓴다. 따라서 주소·서명·재시도·순번 규약은 하나이고, 사실과 attention의 저장·처리만 Twin 내부에서 갈린다.
74
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
+
75
101
  ## 왜 나뉘어 있나
76
102
 
77
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';
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`). */
@@ -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';
@@ -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 });
@@ -65,9 +65,11 @@ __export(index_exports, {
65
65
  OP_PARAM: () => OP_PARAM,
66
66
  ORDER_TERMINAL_STATUS: () => ORDER_TERMINAL_STATUS,
67
67
  PRIORITY_UNSET: () => PRIORITY_UNSET,
68
+ REFUSED_WITHOUT_CODE: () => REFUSED_WITHOUT_CODE,
68
69
  RESOURCE_KIND: () => RESOURCE_KIND,
69
70
  RETIRED_VOCABULARY: () => RETIRED_VOCABULARY,
70
71
  SCHEDULE_STATUS: () => SCHEDULE_STATUS,
72
+ SIGNATURE_REJECTED_REASONS: () => SIGNATURE_REJECTED_REASONS,
71
73
  TWIN_AXES: () => TWIN_AXES,
72
74
  TWIN_PROPERTIES: () => TWIN_PROPERTIES,
73
75
  TWIN_RELATIONS: () => TWIN_RELATIONS,
@@ -75,6 +77,7 @@ __export(index_exports, {
75
77
  UTC_OFFSET: () => UTC_OFFSET,
76
78
  VOCABULARY_EXCEPTIONS: () => VOCABULARY_EXCEPTIONS,
77
79
  VOCABULARY_TYPE: () => VOCABULARY_TYPE,
80
+ WEBHOOK_DELIVERY_CODES: () => WEBHOOK_DELIVERY_CODES,
78
81
  WEBHOOK_LANE: () => WEBHOOK_LANE,
79
82
  WEBHOOK_SECRET_BASE: () => WEBHOOK_SECRET_BASE,
80
83
  WEBHOOK_SECRET_NAME_SHAPE: () => WEBHOOK_SECRET_NAME_SHAPE,
@@ -98,6 +101,7 @@ __export(index_exports, {
98
101
  axisInfo: () => axisInfo,
99
102
  axisSource: () => axisSource,
100
103
  bizTransactionUri: () => bizTransactionUri,
104
+ canPreview: () => canPreview,
101
105
  capabilitiesForType: () => capabilitiesForType,
102
106
  capabilityOf: () => capabilityOf,
103
107
  checkSequence: () => checkSequence,
@@ -165,6 +169,7 @@ __export(index_exports, {
165
169
  isPlannedStopStatus: () => isPlannedStopStatus,
166
170
  isResourceKind: () => isResourceKind,
167
171
  isTransformationRecord: () => isTransformationRecord,
172
+ isWebhookDeliveryCode: () => isWebhookDeliveryCode,
168
173
  isWebhookSecretSegment: () => isWebhookSecretSegment,
169
174
  isoDurationHours: () => isoDurationHours,
170
175
  itemKeyOf: () => itemKeyOf,
@@ -224,10 +229,12 @@ __export(index_exports, {
224
229
  testPassedAt: () => testPassedAt,
225
230
  transactionEvent: () => transactionEvent,
226
231
  transformationEvent: () => transformationEvent,
232
+ unavailableVerdict: () => unavailableVerdict,
227
233
  validateDomainDefinition: () => validateDomainDefinition,
228
234
  validateEpcisEvent: () => validateEpcisEvent,
229
235
  validatePerformance: () => validatePerformance,
230
236
  validateScenario: () => validateScenario,
237
+ verdictOfAck: () => verdictOfAck,
231
238
  webhookLaneOf: () => webhookLaneOf,
232
239
  webhookSecretCandidates: () => webhookSecretCandidates,
233
240
  webhookSecretEnvName: () => webhookSecretEnvName,
@@ -902,6 +909,22 @@ var OP_EVENT = {
902
909
  * 이것은 **결정**이다. 누가·언제·왜 그렇게 정했는지의 자리는 그쪽에 없다.
903
910
  */
904
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",
905
928
  /**
906
929
  * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
907
930
  *
@@ -1293,8 +1316,14 @@ var MES_BIZSTEP = {
1293
1316
  // 원자재 수령
1294
1317
  producing: "urn:epcglobal:cbv:bizstep:commissioning",
1295
1318
  // 생산(제품 최초 생성)
1296
- storing: "urn:epcglobal:cbv:bizstep:storing"
1319
+ storing: "urn:epcglobal:cbv:bizstep:storing",
1297
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"
1298
1327
  };
1299
1328
  var BTT_PRODORDER = "urn:epcglobal:cbv:btt:prodorder";
1300
1329
  function sgtinUri(companyPrefix, itemRef, serial) {
@@ -3681,6 +3710,21 @@ var SPECS = {
3681
3710
  },
3682
3711
  enums: { decision: DISPOSITION_DECISION }
3683
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
+ },
3684
3728
  test: {
3685
3729
  eventType: OP_EVENT.test,
3686
3730
  match: ["testableObjectId"],
@@ -4045,6 +4089,51 @@ function retiredVocabularyIn(line) {
4045
4089
  return hits;
4046
4090
  }
4047
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
+
4048
4137
  // src/webhook.ts
4049
4138
  var WEBHOOK_STATUS = {
4050
4139
  ok: 200,
@@ -4139,13 +4228,13 @@ function webhookSecretEnvName(base, domain, peer) {
4139
4228
  return [base, ...segments.map((s) => s.toUpperCase().replace(/-/g, "_"))].join("__");
4140
4229
  }
4141
4230
  function webhookSecretCandidates(lookup) {
4142
- const { purpose, domain, peer, legacyBases = [], allowSinglePeerFallback = false } = lookup;
4231
+ const { purpose, domain, peer, legacyBases = [] } = lookup;
4143
4232
  const base = WEBHOOK_SECRET_BASE[purpose];
4144
4233
  const out = [
4145
4234
  { name: webhookSecretEnvName(base, domain, peer), pins: "peer", compatibility: false }
4146
4235
  ];
4147
- if (purpose === "verify" && !allowSinglePeerFallback) return out;
4148
- 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 });
4149
4238
  for (const legacy of legacyBases) {
4150
4239
  out.push({ name: webhookSecretEnvName(legacy, domain), pins: "domain", compatibility: true });
4151
4240
  out.push({ name: legacy, pins: "installation", compatibility: true });
@@ -4536,9 +4625,11 @@ function nonEmpty(value) {
4536
4625
  OP_PARAM,
4537
4626
  ORDER_TERMINAL_STATUS,
4538
4627
  PRIORITY_UNSET,
4628
+ REFUSED_WITHOUT_CODE,
4539
4629
  RESOURCE_KIND,
4540
4630
  RETIRED_VOCABULARY,
4541
4631
  SCHEDULE_STATUS,
4632
+ SIGNATURE_REJECTED_REASONS,
4542
4633
  TWIN_AXES,
4543
4634
  TWIN_PROPERTIES,
4544
4635
  TWIN_RELATIONS,
@@ -4546,6 +4637,7 @@ function nonEmpty(value) {
4546
4637
  UTC_OFFSET,
4547
4638
  VOCABULARY_EXCEPTIONS,
4548
4639
  VOCABULARY_TYPE,
4640
+ WEBHOOK_DELIVERY_CODES,
4549
4641
  WEBHOOK_LANE,
4550
4642
  WEBHOOK_SECRET_BASE,
4551
4643
  WEBHOOK_SECRET_NAME_SHAPE,
@@ -4569,6 +4661,7 @@ function nonEmpty(value) {
4569
4661
  axisInfo,
4570
4662
  axisSource,
4571
4663
  bizTransactionUri,
4664
+ canPreview,
4572
4665
  capabilitiesForType,
4573
4666
  capabilityOf,
4574
4667
  checkSequence,
@@ -4636,6 +4729,7 @@ function nonEmpty(value) {
4636
4729
  isPlannedStopStatus,
4637
4730
  isResourceKind,
4638
4731
  isTransformationRecord,
4732
+ isWebhookDeliveryCode,
4639
4733
  isWebhookSecretSegment,
4640
4734
  isoDurationHours,
4641
4735
  itemKeyOf,
@@ -4695,10 +4789,12 @@ function nonEmpty(value) {
4695
4789
  testPassedAt,
4696
4790
  transactionEvent,
4697
4791
  transformationEvent,
4792
+ unavailableVerdict,
4698
4793
  validateDomainDefinition,
4699
4794
  validateEpcisEvent,
4700
4795
  validatePerformance,
4701
4796
  validateScenario,
4797
+ verdictOfAck,
4702
4798
  webhookLaneOf,
4703
4799
  webhookSecretCandidates,
4704
4800
  webhookSecretEnvName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/ops-contract",
3
- "version": "0.9.19",
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",