@zackbart/connecta 0.33.0 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +100 -128
  3. package/SECURITY.md +1 -1
  4. package/bin/version.mjs +1 -1
  5. package/dist/activity.d.ts +3 -0
  6. package/dist/branding.d.ts +7 -0
  7. package/dist/branding.js +76 -9
  8. package/dist/catalog-drift.d.ts +30 -26
  9. package/dist/catalog-drift.js +169 -84
  10. package/dist/catalog-service.js +16 -2
  11. package/dist/config-value-sources.js +6 -1
  12. package/dist/config.d.ts +13 -2
  13. package/dist/config.js +36 -2
  14. package/dist/connector-caller.js +2 -1
  15. package/dist/connectors/api-connector.d.ts +3 -0
  16. package/dist/connectors/api-connector.js +5 -1
  17. package/dist/connectors/option-shapes.js +1 -1
  18. package/dist/connectors/remote-mcp.d.ts +2 -0
  19. package/dist/connectors/remote-mcp.js +5 -1
  20. package/dist/describe-config.d.ts +5 -1
  21. package/dist/describe-config.js +2 -0
  22. package/dist/execute.js +1 -11
  23. package/dist/fix-prompt.js +2 -2
  24. package/dist/index.js +1 -0
  25. package/dist/invocation.js +3 -2
  26. package/dist/membership.d.ts +2 -0
  27. package/dist/meta-tools.js +3 -2
  28. package/dist/oauth-callback-outcome.js +2 -2
  29. package/dist/operator-ui/agents.d.ts +1 -1
  30. package/dist/operator-ui/agents.js +16 -16
  31. package/dist/operator-ui/config-summary.js +565 -0
  32. package/dist/operator-ui/connection-map.js +376 -0
  33. package/dist/operator-ui/endpoints.js +45 -16
  34. package/dist/operator-ui/fix-prompts.js +1 -1
  35. package/dist/operator-ui/generated.js +4 -4
  36. package/dist/operator-ui/health.js +52 -0
  37. package/dist/operator-ui/member-page.js +13 -28
  38. package/dist/operator-ui/overview.js +123 -0
  39. package/dist/operator-ui/view.d.ts +26 -22
  40. package/dist/operator-ui/view.js +130 -96
  41. package/dist/page-styles.js +5 -3
  42. package/dist/provider.d.ts +6 -1
  43. package/dist/provider.js +18 -1
  44. package/dist/providers/_shared/google/workspace.d.ts +9 -4
  45. package/dist/providers/_shared/google/workspace.js +7 -3
  46. package/dist/providers/basecamp/index.d.ts +2 -0
  47. package/dist/providers/breeze/index.d.ts +2 -0
  48. package/dist/providers/ccb/index.d.ts +2 -0
  49. package/dist/providers/ccb/index.js +2 -2
  50. package/dist/providers/cloudflare/index.d.ts +2 -0
  51. package/dist/providers/cloudflare/index.js +5 -1
  52. package/dist/providers/cloudflare/openapi.generated.js +3296 -3206
  53. package/dist/providers/cloudflare/rest.js +19 -0
  54. package/dist/providers/cloudflare/skill.generated.js +2 -2
  55. package/dist/providers/cloudflare/value-safety.js +51 -18
  56. package/dist/providers/docs/index.js +1 -1
  57. package/dist/providers/docs/skill.generated.js +2 -2
  58. package/dist/providers/drive/index.js +1 -1
  59. package/dist/providers/drive/skill.generated.js +2 -2
  60. package/dist/providers/forms/index.js +1 -1
  61. package/dist/providers/forms/skill.generated.js +2 -2
  62. package/dist/providers/gmail/index.js +1 -1
  63. package/dist/providers/gmail/skill.generated.js +2 -2
  64. package/dist/providers/intercom/index.d.ts +24 -0
  65. package/dist/providers/intercom/index.js +121 -0
  66. package/dist/providers/intercom/skill.generated.js +8 -0
  67. package/dist/providers/linear/index.js +204 -19
  68. package/dist/providers/linear/skill.generated.js +2 -2
  69. package/dist/providers/mixpanel/index.d.ts +2 -0
  70. package/dist/providers/mixpanel/index.js +21 -17
  71. package/dist/providers/mixpanel/skill.generated.js +3 -3
  72. package/dist/providers/notion/index.d.ts +2 -0
  73. package/dist/providers/notion/index.js +1 -1
  74. package/dist/providers/notion/skill.generated.js +1 -1
  75. package/dist/providers/overflow/index.d.ts +2 -0
  76. package/dist/providers/overflow/index.js +1 -1
  77. package/dist/providers/overflow/skill.generated.js +2 -2
  78. package/dist/providers/planning-center/index.d.ts +2 -0
  79. package/dist/providers/planning-center/index.js +1 -1
  80. package/dist/providers/planning-center/skill.generated.js +4 -4
  81. package/dist/providers/revenuecat/index.d.ts +5 -3
  82. package/dist/providers/revenuecat/index.js +147 -7
  83. package/dist/providers/revenuecat/skill.generated.js +3 -3
  84. package/dist/providers/sheets/index.js +1 -1
  85. package/dist/providers/sheets/skill.generated.js +2 -2
  86. package/dist/providers/slides/index.js +1 -1
  87. package/dist/providers/slides/skill.generated.js +2 -2
  88. package/dist/providers/stripe/index.d.ts +2 -0
  89. package/dist/providers/stripe/index.js +50 -30
  90. package/dist/providers/stripe/skill.generated.js +2 -1
  91. package/dist/providers/tithely/index.d.ts +2 -0
  92. package/dist/providers/tithely/index.js +1 -1
  93. package/dist/providers/vercel/index.d.ts +2 -0
  94. package/dist/providers/vercel/index.js +2 -2
  95. package/dist/providers/vercel/openapi.generated.js +391 -374
  96. package/dist/providers/vercel/skill.generated.js +2 -2
  97. package/dist/providers/vercel/value-safety.js +14 -4
  98. package/dist/registry.d.ts +10 -14
  99. package/dist/registry.js +77 -62
  100. package/dist/routes/mcp.js +20 -13
  101. package/dist/routes/oauth.js +2 -2
  102. package/dist/routes/ui-data.js +8 -2
  103. package/dist/skills.js +18 -2
  104. package/dist/storage/sql.js +5 -1
  105. package/dist/types.d.ts +36 -11
  106. package/dist/ui.js +9 -4
  107. package/dist/usage-guide.js +1 -1
  108. package/dist/version.d.ts +1 -1
  109. package/dist/version.js +1 -1
  110. package/documentation/README.md +2 -2
  111. package/documentation/architecture.md +66 -18
  112. package/documentation/auth.md +183 -67
  113. package/documentation/code-mode.md +30 -10
  114. package/documentation/deploying.md +5 -1
  115. package/documentation/integrating.md +64 -7
  116. package/documentation/meta-tools.md +38 -24
  117. package/documentation/migration-0.33.md +21 -13
  118. package/documentation/migration-0.34.md +67 -0
  119. package/documentation/operating.md +1 -1
  120. package/documentation/operator-tests.md +52 -20
  121. package/documentation/operator-ui.md +341 -104
  122. package/documentation/provider-auth-migration.md +22 -0
  123. package/examples/worker/AGENTS.md +15 -9
  124. package/examples/worker/README.md +41 -14
  125. package/examples/worker/src/connecta.config.ts +5 -4
  126. package/examples/worker/wrangler.jsonc +1 -1
  127. package/package.json +23 -19
  128. package/templates/node/AGENTS.md +13 -7
  129. package/templates/node/README.md +14 -11
  130. package/templates/node/package.json +1 -1
  131. package/templates/node/src/connecta.config.ts +2 -2
