@canonmsg/backend-contracts 2.1.0 → 2.3.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.
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Canon verb WIRE contract — `canon.verb-wire.v1`.
3
+ *
4
+ * The verb INTENT contract (`canon.verbs.v1`, verbContract.ts) is the
5
+ * plaintext semantic input used locally by tools, MCP, SDK, and adapters. It
6
+ * is NOT the remote wire schema. What crosses the network is this contract:
7
+ * an authenticated public ENVELOPE (the routing/policy metadata the server
8
+ * may read, authorize, and enforce on) plus a BODY that is either plaintext
9
+ * JSON (temporary/plaintext conversations) or MLS ciphertext (encrypted
10
+ * conversations). See docs/design/e2ee-mls.md §1-2.
11
+ *
12
+ * one developer-facing API ≠ one plaintext wire schema
13
+ *
14
+ * Server endpoints (/agent/verbs/*) implement THIS contract with the `json`
15
+ * codec initially; MLS lands later as a codec swap, not an API change. In
16
+ * `json` mode the server may additionally read `body.value` to execute
17
+ * legacy behavior (validation, snapshot building); in `mls` mode the
18
+ * envelope is everything it will ever see.
19
+ *
20
+ * The intent→wire projection (and its inverse, used by the server executor
21
+ * in json mode) is defined here so every binding and the server agree on
22
+ * exactly which intent fields are envelope (server-visible) vs body
23
+ * (content). The split follows the 2026-07-12 plaintext audit: closed
24
+ * enums, ids, deadlines, and turn-protocol keys are envelope; free text,
25
+ * cards, prompts, questions, tool names/summaries, details, diffs, and
26
+ * self-contexts are body.
27
+ */
28
+ import { CANON_VERB_NAMES, VERB_ID_PATTERNS, VERB_LIMITS, } from './verbContract.js';
29
+ export const CANON_VERB_WIRE_SCHEMA_VERSION = 'canon.verb-wire.v1';
30
+ export const CANON_VERB_WIRE_SCHEMA_ID = 'https://canonmsg.com/schemas/canon.verb-wire.v1.json';
31
+ // ---------------------------------------------------------------------------
32
+ // Which envelope fields each verb uses (documentation + validation aid)
33
+ // ---------------------------------------------------------------------------
34
+ export const VERB_WIRE_ENVELOPE_FIELDS = {
35
+ send_to: {
36
+ required: [],
37
+ optional: [
38
+ 'conversationId',
39
+ 'targetUserId',
40
+ 'canonContactId',
41
+ 'sourceConversationId',
42
+ 'sessionSelection',
43
+ 'sessionConfig',
44
+ 'idempotencyKey',
45
+ 'replyTo',
46
+ 'replyToPosition',
47
+ 'mentions',
48
+ 'turn',
49
+ ],
50
+ },
51
+ request_input: {
52
+ required: ['conversationId'],
53
+ optional: ['requestId', 'kind', 'sensitive', 'responseUserId', 'expiresAt', 'native', 'turn'],
54
+ },
55
+ request_approval: {
56
+ required: ['conversationId'],
57
+ optional: [
58
+ 'requestId',
59
+ 'mode',
60
+ 'riskLevel',
61
+ 'risk',
62
+ 'category',
63
+ 'allowSessionRule',
64
+ 'responseUserId',
65
+ 'expiresAt',
66
+ 'native',
67
+ 'runtimeId',
68
+ 'turn',
69
+ ],
70
+ },
71
+ check_approval: {
72
+ required: ['requestId'],
73
+ optional: ['conversationId'],
74
+ },
75
+ send_card: {
76
+ required: ['conversationId'],
77
+ optional: ['requestId', 'native', 'runtimeId', 'turn'],
78
+ },
79
+ request_card: {
80
+ required: ['conversationId'],
81
+ optional: ['requestId', 'responseUserId', 'expiresAt', 'native', 'runtimeId', 'turn'],
82
+ },
83
+ share_contact: {
84
+ required: ['conversationId', 'contactUserId'],
85
+ optional: ['idempotencyKey'],
86
+ },
87
+ react: {
88
+ required: ['conversationId', 'messageId'],
89
+ optional: [],
90
+ },
91
+ forward: {
92
+ required: ['sourceConversationId', 'conversationId', 'messageId'],
93
+ optional: [],
94
+ },
95
+ create_group: {
96
+ required: ['memberIds'],
97
+ optional: [],
98
+ },
99
+ add_member: {
100
+ required: ['conversationId', 'targetUserId'],
101
+ optional: [],
102
+ },
103
+ remove_member: {
104
+ required: ['conversationId', 'targetUserId'],
105
+ optional: [],
106
+ },
107
+ leave_conversation: {
108
+ required: ['conversationId'],
109
+ optional: [],
110
+ },
111
+ list_contacts: { required: [], optional: [] },
112
+ list_contact_requests: { required: [], optional: [] },
113
+ list_conversations: { required: [], optional: ['limit'] },
114
+ };
115
+ // ---------------------------------------------------------------------------
116
+ // JSON Schema
117
+ // ---------------------------------------------------------------------------
118
+ const wireBodyDef = {
119
+ description: 'The verb body: plaintext JSON for temporary/plaintext conversations, MLS '
120
+ + 'ciphertext for encrypted ones. In json mode the server may read value to '
121
+ + 'execute legacy behavior; in mls mode it is opaque.',
122
+ oneOf: [
123
+ {
124
+ type: 'object',
125
+ required: ['encoding', 'value'],
126
+ additionalProperties: false,
127
+ properties: {
128
+ encoding: { const: 'json' },
129
+ value: { type: 'object' },
130
+ },
131
+ },
132
+ {
133
+ type: 'object',
134
+ required: ['encoding', 'epoch', 'ciphertext', 'aadHash'],
135
+ additionalProperties: false,
136
+ properties: {
137
+ encoding: { const: 'mls' },
138
+ epoch: { type: 'integer', minimum: 0 },
139
+ ciphertext: { type: 'string', minLength: 1 },
140
+ aadHash: {
141
+ type: 'string',
142
+ minLength: 1,
143
+ description: 'Hash binding the envelope (AAD) to the ciphertext.',
144
+ },
145
+ },
146
+ },
147
+ ],
148
+ };
149
+ const wireTurnDef = {
150
+ type: 'object',
151
+ additionalProperties: false,
152
+ properties: {
153
+ turnId: { type: ['string', 'null'] },
154
+ turnSemantics: { enum: ['progress', 'turn_complete', 'control'] },
155
+ deliveryIntent: { enum: ['queue', 'interrupt', 'interleave', 'stop'] },
156
+ replyBehavior: { enum: ['allow_auto_reply', 'suppress_auto_reply'] },
157
+ },
158
+ };
159
+ const wireEnvelopeDef = {
160
+ type: 'object',
161
+ description: 'Authenticated public envelope: routing/policy metadata only, never '
162
+ + 'content. Per-verb field discipline: VERB_WIRE_ENVELOPE_FIELDS.',
163
+ additionalProperties: false,
164
+ properties: {
165
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
166
+ targetUserId: { type: 'string', minLength: 1 },
167
+ canonContactId: { type: 'string', minLength: 1 },
168
+ sourceConversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
169
+ contactUserId: { type: 'string', minLength: 1 },
170
+ requestId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
171
+ responseUserId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
172
+ expiresAt: { type: 'integer', minimum: 0 },
173
+ mode: { enum: ['blocking', 'detached'] },
174
+ kind: { enum: ['clarify', 'sudo', 'secret'] },
175
+ sensitive: { type: 'boolean' },
176
+ allowSessionRule: { type: 'boolean' },
177
+ riskLevel: { enum: ['normal', 'destructive'] },
178
+ risk: { enum: ['low', 'normal', 'high', 'destructive'] },
179
+ category: {
180
+ enum: ['command', 'file', 'network', 'browser', 'mcp', 'plugin', 'canon', 'tool'],
181
+ },
182
+ sessionSelection: {
183
+ oneOf: [
184
+ {
185
+ type: 'object',
186
+ required: ['mode'],
187
+ additionalProperties: false,
188
+ properties: { mode: { enum: ['new', 'continue_latest', 'continue_or_create'] } },
189
+ },
190
+ {
191
+ type: 'object',
192
+ required: ['mode', 'conversationId'],
193
+ additionalProperties: false,
194
+ properties: {
195
+ mode: { const: 'specific' },
196
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
197
+ },
198
+ },
199
+ ],
200
+ },
201
+ sessionConfig: { type: ['object', 'null'] },
202
+ idempotencyKey: {
203
+ type: 'string',
204
+ pattern: VERB_ID_PATTERNS.runtimeId,
205
+ not: { pattern: '^(\\.{1,2}|__.*__)$' },
206
+ },
207
+ messageId: { type: 'string', minLength: 1 },
208
+ replyTo: { type: 'string' },
209
+ replyToPosition: { type: 'integer' },
210
+ mentions: {
211
+ type: 'array',
212
+ items: { type: 'string' },
213
+ description: 'Plaintext by decision D3 (routing + mention-piercing pushes).',
214
+ },
215
+ memberIds: {
216
+ type: 'array',
217
+ items: { type: 'string' },
218
+ minItems: 1,
219
+ description: 'Group-membership routing ids (create_group). Plaintext like mentions '
220
+ + '(D3 rationale) but a distinct field — membership operations must not '
221
+ + 'overload the mention slot.',
222
+ },
223
+ turn: { $ref: '#/$defs/turn' },
224
+ native: { type: 'object' },
225
+ runtimeId: { type: 'string', maxLength: VERB_LIMITS.turnIdChars },
226
+ limit: { type: 'integer', minimum: 1 },
227
+ },
228
+ };
229
+ export const CANON_VERB_WIRE_JSON_SCHEMA = {
230
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
231
+ $id: CANON_VERB_WIRE_SCHEMA_ID,
232
+ title: 'Canon Verb Wire v1',
233
+ description: 'The remote wire contract for agent verbs: authenticated envelope + '
234
+ + 'json-or-mls body. The semantic intent contract (canon.verbs.v1) is '
235
+ + 'local; this is what crosses the network.',
236
+ type: 'object',
237
+ required: ['wire', 'verb', 'envelope', 'body'],
238
+ additionalProperties: false,
239
+ properties: {
240
+ wire: { const: CANON_VERB_WIRE_SCHEMA_VERSION },
241
+ verb: { enum: [...CANON_VERB_NAMES] },
242
+ envelope: { $ref: '#/$defs/envelope' },
243
+ body: { $ref: '#/$defs/body' },
244
+ },
245
+ $defs: {
246
+ body: wireBodyDef,
247
+ envelope: wireEnvelopeDef,
248
+ turn: wireTurnDef,
249
+ accepted: {
250
+ type: 'object',
251
+ required: ['status', 'requestId'],
252
+ additionalProperties: true,
253
+ properties: {
254
+ status: { const: 'accepted' },
255
+ requestId: { type: 'string' },
256
+ expiresAt: { type: 'integer' },
257
+ messageId: { type: 'string' },
258
+ responseUserId: { type: 'string' },
259
+ interactive: { type: 'boolean' },
260
+ },
261
+ },
262
+ response: {
263
+ type: 'object',
264
+ required: ['wire', 'verb', 'result'],
265
+ additionalProperties: true,
266
+ properties: {
267
+ wire: { const: CANON_VERB_WIRE_SCHEMA_VERSION },
268
+ verb: { enum: [...CANON_VERB_NAMES] },
269
+ result: { $ref: '#/$defs/body' },
270
+ },
271
+ },
272
+ },
273
+ };
274
+ // ---------------------------------------------------------------------------
275
+ // Intent <-> wire projection (json codec)
276
+ // ---------------------------------------------------------------------------
277
+ const TURN_KEYS = ['turnId', 'turnSemantics', 'deliveryIntent', 'replyBehavior'];
278
+ function splitTurnMetadata(metadata) {
279
+ if (!metadata || typeof metadata !== 'object')
280
+ return { turn: undefined, rest: undefined };
281
+ const turn = {};
282
+ const rest = {};
283
+ for (const [key, value] of Object.entries(metadata)) {
284
+ if (TURN_KEYS.includes(key))
285
+ turn[key] = value;
286
+ else
287
+ rest[key] = value;
288
+ }
289
+ return {
290
+ turn: Object.keys(turn).length ? turn : undefined,
291
+ rest: Object.keys(rest).length ? rest : undefined,
292
+ };
293
+ }
294
+ function compact(record) {
295
+ const out = {};
296
+ for (const [key, value] of Object.entries(record)) {
297
+ if (value !== undefined)
298
+ out[key] = value;
299
+ }
300
+ return out;
301
+ }
302
+ /**
303
+ * Resolve a relative deadline to the absolute epoch-ms the wire carries.
304
+ * Deterministic: callers pass `now` (the runtime library uses Date.now()).
305
+ */
306
+ function resolveExpiresAt(intent, now) {
307
+ if (typeof intent.expiresAt === 'number')
308
+ return intent.expiresAt;
309
+ if (typeof intent.timeoutMs === 'number')
310
+ return now + intent.timeoutMs;
311
+ return undefined;
312
+ }
313
+ /**
314
+ * Project a plaintext verb intent (canon.verbs.v1 input) into its wire form
315
+ * with the `json` codec: envelope = the routing/policy fields the server may
316
+ * see; body.value = the content fields (what MLS later encrypts).
317
+ *
318
+ * The inverse is mergeVerbWireToIntent; round-tripping normalizes deadlines
319
+ * to absolute expiresAt but is otherwise lossless.
320
+ */
321
+ export function projectVerbIntentToWire(verb, intent, options) {
322
+ const input = intent;
323
+ let envelope = {};
324
+ let value = {};
325
+ switch (verb) {
326
+ case 'send_to': {
327
+ const messageOptions = (input.messageOptions ?? {});
328
+ const { turn, rest } = splitTurnMetadata(messageOptions.metadata);
329
+ envelope = compact({
330
+ conversationId: input.targetConversationId,
331
+ targetUserId: input.targetUserId,
332
+ canonContactId: input.canonContactId,
333
+ sourceConversationId: input.sourceConversationId,
334
+ sessionSelection: input.sessionSelection,
335
+ sessionConfig: input.sessionConfig,
336
+ idempotencyKey: messageOptions.messageId,
337
+ replyTo: messageOptions.replyTo,
338
+ replyToPosition: messageOptions.replyToPosition,
339
+ mentions: messageOptions.mentions,
340
+ turn,
341
+ });
342
+ const contentOptions = compact({
343
+ contentType: messageOptions.contentType,
344
+ attachments: messageOptions.attachments,
345
+ metadata: rest,
346
+ });
347
+ value = compact({
348
+ text: input.text,
349
+ selfContext: input.selfContext,
350
+ requestMessage: input.requestMessage,
351
+ messageOptions: Object.keys(contentOptions).length ? contentOptions : undefined,
352
+ });
353
+ break;
354
+ }
355
+ case 'request_input': {
356
+ envelope = compact({
357
+ conversationId: input.conversationId,
358
+ requestId: input.inputId,
359
+ kind: input.kind,
360
+ sensitive: input.sensitive,
361
+ responseUserId: input.responseUserId,
362
+ expiresAt: resolveExpiresAt(input, options.now),
363
+ native: input.native,
364
+ turn: input.turnId !== undefined ? { turnId: input.turnId } : undefined,
365
+ });
366
+ value = compact({
367
+ title: input.title,
368
+ prompt: input.prompt,
369
+ choices: input.choices,
370
+ questions: input.questions,
371
+ secretName: input.secretName,
372
+ });
373
+ break;
374
+ }
375
+ case 'request_approval': {
376
+ envelope = compact({
377
+ conversationId: input.conversationId,
378
+ requestId: input.approvalId,
379
+ mode: input.mode,
380
+ riskLevel: input.riskLevel,
381
+ risk: input.risk,
382
+ category: input.category,
383
+ allowSessionRule: input.allowSessionRule,
384
+ responseUserId: input.responseUserId,
385
+ expiresAt: resolveExpiresAt(input, options.now),
386
+ native: input.native,
387
+ runtimeId: input.runtimeId,
388
+ turn: input.turnId !== undefined ? { turnId: input.turnId } : undefined,
389
+ });
390
+ value = compact({
391
+ toolName: input.toolName,
392
+ toolSummary: input.toolSummary,
393
+ details: input.details,
394
+ diff: input.diff,
395
+ });
396
+ break;
397
+ }
398
+ case 'check_approval': {
399
+ envelope = compact({
400
+ requestId: input.approvalId,
401
+ conversationId: input.conversationId,
402
+ });
403
+ break;
404
+ }
405
+ case 'send_card':
406
+ case 'request_card': {
407
+ envelope = compact({
408
+ conversationId: input.conversationId,
409
+ requestId: input.cardId,
410
+ responseUserId: verb === 'request_card' ? input.responseUserId : undefined,
411
+ expiresAt: verb === 'request_card' ? resolveExpiresAt(input, options.now) : undefined,
412
+ native: input.native,
413
+ runtimeId: input.runtimeId,
414
+ turn: input.turnId !== undefined ? { turnId: input.turnId } : undefined,
415
+ });
416
+ value = compact({ card: input.card });
417
+ break;
418
+ }
419
+ case 'share_contact': {
420
+ envelope = compact({
421
+ conversationId: input.conversationId,
422
+ contactUserId: input.contactUserId,
423
+ idempotencyKey: input.messageId,
424
+ });
425
+ value = compact({ text: input.text });
426
+ break;
427
+ }
428
+ case 'react': {
429
+ envelope = compact({
430
+ conversationId: input.conversationId,
431
+ messageId: input.messageId,
432
+ });
433
+ value = compact({ emoji: input.emoji });
434
+ break;
435
+ }
436
+ case 'forward': {
437
+ envelope = compact({
438
+ sourceConversationId: input.sourceConversationId,
439
+ conversationId: input.targetConversationId,
440
+ messageId: input.messageId,
441
+ });
442
+ value = compact({ text: input.text });
443
+ break;
444
+ }
445
+ case 'create_group': {
446
+ envelope = compact({ memberIds: input.memberIds });
447
+ value = compact({ name: input.name });
448
+ break;
449
+ }
450
+ case 'add_member':
451
+ case 'remove_member': {
452
+ envelope = compact({
453
+ conversationId: input.conversationId,
454
+ targetUserId: input.userId,
455
+ });
456
+ break;
457
+ }
458
+ case 'leave_conversation': {
459
+ envelope = compact({ conversationId: input.conversationId });
460
+ break;
461
+ }
462
+ case 'list_contacts':
463
+ case 'list_contact_requests': {
464
+ break;
465
+ }
466
+ case 'list_conversations': {
467
+ envelope = compact({ limit: input.limit });
468
+ break;
469
+ }
470
+ }
471
+ return {
472
+ wire: CANON_VERB_WIRE_SCHEMA_VERSION,
473
+ verb,
474
+ envelope,
475
+ body: { encoding: 'json', value },
476
+ };
477
+ }
478
+ /**
479
+ * Reconstruct the plaintext verb intent from a json-codec wire request — the
480
+ * server executor's half of the seam. Throws if the body is not json.
481
+ */
482
+ export function mergeVerbWireToIntent(request) {
483
+ if (request.body.encoding !== 'json') {
484
+ throw new Error(`cannot merge encoding '${request.body.encoding}' to a plaintext intent — mls bodies are opaque to the server`);
485
+ }
486
+ const envelope = request.envelope;
487
+ const value = request.body.value;
488
+ switch (request.verb) {
489
+ case 'send_to': {
490
+ const contentOptions = (value.messageOptions ?? {});
491
+ const metadata = compact({
492
+ ...(contentOptions.metadata ?? {}),
493
+ ...(envelope.turn ?? {}),
494
+ });
495
+ const messageOptions = compact({
496
+ messageId: envelope.idempotencyKey,
497
+ contentType: contentOptions.contentType,
498
+ attachments: contentOptions.attachments,
499
+ mentions: envelope.mentions,
500
+ replyTo: envelope.replyTo,
501
+ replyToPosition: envelope.replyToPosition,
502
+ metadata: Object.keys(metadata).length ? metadata : undefined,
503
+ });
504
+ return compact({
505
+ targetConversationId: envelope.conversationId,
506
+ targetUserId: envelope.targetUserId,
507
+ canonContactId: envelope.canonContactId,
508
+ sourceConversationId: envelope.sourceConversationId,
509
+ sessionSelection: envelope.sessionSelection,
510
+ sessionConfig: envelope.sessionConfig,
511
+ text: value.text,
512
+ selfContext: value.selfContext,
513
+ requestMessage: value.requestMessage,
514
+ messageOptions: Object.keys(messageOptions).length ? messageOptions : undefined,
515
+ });
516
+ }
517
+ case 'request_input':
518
+ return compact({
519
+ conversationId: envelope.conversationId,
520
+ inputId: envelope.requestId,
521
+ kind: envelope.kind,
522
+ sensitive: envelope.sensitive,
523
+ responseUserId: envelope.responseUserId,
524
+ expiresAt: envelope.expiresAt,
525
+ native: envelope.native,
526
+ turnId: envelope.turn?.turnId ?? undefined,
527
+ title: value.title,
528
+ prompt: value.prompt,
529
+ choices: value.choices,
530
+ questions: value.questions,
531
+ secretName: value.secretName,
532
+ });
533
+ case 'request_approval':
534
+ return compact({
535
+ conversationId: envelope.conversationId,
536
+ approvalId: envelope.requestId,
537
+ mode: envelope.mode,
538
+ riskLevel: envelope.riskLevel,
539
+ risk: envelope.risk,
540
+ category: envelope.category,
541
+ allowSessionRule: envelope.allowSessionRule,
542
+ responseUserId: envelope.responseUserId,
543
+ expiresAt: envelope.expiresAt,
544
+ native: envelope.native,
545
+ runtimeId: envelope.runtimeId,
546
+ turnId: envelope.turn?.turnId ?? undefined,
547
+ toolName: value.toolName,
548
+ toolSummary: value.toolSummary,
549
+ details: value.details,
550
+ diff: value.diff,
551
+ });
552
+ case 'check_approval':
553
+ return compact({
554
+ approvalId: envelope.requestId,
555
+ conversationId: envelope.conversationId,
556
+ });
557
+ case 'send_card':
558
+ case 'request_card':
559
+ return compact({
560
+ conversationId: envelope.conversationId,
561
+ cardId: envelope.requestId,
562
+ responseUserId: envelope.responseUserId,
563
+ expiresAt: envelope.expiresAt,
564
+ native: envelope.native,
565
+ runtimeId: envelope.runtimeId,
566
+ turnId: envelope.turn?.turnId ?? undefined,
567
+ card: value.card,
568
+ });
569
+ case 'share_contact':
570
+ return compact({
571
+ conversationId: envelope.conversationId,
572
+ contactUserId: envelope.contactUserId,
573
+ messageId: envelope.idempotencyKey,
574
+ text: value.text,
575
+ });
576
+ case 'react':
577
+ return compact({
578
+ conversationId: envelope.conversationId,
579
+ messageId: envelope.messageId,
580
+ emoji: value.emoji,
581
+ });
582
+ case 'forward':
583
+ return compact({
584
+ sourceConversationId: envelope.sourceConversationId,
585
+ targetConversationId: envelope.conversationId,
586
+ messageId: envelope.messageId,
587
+ text: value.text,
588
+ });
589
+ case 'create_group':
590
+ return compact({
591
+ name: value.name,
592
+ memberIds: envelope.memberIds,
593
+ });
594
+ case 'add_member':
595
+ case 'remove_member':
596
+ return compact({
597
+ conversationId: envelope.conversationId,
598
+ userId: envelope.targetUserId,
599
+ });
600
+ case 'leave_conversation':
601
+ return compact({ conversationId: envelope.conversationId });
602
+ case 'list_contacts':
603
+ case 'list_contact_requests':
604
+ return {};
605
+ case 'list_conversations':
606
+ return compact({ limit: envelope.limit });
607
+ }
608
+ }
609
+ /**
610
+ * Envelope discipline check: no field outside the verb's allowed set, all
611
+ * required fields present. Schema validation catches shape; this catches
612
+ * per-verb field misuse (a strict-envelope server rejects on it).
613
+ */
614
+ export function findVerbWireEnvelopeViolations(verb, envelope) {
615
+ const spec = VERB_WIRE_ENVELOPE_FIELDS[verb];
616
+ const allowed = new Set([...spec.required, ...spec.optional]);
617
+ const violations = [];
618
+ for (const key of Object.keys(envelope)) {
619
+ if (!allowed.has(key))
620
+ violations.push(`envelope.${key} is not used by verb '${verb}'`);
621
+ }
622
+ for (const key of spec.required) {
623
+ if (envelope[key] === undefined) {
624
+ violations.push(`envelope.${key} is required for verb '${verb}'`);
625
+ }
626
+ }
627
+ return violations;
628
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/backend-contracts",
3
- "version": "2.1.0",
3
+ "version": "2.3.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",
@@ -10,13 +10,16 @@
10
10
  "import": "./dist/index.js",
11
11
  "require": "./dist/cjs/index.js",
12
12
  "types": "./dist/index.d.ts"
13
- }
13
+ },
14
+ "./canon-verbs.schema.json": "./dist/canon-verbs.schema.json",
15
+ "./canon-verbs.limits.json": "./dist/canon-verbs.limits.json",
16
+ "./canon-verb-wire.schema.json": "./dist/canon-verb-wire.schema.json"
14
17
  },
15
18
  "files": [
16
19
  "dist"
17
20
  ],
18
21
  "scripts": {
19
- "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node -e \"require('fs').mkdirSync('dist/cjs',{recursive:true}); require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
22
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node -e \"require('fs').mkdirSync('dist/cjs',{recursive:true}); require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({type:'commonjs'}))\" && node scripts/emit-verb-schemas.mjs",
20
23
  "dev": "tsc -p tsconfig.json --watch",
21
24
  "test": "vitest run",
22
25
  "prepack": "npm run build"
@@ -40,7 +43,9 @@
40
43
  "access": "public"
41
44
  },
42
45
  "devDependencies": {
46
+ "@canonmsg/rich-cards": "^0.8.2",
43
47
  "@types/node": "^22.0.0",
48
+ "ajv": "^8.20.0",
44
49
  "typescript": "~5.7.0",
45
50
  "vitest": "^4.1.8"
46
51
  },