@fourier-labs/harbour-contracts 0.70.0 → 0.71.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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "https://schemas.harbour.dev/integration-execute-request.schema.json",
4
4
  "title": "IntegrationExecuteRequest",
5
- "description": "Body of POST /_harbour/integrations/execute (app SDK), forwarded unchanged to the governance development and runtime execute routes. Exactly one declared operation against one declared resource. The identity mode is fixed by the operation for every operation but slack.message.post, and always enforced from an approved grant; any identity/userId/scope field is rejected by additionalProperties. A slack.message.post runs as the app (the IT-approved per-app name from the grant, plus a \"Posted by <app> on Isomorph\" footer) or as the signed-in person (no name, no footer) — the same input either way — and since 0.55.0 the call names which of the app's approved modes it runs under (`mode`): required when IT approved both modes for the app (MODE_REQUIRED otherwise), optional when it approved one (that one is used), and a named mode without an approved grant is refused (MODE_NOT_GRANTED) rather than served under the other, so approval order never decides what a post does. Per-operation input bounds: slack.channel.history {limit? 1..15, default 15}; slack.message.post {text 1..4000 chars} and a required idempotencyKey retained across retries of one explicit Send; gmail.thread.list {limit? 1..15, default 15}; gmail.message.read {messageId}; gmail.message.send {to 1..50 addresses (to+cc+bcc <= 50 in total), cc?, bcc?, subject 1..256 chars, text 1..4000 chars, replyToMessageId?} and a required idempotencyKey like slack.message.post (plain text only, no attachments); warehouse.view.read {columns? 1..32, where? 1..16 filters, groupBy? 1..8 columns, aggregates? 1..16, orderBy? 1..8, limit? 1..1000, default 100} (0.70.0: a plain read names columns, an aggregate read names aggregates and optionally groupBy; the cross-field rules — exactly one of columns or aggregates, groupBy needs aggregates, a filter value that fits its op, orderBy naming a returned column, every named column approved — are enforced by governance, not expressible here; ADR 0017). Each operation's then-block is the one statement of its input rules: propertyNames.enum names the fields it accepts, required the ones it needs, and per-field overrides (maximum, default) narrow the shared property. Codegen derives IntegrationExecuteInputRules and validateIntegrationExecuteInput (gen-ts/integration-execute-input-rules.ts, gen-go/integration_execute_input_rules.go) from exactly these blocks, so the governance execute route, the app SDK and the kit's local check refuse the same request with the same message. Transport limits (not expressible here): request <= 16 KiB, total deadline 10 s.",
5
+ "description": "Body of POST /_harbour/integrations/execute (app SDK), forwarded unchanged to the governance development and runtime execute routes. Exactly one declared operation against one declared resource. The identity mode is fixed by the operation for every operation but the declared-mode Slack operations (slack.channel.history, slack.message.post, slack.message.update, slack.message.delete; 0.71.0 widened history), and always enforced from an approved grant; any identity/userId/scope field is rejected by additionalProperties. A declared-mode operation runs as the app (for a post: the IT-approved per-app name from the grant, plus a \"Posted by <app> on Isomorph\" footer) or as the signed-in person (no name, no footer) — the same input either way — and since 0.55.0 the call names which of the app's approved modes it runs under (`mode`): required when IT approved both modes for the app (MODE_REQUIRED otherwise), optional when it approved one (that one is used), and a named mode without an approved grant is refused (MODE_NOT_GRANTED) rather than served under the other, so approval order never decides what a call does. Per-operation input bounds (0.71.0, ADR 0018): slack.channel.history {limit? 1..100, default 15, cursor?, oldest?, latest?}; slack.message.post {text 1..4000 chars, threadId?, format? plain|mrkdwn} and a required idempotencyKey retained across retries of one explicit Send; slack.message.update {messageId, text, format?}; slack.message.delete {messageId}; slack.dm.send {to 1..8 addresses, text, format?} (app mode only) with a required idempotencyKey; slack.user.lookup {email}; gmail.thread.list {limit? 1..50, default 15, q?, labelIds? default [INBOX], pageToken?}; gmail.thread.read {threadId}; gmail.message.read {messageId}; gmail.attachment.read {messageId, attachmentId}; gmail.message.send {to 1..50 addresses (to+cc+bcc <= 50 in total), cc?, bcc?, subject 1..256 chars, text 1..4000 chars, html? <= 64 KiB, attachments? 1..5 of <= 2 MiB each as base64, replyToMessageId?} and a required idempotencyKey like slack.message.post; warehouse.view.read {columns? 1..32, where? 1..16 filters, groupBy? 1..8 columns, aggregates? 1..16, orderBy? 1..8, limit? 1..1000, default 100} (0.70.0: a plain read names columns, an aggregate read names aggregates and optionally groupBy; the cross-field rules — exactly one of columns or aggregates, groupBy needs aggregates, a filter value that fits its op, orderBy naming a returned column, every named column approved — are enforced by governance, not expressible here; ADR 0017). Each operation's then-block is the one statement of its input rules: propertyNames.enum names the fields it accepts, required the ones it needs, and per-field overrides (maximum, default) narrow the shared property. Codegen derives IntegrationExecuteInputRules and validateIntegrationExecuteInput (gen-ts/integration-execute-input-rules.ts, gen-go/integration_execute_input_rules.go) from exactly these blocks, so the governance execute route, the app SDK and the kit's local check refuse the same request with the same message. Transport limits (not expressible here): request <= 16 KiB except a gmail.message.send carrying attachments (governance sizes that route for the attachment bound), total deadline 10 s.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": ["connection", "operation", "resource", "input"],
@@ -14,14 +14,32 @@
14
14
  "type": "object",
