@zackbart/connecta 0.34.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 (78) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +3 -2
  3. package/bin/version.mjs +1 -1
  4. package/dist/catalog-drift.d.ts +30 -26
  5. package/dist/catalog-drift.js +169 -84
  6. package/dist/catalog-service.js +1 -1
  7. package/dist/connectors/api-connector.d.ts +3 -0
  8. package/dist/connectors/api-connector.js +5 -1
  9. package/dist/connectors/option-shapes.js +1 -1
  10. package/dist/connectors/remote-mcp.d.ts +2 -0
  11. package/dist/connectors/remote-mcp.js +5 -1
  12. package/dist/describe-config.d.ts +2 -0
  13. package/dist/describe-config.js +1 -0
  14. package/dist/invocation.js +3 -2
  15. package/dist/meta-tools.js +3 -2
  16. package/dist/operator-ui/config-summary.js +3 -0
  17. package/dist/operator-ui/connection-map.js +2 -1
  18. package/dist/operator-ui/endpoints.js +1 -1
  19. package/dist/operator-ui/generated.js +3 -3
  20. package/dist/operator-ui/health.js +52 -0
  21. package/dist/operator-ui/overview.js +6 -17
  22. package/dist/operator-ui/view.d.ts +3 -4
  23. package/dist/operator-ui/view.js +29 -11
  24. package/dist/provider.d.ts +6 -1
  25. package/dist/provider.js +18 -1
  26. package/dist/providers/_shared/google/workspace.d.ts +3 -0
  27. package/dist/providers/_shared/google/workspace.js +1 -1
  28. package/dist/providers/basecamp/index.d.ts +2 -0
  29. package/dist/providers/breeze/index.d.ts +2 -0
  30. package/dist/providers/ccb/index.d.ts +2 -0
  31. package/dist/providers/ccb/index.js +1 -1
  32. package/dist/providers/cloudflare/index.d.ts +2 -0
  33. package/dist/providers/cloudflare/index.js +5 -1
  34. package/dist/providers/cloudflare/openapi.generated.js +3296 -3206
  35. package/dist/providers/cloudflare/rest.js +19 -0
  36. package/dist/providers/cloudflare/skill.generated.js +2 -2
  37. package/dist/providers/cloudflare/value-safety.js +51 -18
  38. package/dist/providers/intercom/index.d.ts +24 -0
  39. package/dist/providers/intercom/index.js +121 -0
  40. package/dist/providers/intercom/skill.generated.js +8 -0
  41. package/dist/providers/linear/index.js +204 -19
  42. package/dist/providers/linear/skill.generated.js +2 -2
  43. package/dist/providers/mixpanel/index.d.ts +2 -0
  44. package/dist/providers/mixpanel/index.js +21 -17
  45. package/dist/providers/mixpanel/skill.generated.js +1 -1
  46. package/dist/providers/notion/index.d.ts +2 -0
  47. package/dist/providers/notion/index.js +1 -1
  48. package/dist/providers/overflow/index.d.ts +2 -0
  49. package/dist/providers/planning-center/index.d.ts +2 -0
  50. package/dist/providers/revenuecat/index.d.ts +5 -3
  51. package/dist/providers/revenuecat/index.js +147 -7
  52. package/dist/providers/revenuecat/skill.generated.js +3 -3
  53. package/dist/providers/stripe/index.d.ts +2 -0
  54. package/dist/providers/stripe/index.js +50 -30
  55. package/dist/providers/stripe/skill.generated.js +2 -1
  56. package/dist/providers/tithely/index.d.ts +2 -0
  57. package/dist/providers/vercel/index.d.ts +2 -0
  58. package/dist/providers/vercel/index.js +1 -1
  59. package/dist/providers/vercel/openapi.generated.js +391 -374
  60. package/dist/providers/vercel/skill.generated.js +1 -1
  61. package/dist/providers/vercel/value-safety.js +14 -4
  62. package/dist/registry.d.ts +10 -14
  63. package/dist/registry.js +62 -62
  64. package/dist/routes/ui-data.js +8 -2
  65. package/dist/skills.js +8 -2
  66. package/dist/types.d.ts +18 -4
  67. package/dist/version.d.ts +1 -1
  68. package/dist/version.js +1 -1
  69. package/documentation/architecture.md +48 -5
  70. package/documentation/code-mode.md +7 -5
  71. package/documentation/integrating.md +55 -1
  72. package/documentation/meta-tools.md +2 -2
  73. package/documentation/operating.md +1 -1
  74. package/documentation/operator-tests.md +8 -2
  75. package/documentation/operator-ui.md +58 -11
  76. package/documentation/provider-auth-migration.md +22 -0
  77. package/package.json +5 -1
  78. package/templates/node/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.34.1 — 2026-10-19
