@underdogai/mesh-app-sdk 0.4.0 → 0.8.0

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/src/runtime.ts CHANGED
@@ -12,11 +12,14 @@ import {
12
12
  EventType,
13
13
  NS,
14
14
  buildUiActionContent,
15
+ buildUiActionResult,
15
16
  buildUiMessageContent,
16
17
  buildUiMessageEdit,
17
18
  canPublish,
18
19
  classifySender,
19
20
  validateEvent,
21
+ type UiActionClient,
22
+ type UiActionResultContent,
20
23
  type UiCardContent,
21
24
  type WorkflowStateContent,
22
25
  } from "@underdogai/mesh-event-schemas";
@@ -49,7 +52,17 @@ export type Effect =
49
52
  // 발행 계열(외부 SDK Phase 1) — emit(자기 사실 발행)/invoke(타 앱 액션 호출 = UI 없는 org.corp.ui.action).
50
53
  // 형태는 데이터로 남고, 실제 전송은 호스트가 applyEffects 로 적용한다(테스트/증명은 배열만 검사).
51
54
  | { kind: "emit"; type: string; content: Record<string, unknown>; form: EmitForm; stateKey?: string }
52
- | { kind: "invoke"; actionId: string; values: Record<string, unknown> };
55
+ | { kind: "invoke"; actionId: string; values: Record<string, unknown>; label?: string }
56
+ /**
57
+ * 액션 한 건의 판정 결과를 **누른 사람에게** 되돌린다(0.7.0 — `org.corp.ui.action.result`).
58
+ *
59
+ * 선언은 "무엇을 말하는가"만 담는다: 수신자(`to`)·카드 이벤트·액션 참조는 **호스트가 채운다**
60
+ * (핸들러는 자기가 어떤 이벤트에 응답 중인지 알 필요가 없다 — `edit` 가 `cardEventId` 를 모르는 것과 같다).
61
+ */
62
+ | { kind: "result"; result: ActionResult };
63
+
64
+ /** `ctx.result(...)` 가 받는 것 — 와이어에서 호스트가 채우는 네 키를 뺀 나머지. */
65
+ export type ActionResult = Omit<UiActionResultContent, "v" | "to" | "card_event_id" | "m.relates_to">;
53
66
 
54
67
  /**
55
68
  * 호스트가 주입하는 백엔드 핸들 — 핸들러가 "권위 데이터를 읽고 → 판단 → 효과 선언"(read-then-decide)할 때 쓴다.
@@ -86,6 +99,8 @@ export interface ActionCtx {
86
99
  cardId?: string;
87
100
  /** 폼 입력(원본). 핸들러는 action.values.parse(ctx.values) 로 타입 확보. */
88
101
  values: Record<string, unknown>;
102
+ /** 작성 클라이언트 환경(있으면, G6) — 클라의 자기 신고라 표시·진단용이지 권위 아님(권위는 sender). */
103
+ client?: UiActionClient;
89
104
  /** 편집 대상 원본 카드 event_id(있으면). */
90
105
  cardEventId?: string;
91
106
  /** 호스트 주입 백엔드(있으면) — 권위 데이터 read-then-decide. 없으면 write-only(증명/오프라인). */