15
15
  "additionalProperties": false,
16
16
  "properties": {
17
- "limit": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Read operations: how many items to return; the per-operation then-block sets the real ceiling (15 for slack.channel.history and gmail.thread.list, 1000 for warehouse.view.read) and the default." },
18
- "text": { "type": "string", "minLength": 1, "maxLength": 4000, "description": "slack.message.post / gmail.message.send: the plain-text body, 1..4000 characters." },
19
- "messageId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$", "description": "gmail.message.read: the Gmail message id a gmail.thread.list result named." },
20
- "to": { "$ref": "#/definitions/EmailAddressList", "description": "gmail.message.send: primary recipients." },
17
+ "limit": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Read operations: how many items to return; the per-operation then-block sets the real ceiling (100 for slack.channel.history, 50 for gmail.thread.list, 1000 for warehouse.view.read) and the default." },
18
+ "text": { "type": "string", "minLength": 1, "maxLength": 4000, "description": "slack.message.post / slack.message.update / slack.dm.send / gmail.message.send: the body, 1..4000 characters (plain text, or Slack mrkdwn when `format` says so)." },
19
+ "format": { "$ref": "#/definitions/SlackMessageFormat", "description": "slack.message.post / slack.message.update / slack.dm.send (0.71.0): how Slack renders `text`; plain when absent." },
20
+ "threadId": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,64}$", "description": "slack.message.post (0.71.0): the Slack message ts (a slack.channel.history message id) the post replies to, so it lands in that thread. gmail.thread.read: the Gmail thread id a gmail.thread.list result named." },
21
+ "messageId": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,64}$", "description": "gmail.message.read / gmail.attachment.read: the Gmail message id a gmail.thread.list or gmail.thread.read result named. slack.message.update / slack.message.delete (0.71.0): the Slack message ts a post or history result named." },
22
+ "attachmentId": { "type": "string", "minLength": 1, "maxLength": 512, "description": "gmail.attachment.read (0.71.0): the attachment id a gmail.message.read or gmail.thread.read result named." },
23
+ "cursor": { "type": "string", "minLength": 1, "maxLength": 256, "description": "slack.channel.history (0.71.0): the `nextCursor` of the previous page; absent reads the newest page." },
24
+ "oldest": { "type": "string", "minLength": 1, "maxLength": 64, "description": "slack.channel.history (0.71.0): only messages after this Slack ts." },
25
+ "latest": { "type": "string", "minLength": 1, "maxLength": 64, "description": "slack.channel.history (0.71.0): only messages before this Slack ts." },
26
+ "q": { "type": "string", "minLength": 1, "maxLength": 256, "description": "gmail.thread.list (0.71.0): a Gmail search query (the search-box syntax, e.g. `from:jane newer_than:7d`)." },
27
+ "labelIds": { "type": "array", "minItems": 1, "maxItems": 5, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 64 }, "default": ["INBOX"], "description": "gmail.thread.list (0.71.0): the Gmail labels a thread must carry (all of them); INBOX when absent." },
28
+ "pageToken": { "type": "string", "minLength": 1, "maxLength": 256, "description": "gmail.thread.list (0.71.0): the `nextPageToken` of the previous page; absent reads the first page." },
29
+ "email": { "$ref": "#/definitions/EmailAddress", "description": "slack.user.lookup (0.71.0): the workspace member's email address to resolve to a Slack user id." },
30
+ "to": { "$ref": "#/definitions/EmailAddressList", "description": "gmail.message.send: primary recipients. slack.dm.send (0.71.0): the workspace members to message, by email, 1..8; several open one group DM." },
21
31
  "cc": { "$ref": "#/definitions/EmailAddressList", "description": "gmail.message.send: carbon-copy recipients." },
22
32
  "bcc": { "$ref": "#/definitions/EmailAddressList", "description": "gmail.message.send: blind-copy recipients." },
23
33
  "subject": { "type": "string", "minLength": 1, "maxLength": 256, "description": "gmail.message.send: one-line subject (no line breaks)." },
24
34
  "replyToMessageId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$", "description": "gmail.message.send: a Gmail message id (from gmail.thread.list / gmail.message.read) the email replies to; the send joins that thread." },
35
+ "html": { "type": "string", "minLength": 1, "maxLength": 65536, "description": "gmail.message.send (0.71.0): an HTML body sent beside `text` (which stays the plain-text alternative), at most 64 KiB." },
36
+ "attachments": {
37
+ "type": "array",
38
+ "minItems": 1,
39
+ "maxItems": 5,
40
+ "items": { "$ref": "#/definitions/EmailAttachment" },
41
+ "description": "gmail.message.send (0.71.0): files to attach, 1..5, each at most 2 MiB (2,796,203 base64 characters)."
42
+ },
25
43
  "columns": {
26
44
  "type": "array",
27
45
  "minItems": 1,
@@ -67,7 +85,7 @@
67
85
  },
