@fourier-labs/harbour-contracts 0.69.0 → 0.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,8 +38,8 @@ exist here first.
38
38
  | `binding-classification-subject.schema.json` | Complete value-free binding-name classification before version allocation | AI governance §5.7 / SEC-UP-01 |
39
39
  | `sanitized-binding-evidence.schema.json` | Exact-version sanitized runtime-binding allocation receipt | AI governance §5.7 / SEC-UP-02 |
40
40
  | `ai-pricing-revision`, `ai-metering-policy`, `ai-budget-ledger-event`, `ai-trace-event-v2` | Additive metering v2 records, including media dimensions and P5 trace attribution; v1 records remain readable | AI governance §9 |
41
- | `app-integrations-declaration.schema.json` | Committed `.isomorph/integrations.json` (`isomorph.app-integrations/2.0`): closed five-operation set (Slack read/post, Gmail read-only, warehouse view read) with fixed identity modes; owns the shared operation/identity/environment enums | Dev kit §4 |
42
- | `integration-execute-request`, `integration-execute-result`, `integration-connect-result`, `integration-error-details` | App SDK integrations wire contract on `/_harbour/integrations/*`: per-operation input bounds, the five result shapes, consent states and the closed `details.code` reason set | Dev kit §4, §8 |
41
+ | `app-integrations-declaration.schema.json` | Committed `.isomorph/integrations.json` (`isomorph.app-integrations/2.0`): closed six-operation set (Slack read/post, Gmail read/send, warehouse view read) with fixed identity modes; owns the shared operation/identity/environment enums | Dev kit §4 |
42
+ | `integration-execute-request`, `integration-execute-result`, `integration-connect-result`, `integration-error-details` | App SDK integrations wire contract on `/_harbour/integrations/*`: per-operation input bounds (a warehouse read filters, groups, aggregates and sorts in the warehouse and says when it was `truncated` — ADR 0017), the six result shapes, consent states and the closed `details.code` reason set | Dev kit §4, §8 |
43
43
  | `saas-connection.schema.json`, `data-grant-v2.schema.json` | Tenant SaaS connection record (one installation and channel map per tenant and provider) and the additive `harbour.data-grant/2.0` grant record with structured resources and provisioning lease/cleanup (status machine generated; legacy `data-grant` untouched) | Dev kit §5 |
44
44
  | `kit-bundle-manifest.schema.json`, `kit-lock.schema.json` | Published kit bundle identity (SDK tarball digest plus optional `sdk.version` and https `sdk.url` download location) and the committed `.isomorph/kit.lock.json` the CodeBuild SDK-vendoring gate compares against | Dev kit §3 |
45
45
  | `deploy-response.schema.json` | 202 Accepted body for `POST /v1/tenants/{tenant}/apps/{app}/deployments`, returned only after the authoritative QUEUED version exists and its SQS message is accepted | — |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schema": "harbour.contract-release/1.0",
3
- "repositoryVersion": "0.69.0.0",
4
- "publishVersion": "0.69.0",
5
- "publishTag": "v0.69.0",
6
- "schemasDigest": "sha256:e21f310517adb289db8b8233fd3c742a711fed8226892591f6581afb8510e30c",
3
+ "repositoryVersion": "0.70.0.0",
4
+ "publishVersion": "0.70.0",
5
+ "publishTag": "v0.70.0",
6
+ "schemasDigest": "sha256:b3c430914fee8a4f33dc15051ec59a6a8bad9a84c31fda119b6d683eb523b094",
7
7
  "aiFixturesDigest": "sha256:edac31c92e146ca1c40130f2fb5649db82e24b8cd1ac758153b90612052fddb6",
8
- "generatedGoDigest": "sha256:1f793a0d039976e5a9b65a72753928ab68de66d6878c96a6ea242e48a16ca24e",
9
- "generatedTypeScriptDigest": "sha256:6bdb0271dae8bf7df75f72e61addda29ebb778a054b59c8538d05112000ac0e4",
8
+ "generatedGoDigest": "sha256:05b383b27a90f3b3495a327e3da9ab6e13c5c2b047fc9311616ff6e7a283ccab",
9
+ "generatedTypeScriptDigest": "sha256:184aacb9eb73cec089f8e891b1840873ca9497eb91cf4d3b12cfa9f0a9f305d1",
10
10
  "generatedExclusions": {
11
11
  "gen-go/contract_release.go": "contains the generated aggregate digests (self-reference)",
12
12
  "gen-ts/contract-release.ts": "contains the generated aggregate digests (self-reference)"
@@ -54,8 +54,8 @@
54
54
  "schema/error-envelope.schema.json": "sha256:75dccda7dd16fdf8435deda53fc7dbcdc109b65114617a17f11763010c656f90",
55
55
  "schema/integration-connect-result.schema.json": "sha256:3f254b67ef739dc1fa395bdc8c4dc93359b16d481dba2a2d777aa5b6f34b7af8",
56
56
  "schema/integration-error-details.schema.json": "sha256:47ad7621c34b092c7e40f912a21e7ccbdc000b974cfb7fa0c9200463e029f8fb",
57
- "schema/integration-execute-request.schema.json": "sha256:7a3ebd7a9992a9390d05c9eea6b66762e437655afdca79b64ec992967172c325",
58
- "schema/integration-execute-result.schema.json": "sha256:de87c35fb4dfb16f7334cab111f247281fc2300dc27738871ed1f8397ece6753",
57
+ "schema/integration-execute-request.schema.json": "sha256:b0ad5a3f4836d1f724ee9c28c2e55748819004f37ece37e5711a46bbcc85f3a6",
58
+ "schema/integration-execute-result.schema.json": "sha256:ae0f17788d0933ee67e4f058d57484e192bc6d7d964ee01ec53b94b1777a0724",
59
59
  "schema/internal-configuration-request.schema.json": "sha256:229d44a3458f68dceddfba802e5c1adcd95523a45af29b93b972706b88461a7d",
60
60
  "schema/kit-bundle-manifest.schema.json": "sha256:4a9fcdd52ba0d55718ef4dbdab112b145ef90471d9356d4d3fb41e50fe9e400d",
61
61
  "schema/kit-lock.schema.json": "sha256:a48154d9471e49a893d928f7b0e1e0bc77708a4a7edce4fdf14073203e0e8987",
@@ -109,10 +109,10 @@
109
109
  "gen-go/contracts_test.go": "sha256:56376287cb1548be1b35ab4c65d6f0b558c5d34885f7a47cb3d9e8d786607bf5",
110
110
  "gen-go/doc.go": "sha256:316d610ce568597a2ce6f38e9782e34fa5920f6f51ab0866e6d061b8a41cd822",
111
111
  "gen-go/error_category.go": "sha256:b2ddea22dcfa030f1a45e721e635485b0bd816cf1f38e7654628a653f5ed4583",
112
- "gen-go/integration_execute_input_rules.go": "sha256:52a405aa7a97f7b4e2ed3c314a7bd28b7698cc7883ee72ad4c027637be602ab9",
113
- "gen-go/integration_execute_input_rules_test.go": "sha256:c437a8406a2f062a0173b9419e4cb176fc6eb54e569506225d5162323f55b40b",
112
+ "gen-go/integration_execute_input_rules.go": "sha256:7465a20d79cfb82ed5be683eca901677066e9f28fc5355c643a1883a28c4793a",
113
+ "gen-go/integration_execute_input_rules_test.go": "sha256:0368dce6ee45d06cf8d8444afaa5d92449848187d8f391fffe69654ff1ca861b",
114
114
  "gen-go/schema_documents.go": "sha256:c19c0d540f602ee997a84b90e20702d9bc63ed7d27aec267e76c04655fc182e0",
115
- "gen-go/types.go": "sha256:9e3c05f1fcdd06a0eebfc56fe7ddf4b37c9ddc367822c2d3ec753770a09ee1ae",
115
+ "gen-go/types.go": "sha256:096be357356c51cf346b3e4568014f501cad66c2a482bdb6fd349c9e23bbcea0",
116
116
  "gen-go/version_state.go": "sha256:4ef8abb126414c0025cf2c59bc9c79a74ef4a2ff89e8f316cbf05acc872371c1"
117
117
  },