@@ -116,11 +131,31 @@ export interface ActionCtx {
116
131
  emit(type: string, content: Record<string, unknown>, opts?: EmitOptions): void;
117
132
  /**
118
133
  * 효과 선언 헬퍼 — 다른 앱의 액션을 이름으로 호출한다(invoke = UI 없이 org.corp.ui.action 발행).
134
+ * `opts.label` 은 사람이 읽는 이름(액션 로그가 action_id 대신 보인다) — 카드 버튼의 글자에 해당하는 것을 앱이 준다.
119
135
  * 받는 쪽은 버튼에서 왔는지 봇이 보냈는지 구분하지 않는다. action_id 접두사(<app>.)가 라우팅 주소다.
120
136
  */
121
- invoke(actionId: string, values?: Record<string, unknown>): void;
137
+ invoke(actionId: string, values?: Record<string, unknown>, opts?: { label?: string }): void;
138
+ /**
139
+ * 효과 선언 헬퍼 — 이 액션의 **판정 결과를 누른 사람에게** 되돌린다(0.7.0).
140
+ *
141
+ * ctx.result({ kind: "reject", errors: { due: "오늘 이후여야 해요" } })
142
+ * ctx.result({ kind: "denied", reason: "현재 수신자만 처리할 수 있어요" })
143
+ * ctx.result({ kind: "ok", message: "확인 처리됨" })
144
+ * ctx.result({ kind: "options", name: "to", options: [...] }) // 동적 후보(select source:query)
145
+ *
146
+ * 🔴 **`send` 로 오류 카드를 쌓던 자리를 대신한다** — 결과가 방이 아니라 누른 사람의 그 컨트롤로 간다.
147
+ * ⚠️ 한 핸들러가 `result` 를 여러 번 내면 **마지막 것만** 남는다(같은 액션에 대한 판정이 여럿일 수
148
+ * 없다 — 클라 인덱스도 같은 규칙으로 최신 하나만 그린다). 효과 배열에는 그대로 쌓이지만
149
+ * `applyEffects` 가 마지막 하나만 보낸다.
150
+ * ⚠️ `card_id`·`action_id` 는 안 줘도 된다 — 지금 처리 중인 액션의 것을 SDK 가 채운다.
151
+ */
152
+ result(r: PartialActionResult): void;
122
153
  }
123
154
 
155
+ /** `ctx.result(...)` 의 인자 — `card_id`/`action_id` 는 SDK 가 채우므로 생략할 수 있다. */
156
+ export type PartialActionResult = Omit<ActionResult, "card_id" | "action_id"> &
157
+ Partial<Pick<ActionResult, "card_id" | "action_id">>;
158
+
124
159
  export type ActionHandler = (ctx: ActionCtx) => void | Promise<void>;
125
160
 
126
161
  /** 호스트가 주입하는 권한 게이트(= appservice-agent/policy.evaluateAction 의 얇은 래퍼). */
@@ -153,6 +188,17 @@ export interface DispatchInput {
153
188
  stateReader?: (type: string, stateKey: string) => Promise<Record<string, unknown> | null>;
154
189
  /** 호스트 주입 앱 정책 설정(선택) — ctx.config 로 핸들러에 전달(예: { approvalThreshold }). */
155
190
  config?: Record<string, unknown>;
191
+ /**
192
+ * 값 검증 실패·권한 거절에 **결과 효과를 자동으로 붙일지**(기본 true, 0.7.0).
193
+ *
194
+ * 왜 기본이 켬인가: 이 두 경로는 지금까지 **핸들러에 도달조차 안 했다** — 앱 코드가 개입할 자리가
195
+ * 없어서 사용자는 아무 반응도 못 봤다(`ignored`) 또는 앱이 따로 오류 카드를 쌓았다. 자동으로 붙이면
196
+ * **앱을 한 줄도 안 고치고** 옛 앱의 거절·검증 실패가 누른 사람의 그 컨트롤로 인라인 회신된다.
197
+ *
198
+ * 끄는 경우: 결과 문구를 앱이 전부 소유하고 싶거나(전환기 중복 방지), 증명 스크립트가 효과 배열을
199
+ * 엄격히 재는 경우.
200
+ */
201
+ autoResult?: boolean;
156
202
  }
157
203
 
158
204
  export interface DispatchResult {
@@ -169,9 +215,14 @@ export async function dispatchAction(input: DispatchInput): Promise<DispatchResu
169
215
  return { status: "ignored", reason: "publish_forbidden", effects: [] };
170
216
  }
171
217
  // 2) 레지스트리 검증 — 스키마 불일치/알 수 없는 타입이면 무시.
