@canonmsg/backend-contracts 6.2.0 → 6.4.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/README.md CHANGED
@@ -16,7 +16,7 @@ Node.js 18+. Ships both ESM and CommonJS builds — Cloud Functions consume the
16
16
 
17
17
  ## What is in it
18
18
 
19
- **The verb contract.** `canon.verbs.v1` (`verbContract.ts`) is the plaintext *intent* schema — seventeen verbs, their input and result schemas, byte limits, and rate limits. `canon.verb-wire.v1` (`verbWire.ts`) is what actually crosses the network: a server-readable envelope plus a body that is either `{ encoding: 'json', value }` or `{ encoding: 'mls', … }`. `projectVerbIntentToWire` and `mergeVerbWireToIntent` are the two halves of that split, so bindings and the server never disagree about which fields are envelope and which are content.
19
+ **The verb contract.** `canon.verbs.v1` (`verbContract.ts`) is the plaintext *intent* schema — eighteen verbs, their input and result schemas, byte limits, and rate limits. `canon.verb-wire.v1` (`verbWire.ts`) is what actually crosses the network: a server-readable envelope plus a body that is either `{ encoding: 'json', value }` or `{ encoding: 'mls', … }`. `projectVerbIntentToWire` and `mergeVerbWireToIntent` are the two halves of that split, so bindings and the server never disagree about which fields are envelope and which are content.
20
20
 