118
118
  "generatedTypeScript": {
@@ -163,9 +163,9 @@
163
163
  "gen-ts/index.ts": "sha256:8459b1cf136a0c822795154e2df2a28c848cddfadced329604be893ae6eef069",
164
164
  "gen-ts/integration-connect-result.ts": "sha256:4b10f169b6e1af364e9b418de21caa9fadbfb77b43cbff77d741cfae0b52180c",
165
165
  "gen-ts/integration-error-details.ts": "sha256:ad7ecec9dd4533a6e86b3508886a7d69937052f52105497285797f77106bcc6f",
166
- "gen-ts/integration-execute-input-rules.ts": "sha256:0da117ac9f3109462e5862d658edf0f50e4b71fdcbb675d3a9dcb08489bcadd0",
167
- "gen-ts/integration-execute-request.ts": "sha256:1008bc1c8b8fec41dd7241a9f6ccb31233473e34c5cf538c0125f4ee6f45d2db",
168
- "gen-ts/integration-execute-result.ts": "sha256:b7c9270abb0c12888e651aeb1a9927a658faa135ad6197be6dfbd46d942f2a0a",
166
+ "gen-ts/integration-execute-input-rules.ts": "sha256:6f97b347d822624cbb4d51dae5834c67696babdea04b4c3aec9361e2bb670886",
167
+ "gen-ts/integration-execute-request.ts": "sha256:fef0568ab5ffd9e021f3be4ee1178dec5ca1aa54d9799546699ae54b188d2c54",
168
+ "gen-ts/integration-execute-result.ts": "sha256:79d40eb1f6f75ea06ca69d62bef5a71b5ad0c46eb51ab312fedefd5d3f568cb2",
169
169
  "gen-ts/internal-configuration-request.ts": "sha256:7032437797473fa1c581d134adef2f3d4b4f4132f482cd17743e9d47097886bc",
170
170
  "gen-ts/kit-bundle-manifest.ts": "sha256:2777a268aef8787b81e71d154a7d05f6f5ded09d66f378211ff5493ddb885171",
171
171
  "gen-ts/kit-lock.ts": "sha256:00ceeb97e9b7d0187bec00728f5099fd34e775e9cc44c85c4714f7a3a4118ef6",
@@ -1,7 +1,7 @@
1
- export declare const ContractRepositoryVersion: "0.69.0.0";
2
- export declare const ContractPublishVersion: "0.69.0";
3
- export declare const ContractPublishTag: "v0.69.0";
4
- export declare const ContractSchemasDigest: "sha256:e21f310517adb289db8b8233fd3c742a711fed8226892591f6581afb8510e30c";
1
+ export declare const ContractRepositoryVersion: "0.70.0.0";
2
+ export declare const ContractPublishVersion: "0.70.0";
3
+ export declare const ContractPublishTag: "v0.70.0";
4
+ export declare const ContractSchemasDigest: "sha256:b3c430914fee8a4f33dc15051ec59a6a8bad9a84c31fda119b6d683eb523b094";
5
5
  export declare const ContractAiFixturesDigest: "sha256:edac31c92e146ca1c40130f2fb5649db82e24b8cd1ac758153b90612052fddb6";
6
- export declare const ContractGeneratedGoDigest: "sha256:1f793a0d039976e5a9b65a72753928ab68de66d6878c96a6ea242e48a16ca24e";
7
- export declare const ContractGeneratedTypeScriptDigest: "sha256:6bdb0271dae8bf7df75f72e61addda29ebb778a054b59c8538d05112000ac0e4";
6
+ export declare const ContractGeneratedGoDigest: "sha256:05b383b27a90f3b3495a327e3da9ab6e13c5c2b047fc9311616ff6e7a283ccab";
7
+ export declare const ContractGeneratedTypeScriptDigest: "sha256:184aacb9eb73cec089f8e891b1840873ca9497eb91cf4d3b12cfa9f0a9f305d1";
@@ -1,9 +1,9 @@
1
1
  /* eslint-disable */
2
2
  // Code generated by tools/gen/gen.mjs from schema/*.json. DO NOT EDIT.
3
- export const ContractRepositoryVersion = "0.69.0.0";
4
- export const ContractPublishVersion = "0.69.0";
5
- export const ContractPublishTag = "v0.69.0";
6
- export const ContractSchemasDigest = "sha256:e21f310517adb289db8b8233fd3c742a711fed8226892591f6581afb8510e30c";
3
+ export const ContractRepositoryVersion = "0.70.0.0";
4
+ export const ContractPublishVersion = "0.70.0";
5
+ export const ContractPublishTag = "v0.70.0";
6
+ export const ContractSchemasDigest = "sha256:b3c430914fee8a4f33dc15051ec59a6a8bad9a84c31fda119b6d683eb523b094";
7
7
  export const ContractAiFixturesDigest = "sha256:edac31c92e146ca1c40130f2fb5649db82e24b8cd1ac758153b90612052fddb6";
8
- export const ContractGeneratedGoDigest = "sha256:1f793a0d039976e5a9b65a72753928ab68de66d6878c96a6ea242e48a16ca24e";
9
- export const ContractGeneratedTypeScriptDigest = "sha256:6bdb0271dae8bf7df75f72e61addda29ebb778a054b59c8538d05112000ac0e4";
8
+ export const ContractGeneratedGoDigest = "sha256:05b383b27a90f3b3495a327e3da9ab6e13c5c2b047fc9311616ff6e7a283ccab";
9
+ export const ContractGeneratedTypeScriptDigest = "sha256:184aacb9eb73cec089f8e891b1840873ca9497eb91cf4d3b12cfa9f0a9f305d1";
@@ -28,7 +28,30 @@ export type IntegrationExecuteInputFieldRule = {
28
28
  maxLength?: number;
29
29
  pattern?: string;
30
30
  };
31
+ } | {
32
+ kind: 'enum';
33
+ required: boolean;
34
+ values: readonly string[];
35
+ default?: string;
36
+ } | {
37
+ kind: 'object[]';
38
+ required: boolean;
39
+ minItems: number;
40
+ maxItems: number;
41
+ fields: Readonly<Record<string, IntegrationExecuteInputFieldRule>>;
42
+ } | {
43
+ kind: 'value';
44
+ required: boolean;
45
+ types: readonly IntegrationExecuteInputValueType[];
46
+ maxLength?: number;
47
+ list?: {
48
+ minItems: number;
49
+ maxItems: number;
50
+ types: readonly IntegrationExecuteInputValueType[];
51
+ };
31
52
  };
