@frockbot/kernel-contracts 0.0.0 → 0.1.1

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,384 @@
1
+ // The typed payload a user-facing send carries.
2
+ //
3
+ // Parity register row 57b: GrokBot has exactly one voice to the user in chat,
4
+ // `SendToUser`, and it carries a payload union rather than a family of tools;
5
+ // row 57c: a question widget is one of those payloads, not a tool of its own,
6
+ // and sending one ends the turn.
7
+ //
8
+ // The DTO and its decoder live in kernel-contracts for the same reason
9
+ // `memory/injected` and `skill/injected` do: contracts carry the versioned
10
+ // shape that crosses a seam and gets written to the durable log, and the
11
+ // Package carries every scrap of behaviour. Nothing here decides what a turn
12
+ // type admits, when a payload ends a Turn, or how a client draws one.
13
+ //
14
+ // Only the `widget` shape is host-source (§4.2 of `docs/research/
15
+ // grokbot-computer.md`). The other four members are named in the same section
16
+ // but their field lists are not recorded, so they are declared here in the
17
+ // narrowest shape that carries the observed meaning, and widened when a
18
+ // primary source says more.
19
+ //
20
+ // `approval` has no GrokBot payload behind it at all: row 53
21
+ // records only the harness sentence "when your own action needs approval". It
22
+ // is declared here because the constitution's *Self-modification* rule — "a
23
+ // request for more becomes a durable pending decision for the User, never a
24
+ // grant" — needs one shape to carry that request, and a card the Bot sends is
25
+ // the only path a Turn has to a person. Like `widget` it ends the Turn: the
26
+ // Bot has nothing to do until a human answers.
27
+
28
+ /** The widget shape, verbatim from §4.2: `options` holds 1–6 entries. */
29
+ export interface SendToUserWidgetV1 {
30
+ prompt: string;
31
+ helpText?: string;
32
+ options: string[];
33
+ allowCustom?: boolean;
34
+ dismissOnMoveOn?: boolean;
35
+ }
36
+
37
+ export type SendToUserPayloadV1 =
38
+ | { type: "text"; text: string }
39
+ | { type: "attachment"; url: string; name?: string; mediaType?: string }
40
+ | { type: "widget"; widget: SendToUserWidgetV1 }
41
+ | { type: "secret-request"; prompt: string; secretName: string }
42
+ | { type: "agent-card"; agentId: string; title: string; body?: string }
43
+ /**
44
+ * A Connection the Bot has recorded a pending authorization decision for.
45
+ *
46
+ * There is deliberately **no URL** on this payload, and there never will be.
47
+ * A Bot may create a durable *request* for authorization; only an
48
+ * authenticated User action mints a redirect, and a single-use ten-minute
49
+ * link sitting in a client-readable transcript would outlive the decision it
50
+ * belonged to. The client draws a card from the Connection's own projection
51
+ * and the User presses it; the host authors the link at that moment.
52
+ */
53
+ | {
54
+ type: "connect-card";
55
+ connectionId: string;
56
+ title: string;
57
+ body?: string;
58
+ }
59
+ | {
60
+ type: "approval";
61
+ /** The Bot's own id for the decision, and the key it is recorded under. */
62
+ approvalId: string;
63
+ /** What the Bot proposes to do, in the words the User is asked about. */
64
+ action: string;
65
+ /** Why, when the action does not speak for itself. */
66
+ rationale?: string;
67
+ risk: SendToUserApprovalRiskV1;
68
+ /** Clamped when it is recorded; absent takes the default. */
69
+ expiresInSeconds?: number;
70
+ };
71
+
72
+ /** How much a refused-by-silence outcome would cost. Ordered, not free text. */
73
+ export type SendToUserApprovalRiskV1 = "low" | "medium" | "high";
74
+
75
+ export const SEND_TO_USER_APPROVAL_RISKS_V1: readonly SendToUserApprovalRiskV1[] =
76
+ ["low", "medium", "high"];
77
+
78
+ export const SEND_TO_USER_PAYLOAD_TYPES_V1: readonly SendToUserPayloadV1["type"][] =
79
+ [
80
+ "text",
81
+ "attachment",
82
+ "widget",
83
+ "secret-request",
84
+ "agent-card",
85
+ "connect-card",
86
+ "approval",
87
+ ];
88
+
89
+ /**
90
+ * Bounds, so a payload cannot be the way a Turn writes an unbounded record
91
+ * into durable state. They are product-shaped rather than protocol-shaped and
92
+ * live beside the decoder that enforces them.
93
+ */
94
+ export const SEND_TO_USER_LIMITS_V1 = {
95
+ text: 32_000,
96
+ url: 2_048,
97
+ name: 256,
98
+ mediaType: 128,
99
+ prompt: 2_000,
100
+ helpText: 2_000,
101
+ option: 200,
102
+ minOptions: 1,
103
+ maxOptions: 6,
104
+ secretName: 128,
105
+ agentId: 128,
106
+ connectionId: 128,
107
+ title: 200,
108
+ body: 8_000,
109
+ approvalId: 128,
110
+ action: 2_000,
111
+ rationale: 8_000,
112
+ } as const;
113
+
114
+ /**
115
+ * The shape an `approvalId` may take. Narrower than a bounded string because
116
+ * the id becomes a URL path segment and a durable storage key: an id that
117
+ * cannot be addressed is a decision that cannot be answered.
118
+ */
119
+ const APPROVAL_ID_PATTERN_V1 = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
120
+
121
+ function payloadRecord(value: unknown, label: string): Record<string, unknown> {
122
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
123
+ throw new Error(`${label} must be an object`);
124
+ }
125
+ return value as Record<string, unknown>;
126
+ }
127
+
128
+ function exactPayloadKeys(
129
+ value: Record<string, unknown>,
130
+ allowed: readonly string[],
131
+ label: string,
132
+ ): void {
133
+ for (const key of Object.keys(value)) {
134
+ if (!allowed.includes(key)) {
135
+ throw new Error(`${label} has an unexpected key "${key}"`);
136
+ }
137
+ }
138
+ }
139
+
140
+ function boundedString(
141
+ value: unknown,
142
+ maximum: number,
143
+ label: string,
144
+ options: { allowEmpty?: boolean } = {},
145
+ ): string {
146
+ if (typeof value !== "string") throw new Error(`${label} must be a string`);
147
+ if (!options.allowEmpty && value.length === 0) {
148
+ throw new Error(`${label} must not be empty`);
149
+ }
150
+ if (value.length > maximum) {
151
+ throw new Error(`${label} exceeds ${maximum} characters`);
152
+ }
153
+ return value;
154
+ }
155
+
156
+ function optionalBoundedString(
157
+ value: unknown,
158
+ maximum: number,
159
+ label: string,
160
+ ): string | undefined {
161
+ return value === undefined ? undefined : boundedString(value, maximum, label);
162
+ }
163
+
164
+ function boundedBoolean(value: unknown, label: string): boolean | undefined {
165
+ if (value === undefined) return undefined;
166
+ if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
167
+ return value;
168
+ }
169
+
170
+ function decodeWidget(value: unknown, label: string): SendToUserWidgetV1 {
171
+ const widget = payloadRecord(value, label);
172
+ exactPayloadKeys(
173
+ widget,
174
+ ["prompt", "helpText", "options", "allowCustom", "dismissOnMoveOn"],
175
+ label,
176
+ );
177
+ const limits = SEND_TO_USER_LIMITS_V1;
178
+ const prompt = boundedString(widget.prompt, limits.prompt, `${label}.prompt`);
179
+ const helpText = optionalBoundedString(
180
+ widget.helpText,
181
+ limits.helpText,
182
+ `${label}.helpText`,
183
+ );
184
+ if (!Array.isArray(widget.options)) {
185
+ throw new Error(`${label}.options must be an array`);
186
+ }
187
+ if (
188
+ widget.options.length < limits.minOptions ||
189
+ widget.options.length > limits.maxOptions
190
+ ) {
191
+ throw new Error(
192
+ `${label}.options must hold ${limits.minOptions} to ${limits.maxOptions} entries`,
193
+ );
194
+ }
195
+ const options = widget.options.map((option, index) =>
196
+ boundedString(option, limits.option, `${label}.options[${index}]`),
197
+ );
198
+ if (new Set(options).size !== options.length) {
199
+ throw new Error(`${label}.options has duplicates`);
200
+ }
201
+ const allowCustom = boundedBoolean(
202
+ widget.allowCustom,
203
+ `${label}.allowCustom`,
204
+ );
205
+ const dismissOnMoveOn = boundedBoolean(
206
+ widget.dismissOnMoveOn,
207
+ `${label}.dismissOnMoveOn`,
208
+ );
209
+ return {
210
+ prompt,
211
+ ...(helpText === undefined ? {} : { helpText }),
212
+ options,
213
+ ...(allowCustom === undefined ? {} : { allowCustom }),
214
+ ...(dismissOnMoveOn === undefined ? {} : { dismissOnMoveOn }),
215
+ };
216
+ }
217
+
218
+ /** The strict decoder for a send payload crossing any seam. */
219
+ export function decodeSendToUserPayloadV1(
220
+ value: unknown,
221
+ label = "send payload",
222
+ ): SendToUserPayloadV1 {
223
+ const payload = payloadRecord(value, label);
224
+ const limits = SEND_TO_USER_LIMITS_V1;
225
+ switch (payload.type) {
226
+ case "text": {
227
+ exactPayloadKeys(payload, ["type", "text"], label);
228
+ return {
229
+ type: "text",
230
+ text: boundedString(payload.text, limits.text, `${label}.text`),
231
+ };
232
+ }
233
+ case "attachment": {
234
+ exactPayloadKeys(payload, ["type", "url", "name", "mediaType"], label);
235
+ const url = boundedString(payload.url, limits.url, `${label}.url`);
236
+ let parsed: URL;
237
+ try {
238
+ parsed = new URL(url);
239
+ } catch {
240
+ throw new Error(`${label}.url must be an absolute URL`);
241
+ }
242
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
243
+ throw new Error(`${label}.url must be an http or https URL`);
244
+ }
245
+ const name = optionalBoundedString(
246
+ payload.name,
247
+ limits.name,
248
+ `${label}.name`,
249
+ );
250
+ const mediaType = optionalBoundedString(
251
+ payload.mediaType,
252
+ limits.mediaType,
253
+ `${label}.mediaType`,
254
+ );
255
+ return {
256
+ type: "attachment",
257
+ url,
258
+ ...(name === undefined ? {} : { name }),
259
+ ...(mediaType === undefined ? {} : { mediaType }),
260
+ };
261
+ }
262
+ case "widget": {
263
+ exactPayloadKeys(payload, ["type", "widget"], label);
264
+ return {
265
+ type: "widget",
266
+ widget: decodeWidget(payload.widget, `${label}.widget`),
267
+ };
268
+ }
269
+ case "secret-request": {
270
+ exactPayloadKeys(payload, ["type", "prompt", "secretName"], label);
271
+ return {
272
+ type: "secret-request",
273
+ prompt: boundedString(payload.prompt, limits.prompt, `${label}.prompt`),
274
+ secretName: boundedString(
275
+ payload.secretName,
276
+ limits.secretName,
277
+ `${label}.secretName`,
278
+ ),
279
+ };
280
+ }
281
+ case "approval": {
282
+ exactPayloadKeys(
283
+ payload,
284
+ [
285
+ "type",
286
+ "approvalId",
287
+ "action",
288
+ "rationale",
289
+ "risk",
290
+ "expiresInSeconds",
291
+ ],
292
+ label,
293
+ );
294
+ if (
295
+ typeof payload.risk !== "string" ||
296
+ !SEND_TO_USER_APPROVAL_RISKS_V1.includes(
297
+ payload.risk as SendToUserApprovalRiskV1,
298
+ )
299
+ ) {
300
+ throw new Error(`${label}.risk must be low, medium or high`);
301
+ }
302
+ // A window, not a duration to be interpreted: the record clamps it, and
303
+ // the decoder only refuses what could not be a window at all.
304
+ if (payload.expiresInSeconds !== undefined) {
305
+ if (
306
+ typeof payload.expiresInSeconds !== "number" ||
307
+ !Number.isSafeInteger(payload.expiresInSeconds) ||
308
+ payload.expiresInSeconds <= 0
309
+ ) {
310
+ throw new Error(
311
+ `${label}.expiresInSeconds must be a positive whole number of seconds`,
312
+ );
313
+ }
314
+ }
315
+ const rationale = optionalBoundedString(
316
+ payload.rationale,
317
+ limits.rationale,
318
+ `${label}.rationale`,
319
+ );
320
+ const approvalId = boundedString(
321
+ payload.approvalId,
322
+ limits.approvalId,
323
+ `${label}.approvalId`,
324
+ );
325
+ if (!APPROVAL_ID_PATTERN_V1.test(approvalId)) {
326
+ throw new Error(
327
+ `${label}.approvalId must be letters, digits, dot, underscore or dash`,
328
+ );
329
+ }
330
+ return {
331
+ type: "approval",
332
+ approvalId,
333
+ action: boundedString(payload.action, limits.action, `${label}.action`),
334
+ ...(rationale === undefined ? {} : { rationale }),
335
+ risk: payload.risk as SendToUserApprovalRiskV1,
336
+ ...(payload.expiresInSeconds === undefined
337
+ ? {}
338
+ : { expiresInSeconds: payload.expiresInSeconds }),
339
+ };
340
+ }
341
+ case "agent-card": {
342
+ exactPayloadKeys(payload, ["type", "agentId", "title", "body"], label);
343
+ const body = optionalBoundedString(
344
+ payload.body,
345
+ limits.body,
346
+ `${label}.body`,
347
+ );
348
+ return {
349
+ type: "agent-card",
350
+ agentId: boundedString(
351
+ payload.agentId,
352
+ limits.agentId,
353
+ `${label}.agentId`,
354
+ ),
355
+ title: boundedString(payload.title, limits.title, `${label}.title`),
356
+ ...(body === undefined ? {} : { body }),
357
+ };
358
+ }
359
+ case "connect-card": {
360
+ exactPayloadKeys(
361
+ payload,
362
+ ["type", "connectionId", "title", "body"],
363
+ label,
364
+ );
365
+ const body = optionalBoundedString(
366
+ payload.body,
367
+ limits.body,
368
+ `${label}.body`,
369
+ );
370
+ return {
371
+ type: "connect-card",
372
+ connectionId: boundedString(
373
+ payload.connectionId,
374
+ limits.connectionId,
375
+ `${label}.connectionId`,
376
+ ),
377
+ title: boundedString(payload.title, limits.title, `${label}.title`),
378
+ ...(body === undefined ? {} : { body }),
379
+ };
380
+ }
381
+ default:
382
+ throw new Error(`${label}.type is invalid`);
383
+ }
384
+ }