@@ -1,12 +1,11 @@
1
1
  import type { CatalogDriftCounts, CatalogDriftReport, Connector, Logger, ToolClassification, ToolDef } from "./types.js";
2
2
  /**
3
- * Rebuild a drift report as bounded counts and a canonical timestamp.
3
+ * Rebuild a drift report as bounded counts, closed verification facts, and a timestamp.
4
4
  *
5
5
  * `Connector.catalogDrift()` sits on the open plugin seam, and what it returns
6
- * lands in the body of unauthenticated `/health`, on connector status, in the
7
- * operator page, and in `connecta doctor`. TypeScript constrains neither an
8
- * extra enumerable property nor what `observedAt` holds at runtime, so "counts
9
- * and a time and nothing else" is *made* true here — at the boundary where
6
+ * lands on authenticated connector status and the operator page. TypeScript
7
+ * constrains neither extra enumerable properties nor `observedAt` at runtime.
8
+ * Counts, closed verification facts, and a time are rebuilt at the boundary where
10
9
  * third-party output becomes a response — rather than trusted. A report whose
11
10
  * `observedAt` names no instant is no observation connecta can date, and is
12
11
  * dropped whole (INV-6): a plugin forwarding a downstream's report could
@@ -29,11 +28,12 @@ interface VettedToolRecord {
29
28
  * Digest of the input and output schemas that release read, or undefined
30
29
  * when no release has recorded them. Undefined is not "unchanged": a
31
30
  * manifest with no digest cannot report a schema change, and says so by
32
- * counting none. The credential-free provider check does not create or
33
- * update schema digests; the live `tools/list` response remains the schema
31
+ * counting none and reporting the unpinned coverage. The provider check never
32
+ * creates or updates schema digests; live `tools/list` remains the schema
34
33
  * agents receive ([#351](https://github.com/zackbart/connecta/issues/351)).
35
34
  */
36
35
  schemaDigest?: string;
36
+ optional?: boolean;
37
37
  }
38
38
  /**
39
39
  * The vetted manifest one hosted-MCP proxy ships: every tool name a release
@@ -59,6 +59,8 @@ export interface VettedCatalogInput {
59
59
  * read them; an invented digest reports drift that never happened.
60
60
  */
61
61
  schemaDigests?: Readonly<Record<string, string>>;
62
+ /** Reviewed names that may be absent under plan/permission gating. */
63
+ optionalTools?: ReadonlySet<string>;
62
64
  }