68
86
  "mode": {
69
87
  "$ref": "app-integrations-declaration.schema.json#/definitions/IntegrationIdentityMode",
70
- "description": "slack.message.post only (0.55.0): which of the app's IT-approved modes this post runs under — app (the company's Slack app, under the grant's per-app name and the footer) or user (the signed-in person, after their consent; no name, no footer). Required when the app holds approved grants for both modes (else MODE_REQUIRED); may be omitted when it holds one, which is then used. A named mode without an approved grant is refused with MODE_NOT_GRANTED; governance never falls back to the other mode. Every other operation's mode is fixed, so the field is refused there."
88
+ "description": "The declared-mode Slack operations only (slack.message.post since 0.55.0; slack.channel.history, slack.message.update and slack.message.delete since 0.71.0): which of the app's IT-approved modes this call runs under — app (the company's Slack app; a post goes out under the grant's per-app name and the footer) or user (the signed-in person, after their consent; no name, no footer). Required when the app holds approved grants for both modes (else MODE_REQUIRED); may be omitted when it holds one, which is then used. A named mode without an approved grant is refused with MODE_NOT_GRANTED; governance never falls back to the other mode. Every other operation's mode is fixed, so the field is refused there."
71
89
  }
72
90
  },
73
91
  "allOf": [
@@ -76,8 +94,7 @@
76
94
  "then": {
77
95
  "not": { "required": ["idempotencyKey"] },
78
96
  "properties": {
79
- "mode": false,
80
- "input": { "propertyNames": { "enum": ["limit"] }, "properties": { "limit": { "maximum": 15, "default": 15 } } }
97
+ "input": { "propertyNames": { "enum": ["limit", "cursor", "oldest", "latest"] }, "properties": { "limit": { "maximum": 100, "default": 15 } } }
81
98
  }
82
99
  }
83
100
  },
@@ -86,7 +103,45 @@
86
103
  "then": {
87
104
  "required": ["idempotencyKey"],
88
105
  "properties": {
89
- "input": { "propertyNames": { "enum": ["text"] }, "required": ["text"] }
106
+ "input": { "propertyNames": { "enum": ["text", "threadId", "format"] }, "required": ["text"] }
107
+ }
108
+ }
109
+ },
110
+ {
111
+ "if": { "properties": { "operation": { "const": "slack.message.update" } } },
112
+ "then": {
113
+ "not": { "required": ["idempotencyKey"] },
114
+ "properties": {
115
+ "input": { "propertyNames": { "enum": ["messageId", "text", "format"] }, "required": ["messageId", "text"] }
116
+ }
117
+ }
118
+ },
119
+ {
120
+ "if": { "properties": { "operation": { "const": "slack.message.delete" } } },
121
+ "then": {
122
+ "not": { "required": ["idempotencyKey"] },
123
+ "properties": {
124
+ "input": { "propertyNames": { "enum": ["messageId"] }, "required": ["messageId"] }
125
+ }
126
+ }
127
+ },
128
+ {
129
+ "if": { "properties": { "operation": { "const": "slack.dm.send" } } },
130
+ "then": {
131
+ "required": ["idempotencyKey"],
132
+ "properties": {
133
+ "mode": false,
134
+ "input": { "propertyNames": { "enum": ["to", "text", "format"] }, "required": ["to", "text"], "properties": { "to": { "maxItems": 8 } } }
135
+ }
136
+ }
137
+ },
138
+ {
139
+ "if": { "properties": { "operation": { "const": "slack.user.lookup" } } },
140
+ "then": {
141
+ "not": { "required": ["idempotencyKey"] },
142
+ "properties": {
143
+ "mode": false,
144
+ "input": { "propertyNames": { "enum": ["email"] }, "required": ["email"] }
90
145
  }
91
146
  }
92
147
  },
@@ -106,7 +161,17 @@
106
161
  "not": { "required": ["idempotencyKey"] },
107
162
  "properties": {
108
163
  "mode": false,
109
- "input": { "propertyNames": { "enum": ["limit"] }, "properties": { "limit": { "maximum": 15, "default": 15 } } }
164
+ "input": { "propertyNames": { "enum": ["limit", "q", "labelIds", "pageToken"] }, "properties": { "limit": { "maximum": 50, "default": 15 } } }
165
+ }
166
+ }
167
+ },
168
+ {
169
+ "if": { "properties": { "operation": { "const": "gmail.thread.read" } } },
170
+ "then": {
171
+ "not": { "required": ["idempotencyKey"] },
172
+ "properties": {
173
+ "mode": false,
174
+ "input": { "propertyNames": { "enum": ["threadId"] }, "required": ["threadId"] }
110
175
  }
111
176
  }
112
177
  },
@@ -120,13 +185,23 @@
120
185
  }
121
186
  }
122
187
  },
188
+ {
189
+ "if": { "properties": { "operation": { "const": "gmail.attachment.read" } } },
190
+ "then": {
191
+ "not": { "required": ["idempotencyKey"] },
192
+ "properties": {
193
+ "mode": false,
194
+ "input": { "propertyNames": { "enum": ["messageId", "attachmentId"] }, "required": ["messageId", "attachmentId"] }
195
+ }
196
+ }
197
+ },
123
198
  {
124
199
  "if": { "properties": { "operation": { "const": "gmail.message.send" } } },
125
200
  "then": {
126
201
  "required": ["idempotencyKey"],
127
202
  "properties": {
128
203
  "mode": false,
129
- "input": { "propertyNames": { "enum": ["to", "cc", "bcc", "subject", "text", "replyToMessageId"] }, "required": ["to", "subject", "text"] }
204
+ "input": { "propertyNames": { "enum": ["to", "cc", "bcc", "subject", "text", "html", "attachments", "replyToMessageId"] }, "required": ["to", "subject", "text"] }
130
205
  }
131
206
  }
132
207
  }
