@vellumai/vellum-gateway 0.11.8 → 0.11.9-staging.2

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 (49) hide show
  1. package/AGENTS.md +2 -0
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +4 -1
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +4 -1
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  12. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
  13. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +110 -5
  14. package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +5 -33
  15. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +3 -0
  16. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +100 -9
  17. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -2
  18. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +9 -0
  19. package/node_modules/@vellumai/service-contracts/package.json +4 -1
  20. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  21. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  22. package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  23. package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  24. package/package.json +1 -1
  25. package/src/__tests__/slack-reaction-normalize.test.ts +6 -3
  26. package/src/__tests__/slack-socket-mode-outbound-root.test.ts +85 -12
  27. package/src/__tests__/stt-stream-websocket.test.ts +95 -1
  28. package/src/approvals/guardian-request-service.ts +0 -12
  29. package/src/channels/inbound-event.ts +24 -3
  30. package/src/db/__tests__/guardian-request-store.test.ts +0 -45
  31. package/src/db/guardian-request-store.ts +0 -36
  32. package/src/discord/gateway-socket.test.ts +17 -0
  33. package/src/discord/gateway-socket.ts +5 -0
  34. package/src/discord/intents.test.ts +4 -3
  35. package/src/discord/intents.ts +2 -2
  36. package/src/discord/message-schemas.ts +1 -0
  37. package/src/discord/normalize.test.ts +113 -60
  38. package/src/discord/normalize.ts +38 -6
  39. package/src/feature-flag-registry.json +21 -4
  40. package/src/http/routes/platform-push-proxy.test.ts +264 -0
  41. package/src/http/routes/platform-push-proxy.ts +141 -0
  42. package/src/http/routes/stt-stream-websocket.ts +39 -3
  43. package/src/index.ts +45 -1
  44. package/src/ipc/__tests__/guardian-request-routes.test.ts +0 -33
  45. package/src/ipc/guardian-request-handlers.ts +0 -13
  46. package/src/slack/message-change-normalizer.ts +16 -3
  47. package/src/slack/normalize.test.ts +72 -14
  48. package/src/slack/reaction-normalizer.ts +13 -6
  49. package/src/slack/socket-mode.ts +32 -4
