@things-factory/headless-twin 10.1.37 → 10.1.39

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.
Files changed (38) hide show
  1. package/dist-server/engine/twin-engine.js +3 -2
  2. package/dist-server/engine/twin-engine.js.map +1 -1
  3. package/dist-server/engine/twin-state-channel.d.ts +14 -0
  4. package/dist-server/engine/twin-state-channel.js +31 -0
  5. package/dist-server/engine/twin-state-channel.js.map +1 -0
  6. package/dist-server/service/reference/reference-progress-channel.d.ts +14 -0
  7. package/dist-server/service/reference/reference-progress-channel.js +24 -0
  8. package/dist-server/service/reference/reference-progress-channel.js.map +1 -0
  9. package/dist-server/service/reference/reference-progress-subscription.d.ts +2 -3
  10. package/dist-server/service/reference/reference-progress-subscription.js +14 -6
  11. package/dist-server/service/reference/reference-progress-subscription.js.map +1 -1
  12. package/dist-server/service/reference/reference-resolver.js +3 -6
  13. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  14. package/dist-server/service/twin-forecast/gap-analytics.d.ts +10 -1
  15. package/dist-server/service/twin-forecast/gap-analytics.js +15 -6
  16. package/dist-server/service/twin-forecast/gap-analytics.js.map +1 -1
  17. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  18. package/dist-server/service/twin-model/twin-model-item-query.js +6 -1
  19. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  20. package/dist-server/service/twin-state/twin-state-subscription.d.ts +2 -3
  21. package/dist-server/service/twin-state/twin-state-subscription.js +20 -13
  22. package/dist-server/service/twin-state/twin-state-subscription.js.map +1 -1
  23. package/package.json +8 -8
  24. package/server/engine/twin-engine.ts +6 -5
  25. package/server/engine/twin-state-channel.ts +39 -0
  26. package/server/service/reference/reference-progress-channel.ts +26 -0
  27. package/server/service/reference/reference-progress-subscription.ts +15 -10
  28. package/server/service/reference/reference-resolver.ts +6 -6
  29. package/server/service/twin-forecast/gap-analytics.ts +15 -6
  30. package/server/service/twin-forecast/twin-forecast-query.ts +1 -1
  31. package/server/service/twin-model/twin-model-item-query.ts +6 -1
  32. package/server/service/twin-state/twin-state-subscription.ts +22 -19
  33. package/test/boot-resume.test.ts +4 -1
  34. package/test/gap-analytics.test.ts +25 -1
  35. package/test/open-doors.test.ts +197 -0
  36. package/test/twin-model-item-db.test.ts +25 -0
  37. package/test/twin-state-channel.test.ts +111 -0
  38. package/tsconfig.tsbuildinfo +1 -1
@@ -24,6 +24,7 @@ import { itemResolverFor } from '../service/twin-subject/item-resolver.js'
24
24
  import { planLiveContinuity, planWarmStart, unwrapState } from './warm-start.js'
25
25
  import { applyDeclarationLayers } from './local-declarations.js'
26
26
  import { isOfDomain, parseRuntimeKey, runtimeKey } from './runtime-key.js'
27
+ import { TWIN_STATE_TOPIC, twinStatePayload } from './twin-state-channel.js'
27
28
  import { routeCommand } from './command-routing.js'
28
29
  import { TwinInstance } from '../service/twin-instance/twin-instance.js'
29
30
  /* 자극의 집은 원본이다(ADR-0029) — 그 행을 읽고 쓴다. */