@@ -134,6 +209,23 @@
134
209
  "definitions": {
135
210
  "EmailAddress": { "type": "string", "minLength": 3, "maxLength": 254, "pattern": "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$", "description": "One bare email address (no display name)." },
136
211
  "EmailAddressList": { "type": "array", "minItems": 1, "maxItems": 50, "uniqueItems": true, "items": { "$ref": "#/definitions/EmailAddress" } },
212
+ "SlackMessageFormat": {
213
+ "type": "string",
214
+ "enum": ["plain", "mrkdwn"],
215
+ "default": "plain",
216
+ "description": "How Slack renders a message body (0.71.0): plain posts the text verbatim; mrkdwn lets Slack interpret its markup (*bold*, links, lists)."
217
+ },
218
+ "EmailAttachment": {
219
+ "type": "object",
220
+ "additionalProperties": false,
221
+ "required": ["filename", "contentType", "dataBase64"],
222
+ "description": "One file attached to a gmail.message.send (0.71.0), carried inline as base64. At most 2 MiB of bytes, which is 2,796,203 base64 characters.",
223
+ "properties": {
224
+ "filename": { "type": "string", "minLength": 1, "maxLength": 255, "description": "The name the recipient sees." },
225
+ "contentType": { "type": "string", "minLength": 1, "maxLength": 128, "description": "The MIME type, e.g. application/pdf." },
226
+ "dataBase64": { "type": "string", "minLength": 1, "maxLength": 2796203, "description": "The file bytes, standard base64." }
227
+ }
228
+ },
137
229
  "WarehouseFilter": {
138
230
  "type": "object",
139
231
  "additionalProperties": false,
@@ -247,6 +339,47 @@
247
339
  "orderBy": [{ "column": "total_minor", "direction": "desc" }],
248
340
  "limit": 100
249
341
  }
342
+ },
343
+ {
344
+ "connection": "company-slack",
345
+ "operation": "slack.message.update",
346
+ "resource": "team-updates",
347
+ "input": { "messageId": "1725840100.000200", "text": "Weekly update posted *(corrected)*.", "format": "mrkdwn" },
348
+ "mode": "app"
349
+ },
350
+ { "connection": "company-slack", "operation": "slack.message.delete", "resource": "team-updates", "input": { "messageId": "1725840100.000200" } },
351
+ {
352
+ "connection": "company-slack",
353
+ "operation": "slack.dm.send",
354
+ "resource": "workspace",
355
+ "input": { "to": ["jane@example.com"], "text": "Your expense report was approved." },
356
+ "idempotencyKey": "6f1c2c1e-3a1c-4c3e-9f0e-2b6f7d8a9c13"
357
+ },
358
+ { "connection": "company-slack", "operation": "slack.user.lookup", "resource": "workspace", "input": { "email": "jane@example.com" } },
359
+ {
360
+ "connection": "company-slack",
361
+ "operation": "slack.message.post",
362
+ "resource": "team-updates",
363
+ "input": { "text": "Thanks, shipped.", "threadId": "1725840000.000100", "format": "plain" },
364
+ "idempotencyKey": "6f1c2c1e-3a1c-4c3e-9f0e-2b6f7d8a9c14",
365
+ "mode": "user"
366
+ },
367
+ { "connection": "company-slack", "operation": "slack.channel.history", "resource": "team-updates", "input": { "limit": 100, "cursor": "bmV4dF90czoxNzI1ODQwMDAwMDAwMTAw", "oldest": "1725750000.000000" }, "mode": "app" },
368
+ { "connection": "gmail", "operation": "gmail.thread.list", "resource": "inbox", "input": { "limit": 50, "q": "from:jane@example.com newer_than:7d", "labelIds": ["INBOX", "IMPORTANT"] } },
369
+ { "connection": "gmail", "operation": "gmail.thread.read", "resource": "inbox", "input": { "threadId": "18f2c3a4b5d6e7f0" } },
370
+ { "connection": "gmail", "operation": "gmail.attachment.read", "resource": "inbox", "input": { "messageId": "18f2c3a4b5d6e7f8", "attachmentId": "ANGjdJ8w9h1Q2k3L4m5N6o7P8q9R0s" } },
371
+ {
372
+ "connection": "gmail",
373
+ "operation": "gmail.message.send",
374
+ "resource": "inbox",
375
+ "input": {
376
+ "to": ["jane@example.com"],
377
+ "subject": "Weekly report",
378
+ "text": "The weekly report is attached.",
379
+ "html": "<p>The weekly report is <b>attached</b>.</p>",
380
+ "attachments": [{ "filename": "weekly-report.pdf", "contentType": "application/pdf", "dataBase64": "JVBERi0xLjQKJcOkw7zDtsOfCg==" }]
381
+ },
382
+ "idempotencyKey": "6f1c2c1e-3a1c-4c3e-9f0e-2b6f7d8a9c15"
250
383
  }
251
384
  ]
252
385
  }
@@ -2,22 +2,27 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "https://schemas.harbour.dev/integration-execute-result.schema.json",
4
4
  "title": "IntegrationExecuteResult",
