@canonmsg/backend-contracts 5.0.0 → 5.2.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.
Files changed (41) hide show
  1. package/README.md +3 -1
  2. package/dist/accessPolicy.d.ts +9 -0
  3. package/dist/accessPolicy.js +12 -0
  4. package/dist/agentBehaviorPolicy.d.ts +86 -15
  5. package/dist/agentBehaviorPolicy.js +103 -17
  6. package/dist/agentSearch.d.ts +17 -0
  7. package/dist/agentSearch.js +58 -0
  8. package/dist/canon-verb-wire.schema.json +4 -2
  9. package/dist/canon-verbs.limits.json +2 -1
  10. package/dist/canon-verbs.schema.json +34 -0
  11. package/dist/cjs/accessPolicy.js +15 -0
  12. package/dist/cjs/agentBehaviorPolicy.js +104 -17
  13. package/dist/cjs/agentSearch.js +64 -0
  14. package/dist/cjs/contactSources.js +45 -0
  15. package/dist/cjs/firestoreValues.js +27 -0
  16. package/dist/cjs/index.js +6 -0
  17. package/dist/cjs/moderation.js +12 -0
  18. package/dist/cjs/runtimeCardFields.js +30 -1
  19. package/dist/cjs/selfContext.js +33 -0
  20. package/dist/cjs/verbContract.js +18 -2
  21. package/dist/cjs/verbSchemas.js +26 -0
  22. package/dist/cjs/verbWire.js +8 -0
  23. package/dist/contactSources.d.ts +31 -0
  24. package/dist/contactSources.js +41 -0
  25. package/dist/firestoreValues.d.ts +9 -0
  26. package/dist/firestoreValues.js +23 -0
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.js +6 -0
  29. package/dist/moderation.d.ts +7 -0
  30. package/dist/moderation.js +9 -0
  31. package/dist/runtimeCardFields.d.ts +14 -2
  32. package/dist/runtimeCardFields.js +29 -0
  33. package/dist/selfContext.d.ts +9 -0
  34. package/dist/selfContext.js +29 -0
  35. package/dist/verbContract.d.ts +35 -2
  36. package/dist/verbContract.js +17 -1
  37. package/dist/verbSchemas.d.ts +32 -0
  38. package/dist/verbSchemas.js +26 -0
  39. package/dist/verbWire.d.ts +2 -2
  40. package/dist/verbWire.js +8 -0
  41. package/package.json +2 -2
@@ -1,15 +1,41 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PARTICIPATION_HISTORY_FETCH_LIMIT = void 0;
3
+ exports.DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS = exports.PARTICIPATION_HISTORY_FETCH_LIMIT = void 0;
4
4
  exports.parseAgentBehaviorSettings = parseAgentBehaviorSettings;
5
5
  exports.normalizeStoredAgentBehaviorPolicy = normalizeStoredAgentBehaviorPolicy;
6
6
  exports.normalizeAgentBehaviorInstructions = normalizeAgentBehaviorInstructions;
7
+ exports.getDefaultParticipationPolicy = getDefaultParticipationPolicy;
7
8
  exports.resolveAgentBehaviorPolicy = resolveAgentBehaviorPolicy;
8
9
  exports.buildParticipationHistorySnapshot = buildParticipationHistorySnapshot;
9
10
  exports.appendParticipationHistoryMessage = appendParticipationHistoryMessage;
10
11
  exports.evaluateParticipationPolicy = evaluateParticipationPolicy;
11
12
  const turnProtocol_js_1 = require("./turnProtocol.js");
12
13
  exports.PARTICIPATION_HISTORY_FETCH_LIMIT = 50;