6
+
7
+ Connecta 0.34.1 is a patch release with a security fix. The Cloudflare provider passed stored AI Search crawler headers through unredacted, including on the routes 0.34.0 already served, so any caller allowed to read those resources could see header values that may carry credentials. 0.34.1 withholds them, along with conditional firewall match credentials, Vercel daemon probe headers, setup-form defaults and payment client secrets, and refuses Cloudforce One match responses that have no published schema before any request is sent. Deployments that use the Cloudflare or Vercel providers should upgrade.
8
+
9
+ Connectors can now set `readOnly: true` (#895). A read-only connector serves only reviewed reads on every endpoint, maintainers' plain `/mcp` included; write and unclassified tools are hidden from discovery and refused on every call path. It combines with endpoint and pool `readOnly`, and the default is unchanged. The Stripe guide now shows both setups side by side: OAuth to Stripe's hosted MCP, or a secret or restricted key through the REST connector, with or without `readOnly`. A new maintained Intercom provider supports personal OAuth or a shared bearer token in the US or EU region; its schemas are not pinned yet because Intercom's catalog requires authentication to list.
10
+
11
+ Catalog drift no longer counts as connector health (#867): a drifted connector reads Connected and stays out of Needs attention, while drift details remain on the connector page and in Activity. Connector cards, counts and the attention list keep their last verdict and position while details reload, and the top bar is more compact. Drift detection itself is corrected (#884): reordered schema arrays no longer report false changes for schemas pinned in the new format (existing pins keep their original hashing until re-reviewed), plan-gated tools can be marked optional, schema coverage is reported honestly, failed fingerprints mark verification unavailable instead of keeping a clean report, and per-person connectors keep separate observations. The Linear, Mixpanel, RevenueCat and Stripe reviews are reconciled with their live hosted catalogs and pin schemas; Mixpanel's Get-Events, Get-Dashboard, List-Cohorts and Get-Metric serve as reads again without deployment overrides, so a `classification: { mixpanel: { "Get-Events": "read" } }` override can be removed. Upgrading from 0.34.0 needs no configuration, storage migration or token rotation.
12
+
13
+ ### Added
14
+
15
+ - Add a maintained Intercom hosted MCP provider with explicit US/EU region selection, personal OAuth or shared bearer-token auth, reviewed tool classifications, and guidance for sensitive support data and optional Fin Operator actions. Permission-gated Fin tools are optional for missing-tool drift while present tools retain classification checks. Schema coverage reports present reviewed tools as unpinned. Schema digests remain unpinned because anonymous catalog discovery requires authentication.
16
+ - Add optional connector-level `readOnly` to every connector and maintained provider, enforcing read-only discovery and calls on every endpoint. Show the policy in the operator UI and clarify Stripe OAuth and API-key setup.
17
+
18
+ ### Fixed
19
+
20
+ - Correct catalog drift reporting with schema coverage and verification states,
21
+ identity-partitioned observations, optional gated tools, and versioned schema
22
+ set canonicalization that preserves existing pins. Detect documented tool
23
+ access changes and stale unclassified acknowledgements in provider checks.
24
+ Operator refresh responses include the catalog comparison from that refresh,
25
+ so hashing failures and changed schemas cannot return an earlier clean report.
26
+ Provider pin assertions select the stored digest format, and a generated-roster
27
+ guard verifies every pin against captured schemas and rejects input/output drift.
28
+ - Reconcile the maintained Linear catalog with hosted label lifecycle, inbox, saved view, triage, and diff tools. Preserve plan-gated customer requests, review the documented customer-needs read, and pin the observed hosted schemas so later changes fail closed.
29
+ - Refresh reviewed hosted Mixpanel schemas so Get-Events, Get-Dashboard, List-Cohorts, and Get-Metric remain available to read-only endpoints without deployment overrides. Review the limited-beta Get-Audit-Log as a read, retain Fill-Event-Metadata as a write, and classify Get-Experiment as destructive because its optional computations can persist results and auto-conclude experiments.
30
+ - Keep catalog drift informational, keep each connector's last verdict on every operator view while its details are read again so cards, counts and the attention list stop bouncing, keep attention rows in configuration order as their verdicts settle, safely hold prototype-named connector IDs, keep card and detail headers and Overview attention text on single lines with accessible ellipsis, including long connector ids, auth scopes and Read-only badges, reserve the card action footer and empty tool bar row, and compact the top bar for long deployment names and all page tabs.
31
+ - Refresh Vercel and Cloudflare REST contracts and value-safety reviews, withhold conditional firewall match credentials, stored AI Search crawler headers and daemon probe headers, setup-form defaults, and payment client secrets, refuse schemaless Cloudforce match responses before dispatch, admit two stateless Cloudflare POST reads, classify hosted Cloudflare documentation search as read-only, and reconcile Vercel's complete hosted tool inventory.
32
+ - Review RevenueCat subscription-group listing, audience preview, and paywall screenshot rendering as reads, pin four live schemas, and clarify permission-gated catalogs and conservative credential-validation routing.
33
+ - Refresh Stripe hosted MCP tool reviews for money movement, recipient setup, Atlas formation, and renamed feedback; remove retired reviews, pin captured schemas for drift detection, and document account, mode, and human-confirmation requirements.
34
+
5
35
  ## 0.34.0 — 2026-10-16
6
36
 
7
37
  Connecta 0.34.0 rebuilds the operator UI around an Overview with connector health, endpoints, people and agents, a Needs attention list with fix prompts, and a connection map (#867). Maintainers now land on Overview at `/`; Endpoints moves to `/endpoints`, and `/access` redirects there. Update bookmarks, links and browser automation that expected Endpoints at `/`. Members still get their member page on every operator page path. The upgrade steps are in `documentation/migration-0.34.md`.
package/README.md CHANGED
@@ -29,7 +29,7 @@ only the answer comes back.
29
29
 
30
30
  ## What you can connect
31
31
 
32
- Maintained connections: <!-- providers:start -->Basecamp, Breeze ChMS, Church Community Builder, Cloudflare, GitHub, Gmail, Google Docs, Google Drive, Google Forms, Google Sheets, Google Slides, Infisical, Linear, Mixpanel, Notion, Overflow, Planning Center, RevenueCat, Stripe, Tithe.ly, Vercel, and Wispr Flow<!-- providers:end -->.
32
+ Maintained connections: <!-- providers:start -->Basecamp, Breeze ChMS, Church Community Builder, Cloudflare, GitHub, Gmail, Google Docs, Google Drive, Google Forms, Google Sheets, Google Slides, Infisical, Intercom, Linear, Mixpanel, Notion, Overflow, Planning Center, RevenueCat, Stripe, Tithe.ly, Vercel, and Wispr Flow<!-- providers:end -->.
33
33
  Plus any remote MCP server or HTTP API you describe.
34
34
 
35
35
  ## Who can use it
@@ -104,7 +104,8 @@ has the full layout.
104
104
  ## Reads and writes
105
105
 
106
106
  Every endpoint is writable or read-only, and you choose explicitly. A
107
- read-only endpoint hides write tools and refuses every write. On a writable
107
+ read-only endpoint hides write tools and refuses every write. You can also make
108
+ one connection read-only across all endpoints. On a writable
108
109
  one, your agent's host decides what to approve.
109
110
 
110
111
  ## Where it runs
package/bin/version.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated from package.json by scripts/generate-version.mjs.
2
2
  // Workers-safe: no runtime filesystem read or deployment override.
3
- export const CONNECTA_VERSION = "0.34.0";
3
+ export const CONNECTA_VERSION = "0.34.1";
@@ -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 } : {}),
@@ -112,6 +112,8 @@ export interface ApiOptions {
112
112
  * construct.
113
113
  */
114
114
  maxResultBytes?: number;
115
+ /** Serve only read-classified tools on every endpoint. Defaults to false. */
116
+ readOnly?: boolean;
115
117
  /** Optional per-runtime downstream call-admission policy. */
116
118
  callAdmission?: ConnectorCallAdmissionPolicy;
117
119
  /**
@@ -146,6 +148,7 @@ export interface ApiOptions {
146
148
  /** The closed options api() accepts; see `assertKnownOptions`. */
147
149
  export declare const API_OPTIONS: import("../config-schema.js").Field<unknown, unknown, true> & {
148
150
  readonly shape: {
151
+ readOnly: import("../config-schema.js").Field<boolean, boolean | undefined, true>;
149
152
  callAdmission: Omit<import("../config-schema.js").Field<unknown, ConnectorCallAdmissionPolicy | undefined, true>, "parse"> & {
150
153
  parse(value: unknown, path: string): ConnectorCallAdmissionPolicy;
151
154
  readonly shape: import("../config-schema.js").Shape & {
@@ -3,7 +3,7 @@ import { attachFailureFacts, carryFailureFacts, errorLabel } from "../operator-r
3
3
  import { compileValidator, validateCatalogToolInput } from "../validate.js";
4
4
  import { array, assertKnownOptions, instance, keys, optionsOf, strings } from "../config-schema.js";
5
5
  import { describedEndpoint, describedOrigin, describedTools } from "../described.js";
6
- import { CALL_ADMISSION, CREDENTIAL, USAGE_GUIDE } from "./option-shapes.js";
6
+ import { READ_ONLY, CALL_ADMISSION, CREDENTIAL, USAGE_GUIDE } from "./option-shapes.js";
7
7
  import { assertStaticToolNames } from "../tool-name.js";
8
8
  import { registerInvocationAuth } from "../invocation-auth.js";
9
9
  import { redactCatalog, redactSentSecrets, sentSecretsFetch, trackCredentialReads } from "../sent-secrets.js";
@@ -41,6 +41,7 @@ function checkToolContract(id, tool) {
41
41
  /** The closed options api() accepts; see `assertKnownOptions`. */
42
42
  export const API_OPTIONS = optionsOf()({
43
43
  ...keys("title", "description", "authScope", "maxResultBytes", "testCredential", "testCredentials", "validateArgs"),
44
+ readOnly: READ_ONLY,
44
45
  callAdmission: CALL_ADMISSION,
45
46
  usageGuide: USAGE_GUIDE,
46
47
  credential: CREDENTIAL,
@@ -90,6 +91,8 @@ function describedApiAuth(opts) {
90
91
  */
91
92
  export function apiConnector(id, opts, oauth) {
92
93
  opts = assertKnownOptions(opts, `api(${JSON.stringify(id)})`, API_OPTIONS);
94
+ if (opts.readOnly !== undefined)
95
+ READ_ONLY.parse(opts.readOnly, `Connector(${JSON.stringify(id)}).readOnly`);
93
96
  if (opts.callAdmission !== undefined)
94
97
  CALL_ADMISSION.parse(opts.callAdmission, `api(${JSON.stringify(id)}).callAdmission`);
95
98
  assertStaticToolNames(opts.tools, `api(${JSON.stringify(id)}).tools`);
@@ -124,6 +127,7 @@ export function apiConnector(id, opts, oauth) {
124
127
  ...defined({
125
128
  description: opts.description,
126
129
  authScope: opts.authScope,
130
+ readOnly: opts.readOnly,
127
131
  maxResultBytes: opts.maxResultBytes,
128
132
  callAdmission: opts.callAdmission,
129
133
  usageGuide: opts.usageGuide,