@@ -1704,8 +1705,8 @@ export class TwinEngine {
1704
1705
  /* State 채널: runtime.subscribe(snapshot→delta→clock) → pubsub 브로드캐스팅(구독 리졸버가 instanceId 필터). */
1705
1706
  const sub = runtime.subscribe((msg: SubscriptionMessage) => {
1706
1707
  this.publishGuarded(
1707
- 'twin-state',
1708
- { twinState: { instanceId: id, kind: msg.kind, revision: (msg as any).revision, payload: msg } },
1708
+ TWIN_STATE_TOPIC,
1709
+ twinStatePayload(domainId, { instanceId: id, kind: msg.kind, revision: (msg as any).revision, payload: msg }),
1709
1710
  `twin-state:${id}`
1710
1711
  )
1711
1712
  /* 영속 + 라이브 바인딩 브리지: delta 를 저널 버퍼에 담고 + 엔티티별 data(tag:) publish → 보드 컴포넌트 라이브. */
@@ -2353,8 +2354,8 @@ export class TwinEngine {
2353
2354
  // scheduleRefresh(250ms 디바운스)→pollLive 로 되물어봄. 스냅샷(O(state))은 보는 사람이 물을 때만 1회 계산.
2354
2355
  // (여기서 payload 로 스냅샷을 실으면 아무도 안 읽는데 tick 마다 통째로 떠서 순수 낭비 — 신호만 보낸다.)
2355
2356
  this.publishGuarded(
2356
- 'twin-state',
2357
- { twinState: { instanceId: inst.id, kind: 'delta', revision: inst.revision ?? 0 } },
2357
+ TWIN_STATE_TOPIC,
2358
+ twinStatePayload(inst.domainId, { instanceId: inst.id, kind: 'delta', revision: inst.revision ?? 0 }),
2358
2359
  `twin-state:${inst.id}`
2359
2360
  )
2360
2361
  }
@@ -3641,7 +3642,7 @@ export class TwinEngine {
3641
3642
  * 그래도 이 가드를 남기는 이유는 둘이다. `publish` 가 직접 던지는 경우(직렬화 실패 등)를 잡고,
3642
3643
  * 못 보낸 태그의 시그니처를 되돌린다 — 남겨 두면 그 태그가 오류 없이 영원히 낡은 값을 보인다.
3643
3644
  */
3644
- private static publishGuarded(channel: 'data' | 'twin-state', payload: any, what: string): boolean {
3645
+ private static publishGuarded(channel: 'data' | typeof TWIN_STATE_TOPIC, payload: any, what: string): boolean {
3645
3646
  try {
3646
3647
  pubsub.publish(channel as any, payload)
3647
3648
  return true
@@ -0,0 +1,39 @@
1
+ /*
2
+ * The `twin-state` channel — **a message says whose twin it is, and the filter asks.** Pure.
3
+ *
4
+ * ── What went wrong (2026-09-24) ─────────────────────────────────────────────
5
+ * A twin is `(domain, instanceId)` (`runtime-key.ts`), and the same instanceId in two tenants is the
6
+ * expected case — reference source names such as `sap-ewm-1710` are vocabulary tenants share. The
7
+ * channel had not caught up: publishers sent `{ twinState: { instanceId, … } }` with no domain, and the
8
+ * subscription filtered on instanceId alone. Ownership was checked once, when subscribing. So a tenant
9
+ * watching its own `sap-ewm-1710` also received the other tenant's deltas, and the snapshot sent to a
10
+ * new subscriber went to every subscriber of that id in every tenant.
11
+ *
12
+ * The rule is shell's `data` channel's (the message carries its domain, the filter compares it with the
13
+ * key), compared by id because the twin's key already is. Every sender builds its payload here, and
14
+ * `twin-state-channel.test.ts` fails if a sender builds one anywhere else.
15
+ */
16
+
17
+ export const TWIN_STATE_TOPIC = 'twin-state'
18
+
19
+ export interface TwinStateMessageShape {
20
+ instanceId: string
21
+ kind: string
22
+ revision?: number
23
+ payload?: unknown
24
+ }
25
+
26
+ export interface TwinStatePayload {
27
+ domainId: string
28
+ twinState: TwinStateMessageShape
29
+ }
30
+
31
+ export function twinStatePayload(domainId: string, twinState: TwinStateMessageShape): TwinStatePayload {
32
+ if (!domainId) throw new Error('twinStatePayload: domainId is required — a twin state message always belongs to a tenant')
33
+ return { domainId, twinState }
34
+ }
35
+
36
+ /** Passes only this tenant's messages about this twin. */
37
+ export function twinStateFilter(domainId: string, instanceId: string) {
38
+ return (p: TwinStatePayload | undefined): boolean => !!p && p.domainId === domainId && p.twinState?.instanceId === instanceId
39
+ }
@@ -0,0 +1,26 @@
1
+ /*
2
+ * The `twin-reference-progress` channel — **progress says whose import it is, and the filter asks.** Pure.
3
+ *
4
+ * The subscription filtered on `source` alone and checked only that the caller had some domain, so
5
+ * anyone who knew a source name — and names like `sap-ewm-1710` are shared between tenants — received
6
+ * another tenant's import progress, including its failure text. Same fix and same rule as
7
+ * `engine/twin-state-channel.ts`.
8
+ */
9
+
10
+ export const REFERENCE_PROGRESS_TOPIC = 'twin-reference-progress'
11
+
12
+ export interface ReferenceProgressPayload<M extends { source: string } = { source: string }> {
13
+ domainId: string
14
+ twinReferenceProgress: M
15
+ }
16
+
17
+ export function referenceProgressPayload<M extends { source: string }>(domainId: string, message: M): ReferenceProgressPayload<M> {
18
+ if (!domainId) throw new Error('referenceProgressPayload: domainId is required — import progress always belongs to a tenant')
19
+ return { domainId, twinReferenceProgress: message }
20
+ }
21
+
22
+ /** Passes only this tenant's progress for this source. */
23
+ export function referenceProgressFilter(domainId: string, source: string) {
24
+ return (p: ReferenceProgressPayload | undefined): boolean =>
25
+ !!p && p.domainId === domainId && p.twinReferenceProgress?.source === source
26
+ }
@@ -8,10 +8,12 @@
8
8
  * 낱말이 표준이므로 화면은 **단계마다 번역 하나**만 두면 모든 커넥터를 보여 준다.
9
9
  */
10
10
  import { Arg, Field, Int, ObjectType, Resolver, Root, Subscription } from 'type-graphql'
11
- import { pubsub } from '@things-factory/shell'
11
+ import { assertDomainSubscribeAllowed, getRepository, pubsub } from '@things-factory/shell'
12
12
  import { filter, pipe } from 'graphql-yoga'
13
13
 
14
14
  import { REFERENCE_STEPS } from './reference-progress.js'
15
+ import { REFERENCE_PROGRESS_TOPIC, referenceProgressFilter, type ReferenceProgressPayload } from './reference-progress-channel.js'
16
+ import { TwinReference } from './twin-reference.js'
15
17
 
16
18
  @ObjectType({ description: 'One step of progress while a reference is being imported into twins.' })
17
19
  export class TwinReferenceProgressMessage {
@@ -52,20 +54,23 @@ export class TwinReferenceProgressMessage {
52
54
  @Resolver()
53
55
  export class TwinReferenceProgressSubscription {
54
56
  @Subscription(returns => TwinReferenceProgressMessage, {
55
- subscribe: ({ args, context }) => {
57
+ /*
58
+ * Tenancy is asked twice (2026-09-24): the source must be one of the caller's references, and every
59
+ * message must be the caller's domain's. It used to filter on `source` alone after checking only that
60
+ * the caller had a domain — and source names are shared between tenants.
61
+ */
62
+ subscribe: async ({ args, context }) => {
56
63
  const { source } = args
57
- const domainId = (context as any)?.state?.domain?.id
58
- /* 테넌트 격리 — 도메인이 없으면 빈 구독이다(다른 테넌트의 진행을 흘리지 않는다). */
59
- if (!domainId) return pipe(pubsub.subscribe('twin-reference-progress'), filter(() => false))
60
- return pipe(
61
- pubsub.subscribe('twin-reference-progress'),
62
- filter((p: { twinReferenceProgress: TwinReferenceProgressMessage }) => p.twinReferenceProgress.source === source)
63
- )
64
+ const { domain, user } = (context as any)?.state ?? {}
65
+ await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))
66
+ const owned = await getRepository(TwinReference).findOne({ where: { domain: { id: domain.id }, source } })
67
+ if (!owned) throw new Error(`reference not found in this tenant: ${source}`)
68
+ return pipe(pubsub.subscribe(REFERENCE_PROGRESS_TOPIC), filter(referenceProgressFilter(domain.id, source)))
64
69
  },
65
70
  description: 'Progress while importing a reference — standard step names, so one translation per step covers every connector.'
66
71
  })
67
72
  twinReferenceProgress(
68
- @Root() payload: { twinReferenceProgress: TwinReferenceProgressMessage },
73
+ @Root() payload: ReferenceProgressPayload<TwinReferenceProgressMessage>,
69
74
  @Arg('source') source: string
70
75
  ): TwinReferenceProgressMessage {
71
76
  return payload.twinReferenceProgress
@@ -1,5 +1,6 @@
1
1
  import { pubsub } from '@things-factory/shell'
2
2
  import { HOST_STEPS, type ReferenceProgress } from './reference-progress.js'
3
+ import { REFERENCE_PROGRESS_TOPIC, referenceProgressPayload } from './reference-progress-channel.js'
3
4
  import { assessMaster } from './reference-assessment.js'
4
5
  import { Arg, Ctx, Int, Mutation, Query, Resolver, Directive } from 'type-graphql'
5
6
 
@@ -295,9 +296,10 @@ export class TwinReferenceResolver {
295
296
  if (!progressSource) return
296
297
  lastDone = p.done
297
298
  lastStep = p.step
298
- pubsub.publish('twin-reference-progress', {
299
- twinReferenceProgress: { ...p, siteId, siteIndex: 0, siteCount: 1, total: p.total ?? stepTotal, source: progressSource }
300
- })
299
+ pubsub.publish(
300
+ REFERENCE_PROGRESS_TOPIC,
301
+ referenceProgressPayload(domainId, { ...p, siteId, siteIndex: 0, siteCount: 1, total: p.total ?? stepTotal, source: progressSource })
302
+ )
301
303
  }
302
304
 
303
305
  try {
@@ -835,9 +837,7 @@ export class TwinReferenceResolver {
835
837
  const declared = adapter.masterSteps?.length
836
838
  const stepTotal = declared === undefined ? undefined : declared + HOST_STEPS.length
837
839
  const emit = (p: Omit<ReferenceProgress, 'siteCount'>) =>
838
- pubsub.publish('twin-reference-progress', {
839
- twinReferenceProgress: { ...p, siteCount: sites.length, total: p.total ?? stepTotal, source }
840
- })
840
+ pubsub.publish(REFERENCE_PROGRESS_TOPIC, referenceProgressPayload(domainId, { ...p, siteCount: sites.length, total: p.total ?? stepTotal, source }))
841
841
 
842
842
  /*
843
843
  * **읽은 뒤의 총평을 사이트마다 남긴다** (2026-08-22).
@@ -99,19 +99,27 @@ export function computeSpc(errors: number[]): any {
99
99
  return { cl, ucl, lcl, sigma, n, ooc, instability, stable: !instability, biased, biasSign: cl >= 0 ? 'under' : 'over', trackingSignal }
100
100
  }
101
101
 
102
- /** 집계 지표 — 정확도(1−WMAPE)·편향(MPE)·예측구간 적중·FVA(예측 가치). */
102
+ /**
103
+ * 집계 지표 — 정확도(1−WMAPE)·편향(MPE)·예측구간 적중·FVA(예측 가치).
104
+ *
105
+ * A percentage whose denominator is zero is **not measured**, and says so with `null` (2026-09-24).
106
+ * When every actual in the window is 0 — the line stood still, or the metric was not observed — WMAPE
107
+ * is undefined. It used to become 0, so the card read "100% accurate" beside "0 of 6 in band, mean
108
+ * error −20". Likewise `fvaPct` when the naive error is 0: 0 read as "the forecast adds nothing".
109
+ * The absolute measures (mean error, in-band count, fva) are real in that window and stay. The two
110
+ * neighbours below already refuse on thin evidence (`computeSpc` · `learnedCalibration`).
111
+ */
103
112
  export function gapMetrics(pts: GapPoint[]): any {
104
113
  const sumAbsErr = pts.reduce((s, x) => s + Math.abs(x.error), 0)
105
114
  const sumActual = pts.reduce((s, x) => s + Math.abs(x.actual), 0)
106
- const wmape = sumActual ? sumAbsErr / sumActual : 0
107
- const accuracy = Math.max(0, Math.round((1 - wmape) * 100))
115
+ const accuracy = sumActual > 0 ? Math.max(0, Math.round((1 - sumAbsErr / sumActual) * 100)) : null
108
116
  const mpe = pts.length ? pts.reduce((s, x) => s + x.error, 0) / pts.length : 0
109
117
  const coverage = pts.length ? pts.filter(x => x.inBand).length / pts.length : 0
110
118
  // 예측 가치(FVA) — "현재값 그대로"(naive) 대비 오차 감소. fva=감소 총량, fvaPct=naive 오차를 몇 % 줄였나(가치의 크기).
111
119
  const sumNaive = pts.reduce((s, x) => s + x.naiveAbsError, 0)
112
120
  const sumModel = pts.reduce((s, x) => s + x.modelAbsError, 0)
113
121
  const fva = sumNaive - sumModel
114
- const fvaPct = sumNaive > 0 ? Math.round((1 - sumModel / sumNaive) * 100) : 0
122
+ const fvaPct = sumNaive > 0 ? Math.round((1 - sumModel / sumNaive) * 100) : null
115
123
  return { accuracy, mpe, coverage, inBandCount: pts.filter(x => x.inBand).length, total: pts.length, fva, fvaPct }
116
124
  }
117
125
 
@@ -123,8 +131,9 @@ export function learnedCalibration(pts: GapPoint[], metrics: any): any {
123
131
  const corrected = errs.map(e => e - biasShift) // 편향 제거 후 잔차
124
132
  const absC = corrected.map(Math.abs).sort((a, b) => a - b)
125
133
  const halfWidth = absC[Math.floor(absC.length * 0.8)] ?? (absC[absC.length - 1] || 1) // 80% 커버리지 밴드
126
- const sumActual = pts.reduce((s, p) => s + Math.abs(p.actual), 0) || 1
127
- const accAfter = Math.max(0, Math.round((1 - corrected.reduce((s, e) => s + Math.abs(e), 0) / sumActual) * 100))
134
+ /* The bias is learnable with every actual at 0; the accuracy before and after is not (see gapMetrics). */
135
+ const sumActual = pts.reduce((s, p) => s + Math.abs(p.actual), 0)
136
+ const accAfter = sumActual > 0 ? Math.max(0, Math.round((1 - corrected.reduce((s, e) => s + Math.abs(e), 0) / sumActual) * 100)) : null
128
137
  const covAfter = corrected.filter(e => Math.abs(e) <= halfWidth).length
129
138
  return { biasShift: Math.round(biasShift), halfWidth: Math.round(halfWidth), accBefore: metrics.accuracy, accAfter, covBefore: metrics.inBandCount, covAfter, n: pts.length }
130
139
  }
@@ -72,7 +72,7 @@ function metricFn(metric?: string): MetricFn | null {
72
72
  데모 트윈은 재부팅 시 저널 리셋이라 세션 스코프로 충분 — DB 영속(TwinForecastScore)은 후속. */
73
73
  const CALIBRATION = new Map<
74
74
  string,
75
- { biasShift: number; halfWidth: number; at: string; samples: number; accBefore: number; accAfter: number; basis?: ModelBasis }
75
+ { biasShift: number; halfWidth: number; at: string; samples: number; accBefore: number | null; accAfter: number | null; basis?: ModelBasis }
76
76
  >()
77
77
  const calKey = (d: string, i: string, m: string, h: number) => `${d}:${i}:${m}:${h}`
78
78
 
@@ -440,7 +440,12 @@ export class TwinModelItemQuery {
440
440
  links: { out: [], incoming: [] }, properties: propertiesOf(axis).map((pr: any) => ({ key: pr.key, label: pr.label, kind: pr.kind, uom: pr.uom ?? null, observedBy: pr.observedBy ?? null })),
441
441
  /* 어떤 속성이 **효과가 있나** — 등록부가 말한다(§property-effects). 없는 것은 표시만 된다. */
442
442
  propertyEffects: propertyEffectsOf(axis),
443
- names: {}, provenance: { basis: 'state' }
443
+ /*
444
+ * 출처는 이 답의 `basis` 와 같은 규칙이다(아래 읽은 갈래도 같다). 여기를 `state` 로 박아 두어서, 커널이 멈춰
445
+ * 저널로 되세우려던 답이 「지금 상태에서 읽었다」고 말했다(ADR-0075 결정 3 · 결함 넘김 2026-09-24).
446
+ * 아무것도 못 읽었다는 사실은 `observedAbsence` 가 말한다 — 출처를 비우지 않는다(`READ_BASIS` 주석).
447
+ */
448
+ names: {}, provenance: { basis: liveItem ? 'state' : 'journal' }
444
449
  }
445
450
  }
446
451
  const names = namesFor([{ id: itemId, fields: found }], nameIndex(inst.model ?? {}))
@@ -1,9 +1,10 @@
1
1
  import { filter, pipe } from 'graphql-yoga'
2
2
  import { Arg, Field, Int, ObjectType, Resolver, Root, Subscription } from 'type-graphql'
3
3
 
4
- import { pubsub, ScalarObject } from '@things-factory/shell'
4
+ import { assertDomainSubscribeAllowed, pubsub, ScalarObject } from '@things-factory/shell'
5
5
 
6
6
  import { TwinEngine } from '../../engine/index.js'
7
+ import { TWIN_STATE_TOPIC, twinStateFilter, twinStatePayload, type TwinStatePayload } from '../../engine/twin-state-channel.js'
7
8
 
8
9
  /* State 채널 메시지 — snapshot | delta | clock 멀티플렉싱(커널 SubscriptionMessage 대응). */
9
10
  @ObjectType({ description: 'Twin State channel message multiplexing snapshot | delta | clock.' })
@@ -23,37 +24,39 @@ export class TwinStateMessage {
23
24
 
24
25
  /*
25
26
  * 트윈 State 구독 — state-register/data-resolver 패턴 미러.
26
- * 구독 즉시 현재 snapshot 발행(process.nextTick) 후 delta/clock 스트림. instanceId 로 필터.
27
+ * 구독 즉시 현재 snapshot 발행(process.nextTick) 후 delta/clock 스트림.
28
+ *
29
+ * The tenant is asked while streaming, not only at subscribe time (2026-09-24). Ownership used to be
30
+ * checked once and the stream filtered on instanceId alone; the same id in two tenants is the normal
31
+ * case (`runtime-key.ts`), so other tenants' deltas came through, and the snapshot sent to a new
32
+ * subscriber reached every tenant subscribed to that id. Messages now carry their domain and the filter
33
+ * compares it (`engine/twin-state-channel.ts`). A twin the caller does not own is refused rather than
34
+ * given an empty stream — an empty stream cannot tell "nothing changed" from "you may not see this"
35
+ * (shell's `data` throws too).
27
36
  */
28
37
  @Resolver()
29
38
  export class TwinStateSubscription {
30
39
  @Subscription(returns => TwinStateMessage, {
31
- subscribe: ({ args, context }) => {
40
+ subscribe: async ({ args, context }) => {
32
41
  const { instanceId } = args
33
- const domainId = (context as any)?.state?.domain?.id
42
+ const { domain, user } = (context as any)?.state ?? {}
43
+ await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))
34
44
 
35
- /* 테넌트 격리 — 이 도메인 소유 인스턴스가 아니면 스냅샷도 스트림도 흘리지 않는다(빈 구독). */
36
- if (!domainId || !TwinEngine.owns(domainId, instanceId)) {
37
- return pipe(pubsub.subscribe('twin-state'), filter(() => false))
38
- }
45
+ /* One refusal for both cases, so the answer does not reveal whether another tenant has such a twin. */
46
+ if (!TwinEngine.owns(domain.id, instanceId)) throw new Error(`twin instance not running in this tenant: ${instanceId}`)
39
47
 
40
- /* 구독 순간 현재 상태를 먼저 발행(late 구독자도 snapshot→delta 순서 보장). */
48
+ /* Current state first (a late subscriber still sees snapshot → delta). It reaches this tenant's subscribers only. */
41
49
  process.nextTick(() => {
42
- const snap = TwinEngine.snapshot(domainId, instanceId)
50
+ const snap = TwinEngine.snapshot(domain.id, instanceId)
43
51
  if (snap) {
44
- pubsub.publish('twin-state', {
45
- twinState: { instanceId, kind: 'snapshot', revision: snap.revision, payload: snap }
46
- })
52
+ pubsub.publish(TWIN_STATE_TOPIC, twinStatePayload(domain.id, { instanceId, kind: 'snapshot', revision: snap.revision, payload: snap }))
47
53
  }
48
54
  })
49
55
 
50
- return pipe(
51
- pubsub.subscribe('twin-state'),
52
- filter((payload: { twinState: TwinStateMessage }) => payload.twinState.instanceId === instanceId)
53
- )
56
+ return pipe(pubsub.subscribe(TWIN_STATE_TOPIC), filter(twinStateFilter(domain.id, instanceId)))
54
57
  }
55
58
  })
56
- twinState(@Root() payload: { twinState: TwinStateMessage }, @Arg('instanceId') instanceId: string): TwinStateMessage {
57
- return payload.twinState
59
+ twinState(@Root() payload: TwinStatePayload, @Arg('instanceId') instanceId: string): TwinStateMessage {
60
+ return payload.twinState as TwinStateMessage
58
61
  }
59
62
  }
@@ -177,7 +177,10 @@ test('flush 가 시뮬도 본다 — 브로드캐스팅과 저널은 둘 다, st
177
177
  assert.ok(liveOnlyAt >= 0, 'state 채널을 라이브로 가르는 자리가 없다')
178
178
  assert.ok(flushAt < liveOnlyAt, '저널 flush 가 라이브 전용 구간 뒤로 밀리면 시뮬의 사실이 쌓인 채 남는다')
179
179
  /* state 채널은 시뮬이 자기 콜백에서 이미 보낸다 — 여기서 또 보내면 같은 신호가 두 번 간다. */
180
- assert.ok(body.indexOf("twinState: { instanceId: inst.id") > liveOnlyAt, 'state 채널 브로드캐스팅은 라이브 구간에 있어야 한다')
180
+ /* The payload is built by the channel since 2026-09-24 (it carries the domain); look for that call. */
181
+ const stateSendAt = body.indexOf('twinStatePayload(inst.domainId, { instanceId: inst.id')
182
+ assert.ok(stateSendAt >= 0, 'state 채널로 보내는 자리가 없다')
183
+ assert.ok(stateSendAt > liveOnlyAt, 'state 채널 브로드캐스팅은 라이브 구간에 있어야 한다')
181
184
  })
182
185
 
183
186
  test('구독자 하나가 호스트를 죽이지 못한다 — 그리고 못 보낸 것을 보낸 것으로 적지 않는다', () => {
@@ -5,7 +5,7 @@
5
5
  import { test } from 'node:test'
6
6
  import assert from 'node:assert/strict'
7
7
 
8
- import { computeSpc, gapMetrics, learnedCalibration, aggregatePoints } from '../dist-server/service/twin-forecast/gap-analytics.js'
8
+ import { computeSpc, gapMetrics, learnedCalibration, aggregatePoints } from '../server/service/twin-forecast/gap-analytics.ts'
9
9
 
10
10
  const pt = (error: number, actual = 100, inBand = true) => ({
11
11
  t: 0, p50: actual - error, min: 0, max: 0, actual, error, inBand,
@@ -52,6 +52,30 @@ test('learnedCalibration — biasShift=평균오차, 편향 제거로 정확도
52
52
  assert.equal(learn.accAfter, 100, '일정 편향을 완전 제거 → 잔차 0 → 100%')
53
53
  })
54
54
 
55
+ /*
56
+ * ★ Nothing to divide by is not 100% (2026-09-24). With every actual at 0 the card read
57
+ * "100% accurate" beside "0 of 6 in band, mean error −20" — the installed build gave exactly that.
58
+ */
59
+ test('gapMetrics — a window with no actual says the percentages are not measured, and keeps what is', () => {
60
+ const idle = Array.from({ length: 6 }, () => ({ t: 0, p50: 20, min: 15, max: 25, actual: 0, error: -20, inBand: false, naiveAbsError: 0, modelAbsError: 20 }))
61
+ const m = gapMetrics(idle)
62
+ assert.equal(m.accuracy, null, 'WMAPE has no denominator — not 100%')
63
+ assert.equal(m.fvaPct, null, 'the naive error is 0 — "adds nothing" would be invented')
64
+ /* Measured in that window, so they stay. */
65
+ assert.equal(m.mpe, -20)
66
+ assert.equal(m.inBandCount, 0)
67
+ assert.equal(m.total, 6)
68
+ assert.equal(m.fva, -120)
69
+ })
70
+
71
+ test('learnedCalibration — the bias is still learned with no actual; only the percentages are not', () => {
72
+ const idle = Array.from({ length: 4 }, () => pt(-20, 0, false))
73
+ const learn = learnedCalibration(idle, gapMetrics(idle))
74
+ assert.equal(learn.biasShift, -20, 'forecasting 20 over a standing line is a bias worth removing')
75
+ assert.equal(learn.accBefore, null)
76
+ assert.equal(learn.accAfter, null, 'no denominator was made up (it used to be `|| 1`)')
77
+ })
78
+
55
79
  /* 같은 격자 위의 점 — vantage 를 명시한다(예전 헬퍼는 t 를 늘 0 으로 두어 시간축을 전혀 시험하지 않았다). */
56
80
  const at = (t: number, error: number, actual = 100) => ({ ...pt(error, actual), t })
57
81
 
@@ -0,0 +1,197 @@
1
+ /*
2
+ * Which of headless-twin's doors are open, and why each one is allowed to be.
3
+ *
4
+ * ── The thing this catches ──────────────────────────────────────────────────
5
+ * `@privilege` is opt-in. A root field that declares nothing is recorded as open, and that is
6
+ * indistinguishable from a field somebody decided to open. The list below turns the absence into a
7
+ * statement: anything open that is not on it fails, and the reason has to be written first.
8
+ * Same measure as `auth-base/tests/open-doors.test.ts`.
9
+ *
10
+ * ── The decision the reads rest on ──────────────────────────────────────────
11
+ * ADR-0027: the twin gates **writes** only. Reading is the product's purpose, and the boundary for a
12
+ * read is tenant membership — so an ungated read is correct only if its body narrows to the caller's
13
+ * domain. Every entry below says how it does. (The one read that crosses tenants on purpose,
14
+ * `twinLoad`, is super-user gated and so is not here.) A door that answers from the installation
15
+ * rather than from any tenant's rows says so instead.
16
+ *
17
+ * ── Why this reads the build ────────────────────────────────────────────────
18
+ * The schema is built from `service/index`'s own `resolverClasses`, the list the server boots from.
19
+ * type-graphql needs decorator metadata to build it, and Node's type stripping (how the other tests
20
+ * here run the source) does not emit it. So this one reads `dist-server`. A stale build would miss a
21
+ * door added since, and pass — hence the freshness check before anything else.
22
+ */
23
+ import { test } from 'node:test'
24
+ import assert from 'node:assert/strict'
25
+ import { createRequire } from 'node:module'
26
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
27
+ import { dirname, join, relative } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+
30
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..')
31
+ const DIST = join(PKG, 'dist-server')
32
+
33
+ /* Resolver sources newer than their build: the schema below would not be the one served. */
34
+ function staleResolvers(): string[] {
35
+ const out: string[] = []
36
+ const walk = (dir: string) => {
37
+ for (const name of readdirSync(dir)) {
38
+ const p = join(dir, name)
39
+ if (statSync(p).isDirectory()) walk(p)
40
+ else if (p.endsWith('.ts') && /@(Query|Mutation|Subscription)\(/.test(readFileSync(p, 'utf8'))) {
41
+ const built = join(DIST, relative(join(PKG, 'server'), p)).replace(/\.ts$/, '.js')
42
+ if (!existsSync(built) || statSync(built).mtimeMs < statSync(p).mtimeMs) out.push(relative(PKG, p))
43
+ }
44
+ }
45
+ }
46
+ walk(join(PKG, 'server'))
47
+ return out
48
+ }
49
+
50
+ const INSTALLATION = 'answers from the installation, not from any tenant\'s rows'
51
+ const DOMAIN_ROWS = 'reads rows where domain = the caller\'s'
52
+ const DOMAIN_RUNTIME = 'reads the runtime keyed (caller\'s domain, instanceId)'
53
+
54
+ /** Open on purpose (ADR-0027), each with how it stays inside the caller's tenant. */
55
+ const OPEN_BY_DECISION: Record<'Query' | 'Subscription', Record<string, string>> = {
56
+ Query: {
57
+ /* Catalogues — what this build can do. */
58
+ referenceAdapterTypes: `${INSTALLATION}: registered connector type keys`,
59
+ referenceAdapters: `${INSTALLATION}: connector metadata and judged capabilities`,
60
+ twinDomainCatalog: `${INSTALLATION}: the kernel's location-type vocabulary`,
61
+ twinTargetMetrics: `${INSTALLATION}: the metric ids a target may name`,
62
+ twinTemplates: `${INSTALLATION}: registered twin templates and their knobs`,
63
+
64
+ /* Connections and references. */
65
+ discoverReferenceSites: `${DOMAIN_ROWS} (TwinReference by source), then asks that connection`,
66
+ referenceSources: `${DOMAIN_ROWS} (TwinInstance) and the caller's runtimes`,
67
+ twinConnection: `${DOMAIN_ROWS} (TwinReference, TwinInstance)`,
68
+ twinConnections: `${DOMAIN_ROWS} (TwinReference, TwinInstance)`,
69
+ twinReference: `${DOMAIN_ROWS} (TwinReference)`,
70
+ twinReferences: `${DOMAIN_ROWS} (TwinReference, TwinInstance)`,
71
+ twinReferenceReconciliation: `${DOMAIN_ROWS} (TwinReferenceReconciliation)`,
72
+ twinReferenceReconciliations: `${DOMAIN_ROWS} (TwinReferenceReconciliation)`,
73
+
74
+ /* Actuation — reading what was proposed and how it went. Acting is gated. */
75
+ twinActuationOpinion: 'loads the command by (caller\'s domain, id); refuses without a domain',
76
+ twinActuationOutcome: 'loads the command by (caller\'s domain, id); refuses without a domain',
77
+ twinActuationReadiness: 'loads the command by (caller\'s domain, id); refuses without a domain',
78
+ twinActuationRules: `${DOMAIN_ROWS} (TwinActuationRule list params with the caller's domain)`,
79
+ twinCommands: `${DOMAIN_ROWS} (TwinCommandRecord list params with the caller's domain)`,
80
+
81
+ /* Instances and lifecycle. */
82
+ twinAdoptionPath: `${DOMAIN_ROWS} after an ownership check`,
83
+ twinDeletionPreview: `${DOMAIN_ROWS} (TwinInstance, TwinEvent) — describes, deletes nothing`,
84
+ twinInstanceDetail: `${DOMAIN_ROWS} (TwinEngine.detail)`,
85
+ twinInstanceList: `${DOMAIN_ROWS} (TwinEngine.list)`,
86
+ twinInstances: `${DOMAIN_ROWS} (TwinEngine.list)`,
87
+ twinSourceCapability: `${DOMAIN_RUNTIME}; empty without a domain`,
88
+ twinSpaceInstanceIds: `${DOMAIN_ROWS} (TwinEngine.instanceIdsOfSpace)`,
89
+
90
+ /* Journal, history, time travel. */
91
+ twinAuditList: `${DOMAIN_ROWS} (TwinAuditEvent list params with the caller's domain)`,
92
+ twinCapacity: `${DOMAIN_RUNTIME} after an ownership check`,
93
+ twinEventList: `${DOMAIN_ROWS} (TwinEvent) for targets resolved inside the caller's domain`,
94
+ twinEvents: `${DOMAIN_ROWS} (TwinEvent)`,
95
+ twinKpi: `${DOMAIN_ROWS} after an ownership check`,
96
+ twinOperationsCapability: `${DOMAIN_RUNTIME} after an ownership check`,
97
+ twinReplay: `${DOMAIN_RUNTIME} or its journal (TwinEngine.recover)`,
98
+ twinStructures: `${DOMAIN_ROWS} (TwinStructure, TwinEvent) after an ownership check`,
99
+ twinSubjectHistory: `${DOMAIN_ROWS} (TwinSubjectEvent)`,
100
+ twinTimeRange: `${DOMAIN_ROWS} (TwinInstance, TwinEvent)`,
101
+
102
+ /* Model and entity views. */
103
+ spaceDeclarations: `${DOMAIN_ROWS} (TwinSpace, TwinInstance) and the caller's runtimes`,
104
+ twinDeclarations: `${DOMAIN_ROWS} (TwinInstance, TwinSpace) and the caller's runtime`,
105
+ twinItemLineage: `${DOMAIN_ROWS} (TwinInstance, TwinEvent)`,
106
+ twinModelEntities: `${DOMAIN_ROWS} (TwinInstance and the entity tables) or the caller's runtime`,
107
+ twinModelItem: `${DOMAIN_ROWS} (TwinInstance and the entity tables) or the caller's runtime`,
108
+ twinModelSummary: `${DOMAIN_ROWS} (TwinInstance, TwinReference, TwinEvent) or the caller's runtime`,
109
+ twinModelTree: `${DOMAIN_ROWS} (TwinInstance)`,
110
+
111
+ /* Spaces and targets. */
112
+ twinAreas: `${DOMAIN_ROWS} (TwinArea)`,
113
+ twinSpace: `${DOMAIN_ROWS} (TwinSpace, representations, areas)`,
114
+ twinSpaceIntegrity: `${DOMAIN_ROWS} (every space table)`,
115
+ twinSpaces: `${DOMAIN_ROWS} (TwinSpace, representations, TwinInstance)`,
116
+ twinTargets: `${DOMAIN_ROWS} (TwinTarget)`,
117
+
118
+ /* Live health and forecasting — computed from the caller's runtime. */
119
+ twinAttentionDigest: `${DOMAIN_RUNTIME} after an ownership check`,
120
+ twinAttentions: `${DOMAIN_RUNTIME} after an ownership check`,
121
+ twinForecast: `${DOMAIN_RUNTIME} (forks the caller's kernel)`,
122
+ twinForecastGapTrend: `${DOMAIN_RUNTIME} for the caller's co-located twins`,
123
+ twinIngestHealth: `${DOMAIN_RUNTIME} after an ownership check; raw samples need more`,
124
+ twinIngestHistory: `${DOMAIN_ROWS} (TwinIngestWindow) and the caller's runtime`,
125
+ twinMetrics: `${DOMAIN_RUNTIME} after an ownership check`,
126
+ twinMetricsAll: `${DOMAIN_RUNTIME} for every runtime of the caller's domain`
127
+ },
128
+ Subscription: {
129
+ twinState: 'the caller must own the instance to subscribe (ADR-0027 read)',
130
+ twinReferenceProgress: 'import progress for one source (ADR-0027 read)'
131
+ }
132
+ }
133
+
134
+ const req = createRequire(import.meta.url)
135
+ let schemaPromise: Promise<any> | undefined
136
+
137
+ function schema(): Promise<any> {
138
+ const stale = staleResolvers()
139
+ if (stale.length) {
140
+ throw new Error(
141
+ `dist-server is older than ${stale.length} resolver source(s), so the schema below is not the one served:\n ` +
142
+ stale.join('\n ') +
143
+ '\nBuild first: yarn workspace @things-factory/headless-twin build:server'
144
+ )
145
+ }
146
+ schemaPromise ??= (async () => {
147
+ req('reflect-metadata')
148
+ const { buildSchema } = req('type-graphql')
149
+ const { schema: declared } = req(join(DIST, 'service/index.js'))
150
+ const { privilegeDirectiveResolver } = req('@things-factory/auth-base/dist-server/service/privilege/privilege-directive.js')
151
+ /* Subscriptions need a PubSub to build. Nothing is published here — only the shape is read. */
152
+ const pubSub = { publish() {}, subscribe: () => (async function* () {})() }
153
+ const built = await buildSchema({ resolvers: declared.resolverClasses, validate: false, pubSub })
154
+ privilegeDirectiveResolver(built)
155
+ return built
156
+ })()
157
+ return schemaPromise
158
+ }
159
+
160
+ const privilegeOfField = (t: string, f: string) =>
161
+ req('@things-factory/auth-base/dist-server/service/privilege/privilege-directive.js').privilegeOfField(t, f)
162
+
163
+ async function fieldsOf(typeName: 'Query' | 'Mutation' | 'Subscription'): Promise<string[]> {
164
+ const s = await schema()
165
+ const type = typeName === 'Query' ? s.getQueryType() : typeName === 'Mutation' ? s.getMutationType() : s.getSubscriptionType()
166
+ return Object.keys(type?.getFields() ?? {})
167
+ }
168
+
169
+ async function openFields(typeName: 'Query' | 'Mutation' | 'Subscription'): Promise<string[]> {
170
+ return (await fieldsOf(typeName)).filter(f => {
171
+ const gate = privilegeOfField(typeName, f)
172
+ return gate.known && gate.gate === null
173
+ })
174
+ }
175
+
176
+ test('every open read is one somebody chose, with how it stays in the caller\'s tenant', async () => {
177
+ for (const t of ['Query', 'Subscription'] as const) {
178
+ assert.deepEqual((await openFields(t)).sort(), Object.keys(OPEN_BY_DECISION[t]).sort(), `${t}: open fields ≠ OPEN_BY_DECISION`)
179
+ }
180
+ })
181
+
182
+ test('no write is open — ADR-0027 gates every mutation', async () => {
183
+ assert.deepEqual(await openFields('Mutation'), [])
184
+ })
185
+
186
+ test('the walk reaches the whole schema, not a handful of fields', async () => {
187
+ /* If the resolver list shrank to nothing, the two tests above would pass by having nothing to look at. */
188
+ assert.ok((await fieldsOf('Query')).length > 50)
189
+ assert.ok((await fieldsOf('Mutation')).length > 40)
190
+ assert.ok((await fieldsOf('Subscription')).length >= 2)
191
+ })
192
+
193
+ test('an undeclared field is told apart from one the schema does not have', async () => {
194
+ await schema()
195
+ /* `known: false` must never read as open — that is how a typo becomes a gate that is not there. */
196
+ assert.deepEqual(privilegeOfField('Query', 'noSuchField'), { known: false })
197
+ })
@@ -292,3 +292,28 @@ test('멈춘 트윈도 저널을 계산해 답하고, 그 답은 다시 계산
292
292
  assert.equal(it.observedBasis, 'journal', '라이브가 아니라 되짚은 것이라고 말해야 "지금 이렇다" 로 읽히지 않는다')
293
293
  assert.ok(it.observedRevision > 0, '몇 번째 리비전까지 계산했는지 함께 말한다 — 규모의 한계도 그 수가 말한다')
294
294
  })
295
+
296
+ test('★ 멈춘 트윈을 되세우지 못한 답도 출처가 제 근거와 같다 — 저널을 보러 간 답이 「지금 상태」라고 말하지 않는다', async () => {
297
+ /*
298
+ * ADR-0075 결정 3 · 결함 넘김 2026-09-24: 이 갈래만 `provenance.basis` 를 `state` 로 박아 두어, 같은 답이 `basis` 는
299
+ * `journal` 인데 출처는 `state` 였다. 커널이 멈춰 있고(`isRunning` false) 되세우기도 실패하는 때를 대역으로 세운다.
300
+ */
301
+ const { READ_BASIS } = req('@operato/ops-contract')
302
+ const isRunning = TwinEngine.isRunning
303
+ const recover = TwinEngine.recover
304
+ TwinEngine.isRunning = () => false
305
+ TwinEngine.recover = async () => {
306
+ throw new Error('nothing to rebuild')
307
+ }
308
+ try {
309
+ /* 상태에서 읽는 축이라야 이 갈래를 지난다(`orders` — 계약이 `source: 'state'` 로 선언). */
310
+ const it = await q.twinModelItem(INSTANCE, 'orders', 'order-1', ctx)
311
+ assert.equal(it.observedAbsence, 'not-running', '전제: 못 읽은 갈래다')
312
+ assert.equal(it.provenance.basis, it.basis, '출처와 근거가 같은 말을 한다')
313
+ assert.equal(it.provenance.basis, 'journal', '멈춘 트윈은 저널을 보러 갔다')
314
+ assert.ok(READ_BASIS.includes(it.provenance.basis))
315
+ } finally {
316
+ TwinEngine.isRunning = isRunning
317
+ TwinEngine.recover = recover
318
+ }
319
+ })