14
+ /**
15
+ * Safety backstop for groups with no explicit turn cap: at most this many
16
+ * consecutive agent turns before a human has to speak again.
17
+ *
18
+ * This is a backstop, not the steering wheel — agents are expected to stop on
19
+ * their own. Direct conversations deliberately stay unlimited: only a non-agent
20
+ * message resets the streak, so a default cap in a pure agent-to-agent DM would
21
+ * halt the room permanently instead of merely bounding a loop.
22
+ *
23
+ * The same caveat applies to the one group shape this default does cover: a
24
+ * group whose members are all agents (reachable — agent-created groups need no
25
+ * human member) has nothing that resets the streak, so it stops auto-replying
26
+ * for good once the cap is reached. That is deliberate: an unattended room is
27
+ * where a runaway loop is most expensive. Recovery needs a human member — the
28
+ * per-conversation `null` opt-out is writable only by a group owner or admin,
29
+ * who must be a member. Teaching the resolver "does this room contain a
30
+ * human?" would mean loading member types at every resolution site, including
31
+ * the stream gate, which does not read them today.
32
+ */
33
+ exports.DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS = 4;
34
+ function defaultMaxConsecutiveAgentTurns(conversationType) {
35
+ return conversationType === 'group'
36
+ ? exports.DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS
37
+ : null;
38
+ }
13
39
  const VALID_PARTICIPATION_STYLES = new Set([
14
40
  'natural',
15
41
  'collaborative',
@@ -129,6 +155,25 @@ function normalizeAgentBehaviorInstructions(value) {
129
155
  const trimmed = value.trim();
130
156
  return trimmed.length > 0 ? trimmed : null;
131
157
  }
158
+ /**
159
+ * The scope-independent participation defaults. `maxConsecutiveAgentTurns` is
160
+ * `null` here because the turn cap is scope-dependent — a group with no stored
161
+ * setting resolves to {@link DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS}. Use
162
+ * {@link resolveAgentBehaviorPolicy} with a `conversationType` to learn what a
163
+ * given conversation actually enforces.
164
+ */
165
+ function getDefaultParticipationPolicy() {
166
+ return { ...DEFAULT_POLICY.participation };
167
+ }
168
+ /**
169
+ * Coalesce agent defaults and a conversation override into the resolved record
170
+ * every runtime reads. `conversationType` selects the Canon-wide fallbacks that
171
+ * differ per scope — today only the turn cap, which defaults to
172
+ * {@link DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS} in groups and to unlimited
173
+ * everywhere else. Omit it when resolving for the agent scope (no conversation);
174
+ * an absent or unknown type always resolves to the unlimited default, so a call
175
+ * site that forgets to thread it can never invent a cap.
176
+ */
132
177
  function resolveAgentBehaviorPolicy(params) {
133
178
  const agentDefault = params?.agentDefault ?? null;
134
179
  const conversationOverride = params?.conversationOverride ?? null;
@@ -147,7 +192,7 @@ function resolveAgentBehaviorPolicy(params) {
147
192
  ? conversationOverride.maxConsecutiveAgentTurns ?? null
148
193
  : agentDefault?.maxConsecutiveAgentTurns !== undefined
149
194
  ? agentDefault.maxConsecutiveAgentTurns ?? null
150
- : DEFAULT_POLICY.participation.maxConsecutiveAgentTurns,
195
+ : defaultMaxConsecutiveAgentTurns(params?.conversationType),
151
196
  },
152
197
  instructions: [
153
198
  ...(defaultInstructions ? [defaultInstructions] : []),
@@ -159,14 +204,22 @@ function resolveAgentBehaviorPolicy(params) {
159
204
  },
160
205
  };
161
206
  }
207
+ /**
208
+ * The streak counts exactly the agent messages that can trigger another agent —
209
+ * the only messages a loop can be built from. Reusing the trigger gate keeps the
210
+ * two in lockstep: it admits `control` messages that do not suppress auto-reply
211
+ * (hand-crafted runtime metadata can otherwise drive an uncounted loop) and
212
+ * still drops chunk parts, interim progress, card round-trips and media/failure
213
+ * notices, none of which trigger anyone and none of which should burn a slot.
214
+ */
162
215
  function toHistorySenderType(message) {
163
216
  if (message.senderType !== 'ai_agent') {
164
217
  return 'human';
165
218
  }
166
- return (0, turnProtocol_js_1.resolveTurnMessageSemantics)({
167
- senderType: message.senderType,
219
+ return (0, turnProtocol_js_1.shouldTriggerAgentTurn)({
220
+ senderType: 'ai_agent',
168
221
  metadata: message.metadata,
169
- }) === 'turn_complete'
222
+ }).allow
170
223
  ? 'ai_agent'
171
224
  : null;
172
225
  }
@@ -205,37 +258,71 @@ function appendParticipationHistoryMessage(snapshot, message, limit = exports.PA
205
258
  ].slice(0, limit));
206
259
  }
