@zackbart/connecta 0.24.1 → 0.24.3

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 (65) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/auth/bearer.js +2 -0
  3. package/dist/auth/clerk.d.ts +0 -5
  4. package/dist/auth/clerk.js +21 -8
  5. package/dist/auth/downstream-oauth.d.ts +12 -1
  6. package/dist/auth/downstream-oauth.js +147 -35
  7. package/dist/call-admission.d.ts +4 -0
  8. package/dist/call-admission.js +26 -0
  9. package/dist/catalog-drift.js +9 -4
  10. package/dist/catalog-service.d.ts +2 -0
  11. package/dist/catalog-service.js +25 -8
  12. package/dist/catalog.d.ts +2 -0
  13. package/dist/catalog.js +246 -121
  14. package/dist/connector-access.d.ts +32 -0
  15. package/dist/connector-access.js +79 -0
  16. package/dist/connectors/api.js +11 -1
  17. package/dist/connectors/guarded-fetch.d.ts +1 -1
  18. package/dist/connectors/guarded-fetch.js +27 -20
  19. package/dist/connectors/remote-mcp.js +84 -53
  20. package/dist/errors.d.ts +17 -0
  21. package/dist/errors.js +58 -0
  22. package/dist/execute.js +85 -23
  23. package/dist/executor-result.js +3 -1
  24. package/dist/executors/quickjs-child.js +5 -1
  25. package/dist/executors/quickjs-protocol.d.ts +4 -0
  26. package/dist/executors/quickjs-runtime.d.ts +1 -1
  27. package/dist/executors/quickjs-runtime.js +38 -21
  28. package/dist/executors/quickjs.js +68 -27
  29. package/dist/index.d.ts +37 -1
  30. package/dist/index.js +89 -3
  31. package/dist/invocation.js +134 -93
  32. package/dist/mcp-result.js +3 -2
  33. package/dist/meta-tools.js +118 -39
  34. package/dist/registry.d.ts +29 -1
  35. package/dist/registry.js +122 -15
  36. package/dist/routes/credentials.js +1 -0
  37. package/dist/routes/mcp.d.ts +4 -1
  38. package/dist/routes/mcp.js +112 -12
  39. package/dist/routes/oauth-management.js +1 -0
  40. package/dist/routes/oauth.js +4 -0
  41. package/dist/routes/shared.d.ts +7 -1
  42. package/dist/routes/shared.js +12 -13
  43. package/dist/routes/ui.js +2 -1
  44. package/dist/server.js +15 -3
  45. package/dist/skills.js +6 -5
  46. package/dist/storage/file.d.ts +6 -2
  47. package/dist/storage/file.js +312 -34
  48. package/dist/storage/memory.js +12 -1
  49. package/dist/validate.js +3 -3
  50. package/dist/version.d.ts +1 -1
  51. package/dist/version.js +1 -1
  52. package/documentation/architecture.md +30 -9
  53. package/documentation/auth.md +110 -6
  54. package/documentation/call-admission.md +24 -8
  55. package/documentation/code-mode.md +34 -22
  56. package/documentation/connectors.md +47 -5
  57. package/documentation/meta-tools.md +74 -6
  58. package/documentation/operations.md +20 -19
  59. package/documentation/provider-conventions.md +7 -0
  60. package/documentation/request-admission.md +38 -4
  61. package/documentation/storage-and-credentials.md +54 -1
  62. package/documentation/upgrading.md +21 -5
  63. package/ethos.md +1 -1
  64. package/package.json +1 -1
  65. package/templates/node/package.json +1 -1
@@ -6,6 +6,16 @@ Cloudflare Access from `/auth/cloudflare-access`. Providers may be combined;
6
6
  static bearers are checked first, then other providers in configuration order.
7
7
  Connecta no longer issues `cta_` tokens or serves token-management routes.
8
8
 