63
65
  /**
64
66
  * Digest the schemas of one downstream tool.
@@ -67,13 +69,17 @@ export interface VettedCatalogInput {
67
69
  * `src/catalog-fingerprint.ts`: that one is deliberately conservative because a
68
70
  * spurious cache write is cheap, while a spurious drift finding spends a
69
71
  * maintainer's attention on a downstream that reordered its JSON keys.
72
+ * New pins use `sha256-sets-v1:` and sort required, enum, and type arrays only
73
+ * at schema keyword locations. Tuple arrays and JSON data keep their order.
74
+ * Legacy `sha256:` pins use the original literal-array form, so an upgrade
75
+ * cannot silently invalidate a reviewed provider manifest.
70
76
  * Description and annotations are excluded — a reworded description is P1's
71
77
  * business, and an annotation change is already its own drift category.
72
78
  *
73
79
  * Rejects rather than digesting part of a schema: a digest that ignored some
74
80
  * leaf would let a reviewed read keep its verdict after that leaf changed.
75
81
  */
76
- export declare function vettedSchemaDigest(tool: ToolDef): Promise<string>;
82
+ export declare function vettedSchemaDigest(tool: ToolDef, format?: "sets-v1" | "legacy"): Promise<string>;
77
83
  /**
78
84
  * Build a provider's manifest from the lists it already maintains.
79
85
  *
@@ -92,6 +98,11 @@ export declare function reviewedCatalog(classification: ToolClassification, owne
92
98
  * after review.
93
99
  */
94
100
  export declare function reviewedClassification(classification: ToolClassification, owner: string): ToolClassification;
101
+ /** One whole-schema verification shared by reporting and serving this listing. */
102
+ export interface CatalogSchemaVerification {
103
+ changed: ReadonlySet<string>;
104
+ available: boolean;
105
+ }
95
106
  /**
96
107
  * Compare a live catalog with the manifest and count what moved.
97
108
  *
@@ -110,25 +121,18 @@ export declare function catalogReviewOf(connector: Connector): VettedCatalog | u
110
121
  * Classify downstream facts with a review: what the registry serves on every
111
122
  * read, from whichever cache layer the facts came from.
112
123
  *
113
- * Every tool returned is a fresh object, so nothing a caller does to one can
114
- * reach the facts, `memo`, or a later read. `memo` holds digest verification
115
- * per facts array; the registry passes one only for deep-frozen arrays it
116
- * owns. A failed verification is not kept, so the next read tries again, and
117
- * meanwhile no digested review vouches for a read (INV-1).
124
+ * Every tool returned is fresh. The registry supplies the same whole-schema
125
+ * verification it observed for this listing, including failures. Standalone
126
+ * callers verify here. No failed verification survives onto a later listing,
127
+ * and meanwhile no digested review vouches for a read (INV-1).
118
128
  */
119
- export declare function classifyCatalog(catalog: VettedCatalog, connectorId: string, facts: readonly ToolDef[], logger: Logger, memo?: WeakMap<readonly ToolDef[], ReadonlySet<string>>, overrides?: Readonly<Record<string, "read" | "write">>): Promise<ToolDef[]>;
129
+ export declare function classifyCatalog(catalog: VettedCatalog, connectorId: string, facts: readonly ToolDef[], logger: Logger, overrides?: Readonly<Record<string, "read" | "write">>, verification?: CatalogSchemaVerification): Promise<ToolDef[]>;
120
130
  /**
121
- * Compare a listing a refresh just received with the connector's review, and
122
- * keep the counts as its latest observation.
123
- *
124
- * The check happens where the tools are already in hand, while serving a
125
- * refresh the deployment asked for. It adds no request of its own, which is
126
- * the whole boundary: connecta watches a contract while it is serving a
127
- * refresh, and never initiates one to go looking
128
- * ([#179](https://github.com/zackbart/connecta/issues/179),
129
- * [#343](https://github.com/zackbart/connecta/issues/343)).
131
+ * Observe tools already fetched by a listing. No probe, persistence, or catalog
132
+ * payload: registry-owned partition tokens isolate deployment and subject.
133
+ * A digest failure replaces old success, while classification fails closed.
130
134
  */
131
- export declare function observeReviewedDrift(connector: Connector, catalog: VettedCatalog, listed: readonly ToolDef[], logger: Logger): Promise<void>;
132
- /** The latest drift {@link observeReviewedDrift} kept for `connector`. */
133
- export declare function observedCatalogDrift(connector: Connector): CatalogDriftReport | undefined;
135
+ export declare function observeReviewedDrift(connector: Connector, catalog: VettedCatalog | undefined, listed: readonly ToolDef[], logger: Logger, partition?: object, droppedTools?: number): Promise<CatalogSchemaVerification | undefined>;
136
+ /** Look up only this runtime/catalog partition; never start a listing. */
137
+ export declare function observedCatalogDrift(connector: Connector, partition?: object): CatalogDriftReport | undefined;
134
138
  export {};
@@ -20,13 +20,12 @@ function observationTime(value) {
20
20
  return Number.isFinite(time) ? new Date(time).toISOString() : undefined;
21
21
  }