package/AGENTS.md CHANGED
@@ -83,8 +83,10 @@ Gateway inbound events use a channel-discriminated union model (`GatewayInboundE
83
83
 
84
84
  - **`conversationExternalId`**: Delivery/conversation address (e.g., Telegram chat ID, phone number). Used for conversation binding and message routing. **Not** used for trust classification.
85
85
  - **`actorExternalId`**: Sender identity (e.g., Telegram user ID, WhatsApp phone number). Used for trust classification, guardian binding, and ACL enforcement. **Required** for all public channel ingress.
86
+ - **`externalMessageId`**: Dedup identity. It must name one OCCURRENCE of an event, not one kind of event: two distinct acts by the same actor differ here, and the same act delivered twice repeats here. Both halves are load-bearing, because the gateway claims a delivery on `(sourceChannel, externalChatId, externalMessageId)` (`db/inbound-dedup-store.ts`) and the daemon records the permanent event row on the same triple. Prefer the provider's own per-event id (Telegram `update_id`, Slack `event_id`, a Discord snowflake, an email `Message-ID`). Where a provider names only the thing acted on and not the act, as both reaction families do, addressing fields alone (room, target message, actor, verb) repeat byte for byte on the second occurrence and silently dedup it away, so the normalizer must carry a per-event component of its own.
86
87
  - **"conversation"** is canonical vocabulary for delivery addresses. "thread" is reserved for provider-specific fields (Slack `thread_ts`, email thread IDs).
87
88
  - **"actor"** is canonical vocabulary for sender identity.
89
+ - **`source.actorUnattributed`**: set when the platform names no person for an event. `actorExternalId` then carries the channel's synthetic system id (`discord-system`, `slack-system`) and is not an identity claim: the gateway seeds no contact from it, and the runtime's ACL neither resolves a member nor denies, applying the event only to a row the daemon already holds for that chat. Only event kinds that cannot start a turn may carry it; today that is the delete family (Discord names no author; Slack names the author, never the deleter, and the author may be the assistant itself). The `no_one` kill switch still applies, since it keys on the policy alone.
88
90
 
89
91
  Trust/guardian decisions must be keyed on `actorExternalId` only — never fall back to `conversationExternalId` for actor identity.
90
92
 
@@ -22,7 +22,10 @@
22
22
  "./redacted-credential": "./src/redacted-credential.ts",
23
23
  "./secret-detection": "./src/secret-detection.ts",
24
24
  "./stripe-currency": "./src/stripe-currency.ts",
25
- "./error": "./src/error.ts"
25
+ "./error": "./src/error.ts",
26
+ "./reactions": "./src/reactions.ts",
27
+ "./guardian-requests": "./src/guardian-requests.ts",
28
+ "./platform-credential": "./src/platform-credential.ts"
26
29
  },
27
30
  "scripts": {
28
31
  "typecheck": "bunx tsc --noEmit",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Canonical vocabulary for a guardian request's lifecycle status, shared by
3
+ * the gateway that owns the request rows, the daemon that decides and
4
+ * projects them, and the web that renders the projection.
5
+ */
6
+ import { z } from "zod";
7
+
8
+ export const GUARDIAN_REQUEST_STATUS_VALUES = [
9
+ "pending",
10
+ "approved",
11
+ "denied",
12
+ "expired",
13
+ "cancelled",
14
+ ] as const;
15
+ export const GuardianRequestStatusSchema = z.enum(
16
+ GUARDIAN_REQUEST_STATUS_VALUES,
17
+ );
18
+ export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
@@ -28,6 +28,7 @@ export * from "./rpc.js";
28
28
  export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
+ export * from "./platform-credential.js";
31
32
  export * from "./remote-web-pairing.js";
32
33
  export * from "./twilio-ingress.js";
33
34
  export * from "./url-normalization.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Wire contract for asking an assistant whether its stored platform-managed
3
+ * credential still authenticates.
4
+ *
5
+ * The daemon route is the authoritative serving side:
6
+ * - `POST /v1/platform/verify-credential`
7
+ * (`assistant/src/runtime/routes/platform-routes.ts`)
8
+ *
9
+ * The daemon's platform client produces the verdict, the route reports it,
10
+ * and the `vellum` CLI reads it before deciding whether a stored key can be
11
+ * re-injected (`cli/src/lib/assistant-api-key-resolution.ts`), so all three
12
+ * share one definition and cannot silently drift. The web app reads the same
13
+ * shape through its generated daemon client.
14
+ */
15
+ import { z } from "zod";
16
+
17
+ /**
18
+ * What the platform said about the stored credential, right now.
19
+ *
20
+ * - `valid`: the platform accepted it.
21
+ * - `rejected`: the platform refused it (unauthorized or forbidden); the
22
+ * credential needs replacing.
23
+ * - `unknown`: the check itself could not run (no credential stored, the
24
+ * platform unreachable, a server error). Not evidence either way.
25
+ */
26
+ export const PlatformCredentialVerificationStatusSchema = z.enum([
27
+ "valid",
28
+ "rejected",
29
+ "unknown",
30
+ ]);
31
+ export type PlatformCredentialVerificationStatus = z.infer<
32
+ typeof PlatformCredentialVerificationStatusSchema
33
+ >;
34
+
35
+ /** `POST /v1/platform/verify-credential` response body. */
36
+ export const PlatformVerifyCredentialResponseSchema = z.object({
37
+ status: PlatformCredentialVerificationStatusSchema,
38
+ });
39
+ export type PlatformVerifyCredentialResponse = z.infer<
40
+ typeof PlatformVerifyCredentialResponseSchema
41
+ >;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Canonical vocabulary for a reaction's emoji, shared by every service that
3
+ * handles one: the gateway that normalizes it, the daemon that stores and
4
+ * projects it, and the web that renders it.
5
+ *
6
+ * The kind is said by the channel rather than inferred from how the emoji
7
+ * is spelled. Modelled on Zulip's `reaction_type`, the one surveyed system
8
+ * that separates the namespace from the name.
9
+ *
10
+ * `shortcode` is a name in a channel's own namespace whose kind the channel
11
+ * does not disclose: Slack sends `+1` for the standard emoji and `blob_wave`
12
+ * for a workspace upload with nothing to tell them apart, and only the
13
+ * workspace token can resolve the second. It is a distinct kind from
14
+ * `unicode`, not a stand-in for an unknown one.
15
+ */
16
+ import { z } from "zod";
17
+
18
+ export const REACTION_EMOJI_KINDS = ["unicode", "shortcode", "custom"] as const;
19
+ export type ReactionEmojiKind = (typeof REACTION_EMOJI_KINDS)[number];
20
+
21
+ /**
22
+ * The typed emoji fields every schema that carries a reaction spreads in,
23
+ * so the wire contract, the stored envelopes, and the response projection
24
+ * describe one shape. Optional throughout: a persisted row or a replayed
25
+ * payload may carry only the spelling.
26
+ */
27
+ export const ReactionEmojiFieldsSchema = z.object({
28
+ /** Which namespace the emoji was drawn from. */
29
+ emojiKind: z.enum(REACTION_EMOJI_KINDS).optional(),
30
+ /**
31
+ * The emoji's name in that namespace: the character itself for `unicode`,
32
+ * the bare name for `shortcode` and `custom`. Never a mention form.
33
+ */
34
+ emojiName: z.string().optional(),
35
+ /** The channel's id for a `custom` emoji, absent for every other kind. */
36
+ emojiId: z.string().optional(),
37
+ /** Whether a `custom` emoji animates. Absent for every other kind. */
38
+ emojiAnimated: z.boolean().optional(),
39
+ });
40
+ export type ReactionEmojiFields = z.infer<typeof ReactionEmojiFieldsSchema>;
41
+
42
+ /**
43
+ * The typed emoji fields a source actually carries, with undefined ones
44
+ * omitted: an absent key and a present-but-undefined one serialize alike, but the
45
+ * stored envelope and the response should carry only what was declared. Every writer of a reaction shape (the wire
46
+ * payload, both stored envelopes, the response projection) copies the
47
+ * fields through this rather than restating the four-way pick.
48
+ */
49
+ export function pickReactionEmojiFields(
50
+ source: ReactionEmojiFields,
51
+ ): ReactionEmojiFields {
52
+ return {
53
+ ...(source.emojiKind !== undefined ? { emojiKind: source.emojiKind } : {}),
54
+ ...(source.emojiName !== undefined ? { emojiName: source.emojiName } : {}),
55
+ ...(source.emojiId !== undefined ? { emojiId: source.emojiId } : {}),
56
+ ...(source.emojiAnimated !== undefined
57
+ ? { emojiAnimated: source.emojiAnimated }
58
+ : {}),
59
+ };
60
+ }
@@ -22,7 +22,10 @@
22
22
  "./redacted-credential": "./src/redacted-credential.ts",
23
23
  "./secret-detection": "./src/secret-detection.ts",
24
24
  "./stripe-currency": "./src/stripe-currency.ts",
25
- "./error": "./src/error.ts"
25
+ "./error": "./src/error.ts",
26
+ "./reactions": "./src/reactions.ts",
27
+ "./guardian-requests": "./src/guardian-requests.ts",
28
+ "./platform-credential": "./src/platform-credential.ts"
26
29
  },
27
30
  "scripts": {
28
31
  "typecheck": "bunx tsc --noEmit",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Canonical vocabulary for a guardian request's lifecycle status, shared by
3
+ * the gateway that owns the request rows, the daemon that decides and
4
+ * projects them, and the web that renders the projection.
5
+ */
6
+ import { z } from "zod";
7
+
8
+ export const GUARDIAN_REQUEST_STATUS_VALUES = [
9
+ "pending",
10
+ "approved",
11
+ "denied",
12
+ "expired",
13
+ "cancelled",
14
+ ] as const;
15
+ export const GuardianRequestStatusSchema = z.enum(
16
+ GUARDIAN_REQUEST_STATUS_VALUES,
17
+ );
18
+ export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
@@ -28,6 +28,7 @@ export * from "./rpc.js";
28
28
  export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
+ export * from "./platform-credential.js";
31
32
  export * from "./remote-web-pairing.js";
32
33
  export * from "./twilio-ingress.js";
33
34
  export * from "./url-normalization.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Wire contract for asking an assistant whether its stored platform-managed
3
+ * credential still authenticates.
4
+ *
5
+ * The daemon route is the authoritative serving side:
6
+ * - `POST /v1/platform/verify-credential`
7
+ * (`assistant/src/runtime/routes/platform-routes.ts`)
8
+ *
9
+ * The daemon's platform client produces the verdict, the route reports it,
10
+ * and the `vellum` CLI reads it before deciding whether a stored key can be
11
+ * re-injected (`cli/src/lib/assistant-api-key-resolution.ts`), so all three
12
+ * share one definition and cannot silently drift. The web app reads the same
13
+ * shape through its generated daemon client.
14
+ */
15
+ import { z } from "zod";
16
+
17
+ /**
18
+ * What the platform said about the stored credential, right now.
19
+ *
20
+ * - `valid`: the platform accepted it.
21
+ * - `rejected`: the platform refused it (unauthorized or forbidden); the
22
+ * credential needs replacing.
23
+ * - `unknown`: the check itself could not run (no credential stored, the
24
+ * platform unreachable, a server error). Not evidence either way.
25
+ */
26
+ export const PlatformCredentialVerificationStatusSchema = z.enum([
27
+ "valid",
28
+ "rejected",
29
+ "unknown",
30
+ ]);
31
+ export type PlatformCredentialVerificationStatus = z.infer<
32
+ typeof PlatformCredentialVerificationStatusSchema
33
+ >;
34
+
35
+ /** `POST /v1/platform/verify-credential` response body. */
36
+ export const PlatformVerifyCredentialResponseSchema = z.object({
37
+ status: PlatformCredentialVerificationStatusSchema,
38
+ });
39
+ export type PlatformVerifyCredentialResponse = z.infer<
40
+ typeof PlatformVerifyCredentialResponseSchema
41
+ >;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Canonical vocabulary for a reaction's emoji, shared by every service that
3
+ * handles one: the gateway that normalizes it, the daemon that stores and
4
+ * projects it, and the web that renders it.
5
+ *
6
+ * The kind is said by the channel rather than inferred from how the emoji
7
+ * is spelled. Modelled on Zulip's `reaction_type`, the one surveyed system
8
+ * that separates the namespace from the name.
9
+ *
10
+ * `shortcode` is a name in a channel's own namespace whose kind the channel
11
+ * does not disclose: Slack sends `+1` for the standard emoji and `blob_wave`
12
+ * for a workspace upload with nothing to tell them apart, and only the
13
+ * workspace token can resolve the second. It is a distinct kind from
14
+ * `unicode`, not a stand-in for an unknown one.
15
+ */
16
+ import { z } from "zod";
17
+
18
+ export const REACTION_EMOJI_KINDS = ["unicode", "shortcode", "custom"] as const;
19
+ export type ReactionEmojiKind = (typeof REACTION_EMOJI_KINDS)[number];
20
+
21
+ /**
22
+ * The typed emoji fields every schema that carries a reaction spreads in,
23
+ * so the wire contract, the stored envelopes, and the response projection
24
+ * describe one shape. Optional throughout: a persisted row or a replayed
25
+ * payload may carry only the spelling.
26
+ */
27
+ export const ReactionEmojiFieldsSchema = z.object({
28
+ /** Which namespace the emoji was drawn from. */
29
+ emojiKind: z.enum(REACTION_EMOJI_KINDS).optional(),
30
+ /**
31
+ * The emoji's name in that namespace: the character itself for `unicode`,
32
+ * the bare name for `shortcode` and `custom`. Never a mention form.
33
+ */
34
+ emojiName: z.string().optional(),
35
+ /** The channel's id for a `custom` emoji, absent for every other kind. */
36
+ emojiId: z.string().optional(),
37
+ /** Whether a `custom` emoji animates. Absent for every other kind. */
38
+ emojiAnimated: z.boolean().optional(),
39
+ });
40
+ export type ReactionEmojiFields = z.infer<typeof ReactionEmojiFieldsSchema>;
41
+
42
+ /**
43
+ * The typed emoji fields a source actually carries, with undefined ones
44
+ * omitted: an absent key and a present-but-undefined one serialize alike, but the
45
+ * stored envelope and the response should carry only what was declared. Every writer of a reaction shape (the wire
46
+ * payload, both stored envelopes, the response projection) copies the
47
+ * fields through this rather than restating the four-way pick.
48
+ */
49
+ export function pickReactionEmojiFields(
50
+ source: ReactionEmojiFields,
51
+ ): ReactionEmojiFields {
52
+ return {
53
+ ...(source.emojiKind !== undefined ? { emojiKind: source.emojiKind } : {}),
54
+ ...(source.emojiName !== undefined ? { emojiName: source.emojiName } : {}),
55
+ ...(source.emojiId !== undefined ? { emojiId: source.emojiId } : {}),
56
+ ...(source.emojiAnimated !== undefined
57
+ ? { emojiAnimated: source.emojiAnimated }
58
+ : {}),
59
+ };
60
+ }
@@ -16,7 +16,6 @@ import {
16
16
  ExpireGuardianRequestIpcParamsSchema,
17
17
  ExpireInteractionBoundIpcResponseSchema,
18
18
  GetGuardianRequestByCallSessionIpcParamsSchema,
19
- GetGuardianRequestByDestinationMessageIpcParamsSchema,
20
19
  GetGuardianRequestByPendingQuestionIpcParamsSchema,
21
20
  GUARDIAN_REQUESTS_IPC_METHODS,
22
21
  GuardianRequestAclOutcomeSchema,
@@ -482,23 +481,6 @@ describe("delivery + destination IPC schemas", () => {
482
481
  );
483
482
  });
484
483
 
485
- test("get_by_destination_message requires all three keys", () => {
486
- const params = {
487
- channel: "telegram",
488
- chatId: "tg-chat-guardian",
489
- messageId: "msg-42",
490
- };
491
- expect(
492
- GetGuardianRequestByDestinationMessageIpcParamsSchema.parse(params),
493
- ).toEqual(params);
494
- expect(() =>
495
- GetGuardianRequestByDestinationMessageIpcParamsSchema.parse({
496
- channel: "telegram",
497
- chatId: "tg-chat-guardian",
498
- }),
499
- ).toThrow();
500
- });
501
-
502
484
  test("list_pending_by_destination accepts either addressing form", () => {
503
485
  const byConversation = { conversationId: "conv-1", channel: "telegram" };
504
486
  expect(
@@ -11,6 +11,7 @@ import {
11
11
  resolveInboundEventKind,
12
12
  resolveInboundReactionPayload,
13
13
  } from "../inbound-event-kind.js";
14
+ import { RuntimeInboundPayloadSchema } from "../inbound-contract.js";
14
15
 
15
16
  describe("resolveInboundEventKind", () => {
16
17
  test("a stamped kind wins over every legacy field", () => {
@@ -83,14 +84,58 @@ describe("inboundEventRefersToAnotherMessage", () => {
83
84
  });
84
85
 
85
86
  describe("resolveInboundReactionPayload", () => {
86
- test("a structured payload wins outright", () => {
87
+ test("a structured payload wins outright, typed fields included", () => {
87
88
  expect(
88
89
  resolveInboundReactionPayload({
89
90
  eventKind: "reaction",
90
- reaction: { op: "removed", emoji: "+1", targetMessageId: "11.22" },
91
+ reaction: {
92
+ op: "removed",
93
+ emoji: "+1",
94
+ emojiKind: "shortcode",
95
+ emojiName: "+1",
96
+ targetMessageId: "11.22",
97
+ },
91
98
  callbackData: "reaction:eyes",
92
99
  }),
93
- ).toEqual({ op: "removed", emoji: "+1", targetMessageId: "11.22" });
100
+ ).toEqual({
101
+ op: "removed",
102
+ emoji: "+1",
103
+ emojiKind: "shortcode",
104
+ emojiName: "+1",
105
+ targetMessageId: "11.22",
106
+ });
107
+ });
108
+
109
+ test("a stored payload without typed fields recovers its kind", () => {
110
+ // Rows predating the typed fields carry the spelling only. This is the
111
+ // one place the kind is inferred, and each spelling has one answer.
112
+ const at = (emoji: string) =>
113
+ resolveInboundReactionPayload({
114
+ eventKind: "reaction",
115
+ reaction: { op: "added", emoji, targetMessageId: "11.22" },
116
+ });
117
+ expect(at("<:blob_wave:987>")).toMatchObject({
118
+ emojiKind: "custom",
119
+ emojiName: "blob_wave",
120
+ emojiId: "987",
121
+ });
122
+ // The plain form records nothing about animation, so the field is
123
+ // absent rather than a fabricated false; a resolver must not read
124
+ // "not animated" off a row that never knew.
125
+ expect(at("<:blob_wave:987>")).not.toHaveProperty("emojiAnimated");
126
+ expect(at("<a:party:5>")).toMatchObject({
127
+ emojiKind: "custom",
128
+ emojiName: "party",
129
+ emojiId: "5",
130
+ emojiAnimated: true,
131
+ });
132
+ expect(at("thumbsup")).toMatchObject({
133
+ emojiKind: "shortcode",
134
+ emojiName: "thumbsup",
135
+ });
136
+ expect(at("🎉")).toMatchObject({ emojiKind: "unicode", emojiName: "🎉" });
137
+ // The spelling itself is never rewritten: the write path parses it back.
138
+ expect(at("<:blob_wave:987>")?.emoji).toBe("<:blob_wave:987>");
94
139
  });
95
140
 
96
141
  test("a replayed payload parses its sentinel and wire target", () => {
@@ -99,13 +144,25 @@ describe("resolveInboundReactionPayload", () => {
99
144
  callbackData: "reaction:thumbsup",
100
145
  sourceMetadata: { messageId: "33.44" },
101
146
  }),
102
- ).toEqual({ op: "added", emoji: "thumbsup", targetMessageId: "33.44" });
147
+ ).toEqual({
148
+ op: "added",
149
+ emoji: "thumbsup",
150
+ emojiKind: "shortcode",
151
+ emojiName: "thumbsup",
152
+ targetMessageId: "33.44",
153
+ });
103
154
  expect(
104
155
  resolveInboundReactionPayload({
105
156
  callbackData: "reaction_removed:thumbsup",
106
157
  sourceMetadata: { messageId: "33.44" },
107
158
  }),
108
- ).toEqual({ op: "removed", emoji: "thumbsup", targetMessageId: "33.44" });
159
+ ).toEqual({
160
+ op: "removed",
161
+ emoji: "thumbsup",
162
+ emojiKind: "shortcode",
163
+ emojiName: "thumbsup",
164
+ targetMessageId: "33.44",
165
+ });
109
166
  });
110
167
 
111
168
  test("no emoji or no target is not a reaction payload", () => {
@@ -126,3 +183,51 @@ describe("resolveInboundReactionPayload", () => {
126
183
  ).toBeNull();
127
184
  });
128
185
  });
186
+
187
+ describe("RuntimeInboundPayloadSchema carries a reaction's typed emoji", () => {
188
+ const base = {
189
+ sourceChannel: "discord",
190
+ interface: "discord",
191
+ conversationExternalId: "chan-1",
192
+ externalMessageId: "msg-1:reaction:<:party_blob:111>:user-1:ingest-1",
193
+ content: "",
194
+ eventKind: "reaction",
195
+ actorExternalId: "user-1",
196
+ };
197
+
198
+ test("a custom emoji keeps all four typed fields through the wire schema", () => {
199
+ const parsed = RuntimeInboundPayloadSchema.parse({
200
+ ...base,
201
+ reaction: {
202
+ op: "added",
203
+ emoji: "<:party_blob:111>",
204
+ emojiKind: "custom",
205
+ emojiName: "party_blob",
206
+ emojiId: "111",
207
+ emojiAnimated: true,
208
+ targetMessageId: "msg-1",
209
+ },
210
+ });
211
+ expect(parsed.reaction).toEqual({
212
+ op: "added",
213
+ emoji: "<:party_blob:111>",
214
+ emojiKind: "custom",
215
+ emojiName: "party_blob",
216
+ emojiId: "111",
217
+ emojiAnimated: true,
218
+ targetMessageId: "msg-1",
219
+ });
220
+ });
221
+
222
+ test("a reaction carrying only the spelling still parses", () => {
223
+ const parsed = RuntimeInboundPayloadSchema.parse({
224
+ ...base,
225
+ reaction: { op: "removed", emoji: "+1", targetMessageId: "msg-1" },
226
+ });
227
+ expect(parsed.reaction).toEqual({
228
+ op: "removed",
229
+ emoji: "+1",
230
+ targetMessageId: "msg-1",
231
+ });
232
+ });
233
+ });
@@ -15,13 +15,9 @@
15
15
  * daemon's client-facing HTTP surface owns the distinct `guardian_actions_*`
16
16
  * operationIds (`guardian_actions_pending` / `guardian_actions_decision`),
17
17
  * which do not change.
18
- *
19
- * Destination lookups are deliberately split into a single-message lookup
20
- * (`get_by_destination_message`) and a pending-list read
21
- * (`list_pending_by_destination`) so each response schema has exactly one
22
- * shape instead of a params-dependent polymorphic result.
23
18
  */
24
19
 
20
+ import { GuardianRequestStatusSchema } from "@vellumai/service-contracts/guardian-requests";
25
21
  import { z } from "zod";
26
22
 
27
23
  import {
@@ -33,19 +29,11 @@ import {
33
29
  // Enums
34
30
  // ---------------------------------------------------------------------------
35
31
 
36
- const GUARDIAN_REQUEST_STATUS_VALUES = [
37
- "pending",
38
- "approved",
39
- "denied",
40
- "expired",
41
- "cancelled",
42
- ] as const;
43
-
44
- export const GuardianRequestStatusSchema = z.enum(
32
+ export {
45
33
  GUARDIAN_REQUEST_STATUS_VALUES,
46
- );
47
-
48
- export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
34
+ type GuardianRequestStatus,
35
+ GuardianRequestStatusSchema,
36
+ } from "@vellumai/service-contracts/guardian-requests";
49
37
 
50
38
  const GUARDIAN_REQUEST_KIND_VALUES = [
51
39
  "access_request",
@@ -194,7 +182,6 @@ export const GUARDIAN_REQUESTS_IPC_METHODS = {
194
182
  updateDelivery: "guardian_requests_update_delivery",
195
183
  listDeliveries: "guardian_requests_list_deliveries",
196
184
  listDeliveriesByChat: "guardian_requests_list_deliveries_by_chat",
197
- getByDestinationMessage: "guardian_requests_get_by_destination_message",
198
185
  listPendingByDestination: "guardian_requests_list_pending_by_destination",
199
186
  listPendingByScope: "guardian_requests_list_pending_by_scope",
200
187
  inScope: "guardian_requests_in_scope",
@@ -627,21 +614,6 @@ export type GuardianRequestDeliveryListIpcResponse = z.infer<
627
614
  // Destination + scope lookups
628
615
  // ---------------------------------------------------------------------------
629
616
 
630
- /**
631
- * Request for `guardian_requests_get_by_destination_message` — reaction
632
- * routing: recover the pending request whose delivered card is the reacted-to
633
- * message.
634
- */
635
- export const GetGuardianRequestByDestinationMessageIpcParamsSchema = z.object({
636
- channel: z.string().min(1),
637
- chatId: z.string().min(1),
638
- messageId: z.string().min(1),
639
- });
640
-
641
- export type GetGuardianRequestByDestinationMessageIpcParams = z.infer<
642
- typeof GetGuardianRequestByDestinationMessageIpcParamsSchema
643
- >;
644
-
645
617
  /**
646
618
  * Request for `guardian_requests_list_pending_by_destination`. Two forms:
647
619
  * by destination conversation (`conversationId`, optionally narrowed by
@@ -13,6 +13,8 @@
13
13
  import { INBOUND_EVENT_KINDS } from "./inbound-event-kind.js";
14
14
  import { z } from "zod";
15
15
 
16
+ import { ReactionEmojiFieldsSchema } from "@vellumai/service-contracts/reactions";
17
+
16
18
  import { ChannelConversationTypeSchema } from "./channel-permission-contract.js";
17
19
 
18
20
  import { AdmissionPolicySchema } from "./admission-policy-contract.js";
@@ -183,6 +185,7 @@ export const RuntimeInboundPayloadSchema = z.object({
183
185
  .object({
184
186
  op: z.enum(["added", "removed"]),
185
187
  emoji: z.string(),
188
+ ...ReactionEmojiFieldsSchema.shape,
186
189
  targetMessageId: z.string(),
187
190
  })
188
191
  .optional(),