9
+ The bearer adapter challenges with `WWW-Authenticate: Bearer` and deliberately
10
+ omits `resource_metadata`. Its credential is configured out of band; it has no
11
+ OAuth authorization server or registration endpoint to advertise. Interactive
12
+ adapters or the edge own OAuth discovery. Every open deployment with at least
13
+ one connector warns at construction, including API connectors with static auth
14
+ headers. Credential and OAuth connectors add explicit wording about those grants.
15
+
16
+ MCP browser origins pass the [Origin check](./request-admission.md#origin-before-admission)
17
+ before admission or auth. This is independent of an identity's tool grants.
18
+
9
19
  ## Principals, visibility, and operators
10
20
 
11
21
  The actor identifies the caller in activity. The subject owns transient results
@@ -13,10 +23,95 @@ such as `get_result` pages. The principal is the human owner of personal
13
23
  connector auth. An interactive Clerk or Access user supplies all three. A
14
24
  Cloudflare service identity has an actor and subject but no principal.
15
25
 
16
- `identity.connectorAccess` returns `"all"` or declared connector ids. It governs
17
- discovery and use, and defaults to all connectors. Visibility alone grants no
18
- authentication-management permission. Two independent resolvers return
19
- `"all"`, `"none"`, or declared connector ids:
26
+ A subject or user id always selects a result-stash partition, even when the
27
+ provider omits `activityActorNamespace`. An explicit namespace remains the
28
+ partition namespace; without one, Connecta uses `connecta:auth:<provider kind>`.
29
+ Subject ids must be distinct within that namespace. This fallback grants no
30
+ personal-auth ownership and changes no activity attribution. Open deployments
31
+ and providers that return no identity share one result partition. A provider
32
+ that supplies only an explicit principal uses that principal as its subject.
33
+
34
+ `identity.connectorAccess` returns `"all"` or a list of grants. A grant is a
35
+ declared connector id, which opens every tool on it, or a `connector.tool`
36
+ address, which opens that tool alone. Grants are additive, so a bare id beside
37
+ addresses for the same connector means the whole connector. It governs
38
+ discovery and use, and defaults to all connectors.
39
+
40
+ Tool grants are enforced in the scoped registry view, below the catalog
41
+ service. Since 0.24.2, `search_tools`, `describe_tools`, both call tools, a
42
+ program's `connecta.search`, `connecta.describe`, and `connecta.call`, and the
43
+ connection UI read that filtered tool list. Connector-level discovery, guides,
44
+ and `authorize_connector` retain a connector when any tool on it is granted.
45
+ In particular, a `docs.read` grant permits the `docs` authorization handoff,
46
+ subject to the separate auth-management permissions below. Without any grant
47
+ on `docs`, `authorize_connector` returns the same "Unknown connector" refusal
48
+ as an absent connector. An ungranted tool fails exactly like one the connector
49
+ never had: `unknown_tool`, with no hint that it exists. That is the whole security
50
+ claim, and it lives in one place on purpose. There is no separate endpoint per
51
+ tool set; an identity that should see a narrower slice is a branch in this
52
+ resolver, and a bot that needs its own slice is its own bearer subject.
53
+
54
+ ## Pools
55
+
56
+ A pool is a named slice of the deployment served at its own endpoint,
57
+ `/mcp/<pool>`, for the case where one identity needs different capability
58
+ sets on different clients: a support agent that sees three Notion tools and
59
+ Linear, a calendar bot that sees one tool, both over the same credentials and
60
+ catalog cache.
61
+
62
+ ```ts
63
+ createConnecta({
64
+ pools: {
65
+ support: {
66
+ tools: ["linear", "notion.search_pages", "notion.fetch_page"],
67
+ grant: ({ principal }) => supportTeam.has(principal?.id ?? ""),
68
+ },
69
+ calendar_bot: {
70
+ tools: ["calendar.create_event"],
71
+ grant: ({ actor }) => actor.id === "calendar-bot",
72
+ },
73
+ },
74
+ identity: { connectorAccess },
75
+ connectors,
76
+ executor,
77
+ });
78
+ ```
79
+
80
+ The rules, each of which is a test:
81
+
82
+ - **A pool narrows; it never widens.** The view on `/mcp/<pool>` is the pool
83
+ intersected with the identity's own `connectorAccess`. Plain `/mcp` is
84
+ unchanged. The security boundary is still the resolver; the pool decides
85
+ which part of it a given client sees.
86
+ - **Grant defaults to deny.** A pool with no `grant` serves nobody. Only a
87
+ literal `true` admits; any other return, a throw, and an undeclared pool
88
+ name produce one 404 identical in status, body, and headers, so a
89
+ credential does not enumerate the other pools by response content. Timing
90
+ is explicitly not hidden: a declared name awaits its grant, while an
91
+ undeclared name returns without that lookup. We accept this pool-name
92
+ oracle because names grant no access, a fixed delay cannot hide unbounded
93
+ grant I/O, and invoking grants for unknown names would add avoidable work
94
+ while holding an admission permit. Keep grants pure and fast; do not treat
95
+ pool names as secrets. The operator log carries the refusal reason.
96
+ - **Structural mistakes throw at construction.** A malformed name, an
97
+ unknown connector, an empty pool, and a `connector.tool` address an
98
+ `api()` connector's static catalog lacks all refuse to boot. Remote
99
+ catalogs load lazily, so their addresses are checked at load and stay
100
+ unreachable until they match.
101
+ - **OAuth discovery follows the path.** On Clerk, the 401 challenge for
102
+ `/mcp/<pool>` names `/.well-known/oauth-protected-resource/mcp/<pool>`,
103
+ whose `resource` is the pool URL, so RFC 9728 clients see a match.
104
+ Cloudflare Managed OAuth is application-level and needs nothing.
105
+
106
+ A `connector.tool` address the live catalog does not contain is unreachable
107
+ and warned once while its address remains in a 1,024-entry FIFO. An evicted
108
+ address may warn again; caller-derived grant text cannot grow retained warning
109
+ state without bound. Remote catalogs load lazily, so construction
110
+ cannot check it, and a catalog that drifts later can never widen a grant
111
+ because there is no wildcard: every tool grant is an exact name.
112
+
113
+ Visibility alone grants no authentication-management permission. Two
114
+ independent resolvers return `"all"`, `"none"`, or declared connector ids:
20
115
 
21
116
  - `credentialAdministration` allows an interactive human to manage shared
22
117
  credentials and shared OAuth grants.
@@ -39,8 +134,12 @@ administrator role or token-management authority.
39
134
  createConnecta({
40
135
  auth: cloudflareAccessAuth(),
41
136
  identity: {
42
- connectorAccess: ({ principal }) =>
43
- principal?.id === "owner-id" ? "all" : ["shared_docs", "personal_linear"],
137
+ connectorAccess: ({ principal, actor }) =>
138
+ principal?.id === "owner-id"
139
+ ? "all"
140
+ : actor.id === "calendar-bot"
141
+ ? ["calendar.create_event"]
142
+ : ["shared_docs", "personal_linear", "notion.search_pages"],
44
143
  credentialAdministration: ({ principal }) =>
45
144
  principal?.id === "owner-id" ? "all" : "none",
46
145
  personalConnection: () => ["personal_linear"],
@@ -160,6 +259,11 @@ secret-free handoff to the connection UI. Without the UI, that recovery is
160
259
  interactive MCP caller can still start downstream OAuth through
161
260
  `authorize_connector` without the UI. Core owns the callback and verifies state
162
261
  and principal ownership independently of the optional browser application.
262
+ A browser returning from downstream consent normally carries no MCP
263
+ Authorization header, so an interactive bearer provider's 401 does not reject
264
+ the callback. The verified state and its saved principal handoff select the
265
+ owner; a browser identity, when present, must match that owner and may manage
266
+ the connector. An interactive provider's explicit 403 still refuses the flow.
163
267
 
164
268
  See [meta-tools](./meta-tools.md#authorization-recovery) and
165
269
  [storage and credentials](./storage-and-credentials.md). The
@@ -103,15 +103,28 @@ checks, and authorization operations stay outside the budget: they are not the
103
103
  calls a provider is rate-limiting, and charging discovery for them would make
104
104
  a program's first search cost it capacity to act.
105
105
 
106
- Queue admission and connector execution have separate clocks. `queueTimeoutMs`
107
- bounds only the wait for a permit; a per-attempt `timeoutMs` starts after
108
- admission. A saturated call can therefore take up to their sum. With
106
+ `queueTimeoutMs` bounds only the wait for a permit. The per-call `timeoutMs`
107
+ is one deadline over catalog resolution, the permit wait, and the connector
108
+ call together, so a saturated call can never take longer than `timeoutMs`;
109
+ the queue timeout may only end it sooner. With
109
110
  `diagnostics: true`, `admissionMs` reports the permit wait and `connectorMs`
110
111
  the admitted attempt — which is the only way to tell "the provider is slow"
111
112
  from "we are throttling ourselves".
112
113
 
113
114
  ## Enforcement scope
114
115
 
116
+ Shared connectors use the root registry's controller. Personal connectors use
117
+ the owning principal's registry, so two accounts each receive their own budget;
118
+ all requests and call paths for one principal still share it. `close()` rejects
119
+ queued and future admissions on both kinds of controller, including a personal
120
+ registry constructed after shutdown.
121
+
122
+ The principal cache is bounded at 1,024 registries. Eviction closes an idle
123
+ registry so an older request view cannot use an orphaned controller. A registry
124
+ with active calls, queued calls, or unexpired rolling entries is never evicted.
125
+ If all 1,024 are occupied, a new principal view fails closed with HTTP 403 until
126
+ one drains. Cache churn cannot reset a live account's quota.
127
+
115
128
  This is deliberately **per-runtime**. It completely contains fan-out inside one
116
129
  request, including parallel `connecta.call` calls in one Worker isolate. A rolling
117
130
  budget is exact inside one Node process or Worker isolate, and best-effort
@@ -124,13 +137,16 @@ the bound actually is rather than implying a global one.
124
137
 
125
138
  ## Observations
126
139
 
127
- `/health` exposes payload-free aggregates at
128
- `admission.downstreamCalls.connectors.<id>`: retained partition count, current
140
+ `/health` exposes one payload-free aggregate at
141
+ `admission.downstreamCalls.aggregate`, summed across root and retained personal
142
+ controllers: rule and retained partition counts, current
129
143
  active and queued gauges, cumulative admitted/queued/rejected/rate-limited/
130
144
  cancelled counts, and queue-wait count, total, and maximum. The open endpoint
131
- never exposes partition keys, tool arguments, or results — a partition key can
145
+ never exposes connector ids, principal ids, partition keys, tool arguments, or results — a partition key can
132
146
  be a customer identifier, which is precisely why it stays out of an unauthenticated
133
- payload. Ordinary payload-free activity records the final call outcome and its
147
+ payload. Queue-wait maxima take the maximum, other counters sum, and `closed`
148
+ means every included controller is closed. Evicted idle controllers no longer
149
+ contribute to these runtime snapshots. Ordinary payload-free activity records the final call outcome and its
134
150
  typed error code.
135
151
 
136
152
  ## Tests that enforce this
@@ -138,5 +154,5 @@ typed error code.
138
154
  | Invariant | Suite |
139
155
  | --- | --- |
140
156
  | Independent partitions, exact rolling-window reset and retry, queued cancellation charging no budget, synchronous cancel during partition derivation, validated values snapshotted rather than read from mutable config, bounded partition state and contained `partitionKey` failures, empty and multi-rule policies refused | `test/call-admission.test.ts` (controller) |
141
- | One base-registry limiter shared by direct and program calls, promise concurrency with input order preserved, cancellation threading, no dispatch or retry or health poisoning after cancellation, retry hints returned without waiting or poisoning health, payload-free `/health` aggregates | `test/call-admission.test.ts` (integration, Node + Workers) |
157
+ | Shared and personal limiters shared by direct and program calls, independent principal budgets, shutdown covering both, eviction preserving live budgets, promise concurrency with input order preserved, cancellation threading, no dispatch or retry or health poisoning after cancellation, retry hints returned without waiting or poisoning health, payload-free `/health` aggregates | `test/call-admission.test.ts` (integration, Node + Workers) |
142
158
  | Where provider budgets are allowed to come from at all | [provider conventions P12](./provider-conventions.md#p12--declare-an-admission-budget-only-when-the-provider-documents-a-number), [provider audit](https://github.com/zackbart/connecta/blob/main/records/provider-audit.md) |
@@ -180,11 +180,11 @@ const page = await connecta.search({
180
180
  });
181
181
  ```
182
182
 
183
- **S1.** Returns one flat page: `{ tools, total, offset, limit, hasMore }`, plus `nextOffset` when more remains and `matchMode: "partial"` when no tool matched every term. Top-level `search_tools` is different: it returns `{ connectors: [{ id, tools }], total, offset, limit, hasMore }`. Complete matches normally precede partial matches, but a partial candidate whose complete normalized tool name occurs in the normalized raw query competes by score; conversational cleanup applies only to scoring terms. Other candidates covering at least two terms fill the page after every complete match; when no complete match exists, the existing any-term fallback remains. Each entry in `tools` carries `address`, `name`, the configured `connectorTitle` when present (normalized whitespace, at most 120 UTF-8 bytes), and — when requested — `description`, `inputSchema`, `outputSchema`, `annotations`, and the connector's `guide`. An output shape learned under `S9` also carries `outputSchemaSource: "observed"`; provider declarations carry no source marker. Tool rows expose neither lexical scores nor per-result coverage. An empty or whitespace-only query browses. Non-empty input with no ASCII lexical terms returns no tools and bounded no-match analysis; mixed input searches with its ASCII terms. Compact shapes omit property prose, put required fields first, and cap each shape at 1,024 UTF-8 bytes. Each enum node gets 256 of those bytes. About three near-cap enum nodes can therefore coexist while leaving the final quarter for surrounding syntax; the unchanged global fallback still applies above 1,024 bytes. A capped enum preserves whole values before `unknown` and an exact omitted-value count, while an empty enum renders as `never`. Either cap carries `inputSchemaTruncated` or `outputSchemaTruncated`; a shape-wide cap remains structurally valid with `unknown` types plus `/* truncated */`. Small enums remain complete. Use `connecta.describe` (or JSON search) for omitted exact constraints.
183
+ **S1.** Returns one flat page: `{ tools, total, offset, limit, hasMore }`, plus `nextOffset` when more remains and `matchMode: "partial"` when no tool matched every term. Top-level `search_tools` is different: it returns `{ connectors: [{ id, tools }], total, offset, limit, hasMore }`. Complete matches normally precede partial matches, but a partial candidate whose complete normalized tool name occurs in the normalized raw query competes by score; conversational cleanup applies only to scoring terms. Other candidates covering at least two terms fill the page after every complete match; when no complete match exists, the existing any-term fallback remains. Each entry in `tools` carries `address`, `name`, the configured `connectorTitle` when present (normalized whitespace, at most 120 UTF-8 bytes), and — when requested — `description`, `inputSchema`, `outputSchema`, `annotations`, and the connector's `guide`. An output shape learned under `S9` also carries `outputSchemaSource: "observed"`; provider declarations carry no source marker. Tool rows expose neither lexical scores nor per-result coverage. An empty or whitespace-only query browses. Non-empty input with no ASCII lexical terms returns no tools and bounded no-match analysis; mixed input searches with its ASCII terms. Compact shapes omit property prose, put required fields first, and cap each shape at 1,024 UTF-8 bytes. Each render spends at most 2,000 visits across schema nodes, property and required names, literal values, any constraint-free retry, and its key-only fallback. Resolved `$ref` text is reused within that walk; exhausted work yields `unknown /* truncated */` and the corresponding truncation flag. Each enum node gets 256 of those bytes. About three near-cap enum nodes can therefore coexist while leaving the final quarter for surrounding syntax; the unchanged global fallback still applies above 1,024 bytes. A capped enum preserves whole values before `unknown` and an exact omitted-value count, while an empty enum renders as `never`. Either cap carries `inputSchemaTruncated` or `outputSchemaTruncated`; a shape-wide cap remains structurally valid with `unknown` types plus `/* truncated */`. Small enums remain complete. `prefixItems` renders as a tuple with the declared `items` rest, an `unknown[]` rest when open, or no rest when `items` is false. `dependentSchemas` and `if`/`then`/`else` keep the base shape plus `/* conditional */` and set the truncation flag. `$dynamicRef` resolves a same-named definition like `$ref`; unresolved dynamic references become `unknown` with the truncation flag. These forms share the byte and work bounds above. Use `connecta.describe({ address, format: "json" })` or JSON search for omitted exact constraints.
184
184
 
185
185
  **S1a.** `connector` loads only the named catalog; omit it only when the integration is ambiguous, because an unscoped search fans out across every configured connector. `safety: "readOnly"` returns exactly the tools available through `connecta.call`; `"approvalRequired"` returns the complementary fail-closed class, including false, missing, and contradictory annotations. Omitted or `"all"` preserves the complete catalog. These filters grant no authority and change no admission decision.
186
186
 
187
- **S2.** A requested object schema carries `inputKeys`, `requiredInputKeys`, and `outputKeys`: the same names the rendered schema shows, ready to check before building arguments. Match inputs, truncation, safety, and outputs, not lexical
187
+ **S2.** A requested object schema carries `inputKeys`, `requiredInputKeys`, and `outputKeys`: the same names the rendered schema shows, ready to check before building arguments. `requiredInputKeys` includes only declared properties. Match inputs, truncation, safety, and outputs, not lexical
188
188
  rank; search distinct operations separately and use `outputKeys`, not guessed roots. A non-object schema — a union, an array, an
189
189
  unresolvable `$ref` — carries no lists rather than empty ones, because absent
190
190
  means "read the schema" where `[]` would claim the tool takes no fields. The
@@ -199,9 +199,8 @@ carries the same metadata whenever schemas are requested. Code-mode callers
199
199
  can set `includeSchemaKeys: false` to buy the bytes back.
200
200
 
201
201
  **S3.** Discovery is bounded and the bounds throw rather than silently shrink: a
202
- `limit` outside 1–100 is `invalid_args`, and a page whose serialized form
203
- exceeds 256,000 bytes is `result_too_large`, each with a hint naming the ways to
204
- ask for less. The thrown error carries the stable `code`, `retryable`, and
202
+ `limit` outside 1–100, an `offset` that is not a non-negative integer, or a supplied `query` that is not a string is `invalid_args`. Omitted `offset` starts at 0; omitted `query` browses. A page whose serialized form
203
+ exceeds 256,000 bytes is `result_too_large`; a program's page is measured as its serialized value, while top-level `search_tools` measures the complete tool result including both copies and JSON escaping. Each error carries a hint for correcting the request. The thrown error carries the stable `code`, `retryable`, and
205
204
  `details` fields (`E1`).
206
205
 
207
206
  ### connecta.describe
@@ -222,7 +221,7 @@ carry a route-aware `nextAction`; a close miss may add three canonical `suggesti
222
221
  Catalog failures add only `retryAfterMs` when known. One bad address never fails the whole call. Each failed entry clamps its
223
222
  caller-authored `address` to 512 UTF-8 bytes with an `…` marker. Entry order
224
223
  correlates a clipped address with its request; successes keep canonical addresses. More than 100
225
- addresses is `invalid_args`; the same 256,000-byte ceiling applies. A success whose output shape came from `S9` carries `outputSchemaSource: "observed"` beside the rendered schema.
224
+ addresses is `invalid_args`; the same 256,000-byte ceiling applies. Compact describe keeps property prose within a separate 8,192-byte UTF-8 shape cap and shares search's 2,000-visit work budget. A capped shape sets `inputSchemaTruncated` or `outputSchemaTruncated`; use `format: "json"` for the exact schema. A success whose output shape came from `S9` carries `outputSchemaSource: "observed"` beside the rendered schema.
226
225
 
227
226
  ### connecta.call
228
227
 
@@ -286,8 +285,9 @@ clauses are [Emitted output](#emitted-output) (`M1`–`M10`).
286
285
  | Program or execution failure (`E5`, `E6`, a bridge bound in `L6`) | error text | no |
287
286
 
288
287
  Both executor bridges reduce a rejected host call to `new Error(message)`. Connecta restores the typed failure in a trusted prelude with a per-execution authenticated frame (`X11`), without turning the rejection into a returned value.
289
- `message` remains the human text. `code` and `retryable` are the stable branch fields; `details` is the complete host classification. This covers `call`, `search`, `describe`, `emit`, and the host-call budget.
288
+ `message` remains human text, capped at 2,000 JSON-serialized characters including quotes and an `…` marker when clipped. `code` and `retryable` are the stable branch fields; `details` carries the host classification. The complete details object fits 3,700 serialized characters. If optional recovery metadata would exceed that bound, it is omitted whole, preserving `code`, `message`, `retryable`, and `retryAfterMs`; a clipped recovery address or argument would describe a different call. This covers `call`, `search`, `describe`, `emit`, and the host-call budget.
290
289
  Program-authored errors stay untyped, and code must never parse error prose.
290
+ An `unavailable` classification may include optional `details.host` as an HTTP(S) origin of at most 253 UTF-8 bytes and `details.code` as a validated network errno, undici transport code, or `timeout` of at most 32 bytes; these diagnostics never enter activity records.
291
291
 
292
292
  **E2.** The taxonomy: `retryable` is what connecta reports, `Y3` what a program may do.
293
293
 
@@ -301,7 +301,7 @@ Program-authored errors stay untyped, and code must never parse error prose.
301
301
  | `not_found` | the downstream answered and the resource is not there — the one code that says skip this id rather than stop, raised only where the provider tells absence from a permission gap ([H11](./provider-conventions.md#h11--errors-are-mapped-to-what-the-caller-does-next)) | false |
302
302
  | `input_required_unsupported` | a downstream asked for mid-call input | false |
303
303
  | `rate_limited` | the downstream reported a rate limit | true |
304
- | `unavailable` | the downstream is down or unreachable | true |
304
+ | `unavailable` | the downstream is down or unreachable; optional sanitized `details.host` and `details.code` describe the transport failure without paths, queries, credentials, or provider prose | true |
305
305
  | `timeout` | the per-call deadline (`execute.hostCallTimeoutMs`, default 15 s) expired | true |
306
306
  | `cancelled` | the run ended while this call was in flight (`E5`) | false |
307
307
  | `connector_call_failed` | anything else the connector threw | per message |
@@ -330,16 +330,16 @@ guest: admission rejection (`executor_overloaded`, retryable, with
330
330
  reported to the model as an error result. One seam: a host call still in flight
331
331
  when the run is cancelled fails with `cancelled`, catchable on the way out but
332
332
  never worth acting on (`Y3`). When shutdown tears down a program that had
333
- already started, accepted blocks are reported as discarded under `M4`; a failure before execution started carries no discard fields.
333
+ already started, accepted blocks are reported as discarded under `M4`; a failure before execution started carries no discard fields. A returned `error` field is a failure even when empty; an empty string reports `executor_failed` with `Error: Execution failed without an error message.`
334
334
 
335
335
  **E6.** An error the program raises itself — a `TypeError`, a call to a
336
336
  `connecta` member that is not a provider function (including an inherited one
337
337
  like `toString`), a `throw` of its own — ends the run with an error result
338
338
  carrying that message. It is not typed, because it is not a connector failure.
339
339
  One precedence rule: connecta recognizes an escaped tool failure by its message —
340
- exactly first, by containment second — so a program that *wraps* a failure's
340
+ exactly first, by containment second for messages of at least eight characters — so a program that *wraps* a failure's
341
341
  message in its own text still reports the underlying typed failure. Keeping the
342
- type beats keeping the prose.
342
+ type beats keeping the prose. Matching retains only the most recent 64 failures per execution, including caught refusals; an older escaped message remains an untyped execution failure. An empty terminal error uses the fixed message in `E5`.
343
343
 
344
344
  **E7.** `retryable` for `unknown_address`, `unknown_tool`, and `destructive_tool_requires_approval` is pinned false, never inferred from an address containing `503`, `429`, or `temporar`. The first two carry `nextAction: { function: "connecta.search", arguments: { query, connector?, includeSchemas: "compact" } }` — the same scoped discovery the top-level record names, keyed to the surface the caller actually has. A program cannot call `search_tools`, so it is never told to. The message, the derived `query`, and a failed describe entry's `address` clamp caller-authored text to 512 UTF-8 bytes with an `…` marker. Those values land in the text content and `structuredContent`, so an invented 50 KB address would otherwise produce a refusal orders of magnitude past the deployment's result cap. A clipped address still identifies the mistake by its position; a short one — the common case — is exact and untagged.
345
345
 
@@ -383,7 +383,7 @@ anything, so paging its result would reward the one behavior code mode exists to
383
383
  remove — and stashing every unprojected return value would spend the result store
384
384
  on data nobody asked for.
385
385
 
386
- **R5.** `console.log`, `console.warn`, and `console.error` are captured in call order and returned as a single `logs` string, capped at 4,000 characters with a truncation marker. Logs survive failure they ride along with the error result, which is what makes them worth writing. How a non-string argument renders is not contract (`X4`).
386
+ **R5.** `console.log`, `console.warn`, and `console.error` are captured in call order and returned as a single `logs` string, capped at 4,000 characters with a truncation marker. Logs survive program failure through either a returned error result or a thrown error carrying `logs: string[]`. QuickJS streams captured entries to its parent and preserves the received prefix on cancellation, shutdown, deadline termination, child crashes, and IPC failures (`X4`). How a non-string argument renders is not contract (`X4`).
387
387
 
388
388
  **R6.** Nothing else is added to a normal program result. Passing `diagnostics: true` adds one request-local, payload-free `diagnostics` block; a program that emitted adds `emitted: N` and its blocks (`M2`). Omitted, `false`, and emit-free are byte-for-byte the ordinary response path. Diagnostics exist so catalog, connector, and executor costs are distinguishable without persisting payloads or charging normal responses ([#247](https://github.com/zackbart/connecta/issues/247)).
389
389
 
@@ -433,8 +433,8 @@ media, not base64 text.
433
433
  trusted exactly as much as the return value. Preservation is re-emission of
434
434
  the raw downstream block, so `S5`'s uncapped fallthrough is contract.
435
435
 
436
- **M7.** `emit` spends no host-call budget (`L4`); `M5`'s bounds are its only
437
- bounds.
436
+ **M7.** `emit` alone spends no host-call budget (`L4`); `search`, `describe`,
437
+ and `call` share it. `M5`'s bounds are emission's only bounds.
438
438
 
439
439
  **M8.** Emission asks nothing of an executor: `emit` is a provider function,
440
440
  blocks cross the guest boundary once as an argument, and `ExecuteResult` is
@@ -491,7 +491,7 @@ because connecta enforces them above the sandbox:
491
491
 
492
492
  | Bound | Value |
493
493
  | --- | --- |
494
- | Host calls per execution | 20 |
494
+ | Host calls per execution, shared by `search`, `describe`, and `call` | 20 by default, `execute.maxHostCalls` |
495
495
  | Deadline per host call | 15 s, `execute.hostCallTimeoutMs` |
496
496
  | Discovery page | ≤ 100 tools, ≤ 256,000 serialized bytes |
497
497
  | `describe` addresses | ≤ 100 |
@@ -500,7 +500,7 @@ because connecta enforces them above the sandbox:
500
500
  | Result | 24,000 serialized characters |
501
501
  | Logs presented to the model | 4,000 characters |
502
502
 
503
- Exhausting the host-call budget fails that call with non-retryable `budget_exceeded` (`E2`) and a message naming the budget. No connector is reached, and the budget does not refill inside one execution.
503
+ Every `call` attempt spends one host call on entry, before address resolution, catalog lookup, safety checks, validation, or dispatch. Unknown addresses, unknown tools, catalog failures, and other pre-dispatch refusals spend the same budget as successful calls; catching a refusal does not refund it. `search` and `describe` likewise spend on entry. Exhausting the host-call budget fails that call with non-retryable `budget_exceeded` (`E2`) and a message naming the budget. No connector is reached, and the budget does not refill inside one execution.
504
504
 
505
505
  **L5.** The guest is memory-, stack-, and CPU-bounded, and a program that
506
506
  exhausts a bound ends the run with an error instead of degrading the host. The
@@ -588,7 +588,14 @@ arguments and captures `log`, `info`, `warn`, `error`, and `debug`; the Dynamic
588
588
  Worker renders arguments with `String()` (so an object logs as
589
589
  `[object Object]`) and captures only `log`, `warn`, and `error`, prefixing the
590
590
  latter two. Only the three captured everywhere are contract (`R5`); rendering is
591
- not.
591
+ not. QuickJS streams each accepted entry within the existing IPC envelope
592
+ bound while retaining its per-entry and cumulative child caps. The parent
593
+ keeps at most 4,001 joined characters for failure recovery, one beyond the
594
+ presentation cap so truncation stays visible. On a normal reply, its complete
595
+ log array takes precedence over that prefix; the two copies are never joined.
596
+ On termination or IPC failure, the parent attaches its retained prefix to the
597
+ thrown error. Admission rejection before the program starts has no guest logs
598
+ to recover.
592
599
 
593
600
  **X5. Leftover authority.** QuickJS blocks imports and has no `fetch`, `process`, timers, `crypto`, or `WebSocket`. Its Node child starts with an explicitly empty process environment rather than inheriting deployment variables or `NODE_OPTIONS`. A Dynamic Worker has those globals plus a non-contract set of runtime builtins through `import()` and `process.getBuiltinModule()`, including `node:path`, `node:crypto`, `node:net`, `node:tls`, `node:dns`, `node:module`, and `cloudflare:workers`. The upstream set can drift; this list is not an allowlist.
594
601
  The supported Worker construction is exactly `new DynamicWorkerExecutor({ loader })`. Do not pass `bindings`, `modules`, or `globalOutbound`: each can grant ambient configuration, code, or egress. Under it, `process.env`, lexical `this.env`, and `cloudflare:workers.env` are empty; `node:fs`, `node:http`, and `node:https` are unavailable through either access route; external `fetch`, `WebSocket`, `node:net`, and `node:tls` fail with workerd's outbound-denial error; DNS lookup ends unresolved; and `fetch("data:...")` resolves locally.
@@ -625,7 +632,12 @@ from one tool call therefore fails on Node and may succeed on Workers — reduce
625
632
  inside the program either way (`R1`).
626
633
 
627
634
  **X11. Typed host rejection.** Both executors rebuild Connecta's authenticated host-failure frame as a thrown guest `Error` (`E1`). The per-run secret stays in the trusted prelude closure, and the prelude locks `globalThis.Error`, so guest code and connector prose cannot forge the host transport frame.
628
- The human message is unchanged; a mismatched frame is ordinary untyped prose.
635
+ The host bounds details before framing (`E1`), including JSON escapes. QuickJS
636
+ refuses an oversized authenticated frame whole rather than slicing through its
637
+ JSON, and the prelude hides an authenticated frame whose JSON is malformed.
638
+ QuickJS keeps the raw bridge and its JSON decoder in a private closure so guest
639
+ code cannot intercept the frame before the prelude handles it. A mismatched
640
+ frame is ordinary untyped prose.
629
641
 
630
642
  ## Changes from earlier code mode
631
643
 
@@ -659,7 +671,7 @@ the upstream `Executor` shape assignable.
659
671
  | `S5` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (`unwrapMcpResult`) |
660
672
  | `S6` | `test/execute.test.ts` (fail-closed annotations, activity parity) |
661
673
  | `S7` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (parallel calls and shared admission) |
662
- | `S8`, `E1`, `X11` | both guest-contract executors (caught call, discovery, utility, budget, removed-function, and forgery cases; typed promise rejections) |
674
+ | `S8`, `E1`, `X11` | both guest-contract executors (caught call, discovery, utility, budget, removed-function, and forgery cases; typed promise rejections), `test/quickjs-executor.test.ts` (oversized messages, private transport, and forged frames) |
663
675
  | `S9` | `test/result-shapes.test.ts` (value exclusion, bounds, merging, LRU and time expiry, runtime isolation, read-only admission, declared precedence, definition invalidation, unwrapped MCP results, discovery provenance, copy isolation, and failure isolation) |
664
676
  | `E2`, `E8` | `test/guest-api-contract.test.ts` (code → `retryable`, caught, parallel, and uncaught validation recovery), `test/meta-tools.test.ts` (direct, destructive, provider fallback), `test/validate.test.ts` (bounded payload-free findings), `test/errors.test.ts` |
665
677
  | `E3` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (`auth_required`) |
@@ -677,9 +689,9 @@ the upstream `Executor` shape assignable.
677
689
  | `Y4` | `test/meta-tools-call.test.ts`, `test/call-admission.test.ts` (one attempt, retry hints, caller reissue) |
678
690
  | `L1`, `L2` | `test/guest-api-contract.test.ts` (in-flight call fails `cancelled`), `test/execute.test.ts` (cancels outstanding host calls) |
679
691
  | `L3`, `X1` | `test/guest-api-contract.test.ts` (short-deadline executors) |
680
- | `L4`, `L8` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (budgets) |
692
+ | `L4`, `L8` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (shared discovery/call budgets) |
681
693
  | `L5`, `X2` | `test/quickjs-executor.test.ts` (CPU, heap) |
682
- | `L6`, `X10` | `test/quickjs-executor.test.ts` (bridge and IPC bounds for arguments and result; the address in the over-bound message) |
694
+ | `L6`, `X10` | `test/quickjs-executor.test.ts` (bridge and IPC bounds for arguments and result; the address in the over-bound message), `test/quickjs-child-stderr.test.ts` (outer reply serialization failure settles the call) |
683
695
  | `L7` | `test/execute.test.ts`, `test/executor-admission.test.ts` |
684
696
  | `V1`–`V4` | `test/guest-api-contract.test.ts` (dispatched calls, every refusal class including an address no connector owns, the friction each derives, no event for the execution itself), `test/activity.test.ts` (the shared code → friction table, and the identity clamp) |
685
697
  | `M1` | `test/guest-api-contract.test.ts` (invalid emits throw catchably, accept nothing), `test/execute-emit.test.ts` (every rejected shape) |
@@ -690,7 +702,7 @@ the upstream `Executor` shape assignable.
690
702
  | `M8` | two arms passing one case table, `test/codemode-compat.test.ts` |
691
703
  | `M10` | `test/execute-emit.test.ts` (aggregate present, numbers only, absent when nothing emitted) |
692
704
  | `X3` | `test/quickjs-executor.test.ts` (cancels a running child) |
693
- | `X4` | `test/guest-api-contract.test.ts` (string logs only) |
705
+ | `X4` | `test/guest-api-contract.test.ts` (string logs only), `test/quickjs-executor.test.ts` (logs before cancellation), `test/quickjs-child-stderr.test.ts` (crash, shutdown, deadline, IPC failure, bounded parent retention), `test/quickjs-log-limits.test.ts` (unchanged successful logs) |
694
706
  | `X6` | `test/quickjs-executor.test.ts` (never-settling await) |
695
707
  | `X7` | `P3`'s tests; the Workers superset is deliberately unused |
696
708
 
@@ -188,6 +188,21 @@ downstream wrote them, and an unannotated or contradictory one stays
188
188
  fail-closed onto `call_destructive_tool`. The contract binds the surfaces we
189
189
  write, not the catalogs we relay.
190
190
 
191
+ An `unavailable` failure can carry `details.host` and `details.code` in its
192
+ classified error. `host` is an HTTP(S) origin, including a non-default port,
193
+ with no userinfo, path, query, or fragment and at most 253 UTF-8 bytes. `code`
194
+ is an allowlisted network errno, an undici transport code, or `timeout`, at
195
+ most 32 bytes. Invalid or oversized fields are omitted, never clipped.
196
+ `remoteMcp()` and the guarded transport know the failed fetch destination;
197
+ `api()` can classify a handler's structured runtime code but cannot recover
198
+ its destination from `ctx.baseUrl`, which names Connecta itself. A handler can
199
+ supply a known URL in a typed `ConnectorCallError` and the constructor reduces
200
+ it to an origin. AbortError and TimeoutError become diagnostic `timeout`;
201
+ workerd outbound denial supplies no errno, so a fetch boundary reports the
202
+ known origin alone. No provider prose supplies a code or retryability.
203
+ These diagnostics belong in tool failures and the bounded warning log, never
204
+ in payload-free activity records.
205
+
191
206
  ## The guarded fetch transport
192
207
 
193
208
  Every hand-written HTTP surface re-derives the same safety machinery, and two
@@ -237,9 +252,13 @@ What it owns is mechanical and provider-independent:
237
252
  as an absurd response is a fact about the API, not about HTTP. A declared
238
253
  `Content-Length` past the ceiling fails before a byte is read, and a
239
254
  streaming body is abandoned at the ceiling rather than buffered past it.
255
+ Without a body stream, text is measured in UTF-8 bytes before it is accepted;
256
+ JSON is parsed from that same bounded text. Such runtimes still buffer their
257
+ read internally, but cannot return an oversized body as a successful result.
240
258
  - **Normalization.** An unreachable provider becomes a retryable `unavailable`
241
259
  instead of whatever `TypeError` the runtime threw, and `ctx.signal` rides
242
- every request.
260
+ every request. `Retry-After` hints accept delta-seconds and HTTP dates;
261
+ a date in the past means no further wait.
243
262
 
244
263
  What it deliberately does not own is meaning. It never reads a status code and
245
264
  never invents an authentication scheme: the provider's `authenticate` callback
@@ -288,9 +307,13 @@ Connecta deliberately sits between protocol generations
288
307
  compatibility concession, so modern protocol support is still discovered.
289
308
  - **Legacy sessions:** Connecta's own endpoint creates no protocol session, but
290
309
  a stateful legacy downstream can still issue `Mcp-Session-Id`. Closing a
291
- request scope explicitly sends the legacy DELETE before closing its transport.
292
- SDK v2 `Client.close()` does not do that on Connecta's behalf, so
293
- `terminateSession` remains required and tested.
310
+ request scope, rotating credentials, retiring an OAuth generation, and
311
+ abandoning a connect all send the legacy DELETE before closing the transport.
312
+ The DELETE gets at most one second and failures are logged. Rotation and
313
+ abandoned connects start cleanup without waiting; the connector context has
314
+ no deferred-work hook. Scope teardown gives its bounded tail back to core,
315
+ which can attach the runtime's deferred channel. SDK v2 `Client.close()` does
316
+ not send DELETE on Connecta's behalf.
294
317
  - **Modern cache hints:** `tools/list` is deployment-fixed and returns a
295
318
  one-hour private cache hint. Downstream hints do not alter Connecta's existing
296
319
  five-minute fingerprinted catalog cache; that remains gated in
@@ -352,7 +375,11 @@ For remote MCP tools, that path checks the catalog's advertised `inputSchema`
352
375
  before provider dispatch. Supported mismatches become bounded, payload-free
353
376
  `invalid_args` findings; a schema the local validator cannot evaluate passes
354
377
  through unchanged. Connecta does not parse provider error prose to invent a
355
- validation classification.
378
+ validation classification. A downstream JSON-RPC `-32602` is an explicit
379
+ `invalid_args` refusal, with a bounded server message and no retry. HTTP 4xx
380
+ refusals preserve a bounded JSON `message`, including `error.message`, and are
381
+ non-retryable; 429 becomes `rate_limited` and 408 becomes `timeout`. The SDK
382
+ still owns OAuth challenges and step-up authorization.
356
383
 
357
384
  ## Authentication
358
385
 
@@ -363,6 +390,21 @@ server issuer discovered and validated by the SDK; see
363
390
  The callback route validates `state` before passing the complete callback query
364
391
  to the SDK so RFC 9207 `iss` validation is not lost.
365
392
 
393
+ Within one runtime, overlapping refreshes share one completion gate per
394
+ credential partition and generation. A valid token response is a consumed
395
+ refresh token, so the coordinator keeps that response's tokens on the flight.
396
+ If the owner then fails before the SDK saves them — cancelled, redirected to
397
+ authorization, or invalidated — the host persists the rotation itself, holds
398
+ contenders behind the mutation marker until that write lands, and hands them
399
+ the saved rotation; a retired token is never redeemed twice. A write already
400
+ running in `saveTokens` clears the marker on its own success or failure, and a
401
+ late duplicate save from a detached owner is harmless. Nothing can leave a
402
+ permanent 503 gate ([#526](https://github.com/zackbart/connecta/issues/526)).
403
+ Refresh token response validation
404
+ reads at most 65,536 bytes, and credential reads stop after 64 revision races
405
+ with `temporarily_unavailable` so churn cannot spin indefinitely. Request
406
+ cancellation is checked before each attempt.
407
+
366
408
  A remote MCP connector that authenticates with a static key has two ways to
367
409
  receive one. `{ type: "headers", headers }` bakes the literal value into the
368
410
  deployment file, which suits a secret the runtime already holds.
@@ -121,15 +121,25 @@ bytes. Within that unchanged total, each enum node and each constraint
121
121
  annotation may spend at most 256 UTF-8 bytes. Numeric bounds, string length
122
122
  bounds, patterns, and formats render beside their type. A constraint that does
123
123
  not fit is dropped whole. If constraints push the full shape over 1,024 bytes,
124
- search retries the shape without them. Compact describe keeps all declared
125
- constraints. A large enum keeps the longest whole-value prefix that fits, then
124
+ search retries the shape without them. Compact describe keeps declared
125
+ constraints and property prose within its own 8,192-byte shape cap, sharing
126
+ search's 2,000-visit rendering budget; a capped shape sets
127
+ `inputSchemaTruncated` or `outputSchemaTruncated`, and `format: "json"` or
128
+ JSON search returns the exact schema. A large enum keeps the longest whole-value prefix that fits, then
126
129
  adds `unknown` and a comment with the exact omitted-value count. An empty enum
127
130
  renders as the valid `never` type. A capped object becomes a valid
128
131
  required-first shape with `unknown` types; other shapes become
129
132
  `unknown /* truncated */`. Any cap marks the match with
130
133
  `inputSchemaTruncated` or `outputSchemaTruncated`; repeat the search with
131
134
  `includeSchemas: "json"` or use the existing describe path when exact
132
- constraints matter. Small enums and both exact paths remain complete.
135
+ constraints matter. `prefixItems` renders as a tuple, with the `items` type
136
+ as its rest, an `unknown[]` rest when open, and no rest for `items: false`.
137
+ `dependentSchemas` and `if`/`then`/`else` preserve the base shape and append
138
+ `/* conditional */`, setting the truncation flag so the caller reads the exact
139
+ JSON schema. `$dynamicRef` resolves a same-named definition like `$ref`; an
140
+ unresolved dynamic reference renders as `unknown` with the truncation flag.
141
+ These shapes share the same byte and work budgets. Small enums and both exact
142
+ paths remain complete.
133
143
 
134
144
  ## Connector guide selection
135
145
 
@@ -206,12 +216,42 @@ removing or summarizing the text copy is deferred until host-forwarding
206
216
  measurements demonstrate that supported clients do not need it.
207
217
 
208
218
  Plain-text guidance and errors remain text-only. A downstream MCP tool's native
209
- content blocks also pass through unchanged when `call_tool` uses MCP result
210
- mode; they are not a duplicated Connecta object result. Newly stashed JSON and
219
+ content blocks pass through when `call_tool` uses MCP result mode. When no
220
+ text block exists and `structuredContent` is present, Connecta appends a text
221
+ block containing its compact JSON, then applies the same content size guard.
222
+ This preserves structured-only results, including `null`, arrays, and scalars.
223
+ An existing text mirror stays unchanged; Connecta does not add another copy. Newly stashed JSON and
211
224
  downstream content envelopes use compact serialization, so `get_result` byte
212
225
  offsets and totals refer to that exact compact text.
213
226
 
214
- A `call_tool` truncation notice carries both the historical `resultId` and an
227
+ The direct-call stash keeps results for 15 minutes. `results.maxStashBytes`
228
+ defaults to 8 MiB and `results.maxStashEntries` to 64 per `createConnecta`
229
+ runtime, shared across all subjects and pools. Both accept non-negative safe
230
+ integers; zero disables stashing. The byte budget counts the stored ASCII
231
+ paging envelope, including base64 overhead, rather than only the result text.
232
+ Capacity is reserved before each storage write, so concurrent requests cannot
233
+ oversubscribe it. A full stash refuses new entries. A later stash attempt
234
+ deletes expired entries before reusing their capacity; a failed deletion keeps
235
+ the charge. These bounds cover writes by this runtime, not other processes,
236
+ Worker isolates, or entries left by a previous runtime.
237
+
238
+ Results belong to the authenticated subject whenever auth supplies a subject
239
+ or user id, independently of activity configuration. The provider's namespace
240
+ is used when present; otherwise the namespace is `connecta:auth:<provider kind>`.
241
+ Keep subject ids distinct within that namespace. An explicit principal is the
242
+ fallback subject when neither id is supplied. Open deployments and auth
243
+ providers that supply no identity share one partition.
244
+
245
+ New entries store UTF-8 bytes in a base64 envelope. After the KV read,
246
+ `get_result` decodes only the requested byte range and a few boundary bytes;
247
+ it does not encode the full text on every page. Storage still reads one full
248
+ value. Pre-upgrade raw-text entries remain readable during their TTL using
249
+ one full encoding per page. Offsets and `totalBytes` always describe the
250
+ original UTF-8 text, not the envelope. A supplied offset inside a character
251
+ moves back to its start; page ends also align to character boundaries, and a
252
+ page smaller than one character widens just enough to make progress.
253
+
254
+ A successfully stashed `call_tool` truncation notice carries both the historical `resultId` and an
215
255
  exact `nextAction: { tool: "get_result", arguments: { id, offset: 0 } }`. The
216
256
  handle is therefore
217
257
  directly actionable without copying an identifier out of prose. Program results
@@ -219,6 +259,28 @@ and oversized discovery responses carry no such route — paging a program's
219
259
  return value is a refused shape, because a program can shrink anything before
220
260
  it returns.
221
261
 
262
+ A refused stash or failed stash write cannot undo a downstream success. Both direct call tools
263
+ return a truncated preview where usable, with a paging-unavailable notice and
264
+ no `resultId` or paging action. Activity records success; the operator logger
265
+ receives a fixed warning with the connector and tool, without storage error
266
+ prose. For read-only work, reduce the result inside `execute_code`; repeating
267
+ an approved write is not a way to recover its output. Other result-processing
268
+ failures use a fixed `result_processing_failed` message and are never retryable.
269
+
270
+ Downstream MCP `isError` text is bounded at its source to 512 UTF-8 bytes plus
271
+ an `…` marker. Error framing may shorten it further to fit the call's result
272
+ cap, counting JSON escaping and both copies in value mode.
273
+
274
+ The top-level discovery ceiling is 256,000 UTF-8 bytes for the serialized tool
275
+ result, including text, `structuredContent`, and JSON escaping. Measuring only
276
+ one copy would advertise half the bytes the adapter actually returns.
277
+
278
+ A per-call `timeoutMs` covers catalog resolution, admission, and connector
279
+ execution with one deadline. The admission queue's own timeout may expire
280
+ sooner, but it cannot extend the call deadline. Result processing happens after
281
+ that deadline ends, because a completed downstream call must not turn into a
282
+ retryable timeout while Connecta prepares its response.
283
+
222
284
  ## Lexical discovery
223
285
 
224
286
  `search_tools` tokenizes tool names and descriptions at punctuation and
@@ -373,6 +435,12 @@ recovery query, each of which lands in both the text content and
373
435
  the address is the thing being corrected, a clipped one still identifies the
374
436
  mistake, and a short one — every real one — comes back exact and untagged.
375
437
 
438
+ Unknown `get_result.id`, `authorize_connector.connector`, and `skills.name`
439
+ echoes use the same 512-byte clamp. `search_tools.connector` instead rejects
440
+ values over 512 UTF-8 bytes with `invalid_args` before catalog lookup, so a
441
+ clipped scope can never select a different connector. A failed result-storage
442
+ read returns typed `unavailable` without exposing backend error text.
443
+
376
444
  `call_destructive_tool` accepts an optional
377
445
  `reason` of at most 500 characters for the host's human approval view. It is
378
446
  outer-call context only: Connecta neither treats it as authority nor passes it