22
22
  /**
23
- * Rebuild a drift report as bounded counts and a canonical timestamp.
23
+ * Rebuild a drift report as bounded counts, closed verification facts, and a timestamp.
24
24
  *
25
25
  * `Connector.catalogDrift()` sits on the open plugin seam, and what it returns
26
- * lands in the body of unauthenticated `/health`, on connector status, in the
27
- * operator page, and in `connecta doctor`. TypeScript constrains neither an
28
- * extra enumerable property nor what `observedAt` holds at runtime, so "counts
29
- * and a time and nothing else" is *made* true here — at the boundary where
26
+ * lands on authenticated connector status and the operator page. TypeScript
27
+ * constrains neither extra enumerable properties nor `observedAt` at runtime.
28
+ * Counts, closed verification facts, and a time are rebuilt at the boundary where
30
29
  * third-party output becomes a response — rather than trusted. A report whose
31
30
  * `observedAt` names no instant is no observation connecta can date, and is
32
31
  * dropped whole (INV-6): a plugin forwarding a downstream's report could
@@ -41,6 +40,18 @@ export function boundedCatalogDrift(report) {
41
40
  return undefined;
42
41
  return {
43
42
  observedAt,
43
+ ...(typeof report.reviewedBaseline === "boolean" ? { reviewedBaseline: report.reviewedBaseline } : {}),
44
+ ...(report.verification === "complete" || report.verification === "unavailable"
45
+ ? { verification: report.verification }
46
+ : {}),
47
+ ...(report.schemaCoverage && typeof report.schemaCoverage === "object"
48
+ ? {
49
+ schemaCoverage: {
50
+ pinned: boundedCount(report.schemaCoverage.pinned),
51
+ unpinned: boundedCount(report.schemaCoverage.unpinned),
52
+ },
53
+ }
54
+ : {}),
44
55
  unclassifiedTools: boundedCount(report.unclassifiedTools),
45
56
  unservedTools: boundedCount(report.unservedTools),
46
57
  annotationConflicts: boundedCount(report.annotationConflicts),
@@ -66,59 +77,98 @@ function canonicalKeys(value) {
66
77
  const sorted = Object.fromEntries(entries);
67
78
  return Object.keys(sorted).map((key) => [key, sorted[key]]);
68
79
  }
69
- /**
70
- * Deterministic JSON with object keys sorted, so key order is not a schema
71
- * change. Iterative and complete: every leaf at every depth reaches the
72
- * digest, and a schema deeper than the host stack cannot exhaust it. Throws
73
- * past {@link MAX_SCHEMA_DIGEST_NODES}, and on any value JSON cannot carry.
74
- */
75
- function canonicalJson(root) {
80
+ const SCHEMA_MAPS = new Set([
81
+ "properties",
82
+ "patternProperties",
83
+ "$defs",
84
+ "definitions",
85
+ "dependentSchemas",
86
+ "dependencies",
87
+ ]);
88
+ const SCHEMA_ARRAYS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
89
+ const SCHEMA_CHILDREN = new Set([
90
+ "items",
91
+ "additionalItems",
92
+ "additionalProperties",
93
+ "unevaluatedProperties",
94
+ "unevaluatedItems",
95
+ "contains",
96
+ "propertyNames",
97
+ "not",
98
+ "if",
99
+ "then",
100
+ "else",
101
+ "contentSchema",
102
+ ]);
103
+ function childMode(mode, key, value) {
104
+ if (mode === "root" || mode === "map")
105
+ return "schema";
106
+ if (mode !== "schema")
107
+ return "data";
108
+ if (key === "required" || key === "enum" || key === "type")
109
+ return "set";
110
+ if (SCHEMA_MAPS.has(key))
111
+ return "map";
112
+ if (SCHEMA_ARRAYS.has(key) || (key === "items" && Array.isArray(value)))
113
+ return "schema-array";
114
+ return SCHEMA_CHILDREN.has(key) ? "schema" : "data";
115
+ }
116
+ function canonicalJson(root, sets) {
76
117
  const out = [];
77
- const stack = [{ value: root }];
118
+ const stack = [{ value: root, mode: sets ? "root" : "data", out }];
78
119
  let nodes = 0;
79
120
  while (stack.length > 0) {
80
121
  const task = stack.pop();
122
+ const out = task.out;
81
123
  if ("text" in task) {
82
124
  out.push(task.text);
83
125
  continue;
84
126
  }
85
- if (++nodes > MAX_SCHEMA_DIGEST_NODES) {
86
- throw new Error(`schema has more than ${MAX_SCHEMA_DIGEST_NODES} values; refusing to digest it in part.`);
127
+ if ("members" in task) {
128
+ out.push("[", task.members
129
+ .map((member) => member.join(""))
130
+ .sort()
131
+ .join(","), "]");
132
+ continue;
87
133
  }
88
- const value = task.value;
89
- if (value === null || value === undefined) {
134
+ if (++nodes > MAX_SCHEMA_DIGEST_NODES)
135
+ throw new Error(`schema has more than ${MAX_SCHEMA_DIGEST_NODES} values; refusing to digest it in part.`);
136
+ const { value, mode } = task;
137
+ if (value === null || value === undefined)
90
138
  out.push("null");
91
- }
92
- else if (typeof value === "string" || typeof value === "boolean") {
139
+ else if (typeof value === "string" || typeof value === "boolean")
93
140
  out.push(JSON.stringify(value));
94
- }
95
- else if (typeof value === "number") {
141
+ else if (typeof value === "number")
96
142
  out.push(Number.isFinite(value) ? JSON.stringify(value) : "null");
97
- }
98
- else if (typeof value === "function" || typeof value === "symbol") {
99
- // Only array items reach here; object keys holding these are dropped.
143
+ else if (typeof value === "function" || typeof value === "symbol")
100
144
  out.push("null");
101
- }
102
- else if (typeof value !== "object") {
145
+ else if (typeof value !== "object")
103
146
  throw new Error(`schema holds a ${typeof value}, which JSON cannot carry.`);
104
- }
105
147
  else if (Array.isArray(value)) {
106
- out.push("[");
107
- stack.push({ text: "]" });
108
- for (let index = value.length - 1; index >= 0; index -= 1) {
109
- stack.push({ value: value[index] });
110
- if (index > 0)
111
- stack.push({ text: "," });
148
+ if (mode === "set") {
149
+ const members = Array.from({ length: value.length }, () => []);
150
+ stack.push({ members, out });
151
+ for (let index = value.length - 1; index >= 0; index--)
152
+ stack.push({ value: value[index], mode: "data", out: members[index] });
153
+ }
154
+ else {
155
+ out.push("[");
156
+ stack.push({ text: "]", out });
157
+ for (let index = value.length - 1; index >= 0; index--) {
158
+ stack.push({ value: value[index], mode: mode === "schema-array" ? "schema" : "data", out });
159
+ if (index > 0)
160
+ stack.push({ text: ",", out });
161
+ }
112
162
  }
113
163
  }
114
164
  else {
115
165
  out.push("{");
116
- stack.push({ text: "}" });
166
+ stack.push({ text: "}", out });
117
167
  const entries = canonicalKeys(value);
118
- for (let index = entries.length - 1; index >= 0; index -= 1) {
168
+ for (let index = entries.length - 1; index >= 0; index--) {
119
169
  const [key, item] = entries[index];
120
- stack.push({ value: item });
121
- stack.push({ text: `${index > 0 ? "," : ""}${JSON.stringify(key)}:` });
170
+ stack.push({ value: item, mode: childMode(mode, key, item), out });
171
+ stack.push({ text: `${index > 0 ? "," : ""}${JSON.stringify(key)}:`, out });
122
172
  }
123
173
  }
124
174
  }
@@ -131,19 +181,23 @@ function canonicalJson(root) {
131
181
  * `src/catalog-fingerprint.ts`: that one is deliberately conservative because a
132
182
  * spurious cache write is cheap, while a spurious drift finding spends a
133
183
  * maintainer's attention on a downstream that reordered its JSON keys.
184
+ * New pins use `sha256-sets-v1:` and sort required, enum, and type arrays only
185
+ * at schema keyword locations. Tuple arrays and JSON data keep their order.
186
+ * Legacy `sha256:` pins use the original literal-array form, so an upgrade
187
+ * cannot silently invalidate a reviewed provider manifest.
134
188
  * Description and annotations are excluded — a reworded description is P1's
135
189
  * business, and an annotation change is already its own drift category.
136
190
  *
137
191
  * Rejects rather than digesting part of a schema: a digest that ignored some
138
192
  * leaf would let a reviewed read keep its verdict after that leaf changed.
139
193
  */
140
- export async function vettedSchemaDigest(tool) {
194
+ export async function vettedSchemaDigest(tool, format = "sets-v1") {
141
195
  const bytes = encoder.encode(canonicalJson({
142
196
  inputSchema: tool.inputSchema ?? null,
143
197
  outputSchema: tool.outputSchema ?? null,
144
- }));
198
+ }, format === "sets-v1"));
145
199
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
146
- return `sha256:${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
200
+ return `${format === "sets-v1" ? "sha256-sets-v1" : "sha256"}:${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
147
201
  }
148
202
  /**
149
203
  * Build a provider's manifest from the lists it already maintains.
@@ -160,6 +214,7 @@ export function vettedCatalog(input) {
160
214
  const digest = digestFor(name);
161
215
  tools.set(name, {
162
216
  verdict: "read-only",
217
+ ...(input.optionalTools?.has(name) ? { optional: true } : {}),
163
218
  ...(digest !== undefined ? { schemaDigest: digest } : {}),
164
219
  });
165
220
  }
@@ -170,6 +225,7 @@ export function vettedCatalog(input) {
170
225
  const digest = digestFor(name);
171
226
  tools.set(name, {
172
227
  verdict,
228
+ ...(input.optionalTools?.has(name) ? { optional: true } : {}),
173
229
  ...(digest !== undefined ? { schemaDigest: digest } : {}),
174
230
  });
175
231
  }
@@ -178,6 +234,10 @@ export function vettedCatalog(input) {
178
234
  throw new Error(`vettedCatalog() recorded a schema digest for unclassified tool "${name}".`);
179
235
  }
180
236
  }
237
+ for (const name of input.optionalTools ?? []) {
238
+ if (!tools.has(name))
239
+ throw new Error(`vettedCatalog() marked unclassified tool "${name}" optional.`);
240
+ }
181
241
  return { version: 1, tools };
182
242
  }
183
243
  const VERDICTS = {
@@ -190,8 +250,8 @@ const PUBLIC_VERDICTS = {
190
250
  additive: "write",
191
251
  destructive: "destructive",
192
252
  };
193
- const SCHEMA_DIGEST = /^sha256:[0-9a-f]{64}$/;
194
- const REVIEWED_TOOL_KEYS = new Set(["verdict", "reason", "schemaDigest"]);
253
+ const SCHEMA_DIGEST = /^sha256(?:-sets-v1)?:[0-9a-f]{64}$/;
254
+ const REVIEWED_TOOL_KEYS = new Set(["verdict", "reason", "schemaDigest", "optional"]);
195
255
  function isRecord(value) {
196
256
  return typeof value === "object" && value !== null && !Array.isArray(value);
197
257
  }
@@ -232,7 +292,7 @@ function parseClassification(classification, owner) {
232
292
  if (!REVIEWED_TOOL_KEYS.has(key))
233
293
  fail(`tool "${name}" has unknown key "${key}".`);
234
294
  }
235
- const { verdict: publicVerdict, reason, schemaDigest: digest } = record;
295
+ const { verdict: publicVerdict, reason, schemaDigest: digest, optional } = record;
236
296
  const verdict = typeof publicVerdict === "string" && Object.hasOwn(VERDICTS, publicVerdict) ? VERDICTS[publicVerdict] : undefined;
237
297
  if (verdict === undefined) {
238
298
  fail(`tool "${name}" needs verdict "read", "write", or "destructive".`);
@@ -241,10 +301,13 @@ function parseClassification(classification, owner) {
241
301
  fail(`tool "${name}" reason must be a non-empty string.`);
242
302
  }
243
303
  if (digest !== undefined && (typeof digest !== "string" || !SCHEMA_DIGEST.test(digest))) {
244
- fail(`tool "${name}" schemaDigest must be "sha256:" and 64 lowercase hex digits.`);
304
+ fail(`tool "${name}" schemaDigest must be "sha256:" or "sha256-sets-v1:" and 64 lowercase hex digits.`);
245
305
  }
306
+ if (optional !== undefined && typeof optional !== "boolean")
307
+ fail(`tool "${name}" optional must be a boolean.`);
246
308
  tools.set(name, {
247
309
  verdict,
310
+ ...(typeof optional === "boolean" ? { optional } : {}),
248
311
  ...(typeof digest === "string" ? { schemaDigest: digest } : {}),
249
312
  });
250
313
  frozen[name] =
@@ -253,6 +316,7 @@ function parseClassification(classification, owner) {
253
316
  : Object.freeze({
254
317
  verdict: PUBLIC_VERDICTS[verdict],
255
318
  ...(typeof reason === "string" ? { reason } : {}),
319
+ ...(typeof optional === "boolean" ? { optional } : {}),
256
320
  ...(typeof digest === "string" ? { schemaDigest: digest } : {}),
257
321
  });
258
322
  }
@@ -360,7 +424,8 @@ async function changedSchemas(catalog, tools) {
360
424
  const changed = new Set();
361
425
  for (const definition of tools) {
362
426
  const recorded = catalog.tools.get(definition.name)?.schemaDigest;
363
- if (recorded !== undefined && recorded !== (await vettedSchemaDigest(definition))) {
427
+ if (recorded !== undefined &&
428
+ recorded !== (await vettedSchemaDigest(definition, recorded.startsWith("sha256:") ? "legacy" : "sets-v1"))) {
364
429
  changed.add(definition.name);
365
430
  }
366
431
  }
@@ -387,8 +452,8 @@ function countDrift(catalog, tools, changed) {
387
452
  annotationConflicts += 1;
388
453
  }
389
454
  let unservedTools = 0;
390
- for (const name of catalog.tools.keys()) {
391
- if (!served.has(name))
455
+ for (const [name, record] of catalog.tools) {
456
+ if (!record.optional && !served.has(name))
392
457
  unservedTools += 1;
393
458
  }
394
459
  return {
@@ -432,18 +497,20 @@ export function catalogReviewOf(connector) {
432
497
  * Classify downstream facts with a review: what the registry serves on every
433
498
  * read, from whichever cache layer the facts came from.
434
499
  *
435
- * Every tool returned is a fresh object, so nothing a caller does to one can
436
- * reach the facts, `memo`, or a later read. `memo` holds digest verification
437
- * per facts array; the registry passes one only for deep-frozen arrays it
438
- * owns. A failed verification is not kept, so the next read tries again, and
439
- * meanwhile no digested review vouches for a read (INV-1).
500
+ * Every tool returned is fresh. The registry supplies the same whole-schema
501
+ * verification it observed for this listing, including failures. Standalone
502
+ * callers verify here. No failed verification survives onto a later listing,
503
+ * and meanwhile no digested review vouches for a read (INV-1).
440
504
  */
441
- export async function classifyCatalog(catalog, connectorId, facts, logger, memo, overrides) {
442
- let lapsed = memo?.get(facts);
505
+ export async function classifyCatalog(catalog, connectorId, facts, logger, overrides, verification) {
506
+ let lapsed = verification
507
+ ? verification.available
508
+ ? verification.changed
509
+ : digestedTools(catalog, facts)
510
+ : undefined;
443
511
  if (!lapsed) {
444
512
  try {
445
513
  lapsed = await changedSchemas(catalog, facts);
446
- memo?.set(facts, lapsed);
447
514
  }
448
515
  catch (error) {
449
516
  lapsed = digestedTools(catalog, facts);
@@ -455,39 +522,57 @@ export async function classifyCatalog(catalog, connectorId, facts, logger, memo,
455
522
  .filter((fact) => catalog.unlisted !== "hide" || catalog.tools.has(fact.name))
456
523
  .map((fact) => servedTool(catalog, fact, unverified, overrides?.[fact.name]));
457
524
  }
458
- /**
459
- * The drift each reviewed connector object showed on its last listing in this
460
- * runtime. Keyed by the connector rather than by registry, so a principal's
461
- * registry and the root report one observation, as they serve one connector.
462
- */
525
+ /** Counts only, retained weakly by connector and an explicit runtime/catalog partition. */
463
526
  const observations = new WeakMap();
464
527
  /**
465
- * Compare a listing a refresh just received with the connector's review, and
466
- * keep the counts as its latest observation.
467
- *
468
- * The check happens where the tools are already in hand, while serving a
469
- * refresh the deployment asked for. It adds no request of its own, which is
470
- * the whole boundary: connecta watches a contract while it is serving a
471
- * refresh, and never initiates one to go looking
472
- * ([#179](https://github.com/zackbart/connecta/issues/179),
473
- * [#343](https://github.com/zackbart/connecta/issues/343)).
528
+ * Observe tools already fetched by a listing. No probe, persistence, or catalog
529
+ * payload: registry-owned partition tokens isolate deployment and subject.
530
+ * A digest failure replaces old success, while classification fails closed.
474
531
  */
475
- export async function observeReviewedDrift(connector, catalog, listed, logger) {
476
- try {
477
- const changed = await changedSchemas(catalog, listed);
478
- observations.set(connector, {
479
- observedAt: new Date().toISOString(),
480
- ...countDrift(catalog, listed, changed),
481
- });
532
+ export async function observeReviewedDrift(connector, catalog, listed, logger, partition = connector, droppedTools = 0) {
533
+ let verification;
534
+ let report = {
535
+ observedAt: new Date().toISOString(),
536
+ reviewedBaseline: catalog !== undefined,
537
+ unclassifiedTools: 0,
538
+ unservedTools: 0,
539
+ annotationConflicts: 0,
540
+ schemaChanges: 0,
541
+ ...(droppedTools > 0 ? { droppedTools } : {}),
542
+ };
543
+ if (catalog) {
544
+ const reviewed = listed.filter((tool) => catalog.tools.has(tool.name));
545
+ const pinned = digestedTools(catalog, reviewed).size;
546
+ const schemaCoverage = { pinned, unpinned: reviewed.length - pinned };
547
+ try {
548
+ verification = { changed: await changedSchemas(catalog, listed), available: true };
549
+ report = {
550
+ ...report,
551
+ ...countDrift(catalog, listed, verification.changed),
552
+ schemaCoverage,
553
+ verification: "complete",
554
+ };
555
+ }
556
+ catch (error) {
557
+ verification = { changed: new Set(), available: false };
558
+ report = {
559
+ ...report,
560
+ ...countDrift(catalog, listed, verification.changed),
561
+ schemaCoverage,
562
+ verification: "unavailable",
563
+ };
564
+ logFailure(logger, "catalog drift check failed", failureRecord({ connector: connector.id }, error));
565
+ }
482
566
  }
483
- catch (error) {
484
- // A drift check is a report about a catalog, never a condition for
485
- // serving one. Keep the last good observation rather than replacing it
486
- // with a lie, and let the refresh through; classification fails closed.
487
- logFailure(logger, "catalog drift check failed", failureRecord({ connector: connector.id }, error));
567
+ let partitions = observations.get(connector);
568
+ if (!partitions) {
569
+ partitions = new WeakMap();
570
+ observations.set(connector, partitions);
488
571
  }
572
+ partitions.set(partition, report);
573
+ return verification;
489
574
  }
490
- /** The latest drift {@link observeReviewedDrift} kept for `connector`. */
491
- export function observedCatalogDrift(connector) {
492
- return observations.get(connector);
575
+ /** Look up only this runtime/catalog partition; never start a listing. */
576
+ export function observedCatalogDrift(connector, partition = connector) {
577
+ return observations.get(connector)?.get(partition);
493
578
  }
@@ -465,7 +465,7 @@ export class CatalogService {
465
465
  // that asked for it. On a read-only endpoint a write is absent from search
466
466
  // and describe, as if the connector never had it. Guides are not filtered.
467
467
  discoveryCatalogs(ids, route) {
468
- return Effect.forEach(ids, (id) => Effect.result(withDeadlineEffect((signal) => this.readOnly
468
+ return Effect.forEach(ids, (id) => Effect.result(withDeadlineEffect((signal) => this.readOnly || this.registry.getConnector(id)?.readOnly === true
469
469
  ? Effect.map(this.catalog(id, { signal }), (tools) => tools.filter((tool) => tool.classification === "read"))
470
470
  : this.catalog(id, { signal }), {
471
471
  ...(this.requestSignal ? { signal: this.requestSignal } : {}),
@@ -582,7 +582,7 @@ export class CatalogService {
582
582
  }
583
583
  return withAuthorizationHandoff(this.requestScope, error);
584
584
  }
585
- async search(args) {
585
+ async search(args, onCatalogReference) {
586
586
  if (args.query !== undefined && typeof args.query !== "string") {
587
587
  throw new DiscoveryPolicyError("invalid_args", "query must be a string. Omit it or use an empty string to browse the catalog.");
588
588
  }
@@ -915,6 +915,20 @@ export class CatalogService {
915
915
  : "No single tool matched every term in the catalogs that answered. Split distinct intents into separate searches.";
916
916
  };
917
917
  const guidance = searchGuidance();
918
+ // Counts, ranking, and term analysis use the whole searched corpus, not
919
+ // just this page's tools. Bind every searched connector, including failed
920
+ // catalogs and off-page matches, so paging cannot reveal a hidden
921
+ // connector's contribution. Keep provenance host-only.
922
+ for (const connector of connectors)
923
+ onCatalogReference?.(connector.id);
924
+ for (const entry of entries)
925
+ onCatalogReference?.(entry.connector.id, entry.tool.name);
926
+ for (const connector of absence?.configuredConnectors ?? [])
927
+ onCatalogReference?.(connector);
928
+ // An unknown scope's guidance lists the visible roster without searching it.
929
+ if (reportsQueryAnalysis && args.connector && !scopedConnector)
930
+ for (const connector of visibleConnectors)
931
+ onCatalogReference?.(connector.id);
918
932
  return {
919
933
  catalogErrors,
920
934
  ...(absence ? { absence } : {}),
@@ -74,7 +74,12 @@ export function describeConfigSources(snapshot, raw) {
74
74
  ? provided(raw.pools?.[key]?.readOnly ?? raw.readOnly)
75
75
  : field === "owner"
76
76
  ? provided(raw.pools?.[key]?.owner)
77
- : provided(raw.pools);
77
+ : field === "description"
78
+ ? provided(raw.pools?.[key]?.description)
79
+ : provided(raw.pools);
80
+ else if (group === "modules" && key === "activity" && field === "store" && parts[3] !== "kind")
81
+ // A store option such as retentionDays is set only when the store says so.
82
+ source = factorySource(factorySources(raw.activity?.store), parts.slice(3)) ?? "default";
78
83
  else if (group === "modules") {
79
84
  source = provided(key === "ui" ? raw.ui : key === "vault" ? raw.vault : key === "activity" ? raw.activity : raw.accessTokens);
80
85
  }
package/dist/config.d.ts CHANGED
@@ -181,10 +181,19 @@ declare const pool: {
181
181
  * The Google Workspace address agents in this pool act as, on a Workspace
182
182
  * connector whose `subject` is a mapping function. Humans always act as
183
183
  * themselves through that mapping, and a fixed `subject` address applies to
184
- * everyone. Without an owner, an agent's Workspace calls fail
185
- * `auth_required`.
184
+ * everyone. Without an owner, a mapping-subject Workspace connector is
185
+ * absent for this pool's agents, as a personal connector is; people in the
186
+ * pool still see it.
186
187
  */
187
188
  owner: import("./config-schema.js").Field<string, string | undefined, true>;
189
+ /**
190
+ * What this pool is for, in plain words for the model: appended to the MCP
191
+ * `initialize` instructions at `/mcp/<name>` and `/agent/<name>`, so a
192
+ * caller learns its scope ("Team tier: shared tools only."). Trimmed, 1 to
193
+ * 500 characters, no line breaks or control characters. It grants and hides nothing; plain
194
+ * `/mcp` never shows it.
195
+ */
196
+ description: import("./config-schema.js").Field<string, string | undefined, true>;
188
197
  };
189
198
  declare const connectaConfig: {
190
199
  connectors: import("./config-schema.js").Field<Connector[], readonly Connector[], false>;
@@ -210,6 +219,7 @@ declare const connectaConfig: {
210
219
  pools: import("./config-schema.js").Field<Record<string, {
211
220
  readOnly?: boolean | undefined;
212
221
  owner?: string | undefined;
222
+ description?: string | undefined;
213
223
  tools: readonly string[];
214
224
  members: readonly MemberRef[];
215
225
  }>, Readonly<Record<string, {
@@ -217,6 +227,7 @@ declare const connectaConfig: {
217
227
  readonly tools: readonly string[];
218
228
  readonly members: readonly MemberRef[];
219
229
  readonly owner: string | undefined;
230
+ readonly description: string | undefined;
220
231
  }>> | undefined, true>;
221
232
  /** Exact connector id -> tool name -> verdict. Unknown names fail at catalog publication. */
222
233
  classification: import("./config-schema.js").Field<Record<string, Record<string, "read" | "write">>, Readonly<Record<string, Readonly<Record<string, "read" | "write">>>> | undefined, true>;