218
+ // 🔴 status 는 그대로 "ignored" 다(핸들러는 안 돈다). 달라진 건 **말해 준다**는 것뿐이다:
219
+ // 스키마 불일치는 거의 항상 사용자가 채운 값의 문제라, 누른 사람의 그 필드 아래에 문장이 가야 한다.
220
+ // 안 그러면 지금처럼 버튼을 눌러도 화면에 아무 일도 일어나지 않는다.
172
221
  const v = validateEvent(EventType.UiAction, input.actionContent);
173
- if (!v.ok) return { status: "ignored", reason: v.reason, effects: [] };
174
- const action = v.data as { card_id: string; action_id: string; values?: Record<string, unknown> };
222
+ if (!v.ok) {
223
+ return { status: "ignored", reason: v.reason, effects: rejectEffects(input, v.issues) };
224
+ }
225
+ const action = v.data as { card_id: string; action_id: string; values?: Record<string, unknown>; client?: UiActionClient };
175
226
 
176
227
  // 3) 앱이 아는 액션인가.
177
228
  const def = input.app.actions.get(action.action_id);
@@ -187,7 +238,23 @@ export async function dispatchAction(input: DispatchInput): Promise<DispatchResu
187
238
  capability: input.capability,
188
239
  });
189
240
  if (!decision.allowed) {
190
- return { status: "denied", actionId: action.action_id, reason: decision.reason, effects: [] };
241
+ // 🔴 거절도 **말해 준다**. 지금까지는 status denied 돌아가고 화면엔 아무 일도 없거나,
242
+ // 앱이 "수신자만 처리할 수 있어요" 카드를 방 전원에게 쌓았다. 이유는 이미 손에 있다(게이트가 준다).
243
+ const effects: Effect[] =
244
+ input.autoResult === false
245
+ ? []
246
+ : [
247
+ {
248
+ kind: "result",
249
+ result: {
250
+ card_id: action.card_id,
251
+ action_id: action.action_id,
252
+ kind: "denied",
253
+ reason: decision.reason,
254
+ },
255
+ },
256
+ ];
257
+ return { status: "denied", actionId: action.action_id, reason: decision.reason, effects };
191
258
  }
192
259
 
193
260
  // 5) 핸들러 실행 — 효과 수집(부작용은 호스트가 적용).
@@ -200,6 +267,7 @@ export async function dispatchAction(input: DispatchInput): Promise<DispatchResu
200
267
  entity: input.entity ?? {},
201
268
  cardId: action.card_id,
202
269
  values: action.values ?? {},
270
+ client: action.client,
203
271
  cardEventId: input.cardEventId,
204
272
  // backend 는 이 액션이 선언한 tools 화이트리스트로 감싼다(confused-deputy 방어, fail-closed).
205
273
  backend: guardBackend(def, input.backend),
@@ -212,12 +280,60 @@ export async function dispatchAction(input: DispatchInput): Promise<DispatchResu
212
280
  delegate: (d) => effects.push({ kind: "delegate", intent: d.intent, payload: d.payload }),
213
281
  emit: publish.emit,
214
282
  invoke: publish.invoke,
283
+ result: (r) =>
284
+ effects.push({
285
+ kind: "result",
286
+ result: { card_id: action.card_id, action_id: action.action_id, ...r },
287
+ }),
215
288
  };
216
289
  await handler(ctx);
217
290
  }
218
291
  return { status: "ran", actionId: action.action_id, effects };
219
292
  }
220
293
 