207
260
  function evaluateParticipationPolicy(policy, input) {
208
- const consecutiveAgentTurns = Math.max(input.consecutiveAgentTurns ?? (input.senderType === 'ai_agent' ? 1 : 0), input.senderType === 'ai_agent' ? 1 : 0);
261
+ const resolved = policy ?? resolveAgentBehaviorPolicy({ conversationType: input.conversationType });
262
+ const participation = resolved.participation;
263
+ const priorAgentTurns = Math.max(input.consecutiveAgentTurns ?? 0, 0);
264
+ /** Streak length once the message being evaluated is counted. */
265
+ const agentTurnsWithCurrent = priorAgentTurns
266
+ + (input.senderType === 'ai_agent' ? 1 : 0);
267
+ /**
268
+ * The long-running-collaboration brake reads "more than the current turn",
269
+ * so it keeps its historical floor rather than the exact streak length.
270
+ */
271
+ const consecutiveAgentTurns = Math.max(priorAgentTurns, input.senderType === 'ai_agent' ? 1 : 0);
209
272
  const currentAgentStreakStartedByHuman = input.currentAgentStreakStartedByHuman === true;
210
273
  if (input.conversationType === 'group'
211
- && policy.participation.requireMentionForGroupReplies
274
+ && participation.requireMentionForGroupReplies
212
275
  && !input.mentionedAgent) {
213
276
  return {
214
277
  allow: false,
278
+ reasonCode: 'group_mention_required',
215
279
  reason: 'group replies require a direct mention',
216
280
  };
217
281
  }
218
282
  if (input.isOwner) {
219
- return { allow: true, reason: 'owner messages pass through outside mention-required group turns' };
283
+ return {
284
+ allow: true,
285
+ reasonCode: 'owner_sender',
286
+ reason: 'owner messages pass through outside mention-required group turns',
287
+ };
220
288
  }
221
289
  if (input.senderType !== 'ai_agent') {
222
- return { allow: true, reason: 'latest sender is human' };
290
+ return { allow: true, reasonCode: 'human_sender', reason: 'latest sender is human' };
223
291
  }
224
- if (!policy.participation.allowAgentToAgent) {
225
- return { allow: false, reason: 'agent-to-agent participation disabled by policy' };
292
+ if (!participation.allowAgentToAgent) {
293
+ return {
294
+ allow: false,
295
+ reasonCode: 'agent_to_agent_disabled',
296
+ reason: 'agent-to-agent participation is disabled by policy',
297
+ };
226
298
  }
227
- if (!policy.participation.allowLongRunningCollaboration
299
+ if (!participation.allowLongRunningCollaboration
228
300
  && (consecutiveAgentTurns > 1
229
301
  || !currentAgentStreakStartedByHuman)) {
230
302
  return {
231
303
  allow: false,
304
+ reasonCode: 'human_reset_required',
232
305
  reason: 'a fresh human steer is required before continuing agent collaboration',
233
306
  };
234
307
  }
235
- if (typeof policy.participation.maxConsecutiveAgentTurns === 'number'
236
- && policy.participation.maxConsecutiveAgentTurns >= 0
237
- && consecutiveAgentTurns > policy.participation.maxConsecutiveAgentTurns) {
238
- return { allow: false, reason: 'maximum consecutive agent turns reached' };
308
+ // `maxConsecutiveAgentTurns: N` means at most N consecutive agent turns
309
+ // without a human in between. Delivering this message triggers turn
310
+ // `agentTurnsWithCurrent + 1`, so it is suppressed as soon as the streak has
311
+ // already reached N.
312
+ if (typeof participation.maxConsecutiveAgentTurns === 'number'
313
+ && participation.maxConsecutiveAgentTurns >= 0
314
+ && agentTurnsWithCurrent >= participation.maxConsecutiveAgentTurns) {
315
+ return {
316
+ allow: false,
317
+ reasonCode: 'agent_turn_limit_reached',
318
+ reason: 'maximum consecutive agent turns reached',
319
+ };
239
320
  }
240
- return { allow: true, reason: 'agent-to-agent participation allowed by policy' };
321
+ return {
322
+ allow: true,
323
+ reasonCode: input.conversationType === 'group' ? 'group_agent_allowed' : 'direct_agent_allowed',
324
+ reason: participation.requireMentionForGroupReplies
325
+ ? 'policy allows this directly mentioned group reply'
326
+ : 'agent-to-agent participation allowed by policy',
327
+ };
241
328
  }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ // The single agent-directory search implementation, shared by Cloud Functions
3
+ // (which writes `users/{agentId}.searchPrefixes` and filters discovery reads)
4
+ // and `@canonmsg/chat-domain` (which filters the same agents client-side in the
5
+ // app and on web). The server index and the client filter must agree, so both
6
+ // sides derive from these bodies rather than from hand-synced copies.
7
+ //
8
+ // Pure ECMAScript: no imports, no Node built-ins, no Firestore types — safe in
9
+ // CJS, in the browser bundle, and on Hermes.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.normalizeAgentDirectoryQuery = normalizeAgentDirectoryQuery;
12
+ exports.getAgentDirectoryQueryTokens = getAgentDirectoryQueryTokens;
13
+ exports.buildAgentSearchPrefixes = buildAgentSearchPrefixes;
14
+ exports.matchesAgentSearchQuery = matchesAgentSearchQuery;
15
+ const MAX_QUERY_LENGTH = 64;
16
+ const MAX_TOKEN_PREFIX_LENGTH = 32;
17
+ const MAX_SEARCH_PREFIXES = 256;
18
+ const TOKEN_PATTERN = /[\p{L}\p{N}]+/gu;
19
+ function tokenizeAgentSearchValue(value) {
20
+ const normalized = value
21
+ .normalize('NFKD')
22
+ .toLowerCase()
23
+ .trim();
24
+ return normalized.match(TOKEN_PATTERN) ?? [];
25
+ }
26
+ /** Tolerant: non-string input normalises to ''. */
27
+ function normalizeAgentDirectoryQuery(value) {
28
+ if (typeof value !== 'string')
29
+ return '';
30
+ return tokenizeAgentSearchValue(value).join(' ').slice(0, MAX_QUERY_LENGTH);
31
+ }
32
+ function getAgentDirectoryQueryTokens(query) {
33
+ return tokenizeAgentSearchValue(query).map((token) => token.slice(0, MAX_QUERY_LENGTH));
34
+ }
35
+ function buildAgentSearchPrefixes(input) {
36
+ const seen = new Set();
37
+ for (const token of tokenizeAgentSearchValue([input.displayName, input.description].filter(Boolean).join(' '))) {
38
+ const normalizedToken = token.slice(0, MAX_QUERY_LENGTH);
39
+ const prefixLength = Math.min(normalizedToken.length, MAX_TOKEN_PREFIX_LENGTH);
40
+ for (let index = 1; index <= prefixLength; index += 1) {
41
+ seen.add(normalizedToken.slice(0, index));
42
+ if (seen.size >= MAX_SEARCH_PREFIXES) {
43
+ return [...seen];
44
+ }
45
+ }
46
+ if (normalizedToken.length > MAX_TOKEN_PREFIX_LENGTH) {
47
+ seen.add(normalizedToken);
48
+ if (seen.size >= MAX_SEARCH_PREFIXES) {
49
+ return [...seen];
50
+ }
51
+ }
52
+ }
53
+ return [...seen];
54
+ }
55
+ function matchesAgentSearchQuery(profile, query) {
56
+ const tokens = getAgentDirectoryQueryTokens(query);
57
+ if (tokens.length === 0)
58
+ return true;
59
+ const prefixes = new Set(buildAgentSearchPrefixes({
60
+ displayName: profile.displayName,
61
+ description: profile.agentConfig?.description,
62
+ }));
63
+ return tokens.every((token) => prefixes.has(token));
64
+ }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ADMISSION_GRANTING_SOURCES = void 0;
4
+ exports.isAdmissionGrantingContactSource = isAdmissionGrantingContactSource;
5
+ /**
6
+ * Sources that represent a user-initiated relationship which grants admission
7
+ * past `inboundPolicy: 'approval-required'` and
8
+ * `groupJoinPolicy: 'approval-required'`.
9
+ *
10
+ * - `contact_request`: an explicit approval flow.
11
+ * - `direct_add`: the user added the other side directly.
12
+ * - `link` / `qr`: out-of-band invitations that imply consent.
13
+ * - `group`: established by being in a shared group.
14
+ * - `phone_book`: included intentionally — if a human has a registered Canon
15
+ * user in their device contacts, that is enough to start a human-human DM
16
+ * without a separate Canon request.
17
+ * - `open_inbound_message`: the unified source written when a delivered direct
18
+ * interaction reaches an `inboundPolicy: 'open'` recipient. The recipient
19
+ * consented to open inbound; the resulting mutual contact is real.
20
+ *
21
+ * Hard caps (`owner-only`, blocks, inactive agent) override admission grants
22
+ * regardless of source — ordering lives in `functions/src/utils/access.ts`
23
+ * (`evaluatePolicy` / `buildPolicyContext`).
24
+ */
25
+ const ADMISSION_GRANTING_CONTACT_SOURCES = [
26
+ 'contact_request',
27
+ 'direct_add',
28
+ 'link',
29
+ 'qr',
30
+ 'group',
31
+ 'phone_book',
32
+ 'open_inbound_message',
33
+ ];
34
+ exports.ADMISSION_GRANTING_SOURCES = new Set(ADMISSION_GRANTING_CONTACT_SOURCES);
35
+ /**
36
+ * Returns true if a contact-doc source counts as an admission-granting
37
+ * relationship. `null` / `undefined` (no doc, or doc with a missing source)
38
+ * both return `false` — every contact doc must have an explicit source after
39
+ * the cleanup.
40
+ */
41
+ function isAdmissionGrantingContactSource(source) {
42
+ if (source === null || source === undefined)
43
+ return false;
44
+ return exports.ADMISSION_GRANTING_SOURCES.has(source);
45
+ }
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ // Normalizers for values read straight off a Firestore document. Callers pass
3
+ // plain doc-shaped data, never a Firestore handle — this package holds no I/O.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.normalizeStoredString = normalizeStoredString;
6
+ exports.firestoreTimestampToISOString = firestoreTimestampToISOString;
7
+ /** Trimmed string, or null when absent/empty. */
8
+ function normalizeStoredString(value) {
9
+ if (typeof value !== 'string')
10
+ return null;
11
+ const trimmed = value.trim();
12
+ return trimmed.length > 0 ? trimmed : null;
13
+ }
14
+ /**
15
+ * Firestore Timestamp-like -> ISO 8601, or null. Accepts only objects exposing
16
+ * `toDate()`. A raw `Date` is deliberately NOT accepted: that matches both call
17
+ * sites this replaced. `message.ts` and `contactRequest.ts` keep their own
18
+ * Date-tolerant normalizers — a different contract, not this one.
19
+ */
20
+ function firestoreTimestampToISOString(value) {
21
+ if (!value || typeof value !== 'object')
22
+ return null;
23
+ const maybeTimestamp = value;
24
+ if (typeof maybeTimestamp.toDate !== 'function')
25
+ return null;
26
+ return maybeTimestamp.toDate().toISOString();
27
+ }
package/dist/cjs/index.js CHANGED
@@ -22,7 +22,13 @@ __exportStar(require("./runtimeCardFields.js"), exports);
22
22
  __exportStar(require("./runtimeCardStorage.js"), exports);
23
23
  __exportStar(require("./turnProtocol.js"), exports);
24
24
  __exportStar(require("./agentBehaviorPolicy.js"), exports);
25
+ __exportStar(require("./agentSearch.js"), exports);
25
26
  __exportStar(require("./contactRequest.js"), exports);
27
+ __exportStar(require("./contactSources.js"), exports);
26
28
  __exportStar(require("./verbContract.js"), exports);
27
29
  __exportStar(require("./verbSchemas.js"), exports);
28
30
  __exportStar(require("./verbWire.js"), exports);
31
+ __exportStar(require("./accessPolicy.js"), exports);
32
+ __exportStar(require("./firestoreValues.js"), exports);
33
+ __exportStar(require("./moderation.js"), exports);
34
+ __exportStar(require("./selfContext.js"), exports);
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readModerationStatus = readModerationStatus;
4
+ /**
5
+ * The single definition of "this account is ejected", read off an
6
+ * already-fetched `moderationUsers/{id}` document. A missing document reads
7
+ * 'active'. Fetching is the caller's job — this package holds no Firestore handle.
8
+ */
9
+ function readModerationStatus(data) {
10
+ const record = data;
11
+ return record?.status === 'ejected' ? 'ejected' : 'active';
12
+ }
@@ -1,7 +1,28 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RUNTIME_CARD_LIMITS = void 0;
3
+ exports.RUNTIME_CARD_LINE_ITEM_COLUMN_TYPES = exports.RUNTIME_CARD_LIMITS = exports.RUNTIME_CARD_FIELD_TYPES = void 0;
4
4
  exports.validateRuntimeCardFieldValues = validateRuntimeCardFieldValues;
5
+ /**
6
+ * Canonical `canon.card.v1` action-field type vocabulary. The runtime array is
7
+ * the single source of truth and the type is derived from it, so a value and a
8
+ * type cannot drift.
9
+ *
10
+ * ORDER IS LOAD-BEARING: scripts/generate-skill-bundles.mjs renders this list
11
+ * verbatim (via @canonmsg/rich-cards) into two committed SKILL.md files, and
12
+ * `npm run check:skill-bundles` fails on a byte change.
13
+ */
14
+ exports.RUNTIME_CARD_FIELD_TYPES = [
15
+ 'text',
16
+ 'textarea',
17
+ 'select',
18
+ 'multiSelect',
19
+ 'boolean',
20
+ 'date',
21
+ 'number',
22
+ 'currency',
23
+ 'searchSelect',
24
+ 'lineItems',
25
+ ];
5
26
  /**
6
27
  * Caps shared across runtime-card normalizers, response validation, and
7
28
  * authoring tools. Keep these backend-safe so Functions can import them.
@@ -12,6 +33,14 @@ exports.RUNTIME_CARD_LIMITS = {
12
33
  lineItemColumns: 8,
13
34
  searchSelectChoices: 100,
14
35
  };
36
+ /** Column cell types allowed inside a `lineItems` field. */
37
+ exports.RUNTIME_CARD_LINE_ITEM_COLUMN_TYPES = [
38
+ 'text',
39
+ 'number',
40
+ 'currency',
41
+ 'date',
42
+ 'select',
43
+ ];
15
44
  const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
16
45
  function isRecord(value) {
17
46
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeStoredSelfContextId = normalizeStoredSelfContextId;
4
+ exports.serializeSelfContext = serializeSelfContext;
5
+ const verbContract_js_1 = require("./verbContract.js");
6
+ const firestoreValues_js_1 = require("./firestoreValues.js");
7
+ /** Stored self-context id, or null when absent, empty, or path-like. */
8
+ function normalizeStoredSelfContextId(value) {
9
+ const id = (0, firestoreValues_js_1.normalizeStoredString)(value);
10
+ if (!id || id.includes('/'))
11
+ return null;
12
+ return id;
13
+ }
14
+ /**
15
+ * Agent-facing projection of an `agentSelfContexts` document; null for anything
16
+ * that is not a usable cross-session context. Ownership and conversation
17
+ * scoping stay with the CALLER — both consumers check them against their own
18
+ * Firestore handle before calling this.
19
+ */
20
+ function serializeSelfContext(id, data) {
21
+ if (data.type !== verbContract_js_1.SELF_CONTEXT_TYPE)
22
+ return null;
23
+ const context = (0, firestoreValues_js_1.normalizeStoredString)(data.context);
24
+ if (!context)
25
+ return null;
26
+ return {
27
+ id,
28
+ type: verbContract_js_1.SELF_CONTEXT_TYPE,
29
+ context,
30
+ createdAt: (0, firestoreValues_js_1.firestoreTimestampToISOString)(data.createdAt),
31
+ updatedAt: (0, firestoreValues_js_1.firestoreTimestampToISOString)(data.updatedAt),
32
+ };
33
+ }
@@ -16,7 +16,9 @@
16
16
  *
17
17
  * Deliberately NOT verbs: replying in the active conversation, streaming
18
18
  * partials, typing, and read receipts stay host-mediated — the model talks
19
- * and the platform delivers. Still out of scope pending their own design
19
+ * and the platform delivers. `no_reply` is the deliberate exception: it is the
20
+ * ABSENCE of a reply, and only an intent can express that — silence has no
21
+ * host-mediated channel of its own. Still out of scope pending their own design
20
22
  * pass: plan approval (the fourth runtime-interaction kind — coding-host
21
23
  * concern today) and block/mute. Their REST surfaces remain directly
22
24
  * callable.
@@ -42,7 +44,7 @@
42
44
  * canon-verbs.limits.json) after schema validation.
43
45
  */
44
46
  Object.defineProperty(exports, "__esModule", { value: true });
45
- exports.CANON_VERB_LIMITS_ARTIFACT = exports.CREATE_GROUP_CREATOR_SETUP_REQUIRED_CODE = exports.CREATE_GROUP_NO_ADDABLE_MEMBERS_CODE = exports.VERB_NATIVE_METADATA_KEYS = exports.CANON_VERB_NAMES = exports.SELF_CONTEXT_TYPE = exports.VERB_RATE_LIMITS = exports.VERB_LIMITS = exports.VERB_ID_PATTERNS = exports.CANON_CARD_SCHEMA_ID = exports.CANON_VERB_NAMESPACE = exports.CANON_VERBS_SCHEMA_ID = exports.CANON_VERBS_SCHEMA_VERSION = void 0;
47
+ exports.CANON_VERB_LIMITS_ARTIFACT = exports.NO_REPLY_ACK_NOTE = exports.CREATE_GROUP_CREATOR_SETUP_REQUIRED_CODE = exports.CREATE_GROUP_NO_ADDABLE_MEMBERS_CODE = exports.VERB_NATIVE_METADATA_KEYS = exports.CANON_VERB_NAMES = exports.SELF_CONTEXT_TYPE = exports.VERB_RATE_LIMITS = exports.VERB_LIMITS = exports.VERB_ID_PATTERNS = exports.CANON_CARD_SCHEMA_ID = exports.CANON_VERB_NAMESPACE = exports.CANON_VERBS_SCHEMA_ID = exports.CANON_VERBS_SCHEMA_VERSION = void 0;
46
48
  exports.normalizeVerbNativeMetadata = normalizeVerbNativeMetadata;
47
49
  exports.canonVerbToolName = canonVerbToolName;
48
50
  exports.findVerbByteLimitViolations = findVerbByteLimitViolations;
@@ -165,6 +167,12 @@ exports.VERB_LIMITS = {
165
167
  * CREATE does not length-check today — treat as the authoring contract).
166
168
  */
167
169
  groupNameChars: 100,
170
+ /**
171
+ * `no_reply.reason` authoring cap. There is no server enforcement beyond the
172
+ * intent schema — the handler logs the reason's presence and nothing else —
173
+ * so this constant is the canonical figure.
174
+ */
175
+ noReplyReasonChars: 500,
168
176
  };
169
177
  /**
170
178
  * Sender-side rate limits enforced by POST /messages/send
@@ -196,6 +204,7 @@ exports.CANON_VERB_NAMES = [
196
204
  'list_contacts',
197
205
  'list_contact_requests',
198
206
  'list_conversations',
207
+ 'no_reply',
199
208
  ];
200
209
  exports.VERB_NATIVE_METADATA_KEYS = [
201
210
  'runtime',
@@ -271,6 +280,13 @@ exports.CREATE_GROUP_NO_ADDABLE_MEMBERS_CODE = 'CREATE_GROUP_NO_ADDABLE_MEMBERS'
271
280
  * creates the group from the app instead).
272
281
  */
273
282
  exports.CREATE_GROUP_CREATOR_SETUP_REQUIRED_CODE = 'CREATE_GROUP_CREATOR_SETUP_REQUIRED';
283
+ /**
284
+ * The `note` the server returns on a `no_reply` ack — the model's closure
285
+ * sentence. It lives here, not in a host, because every binding surfaces the
286
+ * server's result verbatim and a runtime that answers `no_reply` locally must
287
+ * say the same thing.
288
+ */
289
+ exports.NO_REPLY_ACK_NOTE = 'Acknowledged — nothing was posted to the conversation.';
274
290
  /** Flat tool name for a verb on namespaced tool surfaces (e.g. `canon_send_to`). */
275
291
  function canonVerbToolName(verb) {
276
292
  return `${exports.CANON_VERB_NAMESPACE}_${verb}`;
@@ -1058,6 +1058,30 @@ const list_conversations_result = {
1058
1058
  },
1059
1059
  },
1060
1060
  };
1061
+ const no_reply_input = {
1062
+ type: 'object',
1063
+ description: 'End this turn without posting anything to the conversation. Nothing is '
1064
+ + 'rendered and no other member or agent is triggered.',
1065
+ additionalProperties: false,
1066
+ properties: {
1067
+ conversationId: { type: 'string', pattern: verbContract_js_1.VERB_ID_PATTERNS.runtimeId },
1068
+ reason: {
1069
+ type: 'string',
1070
+ maxLength: verbContract_js_1.VERB_LIMITS.noReplyReasonChars,
1071
+ description: 'Never rendered; logged only.',
1072
+ },
1073
+ },
1074
+ };
1075
+ const no_reply_result = {
1076
+ type: 'object',
1077
+ required: ['status'],
1078
+ additionalProperties: true,
1079
+ properties: {
1080
+ status: { const: 'acknowledged' },
1081
+ conversationId: { type: 'string' },
1082
+ note: { type: 'string' },
1083
+ },
1084
+ };
1061
1085
  // ---------------------------------------------------------------------------
1062
1086
  // Bundle
1063
1087
  // ---------------------------------------------------------------------------
@@ -1116,6 +1140,8 @@ exports.CANON_VERBS_JSON_SCHEMA = {
1116
1140
  list_contact_requests_result,
1117
1141
  list_conversations_input,
1118
1142
  list_conversations_result,
1143
+ no_reply_input,
1144
+ no_reply_result,
1119
1145
  },
1120
1146
  };