21
21
  ```ts
22
22
  import {
@@ -33,6 +33,7 @@
33
33
  "list_contacts",
34
34
  "list_contact_requests",
35
35
  "list_conversations",
36
+ "cancel_contact_request",
36
37
  "no_reply"
37
38
  ]
38
39
  },
@@ -427,6 +428,10 @@
427
428
  "null"
428
429
  ]
429
430
  },
431
+ "sourceMessageId": {
432
+ "type": "string",
433
+ "minLength": 1
434
+ },
430
435
  "turnSemantics": {
431
436
  "enum": [
432
437
  "progress",
@@ -508,6 +513,7 @@
508
513
  "list_contacts",
509
514
  "list_contact_requests",
510
515
  "list_conversations",
516
+ "cancel_contact_request",
511
517
  "no_reply"
512
518
  ]
513
519
  },
@@ -1767,11 +1767,26 @@
1767
1767
  "list_contact_requests_input": {
1768
1768
  "type": "object",
1769
1769
  "additionalProperties": false,
1770
- "properties": {}
1770
+ "properties": {
1771
+ "direction": {
1772
+ "enum": [
1773
+ "inbound",
1774
+ "outbound"
1775
+ ]
1776
+ },
1777
+ "includeResolved": {
1778
+ "type": "boolean"
1779
+ },
1780
+ "limit": {
1781
+ "type": "integer",
1782
+ "minimum": 1,
1783
+ "maximum": 100
1784
+ }
1785
+ }
1771
1786
  },
1772
1787
  "list_contact_requests_result": {
1773
1788
  "type": "object",
1774
- "description": "Pending inbound requests, newest first, capped at 100. Items are SerializedContactRequest (backend-contracts contactRequest.ts). Read-only awareness: approval routes to the owner; agents cannot approve/reject.",
1789
+ "description": "Contact requests, newest first, capped at 100. Defaults to pending inbound; requesters use direction=outbound with includeResolved=true to recover lifecycle state. Items are SerializedContactRequest (backend-contracts contactRequest.ts).",
1775
1790
  "required": [
1776
1791
  "requests"
1777
1792
  ],
@@ -1786,7 +1801,8 @@
1786
1801
  "requesterId",
1787
1802
  "targetId",
1788
1803
  "status",
1789
- "kind"
1804
+ "kind",
1805
+ "phase"
1790
1806
  ],
1791
1807
  "additionalProperties": true,
1792
1808
  "properties": {
@@ -1807,7 +1823,8 @@
1807
1823
  "pending",
1808
1824
  "approved",
1809
1825
  "rejected",
1810
- "expired"
1826
+ "expired",
1827
+ "cancelled"
1811
1828
  ]
1812
1829
  },
1813
1830
  "kind": {
@@ -1816,6 +1833,17 @@
1816
1833
  "group_invite"
1817
1834
  ]
1818
1835
  },
1836
+ "phase": {
1837
+ "enum": [
1838
+ "awaiting_owner",
1839
+ "starting",
1840
+ "connected",
1841
+ "rejected",
1842
+ "expired",
1843
+ "cancelled",
1844
+ "failed"
1845
+ ]
1846
+ },
1819
1847
  "message": {
1820
1848
  "type": [
1821
1849
  "string",
@@ -1949,6 +1977,35 @@
1949
1977
  }
1950
1978
  }
1951
1979
  },
1980
+ "cancel_contact_request_input": {
1981
+ "type": "object",
1982
+ "required": [
1983
+ "requestId"
1984
+ ],
1985
+ "additionalProperties": false,
1986
+ "properties": {
1987
+ "requestId": {
1988
+ "type": "string",
1989
+ "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
1990
+ }
1991
+ }
1992
+ },
1993
+ "cancel_contact_request_result": {
1994
+ "type": "object",
1995
+ "required": [
1996
+ "status",
1997
+ "requestId"
1998
+ ],
1999
+ "additionalProperties": true,
2000
+ "properties": {
2001
+ "status": {
2002
+ "const": "cancelled"
2003
+ },
2004
+ "requestId": {
2005
+ "type": "string"
2006
+ }
2007
+ }
2008
+ },
1952
2009
  "no_reply_input": {
1953
2010
  "type": "object",
1954
2011
  "description": "End this turn without posting anything to the conversation. Nothing is rendered and no other member or agent is triggered.",
@@ -1,18 +1,29 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readContactRequestRequirements = readContactRequestRequirements;
3
4
  exports.readGroupInviteRequirements = readGroupInviteRequirements;
5
+ exports.deriveContactRequestPhase = deriveContactRequestPhase;
4
6
  exports.serializeContactRequest = serializeContactRequest;
5
- function readGroupInviteRequirements(value) {
7
+ function readContactRequestRequirements(value) {
6
8
  const record = isRecord(value) ? value : null;
7
9
  if (typeof record?.policyApproval !== 'boolean'
8
10
  || typeof record.ownerSessionSetup !== 'boolean') {
9
- throw new TypeError('Invalid group invite requirements');
11
+ throw new TypeError('Invalid contact request requirements');
10
12
  }
11
13
  return {
12
14
  policyApproval: record.policyApproval,
13
15
  ownerSessionSetup: record.ownerSessionSetup,
14
16
  };
15
17
  }
18
+ /** Backward-compatible parser retained for existing group invite clients. */
19
+ function readGroupInviteRequirements(value) {
20
+ try {
21
+ return readContactRequestRequirements(value);
22
+ }
23
+ catch {
24
+ throw new TypeError('Invalid group invite requirements');
25
+ }
26
+ }
16
27
  function isRecord(value) {
17
28
  return typeof value === 'object' && value !== null && !Array.isArray(value);
18
29
  }
@@ -30,12 +41,36 @@ function normalizeStatus(value) {
30
41
  if (value === 'pending'
31
42
  || value === 'approved'
32
43
  || value === 'rejected'
33
- || value === 'expired') {
44
+ || value === 'expired'
45
+ || value === 'cancelled') {
34
46
  return value;
35
47
  }
36
48
  return 'pending';
37
49
  }
38
- function serializeContactRequest(requestId, data) {
50
+ function deriveContactRequestPhase(data) {
51
+ const status = data.status;
52
+ const openingStatus = data.openingMessageStatus;
53
+ let phase;
54
+ if (status === 'cancelled')
55
+ phase = 'cancelled';
56
+ else if (status === 'rejected')
57
+ phase = 'rejected';
58
+ else if (status === 'expired')
59
+ phase = 'expired';
60
+ else if (status === 'delivery_failed' || openingStatus === 'failed')
61
+ phase = 'failed';
62
+ else if (status === 'approved')
63
+ phase = 'connected';
64
+ else if (status === 'delivery_pending'
65
+ || openingStatus === 'ready'
66
+ || openingStatus === 'delivering'
67
+ || openingStatus === 'retryable')
68
+ phase = 'starting';
69
+ else
70
+ phase = 'awaiting_owner';
71
+ return phase;
72
+ }
73
+ function serializeContactRequest(requestId, data, options = {}) {
39
74
  if (typeof data.approverId !== 'string' || data.approverId.length === 0) {
40
75
  return null;
41
76
  }
@@ -92,21 +127,35 @@ function serializeContactRequest(requestId, data) {
92
127
  message: null,
93
128
  status: normalizeStatus(data.status),
94
129
  kind: kindValue,
130
+ phase: deriveContactRequestPhase(data),
95
131
  createdAt: normalizeTimestamp(data.createdAt),
96
132
  resolvedAt: normalizeTimestamp(data.resolvedAt),
97
133
  expiresAt: normalizeTimestamp(data.expiresAt),
98
134
  };
99
- if (groupContext) {
135
+ if (groupContext || kindValue === 'dm') {
100
136
  let requirements;
101
137
  try {
102
- requirements = readGroupInviteRequirements(data.requirements);
138
+ requirements = data.requirements === undefined && kindValue === 'dm'
139
+ ? { policyApproval: true, ownerSessionSetup: false }
140
+ : readContactRequestRequirements(data.requirements);
103
141
  }
104
142
  catch {
105
143
  return null;
106
144
  }
107
- payload.groupContext = groupContext;
108
145
  payload.requirements = requirements;
109
146
  }
147
+ if (groupContext)
148
+ payload.groupContext = groupContext;
149
+ if (options.audience === 'requester') {
150
+ if (typeof data.sourceConversationId === 'string' && data.sourceConversationId.length > 0) {
151
+ payload.sourceConversationId = data.sourceConversationId;
152
+ }
153
+ if (payload.phase === 'connected'
154
+ && typeof data.openingMessageConversationId === 'string'
155
+ && data.openingMessageConversationId.length > 0) {
156
+ payload.conversationId = data.openingMessageConversationId;
157
+ }
158
+ }
110
159
  if (data.targetUserType === 'ai_agent') {
111
160
  payload.targetOwnerId = data.targetOwnerId;
112
161
  }
@@ -1,8 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AGENT_CLIENT_TYPES = void 0;
3
4
  exports.serializeStoredMessage = serializeStoredMessage;
4
5
  const media_js_1 = require("./media.js");
5
6
  const runtimeCardStorage_js_1 = require("./runtimeCardStorage.js");
7
+ /** Canonical agent client identifiers shared by Canon services and clients. */
8
+ exports.AGENT_CLIENT_TYPES = [
9
+ 'claude-code',
10
+ 'openclaw',
11
+ 'codex',
12
+ 'hermes',
13
+ 'deepseek-harness',
14
+ 'generic',
15
+ ];
6
16
  function isRecord(value) {
7
17
  return typeof value === 'object' && value !== null && !Array.isArray(value);
8
18
  }
@@ -46,14 +56,9 @@ function requireSenderType(value) {
46
56
  throw new Error('Message is missing canonical senderType');
47
57
  }
48
58
  function normalizeAgentClientType(value) {
49
- if (value === 'claude-code'
50
- || value === 'openclaw'
51
- || value === 'codex'
52
- || value === 'hermes'
53
- || value === 'generic') {
54
- return value;
55
- }
56
- return undefined;
59
+ return exports.AGENT_CLIENT_TYPES.includes(value)
60
+ ? value
61
+ : undefined;
57
62
  }
58
63
  function normalizeContentType(value) {
59
64
  if (value === 'text'
@@ -206,6 +206,7 @@ exports.CANON_VERB_NAMES = [
206
206
  'list_contacts',
207
207
  'list_contact_requests',
208
208
  'list_conversations',
209
+ 'cancel_contact_request',
209
210
  'no_reply',
210
211
  ];
211
212
  exports.VERB_NATIVE_METADATA_KEYS = [
@@ -1004,13 +1004,17 @@ const list_contacts_result = {
1004
1004
  const list_contact_requests_input = {
1005
1005
  type: 'object',
1006
1006
  additionalProperties: false,
1007
- properties: {},
1007
+ properties: {
1008
+ direction: { enum: ['inbound', 'outbound'] },
1009
+ includeResolved: { type: 'boolean' },
1010
+ limit: { type: 'integer', minimum: 1, maximum: 100 },
1011
+ },
1008
1012
  };
1009
1013
  const list_contact_requests_result = {
1010
1014
  type: 'object',
1011
- description: 'Pending inbound requests, newest first, capped at 100. Items are '
1012
- + 'SerializedContactRequest (backend-contracts contactRequest.ts). Read-only awareness: '
1013
- + "approval routes to the owner; agents cannot approve/reject.",
1015
+ description: 'Contact requests, newest first, capped at 100. Defaults to pending inbound; '
1016
+ + 'requesters use direction=outbound with includeResolved=true to recover lifecycle state. '
1017
+ + 'Items are SerializedContactRequest (backend-contracts contactRequest.ts).',
1014
1018
  required: ['requests'],
1015
1019
  additionalProperties: true,
1016
1020
  properties: {
@@ -1018,15 +1022,26 @@ const list_contact_requests_result = {
1018
1022
  type: 'array',
1019
1023
  items: {
1020
1024
  type: 'object',
1021
- required: ['id', 'requesterId', 'targetId', 'status', 'kind'],
1025
+ required: ['id', 'requesterId', 'targetId', 'status', 'kind', 'phase'],
1022
1026
  additionalProperties: true,
1023
1027
  properties: {
1024
1028
  id: { type: 'string' },
1025
1029
  requesterId: { type: 'string' },
1026
1030
  requesterName: { type: 'string' },
1027
1031
  targetId: { type: 'string' },
1028
- status: { enum: ['pending', 'approved', 'rejected', 'expired'] },
1032
+ status: { enum: ['pending', 'approved', 'rejected', 'expired', 'cancelled'] },
1029
1033
  kind: { enum: ['dm', 'group_invite'] },
1034
+ phase: {
1035
+ enum: [
1036
+ 'awaiting_owner',
1037
+ 'starting',
1038
+ 'connected',
1039
+ 'rejected',
1040
+ 'expired',
1041
+ 'cancelled',
1042
+ 'failed',
1043
+ ],
1044
+ },
1030
1045
  message: { type: ['string', 'null'] },
1031
1046
  createdAt: { type: ['string', 'null'] },
1032
1047
  expiresAt: { type: ['string', 'null'] },
@@ -1035,6 +1050,23 @@ const list_contact_requests_result = {
1035
1050
  },
1036
1051
  },
1037
1052
  };
1053
+ const cancel_contact_request_input = {
1054
+ type: 'object',
1055
+ required: ['requestId'],
1056
+ additionalProperties: false,
1057
+ properties: {
1058
+ requestId: { type: 'string', pattern: verbContract_js_1.VERB_ID_PATTERNS.runtimeId },
1059
+ },
1060
+ };
1061
+ const cancel_contact_request_result = {
1062
+ type: 'object',
1063
+ required: ['status', 'requestId'],
1064
+ additionalProperties: true,
1065
+ properties: {
1066
+ status: { const: 'cancelled' },
1067
+ requestId: { type: 'string' },
1068
+ },
1069
+ };
1038
1070
  const list_conversations_input = {
1039
1071
  type: 'object',
1040
1072
  additionalProperties: false,
@@ -1174,6 +1206,8 @@ exports.CANON_VERBS_JSON_SCHEMA = {
1174
1206
  list_contact_requests_result,
1175
1207
  list_conversations_input,
1176
1208
  list_conversations_result,
1209
+ cancel_contact_request_input,
1210
+ cancel_contact_request_result,
1177
1211
  no_reply_input,
1178
1212
  no_reply_result,
1179
1213
  },
@@ -118,6 +118,7 @@ exports.VERB_WIRE_ENVELOPE_FIELDS = {
118
118
  list_contacts: { required: [], optional: [] },
119
119
  list_contact_requests: { required: [], optional: [] },
120
120
  list_conversations: { required: [], optional: ['limit'] },
121
+ cancel_contact_request: { required: ['requestId'], optional: [] },
121
122
  no_reply: { required: [], optional: ['conversationId', 'messageId'] },
122
123
  };
123
124
  // ---------------------------------------------------------------------------
@@ -159,6 +160,7 @@ const wireTurnDef = {
159
160
  additionalProperties: false,
160
161
  properties: {
161
162
  turnId: { type: ['string', 'null'] },
163
+ sourceMessageId: { type: 'string', minLength: 1 },
162
164
  turnSemantics: { enum: ['progress', 'turn_complete', 'control'] },
163
165
  deliveryIntent: { enum: ['queue', 'interrupt', 'interleave', 'stop'] },
164
166
  replyBehavior: { enum: ['allow_auto_reply', 'suppress_auto_reply'] },
@@ -284,7 +286,13 @@ exports.CANON_VERB_WIRE_JSON_SCHEMA = {
284
286
  // ---------------------------------------------------------------------------
285
287
  // Intent <-> wire projection (json codec)
286
288
  // ---------------------------------------------------------------------------
287
- const TURN_KEYS = ['turnId', 'turnSemantics', 'deliveryIntent', 'replyBehavior'];
289
+ const TURN_KEYS = [
290
+ 'turnId',
291
+ 'sourceMessageId',
292
+ 'turnSemantics',
293
+ 'deliveryIntent',
294
+ 'replyBehavior',
295
+ ];
288
296
  function splitTurnMetadata(metadata) {
289
297
  if (!metadata || typeof metadata !== 'object')
290
298
  return { turn: undefined, rest: undefined };
@@ -469,14 +477,25 @@ function projectVerbIntentToWire(verb, intent, options) {
469
477
  envelope = compact({ conversationId: input.conversationId });
470
478
  break;
471
479
  }
472
- case 'list_contacts':
480
+ case 'list_contacts': {
481
+ break;
482
+ }
473
483
  case 'list_contact_requests': {
484
+ value = compact({
485
+ direction: input.direction,
486
+ includeResolved: input.includeResolved,
487
+ limit: input.limit,
488
+ });
474
489
  break;
475
490
  }
476
491
  case 'list_conversations': {
477
492
  envelope = compact({ limit: input.limit });
478
493
  break;
479
494
  }
495
+ case 'cancel_contact_request': {
496
+ envelope = compact({ requestId: input.requestId });
497
+ break;
498
+ }
480
499
  case 'no_reply': {
481
500
  envelope = compact({ conversationId: input.conversationId, messageId: input.messageId });
482
501
  value = compact({ reason: input.reason });
@@ -615,10 +634,17 @@ function mergeVerbWireToIntent(request) {
615
634
  case 'leave_conversation':
616
635
  return compact({ conversationId: envelope.conversationId });
617
636
  case 'list_contacts':
618
- case 'list_contact_requests':
619
637
  return {};
638
+ case 'list_contact_requests':
639
+ return compact({
640
+ direction: value.direction,
641
+ includeResolved: value.includeResolved,
642
+ limit: value.limit,
643
+ });
620
644
  case 'list_conversations':
621
645
  return compact({ limit: envelope.limit });
646
+ case 'cancel_contact_request':
647
+ return compact({ requestId: envelope.requestId });
622
648
  case 'no_reply':
623
649
  return compact({ conversationId: envelope.conversationId, messageId: envelope.messageId, reason: value.reason });
624
650
  }
@@ -1,12 +1,18 @@
1
- export type SerializedContactRequestStatus = 'pending' | 'approved' | 'rejected' | 'expired';
1
+ export type SerializedContactRequestStatus = 'pending' | 'approved' | 'rejected' | 'expired' | 'cancelled';
2
+ export type ContactRequestLifecyclePhase = 'awaiting_owner' | 'starting' | 'connected' | 'rejected' | 'expired' | 'cancelled' | 'failed';
2
3
  /**
3
- * Independent gates that must be satisfied before a pending group invite can
4
- * add its target.
4
+ * Independent gates that must be satisfied before a pending contact request
5
+ * can activate. DM introductions and group invites share this owner-review
6
+ * vocabulary even though their activation pipelines remain separate.
5
7
  */
6
- export interface GroupInviteRequirements {
8
+ export interface ContactRequestRequirements {
7
9
  policyApproval: boolean;
8
10
  ownerSessionSetup: boolean;
9
11
  }
12
+ /** Backward-compatible name retained for existing group invite clients. */
13
+ export type GroupInviteRequirements = ContactRequestRequirements;
14
+ export declare function readContactRequestRequirements(value: unknown): ContactRequestRequirements;
15
+ /** Backward-compatible parser retained for existing group invite clients. */
10
16
  export declare function readGroupInviteRequirements(value: unknown): GroupInviteRequirements;
11
17
  export interface SerializedContactRequest {
12
18
  id: string;
@@ -27,10 +33,18 @@ export interface SerializedContactRequest {
27
33
  conversationId: string;
28
34
  groupName: string | null;
29
35
  };
30
- /** Present for group_invite payloads; omitted for DM requests. */
31
- requirements?: GroupInviteRequirements;
36
+ /** Present for group invites and setup/policy-aware DM introductions. */
37
+ requirements?: ContactRequestRequirements;
38
+ phase: ContactRequestLifecyclePhase;
39
+ /** Requester-only source lane for an opener-backed introduction. */
40
+ sourceConversationId?: string;
41
+ /** Requester-only destination, present after an opener-backed DM connects. */
42
+ conversationId?: string;
32
43
  createdAt: string | null;
33
44
  resolvedAt?: string | null;
34
45
  expiresAt?: string | null;
35
46
  }
36
- export declare function serializeContactRequest(requestId: string, data: Record<string, unknown>): SerializedContactRequest | null;
47
+ export declare function deriveContactRequestPhase(data: Record<string, unknown>): ContactRequestLifecyclePhase;
48
+ export declare function serializeContactRequest(requestId: string, data: Record<string, unknown>, options?: {
49
+ audience?: 'inbound' | 'requester';
50
+ }): SerializedContactRequest | null;
@@ -1,14 +1,23 @@
1
- export function readGroupInviteRequirements(value) {
1
+ export function readContactRequestRequirements(value) {
2
2
  const record = isRecord(value) ? value : null;
3
3
  if (typeof record?.policyApproval !== 'boolean'
4
4
  || typeof record.ownerSessionSetup !== 'boolean') {
5
- throw new TypeError('Invalid group invite requirements');
5
+ throw new TypeError('Invalid contact request requirements');
6
6
  }
7
7
  return {
8
8
  policyApproval: record.policyApproval,
9
9
  ownerSessionSetup: record.ownerSessionSetup,
10
10
  };
11
11
  }
12
+ /** Backward-compatible parser retained for existing group invite clients. */
13
+ export function readGroupInviteRequirements(value) {
14
+ try {
15
+ return readContactRequestRequirements(value);
16
+ }
17
+ catch {
18
+ throw new TypeError('Invalid group invite requirements');
19
+ }
20
+ }
12
21
  function isRecord(value) {
13
22
  return typeof value === 'object' && value !== null && !Array.isArray(value);
14
23
  }
@@ -26,12 +35,36 @@ function normalizeStatus(value) {
26
35
  if (value === 'pending'
27
36
  || value === 'approved'
28
37
  || value === 'rejected'
29
- || value === 'expired') {
38
+ || value === 'expired'
39
+ || value === 'cancelled') {
30
40
  return value;
31
41
  }
32
42
  return 'pending';
33
43
  }
34
- export function serializeContactRequest(requestId, data) {
44
+ export function deriveContactRequestPhase(data) {
45
+ const status = data.status;
46
+ const openingStatus = data.openingMessageStatus;
47
+ let phase;
48
+ if (status === 'cancelled')
49
+ phase = 'cancelled';
50
+ else if (status === 'rejected')
51
+ phase = 'rejected';
52
+ else if (status === 'expired')
53
+ phase = 'expired';
54
+ else if (status === 'delivery_failed' || openingStatus === 'failed')
55
+ phase = 'failed';
56
+ else if (status === 'approved')
57
+ phase = 'connected';
58
+ else if (status === 'delivery_pending'
59
+ || openingStatus === 'ready'
60
+ || openingStatus === 'delivering'
61
+ || openingStatus === 'retryable')
62
+ phase = 'starting';
63
+ else
64
+ phase = 'awaiting_owner';
65
+ return phase;
66
+ }
67
+ export function serializeContactRequest(requestId, data, options = {}) {
35
68
  if (typeof data.approverId !== 'string' || data.approverId.length === 0) {
36
69
  return null;
37
70
  }
@@ -88,21 +121,35 @@ export function serializeContactRequest(requestId, data) {
88
121
  message: null,
89
122
  status: normalizeStatus(data.status),
90
123
  kind: kindValue,
124
+ phase: deriveContactRequestPhase(data),
91
125
  createdAt: normalizeTimestamp(data.createdAt),
92
126
  resolvedAt: normalizeTimestamp(data.resolvedAt),
93
127
  expiresAt: normalizeTimestamp(data.expiresAt),
94
128
  };
95
- if (groupContext) {
129
+ if (groupContext || kindValue === 'dm') {
96
130
  let requirements;
97
131
  try {
98
- requirements = readGroupInviteRequirements(data.requirements);
132
+ requirements = data.requirements === undefined && kindValue === 'dm'
133
+ ? { policyApproval: true, ownerSessionSetup: false }
134
+ : readContactRequestRequirements(data.requirements);
99
135
  }
100
136
  catch {
101
137
  return null;
102
138
  }
103
- payload.groupContext = groupContext;
104
139
  payload.requirements = requirements;
105
140
  }
141
+ if (groupContext)
142
+ payload.groupContext = groupContext;
143
+ if (options.audience === 'requester') {
144
+ if (typeof data.sourceConversationId === 'string' && data.sourceConversationId.length > 0) {
145
+ payload.sourceConversationId = data.sourceConversationId;
146
+ }
147
+ if (payload.phase === 'connected'
148
+ && typeof data.openingMessageConversationId === 'string'
149
+ && data.openingMessageConversationId.length > 0) {
150
+ payload.conversationId = data.openingMessageConversationId;
151
+ }
152
+ }
106
153
  if (data.targetUserType === 'ai_agent') {
107
154
  payload.targetOwnerId = data.targetOwnerId;
108
155
  }
package/dist/message.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { MediaAttachment } from './media.js';
2
2
  export type SerializedSenderType = 'human' | 'ai_agent';
3
- export type SerializedAgentClientType = 'claude-code' | 'openclaw' | 'codex' | 'hermes' | 'generic';
3
+ /** Canonical agent client identifiers shared by Canon services and clients. */
4
+ export declare const AGENT_CLIENT_TYPES: readonly ["claude-code", "openclaw", "codex", "hermes", "deepseek-harness", "generic"];
5
+ export type SerializedAgentClientType = typeof AGENT_CLIENT_TYPES[number];
4
6
  export type SerializedContentType = 'text' | 'image' | 'audio' | 'video' | 'file' | 'contact_card' | 'interaction';
5
7
  export interface ForwardedFrom {
6
8
  sourceConversationId: string;
package/dist/message.js CHANGED
@@ -1,5 +1,14 @@
1
1
  import { normalizeStoredAttachments } from './media.js';
2
2
  import { decodeStoredRuntimeCard } from './runtimeCardStorage.js';
3
+ /** Canonical agent client identifiers shared by Canon services and clients. */
4
+ export const AGENT_CLIENT_TYPES = [
5
+ 'claude-code',
6
+ 'openclaw',
7
+ 'codex',
8
+ 'hermes',
9
+ 'deepseek-harness',
10
+ 'generic',
11
+ ];
3
12
  function isRecord(value) {
4
13
  return typeof value === 'object' && value !== null && !Array.isArray(value);
5
14
  }
@@ -43,14 +52,9 @@ function requireSenderType(value) {
43
52
  throw new Error('Message is missing canonical senderType');
44
53
  }
45
54
  function normalizeAgentClientType(value) {
46
- if (value === 'claude-code'
47
- || value === 'openclaw'
48
- || value === 'codex'
49
- || value === 'hermes'
50
- || value === 'generic') {
51
- return value;
52
- }
53
- return undefined;
55
+ return AGENT_CLIENT_TYPES.includes(value)
56
+ ? value
57
+ : undefined;
54
58
  }
55
59
  function normalizeContentType(value) {
56
60
  if (value === 'text'
@@ -42,7 +42,7 @@
42
42
  * `findVerbByteLimitViolations()` (or equivalent checks from the emitted
43
43
  * canon-verbs.limits.json) after schema validation.
44
44
  */
45
- import type { GroupInviteRequirements } from './contactRequest.js';
45
+ import type { ContactRequestRequirements } from './contactRequest.js';
46
46
  /** Identifier for this contract document. */
47
47
  export declare const CANON_VERBS_SCHEMA_VERSION = "canon.verbs.v1";
48
48
  /** $id of the emitted JSON Schema bundle. */
@@ -184,7 +184,7 @@ export declare const VERB_RATE_LIMITS: {
184
184
  /** The one self-context type the platform accepts today. */
185
185
  export declare const SELF_CONTEXT_TYPE = "cross_session";
186
186
  /** Canonical verb names. */
187
- export declare const CANON_VERB_NAMES: readonly ["send_to", "request_input", "request_approval", "check_approval", "send_card", "request_card", "share_contact", "react", "forward", "create_group", "add_member", "remove_member", "leave_conversation", "list_contacts", "list_contact_requests", "list_conversations", "no_reply"];
187
+ export declare const CANON_VERB_NAMES: readonly ["send_to", "request_input", "request_approval", "check_approval", "send_card", "request_card", "share_contact", "react", "forward", "create_group", "add_member", "remove_member", "leave_conversation", "list_contacts", "list_contact_requests", "list_conversations", "cancel_contact_request", "no_reply"];
188
188
  export type CanonVerbName = (typeof CANON_VERB_NAMES)[number];
189
189
  /** Private note-to-self attached to a cross-conversation send. */
190
190
  export interface VerbSelfContext {
@@ -268,6 +268,7 @@ export interface VerbMediaAttachment {
268
268
  */
269
269
  export interface VerbTurnMetadata {
270
270
  turnId?: string | null;
271
+ sourceMessageId?: string;
271
272
  turnSemantics?: 'progress' | 'turn_complete' | 'control';
272
273
  deliveryIntent?: 'queue' | 'interrupt' | 'interleave' | 'stop';
273
274
  replyBehavior?: 'allow_auto_reply' | 'suppress_auto_reply';
@@ -322,7 +323,7 @@ export type SendToResult = {
322
323
  selfContextId?: string;
323
324
  created?: boolean;
324
325
  reused?: boolean;
325
- sessionSelection?: string;
326
+ sessionSelection?: VerbSessionSelection['mode'];
326
327
  } | {
327
328
  status: 'requested';
328
329
  requestId: string | null;
@@ -613,7 +614,7 @@ export interface CreateGroupInput {
613
614
  export interface PendingGroupInviteResult {
614
615
  userId: string;
615
616
  requestId: string;
616
- requirements: GroupInviteRequirements;
617
+ requirements: ContactRequestRequirements;
617
618
  }
618
619
  export interface CreateGroupResult {
619
620
  status: 'created';
@@ -653,7 +654,7 @@ export type AddMemberResult = {
653
654
  } | {
654
655
  status: 'pending';
655
656
  requestId: string;
656
- requirements: GroupInviteRequirements;
657
+ requirements: ContactRequestRequirements;
657
658
  };
658
659
  export interface RemoveMemberInput {
659
660
  conversationId: string;
@@ -680,11 +681,26 @@ export interface VerbContact {
680
681
  export interface ListContactsResult {
681
682
  contacts: VerbContact[];
682
683
  }
683
- export type ListContactRequestsInput = Record<string, never>;
684
- /** Items are SerializedContactRequest (contactRequest.ts) — pending inbound only, newest first, capped at 100. */
684
+ export type ContactRequestListDirection = 'inbound' | 'outbound';
685
+ export interface ListContactRequestsInput {
686
+ /** Defaults to inbound. Outbound is the reconnect/recovery surface. */
687
+ direction?: ContactRequestListDirection;
688
+ /** Include terminal and starting lifecycle states; default false. */
689
+ includeResolved?: boolean;
690
+ /** Newest-first result cap; default and maximum 100. */
691
+ limit?: number;
692
+ }
693
+ /** Items are SerializedContactRequest (contactRequest.ts), newest first, capped at 100. */
685
694
  export interface ListContactRequestsResult {
686
695
  requests: unknown[];
687
696
  }
697
+ export interface CancelContactRequestInput {
698
+ requestId: string;
699
+ }
700
+ export interface CancelContactRequestResult {
701
+ status: 'cancelled';
702
+ requestId: string;
703
+ }
688
704
  export interface ListConversationsInput {
689
705
  /** Optional client-side cap; the REST endpoint returns all memberships. */
690
706
  limit?: number;
@@ -200,6 +200,7 @@ export const CANON_VERB_NAMES = [
200
200
  'list_contacts',
201
201
  'list_contact_requests',
202
202
  'list_conversations',
203
+ 'cancel_contact_request',
203
204
  'no_reply',
204
205
  ];
205
206
  export const VERB_NATIVE_METADATA_KEYS = [
@@ -1615,7 +1615,19 @@ export declare const CANON_VERBS_JSON_SCHEMA: {
1615
1615
  readonly list_contact_requests_input: {
1616
1616
  readonly type: "object";
1617
1617
  readonly additionalProperties: false;
1618
- readonly properties: {};
1618
+ readonly properties: {
1619
+ readonly direction: {
1620
+ readonly enum: readonly ["inbound", "outbound"];
1621
+ };
1622
+ readonly includeResolved: {
1623
+ readonly type: "boolean";
1624
+ };
1625
+ readonly limit: {
1626
+ readonly type: "integer";
1627
+ readonly minimum: 1;
1628
+ readonly maximum: 100;
1629
+ };
1630
+ };
1619
1631
  };
1620
1632
  readonly list_contact_requests_result: {
1621
1633
  readonly type: "object";
@@ -1627,7 +1639,7 @@ export declare const CANON_VERBS_JSON_SCHEMA: {
1627
1639
  readonly type: "array";
1628
1640
  readonly items: {
1629
1641
  readonly type: "object";
1630
- readonly required: readonly ["id", "requesterId", "targetId", "status", "kind"];
1642
+ readonly required: readonly ["id", "requesterId", "targetId", "status", "kind", "phase"];
1631
1643
  readonly additionalProperties: true;
1632
1644
  readonly properties: {
1633
1645
  readonly id: {
@@ -1643,11 +1655,14 @@ export declare const CANON_VERBS_JSON_SCHEMA: {
1643
1655
  readonly type: "string";
1644
1656
  };
1645
1657
  readonly status: {
1646
- readonly enum: readonly ["pending", "approved", "rejected", "expired"];
1658
+ readonly enum: readonly ["pending", "approved", "rejected", "expired", "cancelled"];
1647
1659
  };
1648
1660
  readonly kind: {
1649
1661
  readonly enum: readonly ["dm", "group_invite"];
1650
1662
  };
1663
+ readonly phase: {
1664
+ readonly enum: readonly ["awaiting_owner", "starting", "connected", "rejected", "expired", "cancelled", "failed"];
1665
+ };
1651
1666
  readonly message: {
1652
1667
  readonly type: readonly ["string", "null"];
1653
1668
  };
@@ -1741,6 +1756,30 @@ export declare const CANON_VERBS_JSON_SCHEMA: {
1741
1756
  };
1742
1757
  };
1743
1758
  };
1759
+ readonly cancel_contact_request_input: {
1760
+ readonly type: "object";
1761
+ readonly required: readonly ["requestId"];
1762
+ readonly additionalProperties: false;
1763
+ readonly properties: {
1764
+ readonly requestId: {
1765
+ readonly type: "string";
1766
+ readonly pattern: "^[A-Za-z0-9_.:-]{1,160}$";
1767
+ };
1768
+ };
1769
+ };
1770
+ readonly cancel_contact_request_result: {
1771
+ readonly type: "object";
1772
+ readonly required: readonly ["status", "requestId"];
1773
+ readonly additionalProperties: true;
1774
+ readonly properties: {
1775
+ readonly status: {
1776
+ readonly const: "cancelled";
1777
+ };
1778
+ readonly requestId: {
1779
+ readonly type: "string";
1780
+ };
1781
+ };
1782
+ };
1744
1783
  readonly no_reply_input: {
1745
1784
  readonly type: "object";
1746
1785
  readonly description: string;
@@ -999,13 +999,17 @@ const list_contacts_result = {
999
999
  const list_contact_requests_input = {
1000
1000
  type: 'object',
1001
1001
  additionalProperties: false,
1002
- properties: {},
1002
+ properties: {
1003
+ direction: { enum: ['inbound', 'outbound'] },
1004
+ includeResolved: { type: 'boolean' },
1005
+ limit: { type: 'integer', minimum: 1, maximum: 100 },
1006
+ },
1003
1007
  };
1004
1008
  const list_contact_requests_result = {
1005
1009
  type: 'object',
1006
- description: 'Pending inbound requests, newest first, capped at 100. Items are '
1007
- + 'SerializedContactRequest (backend-contracts contactRequest.ts). Read-only awareness: '
1008
- + "approval routes to the owner; agents cannot approve/reject.",
1010
+ description: 'Contact requests, newest first, capped at 100. Defaults to pending inbound; '
1011
+ + 'requesters use direction=outbound with includeResolved=true to recover lifecycle state. '
1012
+ + 'Items are SerializedContactRequest (backend-contracts contactRequest.ts).',
1009
1013
  required: ['requests'],
1010
1014
  additionalProperties: true,
1011
1015
  properties: {
@@ -1013,15 +1017,26 @@ const list_contact_requests_result = {
1013
1017
  type: 'array',
1014
1018
  items: {
1015
1019
  type: 'object',
1016
- required: ['id', 'requesterId', 'targetId', 'status', 'kind'],
1020
+ required: ['id', 'requesterId', 'targetId', 'status', 'kind', 'phase'],
1017
1021
  additionalProperties: true,
1018
1022
  properties: {
1019
1023
  id: { type: 'string' },
1020
1024
  requesterId: { type: 'string' },
1021
1025
  requesterName: { type: 'string' },
1022
1026
  targetId: { type: 'string' },
1023
- status: { enum: ['pending', 'approved', 'rejected', 'expired'] },
1027
+ status: { enum: ['pending', 'approved', 'rejected', 'expired', 'cancelled'] },
1024
1028
  kind: { enum: ['dm', 'group_invite'] },
1029
+ phase: {
1030
+ enum: [
1031
+ 'awaiting_owner',
1032
+ 'starting',
1033
+ 'connected',
1034
+ 'rejected',
1035
+ 'expired',
1036
+ 'cancelled',
1037
+ 'failed',
1038
+ ],
1039
+ },
1025
1040
  message: { type: ['string', 'null'] },
1026
1041
  createdAt: { type: ['string', 'null'] },
1027
1042
  expiresAt: { type: ['string', 'null'] },
@@ -1030,6 +1045,23 @@ const list_contact_requests_result = {
1030
1045
  },
1031
1046
  },
1032
1047
  };
1048
+ const cancel_contact_request_input = {
1049
+ type: 'object',
1050
+ required: ['requestId'],
1051
+ additionalProperties: false,
1052
+ properties: {
1053
+ requestId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
1054
+ },
1055
+ };
1056
+ const cancel_contact_request_result = {
1057
+ type: 'object',
1058
+ required: ['status', 'requestId'],
1059
+ additionalProperties: true,
1060
+ properties: {
1061
+ status: { const: 'cancelled' },
1062
+ requestId: { type: 'string' },
1063
+ },
1064
+ };
1033
1065
  const list_conversations_input = {
1034
1066
  type: 'object',
1035
1067
  additionalProperties: false,
@@ -1169,6 +1201,8 @@ export const CANON_VERBS_JSON_SCHEMA = {
1169
1201
  list_contact_requests_result,
1170
1202
  list_conversations_input,
1171
1203
  list_conversations_result,
1204
+ cancel_contact_request_input,
1205
+ cancel_contact_request_result,
1172
1206
  no_reply_input,
1173
1207
  no_reply_result,
1174
1208
  },
@@ -40,6 +40,8 @@ export type VerbWireBody = {
40
40
  /** Turn-protocol keys the server enforces on (subset of TurnMetadata). */
41
41
  export interface VerbWireTurn {
42
42
  turnId?: string | null;
43
+ /** Trusted message that initiated a host-bound owner turn. */
44
+ sourceMessageId?: string;
43
45
  turnSemantics?: 'progress' | 'turn_complete' | 'control';
44
46
  deliveryIntent?: 'queue' | 'interrupt' | 'interleave' | 'stop';
45
47
  replyBehavior?: 'allow_auto_reply' | 'suppress_auto_reply';
@@ -137,7 +139,7 @@ export declare const CANON_VERB_WIRE_JSON_SCHEMA: {
137
139
  readonly const: "canon.verb-wire.v1";
138
140
  };
139
141
  readonly verb: {
140
- readonly enum: readonly ["send_to", "request_input", "request_approval", "check_approval", "send_card", "request_card", "share_contact", "react", "forward", "create_group", "add_member", "remove_member", "leave_conversation", "list_contacts", "list_contact_requests", "list_conversations", "no_reply"];
142
+ readonly enum: readonly ["send_to", "request_input", "request_approval", "check_approval", "send_card", "request_card", "share_contact", "react", "forward", "create_group", "add_member", "remove_member", "leave_conversation", "list_contacts", "list_contact_requests", "list_conversations", "cancel_contact_request", "no_reply"];
141
143
  };
142
144
  readonly envelope: {
143
145
  readonly $ref: "#/$defs/envelope";
@@ -464,6 +466,10 @@ export declare const CANON_VERB_WIRE_JSON_SCHEMA: {
464
466
  readonly turnId: {
465
467
  readonly type: readonly ["string", "null"];
466
468
  };
469
+ readonly sourceMessageId: {
470
+ readonly type: "string";
471
+ readonly minLength: 1;
472
+ };
467
473
  readonly turnSemantics: {
468
474
  readonly enum: readonly ["progress", "turn_complete", "control"];
469
475
  };
@@ -509,7 +515,7 @@ export declare const CANON_VERB_WIRE_JSON_SCHEMA: {
509
515
  readonly const: "canon.verb-wire.v1";
510
516
  };
511
517
  readonly verb: {
512
- readonly enum: readonly ["send_to", "request_input", "request_approval", "check_approval", "send_card", "request_card", "share_contact", "react", "forward", "create_group", "add_member", "remove_member", "leave_conversation", "list_contacts", "list_contact_requests", "list_conversations", "no_reply"];
518
+ readonly enum: readonly ["send_to", "request_input", "request_approval", "check_approval", "send_card", "request_card", "share_contact", "react", "forward", "create_group", "add_member", "remove_member", "leave_conversation", "list_contacts", "list_contact_requests", "list_conversations", "cancel_contact_request", "no_reply"];
513
519
  };
514
520
  readonly result: {
515
521
  readonly $ref: "#/$defs/body";
package/dist/verbWire.js CHANGED
@@ -112,6 +112,7 @@ export const VERB_WIRE_ENVELOPE_FIELDS = {
112
112
  list_contacts: { required: [], optional: [] },
113
113
  list_contact_requests: { required: [], optional: [] },
114
114
  list_conversations: { required: [], optional: ['limit'] },
115
+ cancel_contact_request: { required: ['requestId'], optional: [] },
115
116
  no_reply: { required: [], optional: ['conversationId', 'messageId'] },
116
117
  };
117
118
  // ---------------------------------------------------------------------------
@@ -153,6 +154,7 @@ const wireTurnDef = {
153
154
  additionalProperties: false,
154
155
  properties: {
155
156
  turnId: { type: ['string', 'null'] },
157
+ sourceMessageId: { type: 'string', minLength: 1 },
156
158
  turnSemantics: { enum: ['progress', 'turn_complete', 'control'] },
157
159
  deliveryIntent: { enum: ['queue', 'interrupt', 'interleave', 'stop'] },
158
160
  replyBehavior: { enum: ['allow_auto_reply', 'suppress_auto_reply'] },
@@ -278,7 +280,13 @@ export const CANON_VERB_WIRE_JSON_SCHEMA = {
278
280
  // ---------------------------------------------------------------------------
279
281
  // Intent <-> wire projection (json codec)
280
282
  // ---------------------------------------------------------------------------
281
- const TURN_KEYS = ['turnId', 'turnSemantics', 'deliveryIntent', 'replyBehavior'];
283
+ const TURN_KEYS = [
284
+ 'turnId',
285
+ 'sourceMessageId',
286
+ 'turnSemantics',
287
+ 'deliveryIntent',
288
+ 'replyBehavior',
289
+ ];
282
290
  function splitTurnMetadata(metadata) {
283
291
  if (!metadata || typeof metadata !== 'object')
284
292
  return { turn: undefined, rest: undefined };
@@ -463,14 +471,25 @@ export function projectVerbIntentToWire(verb, intent, options) {
463
471
  envelope = compact({ conversationId: input.conversationId });
464
472
  break;
465
473
  }
466
- case 'list_contacts':
474
+ case 'list_contacts': {
475
+ break;
476
+ }
467
477
  case 'list_contact_requests': {
478
+ value = compact({
479
+ direction: input.direction,
480
+ includeResolved: input.includeResolved,
481
+ limit: input.limit,
482
+ });
468
483
  break;
469
484
  }
470
485
  case 'list_conversations': {
471
486
  envelope = compact({ limit: input.limit });
472
487
  break;
473
488
  }
489
+ case 'cancel_contact_request': {
490
+ envelope = compact({ requestId: input.requestId });
491
+ break;
492
+ }
474
493
  case 'no_reply': {
475
494
  envelope = compact({ conversationId: input.conversationId, messageId: input.messageId });
476
495
  value = compact({ reason: input.reason });
@@ -609,10 +628,17 @@ export function mergeVerbWireToIntent(request) {
609
628
  case 'leave_conversation':
610
629
  return compact({ conversationId: envelope.conversationId });
611
630
  case 'list_contacts':
612
- case 'list_contact_requests':
613
631
  return {};
632
+ case 'list_contact_requests':
633
+ return compact({
634
+ direction: value.direction,
635
+ includeResolved: value.includeResolved,
636
+ limit: value.limit,
637
+ });
614
638
  case 'list_conversations':
615
639
  return compact({ limit: envelope.limit });
640
+ case 'cancel_contact_request':
641
+ return compact({ requestId: envelope.requestId });
616
642
  case 'no_reply':
617
643
  return compact({ conversationId: envelope.conversationId, messageId: envelope.messageId, reason: value.reason });
618
644
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/backend-contracts",
3
- "version": "6.2.0",
3
+ "version": "6.4.0",
4
4
  "description": "Canon backend contract helpers shared by Functions and stream-service",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",