294
+ /**
295
+ * 스키마 불일치를 **필드별 문장**으로 바꾼다(0.7.0). zod issue 의 경로가 필드 이름을 들고 있다 —
296
+ * 폼 값은 `values.<name>` 아래 살므로 그 한 겹을 벗긴다(`values.due: …` → `due`).
297
+ *
298
+ * 🔴 **best-effort 다.** content 자체가 스키마를 통과하지 못한 상태라 `card_id`/`action_id` 조차
299
+ * 믿을 수 없다 — 둘 다 문자열로 읽히지 않으면 회신을 **포기한다**(잘못된 카드에 오류를 붙이는 것보다
300
+ * 아무 말도 안 하는 쪽이 낫다). 경로가 `values` 아래가 아니면(예: `action_type`) 필드로 못 붙이므로
301
+ * `kind: "reject"` 대신 한 줄짜리 `denied` 로 떨어뜨린다 — 사람이 고칠 수 있는 게 없는 오류라서다.
302
+ */
303
+ function rejectEffects(input: DispatchInput, issues: string[] | undefined): Effect[] {
304
+ if (input.autoResult === false) return [];
305
+ const c = input.actionContent as Record<string, unknown> | null | undefined;
306
+ const cardId = typeof c?.card_id === "string" ? c.card_id : undefined;
307
+ const actionId = typeof c?.action_id === "string" ? c.action_id : undefined;
308
+ if (!cardId || !actionId) return [];
309
+
310
+ const errors: Record<string, string> = {};
311
+ const rest: string[] = [];
312
+ for (const issue of issues ?? []) {
313
+ const at = issue.indexOf(": ");
314
+ const path = at >= 0 ? issue.slice(0, at) : "";
315
+ const message = at >= 0 ? issue.slice(at + 2) : issue;
316
+ const segments = path.split(".").filter(Boolean);
317
+ // `values.<name>` → 그 필드. `values` 안쪽으로 더 깊어도(배열 인덱스 등) 첫 이름이 필드다.
318
+ if (segments[0] === "values" && segments[1]) errors[segments[1]] ??= message;
319
+ else rest.push(issue);
320
+ }
321
+ if (Object.keys(errors).length) {
322
+ return [{ kind: "result", result: { card_id: cardId, action_id: actionId, kind: "reject", errors } }];
323
+ }
324
+ return [
325
+ {
326
+ kind: "result",
327
+ result: {
328
+ card_id: cardId,
329
+ action_id: actionId,
330
+ kind: "denied",
331
+ reason: rest[0] ?? "schema_mismatch",
332
+ },
333
+ },
334
+ ];
335
+ }
336
+
221
337
  // ---------------------------------------------------------------------------
222
338
  // 발행 헬퍼(emit/invoke) — ActionCtx/EventCtx 가 공유하는 효과 수집기
223
339
  // ---------------------------------------------------------------------------
@@ -276,14 +392,14 @@ function makePublishHelpers(
276
392
  ...(form === "state" ? { stateKey: opts?.stateKey ?? "" } : {}),
277
393
  });
278
394
  },
279
- invoke(actionId, values) {
395
+ invoke(actionId, values, opts) {
280
396
  // 주소(<app>. 접두사) 없는 id 는 라우팅될 수 없다 — assertActionNamespace 가 보장하는 형태를 요구한다.
281
397
  if (!actionId.includes(".")) {
282
398
  throw new Error(
283
399
  `[app-sdk] invoke: action_id '${actionId}' 는 '<app>.<action>' 형태여야 합니다(접두사 = 라우팅 주소).`,
284
400
  );
285
401
  }
286
- effects.push({ kind: "invoke", actionId, values: values ?? {} });
402
+ effects.push({ kind: "invoke", actionId, values: values ?? {}, ...(opts?.label ? { label: opts.label } : {}) });
287
403
  },
288
404
  };
289
405
  }
@@ -359,7 +475,7 @@ export interface EventCtx {
359
475
  /** 효과 선언 헬퍼 — 자기 사실을 발행한다(규칙은 ActionCtx.emit 과 동일). */
360
476
  emit(type: string, content: Record<string, unknown>, opts?: EmitOptions): void;
361
477
  /** 효과 선언 헬퍼 — 다른 앱의 액션을 호출한다(규칙은 ActionCtx.invoke 와 동일). */
362
- invoke(actionId: string, values?: Record<string, unknown>): void;
478
+ invoke(actionId: string, values?: Record<string, unknown>, opts?: { label?: string }): void;
363
479
  }