5
- "description": "The `data` member of a successful execute response `{data, requestId}`; the SDK unwraps `data`. Exactly one of the six operation results: slack.channel.history -> {messages, hasMore} (plain text/IDs/time, plus the display name and emoji icon a per-app-named app post carries — authorName/authorIcon, 0.55.0 — never the provider payload); slack.message.post -> {messageId}; gmail.thread.list -> {threads, hasMore} (newest inbox threads; each `messageId` is the thread's newest message, what gmail.message.read takes); gmail.message.read -> {id, threadId, subject, from, to, date, text, truncated} (headers plus the plain text with html stripped, cut at 16 KiB with `truncated` set); gmail.message.send -> {messageId, threadId} (the Gmail ids of the sent message and its thread); warehouse.view.read -> {rows, truncated} (rows keyed by the selected columns, or for an aggregate read by the groupBy columns then the aggregate aliases; decimal/64-bit values as strings, dates as ISO strings; since 0.70.0 every server sends `truncated`, true when more rows matched than `limit` — the server reads limit+1 rows to know — so the rows are incomplete and must not be totalled or averaged; a server before 0.70.0 omitted it). An unknown send outcome is never a result; it is an error with details.code SEND_OUTCOME_UNKNOWN. A whole result over 1 MiB is rejected, never cut down to fit.",
5
+ "description": "The `data` member of a successful execute response `{data, requestId}`; the SDK unwraps `data`. Exactly one of the operation results (twelve operations since 0.71.0, ADR 0018): slack.channel.history -> {messages, hasMore, nextCursor?} (plain text/IDs/time, plus the display name and emoji icon a per-app-named app post carries — authorName/authorIcon, 0.55.0 — never the provider payload; `nextCursor` when hasMore, to pass as the next read's `cursor`); slack.message.post, slack.message.update, slack.message.delete -> {messageId} (the Slack ts); slack.dm.send -> {messageId, channelId} (the ts and the DM channel it went to); slack.user.lookup -> {userId, displayName}; gmail.thread.list -> {threads, hasMore, nextPageToken?} (newest matching threads; each `messageId` is the thread's newest message, what gmail.message.read takes; `nextPageToken` when hasMore); gmail.thread.read -> {id, emails} (every message of the thread, oldest first, each shaped like a gmail.message.read result); gmail.message.read -> {id, threadId, subject, from, to, date, text, truncated, attachments?} (headers plus the plain text with html stripped, cut at 16 KiB with `truncated` set; `attachments` lists the files without their bytes, which gmail.attachment.read fetches by id); gmail.attachment.read -> {filename, mimeType, size, dataBase64}; gmail.message.send -> {messageId, threadId} (the Gmail ids of the sent message and its thread); warehouse.view.read -> {rows, truncated} (rows keyed by the selected columns, or for an aggregate read by the groupBy columns then the aggregate aliases; decimal/64-bit values as strings, dates as ISO strings; since 0.70.0 every server sends `truncated`, true when more rows matched than `limit` — the server reads limit+1 rows to know — so the rows are incomplete and must not be totalled or averaged; a server before 0.70.0 omitted it). An unknown send outcome is never a result; it is an error with details.code SEND_OUTCOME_UNKNOWN. A whole result over 1 MiB is rejected, never cut down to fit — except a gmail.attachment.read, whose single attachment is bounded by its own 2 MiB.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "properties": {
9
9
  "messages": {
10
10
  "type": "array",
11
- "maxItems": 15,
11
+ "maxItems": 100,
12
12
  "items": { "$ref": "#/definitions/ChannelMessage" }
13
13
  },
14
14
  "hasMore": { "type": "boolean" },
15
+ "nextCursor": { "type": "string", "minLength": 1, "maxLength": 256, "description": "slack.channel.history (0.71.0): the cursor of the next (older) page, present only when hasMore." },
15
16
  "messageId": { "type": "string", "minLength": 1, "maxLength": 128 },
17
+ "channelId": { "type": "string", "pattern": "^[A-Z0-9]{5,20}$", "description": "slack.dm.send (0.71.0): the Slack DM channel id the message went to." },
18
+ "userId": { "type": "string", "minLength": 1, "maxLength": 128, "description": "slack.user.lookup (0.71.0): the Slack user id of the member with that email." },
19
+ "displayName": { "type": "string", "maxLength": 256, "description": "slack.user.lookup (0.71.0): the member's display name, or real name when they set no display name; may be empty." },
16
20
  "threads": {
17
21
  "type": "array",
18
- "maxItems": 15,
22
+ "maxItems": 50,
19
23
  "items": { "$ref": "#/definitions/GmailThread" }
20
24
  },
25
+ "nextPageToken": { "type": "string", "minLength": 1, "maxLength": 256, "description": "gmail.thread.list (0.71.0): the token of the next page, present only when hasMore." },
21
26
  "id": { "type": "string", "minLength": 1, "maxLength": 128 },
22
27
  "threadId": { "type": "string", "minLength": 1, "maxLength": 128 },
23
28
  "subject": { "type": "string", "maxLength": 2048 },
@@ -26,6 +31,22 @@
26
31
  "date": { "type": "string", "maxLength": 128 },
27
32
  "text": { "type": "string", "maxLength": 16384 },
28
33
  "truncated": { "type": "boolean", "description": "gmail.message.read: the text was cut at 16 KiB. warehouse.view.read (0.70.0): more rows matched than the read's limit, so `rows` is incomplete." },