1121
1147
  exports.CANON_VERB_SCHEMA_REFS = Object.fromEntries(verbContract_js_1.CANON_VERB_NAMES.map((name) => [
@@ -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
+ no_reply: { required: [], optional: ['conversationId'] },
121
122
  };
122
123
  // ---------------------------------------------------------------------------
123
124
  // JSON Schema
@@ -476,6 +477,11 @@ function projectVerbIntentToWire(verb, intent, options) {
476
477
  envelope = compact({ limit: input.limit });
477
478
  break;
478
479
  }
480
+ case 'no_reply': {
481
+ envelope = compact({ conversationId: input.conversationId });
482
+ value = compact({ reason: input.reason });
483
+ break;
484
+ }
479
485
  }
480
486
  return {
481
487
  wire: exports.CANON_VERB_WIRE_SCHEMA_VERSION,
@@ -613,6 +619,8 @@ function mergeVerbWireToIntent(request) {
613
619
  return {};
614
620
  case 'list_conversations':
615
621
  return compact({ limit: envelope.limit });
622
+ case 'no_reply':
623
+ return compact({ conversationId: envelope.conversationId, reason: value.reason });
616
624
  }
617
625
  }
618
626
  /**
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Sources that represent a user-initiated relationship which grants admission
3
+ * past `inboundPolicy: 'approval-required'` and
4
+ * `groupJoinPolicy: 'approval-required'`.
5
+ *
6
+ * - `contact_request`: an explicit approval flow.
7
+ * - `direct_add`: the user added the other side directly.
8
+ * - `link` / `qr`: out-of-band invitations that imply consent.
9
+ * - `group`: established by being in a shared group.
10
+ * - `phone_book`: included intentionally — if a human has a registered Canon
11
+ * user in their device contacts, that is enough to start a human-human DM
12
+ * without a separate Canon request.
13
+ * - `open_inbound_message`: the unified source written when a delivered direct
14
+ * interaction reaches an `inboundPolicy: 'open'` recipient. The recipient
15
+ * consented to open inbound; the resulting mutual contact is real.
16
+ *
17
+ * Hard caps (`owner-only`, blocks, inactive agent) override admission grants
18
+ * regardless of source — ordering lives in `functions/src/utils/access.ts`
19
+ * (`evaluatePolicy` / `buildPolicyContext`).
20
+ */
21
+ declare const ADMISSION_GRANTING_CONTACT_SOURCES: readonly ["contact_request", "direct_add", "link", "qr", "group", "phone_book", "open_inbound_message"];
22
+ export type AdmissionGrantingContactSource = (typeof ADMISSION_GRANTING_CONTACT_SOURCES)[number];
23
+ export declare const ADMISSION_GRANTING_SOURCES: ReadonlySet<AdmissionGrantingContactSource>;
24
+ /**
25
+ * Returns true if a contact-doc source counts as an admission-granting
26
+ * relationship. `null` / `undefined` (no doc, or doc with a missing source)
27
+ * both return `false` — every contact doc must have an explicit source after
28
+ * the cleanup.
29
+ */
30
+ export declare function isAdmissionGrantingContactSource(source: string | null | undefined): boolean;
31
+ export {};
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Sources that represent a user-initiated relationship which grants admission
3
+ * past `inboundPolicy: 'approval-required'` and
4
+ * `groupJoinPolicy: 'approval-required'`.
5
+ *
6
+ * - `contact_request`: an explicit approval flow.
7
+ * - `direct_add`: the user added the other side directly.
8
+ * - `link` / `qr`: out-of-band invitations that imply consent.
9
+ * - `group`: established by being in a shared group.
10
+ * - `phone_book`: included intentionally — if a human has a registered Canon
11
+ * user in their device contacts, that is enough to start a human-human DM
12
+ * without a separate Canon request.
13
+ * - `open_inbound_message`: the unified source written when a delivered direct
14
+ * interaction reaches an `inboundPolicy: 'open'` recipient. The recipient
15
+ * consented to open inbound; the resulting mutual contact is real.
16
+ *
17
+ * Hard caps (`owner-only`, blocks, inactive agent) override admission grants
18
+ * regardless of source — ordering lives in `functions/src/utils/access.ts`
19
+ * (`evaluatePolicy` / `buildPolicyContext`).
20
+ */
21
+ const ADMISSION_GRANTING_CONTACT_SOURCES = [
22
+ 'contact_request',
23
+ 'direct_add',
24
+ 'link',
25
+ 'qr',
26
+ 'group',
27
+ 'phone_book',
28
+ 'open_inbound_message',
29
+ ];
30
+ export const ADMISSION_GRANTING_SOURCES = new Set(ADMISSION_GRANTING_CONTACT_SOURCES);
31
+ /**
32
+ * Returns true if a contact-doc source counts as an admission-granting
33
+ * relationship. `null` / `undefined` (no doc, or doc with a missing source)
34
+ * both return `false` — every contact doc must have an explicit source after
35
+ * the cleanup.
36
+ */
37
+ export function isAdmissionGrantingContactSource(source) {
38
+ if (source === null || source === undefined)
39
+ return false;
40
+ return ADMISSION_GRANTING_SOURCES.has(source);
41
+ }
@@ -0,0 +1,9 @@
1
+ /** Trimmed string, or null when absent/empty. */
2
+ export declare function normalizeStoredString(value: unknown): string | null;
3
+ /**
4
+ * Firestore Timestamp-like -> ISO 8601, or null. Accepts only objects exposing
5
+ * `toDate()`. A raw `Date` is deliberately NOT accepted: that matches both call
6
+ * sites this replaced. `message.ts` and `contactRequest.ts` keep their own
7
+ * Date-tolerant normalizers — a different contract, not this one.
8
+ */
9
+ export declare function firestoreTimestampToISOString(value: unknown): string | null;