364
480
 
365
481
  export type EventHandler = (ctx: EventCtx) => void | Promise<void>;
@@ -439,6 +555,14 @@ export interface ApplyEffectsInput {
439
555
  entity?: { type?: string; id?: string };
440
556
  /** workflow.updated_by 로 기록할 주체(보통 액션 발신자). */
441
557
  sender?: string;
558
+ /**
559
+ * `result` 효과의 **수신자**(0.7.0) — 이 액션을 누른 사람의 MXID. 없으면 결과를 보낼 곳이 없으므로
560
+ * 경고 후 건너뛴다(throw 하지 않는다 — 결과 회신은 부가 기능이고, 그것 때문에 뒤따르는 카드 편집이
561
+ * 취소되면 더 나쁘다. `workflow` 가 로그 후 계속하는 것과 같은 판단).
562
+ */
563
+ actor?: string;
564
+ /** `result` 효과가 가리킬 **액션** 이벤트 id(0.7.0). 없으면 `m.relates_to` 없이 보낸다(card_id 로 붙는다). */
565
+ actionEventId?: string;
442
566
  /**
443
567
  * delegate 효과 콜백 — stateful·교차에이전트(A2A) 프로토콜은 호스트가 소유한다. 미주입 호스트(1회 발행·
444
568
  * 단순 봇)에서 delegate 가 나오면 경고 후 무시한다(조용한 유실 방지 로그).
@@ -449,17 +573,38 @@ export interface ApplyEffectsInput {
449
573
  error?: (msg: string) => void;
450
574
  }
451
575
 
576
+ /**
577
+ * 권한 거부(403 / M_FORBIDDEN) 판정 — 전송 클라이언트는 **호스트가 주입**하므로 오류 모양이 하나가 아니다
578
+ * (serve() 의 MatrixApiError 는 `.status`, matrix-bot-sdk 는 `body.errcode`). 어느 쪽도 못 읽으면 메시지
579
+ * 문자열로 떨어진다. 오판해도 손해는 "조치 문구가 안 붙는다" 뿐이라 관대하게 본다.
580
+ */
581
+ function isForbidden(err: unknown): boolean {
582
+ const e = err as { status?: unknown; statusCode?: unknown; errcode?: unknown; body?: { errcode?: unknown } };
583
+ if (e?.status === 403 || e?.statusCode === 403) return true;
584
+ if (e?.errcode === "M_FORBIDDEN" || e?.body?.errcode === "M_FORBIDDEN") return true;
585
+ return /\b403\b|M_FORBIDDEN/.test(err instanceof Error ? err.message : String(err));
586
+ }
587
+
452
588
  /**
453
589
  * 핸들러가 선언한 효과를 Matrix 로 적용한다 — 호스트 무관(AS 에이전트·봇 계정·증명 스크립트가 같은 코드 사용).
454
- * 배열 순서대로 적용한다. edit/send/emit/invoke 전송 실패는 throw(호스트가 회복 정책 소유),
455
- * workflow 는 상태 이벤트 특성상 로그 후 계속(agent 레거시 동작 보존 — 카드 갱신을 막지 않는다).
590
+ * 배열 순서대로 적용한다. edit/send/emit/invoke 전송 실패는 throw(호스트가 회복 정책 소유).
591
+ *
592
+ * `workflow` 만 예외로 **로그 후 계속**한다. 이건 관대함이 아니라 순서의 문제다: workflow 는 보통 카드
593
+ * 편집 **뒤에** 오는 부기(bookkeeping)라, 여기서 throw 하면 이미 사용자 화면에 반영된 작업의 나머지 효과가
594
+ * 취소된다 — 진짜 일(머지·승인)은 이미 끝났는데 후속 효과만 잃는 게 더 나쁘다.
595
+ *
596
+ * 대신 **조용하지는 않게** 한다: 403(봇 PL 부족)은 가장 흔한 원인이자 유일하게 사람이 고칠 수 있는
597
+ * 원인이라, 로그가 원인과 **조치 두 갈래**를 같이 적는다(로그만 보고 판단할 수 있게).
456
598
  */
457
599
  export async function applyEffects(input: ApplyEffectsInput): Promise<void> {
458
600
  // SDK 는 런타임 환경을 가정하지 않는다(lib 에 console 없음) — 기본 로그는 globalThis.console 이 있을 때만.
459
601
  const g = globalThis as { console?: { warn(m: string): void; error(m: string): void } };
460
602
  const warn = input.warn ?? ((m: string) => g.console?.warn(m));
461
603
  const error = input.error ?? ((m: string) => g.console?.error(m));
462
- for (const e of input.effects) {
604
+ // 같은 액션의 판정은 하나뿐이다 — 핸들러가 result 를 여러 번 내면 **마지막 것만** 보낸다
605
+ // (클라 인덱스도 같은 규칙으로 최신 하나만 그린다. 여러 장을 보내면 화면에서 서로를 덮어 깜빡인다).
606
+ const lastResult = input.effects.reduce<number>((at, e, i) => (e.kind === "result" ? i : at), -1);
607
+ for (const [i, e] of input.effects.entries()) {
463
608
  switch (e.kind) {
464
609
  case "edit": {
465
610
  // 편집 대상 카드가 있으면 m.replace, 없으면(참조 유실) 새 카드로 graceful.
@@ -485,7 +630,17 @@ export async function applyEffects(input: ApplyEffectsInput): Promise<void> {
485
630
  try {
486
631
  await input.client.sendStateEvent(input.roomId, EventType.WorkflowState, input.entity.id, content);
487
632
  } catch (err) {
488
- error(`[app-sdk] workflow.state 게시 실패 (${input.entity.id}, ${e.status}): ${err instanceof Error ? err.message : String(err)}`);
633
+ // 403 거의 항상 "봇 PL 0 < state_default 50". 원인만 찍으면 로그를 읽는 사람이 다시 추적해야 하니
634
+ // 조치를 같이 적는다 — 그리고 **끄는 쪽도 조치다**: 방이 엔티티 하나가 아니면(레포·팀 채널 등) 방 상태에
635
+ // 남길 이유가 없고, state_key 가 엔티티마다 쌓여 방 상태가 무한히 자란다.
636
+ const hint = isForbidden(err)
637
+ ? " — 봇에 방 상태 쓰기 권한이 없습니다(보통 봇 PL 0 < state_default 50)." +
638
+ ` 남길 값이면 방 ${input.roomId} 의 m.room.power_levels 에 events["${EventType.WorkflowState}"] 를 낮추거나 봇 PL 을 올리고,` +
639
+ " 아니라면 핸들러의 ctx.workflow(...) 를 지우는 쪽이 맞습니다(방=엔티티가 아닌 방에선 후자)."
640
+ : "";
641
+ error(
642
+ `[app-sdk] workflow.state 게시 실패 (${input.entity.id}, ${e.status}): ${err instanceof Error ? err.message : String(err)}${hint}`,
643
+ );
489
644
  }
490
645
  break;
491
646
  }
@@ -514,12 +669,38 @@ export async function applyEffects(input: ApplyEffectsInput): Promise<void> {
514
669
  const content = buildUiActionContent({
515
670
  card_id: `invoke:${input.app.app}`,
516
671
  action_id: e.actionId,
672
+ label: e.label,
517
673
  action_type: "submit",
518
674
  values: e.values,
519
675
  });
520
676
  await input.client.sendEvent(input.roomId, EventType.UiAction, content as unknown as Record<string, unknown>);
521
677
  break;
522
678
  }
679
+ case "result": {
680
+ if (i !== lastResult) break; // 마지막 판정만 나간다(위 주석).
681
+ if (!input.actor) {
682
+ // 보낼 곳을 모르면 그냥 건너뛴다 — throw 하면 이미 화면에 반영된 카드 편집까지 취소된다.
683
+ warn(
684
+ `[app-sdk] result 효과 생략 — 수신자(actor) 미해석 (action_id=${e.result.action_id}, kind=${e.result.kind}).` +
685
+ " 호스트가 applyEffects 에 actor(액션 발신자)를 넘겨야 합니다.",
686
+ );
687
+ break;
688
+ }
689
+ const content: UiActionResultContent = buildUiActionResult({
690
+ ...e.result,
691
+ to: input.actor,
692
+ ...(input.cardEventId ? { card_event_id: input.cardEventId } : {}),
693
+ ...(input.actionEventId
694
+ ? { "m.relates_to": { rel_type: "m.reference" as const, event_id: input.actionEventId } }
695
+ : {}),
696
+ });
697
+ await input.client.sendEvent(
698
+ input.roomId,
699
+ EventType.UiActionResult,
700
+ content as unknown as Record<string, unknown>,
701
+ );
702
+ break;
703
+ }
523
704
  }
524
705
  }
525
706
  }
package/src/serve.ts CHANGED
@@ -523,15 +523,26 @@ export async function serve(app: App, opts: ServeOptions): Promise<void> {
523
523
  .catch(() => null),
524
524
  config: opts.config,
525
525
  });
526
+ // `actor`/`actionEventId` — `result` 효과(0.7.0)가 "누구에게, 어느 액션의 답으로" 가는지.
527
+ // 안 넘기면 결과 회신이 조용히 생략된다(applyEffects 가 경고만 하고 건너뛴다).
528
+ const effectInput = {
529
+ client, app, roomId, cardEventId, entity,
530
+ sender: ev.sender, actor: ev.sender, actionEventId: ev.event_id,
531
+ onDelegate: opts.onDelegate,
532
+ };
526
533
  // 자기 것이 아니면(unknown_action 등 ignored) 아무것도 발행하지 않는다 — 외부 앱 공존의 반쪽(§1.5의 역).
527
534
  if (res.status === "ran" && res.effects.length) {
528
- await applyEffects({
529
- client, app, roomId, cardEventId, entity,
530
- sender: ev.sender, effects: res.effects, onDelegate: opts.onDelegate,
531
- });
535
+ await applyEffects({ ...effectInput, effects: res.effects });
532
536
  log(`[app-sdk] 액션 실행 (${res.actionId}, effects=${res.effects.length})`);
533
537
  } else if (res.status === "denied") {
534
- log(`[app-sdk] 액션 거부 (${res.actionId}): ${res.reason}`); // 거부 카드 발행 여부는 정책 — 기본은 로그만.
538
+ // 🔴 거부도 **누른 사람에게는 말해 준다**(0.7.0) dispatchAction 붙인 `result` 효과만 적용한다.
539
+ // 방에 거부 *카드*를 쌓을지는 여전히 앱 정책이다(기본은 안 쌓는다). 그 경계가 이 채널의 요점이다.
540
+ await applyEffects({ ...effectInput, effects: res.effects.filter((e) => e.kind === "result") });
541
+ log(`[app-sdk] 액션 거부 (${res.actionId}): ${res.reason}`);
542
+ } else if (res.effects.some((e) => e.kind === "result")) {
543
+ // 값 검증 실패(schema_mismatch) — 핸들러는 안 돌았지만 사용자는 왜 아무 일도 안 일어났는지 알아야 한다.
544
+ await applyEffects({ ...effectInput, effects: res.effects.filter((e) => e.kind === "result") });
545
+ log(`[app-sdk] 액션 무시 (${res.reason}) — 결과 회신만 보냈다`);
535
546
  }
536
547
  return;
537
548
  }