@zackbart/connecta 0.38.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +3 -2
  3. package/bin/connecta.mjs +4 -8
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.js +6 -4
  6. package/dist/agent-routes.js +231 -0
  7. package/dist/auth/downstream-oauth/consent.js +519 -0
  8. package/dist/auth/downstream-oauth/grant-store.js +439 -0
  9. package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
  10. package/dist/auth/downstream-oauth/refresh-records.js +447 -0
  11. package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
  12. package/dist/auth/downstream-oauth.js +66 -2382
  13. package/dist/auth/native.js +37 -0
  14. package/dist/auth/oauth-grant.js +13 -0
  15. package/dist/auth/static-oauth.js +153 -131
  16. package/dist/auth-elicitation.js +7 -1
  17. package/dist/authorization-handoff.js +11 -5
  18. package/dist/catalog-cache.js +219 -59
  19. package/dist/catalog-policy.js +18 -0
  20. package/dist/catalog-service.js +284 -47
  21. package/dist/catalog.js +18 -2
  22. package/dist/config.d.ts +16 -11
  23. package/dist/config.js +9 -6
  24. package/dist/connector-caller.d.ts +1 -1
  25. package/dist/connector-health.js +59 -0
  26. package/dist/connector-status.js +117 -0
  27. package/dist/connectors/api-connector.d.ts +7 -2
  28. package/dist/connectors/api-connector.js +7 -4
  29. package/dist/connectors/negotiation-cache.js +22 -4
  30. package/dist/connectors/remote-mcp/call.js +164 -0
  31. package/dist/connectors/remote-mcp/catalog.js +278 -0
  32. package/dist/connectors/remote-mcp/config.js +263 -0
  33. package/dist/connectors/remote-mcp/connection.js +682 -0
  34. package/dist/connectors/remote-mcp/errors.js +488 -0
  35. package/dist/connectors/remote-mcp/oauth.js +473 -0
  36. package/dist/connectors/remote-mcp/resources.js +473 -0
  37. package/dist/connectors/remote-mcp/runtime.js +1 -0
  38. package/dist/connectors/remote-mcp/transport.js +555 -0
  39. package/dist/connectors/remote-mcp.js +62 -2863
  40. package/dist/credential-contract.d.ts +23 -0
  41. package/dist/credential-reads.js +17 -0
  42. package/dist/credential-verification.js +53 -0
  43. package/dist/credentials.d.ts +7 -0
  44. package/dist/credentials.js +60 -25
  45. package/dist/describe-config.d.ts +12 -2
  46. package/dist/describe-config.js +2 -0
  47. package/dist/discovery-health.js +199 -0
  48. package/dist/downstream-input.js +2 -0
  49. package/dist/errors.d.ts +55 -17
  50. package/dist/errors.js +95 -41
  51. package/dist/execute.js +152 -114
  52. package/dist/executor-result.js +3 -1
  53. package/dist/guest-functions.js +83 -0
  54. package/dist/guest-runtime.js +60 -19
  55. package/dist/guest-types.d.ts +19 -5
  56. package/dist/http/argument-problems.js +35 -0
  57. package/dist/http/errors.js +5 -4
  58. package/dist/http/fields.js +192 -13
  59. package/dist/http/hatch-response.js +73 -0
  60. package/dist/http/hatch.js +88 -23
  61. package/dist/http/oauth.js +3 -2
  62. package/dist/http/provider.js +32 -4
  63. package/dist/http/safety.js +8 -0
  64. package/dist/http/tools.js +44 -23
  65. package/dist/index.d.ts +1 -1
  66. package/dist/index.js +9 -2
  67. package/dist/invocation.js +26 -8
  68. package/dist/membership.d.ts +13 -5
  69. package/dist/membership.js +34 -2
  70. package/dist/meta-tools.js +54 -31
  71. package/dist/native-recovery.js +4 -16
  72. package/dist/native-tools.js +5 -2
  73. package/dist/oauth-sealing.js +10 -0
  74. package/dist/operation-vocabulary.js +135 -13
  75. package/dist/operator-record.d.ts +2 -2
  76. package/dist/operator-record.js +1 -0
  77. package/dist/operator-ui/contract.d.ts +10 -0
  78. package/dist/operator-ui/generated.js +2 -2
  79. package/dist/operator-ui/model.d.ts +22 -1
  80. package/dist/provider-call-facts.js +6 -2
  81. package/dist/providers/_shared/rest/operation-index.js +48 -50
  82. package/dist/providers/_shared/rest/value-safety.js +80 -0
  83. package/dist/providers/breeze/api.js +7 -1
  84. package/dist/providers/breeze/skill.generated.js +1 -1
  85. package/dist/providers/breeze/tools.js +4 -3
  86. package/dist/providers/ccb/api.js +2 -1
  87. package/dist/providers/ccb/index.js +5 -0
  88. package/dist/providers/ccb/skill.generated.js +1 -1
  89. package/dist/providers/cloudflare/api.js +8 -1
  90. package/dist/providers/cloudflare/skill.generated.js +1 -1
  91. package/dist/providers/docs/lib/schemas.js +24 -22
  92. package/dist/providers/docs/safety.js +2 -0
  93. package/dist/providers/docs/skill.generated.js +1 -1
  94. package/dist/providers/docs/tools.js +2 -4
  95. package/dist/providers/drive/api.js +1 -0
  96. package/dist/providers/drive/lib/contracts.js +8 -5
  97. package/dist/providers/drive/skill.generated.js +1 -1
  98. package/dist/providers/drive/tools.js +11 -16
  99. package/dist/providers/forms/api.js +1 -0
  100. package/dist/providers/forms/skill.generated.js +1 -1
  101. package/dist/providers/forms/tools.js +10 -7
  102. package/dist/providers/github/api.js +1 -0
  103. package/dist/providers/github/lib/discovery.js +1 -1
  104. package/dist/providers/github/skill.generated.js +1 -1
  105. package/dist/providers/github/tools.js +61 -17
  106. package/dist/providers/gmail/lib/schemas.js +60 -50
  107. package/dist/providers/gmail/skill.generated.js +1 -1
  108. package/dist/providers/gmail/tools.js +7 -5
  109. package/dist/providers/granola/index.js +2 -0
  110. package/dist/providers/granola/skill.generated.js +1 -1
  111. package/dist/providers/overflow/api.js +7 -1
  112. package/dist/providers/overflow/skill.generated.js +1 -1
  113. package/dist/providers/planning-center/api.js +3 -1
  114. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  115. package/dist/providers/planning-center/skill.generated.js +1 -1
  116. package/dist/providers/quickbooks/safety.js +80 -39
  117. package/dist/providers/quickbooks/skill.generated.js +1 -1
  118. package/dist/providers/sheets/api.js +1 -0
  119. package/dist/providers/sheets/skill.generated.js +1 -1
  120. package/dist/providers/sheets/tools.js +13 -14
  121. package/dist/providers/slides/skill.generated.js +1 -1
  122. package/dist/providers/slides/tools.js +15 -13
  123. package/dist/providers/stripe/skill.generated.js +1 -1
  124. package/dist/providers/tithely/api.js +3 -1
  125. package/dist/providers/tithely/skill.generated.js +1 -1
  126. package/dist/providers/vercel/api.js +1 -1
  127. package/dist/providers/vercel/skill.generated.js +1 -1
  128. package/dist/providers/vercel/tools.js +4 -3
  129. package/dist/registry.d.ts +10 -4
  130. package/dist/registry.js +41 -13
  131. package/dist/routes/credentials.js +129 -60
  132. package/dist/routes/dispatch.js +5 -1
  133. package/dist/routes/mcp.js +30 -14
  134. package/dist/routes/operator.js +2 -2
  135. package/dist/routes/shared.d.ts +6 -1
  136. package/dist/routes/shared.js +7 -1
  137. package/dist/routes/ui-config.js +2 -2
  138. package/dist/routes/ui-data.js +126 -20
  139. package/dist/routes/ui.js +16 -0
  140. package/dist/runtime/call.js +7 -2
  141. package/dist/runtime/native-storage.js +12 -0
  142. package/dist/runtime/request-reads.js +207 -0
  143. package/dist/runtime/request-scope.js +16 -0
  144. package/dist/runtime/storage.js +12 -0
  145. package/dist/skills.js +37 -20
  146. package/dist/storage/keys.js +53 -3
  147. package/dist/storage/memory.js +8 -2
  148. package/dist/storage/sql-native.js +53 -1
  149. package/dist/storage/sql.js +12 -2
  150. package/dist/storage-digest.js +32 -0
  151. package/dist/timeout.js +8 -0
  152. package/dist/types.d.ts +33 -0
  153. package/dist/usage-guide.js +16 -35
  154. package/dist/validate.d.ts +3 -0
  155. package/dist/validate.js +220 -158
  156. package/dist/version.d.ts +1 -1
  157. package/dist/version.js +1 -1
  158. package/dist/write-outcome.js +0 -3
  159. package/documentation/README.md +13 -13
  160. package/documentation/architecture.md +385 -20
  161. package/documentation/auth.md +233 -64
  162. package/documentation/code-mode.md +122 -39
  163. package/documentation/deploying.md +6 -3
  164. package/documentation/integrating.md +16 -2
  165. package/documentation/meta-tools.md +240 -49
  166. package/documentation/migration-0.33.md +5 -1
  167. package/documentation/migration-0.34.md +3 -1
  168. package/documentation/operator-tests.md +1 -1
  169. package/documentation/operator-ui.md +105 -12
  170. package/documentation/pool-membership-upgrade.md +44 -0
  171. package/documentation/providers.md +105 -0
  172. package/examples/worker/AGENTS.md +10 -10
  173. package/examples/worker/README.md +4 -3
  174. package/examples/worker/src/connecta.config.ts +1 -0
  175. package/guides/connecting-agents.md +2 -1
  176. package/guides/how-it-works.md +4 -2
  177. package/package.json +1 -1
  178. package/templates/node/AGENTS.md +13 -12
  179. package/templates/node/package.json +1 -1
  180. package/templates/node/src/connecta.config.ts +1 -0