34
+ "attachments": {
35
+ "type": "array",
36
+ "maxItems": 100,
37
+ "items": { "$ref": "#/definitions/GmailAttachment" },
38
+ "description": "gmail.message.read (0.71.0): the message's attachments, without their bytes."
39
+ },
40
+ "emails": {
41
+ "type": "array",
42
+ "maxItems": 100,
43
+ "items": { "$ref": "#/definitions/GmailMessage" },
44
+ "description": "gmail.thread.read (0.71.0): every message of the thread, oldest first."
45
+ },
46
+ "filename": { "type": "string", "minLength": 1, "maxLength": 255 },
47
+ "mimeType": { "type": "string", "minLength": 1, "maxLength": 128 },
48
+ "size": { "type": "integer", "minimum": 0, "description": "gmail.attachment.read (0.71.0): the attachment's size in bytes." },
49
+ "dataBase64": { "type": "string", "maxLength": 2796203, "description": "gmail.attachment.read (0.71.0): the attachment's bytes, standard base64, at most 2 MiB of bytes." },
29
50
  "rows": {
30
51
  "type": "array",
31
52
  "maxItems": 1000,
@@ -33,14 +54,47 @@
33
54
  }
34
55
  },
35
56
  "oneOf": [
36
- { "required": ["messages", "hasMore"], "maxProperties": 2 },
57
+ { "required": ["messages", "hasMore"], "propertyNames": { "enum": ["messages", "hasMore", "nextCursor"] } },
37
58
  { "required": ["messageId"], "maxProperties": 1 },
38
59
  { "required": ["messageId", "threadId"], "maxProperties": 2 },
39
- { "required": ["threads", "hasMore"], "maxProperties": 2 },
40
- { "required": ["id", "threadId", "subject", "from", "to", "date", "text", "truncated"], "maxProperties": 8 },
60
+ { "required": ["messageId", "channelId"], "maxProperties": 2 },
61
+ { "required": ["userId", "displayName"], "maxProperties": 2 },
62
+ { "required": ["threads", "hasMore"], "propertyNames": { "enum": ["threads", "hasMore", "nextPageToken"] } },
63
+ { "required": ["id", "emails"], "maxProperties": 2 },
64
+ { "required": ["id", "threadId", "subject", "from", "to", "date", "text", "truncated"], "propertyNames": { "enum": ["id", "threadId", "subject", "from", "to", "date", "text", "truncated", "attachments"] } },
65
+ { "required": ["filename", "mimeType", "size", "dataBase64"], "maxProperties": 4 },
41
66
  { "required": ["rows"], "propertyNames": { "enum": ["rows", "truncated"] } }
42
67
  ],
43
68
  "definitions": {
69
+ "GmailAttachment": {
70
+ "type": "object",
71
+ "additionalProperties": false,
72
+ "required": ["id", "filename", "mimeType", "size"],
73
+ "description": "One attachment of a Gmail message (0.71.0), named without its bytes; gmail.attachment.read fetches them by `id`.",
74
+ "properties": {
75
+ "id": { "type": "string", "minLength": 1, "maxLength": 512 },
76
+ "filename": { "type": "string", "maxLength": 255 },
77
+ "mimeType": { "type": "string", "maxLength": 128 },
78
+ "size": { "type": "integer", "minimum": 0, "description": "Bytes." }
79
+ }
80
+ },
81
+ "GmailMessage": {
82
+ "type": "object",
83
+ "additionalProperties": false,
84
+ "required": ["id", "threadId", "subject", "from", "to", "date", "text", "truncated"],
85
+ "description": "One message of a gmail.thread.read result (0.71.0): the gmail.message.read shape.",
86
+ "properties": {
87
+ "id": { "type": "string", "minLength": 1, "maxLength": 128 },
88
+ "threadId": { "type": "string", "minLength": 1, "maxLength": 128 },
89
+ "subject": { "type": "string", "maxLength": 2048 },
90
+ "from": { "type": "string", "maxLength": 2048 },
91
+ "to": { "type": "string", "maxLength": 8192 },
92
+ "date": { "type": "string", "maxLength": 128 },
93
+ "text": { "type": "string", "maxLength": 16384 },
94
+ "truncated": { "type": "boolean" },
95
+ "attachments": { "type": "array", "maxItems": 100, "items": { "$ref": "#/definitions/GmailAttachment" } }
96
+ }
97
+ },
44
98
  "GmailThread": {
45
99
  "type": "object",
46
100
  "additionalProperties": false,
@@ -81,6 +135,27 @@
81
135
  },
82
136
  { "id": "18f2c3a4b5d6e7f8", "threadId": "18f2c3a4b5d6e7f0", "subject": "Weekly report", "from": "Jane <jane@example.com>", "to": "team@example.com", "date": "2026-09-11T08:00:00Z", "text": "Shipped the weekly report.", "truncated": false },
83
137
  { "rows": [{ "week": "2026-W36", "total": "1234.50" }] },
84
- { "rows": [{ "region_code": "EMEA", "total_minor": "1234550", "invoices": "42" }, { "region_code": "APAC", "total_minor": "998000", "invoices": "17" }], "truncated": true }
138
+ { "rows": [{ "region_code": "EMEA", "total_minor": "1234550", "invoices": "42" }, { "region_code": "APAC", "total_minor": "998000", "invoices": "17" }], "truncated": true },
139
+ {
140
+ "messages": [{ "id": "1725840000.000100", "text": "Shipped the weekly report.", "authorId": "U0123456", "createdAt": "2026-09-09T08:00:00Z" }],
141
+ "hasMore": true,
142
+ "nextCursor": "bmV4dF90czoxNzI1ODQwMDAwMDAwMTAw"
143
+ },
144
+ { "messageId": "1725840200.000300", "channelId": "D0123456DM" },
145
+ { "userId": "U0123456", "displayName": "Jane Doe" },
146
+ {
147
+ "threads": [{ "id": "18f2c3a4b5d6e7f0", "messageId": "18f2c3a4b5d6e7f8", "subject": "Weekly report", "from": "Jane <jane@example.com>", "date": "2026-09-11T08:00:00Z", "snippet": "Shipped the weekly report." }],
148
+ "hasMore": true,
149
+ "nextPageToken": "CAUQ0ZSH0b2DiwMg"
150
+ },
151
+ {
152
+ "id": "18f2c3a4b5d6e7f0",
153
+ "emails": [
154
+ { "id": "18f2c3a4b5d6e7f7", "threadId": "18f2c3a4b5d6e7f0", "subject": "Weekly report", "from": "Jane <jane@example.com>", "to": "team@example.com", "date": "2026-09-10T08:00:00Z", "text": "Draft attached.", "truncated": false, "attachments": [{ "id": "ANGjdJ8w9h1Q2k3L4m5N6o7P8q9R0s", "filename": "weekly-report.pdf", "mimeType": "application/pdf", "size": 48213 }] },
155
+ { "id": "18f2c3a4b5d6e7f8", "threadId": "18f2c3a4b5d6e7f0", "subject": "Re: Weekly report", "from": "Sam <sam@example.com>", "to": "team@example.com", "date": "2026-09-11T08:00:00Z", "text": "Shipped the weekly report.", "truncated": false }
156
+ ]
157
+ },
158
+ { "id": "18f2c3a4b5d6e7f7", "threadId": "18f2c3a4b5d6e7f0", "subject": "Weekly report", "from": "Jane <jane@example.com>", "to": "team@example.com", "date": "2026-09-10T08:00:00Z", "text": "Draft attached.", "truncated": false, "attachments": [{ "id": "ANGjdJ8w9h1Q2k3L4m5N6o7P8q9R0s", "filename": "weekly-report.pdf", "mimeType": "application/pdf", "size": 48213 }] },
159
+ { "filename": "weekly-report.pdf", "mimeType": "application/pdf", "size": 20, "dataBase64": "JVBERi0xLjQKJcOkw7zDtsOfCg==" }
85
160
  ]
