# @things-factory/headless-twin

operato-twin 커널(WMS/YMS/MES, zero-dep·결정적)을 things-factory 모듈로 호스팅한다.
State 를 GraphQL subscription 으로 스트리밍하고, 이벤트를 append-only 로 영속하며, 워커 tick 으로 시뮬을 진행하고, command/scenario 를 mutation 으로 받는다.

- 커널: `@operato/twin-kernel` **^0.2.3**
- 상태: **159 tests green** (`node --test test/*.test.ts`)

설계 SoT: `operato-twin/design/integration/things-factory-host.md`. Kernel↔Host 경계(§6): **성능·의존성·스케일·기하 = host/scene, 정합성·결정성·zero-dep = kernel**.

## 구조

### engine — 커널을 감싸는 것들
- `twin-engine.ts` — 인스턴스 맵 + `subscribe→pubsub` 브리지 + 워커 `tick` + append-only 영속. 마스터에서 공정 명세를 실어 주고(`applyOperations`) 추정기를 주입한다(`installEstimators`).
- `canonical-ingest.ts` — 실세계 피드 → 정규 EPCIS 한 문(`ingestLive`). **저널 기록은 여기 한 곳뿐이다.**
- `measured-estimator.ts` · `travel-estimator.ts` — 소요시간 추정기. 실측(저널 폴드, TTL 캐시) → 거리×속도(좌표+설비 속도). 셋 중 하나라도 모르면 **답하지 않는다**(커널이 명세·상수로 떨어진다).
- `spec-coverage.ts` · `model-basis.ts` — 예측이 무엇에 근거했는지(`relative`/`partial`/`absolute-capable`/`calibrated`)와 근거가 바뀌었는지(보정 신선도).
- `kpi-fold.ts` · `kpi-query.ts` · `oee-accumulator.ts` — 저널을 접어 성과를 낸다(순수 폴드, 사실만).
- `entity-delta.ts` · `live-attentions.ts` · `warm-start.ts` — 화면용 엔티티 델타, 라이브 주목 신호, 재기동 복구.
- `service/reference/attention-lane.ts` — webhook의 `ATTENTIONS` lane을 제품이 등록한 inbox handler로 전달한다. 커널은 운반만 하며 attention을 사실 저널에 섞지 않는다.

### service — 바깥으로 낸 면
`twin-state`(구독) · `twin-event`(저널) · `twin-control`(command/scenario) · `twin-instance` · `twin-lifecycle` · `twin-forecast` · `twin-metrics` · `twin-journal` · `twin-attention` · `twin-space` · `reference`(마스터·커넥터).

## 라이브도 커널이다

예전에는 시뮬 인스턴스만 커널을 가졌고, 라이브(미러)는 투영기만 세웠다. 그래서 미러 트윈에 예측을 물으면 답할 것이 없어 **요청마다 일회용 커널을 세우는 우회**가 붙어 있었다.

지금은 `startLive` 도 **관측 모드 커널**을 세운다. 규칙은 커널 안에 한 벌뿐이고(`ObservedReducer`), 호스트는 그 커널에 이벤트를 흘릴 뿐이다. 결과:

- 미러 트윈이 우회 없이 예측한다 — 인스턴스가 곧 커널이므로 찾을 것이 이미 거기 있다.
- 명세·추정기가 라이브에도 붙어 **예측 자격이 트윈 종류를 가리지 않는다.**
- 저널 중복 기록은 **구조적으로 불가능**하다 — 기록은 `ingestLive` 한 곳뿐이고, `startLive` 는 커널 방출을 구독하지 않는다.

## Webhook: 사실과 AI 분석 입력의 분리

연결은 계속 하나의 주소 `POST /domain/{domain}/twin/hook/{source}/{instanceId}`를 쓴다. `lane`을 생략하면
기존과 동일한 `FACTS`이고 canonical ingest로 들어간다. `lane: 'ATTENTIONS'`는 같은 인증·서명·순번 검증을
거친 뒤 `deliverAttention`으로 보낸다.

```
connector outbox
  └─ webhook (FACTS | ATTENTIONS)
       ├─ FACTS      → canonical ingest → journal → live kernel
       └─ ATTENTIONS → product inbox    → product-owned persistence / analysis
```

`headless-twin`은 attention의 업무 의미·DB 스키마·AI 판단을 소유하지 않는다. 앱이
`registerAttentionHandler`로 수신기를 등록해야 하며, 등록되지 않은 ATTENTIONS 요청은 성공한 것처럼
처리하지 않고 실패한다. 현재 operato-twin 앱은 이를 별도 inbox 테이블에 저장한다. 저장된 외부 attention을
어떤 Twin 분석에 어떤 방식으로 결합할지는 앱의 명시적 분석 정책이며, 커널이 자동으로 인과관계를 만들지 않는다.

## 커널 연동
- 커널은 ESM+CJS 이중배포. 이 모듈(CommonJS)은 **CJS 번들을 `require`** 로 로드(require(ESM) 회피). 타입은 `import type` 로 `.d.ts` 참조. 양쪽 다 패키지명 `@operato/twin-kernel`.
- **0.x semver 함정**: `^0.2.0` 은 0.2.1 을 집지 않는다. 커널을 올릴 때 **루트와 소비 패키지 둘 다** 범위를 올려야 하고, 안 그러면 호이스팅된 옛 사본이 이긴다(중첩 `node_modules` 잔여 사본도 확인).

## 검증
1. `yarn install` → `npm run build`(tsc → `dist-server`).
2. 앱 패키지가 `@things-factory/headless-twin` 를 의존해야 `orderedModuleNames` 가 로드한다(schema/entity 병합).
3. `subscription { twinState(instanceId) { kind revision payload } }` → snapshot 먼저, 이후 delta/clock.
4. `mutation controlTwinScenario(instanceId, "start")` → delta 흐름 + `TwinEvent` 행 적재.
5. 단위 테스트는 **가벼운 import 만** 한다 — `twin-engine.ts` 는 DB·shell 을 잡으므로 단위 테스트로 닿지 않는다. 그 층은 실행 중인 앱에서 확인한다.

## 스케일 하드닝 (향후, §host 경계)
- 워커 tick 은 메인 루프 `setInterval`. 긴 시뮬·다수 인스턴스는 **worker thread 오프-루프**.
- ingest 검증 throughput 필요 시 **host 에서 Ajv 컴파일**(커널 zero-dep 검증기는 정본 유지).
- 재부팅 복구: `TwinEvent`(DB) → `warm-start` 로 상태 재구성.