53
+ /** The scalar types a `value` input accepts, alone or in its list form. */
54
+ export type IntegrationExecuteInputValueType = 'string' | 'number' | 'boolean';
32
55
  export type IntegrationExecuteOperationRule = {
33
56
  idempotencyKey: boolean;
34
57
  fields: Record<string, IntegrationExecuteInputFieldRule>;
@@ -144,7 +167,7 @@ export declare const IntegrationExecuteInputRules: {
144
167
  readonly fields: {
145
168
  readonly columns: {
146
169
  readonly kind: "string[]";
147
- readonly required: true;
170
+ readonly required: false;
148
171
  readonly minItems: 1;
149
172
  readonly maxItems: 32;
150
173
  readonly uniqueItems: false;
@@ -153,6 +176,92 @@ export declare const IntegrationExecuteInputRules: {
153
176
  readonly pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$";
154
177
  };
155
178
  };
179
+ readonly where: {
180
+ readonly kind: "object[]";
181
+ readonly required: false;
182
+ readonly minItems: 1;
183
+ readonly maxItems: 16;
184
+ readonly fields: {
185
+ readonly column: {
186
+ readonly kind: "string";
187
+ readonly required: true;
188
+ readonly minLength: 0;
189
+ readonly pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$";
190
+ };
191
+ readonly op: {
192
+ readonly kind: "enum";
193
+ readonly required: true;
194
+ readonly values: readonly ["eq", "ne", "gt", "gte", "lt", "lte", "in", "notIn", "isNull", "isNotNull"];
195
+ };
196
+ readonly value: {
197
+ readonly kind: "value";
198
+ readonly required: false;
199
+ readonly types: readonly ["string", "number", "boolean"];
200
+ readonly maxLength: 256;
201
+ readonly list: {
202
+ readonly minItems: 1;
203
+ readonly maxItems: 100;
204
+ readonly types: readonly ["string", "number"];
205
+ };
206
+ };
207
+ };
208
+ };
209
+ readonly groupBy: {
210
+ readonly kind: "string[]";
211
+ readonly required: false;
212
+ readonly minItems: 1;
213
+ readonly maxItems: 8;
214
+ readonly uniqueItems: false;
215
+ readonly item: {
216
+ readonly minLength: 0;
217
+ readonly pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$";
218
+ };
219
+ };
220
+ readonly aggregates: {
221
+ readonly kind: "object[]";
222
+ readonly required: false;
223
+ readonly minItems: 1;
224
+ readonly maxItems: 16;
225
+ readonly fields: {
226
+ readonly fn: {
227
+ readonly kind: "enum";
228
+ readonly required: true;
229
+ readonly values: readonly ["count", "sum", "avg", "min", "max"];
230
+ };
231
+ readonly column: {
232
+ readonly kind: "string";
233
+ readonly required: true;
234
+ readonly minLength: 0;
235
+ readonly pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$";
236
+ };
237
+ readonly as: {
238
+ readonly kind: "string";
239
+ readonly required: true;
240
+ readonly minLength: 0;
241
+ readonly pattern: "^[a-z_][a-z0-9_]{0,62}$";
242
+ };
243
+ };
244
+ };
245
+ readonly orderBy: {
246
+ readonly kind: "object[]";
247
+ readonly required: false;
248
+ readonly minItems: 1;
249
+ readonly maxItems: 8;
250
+ readonly fields: {
251
+ readonly column: {
252
+ readonly kind: "string";
253
+ readonly required: true;
254
+ readonly minLength: 0;
255
+ readonly pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$";
256
+ };
257
+ readonly direction: {
258
+ readonly kind: "enum";
259
+ readonly required: false;
260
+ readonly values: readonly ["asc", "desc"];
261
+ readonly default: "asc";
262
+ };
263
+ };
264
+ };
156
265
  readonly limit: {
157
266
  readonly kind: "integer";
158
267
  readonly required: false;
@@ -39,7 +39,22 @@ export const IntegrationExecuteInputRules = {
39
39
  "warehouse.view.read": {
40
40
  idempotencyKey: false,
41
41
  fields: {
42
- columns: { kind: "string[]", required: true, minItems: 1, maxItems: 32, uniqueItems: false, item: { minLength: 0, pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$" } },
42
+ columns: { kind: "string[]", required: false, minItems: 1, maxItems: 32, uniqueItems: false, item: { minLength: 0, pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$" } },
43
+ where: { kind: "object[]", required: false, minItems: 1, maxItems: 16, fields: {
44
+ column: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$" },
45
+ op: { kind: "enum", required: true, values: ["eq", "ne", "gt", "gte", "lt", "lte", "in", "notIn", "isNull", "isNotNull"] },
46
+ value: { kind: "value", required: false, types: ["string", "number", "boolean"], maxLength: 256, list: { minItems: 1, maxItems: 100, types: ["string", "number"] } },
47
+ } },
48
+ groupBy: { kind: "string[]", required: false, minItems: 1, maxItems: 8, uniqueItems: false, item: { minLength: 0, pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$" } },
49
+ aggregates: { kind: "object[]", required: false, minItems: 1, maxItems: 16, fields: {
50
+ fn: { kind: "enum", required: true, values: ["count", "sum", "avg", "min", "max"] },
51
+ column: { kind: "string", required: true, minLength: 0, pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$" },
52
+ as: { kind: "string", required: true, minLength: 0, pattern: "^[a-z_][a-z0-9_]{0,62}$" },
53
+ } },
54
+ orderBy: { kind: "object[]", required: false, minItems: 1, maxItems: 8, fields: {
55
+ column: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$" },
56
+ direction: { kind: "enum", required: false, values: ["asc", "desc"], default: "asc" },
57
+ } },
43
58
  limit: { kind: "integer", required: false, minimum: 1, maximum: 1000, default: 100 },
44
59
  },
45
60
  },
@@ -60,55 +75,111 @@ function matches(pattern, value) {
60
75
  }
61
76
  return compiled.test(value);
62
77
  }
78
+ function refuse(field, message) { return { ok: false, problem: { field, message } }; }
79
+ const valueNouns = { string: ['a string', 'strings'], number: ['a number', 'numbers'], boolean: ['a boolean', 'booleans'] };
80
+ function either(parts) {
81
+ return parts.length > 1 ? `${parts.slice(0, -1).join(', ')} or ${parts[parts.length - 1]}` : parts.join('');
82
+ }
83
+ function isScalar(types, maxLength, value) {
84
+ if (typeof value === 'string')
85
+ return types.includes('string') && (maxLength === undefined || value.length <= maxLength);
86
+ if (typeof value === 'number')
87
+ return types.includes('number') && Number.isFinite(value);
88
+ return typeof value === 'boolean' && types.includes('boolean');
89
+ }
63
90
  /**
64
- * Validates one operation's input against the contract and returns it with the
65
- * contract's defaults applied. The message names the field and its allowed
66
- * range; it is the sentence the platform answers with, so a caller may show it.
91
+ * Checks one object's fields in rule order and applies defaults: the input
92
+ * itself (where null counts as absent), or one entry of an object[] input
93
+ * (where only a missing key is absent, so null is refused like any wrong value).
67
94
  */
68
- export function validateIntegrationExecuteInput(operation, input) {
69
- const rule = IntegrationExecuteInputRules[operation];
70
- if (!rule)
71
- return { ok: false, problem: { field: 'operation', message: `${operation} is not a declared integration operation` } };
72
- const raw = input === undefined ? {} : input;
73
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
74
- return { ok: false, problem: { field: 'input', message: 'input must be an object' } };
75
- const given = raw;
95
+ function checkFields(operation, prefix, fields, given, nullIsAbsent) {
76
96
  for (const name of Object.keys(given))
77
- if (!Object.prototype.hasOwnProperty.call(rule.fields, name))
78
- return { ok: false, problem: { field: name, message: `${name} is not an input of ${operation}` } };
97
+ if (!Object.prototype.hasOwnProperty.call(fields, name))
98
+ return refuse(prefix + name, `${prefix}${name} is not an input of ${operation}`);
79
99
  const out = {};
80
- for (const [name, field] of Object.entries(rule.fields)) {
100
+ for (const [name, field] of Object.entries(fields)) {
101
+ const path = prefix + name;
81
102
  const value = given[name];
82
- if (value === undefined || value === null) {
103
+ if (value === undefined || (nullIsAbsent && value === null)) {
83
104
  if (field.required)
84
- return { ok: false, problem: { field: name, message: `${name} is required for ${operation}` } };
85
- if (field.kind === 'integer' && field.default !== undefined)
105
+ return refuse(path, `${path} is required for ${operation}`);
106
+ if ((field.kind === 'integer' || field.kind === 'enum') && field.default !== undefined)
86
107
  out[name] = field.default;
87
108
  continue;
88
109
  }
89
- if (field.kind === 'integer') {
110
+ const checked = checkField(operation, path, name, field, value);
111
+ if (!checked.ok)
112
+ return checked;
113
+ out[name] = checked.value;
114
+ }
115
+ return { ok: true, value: out };
116
+ }
117
+ function checkField(operation, path, name, field, value) {
118
+ switch (field.kind) {
119
+ case 'integer':
90
120
  if (typeof value !== 'number' || !Number.isInteger(value) || value < field.minimum || value > field.maximum)
91
- return { ok: false, problem: { field: name, message: `${name} must be an integer between ${field.minimum} and ${field.maximum}` } };
92
- }
93
- else if (field.kind === 'string') {
121
+ return refuse(path, `${path} must be an integer between ${field.minimum} and ${field.maximum}`);
122
+ return { ok: true, value };
123
+ case 'string':
94
124
  if (typeof value !== 'string' || value.length < field.minLength || (field.maxLength !== undefined && value.length > field.maxLength))
95
- return { ok: false, problem: { field: name, message: field.maxLength !== undefined ? `${name} must be ${field.minLength} to ${field.maxLength} characters` : `${name} must be a string of at least ${field.minLength} characters` } };
125
+ return refuse(path, field.maxLength !== undefined ? `${path} must be ${field.minLength} to ${field.maxLength} characters` : `${path} must be a string of at least ${field.minLength} characters`);
96
126
  if (field.pattern && !matches(field.pattern, value))
97
- return { ok: false, problem: { field: name, message: `${name} must match ${field.pattern}` } };
98
- }
99
- else {
127
+ return refuse(path, `${path} must match ${field.pattern}`);
128
+ return { ok: true, value };
129
+ case 'enum':
130
+ if (typeof value !== 'string' || !field.values.includes(value))
131
+ return refuse(path, `${path} must be one of ${field.values.join(', ')}`);
132
+ return { ok: true, value };
133
+ case 'string[]': {
100
134
  if (!Array.isArray(value) || value.length < field.minItems || value.length > field.maxItems)
101
- return { ok: false, problem: { field: name, message: `${name} must list ${field.minItems} to ${field.maxItems} items` } };
135
+ return refuse(path, `${path} must list ${field.minItems} to ${field.maxItems} items`);
102
136
  const seen = new Set();
103
137
  for (const [index, item] of value.entries()) {
104
138
  if (typeof item !== 'string' || item.length < field.item.minLength || (field.item.maxLength !== undefined && item.length > field.item.maxLength) || (field.item.pattern !== undefined && !matches(field.item.pattern, item)))
105
- return { ok: false, problem: { field: name, message: `${name}[${index}] is not a valid ${name} entry` } };
139
+ return refuse(path, `${path}[${index}] is not a valid ${name} entry`);
106
140
  if (field.uniqueItems && seen.has(item))
107
- return { ok: false, problem: { field: name, message: `${name} must not repeat an entry` } };
141
+ return refuse(path, `${path} must not repeat an entry`);
108
142
  seen.add(item);
109
143
  }
144
+ return { ok: true, value };
145
+ }
146
+ case 'object[]': {
147
+ if (!Array.isArray(value) || value.length < field.minItems || value.length > field.maxItems)
148
+ return refuse(path, `${path} must list ${field.minItems} to ${field.maxItems} items`);
149
+ const entries = [];
150
+ for (const [index, item] of value.entries()) {
151
+ if (item === null || typeof item !== 'object' || Array.isArray(item))
152
+ return refuse(path, `${path}[${index}] is not a valid ${name} entry`);
153
+ const entry = checkFields(operation, `${path}[${index}].`, field.fields, item, false);
154
+ if (!entry.ok)
155
+ return entry;
156
+ entries.push(entry.value);
157
+ }
158
+ return { ok: true, value: entries };
159
+ }
160
+ case 'value': {
161
+ const { types, maxLength, list } = field;
162
+ if (isScalar(types, maxLength, value) || (list !== undefined && Array.isArray(value) && value.length >= list.minItems && value.length <= list.maxItems && value.every(item => isScalar(list.types, maxLength, item))))
163
+ return { ok: true, value };
164
+ const shapes = types.map(type => (type === 'string' && maxLength !== undefined ? `a string of at most ${maxLength} characters` : valueNouns[type][0]));
165
+ if (list !== undefined)
166
+ shapes.push(`a list of ${list.minItems} to ${list.maxItems} ${either(list.types.map(type => valueNouns[type][1]))}`);
167
+ return refuse(path, `${path} must be ${either(shapes)}`);
110
168
  }
111
- out[name] = value;
112
169
  }
113
- return { ok: true, input: out };
170
+ }
171
+ /**
172
+ * Validates one operation's input against the contract and returns it with the
173
+ * contract's defaults applied. The message names the field and its allowed
174
+ * range; it is the sentence the platform answers with, so a caller may show it.
175
+ */
176
+ export function validateIntegrationExecuteInput(operation, input) {
177
+ const rule = IntegrationExecuteInputRules[operation];
178
+ if (!rule)
179
+ return refuse('operation', `${operation} is not a declared integration operation`);
180
+ const raw = input === undefined ? {} : input;
181
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
182
+ return refuse('input', 'input must be an object');
183
+ const checked = checkFields(operation, '', rule.fields, raw, true);
184
+ return checked.ok ? { ok: true, input: checked.value } : checked;
114
185
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 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, limit? 1..1000, default 100}. 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.
2
+ * 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.
3
3
  */
4
4
  export type IntegrationExecuteRequest = {
5
5
  /**
@@ -39,12 +39,40 @@ export type IntegrationExecuteRequest = {
39
39
  */
40
40
  replyToMessageId?: string;
41
41
  /**
42
- * Explicit columns, or `*` alone when the app's approved resource grant allows all current and future columns.
42
+ * warehouse.view.read, a plain read: explicit columns, or `*` alone when the app's approved resource grant allows all current and future columns. An aggregate read names aggregates instead.
43
43
  *
44
44
  * @minItems 1
45
45
  * @maxItems 32
46
46
  */
47
47
  columns?: string[];
48
+ /**
49
+ * warehouse.view.read (0.70.0): filters the warehouse applies before it returns, grouping or limits rows. All of them must hold (AND); there is no OR and no LIKE.
50
+ *
51
+ * @minItems 1
52
+ * @maxItems 16
53
+ */
54
+ where?: WarehouseFilter[];
55
+ /**
56
+ * warehouse.view.read (0.70.0): columns (never `*`) that group the rows; needs aggregates. Each returned row is one group: its groupBy columns, then its aggregate aliases.
57
+ *
58
+ * @minItems 1
59
+ * @maxItems 8
60
+ */
61
+ groupBy?: string[];
62
+ /**
63
+ * warehouse.view.read (0.70.0): what an aggregate read computes, over each group or over every matching row when there is no groupBy.
64
+ *
65
+ * @minItems 1
66
+ * @maxItems 16
67
+ */
68
+ aggregates?: WarehouseAggregate[];
69
+ /**
70
+ * warehouse.view.read (0.70.0): sort keys, first key first. Without orderBy an aggregate read is sorted by its groupBy columns and a plain read is not sorted at all.
71
+ *
72
+ * @minItems 1
73
+ * @maxItems 8
74
+ */
75
+ orderBy?: WarehouseOrder[];
48
76
  };
49
77
  /**
50
78
  * UUID of one explicit Send action; required for slack.message.post and gmail.message.send only. The same key with the same payload and subject returns the recorded outcome for seven days.
@@ -80,3 +108,53 @@ export type EmailAddressList1 = EmailAddress[];
80
108
  * @maxItems 50
81
109
  */
82
110
  export type EmailAddressList2 = EmailAddress[];
111
+ /**
112
+ * A filter comparison: = <> > >= < <= IN, NOT IN, IS NULL, IS NOT NULL. Word-like so every value is a valid generated constant.
113
+ */
114
+ export type WarehouseFilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'notIn' | 'isNull' | 'isNotNull';
115
+ /**
116
+ * An aggregate function.
117
+ */
118
+ export type WarehouseAggregateFn = 'count' | 'sum' | 'avg' | 'min' | 'max';
119
+ /**
120
+ * Sort direction; asc when absent.
121
+ */
122
+ export type WarehouseOrderDirection = 'asc' | 'desc';
123
+ /**
124
+ * One warehouse.view.read filter (0.70.0): <column> <op> <value>. The value is always bound as a query parameter, never written into the SQL. Which value an op takes is a governance rule: isNull/isNotNull take none; in/notIn take a non-empty list of one scalar type; every other op takes one scalar, and a boolean only with eq/ne.
125
+ */
126
+ export interface WarehouseFilter {
127
+ /**
128
+ * The filtered column; never `*`.
129
+ */
130
+ column: string;
131
+ op: WarehouseFilterOp;
132
+ /**
133
+ * The value compared with: a string (a date or timestamp is its ISO string), a number, a boolean, or a list of strings or numbers for in/notIn. Never null; use isNull or isNotNull.
134
+ */
135
+ value?: string | number | boolean | (string | number)[];
136
+ }
137
+ /**
138
+ * One computed column of an aggregate warehouse.view.read (0.70.0): fn(column) AS as. `*` is a column only for count (a governance rule). The value follows the read's cell rules, so a 64-bit or decimal result may be a string.
139
+ */
140
+ export interface WarehouseAggregate {
141
+ fn: WarehouseAggregateFn;
142
+ /**
143
+ * A plain warehouse column identifier, or `*` as the sole selection when IT exposed all current and future columns.
144
+ */
145
+ column: string;
146
+ /**
147
+ * The name the computed value is returned under; unique in the read and never a groupBy column (governance rules).
148
+ */
149
+ as: string;
150
+ }
151
+ /**
152
+ * One warehouse.view.read sort key (0.70.0). A plain read sorts by a selected column (any column when it selects `*`); an aggregate read sorts by a groupBy column or an aggregate alias (governance rules).
153
+ */
154
+ export interface WarehouseOrder {
155
+ /**
156
+ * A column or an aggregate alias; never `*`.
157
+ */
158
+ column: string;
159
+ direction?: WarehouseOrderDirection;
160
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 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} (decimal/64-bit values as strings, dates as ISO strings). An unknown send outcome is never a result; it is an error with details.code SEND_OUTCOME_UNKNOWN. Results over 1 MiB are rejected, not truncated.
2
+ * 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.
3
3
  */
4
4
  export type IntegrationExecuteResult = {
5
5
  /**
@@ -19,6 +19,9 @@ export type IntegrationExecuteResult = {
19
19
  to?: string;
20
20
  date?: string;
21
21
  text?: string;
22
+ /**
23
+ * 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.
24
+ */
22
25
  truncated?: boolean;
23
26
  /**
24
27
  * @maxItems 1000
@@ -1,10 +1,10 @@
1
1
  /* eslint-disable */
2
2
  // Code generated by tools/gen/gen.mjs from schema/*.json. DO NOT EDIT.
3
3
 
4
- export const ContractRepositoryVersion = "0.69.0.0" as const;
5
- export const ContractPublishVersion = "0.69.0" as const;
6
- export const ContractPublishTag = "v0.69.0" as const;
7
- export const ContractSchemasDigest = "sha256:e21f310517adb289db8b8233fd3c742a711fed8226892591f6581afb8510e30c" as const;
4
+ export const ContractRepositoryVersion = "0.70.0.0" as const;
5
+ export const ContractPublishVersion = "0.70.0" as const;
6
+ export const ContractPublishTag = "v0.70.0" as const;
7
+ export const ContractSchemasDigest = "sha256:b3c430914fee8a4f33dc15051ec59a6a8bad9a84c31fda119b6d683eb523b094" as const;
8
8
  export const ContractAiFixturesDigest = "sha256:edac31c92e146ca1c40130f2fb5649db82e24b8cd1ac758153b90612052fddb6" as const;
9
- export const ContractGeneratedGoDigest = "sha256:1f793a0d039976e5a9b65a72753928ab68de66d6878c96a6ea242e48a16ca24e" as const;
10
- export const ContractGeneratedTypeScriptDigest = "sha256:6bdb0271dae8bf7df75f72e61addda29ebb778a054b59c8538d05112000ac0e4" as const;
9
+ export const ContractGeneratedGoDigest = "sha256:05b383b27a90f3b3495a327e3da9ab6e13c5c2b047fc9311616ff6e7a283ccab" as const;
10
+ export const ContractGeneratedTypeScriptDigest = "sha256:184aacb9eb73cec089f8e891b1840873ca9497eb91cf4d3b12cfa9f0a9f305d1" as const;
@@ -11,7 +11,13 @@
11
11
  export type IntegrationExecuteInputFieldRule =
12
12
  | { kind: 'integer'; required: boolean; minimum: number; maximum: number; default?: number }
13
13
  | { kind: 'string'; required: boolean; minLength: number; maxLength?: number; pattern?: string }
14
- | { kind: 'string[]'; required: boolean; minItems: number; maxItems: number; uniqueItems: boolean; item: { minLength: number; maxLength?: number; pattern?: string } };
14
+ | { kind: 'string[]'; required: boolean; minItems: number; maxItems: number; uniqueItems: boolean; item: { minLength: number; maxLength?: number; pattern?: string } }
15
+ | { kind: 'enum'; required: boolean; values: readonly string[]; default?: string }
16
+ | { kind: 'object[]'; required: boolean; minItems: number; maxItems: number; fields: Readonly<Record<string, IntegrationExecuteInputFieldRule>> }
17
+ | { kind: 'value'; required: boolean; types: readonly IntegrationExecuteInputValueType[]; maxLength?: number; list?: { minItems: number; maxItems: number; types: readonly IntegrationExecuteInputValueType[] } };
18
+
19
+ /** The scalar types a `value` input accepts, alone or in its list form. */
20
+ export type IntegrationExecuteInputValueType = 'string' | 'number' | 'boolean';
15
21
 
16
22
  export type IntegrationExecuteOperationRule = { idempotencyKey: boolean; fields: Record<string, IntegrationExecuteInputFieldRule> };
17
23
 
@@ -54,7 +60,22 @@ export const IntegrationExecuteInputRules = {
54
60
  "warehouse.view.read": {
55
61
  idempotencyKey: false,
56
62
  fields: {
57
- columns: { kind: "string[]", required: true, minItems: 1, maxItems: 32, uniqueItems: false, item: { minLength: 0, pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$" } },
63
+ columns: { kind: "string[]", required: false, minItems: 1, maxItems: 32, uniqueItems: false, item: { minLength: 0, pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$" } },
64
+ where: { kind: "object[]", required: false, minItems: 1, maxItems: 16, fields: {
65
+ column: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$" },
66
+ op: { kind: "enum", required: true, values: ["eq", "ne", "gt", "gte", "lt", "lte", "in", "notIn", "isNull", "isNotNull"] },
67
+ value: { kind: "value", required: false, types: ["string", "number", "boolean"], maxLength: 256, list: { minItems: 1, maxItems: 100, types: ["string", "number"] } },
68
+ } },
69
+ groupBy: { kind: "string[]", required: false, minItems: 1, maxItems: 8, uniqueItems: false, item: { minLength: 0, pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$" } },
70
+ aggregates: { kind: "object[]", required: false, minItems: 1, maxItems: 16, fields: {
71
+ fn: { kind: "enum", required: true, values: ["count", "sum", "avg", "min", "max"] },
72
+ column: { kind: "string", required: true, minLength: 0, pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$" },
73
+ as: { kind: "string", required: true, minLength: 0, pattern: "^[a-z_][a-z0-9_]{0,62}$" },
74
+ } },
75
+ orderBy: { kind: "object[]", required: false, minItems: 1, maxItems: 8, fields: {
76
+ column: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$" },
77
+ direction: { kind: "enum", required: false, values: ["asc", "desc"], default: "asc" },
78
+ } },
58
79
  limit: { kind: "integer", required: false, minimum: 1, maximum: 1000, default: 100 },
59
80
  },
60
81
  },
@@ -79,41 +100,95 @@ function matches(pattern: string, value: string): boolean {
79
100
  return compiled.test(value);
80
101
  }
81
102
 
103
+ type Refusal = { ok: false; problem: IntegrationExecuteInputProblem };
104
+ function refuse(field: string, message: string): Refusal { return { ok: false, problem: { field, message } }; }
105
+
106
+ const valueNouns = { string: ['a string', 'strings'], number: ['a number', 'numbers'], boolean: ['a boolean', 'booleans'] } as const;
107
+ function either(parts: readonly string[]): string {
108
+ return parts.length > 1 ? `${parts.slice(0, -1).join(', ')} or ${parts[parts.length - 1]}` : parts.join('');
109
+ }
110
+ function isScalar(types: readonly IntegrationExecuteInputValueType[], maxLength: number | undefined, value: unknown): boolean {
111
+ if (typeof value === 'string') return types.includes('string') && (maxLength === undefined || value.length <= maxLength);
112
+ if (typeof value === 'number') return types.includes('number') && Number.isFinite(value);
113
+ return typeof value === 'boolean' && types.includes('boolean');
114
+ }
115
+
82
116
  /**
83
- * Validates one operation's input against the contract and returns it with the
84
- * contract's defaults applied. The message names the field and its allowed
85
- * range; it is the sentence the platform answers with, so a caller may show it.
117
+ * Checks one object's fields in rule order and applies defaults: the input
118
+ * itself (where null counts as absent), or one entry of an object[] input
119
+ * (where only a missing key is absent, so null is refused like any wrong value).
86
120
  */
87
- export function validateIntegrationExecuteInput(operation: string, input: unknown): IntegrationExecuteInputValidation {
88
- const rule = (IntegrationExecuteInputRules as Record<string, IntegrationExecuteOperationRule>)[operation];
89
- if (!rule) return { ok: false, problem: { field: 'operation', message: `${operation} is not a declared integration operation` } };
90
- const raw = input === undefined ? {} : input;
91
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, problem: { field: 'input', message: 'input must be an object' } };
92
- const given = raw as Record<string, unknown>;
93
- for (const name of Object.keys(given)) if (!Object.prototype.hasOwnProperty.call(rule.fields, name)) return { ok: false, problem: { field: name, message: `${name} is not an input of ${operation}` } };
121
+ function checkFields(operation: string, prefix: string, fields: Readonly<Record<string, IntegrationExecuteInputFieldRule>>, given: Record<string, unknown>, nullIsAbsent: boolean): { ok: true; value: Record<string, unknown> } | Refusal {
122
+ for (const name of Object.keys(given)) if (!Object.prototype.hasOwnProperty.call(fields, name)) return refuse(prefix + name, `${prefix}${name} is not an input of ${operation}`);
94
123
  const out: Record<string, unknown> = {};
95
- for (const [name, field] of Object.entries(rule.fields)) {
124
+ for (const [name, field] of Object.entries(fields)) {
125
+ const path = prefix + name;
96
126
  const value = given[name];
97
- if (value === undefined || value === null) {
98
- if (field.required) return { ok: false, problem: { field: name, message: `${name} is required for ${operation}` } };
99
- if (field.kind === 'integer' && field.default !== undefined) out[name] = field.default;
127
+ if (value === undefined || (nullIsAbsent && value === null)) {
128
+ if (field.required) return refuse(path, `${path} is required for ${operation}`);
129
+ if ((field.kind === 'integer' || field.kind === 'enum') && field.default !== undefined) out[name] = field.default;
100
130
  continue;
101
131
  }
102
- if (field.kind === 'integer') {
103
- if (typeof value !== 'number' || !Number.isInteger(value) || value < field.minimum || value > field.maximum) return { ok: false, problem: { field: name, message: `${name} must be an integer between ${field.minimum} and ${field.maximum}` } };
104
- } else if (field.kind === 'string') {
105
- if (typeof value !== 'string' || value.length < field.minLength || (field.maxLength !== undefined && value.length > field.maxLength)) return { ok: false, problem: { field: name, message: field.maxLength !== undefined ? `${name} must be ${field.minLength} to ${field.maxLength} characters` : `${name} must be a string of at least ${field.minLength} characters` } };
106
- if (field.pattern && !matches(field.pattern, value)) return { ok: false, problem: { field: name, message: `${name} must match ${field.pattern}` } };
107
- } else {
108
- if (!Array.isArray(value) || value.length < field.minItems || value.length > field.maxItems) return { ok: false, problem: { field: name, message: `${name} must list ${field.minItems} to ${field.maxItems} items` } };
132
+ const checked = checkField(operation, path, name, field, value);
133
+ if (!checked.ok) return checked;
134
+ out[name] = checked.value;
135
+ }
136
+ return { ok: true, value: out };
137
+ }
138
+
139
+ function checkField(operation: string, path: string, name: string, field: IntegrationExecuteInputFieldRule, value: unknown): { ok: true; value: unknown } | Refusal {
140
+ switch (field.kind) {
141
+ case 'integer':
142
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < field.minimum || value > field.maximum) return refuse(path, `${path} must be an integer between ${field.minimum} and ${field.maximum}`);
143
+ return { ok: true, value };
144
+ case 'string':
145
+ if (typeof value !== 'string' || value.length < field.minLength || (field.maxLength !== undefined && value.length > field.maxLength)) return refuse(path, field.maxLength !== undefined ? `${path} must be ${field.minLength} to ${field.maxLength} characters` : `${path} must be a string of at least ${field.minLength} characters`);
146
+ if (field.pattern && !matches(field.pattern, value)) return refuse(path, `${path} must match ${field.pattern}`);
147
+ return { ok: true, value };
148
+ case 'enum':
149
+ if (typeof value !== 'string' || !field.values.includes(value)) return refuse(path, `${path} must be one of ${field.values.join(', ')}`);
150
+ return { ok: true, value };
151
+ case 'string[]': {
152
+ if (!Array.isArray(value) || value.length < field.minItems || value.length > field.maxItems) return refuse(path, `${path} must list ${field.minItems} to ${field.maxItems} items`);
109
153
  const seen = new Set<string>();
110
154
  for (const [index, item] of value.entries()) {
111
- if (typeof item !== 'string' || item.length < field.item.minLength || (field.item.maxLength !== undefined && item.length > field.item.maxLength) || (field.item.pattern !== undefined && !matches(field.item.pattern, item))) return { ok: false, problem: { field: name, message: `${name}[${index}] is not a valid ${name} entry` } };
112
- if (field.uniqueItems && seen.has(item)) return { ok: false, problem: { field: name, message: `${name} must not repeat an entry` } };
155
+ if (typeof item !== 'string' || item.length < field.item.minLength || (field.item.maxLength !== undefined && item.length > field.item.maxLength) || (field.item.pattern !== undefined && !matches(field.item.pattern, item))) return refuse(path, `${path}[${index}] is not a valid ${name} entry`);
156
+ if (field.uniqueItems && seen.has(item)) return refuse(path, `${path} must not repeat an entry`);
113
157
  seen.add(item);
114
158
  }
159
+ return { ok: true, value };
160
+ }
161
+ case 'object[]': {
162
+ if (!Array.isArray(value) || value.length < field.minItems || value.length > field.maxItems) return refuse(path, `${path} must list ${field.minItems} to ${field.maxItems} items`);
163
+ const entries: Record<string, unknown>[] = [];
164
+ for (const [index, item] of value.entries()) {
165
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) return refuse(path, `${path}[${index}] is not a valid ${name} entry`);
166
+ const entry = checkFields(operation, `${path}[${index}].`, field.fields, item as Record<string, unknown>, false);
167
+ if (!entry.ok) return entry;
168
+ entries.push(entry.value);
169
+ }
170
+ return { ok: true, value: entries };
171
+ }
172
+ case 'value': {
173
+ const { types, maxLength, list } = field;
174
+ if (isScalar(types, maxLength, value) || (list !== undefined && Array.isArray(value) && value.length >= list.minItems && value.length <= list.maxItems && value.every(item => isScalar(list.types, maxLength, item)))) return { ok: true, value };
175
+ const shapes = types.map(type => (type === 'string' && maxLength !== undefined ? `a string of at most ${maxLength} characters` : valueNouns[type][0]));
176
+ if (list !== undefined) shapes.push(`a list of ${list.minItems} to ${list.maxItems} ${either(list.types.map(type => valueNouns[type][1]))}`);
177
+ return refuse(path, `${path} must be ${either(shapes)}`);
115
178
  }
116
- out[name] = value;
117
179
  }
118
- return { ok: true, input: out };
180
+ }
181
+
182
+ /**
183
+ * Validates one operation's input against the contract and returns it with the
184
+ * contract's defaults applied. The message names the field and its allowed
185
+ * range; it is the sentence the platform answers with, so a caller may show it.
186
+ */
187
+ export function validateIntegrationExecuteInput(operation: string, input: unknown): IntegrationExecuteInputValidation {
188
+ const rule = (IntegrationExecuteInputRules as Record<string, IntegrationExecuteOperationRule>)[operation];
189
+ if (!rule) return refuse('operation', `${operation} is not a declared integration operation`);
190
+ const raw = input === undefined ? {} : input;
191
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return refuse('input', 'input must be an object');
192
+ const checked = checkFields(operation, '', rule.fields, raw as Record<string, unknown>, true);
193
+ return checked.ok ? { ok: true, input: checked.value } : checked;
119
194
  }
@@ -2,7 +2,7 @@
2
2
  // Code generated by tools/gen/gen.mjs from schema/*.json. DO NOT EDIT.
3
3
 
4
4
  /**
5
- * 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, limit? 1..1000, default 100}. 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
+ * 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.
6
6
  */
7
7
  export type IntegrationExecuteRequest = {
8
8
  /**
@@ -48,12 +48,40 @@ export type IntegrationExecuteRequest = {
48
48
  */
49
49
  replyToMessageId?: string;
50
50
  /**
51
- * Explicit columns, or `*` alone when the app's approved resource grant allows all current and future columns.
51
+ * warehouse.view.read, a plain read: explicit columns, or `*` alone when the app's approved resource grant allows all current and future columns. An aggregate read names aggregates instead.
52
52
  *
53
53
  * @minItems 1
54
54
  * @maxItems 32
55
55
  */
56
56
  columns?: string[];
57
+ /**
58
+ * warehouse.view.read (0.70.0): filters the warehouse applies before it returns, grouping or limits rows. All of them must hold (AND); there is no OR and no LIKE.
59
+ *
60
+ * @minItems 1
61
+ * @maxItems 16
62
+ */
63
+ where?: WarehouseFilter[];
64
+ /**
65
+ * warehouse.view.read (0.70.0): columns (never `*`) that group the rows; needs aggregates. Each returned row is one group: its groupBy columns, then its aggregate aliases.
66
+ *
67
+ * @minItems 1
68
+ * @maxItems 8
69
+ */
70
+ groupBy?: string[];
71
+ /**
72
+ * warehouse.view.read (0.70.0): what an aggregate read computes, over each group or over every matching row when there is no groupBy.
73
+ *
74
+ * @minItems 1
75
+ * @maxItems 16
76
+ */
77
+ aggregates?: WarehouseAggregate[];
78
+ /**
79
+ * warehouse.view.read (0.70.0): sort keys, first key first. Without orderBy an aggregate read is sorted by its groupBy columns and a plain read is not sorted at all.
80
+ *
81
+ * @minItems 1
82
+ * @maxItems 8
83
+ */
84
+ orderBy?: WarehouseOrder[];
57
85
  };
58
86
  /**
59
87
  * UUID of one explicit Send action; required for slack.message.post and gmail.message.send only. The same key with the same payload and subject returns the recorded outcome for seven days.
@@ -89,3 +117,54 @@ export type EmailAddressList1 = EmailAddress[];
89
117
  * @maxItems 50
90
118
  */
91
119
  export type EmailAddressList2 = EmailAddress[];
120
+ /**
121
+ * A filter comparison: = <> > >= < <= IN, NOT IN, IS NULL, IS NOT NULL. Word-like so every value is a valid generated constant.
122
+ */
123
+ export type WarehouseFilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'notIn' | 'isNull' | 'isNotNull';
124
+ /**
125
+ * An aggregate function.
126
+ */
127
+ export type WarehouseAggregateFn = 'count' | 'sum' | 'avg' | 'min' | 'max';
128
+ /**
129
+ * Sort direction; asc when absent.
130
+ */
131
+ export type WarehouseOrderDirection = 'asc' | 'desc';
132
+
133
+ /**
134
+ * One warehouse.view.read filter (0.70.0): <column> <op> <value>. The value is always bound as a query parameter, never written into the SQL. Which value an op takes is a governance rule: isNull/isNotNull take none; in/notIn take a non-empty list of one scalar type; every other op takes one scalar, and a boolean only with eq/ne.
135
+ */
136
+ export interface WarehouseFilter {
137
+ /**
138
+ * The filtered column; never `*`.
139
+ */
140
+ column: string;
141
+ op: WarehouseFilterOp;
142
+ /**
143
+ * The value compared with: a string (a date or timestamp is its ISO string), a number, a boolean, or a list of strings or numbers for in/notIn. Never null; use isNull or isNotNull.
144
+ */
145
+ value?: string | number | boolean | (string | number)[];
146
+ }
147
+ /**
148
+ * One computed column of an aggregate warehouse.view.read (0.70.0): fn(column) AS as. `*` is a column only for count (a governance rule). The value follows the read's cell rules, so a 64-bit or decimal result may be a string.
149
+ */
150
+ export interface WarehouseAggregate {
151
+ fn: WarehouseAggregateFn;
152
+ /**
153
+ * A plain warehouse column identifier, or `*` as the sole selection when IT exposed all current and future columns.
154
+ */
155
+ column: string;
156
+ /**
157
+ * The name the computed value is returned under; unique in the read and never a groupBy column (governance rules).
158
+ */
159
+ as: string;
160
+ }
161
+ /**
162
+ * One warehouse.view.read sort key (0.70.0). A plain read sorts by a selected column (any column when it selects `*`); an aggregate read sorts by a groupBy column or an aggregate alias (governance rules).
163
+ */
164
+ export interface WarehouseOrder {
165
+ /**
166
+ * A column or an aggregate alias; never `*`.
167
+ */
168
+ column: string;
169
+ direction?: WarehouseOrderDirection;
170
+ }
@@ -2,7 +2,7 @@
2
2
  // Code generated by tools/gen/gen.mjs from schema/*.json. DO NOT EDIT.
3
3
 
4
4
  /**
5
- * 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} (decimal/64-bit values as strings, dates as ISO strings). An unknown send outcome is never a result; it is an error with details.code SEND_OUTCOME_UNKNOWN. Results over 1 MiB are rejected, not truncated.
5
+ * 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.
6
6
  */
7
7
  export type IntegrationExecuteResult = {
8
8
  /**
@@ -22,6 +22,9 @@ export type IntegrationExecuteResult = {
22
22
  to?: string;
23
23
  date?: string;
24
24
  text?: string;
25
+ /**
26
+ * 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.
27
+ */
25
28
  truncated?: boolean;
26
29
  /**
27
30
  * @maxItems 1000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour-contracts",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "description": "Single source of truth for every data shape that crosses a boundary between Isomorph components.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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, limit? 1..1000, default 100}. 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 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.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": ["connection", "operation", "resource", "input"],
@@ -28,7 +28,35 @@
28
28
  "maxItems": 32,
29
29
  "items": { "$ref": "app-integrations-declaration.schema.json#/definitions/ColumnName" },
30
30
  "not": { "allOf": [{ "contains": { "const": "*" } }, { "minItems": 2 }] },
31
- "description": "Explicit columns, or `*` alone when the app's approved resource grant allows all current and future columns."
31
+ "description": "warehouse.view.read, a plain read: explicit columns, or `*` alone when the app's approved resource grant allows all current and future columns. An aggregate read names aggregates instead."
32
+ },
33
+ "where": {
34
+ "type": "array",
35
+ "minItems": 1,
36
+ "maxItems": 16,
37
+ "items": { "$ref": "#/definitions/WarehouseFilter" },
38
+ "description": "warehouse.view.read (0.70.0): filters the warehouse applies before it returns, grouping or limits rows. All of them must hold (AND); there is no OR and no LIKE."
39
+ },
40
+ "groupBy": {
41
+ "type": "array",
42
+ "minItems": 1,
43
+ "maxItems": 8,
44
+ "items": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,62}$" },
45
+ "description": "warehouse.view.read (0.70.0): columns (never `*`) that group the rows; needs aggregates. Each returned row is one group: its groupBy columns, then its aggregate aliases."
46
+ },
47
+ "aggregates": {
48
+ "type": "array",
49
+ "minItems": 1,
50
+ "maxItems": 16,
51
+ "items": { "$ref": "#/definitions/WarehouseAggregate" },
52
+ "description": "warehouse.view.read (0.70.0): what an aggregate read computes, over each group or over every matching row when there is no groupBy."
53
+ },
54
+ "orderBy": {
55
+ "type": "array",
56
+ "minItems": 1,
57
+ "maxItems": 8,
58
+ "items": { "$ref": "#/definitions/WarehouseOrder" },
59
+ "description": "warehouse.view.read (0.70.0): sort keys, first key first. Without orderBy an aggregate read is sorted by its groupBy columns and a plain read is not sorted at all."
32
60
  }
33
61
  }
34
62
  },
@@ -68,7 +96,7 @@
68
96
  "not": { "required": ["idempotencyKey"] },
69
97
  "properties": {
70
98
  "mode": false,
71
- "input": { "propertyNames": { "enum": ["columns", "limit"] }, "required": ["columns"], "properties": { "limit": { "maximum": 1000, "default": 100 } } }
99
+ "input": { "propertyNames": { "enum": ["columns", "where", "groupBy", "aggregates", "orderBy", "limit"] }, "properties": { "limit": { "maximum": 1000, "default": 100 } } }
72
100
  }
73
101
  }
74
102
  },
@@ -105,7 +133,63 @@
105
133
  ],
106
134
  "definitions": {
107
135
  "EmailAddress": { "type": "string", "minLength": 3, "maxLength": 254, "pattern": "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$", "description": "One bare email address (no display name)." },
108
- "EmailAddressList": { "type": "array", "minItems": 1, "maxItems": 50, "uniqueItems": true, "items": { "$ref": "#/definitions/EmailAddress" } }
136
+ "EmailAddressList": { "type": "array", "minItems": 1, "maxItems": 50, "uniqueItems": true, "items": { "$ref": "#/definitions/EmailAddress" } },
137
+ "WarehouseFilter": {
138
+ "type": "object",
139
+ "additionalProperties": false,
140
+ "required": ["column", "op"],
141
+ "description": "One warehouse.view.read filter (0.70.0): <column> <op> <value>. The value is always bound as a query parameter, never written into the SQL. Which value an op takes is a governance rule: isNull/isNotNull take none; in/notIn take a non-empty list of one scalar type; every other op takes one scalar, and a boolean only with eq/ne.",
142
+ "properties": {
143
+ "column": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,62}$", "description": "The filtered column; never `*`." },
144
+ "op": { "$ref": "#/definitions/WarehouseFilterOp" },
145
+ "value": {
146
+ "description": "The value compared with: a string (a date or timestamp is its ISO string), a number, a boolean, or a list of strings or numbers for in/notIn. Never null; use isNull or isNotNull.",
147
+ "oneOf": [
148
+ { "type": "string", "maxLength": 256 },
149
+ { "type": "number" },
150
+ { "type": "boolean" },
151
+ { "type": "array", "minItems": 1, "maxItems": 100, "items": { "oneOf": [{ "type": "string", "maxLength": 256 }, { "type": "number" }] } }
152
+ ]
153
+ }
154
+ }
155
+ },
156
+ "WarehouseFilterOp": {
157
+ "type": "string",
158
+ "enum": ["eq", "ne", "gt", "gte", "lt", "lte", "in", "notIn", "isNull", "isNotNull"],
159
+ "description": "A filter comparison: = <> > >= < <= IN, NOT IN, IS NULL, IS NOT NULL. Word-like so every value is a valid generated constant."
160
+ },
161
+ "WarehouseAggregate": {
162
+ "type": "object",
163
+ "additionalProperties": false,
164
+ "required": ["fn", "column", "as"],
165
+ "description": "One computed column of an aggregate warehouse.view.read (0.70.0): fn(column) AS as. `*` is a column only for count (a governance rule). The value follows the read's cell rules, so a 64-bit or decimal result may be a string.",
166
+ "properties": {
167
+ "fn": { "$ref": "#/definitions/WarehouseAggregateFn" },
168
+ "column": { "$ref": "app-integrations-declaration.schema.json#/definitions/ColumnName" },
169
+ "as": { "type": "string", "pattern": "^[a-z_][a-z0-9_]{0,62}$", "description": "The name the computed value is returned under; unique in the read and never a groupBy column (governance rules)." }
170
+ }
171
+ },
172
+ "WarehouseAggregateFn": {
173
+ "type": "string",
174
+ "enum": ["count", "sum", "avg", "min", "max"],
175
+ "description": "An aggregate function."
176
+ },
177
+ "WarehouseOrder": {
178
+ "type": "object",
179
+ "additionalProperties": false,
180
+ "required": ["column"],
181
+ "description": "One warehouse.view.read sort key (0.70.0). A plain read sorts by a selected column (any column when it selects `*`); an aggregate read sorts by a groupBy column or an aggregate alias (governance rules).",
182
+ "properties": {
183
+ "column": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,62}$", "description": "A column or an aggregate alias; never `*`." },
184
+ "direction": { "$ref": "#/definitions/WarehouseOrderDirection" }
185
+ }
186
+ },
187
+ "WarehouseOrderDirection": {
188
+ "type": "string",
189
+ "enum": ["asc", "desc"],
190
+ "default": "asc",
191
+ "description": "Sort direction; asc when absent."
192
+ }
109
193
  },
110
194
  "examples": [
111
195
  { "connection": "company-slack", "operation": "slack.channel.history", "resource": "team-updates", "input": { "limit": 15 } },
@@ -133,6 +217,36 @@
133
217
  "input": { "text": "Lunch is at noon." },
134
218
  "idempotencyKey": "6f1c2c1e-3a1c-4c3e-9f0e-2b6f7d8a9c12",
135
219
  "mode": "user"
220
+ },
221
+ {
222
+ "connection": "sales-warehouse",
223
+ "operation": "warehouse.view.read",
224
+ "resource": "invoices",
225
+ "input": {
226
+ "columns": ["invoice_id", "region_code", "amount_minor"],
227
+ "where": [
228
+ { "column": "status", "op": "eq", "value": "PAID" },
229
+ { "column": "invoice_date", "op": "gte", "value": "2026-02-01" },
230
+ { "column": "currency", "op": "in", "value": ["USD", "EUR"] },
231
+ { "column": "closed_at", "op": "isNull" }
232
+ ],
233
+ "limit": 500
234
+ }
235
+ },
236
+ {
237
+ "connection": "sales-warehouse",
238
+ "operation": "warehouse.view.read",
239
+ "resource": "invoices",
240
+ "input": {
241
+ "where": [{ "column": "status", "op": "eq", "value": "PAID" }],
242
+ "groupBy": ["region_code"],
243
+ "aggregates": [
244
+ { "fn": "sum", "column": "amount_minor", "as": "total_minor" },
245
+ { "fn": "count", "column": "*", "as": "invoices" }
246
+ ],
247
+ "orderBy": [{ "column": "total_minor", "direction": "desc" }],
248
+ "limit": 100
249
+ }
136
250
  }
137
251
  ]
138
252
  }
@@ -2,7 +2,7 @@
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} (decimal/64-bit values as strings, dates as ISO strings). An unknown send outcome is never a result; it is an error with details.code SEND_OUTCOME_UNKNOWN. Results over 1 MiB are rejected, not truncated.",
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.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "properties": {
@@ -25,11 +25,11 @@
25
25
  "to": { "type": "string", "maxLength": 8192 },
26
26
  "date": { "type": "string", "maxLength": 128 },
27
27
  "text": { "type": "string", "maxLength": 16384 },
28
- "truncated": { "type": "boolean" },
28
+ "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." },
29
29
  "rows": {
30
30
  "type": "array",
31
31
  "maxItems": 1000,
32
- "items": { "type": "object", "additionalProperties": {}, "description": "One row keyed by the requested column names." }
32
+ "items": { "type": "object", "additionalProperties": {}, "description": "One row keyed by the selected column names, or by the groupBy columns then the aggregate aliases." }
33
33
  }
34
34
  },
35
35
  "oneOf": [
@@ -38,7 +38,7 @@
38
38
  { "required": ["messageId", "threadId"], "maxProperties": 2 },
39
39
  { "required": ["threads", "hasMore"], "maxProperties": 2 },
40
40
  { "required": ["id", "threadId", "subject", "from", "to", "date", "text", "truncated"], "maxProperties": 8 },
41
- { "required": ["rows"], "maxProperties": 1 }
41
+ { "required": ["rows"], "propertyNames": { "enum": ["rows", "truncated"] } }
42
42
  ],
43
43
  "definitions": {
44
44
  "GmailThread": {
@@ -80,6 +80,7 @@
80
80
  "hasMore": false
81
81
  },
82
82
  { "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
- { "rows": [{ "week": "2026-W36", "total": "1234.50" }] }
83
+ { "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 }
84
85
  ]
85
86
  }