86
161
  }
@@ -26,13 +26,17 @@
26
26
  "operations": {
27
27
  "type": "array",
28
28
  "minItems": 1,
29
- "maxItems": 5,
29
+ "maxItems": 11,
30
30
  "uniqueItems": true,
31
31
  "items": { "$ref": "app-integrations-declaration.schema.json#/definitions/IntegrationOperation" },
32
32
  "not": { "contains": { "const": "warehouse.view.read" } },
33
- "description": "Operations IT allows apps to request on this connection: the provider's own SaaS operations (Slack: slack.channel.history, slack.message.post; Gmail: gmail.thread.list, gmail.message.read, gmail.message.send). Never warehouse.view.read."
33
+ "description": "Operations IT allows apps to request on this connection: the provider's own SaaS operations (Slack: slack.channel.history, slack.message.post, slack.message.update, slack.message.delete, slack.dm.send, slack.user.lookup; Gmail: gmail.thread.list, gmail.thread.read, gmail.message.read, gmail.message.send, gmail.attachment.read). Never warehouse.view.read."
34
34
  },
35
35
  "developmentPreapproval": { "$ref": "#/definitions/DevelopmentPreapproval" },
36
+ "oauthClient": { "$ref": "#/definitions/SaasOauthClient" },
37
+ "userModeApproval": { "$ref": "#/definitions/UserModeApproval" },
38
+ "directMessages": { "$ref": "#/definitions/DirectMessagePolicy" },
39
+ "externalRecipients": { "$ref": "#/definitions/ExternalRecipientPolicy" },
36
40
  "blockedDevelopmentBuilders": {
37
41
  "type": "array",
38
42
  "maxItems": 256,
@@ -73,7 +77,68 @@
73
77
  "SaasProvider": {
74
78
  "type": "string",
75
79
  "enum": ["slack", "gmail"],
76
- "description": "Slack carries an installation and a channel map; Gmail is user-consent only and read-only."
80
+ "description": "Slack carries an installation and a channel map; Gmail is user-consent only."
81
+ },
82
+ "SaasOauthClient": {
83
+ "type": "object",
84
+ "additionalProperties": false,
85
+ "required": ["clientId", "secretRef", "setAt", "setBy"],
86
+ "description": "The tenant's own OAuth client for this provider (0.71.0; the ADR 0068 follow-up): when present, installation and user consent run through the company's Slack app or Google Cloud project instead of the platform-owned one, so the tenant's IT sees and controls the app in their own admin console. A reference to the secret, never the secret.",
87
+ "properties": {
88
+ "clientId": { "type": "string", "minLength": 1, "maxLength": 255 },
89
+ "secretRef": { "type": "string", "pattern": "^secretref://[A-Za-z0-9][A-Za-z0-9/_.:-]*$", "description": "Where the client secret lives." },
90
+ "setAt": { "type": "string", "format": "date-time" },
91
+ "setBy": { "type": "string", "minLength": 1, "maxLength": 255, "description": "The IT admin who saved it (lower-cased email)." }
92
+ }
93
+ },
94
+ "UserModeApproval": {
95
+ "type": "string",
96
+ "enum": ["automatic", "it"],
97
+ "description": "How a user-mode access request on this connection is approved (0.71.0): automatic — the person's own consent is the approval, since the app only ever acts as them; it — an IT admin reviews it like an app-mode request. Absent means it."
98
+ },
99
+ "DirectMessagePolicy": {
100
+ "type": "object",
101
+ "additionalProperties": false,
102
+ "required": ["allowed", "dailyRecipientCap"],
103
+ "description": "Whom an app may direct-message through slack.dm.send (0.71.0), set by IT on the connection and enforced by governance on every send.",
104
+ "properties": {
105
+ "allowed": { "$ref": "#/definitions/DirectMessageAudience" },
106
+ "channel": { "$ref": "app-integrations-declaration.schema.json#/definitions/IntegrationAlias", "description": "channel_members only: the alias (a key of `channels`) whose current members may be messaged." },
107
+ "dailyRecipientCap": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Distinct recipients one app may message per UTC day on this connection; the cap refuses the send that would exceed it." }
108
+ },
109
+ "if": { "properties": { "allowed": { "const": "channel_members" } } },
110
+ "then": { "required": ["channel"] },
111
+ "else": { "not": { "required": ["channel"] } }
112
+ },
113
+ "DirectMessageAudience": {
114
+ "type": "string",
115
+ "enum": ["signed_in_person", "channel_members", "anyone"],
116
+ "description": "signed_in_person — only the person using the app; channel_members — the members of the named channel; anyone — any workspace member."
117
+ },
118
+ "ExternalRecipientPolicy": {
119
+ "type": "object",
120
+ "additionalProperties": false,
121
+ "required": ["mode"],
122
+ "description": "Whether a gmail.message.send may address people outside the company (0.71.0). An external recipient is one whose email domain is not one of the tenant's verified domains.",
123
+ "properties": {
124
+ "mode": { "$ref": "#/definitions/ExternalRecipientMode" },
125
+ "allowedDomains": {
126
+ "type": "array",
127
+ "minItems": 1,
128
+ "maxItems": 50,
129
+ "uniqueItems": true,
130
+ "items": { "type": "string", "format": "hostname", "maxLength": 253 },
131
+ "description": "allowlist only: the external domains a send may address; a recipient's domain must equal one of them."
132
+ }
133
+ },
134
+ "if": { "properties": { "mode": { "const": "allowlist" } } },
135
+ "then": { "required": ["allowedDomains"] },
136
+ "else": { "not": { "required": ["allowedDomains"] } }
137
+ },
138
+ "ExternalRecipientMode": {
139
+ "type": "string",
140
+ "enum": ["block", "allowlist", "allow"],
141
+ "description": "block — every recipient must be internal (the default when absent); allowlist — internal or in `allowedDomains`; allow — any address."
77
142
  },
78
143
  "SaasConnectionStatus": {
79
144
  "type": "string",
@@ -148,8 +213,11 @@
148
213
  "version": 3,
149
214
  "installation": { "tokenRecordId": "tok_workspace", "workspaceId": "T0WORKSPACE", "botUserId": "U0BOT", "status": "installed", "installedAt": "2026-09-09T08:00:00Z", "scopes": ["channels:read", "chat:write", "chat:write.customize"], "botName": "isomorph_ai" },
150
215
  "channels": { "team-updates": "C0123TEAM" },
151
- "operations": ["slack.channel.history", "slack.message.post"],
216
+ "operations": ["slack.channel.history", "slack.message.post", "slack.message.update", "slack.message.delete", "slack.dm.send", "slack.user.lookup"],
152
217
  "developmentPreapproval": { "enabled": true, "hours": 24 },
218
+ "oauthClient": { "clientId": "1234567890.1234567890", "secretRef": "secretref://tenant/fourier/saas/company-slack/oauth-client", "setAt": "2026-10-01T08:00:00Z", "setBy": "it-admin@example.com" },
219
+ "userModeApproval": "automatic",
220
+ "directMessages": { "allowed": "channel_members", "channel": "team-updates", "dailyRecipientCap": 50 },
153
221
  "createdAt": "2026-09-09T08:00:00Z",
154
222
  "updatedAt": "2026-09-09T08:00:00Z"
155
223
  },
@@ -163,9 +231,10 @@
163
231
  "status": "ACTIVE",
164
232
  "version": 1,
165
233
  "channels": {},
166
- "operations": ["gmail.thread.list", "gmail.message.read", "gmail.message.send"],
234
+ "operations": ["gmail.thread.list", "gmail.thread.read", "gmail.message.read", "gmail.message.send", "gmail.attachment.read"],
167
235
  "developmentPreapproval": { "enabled": false, "hours": 24 },
168
236
  "blockedDevelopmentBuilders": ["contractor@example.com"],
237
+ "externalRecipients": { "mode": "allowlist", "allowedDomains": ["partner.example.com"] },
169
238
  "createdAt": "2026-09-11T08:00:00Z",
170
239
  "updatedAt": "2026-09-11T08:00:00Z"
171
240
  }