@@ -20,8 +20,12 @@ export interface UiTool extends Pick<OperatorTool, "name" | "address" | "descrip
20
20
  * (`credential_required`), or a secret that lives in deployment configuration
21
21
  * (`auth_required`). Principal forwarding instead reports `caller_refused`,
22
22
  * which has no secret-repair prompt.
23
+ *
24
+ * `connector_timeout` is a downstream that did not answer before the operator
25
+ * page's deadline: slow or unreachable, not known to be broken. The page
26
+ * stops retrying it on its own until someone asks with Refresh.
23
27
  */
24
- export type UiProblem = "connector_unavailable" | "oauth_required" | "credential_required" | "auth_required" | "caller_refused" | "credential_mismatch" | "catalog_failed";
28
+ export type UiProblem = "connector_unavailable" | "connector_timeout" | "oauth_required" | "credential_required" | "auth_required" | "caller_refused" | "credential_mismatch" | "catalog_failed";
25
29
  /** A stored credential that cannot be used, by cause. */
26
30
  export type UiCredentialProblem = "credential_mismatch" | "credential_unreadable";
27
31
  interface UiCredentialField {
@@ -35,6 +39,13 @@ interface UiCredentialField {
35
39
  lastFour?: string;
36
40
  updatedAt?: string;
37
41
  }
42
+ /** Mirrors `CredentialVerification` in src/credential-verification.ts. */
43
+ export interface UiVerification {
44
+ outcome: "ok" | "unverified" | UiCredentialFailure;
45
+ observedAt: string;
46
+ }
47
+ /** Why a credential test failed, as the route reports it. */
48
+ export type UiCredentialFailure = "rejected" | "scope" | "unreachable" | "timeout" | "failed";
38
49
  interface UiCredential {
39
50
  label: string;
40
51
  description?: string;
@@ -83,11 +94,21 @@ export interface UiConnector {
83
94
  authorizationUrl?: string;
84
95
  toolCount: number;
85
96
  tools: UiTool[];
97
+ /** Age of the stored status observation this row shows; absent on a live read. */
98
+ observedAgeMs?: number;
99
+ /** Browser-only: the stored observation's time, fixed as the row lands. */
100
+ observedAt?: number;
86
101
  /** This connector exposes manageable downstream OAuth lifecycle hooks. */
87
102
  oauth?: boolean;
88
103
  /** Selected downstream client identity mechanism; never a client ID or secret. */
89
104
  registrationPath?: "cimd" | "dcr" | "static";
90
105
  credential?: UiCredential;
106
+ /**
107
+ * The stored last verification of this connector's operator-managed
108
+ * credential. Absent when nothing has verified the save stored now. A
109
+ * typed outcome and a time; never vendor text.
110
+ */
111
+ verification?: UiVerification;
91
112
  /**
92
113
  * Drift the last catalog refresh saw *in this runtime*
93
114
  * ([#343](https://github.com/zackbart/connecta/issues/343)). Four counts and
@@ -133,9 +133,13 @@ export function renewedAfterUnauthorized(request, sent, fresh) {
133
133
  export function refusalRequest(error) {
134
134
  return error !== null && typeof error === "object" ? refusals.get(error) : undefined;
135
135
  }
136
- export function knownProviderFailure(error) {
137
- if (error !== null && typeof error === "object")
136
+ /** A local refusal may additionally name the physical request it prevented. */
137
+ export function knownProviderFailure(error, unsent) {
138
+ if (error !== null && typeof error === "object") {
138
139
  knownFailures.add(error);
140
+ if (unsent && !unsent.dispatched)
141
+ refusals.set(error, unsent);
142
+ }
139
143
  return error;
140
144
  }
141
145
  export function isKnownProviderFailure(error) {
@@ -11,7 +11,7 @@ import { parseIndexDetails } from "./compact-index.js";
11
11
  // are refused with the accepted names, because a guessed argument is the most
12
12
  // common way an agent wastes a call. Drift between the pin and the vendor is
13
13
  // caught by `providers:check` and refreshed by `providers:spec`.
14
- import { ConnectorCallError } from "../../../errors.js";
14
+ import { ConnectorCallError, issueSummary } from "../../../errors.js";
15
15
  const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
16
16
  const MAX_ISSUES = 20;
17
17
  function words(text) {
@@ -21,11 +21,16 @@ function words(text) {
21
21
  .split(/[^a-z0-9]+/)
22
22
  .filter(Boolean);
23
23
  }
24
- function invalid(message, issues, repair) {
25
- throw new ConnectorCallError("invalid_args", message, {
26
- ...(issues ? { validation: { issues } } : {}),
27
- ...(repair ? { repair } : {}),
28
- });
24
+ /** Index findings name no example: the hatch schema cannot prove an operation's arguments safe to echo. */
25
+ function invalid(message, issues, details) {
26
+ throw new ConnectorCallError("invalid_args", message, issues
27
+ ? {
28
+ validation: {
29
+ issues,
30
+ exampleUnavailable: `Examples are withheld for API operations; read ${details} for the contract.`,
31
+ },
32
+ }
33
+ : {});
29
34
  }
30
35
  function typesOf(node) {
31
36
  return node.t === undefined ? [] : typeof node.t === "string" ? [node.t] : node.t;
@@ -42,7 +47,7 @@ function distance(a, b) {
42
47
  }
43
48
  return previous[b.length];
44
49
  }
45
- /** The accepted names, bounded and led by the likeliest intended one; repair details carry the full list. */
50
+ /** The accepted names, bounded and led by the likeliest intended one; the full list is in the tool schema. */
46
51
  function oneOf(names, given) {
47
52
  const close = given === undefined
48
53
  ? undefined
@@ -245,7 +250,7 @@ export class OperationIndex {
245
250
  best.rows.push(row);
246
251
  });
247
252
  if (best.rows.length === 0) {
248
- invalid(`${this.#title} has no ${path} operation in the pinned API index (${this.revision}). ` +
253
+ invalid(`${this.#title} has no operation at that path in the pinned API index (${this.revision}). ` +
249
254
  `Nearest: ${this.nearest(method, segments).join("; ")}. Find operations with ${this.#vendorTool("search")}.`);
250
255
  }
251
256
  const templates = new Set(best.rows.map((row) => this.ops[row].path));
@@ -279,17 +284,16 @@ export class OperationIndex {
279
284
  }
280
285
  });
281
286
  const issues = [];
282
- const repair = [];
283
287
  for (const [name, at, , schema] of this.#row(op)?.[0] ?? []) {
284
288
  if (at === "path" && params[name] !== undefined)
285
- this.#check(schema, decodeURIComponent(params[name]), `/path/${name}`, issues, repair);
289
+ this.#check(schema, decodeURIComponent(params[name]), `/path/${name}`, issues);
286
290
  }
287
291
  if (issues.length) {
288
292
  const shown = issues
289
293
  .slice(0, 5)
290
- .map((issue) => `${issue.path} (${issue.code}: ${issue.expected})`)
294
+ .map((issue) => issueSummary([issue]))
291
295
  .join("; ");
292
- invalid(`Not sent: the path does not match ${op.method} ${op.path} in the pinned API index: ${shown}. Substitute real ids matching ${this.#vendorTool("details")}.${issues.some((issue) => issue.expected?.includes("safe integer")) ? " Integer ids use canonical ASCII decimal notation within the safe-integer range." : ""}`, issues.slice(0, MAX_ISSUES));
296
+ invalid(`Not sent: the path does not match ${op.method} ${op.path} in the pinned API index: ${shown}. Substitute real ids matching ${this.#vendorTool("details")}.${issues.some((issue) => issue.expected?.includes("safe integer")) ? " Integer ids use canonical ASCII decimal notation within the safe-integer range." : ""}`, issues.slice(0, MAX_ISSUES), this.#vendorTool("details"));
293
297
  }
294
298
  return { op, params };
295
299
  }
@@ -458,7 +462,7 @@ export class OperationIndex {
458
462
  }
459
463
  }
460
464
  if (only !== undefined && parameters.length === 0 && body === undefined) {
461
- invalid(`${op.method} ${op.path} has no parameter named ${only}.`);
465
+ invalid(`${op.method} ${op.path} has no parameter by that name.`);
462
466
  }
463
467
  return {
464
468
  method: op.method,
@@ -480,7 +484,6 @@ export class OperationIndex {
480
484
  check(op, query, body) {
481
485
  const row = this.#row(op);
482
486
  const issues = [];
483
- const repair = [];
484
487
  const declared = (row ? row[0] : []).filter(([, at]) => at === "query");
485
488
  const accepted = declared.map(([name]) => name);
486
489
  for (const [name, value] of Object.entries(query)) {
@@ -489,40 +492,39 @@ export class OperationIndex {
489
492
  const param = declared.find(([key]) => key === name);
490
493
  if (!param) {
491
494
  issues.push({
492
- path: `/query/${name}`,
493
- code: "additionalProperties",
495
+ path: "/query/<key>",
494
496
  expected: accepted.length ? oneOf(accepted, name) : "no query parameters",
497
+ received: valueType(value),
495
498
  });
496
- repair.push({ path: `/query/${name}`, receivedType: valueType(value), acceptedKeys: accepted });
497
499
  continue;
498
500
  }
499
- this.#check(param[3], value, `/query/${name}`, issues, repair);
501
+ this.#check(param[3], value, `/query/${name}`, issues);
500
502
  }
501
503
  for (const [name, , required] of declared) {
502
504
  if (required === 1 && query[name] === undefined) {
503
- issues.push({ path: `/query/${name}`, code: "required", expected: "a value" });
505
+ issues.push({ path: `/query/${name}`, expected: "a value (required)", received: "missing" });
504
506
  }
505
507
  }
506
508
  const bodyRow = row && row[1] !== 0 ? row[1] : undefined;
507
509
  if (body !== undefined) {
508
510
  if (!bodyRow) {
509
- issues.push({ path: "/body", code: "false", expected: "no body for this operation" });
511
+ issues.push({ path: "/body", expected: "no body for this operation" });
510
512
  }
511
513
  else {
512
- this.#check(bodyRow[1], body, "/body", issues, repair);
514
+ this.#check(bodyRow[1], body, "/body", issues);
513
515
  }
514
516
  }
515
517
  else if (bodyRow?.[2] === 1) {
516
518
  // An optional body may be omitted whole; a required one may not.
517
- issues.push({ path: "/body", code: "required", expected: `a ${bodyRow[0]} body` });
519
+ issues.push({ path: "/body", expected: `a ${bodyRow[0]} body (required)`, received: "missing" });
518
520
  }
519
521
  if (issues.length > 0) {
520
522
  const shown = issues
521
523
  .slice(0, 5)
522
- .map((issue) => `${issue.path} (${issue.code}: ${issue.expected})`)
524
+ .map((issue) => issueSummary([issue]))
523
525
  .join("; ");
524
526
  invalid(`Not sent: the arguments do not match ${op.method} ${op.path} in the pinned API index: ${shown}` +
525
- `${issues.length > 5 ? `; ${issues.length - 5} more` : ""}. Read ${this.#vendorTool("details")} for the contract.`, issues.slice(0, MAX_ISSUES), repair.length ? { issues: repair.slice(0, 5) } : undefined);
527
+ `${issues.length > 5 ? `; ${issues.length - 5} more` : ""}. Read ${this.#vendorTool("details")} for the contract.`, issues.slice(0, MAX_ISSUES), this.#vendorTool("details"));
526
528
  }
527
529
  }
528
530
  #vendorTool(kind) {
@@ -606,7 +608,7 @@ export class OperationIndex {
606
608
  out["truncated"] = true;
607
609
  return out;
608
610
  }
609
- #check(node, value, at, issues, repair) {
611
+ #check(node, value, at, issues) {
610
612
  if (issues.length >= MAX_ISSUES)
611
613
  return;
612
614
  const schema = this.#deref(node);
@@ -624,7 +626,6 @@ export class OperationIndex {
624
626
  (integer && !Number.isSafeInteger(numeric))) {
625
627
  issues.push({
626
628
  path: at,
627
- code: "type",
628
629
  expected: integer ? "a canonical ASCII decimal safe integer" : "a canonical ASCII decimal number",
629
630
  });
630
631
  return;
@@ -635,7 +636,7 @@ export class OperationIndex {
635
636
  if (types.includes("boolean") && (value === "true" || value === "false"))
636
637
  value = value === "true";
637
638
  else {
638
- issues.push({ path: at, code: "type", expected: types.join(" or ") });
639
+ issues.push({ path: at, expected: types.join(" or ") });
639
640
  return;
640
641
  }
641
642
  }
@@ -644,7 +645,7 @@ export class OperationIndex {
644
645
  // Conflicting composed constraints (disjoint enums or types) admit nothing,
645
646
  // not even the null or "" that otherwise unsets a field.
646
647
  if (schema.e?.length === 0) {
647
- issues.push({ path: at, code: "enum", expected: "no value: this field's combined constraints admit none" });
648
+ issues.push({ path: at, expected: "no value: this field's combined constraints admit none" });
648
649
  return;
649
650
  }
650
651
  // Only newly opted-in bounded schemas enforce scalar numeric values;
@@ -661,42 +662,40 @@ export class OperationIndex {
661
662
  ? Number(value)
662
663
  : NaN;
663
664
  if (!Number.isFinite(numeric) || (integer && !Number.isSafeInteger(numeric))) {
664
- issues.push({ path: at, code: "type", expected: integer ? "integer" : "finite number" });
665
+ issues.push({ path: at, expected: integer ? "integer" : "finite number" });
665
666
  return;
666
667
  }
667
668
  if (schema.l !== undefined && numeric < schema.l)
668
- issues.push({ path: at, code: "minimum", expected: `at least ${schema.l}` });
669
+ issues.push({ path: at, expected: `at least ${schema.l}`, received: "number" });
669
670
  if (schema.u !== undefined && numeric > schema.u)
670
- issues.push({ path: at, code: "maximum", expected: `at most ${schema.u}` });
671
+ issues.push({ path: at, expected: `at most ${schema.u}`, received: "number" });
671
672
  }
672
673
  if (value === null || value === "")
673
674
  return;
674
675
  if (schema.a) {
675
676
  let fewest;
676
677
  for (const branch of schema.a) {
677
- const attempt = { issues: [], repair: [] };
678
- this.#check(branch, value, at, attempt.issues, attempt.repair);
679
- if (attempt.issues.length === 0)
678
+ const attempt = [];
679
+ this.#check(branch, value, at, attempt);
680
+ if (attempt.length === 0)
680
681
  return;
681
- if (!fewest || attempt.issues.length < fewest.issues.length)
682
+ if (!fewest || attempt.length < fewest.length)
682
683
  fewest = attempt;
683
684
  }
684
- if (fewest) {
685
- issues.push(...fewest.issues);
686
- repair.push(...fewest.repair);
687
- }
685
+ if (fewest)
686
+ issues.push(...fewest);
688
687
  return;
689
688
  }
690
689
  if (schema.e && (typeof value === "string" || typeof value === "number" || typeof value === "boolean")) {
691
690
  if (!schema.e.some((option) => option === value || String(option) === String(value))) {
692
- issues.push({ path: at, code: "enum", expected: oneOf(schema.e.map(String)) });
691
+ issues.push({ path: at, expected: oneOf(schema.e.map(String)) });
693
692
  }
694
693
  return;
695
694
  }
696
695
  if (schema.p || types.includes("object")) {
697
696
  if (!isRecord(value)) {
698
697
  if (types.length === 0 || types.includes("object")) {
699
- issues.push({ path: at, code: "type", expected: "object" });
698
+ issues.push({ path: at, expected: "object" });
700
699
  }
701
700
  return;
702
701
  }
@@ -707,35 +706,34 @@ export class OperationIndex {
707
706
  continue;
708
707
  const child = properties[key];
709
708
  if (child)
710
- this.#check(child, item, `${at}/${key}`, issues, repair);
709
+ this.#check(child, item, `${at}/${key}`, issues);
711
710
  else if (schema.m)
712
- this.#check(schema.m, item, `${at}/${key}`, issues, repair);
711
+ this.#check(schema.m, item, `${at}/<key>`, issues);
713
712
  else if (schema.p) {
714
713
  issues.push({
715
- path: `${at}/${key}`,
716
- code: "additionalProperties",
714
+ path: `${at}/<key>`,
717
715
  expected: accepted.length ? oneOf(accepted, key) : "no properties",
716
+ received: valueType(item),
718
717
  });
719
- repair.push({ path: `${at}/${key}`, receivedType: valueType(item), acceptedKeys: accepted });
720
718
  }
721
719
  }
722
720
  for (const key of schema.r ?? []) {
723
721
  if (value[key] === undefined)
724
- issues.push({ path: `${at}/${key}`, code: "required", expected: "a value" });
722
+ issues.push({ path: `${at}/${key}`, expected: "a value (required)", received: "missing" });
725
723
  }
726
724
  return;
727
725
  }
728
726
  if (schema.i || types.includes("array")) {
729
727
  if (!Array.isArray(value)) {
730
- issues.push({ path: at, code: "type", expected: "array" });
728
+ issues.push({ path: at, expected: "array" });
731
729
  return;
732
730
  }
733
731
  if (schema.i)
734
- value.forEach((item, index) => this.#check(schema.i, item, `${at}/${index}`, issues, repair));
732
+ value.forEach((item, index) => this.#check(schema.i, item, `${at}/${index}`, issues));
735
733
  return;
736
734
  }
737
735
  if (types.length > 0 && (isRecord(value) || Array.isArray(value))) {
738
- issues.push({ path: at, code: "type", expected: types.join(" or ") });
736
+ issues.push({ path: at, expected: types.join(" or ") });
739
737
  }
740
738
  }
741
739
  }
@@ -1338,6 +1338,53 @@ function acceptsCredential(schema, depth) {
1338
1338
  acceptsCredential(node.items, depth + 1) ||
1339
1339
  (node.anyOf ?? []).some((branch) => acceptsCredential(branch, depth + 1)));
1340
1340
  }
1341
+ const ENV_VALUE_NAMES = ENV_VALUE_FIELDS.map(normalizedName);
1342
+ /** `data[].id` segments as tokens, a list's items as `[]`: `["data", "[]", "id"]`. */
1343
+ function tokensOf(segments) {
1344
+ return segments.flatMap((segment) => segment === "[]"
1345
+ ? ["[]"]
1346
+ : segment.endsWith("[]")
1347
+ ? [normalizedName(segment.slice(0, -2)), "[]"]
1348
+ : [normalizedName(segment)]);
1349
+ }
1350
+ /**
1351
+ * A best-effort hint for discovery over rules that change every non-null value
1352
+ * they reach: a path is withheld when it sits at or inside a reviewed redaction
1353
+ * path (matched from the body's root, or at any depth with `anyDepth`), or any
1354
+ * of its names satisfies the name rule, which also sees the parent's name.
1355
+ * Paths carry their list markers: a reviewed `[]` needs a declared list there,
1356
+ * while a named segment also reaches every item of a list. Rules that can leave
1357
+ * a value unchanged (`url:`, `origin:` or `#url` sanitizing, `[?…]`, `@keys`)
1358
+ * are not hints. Names compare normalized, and `{}` or `*` matches any name.
1359
+ */
1360
+ export function pathWithholds(rules) {
1361
+ const patterns = (rules.paths ?? []).flatMap((raw) => {
1362
+ const { mode, segments } = parsePath(raw);
1363
+ if (mode !== "redact" || segments.some((segment) => segment.startsWith("[?") || segment === "@keys"))
1364
+ return [];
1365
+ return [segments.map((segment) => (segment === "[]" ? "[]" : segment === "{}" ? "*" : normalizedName(segment)))];
1366
+ });
1367
+ const reaches = (pattern, tokens, from) => {
1368
+ let at = from;
1369
+ for (const part of pattern) {
1370
+ if (part !== "[]")
1371
+ while (tokens[at] === "[]")
1372
+ at += 1;
1373
+ if (at >= tokens.length || (part === "[]" ? tokens[at] !== "[]" : part !== "*" && part !== tokens[at]))
1374
+ return false;
1375
+ at += 1;
1376
+ }
1377
+ return true;
1378
+ };
1379
+ return (path) => {
1380
+ const tokens = tokensOf(path);
1381
+ const names = tokens.filter((token) => token !== "[]");
1382
+ if (rules.names && names.some((name, at) => rules.names(name, names[at - 1] ?? "")))
1383
+ return true;
1384
+ const starts = rules.anyDepth ? tokens.flatMap((token, at) => (token === "[]" ? [] : [at])) : [0];
1385
+ return patterns.some((pattern) => starts.some((at) => reaches(pattern, tokens, at)));
1386
+ };
1387
+ }
1341
1388
  /** A vendor's value-safety mechanism over its reviewed table and pinned index. */
1342
1389
  export function valueSafety(table, index) {
1343
1390
  const fields = table.fields ?? {};
@@ -1347,9 +1394,42 @@ export function valueSafety(table, index) {
1347
1394
  const key = `${method} ${template}`;
1348
1395
  return Object.hasOwn(table.operations, key) ? table.operations[key] : undefined;
1349
1396
  };
1397
+ const hints = new Map();
1398
+ const redactedNames = Object.entries(fields)
1399
+ .filter(([, review]) => review.verdict === "redact")
1400
+ .map(([name]) => normalizedName(name));
1401
+ const keptNames = Object.entries(fields)
1402
+ .filter(([, review]) => review.verdict === "keep")
1403
+ .map(([name]) => normalizedName(name));
1404
+ const scopeParents = scopeMaps.map(normalizedName);
1350
1405
  return {
1351
1406
  table,
1352
1407
  verdict,
1408
+ withholds(method, template) {
1409
+ const key = `${method} ${template}`;
1410
+ let hint = hints.get(key);
1411
+ if (!hint) {
1412
+ const reviewed = verdict(method, template);
1413
+ const env = table.envBodies?.test(template) === true;
1414
+ const kept = new Set([
1415
+ ...keptNames,
1416
+ ...(reviewed && reviewed.verdict !== "refuse" ? (reviewed.keep ?? []) : []).map((path) => trailOf(path).map(normalizedName).at(-1)),
1417
+ ]);
1418
+ hint =
1419
+ reviewed?.verdict === "refuse"
1420
+ ? () => true
1421
+ : pathWithholds({
1422
+ paths: reviewed?.verdict === "redact" ? reviewed.paths : [],
1423
+ names: (name, parent) => redactedNames.includes(name) ||
1424
+ (env && ENV_VALUE_NAMES.includes(name)) ||
1425
+ (parent === "deployhooks" && name === "url") ||
1426
+ // The suffix rule scrubs only strings, so only the whole-value credential rule hints.
1427
+ (!kept.has(name) && !scopeParents.includes(parent) && containsCredential(name)),
1428
+ });
1429
+ hints.set(key, hint);
1430
+ }
1431
+ return hint;
1432
+ },
1353
1433
  refusal(method, template) {
1354
1434
  const reviewed = verdict(method, template);
1355
1435
  return reviewed?.verdict === "refuse" ? `Connecta refuses ${method} ${template}. ${reviewed.reason}` : undefined;
@@ -1,3 +1,5 @@
1
+ import { Routes, routePhrases } from "../../agent-routes.js";
2
+ import { pathWithholds } from "../_shared/rest/value-safety.js";
1
3
  import { asRecord } from "../_shared/json.js";
2
4
  // Breeze's reviewed GET-only vendor. HTTP methods do not establish safety: see tools.ts.
3
5
  import { Effect, Schema } from "effect";
@@ -467,10 +469,11 @@ export const admit = (url) => {
467
469
  };
468
470
  export const authenticate = Effect.gen(function* () {
469
471
  const values = yield* (yield* Credentials).values.pipe(Effect.mapError((error) => new Refusal({ error })));
472
+ const routes = yield* Routes;
470
473
  const key = values?.["value"]?.trim();
471
474
  if (!key)
472
475
  return yield* Effect.fail(new Refusal({
473
- error: new ConnectorCallError("auth_required", "No Breeze API key is configured for this connector. Call authorize_connector for recovery options. When available, an operator can add the key in this connection in the operator UI."),
476
+ error: new ConnectorCallError("auth_required", `No Breeze API key is configured for this connector. ${routePhrases(routes).authorizeFor("recovery options")}. When available, an operator can add the key in this connection in the operator UI.`),
474
477
  }));
475
478
  return { "Api-Key": key };
476
479
  });
@@ -537,6 +540,9 @@ export const breezeSafety = {
537
540
  })(),
538
541
  }),
539
542
  redact: (body, op) => withoutCalendarFeeds(op.template, body),
543
+ withholds: (op) => op.template === "/events/calendars/list"
544
+ ? pathWithholds({ names: (name) => name === "address" || name === "embedkey" })
545
+ : () => false,
540
546
  withholdsErrors: () => false,
541
547
  },
542
548
  sensitiveFields: { names: [], choice: "drop" },
@@ -1,5 +1,5 @@
1
1
  // Generated by scripts/generate-providers.mjs; do not edit.
2
2
  export const skill = {
3
- "content": "## Church records\n\nBreeze ChMS (now Tithely Church Management) holds people, tags, events,\nattendance, forms, volunteers and giving. People carry contact information,\nbirthdates and family ties; giving reveals donors and amounts. Reduce records\nin code and return only the names, counts or totals the task needs.\n\n## Ids and joins\n\nIds are numeric strings. Resolve them from the relevant inventory before\nwriting. Events have an occurrence id and a series `eventId`; attendance,\nvolunteers and check-in use the occurrence id. Event and pledge lists can lag\nup to 15 minutes behind the site. An all-zero MySQL date means no time was\nrecorded, including no check-out.\n\nProfile `details` use field ids, not names. Resolve field and option ids from\n`list_profile_fields`; multiple-choice filters join option ids with `-`.\nA person-name lookup requires listing people and matching in code. Form entry\nresponses similarly join to that form's field ids.\n\n## Giving\n\nGiving, funds and pledges use archival endpoints removed from the public\nreference in 2023. They can change or vanish without notice. Amounts are\nexact decimal strings: reduce them with integer-cent arithmetic, and read the\ncurrency from `get_account_summary`. A split gift's fund amounts belong to\nits fund ids. Tithe.ly deposits sync in batches, so recent gifts can be absent.\nEditing a giving transaction may return a new payment id.\nUse `limit: 1000` when reducing broad giving ranges: each local page refetches\nthe bounded vendor list, so larger windows avoid repeated full-list fetches.\n\n## Required sequences\n\nCheck for an existing person before adding one: creation does not deduplicate.\nResolve profile fields before updating them. Text, dates (M/D/YYYY) and option\nids use `response`; email, phone, address and family-role values use `details`.\nNamed person writes take these entries in `values`.\nFamily changes can remove previous associations. Attendance check-out records\na departure time; removing attendance deletes the record instead.\n\nBreeze sends writes as GET requests too. Query names ending in `_json` take\nJSON-encoded strings. The named person writes encode their typed profile values\nfor you. The account-log `object_json` is a serialized affected-object value;\nits free-text description has no stable schema. Events and account logs have\nvendor caps: narrow the date range when the result fills its limit.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `breeze_api_details`, `breeze_api_search`, `list_attendance`, `list_contributions`, `list_funds`, `list_people`, `list_profile_fields`, `list_tags`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. For `breeze_api_read`, put it in `query[page.param]` or `body[page.param]`, as `page.in` specifies. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 100, at most 1000). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `breeze_api_search` finds this API's operations and reads no account data, `breeze_api_details` shows one operation's contract, and `breeze_api_read` calls a reviewed read. `breeze_api_write` calls a reviewed write; the host may ask the user to confirm it. An operation without a reviewed verdict is refused.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. `not_found` means the record does not exist; this API tells absence from a permission gap. It documents no conflict.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
3
+ "content": "## Church records\n\nBreeze ChMS (now Tithely Church Management) holds people, tags, events,\nattendance, forms, volunteers and giving. People carry contact information,\nbirthdates and family ties; giving reveals donors and amounts. Reduce records\nin code and return only the names, counts or totals the task needs.\n\n## Ids and joins\n\nIds are numeric strings. Resolve them from the relevant inventory before\nwriting. Events have an occurrence id and a series `eventId`; attendance,\nvolunteers and check-in use the occurrence id. Event and pledge lists can lag\nup to 15 minutes behind the site. An all-zero MySQL date means no time was\nrecorded, including no check-out.\n\nProfile `details` use field ids, not names. Resolve field and option ids from\n`list_profile_fields`; multiple-choice filters join option ids with `-`.\nA person-name lookup requires listing people and matching in code. Form entry\nresponses similarly join to that form's field ids.\n\n## Giving\n\nGiving, funds and pledges use archival endpoints removed from the public\nreference in 2023. They can change or vanish without notice. Amounts are\nexact decimal strings: reduce them with integer-cent arithmetic, and read the\ncurrency from `get_account_summary`. A split gift's fund amounts belong to\nits fund ids. Tithe.ly deposits sync in batches, so recent gifts can be absent.\nEditing a giving transaction may return a new payment id.\nUse `limit: 1000` when reducing broad giving ranges: each local page refetches\nthe bounded vendor list, so larger windows avoid repeated full-list fetches.\n\n## Required sequences\n\nCheck for an existing person before adding one: creation does not deduplicate.\nResolve profile fields before updating them. Text, dates (M/D/YYYY) and option\nids use `response`; email, phone, address and family-role values use `details`.\nNamed person writes take these entries in `values`.\nFamily changes can remove previous associations. Attendance check-out records\na departure time; removing attendance deletes the record instead.\n\nBreeze sends writes as GET requests too. Query names ending in `_json` take\nJSON-encoded strings. The named person writes encode their typed profile values\nfor you. The account-log `object_json` is a serialized affected-object value;\nits free-text description has no stable schema. Events and account logs have\nvendor caps: narrow the date range when the result fills its limit.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `breeze_api_details`, `breeze_api_search`, `list_attendance`, `list_contributions`, `list_funds`, `list_people`, `list_profile_fields`, `list_tags`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. For `breeze_api_read`, put it in `query[page.param]` or `body[page.param]`, as `page.in` specifies. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 100, at most 1000). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `breeze_api_search` finds this API's operations and reads no account data, `breeze_api_details` shows one operation's contract, and `breeze_api_read` calls a reviewed read. Details' `response.fields` promises selection only; `withheld` hints are incomplete, and absence never means safe or readable. `breeze_api_write` calls a reviewed write; the host may ask the user to confirm it. An operation without a reviewed verdict is refused.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. `not_found` means the record does not exist; this API tells absence from a permission gap. It documents no conflict.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
4
4
  "instructionsHeading": "Church instructions"
5
5
  };
@@ -6,7 +6,7 @@ import { ConnectorCallError } from "../../errors.js";
6
6
  import { Refusal } from "../../http/errors.js";
7
7
  import { Integer, isMinChars, isMaxChars } from "../../http/json-schema.js";
8
8
  import { RequestSecrets, Classification } from "../../http/services.js";
9
- import { fieldsInput, fieldsProblem, selectFields } from "../../http/fields.js";
9
+ import { fieldsContract, selectFields } from "../../http/fields.js";
10
10
  import { read, list, write as writeTool } from "../../http/tools.js";
11
11
  import { READ_ENDPOINTS } from "./api.js";
12
12
  const DEFAULT_EVENT_LIMIT = 100;
@@ -218,6 +218,7 @@ const Result_list_events = Schema.Struct({
218
218
  });
219
219
  const Row_list_events = Result_list_events.fields.events.value;
220
220
  const SelectedEvent = Schema.Struct(Object.fromEntries(Object.entries(Row_list_events.fields).map(([name, schema]) => [name, Schema.optionalKey(schema)])));
221
+ const EVENT_FIELDS = fieldsContract("item", SelectedEvent);
221
222
  const Result_list_attendance = Schema.Struct({
222
223
  "attendance": Schema.Array(Schema.Struct({
223
224
  "personId": Schema.String,
@@ -527,7 +528,7 @@ export function breezeTools({ connection, pageSize: defaultPageSize, }) {
527
528
  description: "Instances returned, 1 to Breeze's 1000. Defaults to 100.",
528
529
  default: 100,
529
530
  })),
530
- fields: Schema.optionalKey(fieldsInput("Dot paths to keep in each event item.")),
531
+ fields: Schema.optionalKey(EVENT_FIELDS.input),
531
532
  }),
532
533
  output: Schema.Struct({
533
534
  items: Schema.Array(SelectedEvent),
@@ -535,7 +536,7 @@ export function breezeTools({ connection, pageSize: defaultPageSize, }) {
535
536
  truncated: Schema.optionalKey(Schema.Array(Schema.String)),
536
537
  }),
537
538
  run: (args) => Effect.gen(function* () {
538
- const error = fieldsProblem(args.fields, new Set(Object.keys(Row_list_events.fields)), "an event item");
539
+ const error = EVENT_FIELDS.problem(args.fields);
539
540
  if (error)
540
541
  return yield* Effect.fail(new Refusal({ error }));
541
542
  const result = yield* load_list_events(args);
@@ -1,4 +1,5 @@
1
1
  import { asRecord, asArray } from "../_shared/json.js";
2
+ import { routePhrases } from "../../agent-routes.js";
2
3
  import { CCB_MEDIA_TYPE, CCB_ENVIRONMENTS, CCB_READ_SCOPES, CCB_WRITE_SCOPES, CCB_SENSITIVE_FIELDS, } from "./lib/constants.js";
3
4
  // Pushpay ChMS v2 JSON contracts, OAuth, status meanings, admission and privacy.
4
5
  // The legacy per-church XML API is outside this provider's scope.
@@ -465,7 +466,7 @@ export const errors = {
465
466
  statuses: {
466
467
  401: () => ({
467
468
  code: "auth_required",
468
- words: (detail) => `${detail} CCB rejected the connection's access token. Call authorize_connector for this connector.`,
469
+ words: (detail, routes) => `${detail} CCB rejected the connection's access token. ${routePhrases(routes).authorizeFor("this connector")}.`,
469
470
  advice: ", then retry",
470
471
  }),
471
472
  403: () => ({
@@ -47,6 +47,11 @@ export const ccb = defineApiProvider({
47
47
  note: "./providers/ccb merged GitHub/Cloudflare/CCB step-2 snapshot measures 391,649 B standalone gzip-9 before recording these bundle facts, Node 26.12.0 with zlib-ng 1.3.1; recomputed with the combined opt-in hatch support. Earlier step-2 standalone gzip snapshot at ffd64b04, CI run 40080865116 static job, Node 22.23.3, against main ce0c7492 (345,272 B). Includes the reviewed 385-operation request/response index. Shared Effect is paid once per deployment; provider entries are reported, uncapped and non-additive (#703) (#899).",
48
48
  },
49
49
  skill,
50
+ // The guide resolves a person from individuals and a household from families.
51
+ searchSynonyms: [
52
+ ["person", "individual"],
53
+ ["household", "family"],
54
+ ],
50
55
  options: CCB_OPTIONS,
51
56
  displayTitle: (options) => defaultTitle(options.environment, options.mode, writable(options)),
52
57
  description: (options) => `Church Community Builder (Pushpay ChMS) — ${routingLine(options.environment, options.mode, writable(options))}: ${options.purpose}`,
@@ -1,5 +1,5 @@
1
1
  // Generated by scripts/generate-providers.mjs; do not edit.
2
2
  export const skill = {
3
- "content": "## People, households and groups\n\nCCB v2 addresses records by integer ids. Resolve a person from the individual\nlist and a household from families; never guess ids. Family membership and\nchurch group membership join by individual id. A membership role belongs to\nthat group, rather than being a property of the person everywhere.\n\nIdentity Auth acts with the signed-in person's permissions. A permission\nrefusal can mean that person lacks access even when the connection is healthy.\n\n## Events and attendance\n\nAn event is a series; its dated occurrence is a separate identity. Attendance\njoins need both the event id and occurrence. Preserve the vendor's date and\ntime-zone meaning instead of treating every occurrence as UTC midnight.\n\n## Pastoral and financial records\n\nNotes, background checks, pledges and giving metrics can be sensitive. Read\nonly what the task needs and reduce those records inside execute_code before\nreturning an answer. Gift metrics are counts by period, with a range of at\nmost twelve months. Per-gift transactions and amounts belong to the legacy\nv1 XML API, which this v2 connector does not reach.\n\n## Vendor call rates and recovery\n\nCCB meters endpoints separately per API client. Individual profile reads have\nno burst; individual advanced searches allow one call per five seconds, and\ncategory schedule lists allow one per two seconds. Prefer list pages over a\nloop of individual gets. Other deployments sharing the client spend from the\nsame vendor allowance.\n\nFor a profile edit, resolve the individual first and preserve fields outside\nthe requested change. Use `ccb_api_write` for the edit; the host may ask the\nuser to confirm. After an ambiguous update, read that profile to determine\nwhat actually changed before deciding whether another write is needed.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `ccb_api_details`, `ccb_api_read`, `ccb_api_search`, `list_event_attendees`, `list_events`, `list_families`, `list_group_members`, `list_groups`, `list_individual_groups`, `list_individual_notes`, `list_individuals`, `search_records`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 25, at most 100). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `ccb_api_search` finds this API's operations and reads no account data, `ccb_api_details` shows one operation's contract, and `ccb_api_read` calls a reviewed read. `ccb_api_write` calls a reviewed write; the host may ask the user to confirm it. An operation without a reviewed verdict is refused. 14 operations are refused by design; use `ccb_api_details` with the method and path to inspect the original operation summary and its separate refusal. Use details for refused methods outside the read/write call schemas; those methods remain unavailable.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. `not_found` means the record does not exist; this API tells absence from a permission gap. It documents no conflict.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
3
+ "content": "## People, households and groups\n\nCCB v2 addresses records by integer ids. Resolve a person from the individual\nlist and a household from families; never guess ids. Family membership and\nchurch group membership join by individual id. A membership role belongs to\nthat group, rather than being a property of the person everywhere.\n\nIdentity Auth acts with the signed-in person's permissions. A permission\nrefusal can mean that person lacks access even when the connection is healthy.\n\n## Events and attendance\n\nAn event is a series; its dated occurrence is a separate identity. Attendance\njoins need both the event id and occurrence. Preserve the vendor's date and\ntime-zone meaning instead of treating every occurrence as UTC midnight.\n\n## Pastoral and financial records\n\nNotes, background checks, pledges and giving metrics can be sensitive. Read\nonly what the task needs and reduce those records inside execute_code before\nreturning an answer. Gift metrics are counts by period, with a range of at\nmost twelve months. Per-gift transactions and amounts belong to the legacy\nv1 XML API, which this v2 connector does not reach.\n\n## Vendor call rates and recovery\n\nCCB meters endpoints separately per API client. Individual profile reads have\nno burst; individual advanced searches allow one call per five seconds, and\ncategory schedule lists allow one per two seconds. Prefer list pages over a\nloop of individual gets. Other deployments sharing the client spend from the\nsame vendor allowance.\n\nFor a profile edit, resolve the individual first and preserve fields outside\nthe requested change. Use `ccb_api_write` for the edit; the host may ask the\nuser to confirm. After an ambiguous update, read that profile to determine\nwhat actually changed before deciding whether another write is needed.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `ccb_api_details`, `ccb_api_read`, `ccb_api_search`, `list_event_attendees`, `list_events`, `list_families`, `list_group_members`, `list_groups`, `list_individual_groups`, `list_individual_notes`, `list_individuals`, `search_records`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 25, at most 100). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `ccb_api_search` finds this API's operations and reads no account data, `ccb_api_details` shows one operation's contract, and `ccb_api_read` calls a reviewed read. Details' `response.fields` promises selection only; `withheld` hints are incomplete, and absence never means safe or readable. `ccb_api_write` calls a reviewed write; the host may ask the user to confirm it. An operation without a reviewed verdict is refused. 14 operations are refused by design; use `ccb_api_details` with the method and path to see each summary and refusal, even for methods outside the read/write schemas.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. `not_found` means the record does not exist; this API tells absence from a permission gap. It documents no conflict.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
4
4
  "instructionsHeading": "Church instructions"
5
5
  };
@@ -433,7 +433,7 @@ class CloudflareIndex extends OperationIndex {
433
433
  for (const name of EXCLUSIVE_PATH_IDS[`${match.op.method} ${match.op.path}`] ?? []) {
434
434
  if (Number(decodeURIComponent(match.params[name])) < 1) {
435
435
  throw new ConnectorCallError("invalid_args", `Not sent: Cloudflare path parameter ${name} must be at least 1. Read cloudflare_api_details for the pinned contract.`, {
436
- validation: { issues: [{ path: `/path/${name}`, code: "minimum", expected: "at least 1" }] },
436
+ validation: { issues: [{ path: `/path/${name}`, expected: "at least 1" }] },
437
437
  });
438
438
  }
439
439
  }
@@ -528,6 +528,13 @@ export const safety = () => {
528
528
  }
529
529
  : SAFETY.redact(body, operation.method, canonical(operation)),
530
530
  withholdsErrors: (operation) => operation.method === "POST" && operation.template === "/graphql" ? false : reviewed.withholdsErrors(operation),
531
+ // The table applies to the envelope and again to its result, as redact does.
532
+ withholds: (operation) => {
533
+ const hint = reviewed.withholds(operation);
534
+ return (path) => hint(path) ||
535
+ (path[0] === "result" && hint(path.slice(1))) ||
536
+ (path[0] === "result[]" && hint(["[]", ...path.slice(1)]));
537
+ },
531
538
  },
532
539
  sensitiveFields: { names: [], choice: "drop" },
533
540
  };
@@ -1,5 +1,5 @@
1
1
  // Generated by scripts/generate-providers.mjs; do not edit.
2
2
  export const skill = {
3
- "content": "## Accounts, domains and records\n\nResolve account and zone ids before acting. A zone is a domain, found by its name;\nits account id joins it to the account inventory. Cloudflare ids are normally\n32-character hex strings. A default id routes a request; a pin limits its scope.\n\nDNS TTL is in seconds; TTL 1 means automatic. Read the record before changing it.\nPATCH changes supplied fields; PUT replaces the record, so preserve its type,\nname and content. Read state before replacing, rolling back or purging. Prefer\na targeted cache purge by files, tags, hosts or prefixes over purging everything.\n\nHatch data keeps Cloudflare's vendor envelope. For example, select DNS record\nidentities with `fields: [\"result.id\", \"result.name\"]`; a list stays under\n`data.result`. Named account and zone lists keep projected rows with camelCase\naccount and name-server fields.\n\n## Workers, storage and analytics\n\nWorker script source is code; secret binding values are separate and unavailable.\nKV values are application data. Encode a slash inside a KV key or R2 object key\nas `%2F`. R2 requires API-token authentication, rather than a Global API Key.\n\nGraphQL Analytics uses accountTag and zoneTag for account/zone identities.\nFilter every accounts/zones field by equality or an explicit set of those ids.\nA pinned query uses one operation per document and every declared fragment.\nAnalytics Engine SQL is read-only; D1 SQL and Durable Object queries can write.\nAI inference, previews and outbound checks can cost money or activate work.\n\n## Disqualifiers and recovery\n\nCredential minting, rotation, stored secrets, private session captures and signed\nupload or deployment links are unavailable. Use the Cloudflare dashboard for\ncredential administration. A Global API Key cannot change user settings or\nmemberships through this connection.\n\nAfter an authentication failure, use `verify_credential` once to distinguish a\nrejected credential from a permission gap. An operator replaces the credential;\nrepeating the failed request does not add permissions. Cloudflare's global limit\ncounts dashboard traffic and all tokens together, so reduce requests as well as\nhonoring the returned wait.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `cloudflare_api_details`, `cloudflare_api_search`, `list_accounts`, `list_zones`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. For `cloudflare_api_read`, put it in `query[page.param]` or `body[page.param]`, as `page.in` specifies. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 20, at most 50). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Envelope fields:** hatch data keeps the vendor envelope. Select declared fields with `fields: [\"result.id\", \"result.name\"]`; array traversal also accepts `result[].id`. Named reads keep their projected fields.\n- **Hatch:** `cloudflare_api_search` finds this API's operations and reads no account data, `cloudflare_api_details` shows one operation's contract, and `cloudflare_api_read` calls a reviewed read. `cloudflare_api_write` calls a reviewed write; the host may ask the user to confirm it. An operation without a reviewed verdict is refused. 70 operations are refused by design; use `cloudflare_api_details` with the method and path to inspect the original operation summary and its separate refusal. Use details for refused methods outside the read/write call schemas; those methods remain unavailable.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. `not_found` means the record does not exist; this API tells absence from a permission gap. It documents no conflict.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
3
+ "content": "## Accounts, domains and records\n\nResolve account and zone ids before acting. A zone is a domain, found by its name;\nits account id joins it to the account inventory. Cloudflare ids are normally\n32-character hex strings. A default id routes a request; a pin limits its scope.\n\nDNS TTL is in seconds; TTL 1 means automatic. Read the record before changing it.\nPATCH changes supplied fields; PUT replaces the record, so preserve its type,\nname and content. Read state before replacing, rolling back or purging. Prefer\na targeted cache purge by files, tags, hosts or prefixes over purging everything.\n\nHatch data keeps Cloudflare's vendor envelope. For example, select DNS record\nidentities with `fields: [\"result.id\", \"result.name\"]`; a list stays under\n`data.result`. Named account and zone lists keep projected rows with camelCase\naccount and name-server fields.\n\n## Workers, storage and analytics\n\nWorker script source is code; secret binding values are separate and unavailable.\nKV values are application data. Encode a slash inside a KV key or R2 object key\nas `%2F`. R2 requires API-token authentication, rather than a Global API Key.\n\nGraphQL Analytics uses accountTag and zoneTag for account/zone identities.\nFilter every accounts/zones field by equality or an explicit set of those ids.\nA pinned query uses one operation per document and every declared fragment.\nAnalytics Engine SQL is read-only; D1 SQL and Durable Object queries can write.\nAI inference, previews and outbound checks can cost money or activate work.\n\n## Disqualifiers and recovery\n\nCredential minting, rotation, stored secrets, private session captures and signed\nupload or deployment links are unavailable. Use the Cloudflare dashboard for\ncredential administration. A Global API Key cannot change user settings or\nmemberships through this connection.\n\nAfter an authentication failure, use `verify_credential` once to distinguish a\nrejected credential from a permission gap. An operator replaces the credential;\nrepeating the failed request does not add permissions. Cloudflare's global limit\ncounts dashboard traffic and all tokens together, so reduce requests as well as\nhonoring the returned wait.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `cloudflare_api_details`, `cloudflare_api_search`, `list_accounts`, `list_zones`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. For `cloudflare_api_read`, put it in `query[page.param]` or `body[page.param]`, as `page.in` specifies. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 20, at most 50). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Envelope fields:** hatch data keeps the vendor envelope. Select declared fields with `fields: [\"result.id\", \"result.name\"]`; array traversal also accepts `result[].id`. Named reads keep their projected fields.\n- **Hatch:** `cloudflare_api_search` finds this API's operations and reads no account data, `cloudflare_api_details` shows one operation's contract, and `cloudflare_api_read` calls a reviewed read. Details' `response.fields` promises selection only; `withheld` hints are incomplete, and absence never means safe or readable. `cloudflare_api_write` calls a reviewed write; the host may ask the user to confirm it. An operation without a reviewed verdict is refused. 70 operations are refused by design; use `cloudflare_api_details` with the method and path to see each summary and refusal, even for methods outside the read/write schemas.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. `not_found` means the record does not exist; this API tells absence from a permission gap. It documents no conflict.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
4
4
  "instructionsHeading": "Account instructions"
5
5
  };